Skip to content

Commit

Permalink
fix: ISR support for adapter-vercel (#9063)
Browse files Browse the repository at this point in the history
* fix ancestor calculation

* ISR

* Update packages/adapter-vercel/index.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

* fix

* dont expose group to users

* dont involve isr config in splitting decisions

* avoid shadowing

* handle case where there is only one function

* handle isr on root route

---------

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
  • Loading branch information
3 people committed Feb 17, 2023
1 parent dcada15 commit 181ba18
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 53 deletions.
5 changes: 5 additions & 0 deletions .changeset/pretty-kids-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-vercel': patch
---

fix: get ISR working on Vercel
8 changes: 2 additions & 6 deletions documentation/docs/25-build-and-deploy/90-adapter-vercel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<token> cookie.
//
// Making a `GET` or `HEAD` request with `x-prerender-revalidate: <token>` 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']
}
};
Expand Down
14 changes: 14 additions & 0 deletions packages/adapter-vercel/files/serverless.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,25 @@ await server.init({
env: /** @type {Record<string, string>} */ (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;

Expand Down
4 changes: 0 additions & 4 deletions packages/adapter-vercel/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<token> cookie.
Expand Down
120 changes: 77 additions & 43 deletions packages/adapter-vercel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
);
}
}

/**
Expand Down Expand Up @@ -158,6 +142,9 @@ const plugin = function (defaults = {}) {
/** @type {Map<string, string>} */
const functions = new Map();

/** @type {Map<import('@sveltejs/kit').RouteDefinition<import('.').Config>, { 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;
Expand All @@ -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
Expand All @@ -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<any>[]} */ (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);
}
}

Expand All @@ -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}` });
}
}

Expand All @@ -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('/');
}

Expand Down Expand Up @@ -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);
}
}

Expand Down

0 comments on commit 181ba18

Please sign in to comment.