@sveltejs/kit
Version:
SvelteKit is the fastest way to build Svelte apps
761 lines (623 loc) • 20.8 kB
JavaScript
import process from 'node:process';
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { walk } from '../../utils/filesystem.js';
import { noop } from '../../utils/functions.js';
import { decode_uri, is_root_relative, resolve } from '../../utils/url.js';
import { escape_html } from '../../utils/escape.js';
import { logger } from '../utils.js';
import { extract_svelte_config, load_vite_config } from '../config/index.js';
import { get_route_segments } from '../../utils/routing.js';
import { queue } from './queue.js';
import { crawl } from './crawl.js';
import { forked } from '../../utils/fork.js';
import * as devalue from 'devalue';
import { createReadableStream } from '@sveltejs/kit/node';
import generate_fallback from './fallback.js';
import { stringify_remote_arg } from '../../runtime/shared.js';
import { matches_content_type } from '../../utils/http.js';
import { fix_stack_trace } from '../../runtime/server/sourcemaps.js';
import * as e from '../../messages/build-errors.js';
import * as w from '../../messages/build-warnings.js';
import { capture_message } from '../../messages/internal/build.js';
import { bullet_list } from '../../utils/format.js';
export default forked(import.meta.url, prerender);
// https://html.spec.whatwg.org/multipage/browsing-the-web.html#scrolling-to-a-fragment
// "If fragment is the empty string, then return the special value top of the document."
// ...and
// "If decodedFragment is an ASCII case-insensitive match for the string 'top', then return the top of the document."
const SPECIAL_HASHLINKS = new Set(['', 'top']);
/**
* @param {{
* hash: boolean;
* out: string;
* manifest_path: string;
* metadata: import('types').ServerMetadata;
* verbose: boolean;
* env: Record<string, string>;
* vite_config_file: string | undefined;
* is_tty: boolean | undefined;
* }} opts
*/
async function prerender({
hash,
out,
manifest_path,
metadata,
verbose,
env,
vite_config_file,
is_tty
}) {
/** @type {import('types').SSRManifest} */
const manifest = (await import(pathToFileURL(manifest_path).href)).manifest;
/** @type {import('types').ServerModule} */
const { configure, format_response } = await import(pathToFileURL(`${out}/server/index.js`).href);
const { init, respond } = await configure({
building: true,
prerendering: true,
env,
manifest,
read: (file) => createReadableStream(`${out}/server/${file}`)
});
const throw_handled = () => {
throw new Error('__handled__');
};
/**
* @template {{message: string}} T
* @template {Omit<T, 'message'>} K
* @param {string} name
* @param {'fail' | 'warn' | 'ignore' | undefined | ((details: T) => void)} input
* @param {(details: K) => string} format
* @returns {(details: K & { error?: unknown }) => void}
*/
function normalise_error_handler(name, input, format) {
/**
* @param {any} details
*/
function log_failure(details) {
const message = format(details);
log.error(`\n${message}\n`);
}
switch (input) {
case 'fail':
return (details) => {
log_failure(details);
throw_handled();
};
case 'warn':
return log_failure;
case 'ignore':
return noop;
case undefined: {
return (details) => {
log_failure(details);
log.err(
`To suppress or handle this error, implement \`${name}\` in https://svelte.dev/docs/kit/configuration#prerender\n`
);
throw_handled();
};
}
default:
return (details) => {
const message = format(details);
try {
// @ts-expect-error TS thinks T might be of a different kind, but it's not
input({ ...details, message });
} catch (error) {
log.prettyError(error, import.meta.dirname);
throw_handled();
}
};
}
}
const OK = 2;
const REDIRECT = 3;
/** @type {import('types').Prerendered} */
const prerendered = {
pages: new Map(),
assets: new Map(),
redirects: new Map(),
paths: []
};
/** @type {import('types').PrerenderMap} */
const prerender_map = new Map();
for (const [id, { prerender }] of metadata.routes) {
if (prerender !== undefined) {
prerender_map.set(id, prerender);
}
}
/** @type {Set<string>} */
const prerendered_routes = new Set();
const vite_config = await load_vite_config(vite_config_file);
const config = extract_svelte_config(vite_config);
const prerender_origin = config.paths.origin || 'http://sveltekit-prerender';
if (hash) {
const fallback = await generate_fallback({
manifest_path,
env,
out_dir: config.outDir,
origin: prerender_origin,
assets: config.files.assets
});
const file = output_filename('/', true);
const dest = `${config.outDir}/output/prerendered/pages/${file}`;
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(dest, fallback);
prerendered.pages.set('/', { file });
return { prerendered, prerender_map };
}
const emulator = await config.adapter?.emulate?.();
/** @type {import('types').Logger} */
const log = logger({ verbose });
/** @type {Map<string, string>} */
const saved = new Map();
const handle_http_error = normalise_error_handler(
'handleHttpError',
config.prerender.handleHttpError,
({ status, path, referrer, referenceType }) => {
if (status === 404) {
if (!path.startsWith(config.paths.base)) {
return capture_message(() =>
e.prerender_path_outside_base(
referrer ? { path, reference_type: referenceType, referrer } : { path }
)
);
}
if (referrer) {
return capture_message(() =>
e.prerender_http_error({ path, reference_type: referenceType, referrer })
);
}
}
return capture_message(() => e.prerender_http_error({ path }));
}
);
const handle_missing_id = normalise_error_handler(
'handleMissingId',
config.prerender.handleMissingId,
({ path, id, referrers }) => {
return capture_message(() =>
e.prerender_missing_id({
path,
id,
referrers: bullet_list(referrers)
})
);
}
);
const handle_entry_generator_mismatch = normalise_error_handler(
'handleEntryGeneratorMismatch',
config.prerender.handleEntryGeneratorMismatch,
({ generatedFromId, entry, matchedId }) => {
return capture_message(() =>
e.prerender_entry_generator_mismatch({
id: generatedFromId,
entry,
matched: matchedId === entry ? 'a static route' : matchedId
})
);
}
);
const handle_not_prerendered_route = normalise_error_handler(
'handleUnseenRoutes',
config.prerender.handleUnseenRoutes,
({ routes }) => {
return capture_message(() => e.prerender_unseen_routes({ routes: bullet_list(routes) }));
}
);
const handle_invalid_url = normalise_error_handler(
'handleInvalidUrl',
config.prerender.handleInvalidUrl,
({ href, referrer }) => {
return capture_message(() =>
e.prerender_invalid_url(referrer ? { href, referrer } : { href })
);
}
);
const q = queue(config.prerender.concurrency);
/**
* @param {string} path
* @param {boolean} is_html
*/
function output_filename(path, is_html) {
const file = path.slice(config.paths.base.length + 1) || 'index.html';
if (is_html && !file.endsWith('.html')) {
return file + (file.endsWith('/') ? 'index.html' : '.html');
}
return file;
}
const files = new Set(walk(`${out}/client`));
files.add(`${config.appDir}/env.js`);
const immutable = `${config.appDir}/immutable`;
if (existsSync(`${out}/server/${immutable}`)) {
for (const file of walk(`${out}/server/${immutable}`)) {
files.add(`${config.appDir}/immutable/${file}`);
}
}
const remote_prefix = `${config.paths.base}/${config.appDir}/remote/`;
const seen = new Set();
const written = new Set();
/** @type {Map<string, Promise<any>>} */
const remote_responses = new Map();
/** @type {null | { clear: () => void; update: (path: string) => void; updated: number }} */
let progress = null;
if (is_tty) {
// Where possible, provide progress feedback by showing the path we're
// currently requesting, then clearing the line once the response comes in.
// This avoids the wall of text that happens when you prerender
// many pages and log each response
const { stdout, stderr } = process;
let current = false;
let needs_newline = true;
const write = stdout.write;
/** @param {string} value */
const print = (value) => write.call(stdout, value);
/** @type {ProxyHandler<typeof stdout.write>} */
const intercept = {
apply(target, this_arg, args) {
const chunk = args[0];
if (chunk.length > 0) {
current = false;
needs_newline =
typeof chunk === 'string' ? !chunk.endsWith('\n') : chunk[chunk.length - 1] !== 10;
}
return Reflect.apply(target, this_arg, args);
}
};
stdout.write = new Proxy(stdout.write, intercept);
stderr.write = new Proxy(stderr.write, intercept);
progress = {
clear: () => {
// If app code writes to stdout or stderr, don't move the cursor to clear
// the previous progress log, because that will corrupt things
if (!current) return;
print('\x1B[1A'); // move cursor to start of progress update
print('\x1B[2K'); // clear current line
},
update: (path) => {
// if we're in the middle of a line, start a new one
if (needs_newline) print('\n');
print(`crawling ${path}\n`);
current = true;
needs_newline = false;
},
updated: 0
};
}
/** @type {Set<string>} */
const resolved_route_ids = new Set();
/** @type {Map<string, Set<string>>} */
const expected_hashlinks = new Map();
/** @type {Map<string, Set<string>>} */
const actual_hashlinks = new Map();
/**
* @param {string | null} referrer
* @param {string} decoded
* @param {string} [encoded]
* @param {string} [generated_from_id]
*/
function enqueue(referrer, decoded, encoded, generated_from_id) {
if (seen.has(decoded)) return;
seen.add(decoded);
const file = decoded.slice(config.paths.base.length + 1);
if (files.has(file)) return;
return q.add(() => visit(decoded, encoded || encodeURI(decoded), referrer, generated_from_id));
}
/**
* @param {string} decoded
* @param {string} encoded
* @param {string?} referrer
* @param {string} [generated_from_id]
*/
async function visit(decoded, encoded, referrer, generated_from_id) {
if (!decoded.startsWith(config.paths.base)) {
handle_http_error({ status: 404, path: decoded, referrer, referenceType: 'linked' });
return;
}
/** @type {Map<string, import('types').PrerenderDependency>} */
const dependencies = new Map();
if (progress) {
progress.clear();
progress.update(decoded);
if (Date.now() - progress.updated > 50) {
progress.updated = Date.now();
// without this, the update will rarely be visible, and progress will appear stuck
await new Promise((f) => setTimeout(f, 0));
}
}
const request = new Request(prerender_origin + encoded);
const response = await respond(request, {
getClientAddress() {
e.prerender_client_address();
},
prerendering: {
dependencies,
remote_responses,
resolved_route_ids
},
read: (file) => {
// stuff we just wrote
const filepath = saved.get(file);
if (filepath) return readFileSync(filepath);
// Static assets emitted during build
if (file.startsWith(config.appDir)) {
return readFileSync(`${out}/server/${file}`);
}
// stuff in `static`
return readFileSync(join(config.files.assets, file));
},
emulator
});
const encoded_id = response.headers.get('x-sveltekit-routeid');
const decoded_id = encoded_id && decode_uri(encoded_id);
if (
decoded_id !== null &&
generated_from_id !== undefined &&
decoded_id !== generated_from_id
) {
handle_entry_generator_mismatch({
generatedFromId: generated_from_id,
entry: decoded,
matchedId: decoded_id
});
}
if (response.status >= 400) {
console.log(format_response(response.status, request));
}
const body = Buffer.from(await response.arrayBuffer());
const category = decoded.startsWith(remote_prefix) ? 'data' : 'pages';
save(category, response, body, decoded, encoded, referrer, 'linked');
for (const [dependency_path, result] of dependencies) {
// this seems circuitous, but using new URL allows us to not care
// whether dependency_path is encoded or not
const encoded_dependency_path = new URL(dependency_path, 'http://localhost').pathname;
const decoded_dependency_path = decode_uri(encoded_dependency_path);
const headers = Object.fromEntries(result.response.headers);
const prerender = headers['x-sveltekit-prerender'];
if (prerender) {
const encoded_route_id = headers['x-sveltekit-routeid'];
if (encoded_route_id != null) {
const route_id = decode_uri(encoded_route_id);
const existing_value = prerender_map.get(route_id);
if (existing_value !== 'auto') {
prerender_map.set(route_id, prerender === 'true' ? true : 'auto');
}
}
}
const body = result.body ?? new Uint8Array(await result.response.arrayBuffer());
const category = decoded_dependency_path.startsWith(remote_prefix) ? 'data' : 'dependencies';
save(
category,
result.response,
body,
decoded_dependency_path,
encoded_dependency_path,
decoded,
'fetched'
);
}
// avoid triggering `filterSerializeResponseHeaders` guard
const headers = Object.fromEntries(response.headers);
// if it's a 200 HTML response, crawl it. Skip error responses, as we don't save those
if (
response.ok &&
config.prerender.crawl &&
matches_content_type(headers['content-type'], 'text/html')
) {
const { ids, hrefs, invalid } = crawl(body.toString(), decoded);
for (const href of invalid) {
handle_invalid_url({ href, referrer: decoded });
}
actual_hashlinks.set(decoded, new Set(ids));
/** @param {string} href */
const removePrerenderOrigin = (href) => {
if (href.startsWith(prerender_origin)) {
if (href === prerender_origin) return '/';
if (href.at(prerender_origin.length) !== '/') return href;
return href.slice(prerender_origin.length);
}
return href;
};
for (const href of hrefs.map(removePrerenderOrigin)) {
if (!is_root_relative(href)) continue;
const { pathname, search, hash } = new URL(href, 'http://localhost');
if (search) {
// TODO warn that query strings have no effect on statically-exported pages
}
if (hash) {
const key = decode_uri(pathname + hash);
if (!expected_hashlinks.has(key)) {
expected_hashlinks.set(key, new Set());
}
/** @type {Set<string>} */ (expected_hashlinks.get(key)).add(decoded);
}
void enqueue(decoded, decode_uri(pathname), pathname);
}
}
}
/**
* @param {'pages' | 'dependencies' | 'data'} category
* @param {Response} response
* @param {string | Uint8Array} body
* @param {string} decoded
* @param {string} encoded
* @param {string | null} referrer
* @param {'linked' | 'fetched'} referenceType
*/
function save(category, response, body, decoded, encoded, referrer, referenceType) {
const response_type = Math.floor(response.status / 100);
const headers = Object.fromEntries(response.headers);
const type = headers['content-type'];
const is_html = response_type === REDIRECT || matches_content_type(type, 'text/html');
if (!is_html && response.status === 200 && decoded.slice(config.paths.base.length + 1) === '') {
e.prerender_root_non_html({ base: config.paths.base || '/' });
}
const file = output_filename(decoded, is_html);
const dest = `${config.outDir}/output/prerendered/${category}/${file}`;
if (written.has(file)) return;
const encoded_route_id = response.headers.get('x-sveltekit-routeid');
const route_id = encoded_route_id != null ? decode_uri(encoded_route_id) : null;
if (route_id !== null) prerendered_routes.add(route_id);
if (response_type === REDIRECT) {
const location = headers['location'];
if (location) {
const resolved = resolve(encoded, location);
if (is_root_relative(resolved)) {
void enqueue(decoded, decode_uri(resolved), resolved);
}
if (!headers['x-sveltekit-normalize']) {
mkdirSync(dirname(dest), { recursive: true });
writeFileSync(
dest,
`<script>location.href=${devalue.uneval(
location
)};</script><meta http-equiv="refresh" content="${escape_html(
`0;url=${location}`,
true
)}">`
);
written.add(file);
if (!prerendered.redirects.has(decoded)) {
prerendered.redirects.set(decoded, {
status: response.status,
location: resolved
});
prerendered.paths.push(decoded);
}
}
} else {
w.prerender_redirect_location_missing({ path: decoded });
}
return;
}
if (response.status === 200) {
if (existsSync(dest) && statSync(dest).isDirectory()) {
e.prerender_directory_conflict({ path: decoded });
}
const dir = dirname(dest);
if (existsSync(dir) && !statSync(dir).isDirectory()) {
const parent = decoded.split('/').slice(0, -1).join('/');
e.prerender_directory_conflict({ path: decoded, parent });
}
mkdirSync(dir, { recursive: true });
writeFileSync(dest, body);
written.add(file);
if (is_html) {
prerendered.pages.set(decoded, {
file
});
} else {
prerendered.assets.set(decoded, {
type
});
}
prerendered.paths.push(decoded);
} else if (response_type !== OK) {
handle_http_error({
status: response.status,
path: decoded,
referrer,
referenceType
});
}
manifest.assets.add(file);
saved.set(file, dest);
}
/** @type {Array<{ id: string, entries: Array<string>}>} */
const route_level_entries = [];
for (const [id, { entries }] of metadata.routes.entries()) {
if (entries) {
route_level_entries.push({ id, entries });
}
}
let should_prerender = false;
for (const value of prerender_map.values()) {
if (value) {
should_prerender = true;
break;
}
}
/** @type {Array<import('types').RemotePrerenderInternals>} */
const prerender_functions = [];
for (const loader of Object.values(manifest.remotes)) {
const module = await loader();
for (const fn of Object.values(module.default)) {
if (fn?.__?.type === 'prerender') {
prerender_functions.push(fn.__);
should_prerender = true;
}
}
}
if (!should_prerender) {
return { prerendered, prerender_map };
}
// only run the server after the `should_prerender` check so that we
// don't run the user's init hook unnecessarily
await init();
log.info('Prerendering');
for (const entry of config.prerender.entries) {
if (entry === '*') {
for (const [id, prerender] of prerender_map) {
if (prerender) {
// remove optional parameters from the route
const segments = get_route_segments(id).filter((segment) => !segment.startsWith('[['));
const processed_id = '/' + segments.join('/');
if (processed_id.includes('[')) continue;
const path = `/${get_route_segments(processed_id).join('/')}`;
void enqueue(null, config.paths.base + path);
}
}
} else {
void enqueue(null, config.paths.base + entry);
}
}
for (const { id, entries } of route_level_entries) {
for (const entry of entries) {
void enqueue(null, config.paths.base + entry, undefined, id);
}
}
for (const internals of prerender_functions) {
/** @type {any[]} */
let inputs;
try {
inputs = (await internals.inputs?.()) ?? [];
} catch (e) {
if (e instanceof Error) fix_stack_trace(e);
throw e;
}
if (internals.has_arg) {
for (const arg of inputs) {
void enqueue(null, remote_prefix + internals.id + '/' + stringify_remote_arg(arg));
}
} else {
void enqueue(null, remote_prefix + internals.id);
}
}
await q.done();
progress?.clear();
// handle invalid fragment links
for (const [key, referrers] of expected_hashlinks) {
const index = key.indexOf('#');
const path = key.slice(0, index);
const id = key.slice(index + 1);
const hashlinks = actual_hashlinks.get(path);
// ignore fragment links to pages that were not prerendered
if (!hashlinks) continue;
if (!hashlinks.has(id) && !SPECIAL_HASHLINKS.has(id)) {
handle_missing_id({ id, path, referrers: Array.from(referrers) });
}
}
/** @type {string[]} */
const not_prerendered = [];
for (const [route_id, prerender] of prerender_map) {
if (prerender === true && !prerendered_routes.has(route_id)) {
not_prerendered.push(route_id);
}
}
if (not_prerendered.length > 0) {
handle_not_prerendered_route({ routes: not_prerendered });
}
return { prerendered, prerender_map };
}