Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(query): fully cacheable api #1752

Merged
merged 1 commit into from
Dec 15, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
31 changes: 21 additions & 10 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ export default defineNuxtModule<ModuleOptions>({

// Add server handlers
nitroConfig.handlers.push(
{
method: 'get',
route: `${options.api.baseURL}/query/:qid/**:params`,
handler: resolveRuntimeModule('./server/api/query')
},
{
method: 'get',
route: `${options.api.baseURL}/query/:qid`,
Expand Down Expand Up @@ -444,16 +449,22 @@ export default defineNuxtModule<ModuleOptions>({

nuxt.hook('nitro:config', (nitroConfig) => {
nitroConfig.handlers = nitroConfig.handlers || []
nitroConfig.handlers.push({
method: 'get',
route: `${options.api.baseURL}/navigation/:qid`,
handler: resolveRuntimeModule('./server/api/navigation')
})
nitroConfig.handlers.push({
method: 'get',
route: `${options.api.baseURL}/navigation`,
handler: resolveRuntimeModule('./server/api/navigation')
})
nitroConfig.handlers.push(
{
method: 'get',
route: `${options.api.baseURL}/navigation/:qid/**:params`,
handler: resolveRuntimeModule('./server/api/navigation')
}, {
method: 'get',
route: `${options.api.baseURL}/navigation/:qid`,
handler: resolveRuntimeModule('./server/api/navigation')
},
{
method: 'get',
route: `${options.api.baseURL}/navigation`,
handler: resolveRuntimeModule('./server/api/navigation')
}
)
})
} else {
addImports({ name: 'navigationDisabled', as: 'fetchContentNavigation', from: resolveRuntimeModule('./composables/utils') })
Expand Down
18 changes: 5 additions & 13 deletions src/runtime/composables/navigation.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { hash } from 'ohash'
import { useCookie, useRuntimeConfig } from '#app'
import { useRuntimeConfig } from '#app'
import type { NavItem, QueryBuilder, QueryBuilderParams } from '../types'
import { jsonStringify } from '../utils/json'
import { encodeQueryParams } from '../utils/query'
import { addPrerenderPath, shouldUseClientDB, withContentBase } from './utils'

export const fetchContentNavigation = async (queryBuilder?: QueryBuilder | QueryBuilderParams): Promise<Array<NavItem>> => {
const { content } = useRuntimeConfig().public

// When params is an instance of QueryBuilder then we need to pick the params explicitly
const params: QueryBuilderParams = typeof queryBuilder?.params === 'function' ? queryBuilder.params() : queryBuilder
const params: QueryBuilderParams = typeof queryBuilder?.params === 'function' ? queryBuilder.params() : queryBuilder || {}

// Filter by locale if:
// - locales are defined
Expand All @@ -21,8 +21,7 @@ export const fetchContentNavigation = async (queryBuilder?: QueryBuilder | Query
}
}

const _apiPath = params ? `/navigation/${hash(params)}` : '/navigation/'
const apiPath = withContentBase(process.dev ? _apiPath : `${_apiPath}.${content.integrity}.json`)
const apiPath = withContentBase(`/navigation/${process.dev ? '_' : `${hash(params)}.${content.integrity}`}/${encodeQueryParams(params)}.json`)

// Add `prefetch` to `<head>` in production
if (!process.dev && process.server) {
Expand All @@ -34,14 +33,7 @@ export const fetchContentNavigation = async (queryBuilder?: QueryBuilder | Query
return generateNavigation(params)
}

const data = await $fetch<NavItem[]>(apiPath, {
method: 'GET',
responseType: 'json',
params: {
_params: jsonStringify(params || {}),
previewToken: useCookie('previewToken').value
}
})
const data = await $fetch<NavItem[]>(apiPath, { method: 'GET', responseType: 'json' })

// On SSG, all url are redirected to `404.html` when not found, so we need to check the content type
// to know if the response is a valid JSON or not
Expand Down
15 changes: 4 additions & 11 deletions src/runtime/composables/query.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { joinURL, withLeadingSlash, withoutTrailingSlash } from 'ufo'
import { hash } from 'ohash'
import { useCookie, useRuntimeConfig } from '#app'
import { useRuntimeConfig } from '#app'
import { createQuery } from '../query/query'
import type { ParsedContent, QueryBuilder, QueryBuilderParams } from '../types'
import { jsonStringify } from '../utils/json'
import { encodeQueryParams } from '../utils/query'
import { addPrerenderPath, shouldUseClientDB, withContentBase } from './utils'

/**
Expand Down Expand Up @@ -37,7 +37,7 @@ export const createQueryFetch = <T = ParsedContent>(path?: string) => async (que

const params = query.params()

const apiPath = withContentBase(process.dev ? '/query' : `/query/${hash(params)}.${content.integrity}.json`)
const apiPath = withContentBase(`/query/${process.dev ? '_' : `${hash(params)}.${content.integrity}`}/${encodeQueryParams(params)}.json`)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important note: This is potentially a breaking change as Nitro v1 limits whole URL length to 250 and by this change, we cause to skip such routes when prerendering.

https://github.com/unjs/nitro/blob/cbcd0681d37839f93fc7f3f106d34e3f01d2ac84/src/prerender.ts#L83

This change could be behind an experimental flag until the release of Nuxt 3.1 and Nitro 2

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, this change causes slight performance overhead of long URLs which is mainly needed for hybrid/server mode. When _generate: true flag exists, we can safe assume deployment is full-static and omit last segment.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So what you are suggesting is that we should switch between these methods based on nuxt.options._generate boolean.
Pass this option to the runtimeconfig to delect whether its generated or not and change the API fetch.

Right?
@pi0

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I don't like to create complicated behavior for us to describe and for users to understand.
So I'll go with experimental flag and disable this feature by default.

Copy link
Member

@pi0 pi0 Dec 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should work yes. We also need to have it as an experimental opt-in flag like content.experimental.fullPath since as mentioned above, this feature, needs to be coupled with nitro v2 fix for long path segments support.


// Prefetch the query
if (!process.dev && process.server) {
Expand All @@ -49,14 +49,7 @@ export const createQueryFetch = <T = ParsedContent>(path?: string) => async (que
return db.fetch(query as QueryBuilder<ParsedContent>)
}

const data = await $fetch(apiPath as any, {
method: 'GET',
responseType: 'json',
params: {
_params: jsonStringify(params),
previewToken: useCookie('previewToken').value
}
})
const data = await $fetch(apiPath as any, { method: 'GET', responseType: 'json' })

// On SSG, all url are redirected to `404.html` when not found, so we need to check the content type
// to know if the response is a valid JSON or not
Expand Down
2 changes: 1 addition & 1 deletion src/runtime/utils/json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function regExpReplacer (_key: string, value: any) {
/**
* A function that transforms RegExp string representation back to RegExp objects.
*/
function regExpReviver (_key, value) {
function regExpReviver (_key: string, value: any) {
const withOperator = (typeof value === 'string' && value.match(/^--([A-Z]+) (.+)$/)) || []

if (withOperator[1] === 'REGEX') {
Expand Down
35 changes: 31 additions & 4 deletions src/runtime/utils/query.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,50 @@
import { getQuery, H3Event, createError } from 'h3'
import { QueryBuilderParams, QueryBuilderWhere } from '../types'
import { jsonParse } from './json'
import { jsonParse, jsonStringify } from './json'

const parseQueryParams = (body: string) => {
const parseJSONQueryParams = (body: string) => {
try {
return jsonParse(body)
} catch (e) {
throw createError({ statusCode: 400, message: 'Invalid _params query' })
}
}

export const encodeQueryParams = (params: QueryBuilderParams) => {
let encoded = jsonStringify(params)
encoded = typeof Buffer !== 'undefined' ? Buffer.from(encoded).toString('base64') : btoa(encoded)

encoded = encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')

// split to chunks of 100 chars
const chunks = encoded.match(/.{1,100}/g) || []
return chunks.join('/')
}

export const decodeQuryParams = (encoded: string) => {
// remove chunks
encoded = encoded.replace(/\//g, '')

// revert base64
encoded = encoded.replace(/-/g, '+').replace(/_/g, '/')
encoded = encoded.padEnd(encoded.length + (4 - (encoded.length % 4)) % 4, '=')

return parseJSONQueryParams(typeof Buffer !== 'undefined' ? Buffer.from(encoded, 'base64').toString() : atob(encoded))
}

const memory: Record<string, QueryBuilderParams> = {}
export const getContentQuery = (event: H3Event): QueryBuilderParams => {
const { params } = event.context.params || {}
if (params) {
return decodeQuryParams(params.replace(/.json$/, ''))
}

const qid = event.context.params.qid?.replace(/.json$/, '')
const query: any = getQuery(event) || {}

// Using /api/_content/query/:qid?_params=....
if (qid && query._params) {
memory[qid] = parseQueryParams(query._params)
memory[qid] = parseJSONQueryParams(query._params)

if (memory[qid].where && !Array.isArray(memory[qid].where)) {
memory[qid].where = [memory[qid].where as any as QueryBuilderWhere]
Expand All @@ -31,7 +58,7 @@ export const getContentQuery = (event: H3Event): QueryBuilderParams => {

// Using /api/_content/query?_params={{JSON_FORMAT}}
if (query._params) {
return parseQueryParams(query._params)
return parseJSONQueryParams(query._params)
}

// Using /api/_content/query?path=...&only=...
Expand Down