diff --git a/.changeset/pretty-kids-camp.md b/.changeset/pretty-kids-camp.md new file mode 100644 index 000000000000..1d8a0f191623 --- /dev/null +++ b/.changeset/pretty-kids-camp.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': patch +--- + +fix: get ISR working on Vercel diff --git a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md index 6b206385db40..9f81325e0e2c 100644 --- a/documentation/docs/25-build-and-deploy/90-adapter-vercel.md +++ b/documentation/docs/25-build-and-deploy/90-adapter-vercel.md @@ -88,18 +88,14 @@ export const config = { // Setting the value to `false` means it will never expire. expiration: 60, - // Option group number of the asset. Assets with the same group number will all be re-validated at the same time. - group: 1, - // Random token that can be provided in the URL to bypass the cached version of the asset, by requesting the asset // with a __prerender_bypass= cookie. // // Making a `GET` or `HEAD` request with `x-prerender-revalidate: ` will force the asset to be re-validated. bypassToken: BYPASS_TOKEN, - // List of query string parameter names that will be cached independently. - // If an empty array, query values are not considered for caching. - // If `undefined` each unique query value is cached independently + // List of valid query parameters. Other parameters (such as utm tracking codes) will be ignored, + // ensuring that they do not result in content being regenerated unnecessarily allowQuery: ['search'] } }; diff --git a/packages/adapter-vercel/files/serverless.js b/packages/adapter-vercel/files/serverless.js index a9432521ed41..e2f506c22859 100644 --- a/packages/adapter-vercel/files/serverless.js +++ b/packages/adapter-vercel/files/serverless.js @@ -11,11 +11,25 @@ await server.init({ env: /** @type {Record} */ (process.env) }); +const DATA_SUFFIX = '/__data.json'; + /** * @param {import('http').IncomingMessage} req * @param {import('http').ServerResponse} res */ export default async (req, res) => { + if (req.url) { + const [path, search] = req.url.split('?'); + + const params = new URLSearchParams(search); + const pathname = params.get('__pathname'); + + if (pathname) { + params.delete('__pathname'); + req.url = `${pathname}${path.endsWith(DATA_SUFFIX) ? DATA_SUFFIX : ''}?${params}`; + } + } + /** @type {Request} */ let request; diff --git a/packages/adapter-vercel/index.d.ts b/packages/adapter-vercel/index.d.ts index cdfa2d324a49..6f95260b4349 100644 --- a/packages/adapter-vercel/index.d.ts +++ b/packages/adapter-vercel/index.d.ts @@ -36,10 +36,6 @@ export interface ServerlessConfig { * Expiration time (in seconds) before the cached asset will be re-generated by invoking the Serverless Function. Setting the value to `false` means it will never expire. */ expiration: number | false; - /** - * Option group number of the asset. Assets with the same group number will all be re-validated at the same time. - */ - group?: number; /** * Random token that can be provided in the URL to bypass the cached version of the asset, by requesting the asset * with a __prerender_bypass= cookie. diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 16080fb8dcca..47d5d648ed1a 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -76,22 +76,6 @@ const plugin = function (defaults = {}) { `${dirs.functions}/${name}.func`, config ); - - if (config.isr) { - write( - `${dirs.functions}/${name}.prerender-config.json`, - JSON.stringify( - { - expiration: config.isr.expiration, - group: config.isr.group, - bypassToken: config.isr.bypassToken, - allowQuery: config.isr.allowQuery - }, - null, - '\t' - ) - ); - } } /** @@ -158,6 +142,9 @@ const plugin = function (defaults = {}) { /** @type {Map} */ const functions = new Map(); + /** @type {Map, { expiration: number | false, bypassToken: string | undefined, allowQuery: string[], group: number, passQuery: true }>} */ + const isr_config = new Map(); + // group routes by config for (const route of builder.routes) { if (route.prerender === true) continue; @@ -175,6 +162,20 @@ const plugin = function (defaults = {}) { const config = { runtime, ...defaults, ...route.config }; + if (config.isr) { + if (config.isr.allowQuery?.includes('__pathname')) { + throw new Error('__pathname is a reserved query parameter for isr.allowQuery'); + } + + isr_config.set(route, { + expiration: config.isr.expiration, + bypassToken: config.isr.bypassToken, + allowQuery: ['__pathname', ...(config.isr.allowQuery ?? [])], + group: isr_config.size + 1, + passQuery: true + }); + } + const hash = hash_config(config); // first, check there are no routes with incompatible configs that will be merged @@ -200,25 +201,28 @@ const plugin = function (defaults = {}) { group.routes.push(route); } + const singular = groups.size === 1; + for (const group of groups.values()) { const generate_function = group.config.runtime === 'edge' ? generate_edge_function : generate_serverless_function; // generate one function for the group - const name = `fn-${group.i}`; + const name = singular ? 'fn' : `fn-${group.i}`; + await generate_function( name, /** @type {any} */ (group.config), /** @type {import('@sveltejs/kit').RouteDefinition[]} */ (group.routes) ); - if (groups.size === 1) { + if (singular) { // Special case: One function for all routes static_config.routes.push({ src: '/.*', dest: `/${name}` }); - } else { - for (const route of group.routes) { - functions.set(route.pattern.toString(), name); - } + } + + for (const route of group.routes) { + functions.set(route.pattern.toString(), name); } } @@ -238,12 +242,47 @@ const plugin = function (defaults = {}) { src = '^/?'; } - src += '(?:/__data.json)?$'; + const name = functions.get(pattern) ?? 'fn-0'; + + const isr = isr_config.get(route); + if (isr) { + const isr_name = route.id.slice(1) || '__root__'; // should we check that __root__ isn't a route? + const base = `${dirs.functions}/${isr_name}`; + builder.mkdirp(base); + + const target = `${dirs.functions}/${name}.func`; + const relative = path.relative(path.dirname(base), target); + + // create a symlink to the actual function, but use the + // route name so that we can derive the correct URL + fs.symlinkSync(relative, `${base}.func`); + fs.symlinkSync(`../${relative}`, `${base}/__data.json.func`); + + let i = 1; + const pathname = route.segments + .map((segment) => { + return segment.dynamic ? `$${i++}` : segment.content; + }) + .join('/'); + + const json = JSON.stringify(isr, null, '\t'); + + write(`${base}.prerender-config.json`, json); + write(`${base}/__data.json.prerender-config.json`, json); + + const q = `?__pathname=/${pathname}`; - const name = functions.get(pattern); - if (name) { - static_config.routes.push({ src, dest: `/${name}` }); - functions.delete(pattern); + static_config.routes.push({ + src: src + '$', + dest: `${isr_name}${q}` + }); + + static_config.routes.push({ + src: src + '/__data.json$', + dest: `${isr_name}/__data.json${q}` + }); + } else if (!singular) { + static_config.routes.push({ src: src + '(?:/__data.json)?$', dest: `/${name}` }); } } @@ -266,11 +305,7 @@ function hash_config(config) { config.external ?? '', config.regions ?? '', config.memory ?? '', - config.maxDuration ?? '', - config.isr?.expiration ?? '', - config.isr?.group ?? '', - config.isr?.bypassToken ?? '', - config.isr?.allowQuery ?? '' + config.maxDuration ?? '' ].join('/'); } @@ -400,22 +435,21 @@ async function create_function_bundle(builder, entry, dir, config) { } } + const files = Array.from(traced.fileList); + // find common ancestor directory /** @type {string[]} */ - let common_parts = []; + let common_parts = files[0]?.split(path.sep) ?? []; - for (const file of traced.fileList) { - if (common_parts) { - const parts = file.split(path.sep); + for (let i = 1; i < files.length; i += 1) { + const file = files[i]; + const parts = file.split(path.sep); - for (let i = 0; i < common_parts.length; i += 1) { - if (parts[i] !== common_parts[i]) { - common_parts = common_parts.slice(0, i); - break; - } + for (let j = 0; j < common_parts.length; j += 1) { + if (parts[j] !== common_parts[j]) { + common_parts = common_parts.slice(0, j); + break; } - } else { - common_parts = path.dirname(file).split(path.sep); } }