import { readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { URL as URL$1, pathToFileURL } from 'url'; import { w as walk, p as posixify, m as mkdirp } from './chunks/filesystem.js'; import { installPolyfills } from './node/polyfills.js'; import { l as logger } from './chunks/utils.js'; import { l as load_config } from './chunks/index.js'; import 'assert'; import 'net'; import 'http'; import 'stream'; import 'buffer'; import 'util'; import 'stream/web'; import 'perf_hooks'; import 'util/types'; import 'events'; import 'tls'; import 'async_hooks'; import 'console'; import 'zlib'; import 'node:http'; import 'node:https'; import 'node:zlib'; import 'node:stream'; import 'node:buffer'; import 'node:util'; import 'node:url'; import 'node:net'; import 'node:fs'; import 'node:path'; import 'crypto'; const absolute = /^([a-z]+:)?\/?\//; const scheme = /^[a-z]+:/; /** * @param {string} base * @param {string} path */ function resolve(base, path) { if (scheme.test(path)) return path; const base_match = absolute.exec(base); const path_match = absolute.exec(path); if (!base_match) { throw new Error(`bad base path: "${base}"`); } const baseparts = path_match ? [] : base.slice(base_match[0].length).split('/'); const pathparts = path_match ? path.slice(path_match[0].length).split('/') : path.split('/'); baseparts.pop(); for (let i = 0; i < pathparts.length; i += 1) { const part = pathparts[i]; if (part === '.') continue; else if (part === '..') baseparts.pop(); else baseparts.push(part); } const prefix = (path_match && path_match[0]) || (base_match && base_match[0]) || ''; return `${prefix}${baseparts.join('/')}`; } /** @param {string} path */ function is_root_relative(path) { return path[0] === '/' && path[1] !== '/'; } /** * @typedef {{ * fn: () => Promise, * fulfil: (value: any) => void, * reject: (error: Error) => void * }} Task */ /** @param {number} concurrency */ function queue(concurrency) { /** @type {Task[]} */ const tasks = []; let current = 0; /** @type {(value?: any) => void} */ let fulfil; /** @type {(error: Error) => void} */ let reject; let closed = false; const done = new Promise((f, r) => { fulfil = f; reject = r; }); done.catch(() => { // this is necessary in case a catch handler is never added // to the done promise by the user }); function dequeue() { if (current < concurrency) { const task = tasks.shift(); if (task) { current += 1; const promise = Promise.resolve(task.fn()); promise .then(task.fulfil, (err) => { task.reject(err); reject(err); }) .then(() => { current -= 1; dequeue(); }); } else if (current === 0) { closed = true; fulfil(); } } } return { /** @param {() => any} fn */ add: (fn) => { if (closed) throw new Error('Cannot add tasks to a queue that has ended'); const promise = new Promise((fulfil, reject) => { tasks.push({ fn, fulfil, reject }); }); dequeue(); return promise; }, done: () => { if (current === 0) { closed = true; fulfil(); } return done; } }; } const DOCTYPE = 'DOCTYPE'; const CDATA_OPEN = '[CDATA['; const CDATA_CLOSE = ']]>'; const COMMENT_OPEN = '--'; const COMMENT_CLOSE = '-->'; const TAG_OPEN = /[a-zA-Z]/; const TAG_CHAR = /[a-zA-Z0-9]/; const ATTRIBUTE_NAME = /[^\t\n\f />"'=]/; const WHITESPACE = /[\s\n\r]/; /** @param {string} html */ function crawl(html) { /** @type {string[]} */ const hrefs = []; let i = 0; main: while (i < html.length) { const char = html[i]; if (char === '<') { if (html[i + 1] === '!') { i += 2; if (html.slice(i, i + DOCTYPE.length).toUpperCase() === DOCTYPE) { i += DOCTYPE.length; while (i < html.length) { if (html[i++] === '>') { continue main; } } } // skip cdata if (html.slice(i, i + CDATA_OPEN.length) === CDATA_OPEN) { i += CDATA_OPEN.length; while (i < html.length) { if (html.slice(i, i + CDATA_CLOSE.length) === CDATA_CLOSE) { i += CDATA_CLOSE.length; continue main; } i += 1; } } // skip comments if (html.slice(i, i + COMMENT_OPEN.length) === COMMENT_OPEN) { i += COMMENT_OPEN.length; while (i < html.length) { if (html.slice(i, i + COMMENT_CLOSE.length) === COMMENT_CLOSE) { i += COMMENT_CLOSE.length; continue main; } i += 1; } } } // parse opening tags const start = ++i; if (TAG_OPEN.test(html[start])) { while (i < html.length) { if (!TAG_CHAR.test(html[i])) { break; } i += 1; } const tag = html.slice(start, i).toUpperCase(); if (tag === 'SCRIPT' || tag === 'STYLE') { while (i < html.length) { if ( html[i] === '<' && html[i + 1] === '/' && html.slice(i + 2, i + 2 + tag.length).toUpperCase() === tag ) { continue main; } i += 1; } } let href = ''; let rel = ''; while (i < html.length) { const start = i; const char = html[start]; if (char === '>') break; if (ATTRIBUTE_NAME.test(char)) { i += 1; while (i < html.length) { if (!ATTRIBUTE_NAME.test(html[i])) { break; } i += 1; } const name = html.slice(start, i).toLowerCase(); while (WHITESPACE.test(html[i])) i += 1; if (html[i] === '=') { i += 1; while (WHITESPACE.test(html[i])) i += 1; let value; if (html[i] === "'" || html[i] === '"') { const quote = html[i++]; const start = i; let escaped = false; while (i < html.length) { if (!escaped) { const char = html[i]; if (html[i] === quote) { break; } if (char === '\\') { escaped = true; } } i += 1; } value = html.slice(start, i); } else { const start = i; while (html[i] !== '>' && !WHITESPACE.test(html[i])) i += 1; value = html.slice(start, i); i -= 1; } if (name === 'href') { href = value; } else if (name === 'rel') { rel = value; } else if (name === 'src') { hrefs.push(value); } else if (name === 'srcset') { const candidates = []; let insideURL = true; value = value.trim(); for (let i = 0; i < value.length; i++) { if (value[i] === ',' && (!insideURL || (insideURL && value[i + 1] === ' '))) { candidates.push(value.slice(0, i)); value = value.substring(i + 1).trim(); i = 0; insideURL = true; } else if (value[i] === ' ') { insideURL = false; } } candidates.push(value); for (const candidate of candidates) { const src = candidate.split(WHITESPACE)[0]; hrefs.push(src); } } } else { i -= 1; } } i += 1; } if (href && !/\bexternal\b/i.test(rel)) { hrefs.push(href); } } } i += 1; } return hrefs; } /** * Inside a script element, only ``, so the script element might be unexpectedly * kept open until until an unrelated HTML comment in the page. * * U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are escaped for the sake of pre-2018 * browsers. * * @see tests for unsafe parsing examples. * @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-state * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state * @see https://github.com/tc39/proposal-json-superset * @type {Record} */ const render_json_payload_script_dict = { '<': '\\u003C', '\u2028': '\\u2028', '\u2029': '\\u2029' }; new RegExp( `[${Object.keys(render_json_payload_script_dict).join('')}]`, 'g' ); /** * When inside a double-quoted attribute value, only `&` and `"` hold special meaning. * @see https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(double-quoted)-state * @type {Record} */ const escape_html_attr_dict = { '&': '&', '"': '"' }; const escape_html_attr_regex = new RegExp( // special characters `[${Object.keys(escape_html_attr_dict).join('')}]|` + // high surrogate without paired low surrogate '[\\ud800-\\udbff](?![\\udc00-\\udfff])|' + // a valid surrogate pair, the only match with 2 code units // we match it so that we can match unpaired low surrogates in the same pass // TODO: use lookbehind assertions once they are widely supported: (?...`; */ function escape_html_attr(str) { const escaped_str = str.replace(escape_html_attr_regex, (match) => { if (match.length === 2) { // valid surrogate pair return match; } return escape_html_attr_dict[match] ?? `&#${match.charCodeAt(0)};`; }); return `"${escaped_str}"`; } /** * @typedef {import('types').PrerenderErrorHandler} PrerenderErrorHandler * @typedef {import('types').Logger} Logger */ const [, , client_out_dir, results_path, manifest_path, verbose] = process.argv; prerender(); /** * @param {Parameters[0]} details * @param {import('types').ValidatedKitConfig} config */ function format_error({ status, path, referrer, referenceType }, config) { const message = status === 404 && !path.startsWith(config.paths.base) ? `${path} does not begin with \`base\`, which is configured in \`paths.base\` and can be imported from \`$app/paths\`` : path; return `${status} ${message}${referrer ? ` (${referenceType} from ${referrer})` : ''}`; } /** * @param {Logger} log * @param {import('types').ValidatedKitConfig} config * @returns {PrerenderErrorHandler} */ function normalise_error_handler(log, config) { switch (config.prerender.onError) { case 'continue': return (details) => { log.error(format_error(details, config)); }; case 'fail': return (details) => { throw new Error(format_error(details, config)); }; default: return config.prerender.onError; } } const OK = 2; const REDIRECT = 3; /** * @param {import('types').Prerendered} prerendered */ const output_and_exit = (prerendered) => { writeFileSync( results_path, JSON.stringify(prerendered, (_key, value) => value instanceof Map ? Array.from(value.entries()) : value ) ); process.exit(0); }; async function prerender() { /** @type {import('types').Prerendered} */ const prerendered = { pages: new Map(), assets: new Map(), redirects: new Map(), paths: [] }; /** @type {import('types').ValidatedKitConfig} */ const config = (await load_config()).kit; if (!config.prerender.enabled) { output_and_exit(prerendered); return; } /** @type {import('types').Logger} */ const log = logger({ verbose: verbose === 'true' }); installPolyfills(); const { fetch } = globalThis; globalThis.fetch = async (info, init) => { /** @type {string} */ let url; /** @type {RequestInit} */ let opts = {}; if (info instanceof Request) { url = info.url; opts = { method: info.method, headers: info.headers, body: info.body, mode: info.mode, credentials: info.credentials, cache: info.cache, redirect: info.redirect, referrer: info.referrer, integrity: info.integrity }; } else { url = info.toString(); } if (url.startsWith(config.prerender.origin + '/')) { const request = new Request(url, opts); const response = await server.respond(request, { getClientAddress, prerendering: { dependencies: new Map() } }); const decoded = new URL$1(url).pathname; save( 'dependencies', response, Buffer.from(await response.clone().arrayBuffer()), decoded, encodeURI(decoded), null, 'fetched' ); return response; } return fetch(info, init); }; const server_root = join(config.outDir, 'output'); /** @type {import('types').ServerModule} */ const { Server, override } = await import(pathToFileURL(`${server_root}/server/index.js`).href); /** @type {import('types').SSRManifest} */ const manifest = (await import(pathToFileURL(`${server_root}/server/manifest.js`).href)).manifest; override({ paths: config.paths, prerendering: true, read: (file) => readFileSync(join(config.files.assets, file)) }); const server = new Server(manifest); const error = normalise_error_handler(log, config); 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(client_out_dir).map(posixify)); const seen = new Set(); const written = new Set(); /** * @param {string | null} referrer * @param {string} decoded * @param {string} [encoded] */ function enqueue(referrer, decoded, encoded) { 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)); } /** * @param {string} decoded * @param {string} encoded * @param {string?} referrer */ async function visit(decoded, encoded, referrer) { if (!decoded.startsWith(config.paths.base)) { error({ status: 404, path: decoded, referrer, referenceType: 'linked' }); return; } /** @type {Map} */ const dependencies = new Map(); const response = await server.respond(new Request(config.prerender.origin + encoded), { getClientAddress, prerendering: { dependencies } }); const body = Buffer.from(await response.arrayBuffer()); save('pages', 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$1(dependency_path, 'http://localhost').pathname; const decoded_dependency_path = decodeURI(encoded_dependency_path); const body = result.body ?? new Uint8Array(await result.response.arrayBuffer()); save( 'dependencies', result.response, body, decoded_dependency_path, encoded_dependency_path, decoded, 'fetched' ); } if (config.prerender.crawl && response.headers.get('content-type') === 'text/html') { for (const href of crawl(body.toString())) { if (href.startsWith('data:') || href.startsWith('#')) continue; const resolved = resolve(encoded, href); if (!is_root_relative(resolved)) continue; const { pathname, search } = new URL$1(resolved, 'http://localhost'); enqueue(decoded, decodeURI(pathname), pathname); } } } /** * @param {'pages' | 'dependencies'} 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 type = /** @type {string} */ (response.headers.get('content-type')); const is_html = response_type === REDIRECT || type === 'text/html'; const file = output_filename(decoded, is_html); const dest = `${config.outDir}/output/prerendered/${category}/${file}`; if (written.has(file)) return; if (response_type === REDIRECT) { const location = response.headers.get('location'); if (location) { const resolved = resolve(encoded, location); if (is_root_relative(resolved)) { enqueue(decoded, decodeURI(resolved), resolved); } if (!response.headers.get('x-sveltekit-normalize')) { mkdirp(dirname(dest)); log.warn(`${response.status} ${decoded} -> ${location}`); writeFileSync( dest, `` ); written.add(file); if (!prerendered.redirects.has(decoded)) { prerendered.redirects.set(decoded, { status: response.status, location: resolved }); prerendered.paths.push(decoded); } } } else { log.warn(`location header missing on redirect received from ${decoded}`); } return; } if (response.status === 200) { mkdirp(dirname(dest)); log.info(`${response.status} ${decoded}`); 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) { error({ status: response.status, path: decoded, referrer, referenceType }); } } if (config.prerender.enabled) { for (const entry of config.prerender.entries) { if (entry === '*') { /** @type {import('types').ManifestData} */ const { routes } = (await import(pathToFileURL(manifest_path).href)).manifest._; const entries = routes .map((route) => (route.type === 'page' ? route.path : '')) .filter(Boolean); for (const entry of entries) { enqueue(null, config.paths.base + entry); // TODO can we pre-normalize these? } } else { enqueue(null, config.paths.base + entry); } } await q.done(); } const rendered = await server.respond(new Request(config.prerender.origin + '/[fallback]'), { getClientAddress, prerendering: { fallback: true, dependencies: new Map() } }); const file = `${config.outDir}/output/prerendered/fallback.html`; mkdirp(dirname(file)); writeFileSync(file, await rendered.text()); output_and_exit(prerendered); } /** @return {string} */ function getClientAddress() { throw new Error('Cannot read clientAddress during prerendering'); } export { prerender };