Skip to content

feat: service worker rendering #13791

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

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/ten-pens-develop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add service worker rendering
1 change: 1 addition & 0 deletions packages/kit/src/core/config/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function process_config(config, { cwd = process.cwd() } = {}) {
if (key === 'hooks') {
validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client);
validated.kit.files.hooks.server = path.resolve(cwd, validated.kit.files.hooks.server);
validated.kit.files.hooks.serviceWorker = path.resolve(cwd, validated.kit.files.hooks.serviceWorker);
validated.kit.files.hooks.universal = path.resolve(cwd, validated.kit.files.hooks.universal);
} else {
// @ts-expect-error
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const options = object(
hooks: object({
client: string(join('src', 'hooks.client')),
server: string(join('src', 'hooks.server')),
serviceWorker: string(join('src', 'hooks.worker')),
universal: string(join('src', 'hooks'))
}),
lib: string(join('src', 'lib')),
Expand Down
139 changes: 139 additions & 0 deletions packages/kit/src/core/generate_manifest/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,142 @@ export function generate_manifest({ build_data, prerendered, relative_path, rout
})()
`;
}

/**
* Generates the data used to write the service worker manifest.js file. This data is used in the Vite
* build process, to power routing, etc.
* @param {{
* build_data: import('types').BuildData;
* prerendered: string[];
* relative_path: string;
* routes: import('types').RouteData[];
* }} opts
*/
export function generate_service_worker_manifest({
build_data,
prerendered,
relative_path,
routes
}) {
/**
* @type {Map<any, number>} The new index of each node in the filtered nodes array
*/
const reindexed = new Map();
/**
* All nodes actually used in the routes definition (prerendered routes are omitted).
* Root layout/error is always included as they are needed for 404 and root errors.
* @type {Set<any>}
*/
const used_nodes = new Set([0, 1]);

const server_assets = find_server_assets(build_data, routes);

for (const route of routes) {
if (route.page) {
for (const i of route.page.layouts) used_nodes.add(i);
for (const i of route.page.errors) used_nodes.add(i);
used_nodes.add(route.page.leaf);
}
}

const node_paths = compact(
build_data.manifest_data.nodes.map((_, i) => {
if (used_nodes.has(i)) {
reindexed.set(i, reindexed.size);
return join_relative(relative_path, `/nodes/${i}.js`);
}
})
);

/** @type {(path: string) => string} */
const loader = (path) => `__memo(() => import('${path}'))`;

const assets = build_data.manifest_data.assets.map((asset) => asset.file);
if (build_data.service_worker) {
assets.push(build_data.service_worker);
}

// In case of server side route resolution, we need to include all matchers. Prerendered routes are not part
// of the server manifest, and they could reference matchers that then would not be included.
const matchers = new Set(
build_data.client?.nodes ? Object.keys(build_data.manifest_data.matchers) : undefined
);

/** @param {Array<number | undefined>} indexes */
function get_nodes(indexes) {
const string = indexes.map((n) => reindexed.get(n) ?? '').join(',');

// since JavaScript ignores trailing commas, we need to insert a dummy
// comma so that the array has the correct length if the last item
// is undefined
return `[${string},]`;
}

const mime_types = get_mime_lookup(build_data.manifest_data);

/** @type {Record<string, number>} */
const files = {};
for (const file of server_assets) {
files[file] = fs.statSync(path.resolve(build_data.out_dir, 'server', file)).size;

const ext = path.extname(file);
mime_types[ext] ??= mime.lookup(ext) || '';
}

// prettier-ignore
// String representation of
/** @template {import('types').SWRManifest} T */
const manifest_expr = dedent`
{
appDir: ${s(build_data.app_dir)},
appPath: ${s(build_data.app_path)},
assets: new Set(${s(assets)}),
mimeTypes: ${s(mime_types)},
client: ${uneval(build_data.client)},
nodes: [
${(node_paths).map(loader).join(',\n')}
],
routes: [
${routes.map(route => {
if (!route.page && !route.endpoint) return;

route.params.forEach(param => {
if (param.matcher) matchers.add(param.matcher);
});

return dedent`
{
id: ${s(route.id)},
pattern: ${route.pattern},
params: ${s(route.params)},
page: ${route.page ? `{ layouts: ${get_nodes(route.page.layouts)}, errors: ${get_nodes(route.page.errors)}, leaf: ${reindexed.get(route.page.leaf)} }` : 'null'},
endpoint: ${route.endpoint ? 'true' : 'null'}
}
`;
}).filter(Boolean).join(',\n')}
],
prerendered_routes: new Set(${s(prerendered)}),
matchers: async () => {
${Array.from(
matchers,
type => `const { match: ${type} } = await import ('${(join_relative(relative_path, `/entries/matchers/${type}.js`))}')`
).join('\n')}
return { ${Array.from(matchers).join(', ')} };
},
server_assets: ${s(files)}
}
`;

// Memoize the loaders to prevent Node from doing unnecessary work
// on every dynamic import call
return dedent`
(() => {
function __memo(fn) {
let value;
return () => value ??= (value = fn());
}

return ${manifest_expr}
})()
`;
}
2 changes: 2 additions & 0 deletions packages/kit/src/core/sync/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { write_types, write_all_types } from './write_types/index.js';
import { write_ambient } from './write_ambient.js';
import { write_non_ambient } from './write_non_ambient.js';
import { write_server } from './write_server.js';
import { write_service_worker } from './write_service_worker.js';

/**
* Initialize SvelteKit's generated files.
Expand All @@ -30,6 +31,7 @@ export function create(config) {

write_client_manifest(config.kit, manifest_data, `${output}/client`);
write_server(config, output);
write_service_worker(config, output);
write_root(manifest_data, output);
write_all_types(config, manifest_data);

Expand Down
127 changes: 127 additions & 0 deletions packages/kit/src/core/sync/write_service_worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import path from 'node:path';
import process from 'node:process';
import { hash } from '../../runtime/hash.js';
import { posixify, resolve_entry } from '../../utils/filesystem.js';
import { s } from '../../utils/misc.js';
import { load_error_page, load_template } from '../config/index.js';
import { runtime_directory } from '../utils.js';
import { isSvelte5Plus, write_if_changed } from './utils.js';
import colors from 'kleur';

/**
* @param {{
* worker_hooks: string | null;
* universal_hooks: string | null;
* config: import('types').ValidatedConfig;
* runtime_directory: string;
* template: string;
* error_page: string;
* }} opts
*/
const service_worker_template = ({
config,
worker_hooks,
universal_hooks,
runtime_directory,
template,
error_page
}) => `
import root from '../root.${isSvelte5Plus() ? 'js' : 'svelte'}';
import { set_building, set_prerendering } from '__sveltekit/environment';
import { set_assets } from '__sveltekit/paths';
import { set_manifest, set_read_implementation } from '__sveltekit/service-worker';
import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js';

export const options = {
app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')},
csp: ${s(config.kit.csp)},
csrf_check_origin: ${s(config.kit.csrf.checkOrigin)},
embedded: ${config.kit.embedded},
env_public_prefix: '${config.kit.env.publicPrefix}',
env_private_prefix: '${config.kit.env.privatePrefix}',
hash_routing: ${s(config.kit.router.type === 'hash')},
hooks: null, // added lazily, via \`get_hooks\`
preload_strategy: ${s(config.kit.output.preloadStrategy)},
root,
templates: {
app: ({ head, body, assets, nonce, env }) => ${s(template)
.replace('%sveltekit.head%', '" + head + "')
.replace('%sveltekit.body%', '" + body + "')
.replace(/%sveltekit\.assets%/g, '" + assets + "')
.replace(/%sveltekit\.nonce%/g, '" + nonce + "')
.replace(
/%sveltekit\.env\.([^%]+)%/g,
(_match, capture) => `" + (env[${s(capture)}] ?? "") + "`
)},
error: ({ status, message }) => ${s(error_page)
.replace(/%sveltekit\.status%/g, '" + status + "')
.replace(/%sveltekit\.error\.message%/g, '" + message + "')}
},
version_hash: ${s(hash(config.kit.version.name))}
};

export async function get_hooks() {
let handle;
let handleFetch;
let handleError;
${worker_hooks ? `({ handle, handleFetch, handleError } = await import(${s(worker_hooks)}));` : ''}

let reroute;
let transport;
${universal_hooks ? `({ reroute, transport } = await import(${s(universal_hooks)}));` : ''}

return {
handle,
handleFetch,
handleError,
reroute,
transport
};
}

export { set_assets, set_building, set_manifest, set_prerendering, set_private_env, set_public_env, set_read_implementation, set_safe_public_env };
`;

// TODO need to re-run this whenever src/app.html or src/error.html are
// created or changed. Also, need to check that updating hooks.worker.js works

/**
* Write service worker configuration to disk
* @param {import('types').ValidatedConfig} config
* @param {string} output
*/
export function write_service_worker(config, output) {
const service_worker_hooks_file = resolve_entry(config.kit.files.hooks.serviceWorker);
const universal_hooks_file = resolve_entry(config.kit.files.hooks.universal);

const typo = resolve_entry('src/+hooks.worker');
if (typo) {
console.log(
colors
.bold()
.yellow(
`Unexpected + prefix. Did you mean ${typo.split('/').at(-1)?.slice(1)}?` +
` at ${path.resolve(typo)}`
)
);
}

/** @param {string} file */
function relative(file) {
return posixify(path.relative(`${output}/service-worker`, file));
}

// Contains the stringified version of
/** @type {import('types').SSROptions} */
write_if_changed(
`${output}/service-worker/internal.js`,
service_worker_template({
config,
worker_hooks: service_worker_hooks_file ? relative(service_worker_hooks_file) : null,
universal_hooks: universal_hooks_file ? relative(universal_hooks_file) : null,
runtime_directory: relative(runtime_directory),
template: load_template(process.cwd(), config),
error_page: load_error_page(config)
})
);
}
14 changes: 13 additions & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
RequestOptions,
RouteSegment
} from '../types/private.js';
import { BuildData, SSRNodeLoader, SSRRoute, ValidatedConfig } from 'types';
import { BuildData, SSRNodeLoader, SSRRoute, SWRequestEvent, ValidatedConfig } from 'types';
import type { PluginOptions } from '@sveltejs/vite-plugin-svelte';

export { PrerenderOption } from '../types/private.js';
Expand Down Expand Up @@ -424,6 +424,11 @@ export interface KitConfig {
* @default "src/hooks.client"
*/
client?: string;
/**
* The location of the your service worker [hooks](https://svelte.dev/docs/kit/hooks).
* @default "src/hooks.worker"
*/
serviceWorker?: string;
/**
* The location of your server [hooks](https://svelte.dev/docs/kit/hooks).
* @default "src/hooks.server"
Expand Down Expand Up @@ -776,6 +781,13 @@ export type HandleServerError = (input: {
message: string;
}) => MaybePromise<void | App.Error>;

export type HandleServiceWorkerError = (input: {
error: unknown;
event: SWRequestEvent;
status: number;
message: string;
}) => MaybePromise<void | App.Error>;

/**
* The client-side [`handleError`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleError) hook runs when an unexpected error is thrown while navigating.
*
Expand Down
Loading
Loading