From 288f731c8a5b20cadb9e219f9583f3f16bf8c7b8 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Thu, 18 Jan 2024 18:29:42 -0500 Subject: [PATCH] feat: implement `read` (#11649) * feat: implement readAsset * lint etc * maybe make it work on netlify? no idea how to test, manual deploys dont appear to respect .netlify directory * set length/type from manifest * tidy up * regenerate types * lint * missed a spot * more efficient manifest generation * working on vercel * lint/fix * Update packages/adapter-vercel/index.js Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> * fix * createReadable helper * more future-proof API * account for basepath * lint * rename to just `read` * inline docs * it is already deprecated, we just need to remove it * read_asset -> read_implementation * add test * Apply suggestions from code review Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> * improve searchability * prevent $app/server being imported client-side * regenerate types * add dev time feature tracking mechanism * test feature support at build time * lint * lint * regenerate types * account for hooks.server.js, mostly * regenerate types * fix * bump peerdeps, add changesets * createReadable -> createReadableStream * Apply suggestions from code review Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> * remove unnecessary if * replace docs for find_server_assets * update adapter author docs * regenerate types * explain what __SVELTEKIT_TRACK__ does * mention `$app/server` on server-only modules page * minor details * oh ffs * exclude prerendered routes from feature detection, handle /@fs assets in dev * use read to populate content.json * fix prerendering * simplify * simplify docs logic * fix * style * simplify * lockfile * Apply suggestions from code review Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> * capitalize * Apply suggestions from code review Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --------- Co-authored-by: Rich Harris Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com> Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- .changeset/rude-apples-jam.md | 7 + .changeset/tasty-masks-talk.md | 5 + .changeset/tiny-maps-chew.md | 7 + .../99-writing-adapters.md | 8 + .../30-advanced/50-server-only-modules.md | 4 + packages/adapter-netlify/index.js | 16 +- packages/adapter-netlify/package.json | 2 +- packages/adapter-netlify/src/serverless.js | 4 +- packages/adapter-node/ambient.d.ts | 1 + packages/adapter-node/index.js | 11 +- packages/adapter-node/package.json | 2 +- packages/adapter-node/src/handler.js | 13 +- packages/adapter-vercel/files/serverless.js | 5 +- packages/adapter-vercel/index.js | 32 +++- packages/adapter-vercel/package.json | 2 +- packages/kit/scripts/generate-dts.js | 1 + packages/kit/src/core/adapt/builder.js | 8 + .../generate_manifest/find_server_assets.js | 52 ++++++ .../kit/src/core/generate_manifest/index.js | 26 ++- packages/kit/src/core/postbuild/analyse.js | 85 ++++++++-- packages/kit/src/core/postbuild/prerender.js | 6 +- .../core/sync/create_manifest_data/index.js | 20 ++- packages/kit/src/core/sync/write_server.js | 3 +- packages/kit/src/exports/node/index.js | 12 ++ packages/kit/src/exports/public.d.ts | 21 +++ packages/kit/src/exports/vite/build/utils.js | 12 +- packages/kit/src/exports/vite/dev/index.js | 30 +++- .../src/exports/vite/graph_analysis/index.js | 8 +- packages/kit/src/exports/vite/index.js | 45 ++++- packages/kit/src/exports/vite/module_ids.js | 11 +- .../kit/src/exports/vite/preview/index.js | 5 +- packages/kit/src/runtime/app/server/index.js | 58 +++++++ packages/kit/src/runtime/server/index.js | 12 +- packages/kit/src/runtime/server/page/index.js | 7 +- .../runtime/server/page/load_page_nodes.js | 11 ++ packages/kit/src/runtime/server/respond.js | 27 ++- packages/kit/src/types/ambient-private.d.ts | 10 ++ packages/kit/src/types/global-private.d.ts | 15 ++ packages/kit/src/types/internal.d.ts | 16 +- packages/kit/src/utils/features.js | 24 +++ packages/kit/src/utils/route_config.js | 20 +++ .../src/routes/read-file/+page.server.js | 15 ++ .../basics/src/routes/read-file/+page.svelte | 6 + .../apps/basics/src/routes/read-file/auto.txt | 1 + .../apps/basics/src/routes/read-file/url.txt | 1 + packages/kit/test/apps/basics/test/test.js | 12 ++ packages/kit/types/index.d.ts | 48 ++++++ pnpm-lock.yaml | 52 +++--- sites/kit.svelte.dev/package.json | 1 - sites/kit.svelte.dev/src/constants.js | 6 - .../src/lib/server/docs/index.js | 155 ++++++++---------- .../src/routes/+layout.server.js | 2 +- .../src/routes/content.json/content.server.js | 29 +--- .../src/routes/docs/+layout.server.js | 4 +- .../src/routes/docs/+layout.svelte | 3 +- .../src/routes/docs/[slug]/+page.server.js | 9 +- .../src/routes/nav.json/+server.js | 27 ++- 57 files changed, 800 insertions(+), 235 deletions(-) create mode 100644 .changeset/rude-apples-jam.md create mode 100644 .changeset/tasty-masks-talk.md create mode 100644 .changeset/tiny-maps-chew.md create mode 100644 packages/kit/src/core/generate_manifest/find_server_assets.js create mode 100644 packages/kit/src/runtime/app/server/index.js create mode 100644 packages/kit/src/runtime/server/page/load_page_nodes.js create mode 100644 packages/kit/src/utils/features.js create mode 100644 packages/kit/src/utils/route_config.js create mode 100644 packages/kit/test/apps/basics/src/routes/read-file/+page.server.js create mode 100644 packages/kit/test/apps/basics/src/routes/read-file/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/read-file/auto.txt create mode 100644 packages/kit/test/apps/basics/src/routes/read-file/url.txt delete mode 100644 sites/kit.svelte.dev/src/constants.js diff --git a/.changeset/rude-apples-jam.md b/.changeset/rude-apples-jam.md new file mode 100644 index 000000000000..57dfa17ca204 --- /dev/null +++ b/.changeset/rude-apples-jam.md @@ -0,0 +1,7 @@ +--- +'@sveltejs/adapter-netlify': major +'@sveltejs/adapter-vercel': major +'@sveltejs/adapter-node': major +--- + +breaking: update peer dependency on `@sveltejs/kit` diff --git a/.changeset/tasty-masks-talk.md b/.changeset/tasty-masks-talk.md new file mode 100644 index 000000000000..a7e80644963d --- /dev/null +++ b/.changeset/tasty-masks-talk.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: add `$app/server` module with `read` function for reading assets from filesystem diff --git a/.changeset/tiny-maps-chew.md b/.changeset/tiny-maps-chew.md new file mode 100644 index 000000000000..597bbff3abc5 --- /dev/null +++ b/.changeset/tiny-maps-chew.md @@ -0,0 +1,7 @@ +--- +'@sveltejs/adapter-netlify': minor +'@sveltejs/adapter-vercel': minor +'@sveltejs/adapter-node': minor +--- + +feat: support `read` from `$app/server` diff --git a/documentation/docs/25-build-and-deploy/99-writing-adapters.md b/documentation/docs/25-build-and-deploy/99-writing-adapters.md index 451d474ef5a9..183d4c727981 100644 --- a/documentation/docs/25-build-and-deploy/99-writing-adapters.md +++ b/documentation/docs/25-build-and-deploy/99-writing-adapters.md @@ -7,6 +7,7 @@ If an adapter for your preferred environment doesn't yet exist, you can build yo Adapters packages must implement the following API, which creates an `Adapter`: ```js +// @errors: 2322 // @filename: ambient.d.ts type AdapterSpecificOptions = any; @@ -19,6 +20,13 @@ export default function (options) { name: 'adapter-package-name', async adapt(builder) { // adapter implementation + }, + supports: { + read: ({ config, route }) => { + // Return `true` if the route with the given `config` can use `read` + // from `$app/server` in production, return `false` if it can't. + // Or throw a descriptive error describing how to configure the deployment + } } }; diff --git a/documentation/docs/30-advanced/50-server-only-modules.md b/documentation/docs/30-advanced/50-server-only-modules.md index 78f3a75da261..22e141d489d4 100644 --- a/documentation/docs/30-advanced/50-server-only-modules.md +++ b/documentation/docs/30-advanced/50-server-only-modules.md @@ -8,6 +8,10 @@ Like a good friend, SvelteKit keeps your secrets. When writing your backend and The `$env/static/private` and `$env/dynamic/private` modules, which are covered in the [modules](modules) section, can only be imported into modules that only run on the server, such as [`hooks.server.js`](hooks#server-hooks) or [`+page.server.js`](routing#page-page-server-js). +## Server-only utilities + +The [`$app/server`](/docs/modules#$app-server) module, which contains a `read` function for reading assets from the filesystem, can likewise only be imported by code that runs on the server. + ## Your modules You can make your own modules server-only in two ways: diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index fafcecfea75a..767644efd892 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -27,6 +27,7 @@ import toml from '@iarna/toml'; * }} HandlerManifest */ +const name = '@sveltejs/adapter-netlify'; const files = fileURLToPath(new URL('./files', import.meta.url).href); const edge_set_in_env_var = @@ -38,7 +39,7 @@ const FUNCTION_PREFIX = 'sveltekit-'; /** @type {import('./index.js').default} */ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { return { - name: '@sveltejs/adapter-netlify', + name, async adapt(builder) { if (!builder.routes) { @@ -92,6 +93,19 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } else { await generate_lambda_functions({ builder, split, publish }); } + }, + + supports: { + // reading from the filesystem only works in serverless functions + read: ({ route }) => { + if (edge) { + throw new Error( + `${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` when using edge functions` + ); + } + + return true; + } } }; } diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index 61ddc8af4897..f67ba70972f5 100644 --- a/packages/adapter-netlify/package.json +++ b/packages/adapter-netlify/package.json @@ -51,6 +51,6 @@ "vitest": "^1.2.0" }, "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^2.4.0" } } diff --git a/packages/adapter-netlify/src/serverless.js b/packages/adapter-netlify/src/serverless.js index db394a888302..e14c8189dee4 100644 --- a/packages/adapter-netlify/src/serverless.js +++ b/packages/adapter-netlify/src/serverless.js @@ -1,6 +1,7 @@ import './shims'; import { Server } from '0SERVER'; import { split_headers } from './headers.js'; +import { createReadableStream } from '@sveltejs/kit/node'; /** * @param {import('@sveltejs/kit').SSRManifest} manifest @@ -10,7 +11,8 @@ export function init(manifest) { const server = new Server(manifest); let init_promise = server.init({ - env: process.env + env: process.env, + read: (file) => createReadableStream(`.netlify/server/${file}`) }); return async (event, context) => { diff --git a/packages/adapter-node/ambient.d.ts b/packages/adapter-node/ambient.d.ts index 7d45ea6dc64a..acdc19bb2587 100644 --- a/packages/adapter-node/ambient.d.ts +++ b/packages/adapter-node/ambient.d.ts @@ -9,6 +9,7 @@ declare module 'HANDLER' { declare module 'MANIFEST' { import { SSRManifest } from '@sveltejs/kit'; + export const base: string; export const manifest: SSRManifest; export const prerendered: Set; } diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 4cd8d42cbe29..2f9ab8ed441f 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -39,8 +39,11 @@ export default function (opts = {}) { writeFileSync( `${tmp}/manifest.js`, - `export const manifest = ${builder.generateManifest({ relativePath: './' })};\n\n` + - `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});\n` + [ + `export const manifest = ${builder.generateManifest({ relativePath: './' })};`, + `export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});`, + `export const base = ${JSON.stringify(builder.config.kit.paths.base)};` + ].join('\n\n') ); const pkg = JSON.parse(readFileSync('package.json', 'utf8')); @@ -86,6 +89,10 @@ export default function (opts = {}) { ENV_PREFIX: JSON.stringify(envPrefix) } }); + }, + + supports: { + read: () => true } }; } diff --git a/packages/adapter-node/package.json b/packages/adapter-node/package.json index 91aa8eced7cd..895f3146f916 100644 --- a/packages/adapter-node/package.json +++ b/packages/adapter-node/package.json @@ -50,6 +50,6 @@ "rollup": "^4.9.5" }, "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^2.4.0" } } diff --git a/packages/adapter-node/src/handler.js b/packages/adapter-node/src/handler.js index 6adbe7c35fd8..d73b1c874831 100644 --- a/packages/adapter-node/src/handler.js +++ b/packages/adapter-node/src/handler.js @@ -4,15 +4,15 @@ import path from 'node:path'; import sirv from 'sirv'; import { fileURLToPath } from 'node:url'; import { parse as polka_url_parser } from '@polka/url'; -import { getRequest, setResponse } from '@sveltejs/kit/node'; +import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/node'; import { Server } from 'SERVER'; -import { manifest, prerendered } from 'MANIFEST'; +import { manifest, prerendered, base } from 'MANIFEST'; import { env } from 'ENV'; /* global ENV_PREFIX */ const server = new Server(manifest); -await server.init({ env: process.env }); + const origin = env('ORIGIN', undefined); const xff_depth = parseInt(env('XFF_DEPTH', '1')); const address_header = env('ADDRESS_HEADER', '').toLowerCase(); @@ -29,6 +29,13 @@ if (isNaN(body_size_limit)) { const dir = path.dirname(fileURLToPath(import.meta.url)); +const asset_dir = `${dir}/client${base}`; + +await server.init({ + env: process.env, + read: (file) => createReadableStream(`${asset_dir}/${file}`) +}); + /** * @param {string} path * @param {boolean} client diff --git a/packages/adapter-vercel/files/serverless.js b/packages/adapter-vercel/files/serverless.js index d732371568c8..1a76ba8a6f29 100644 --- a/packages/adapter-vercel/files/serverless.js +++ b/packages/adapter-vercel/files/serverless.js @@ -1,5 +1,5 @@ import { installPolyfills } from '@sveltejs/kit/node/polyfills'; -import { getRequest, setResponse } from '@sveltejs/kit/node'; +import { getRequest, setResponse, createReadableStream } from '@sveltejs/kit/node'; import { Server } from 'SERVER'; import { manifest } from 'MANIFEST'; @@ -8,7 +8,8 @@ installPolyfills(); const server = new Server(manifest); await server.init({ - env: /** @type {Record} */ (process.env) + env: /** @type {Record} */ (process.env), + read: createReadableStream }); const DATA_SUFFIX = '/__data.json'; diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 6a38d4e77e38..683df1757073 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -5,6 +5,7 @@ import { nodeFileTrace } from '@vercel/nft'; import esbuild from 'esbuild'; import { get_pathname } from './utils.js'; +const name = '@sveltejs/adapter-vercel'; const DEFAULT_FUNCTION_NAME = 'fn'; const get_default_runtime = () => { @@ -24,7 +25,7 @@ const plugin = function (defaults = {}) { } return { - name: '@sveltejs/adapter-vercel', + name, async adapt(builder) { if (!builder.routes) { @@ -63,6 +64,8 @@ const plugin = function (defaults = {}) { * @param {import('@sveltejs/kit').RouteDefinition[]} routes */ async function generate_serverless_function(name, config, routes) { + const dir = `${dirs.functions}/${name}.func`; + const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); builder.copy(`${files}/serverless.js`, `${tmp}/index.js`, { @@ -77,12 +80,12 @@ const plugin = function (defaults = {}) { `export const manifest = ${builder.generateManifest({ relativePath, routes })};\n` ); - await create_function_bundle( - builder, - `${tmp}/index.js`, - `${dirs.functions}/${name}.func`, - config - ); + await create_function_bundle(builder, `${tmp}/index.js`, dir, config); + + for (const asset of builder.findServerAssets(routes)) { + // TODO use symlinks, once Build Output API supports doing so + builder.copy(`${builder.getServerDirectory()}/${asset}`, `${dir}/${asset}`); + } } /** @@ -335,6 +338,21 @@ const plugin = function (defaults = {}) { builder.log.minor('Writing routes...'); write(`${dir}/config.json`, JSON.stringify(static_config, null, '\t')); + }, + + supports: { + // reading from the filesystem only works in serverless functions + read: ({ config, route }) => { + const runtime = config.runtime ?? defaults.runtime; + + if (runtime === 'edge') { + throw new Error( + `${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` configured with \`runtime: 'edge'\`` + ); + } + + return true; + } } }; }; diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index b172039a51c1..c35b8bc999f7 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -42,6 +42,6 @@ "vitest": "^1.2.0" }, "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^2.4.0" } } diff --git a/packages/kit/scripts/generate-dts.js b/packages/kit/scripts/generate-dts.js index 5d23c06d23f8..8d21d7881d7f 100644 --- a/packages/kit/scripts/generate-dts.js +++ b/packages/kit/scripts/generate-dts.js @@ -13,6 +13,7 @@ await createBundle({ '$app/forms': 'src/runtime/app/forms.js', '$app/navigation': 'src/runtime/app/navigation.js', '$app/paths': 'src/runtime/app/paths/types.d.ts', + '$app/server': 'src/runtime/app/server/index.js', '$app/stores': 'src/runtime/app/stores.js' }, include: ['src'] diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 45fa938ce894..e02efbdd1e28 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -11,6 +11,7 @@ import { get_env } from '../../exports/vite/utils.js'; import generate_fallback from '../postbuild/fallback.js'; import { write } from '../sync/utils.js'; import { list_files } from '../utils.js'; +import { find_server_assets } from '../generate_manifest/find_server_assets.js'; const pipe = promisify(pipeline); const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.wasm']; @@ -145,6 +146,13 @@ export function create_builder({ } }, + findServerAssets(route_data) { + return find_server_assets( + build_data, + route_data.map((route) => /** @type {import('types').RouteData} */ (lookup.get(route))) + ); + }, + async generateFallback(dest) { const manifest_path = `${config.kit.outDir}/output/server/manifest-full.js`; const env = get_env(config.kit.env, vite_config.mode); diff --git a/packages/kit/src/core/generate_manifest/find_server_assets.js b/packages/kit/src/core/generate_manifest/find_server_assets.js new file mode 100644 index 000000000000..3a562f4b53a0 --- /dev/null +++ b/packages/kit/src/core/generate_manifest/find_server_assets.js @@ -0,0 +1,52 @@ +import { find_deps } from '../../exports/vite/build/utils.js'; + +/** + * Finds all the assets that are imported by server files associated with `routes` + * @param {import('types').BuildData} build_data + * @param {import('types').RouteData[]} routes + */ +export function find_server_assets(build_data, routes) { + /** + * 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} + */ + const used_nodes = new Set([0, 1]); + + // TODO add hooks.server.js asset imports + /** @type {Set} */ + const server_assets = new Set(); + + /** @param {string} id */ + function add_assets(id) { + if (id in build_data.server_manifest) { + const deps = find_deps(build_data.server_manifest, id, false); + for (const asset of deps.assets) { + server_assets.add(asset); + } + } + } + + 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); + } + + if (route.endpoint) { + add_assets(route.endpoint.file); + } + } + + for (const n of used_nodes) { + const node = build_data.manifest_data.nodes[n]; + if (node?.server) add_assets(node.server); + } + + if (build_data.manifest_data.hooks.server) { + add_assets(build_data.manifest_data.hooks.server); + } + + return Array.from(server_assets); +} diff --git a/packages/kit/src/core/generate_manifest/index.js b/packages/kit/src/core/generate_manifest/index.js index 272d79e55d29..a2ef4cd4c619 100644 --- a/packages/kit/src/core/generate_manifest/index.js +++ b/packages/kit/src/core/generate_manifest/index.js @@ -1,9 +1,13 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import * as mime from 'mrmime'; import { s } from '../../utils/misc.js'; import { get_mime_lookup } from '../utils.js'; import { resolve_symlinks } from '../../exports/vite/build/utils.js'; import { compact } from '../../utils/array.js'; import { join_relative } from '../../utils/filesystem.js'; import { dedent } from '../sync/utils.js'; +import { find_server_assets } from './find_server_assets.js'; /** * Generates the data used to write the server-side manifest.js file. This data is used in the Vite @@ -26,6 +30,8 @@ export function generate_manifest({ build_data, relative_path, routes }) { */ 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); @@ -63,6 +69,17 @@ export function generate_manifest({ build_data, relative_path, routes }) { return `[${string},]`; } + const mime_types = get_mime_lookup(build_data.manifest_data); + + /** @type {Record} */ + 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('@sveltejs/kit').SSRManifest} T */ @@ -71,7 +88,7 @@ export function generate_manifest({ build_data, relative_path, routes }) { appDir: ${s(build_data.app_dir)}, appPath: ${s(build_data.app_path)}, assets: new Set(${s(assets)}), - mimeTypes: ${s(get_mime_lookup(build_data.manifest_data))}, + mimeTypes: ${s(mime_types)}, _: { client: ${s(build_data.client)}, nodes: [ @@ -80,7 +97,7 @@ export function generate_manifest({ build_data, relative_path, routes }) { routes: [ ${routes.map(route => { if (!route.page && !route.endpoint) return; - + route.params.forEach(param => { if (param.matcher) matchers.add(param.matcher); }); @@ -98,11 +115,12 @@ export function generate_manifest({ build_data, relative_path, routes }) { ], matchers: async () => { ${Array.from( - matchers, + 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)} } } `; diff --git a/packages/kit/src/core/postbuild/analyse.js b/packages/kit/src/core/postbuild/analyse.js index e4aac23ec08a..ea2c0637dd2d 100644 --- a/packages/kit/src/core/postbuild/analyse.js +++ b/packages/kit/src/core/postbuild/analyse.js @@ -14,16 +14,22 @@ import { installPolyfills } from '../../exports/node/polyfills.js'; import { ENDPOINT_METHODS } from '../../constants.js'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; import { resolve_route } from '../../utils/routing.js'; +import { get_page_config } from '../../utils/route_config.js'; +import { check_feature } from '../../utils/features.js'; +import { createReadableStream } from '@sveltejs/kit/node'; export default forked(import.meta.url, analyse); /** * @param {{ * manifest_path: string; + * manifest_data: import('types').ManifestData; + * server_manifest: import('vite').Manifest; + * tracked_features: Record; * env: Record * }} opts */ -async function analyse({ manifest_path, env }) { +async function analyse({ manifest_path, manifest_data, server_manifest, tracked_features, env }) { /** @type {import('@sveltejs/kit').SSRManifest} */ const manifest = (await import(pathToFileURL(manifest_path).href)).manifest; @@ -48,6 +54,8 @@ async function analyse({ manifest_path, env }) { internal.set_private_env(private_env); internal.set_public_env(public_env); internal.set_safe_public_env(public_env); + internal.set_manifest(manifest); + internal.set_read_implementation((file) => createReadableStream(`${server_root}/server/${file}`)); /** @type {import('types').ServerMetadata} */ const metadata = { @@ -89,12 +97,26 @@ async function analyse({ manifest_path, env }) { } } + const route_config = page?.config ?? endpoint?.config; + const prerender = page?.prerender ?? endpoint?.prerender; + + if (prerender !== true) { + for (const feature of list_features( + route, + manifest_data, + server_manifest, + tracked_features + )) { + check_feature(route.id, route_config, feature, config.adapter); + } + } + const page_methods = page?.methods ?? []; const api_methods = endpoint?.methods ?? []; const entries = page?.entries ?? endpoint?.entries; metadata.routes.set(route.id, { - config: page?.config ?? endpoint?.config, + config: route_config, methods: Array.from(new Set([...page_methods, ...api_methods])), page: { methods: page_methods @@ -102,7 +124,7 @@ async function analyse({ manifest_path, env }) { api: { methods: api_methods }, - prerender: page?.prerender ?? endpoint?.prerender, + prerender, entries: entries && (await entries()).map((entry_object) => resolve_route(route.id, entry_object)) }); @@ -163,7 +185,7 @@ function analyse_page(layouts, leaf) { validate_page_exports(leaf.universal, leaf.universal_id); return { - config: get_config([...layouts, leaf]), + config: get_page_config([...layouts, leaf]), entries: leaf.universal?.entries ?? leaf.server?.entries, methods, prerender: get_option([...layouts, leaf], 'prerender') ?? false @@ -171,22 +193,51 @@ function analyse_page(layouts, leaf) { } /** - * Do a shallow merge (first level) of the config object - * @param {Array} nodes + * @param {import('types').SSRRoute} route + * @param {import('types').ManifestData} manifest_data + * @param {import('vite').Manifest} server_manifest + * @param {Record} tracked_features */ -function get_config(nodes) { - /** @type {any} */ - let current = {}; +function list_features(route, manifest_data, server_manifest, tracked_features) { + const features = new Set(); - for (const node of nodes) { - if (!node?.universal?.config && !node?.server?.config) continue; + const route_data = /** @type {import('types').RouteData} */ ( + manifest_data.routes.find((r) => r.id === route.id) + ); - current = { - ...current, - ...node?.universal?.config, - ...node?.server?.config - }; + /** @param {string} id */ + function visit(id) { + const chunk = server_manifest[id]; + if (!chunk) return; + + if (chunk.file in tracked_features) { + for (const feature of tracked_features[chunk.file]) { + features.add(feature); + } + } + + if (chunk.imports) { + for (const id of chunk.imports) { + visit(id); + } + } + } + + let page_node = route_data?.leaf; + while (page_node) { + if (page_node.server) visit(page_node.server); + page_node = page_node.parent ?? null; + } + + if (route_data.endpoint) { + visit(route_data.endpoint.file); + } + + if (manifest_data.hooks.server) { + // TODO if hooks.server.js imports `read`, it will be in the entry chunk + // we don't currently account for that case + visit(manifest_data.hooks.server); } - return Object.keys(current).length ? current : undefined; + return Array.from(features); } diff --git a/packages/kit/src/core/postbuild/prerender.js b/packages/kit/src/core/postbuild/prerender.js index 4cf3053fe2bb..c29ff168f562 100644 --- a/packages/kit/src/core/postbuild/prerender.js +++ b/packages/kit/src/core/postbuild/prerender.js @@ -12,6 +12,7 @@ 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'; export default forked(import.meta.url, prerender); @@ -430,7 +431,10 @@ async function prerender({ out, manifest_path, metadata, verbose, env }) { log.info('Prerendering'); const server = new Server(manifest); - await server.init({ env }); + await server.init({ + env, + read: (file) => createReadableStream(`${config.outDir}/output/server/${file}`) + }); for (const entry of config.prerender.entries) { if (entry === '*') { diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index 5d72686db746..ab807f9f2f50 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -3,7 +3,7 @@ import path from 'node:path'; import colors from 'kleur'; import { lookup } from 'mrmime'; import { list_files, runtime_directory } from '../../utils.js'; -import { posixify } from '../../../utils/filesystem.js'; +import { posixify, resolve_entry } from '../../../utils/filesystem.js'; import { parse_route_id } from '../../../utils/routing.js'; import { sort_routes } from './sort.js'; @@ -22,6 +22,7 @@ export default function create_manifest_data({ cwd = process.cwd() }) { const assets = create_assets(config); + const hooks = create_hooks(config, cwd); const matchers = create_matchers(config, cwd); const { nodes, routes } = create_routes_and_nodes(cwd, config, fallback); @@ -35,6 +36,7 @@ export default function create_manifest_data({ return { assets, + hooks, matchers, nodes, routes @@ -52,6 +54,22 @@ export function create_assets(config) { })); } +/** + * @param {import('types').ValidatedConfig} config + * @param {string} cwd + */ +function create_hooks(config, cwd) { + const client = resolve_entry(config.kit.files.hooks.client); + const server = resolve_entry(config.kit.files.hooks.server); + const universal = resolve_entry(config.kit.files.hooks.universal); + + return { + client: client && posixify(path.relative(cwd, client)), + server: server && posixify(path.relative(cwd, server)), + universal: universal && posixify(path.relative(cwd, universal)) + }; +} + /** * @param {import('types').ValidatedConfig} config * @param {string} cwd diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index a781711bbfe9..3aa65f9ebe0b 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -30,6 +30,7 @@ const server_template = ({ 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/server'; import { set_private_env, set_public_env, set_safe_public_env } from '${runtime_directory}/shared-server.js'; export const options = { @@ -68,7 +69,7 @@ export async function get_hooks() { }; } -export { set_assets, set_building, set_prerendering, set_private_env, set_public_env, set_safe_public_env }; +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 diff --git a/packages/kit/src/exports/node/index.js b/packages/kit/src/exports/node/index.js index d15682838aaf..f38a09358084 100644 --- a/packages/kit/src/exports/node/index.js +++ b/packages/kit/src/exports/node/index.js @@ -1,3 +1,5 @@ +import { createReadStream } from 'node:fs'; +import { Readable } from 'node:stream'; import * as set_cookie_parser from 'set-cookie-parser'; import { SvelteKitError } from '../../runtime/control.js'; @@ -189,3 +191,13 @@ export async function setResponse(res, response) { } } } + +/** + * Converts a file on disk to a readable stream + * @param {string} file + * @returns {ReadableStream} + * @since 2.4.0 + */ +export function createReadableStream(file) { + return /** @type {ReadableStream} */ (Readable.toWeb(createReadStream(file))); +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 183b85da657c..6b21eaae8e46 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -35,6 +35,16 @@ export interface Adapter { * @param builder An object provided by SvelteKit that contains methods for adapting the app */ adapt(builder: Builder): MaybePromise; + /** + * Checks called during dev and build to determine whether specific features will work in production with this adapter + */ + supports?: { + /** + * Test support for `read` from `$app/server` + * @param config The merged route config + */ + read?: (details: { config: any; route: { id: string } }) => boolean; + }; } export type LoadProperties | void> = input extends void @@ -89,6 +99,7 @@ export interface Builder { /** An array of all routes (including prerendered) */ routes: RouteDefinition[]; + // TODO 3.0 remove this method /** * Create separate functions that map to one or more routes of your app. * @param fn A function that groups a set of routes into an entry point @@ -96,6 +107,11 @@ export interface Builder { */ createEntries(fn: (route: RouteDefinition) => AdapterEntry): Promise; + /** + * Find all the assets imported by server files belonging to `routes` + */ + findServerAssets(routes: RouteDefinition[]): string[]; + /** * Generate a fallback page for a static webserver to use when no route is matched. Useful for single-page apps. */ @@ -1144,7 +1160,10 @@ export class Server { } export interface ServerInitOptions { + /** A map of environment variables */ env: Record; + /** A function that turns an asset filename into a `ReadableStream`. Required for the `read` export from `$app/server` to work */ + read?: (file: string) => ReadableStream; } export interface SSRManifest { @@ -1159,6 +1178,8 @@ export interface SSRManifest { nodes: SSRNodeLoader[]; routes: SSRRoute[]; matchers(): Promise>; + /** A `[file]: size` map of all assets imported by server code */ + server_assets: Record; }; } diff --git a/packages/kit/src/exports/vite/build/utils.js b/packages/kit/src/exports/vite/build/utils.js index 7c12c1e2139d..f265747687a9 100644 --- a/packages/kit/src/exports/vite/build/utils.js +++ b/packages/kit/src/exports/vite/build/utils.js @@ -20,7 +20,7 @@ export function find_deps(manifest, entry, add_dynamic_css) { const stylesheets = new Set(); /** @type {Set} */ - const fonts = new Set(); + const imported_assets = new Set(); /** * @param {string} current @@ -36,9 +36,7 @@ export function find_deps(manifest, entry, add_dynamic_css) { if (chunk.assets) { for (const asset of chunk.assets) { - if (/\.(woff2?|ttf|otf)$/.test(asset)) { - fonts.add(asset); - } + imported_assets.add(asset); } } @@ -59,11 +57,15 @@ export function find_deps(manifest, entry, add_dynamic_css) { traverse(file, true); + const assets = Array.from(imported_assets); + return { + assets, file: chunk.file, imports: Array.from(imports), stylesheets: Array.from(stylesheets), - fonts: Array.from(fonts) + // TODO do we need this separately? + fonts: assets.filter((asset) => /\.(woff2?|ttf|otf)$/.test(asset)) }; } diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index 7ebf66444548..cfe846dbe326 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -1,10 +1,11 @@ import fs from 'node:fs'; import path from 'node:path'; import { URL } from 'node:url'; +import { AsyncLocalStorage } from 'node:async_hooks'; import colors from 'kleur'; import sirv from 'sirv'; import { isCSSRequest, loadEnv, buildErrorMessage } from 'vite'; -import { getRequest, setResponse } from '../../../exports/node/index.js'; +import { createReadableStream, getRequest, setResponse } from '../../../exports/node/index.js'; import { installPolyfills } from '../../../exports/node/polyfills.js'; import { coalesce_to_error } from '../../../utils/error.js'; import { posixify, resolve_entry, to_fs } from '../../../utils/filesystem.js'; @@ -15,6 +16,7 @@ import { get_mime_lookup, runtime_base } from '../../../core/utils.js'; import { compact } from '../../../utils/array.js'; import { not_found } from '../utils.js'; import { SCHEME } from '../../../utils/url.js'; +import { check_feature } from '../../../utils/features.js'; const cwd = process.cwd(); @@ -27,6 +29,15 @@ const cwd = process.cwd(); export async function dev(vite, vite_config, svelte_config) { installPolyfills(); + const async_local_storage = new AsyncLocalStorage(); + + globalThis.__SVELTEKIT_TRACK__ = (label) => { + const context = async_local_storage.getStore(); + if (!context || context.prerender === true) return; + + check_feature(context.event.route.id, context.config, label, svelte_config.kit.adapter); + }; + const fetch = globalThis.fetch; globalThis.fetch = (info, init) => { if (typeof info === 'string' && !SCHEME.test(info)) { @@ -123,6 +134,13 @@ export async function dev(vite, vite_config, svelte_config) { fonts: [], uses_env_dynamic_public: true }, + server_assets: new Proxy( + {}, + { + has: (_, /** @type {string} */ file) => fs.existsSync(file.replace(/^\/@fs/, '')), + get: (_, /** @type {string} */ file) => fs.statSync(file.replace(/^\/@fs/, '')).size + } + ), nodes: manifest_data.nodes.map((node, index) => { return async () => { /** @type {import('types').SSRNode} */ @@ -470,7 +488,10 @@ export async function dev(vite, vite_config, svelte_config) { const server = new Server(manifest); - await server.init({ env }); + await server.init({ + env, + read: (file) => createReadableStream(file.replace(/^\/@fs/, '')) + }); const request = await getRequest({ base, @@ -505,7 +526,10 @@ export async function dev(vite, vite_config, svelte_config) { if (remoteAddress) return remoteAddress; throw new Error('Could not determine clientAddress'); }, - read: (file) => fs.readFileSync(path.join(svelte_config.kit.files.assets, file)) + read: (file) => fs.readFileSync(path.join(svelte_config.kit.files.assets, file)), + before_handle: (event, config, prerender) => { + async_local_storage.enterWith({ event, config, prerender }); + } }); if (rendered.status === 404) { diff --git a/packages/kit/src/exports/vite/graph_analysis/index.js b/packages/kit/src/exports/vite/graph_analysis/index.js index 7ab7cd3cdfa2..a58cc7a72a2a 100644 --- a/packages/kit/src/exports/vite/graph_analysis/index.js +++ b/packages/kit/src/exports/vite/graph_analysis/index.js @@ -1,9 +1,9 @@ import path from 'node:path'; import { posixify } from '../../../utils/filesystem.js'; import { strip_virtual_prefix } from '../utils.js'; -import { env_dynamic_private, env_static_private } from '../module_ids.js'; +import { app_server, env_dynamic_private, env_static_private } from '../module_ids.js'; -const ILLEGAL_IMPORTS = new Set([env_dynamic_private, env_static_private]); +const ILLEGAL_IMPORTS = new Set([env_dynamic_private, env_static_private, app_server]); const ILLEGAL_MODULE_NAME_PATTERN = /.*\.server\..+/; /** @@ -101,5 +101,9 @@ export function normalize_id(id, lib, cwd) { id = path.relative(cwd, id); } + if (id === app_server) { + return '$app/server'; + } + return posixify(id); } diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 98e487c4fae7..7c8297faf3be 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -31,7 +31,8 @@ import { env_static_public, service_worker, sveltekit_environment, - sveltekit_paths + sveltekit_paths, + sveltekit_server } from './module_ids.js'; import { pathToFileURL } from 'node:url'; @@ -224,6 +225,13 @@ async function kit({ svelte_config }) { const service_worker_entry_file = resolve_entry(kit.files.serviceWorker); const parsed_service_worker = path.parse(kit.files.serviceWorker); + /** + * A map showing which features (such as `$app/server:read`) are defined + * in which chunks, so that we can later determine which routes use which features + * @type {Record} + */ + const tracked_features = {}; + const sourcemapIgnoreList = /** @param {string} relative_path */ (relative_path) => relative_path.includes('node_modules') || relative_path.includes(kit.outDir); @@ -492,13 +500,29 @@ async function kit({ svelte_config }) { } `; } + + case sveltekit_server: { + return dedent` + export let read_implementation = null; + + export let manifest = null; + + export function set_read_implementation(fn) { + read_implementation = fn; + } + + export function set_manifest(_) { + manifest = _; + } + `; + } } } }; /** * Ensures that client-side code can't accidentally import server-side code, - * whether in `*.server.js` files, `$lib/server`, or `$env/[static|dynamic]/private` + * whether in `*.server.js` files, `$app/server`, `$lib/server`, or `$env/[static|dynamic]/private` * @type {import('vite').Plugin} */ const plugin_guard = { @@ -685,6 +709,19 @@ async function kit({ svelte_config }) { } }, + renderChunk(code, chunk) { + if (code.includes('__SVELTEKIT_TRACK__')) { + return { + code: code.replace(/__SVELTEKIT_TRACK__\('(.+?)'\)/g, (_, label) => { + (tracked_features[chunk.name + '.js'] ??= []).push(label); + // put extra whitespace at the end of the comment to preserve the source size and avoid interfering with source maps + return `/* track ${label} */`; + }), + map: null // TODO we may need to generate a sourcemap in future + }; + } + }, + generateBundle() { if (vite_config.build.ssr) return; @@ -716,6 +753,7 @@ async function kit({ svelte_config }) { app_dir: kit.appDir, app_path: `${kit.paths.base.slice(1)}${kit.paths.base ? '/' : ''}${kit.appDir}`, manifest_data, + out_dir: out, service_worker: service_worker_entry_file ? 'service-worker.js' : null, // TODO make file configurable? client: null, server_manifest @@ -738,6 +776,9 @@ async function kit({ svelte_config }) { const metadata = await analyse({ manifest_path, + manifest_data, + server_manifest, + tracked_features, env: { ...env.private, ...env.public } }); diff --git a/packages/kit/src/exports/vite/module_ids.js b/packages/kit/src/exports/vite/module_ids.js index a6097f5b6f44..3be16f68a71f 100644 --- a/packages/kit/src/exports/vite/module_ids.js +++ b/packages/kit/src/exports/vite/module_ids.js @@ -1,7 +1,16 @@ +import { fileURLToPath } from 'node:url'; + export const env_static_private = '\0virtual:$env/static/private'; export const env_static_public = '\0virtual:$env/static/public'; export const env_dynamic_private = '\0virtual:$env/dynamic/private'; export const env_dynamic_public = '\0virtual:$env/dynamic/public'; + export const service_worker = '\0virtual:$service-worker'; -export const sveltekit_paths = '\0virtual:__sveltekit/paths'; + export const sveltekit_environment = '\0virtual:__sveltekit/environment'; +export const sveltekit_paths = '\0virtual:__sveltekit/paths'; +export const sveltekit_server = '\0virtual:__sveltekit/server'; + +export const app_server = fileURLToPath( + new URL('../../runtime/app/server/index.js', import.meta.url) +); diff --git a/packages/kit/src/exports/vite/preview/index.js b/packages/kit/src/exports/vite/preview/index.js index 997922bb5272..c34af3121e1d 100644 --- a/packages/kit/src/exports/vite/preview/index.js +++ b/packages/kit/src/exports/vite/preview/index.js @@ -4,7 +4,7 @@ import { pathToFileURL } from 'node:url'; import { lookup } from 'mrmime'; import sirv from 'sirv'; import { loadEnv, normalizePath } from 'vite'; -import { getRequest, setResponse } from '../../../exports/node/index.js'; +import { createReadableStream, getRequest, setResponse } from '../../../exports/node/index.js'; import { installPolyfills } from '../../../exports/node/polyfills.js'; import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import { not_found } from '../utils.js'; @@ -47,7 +47,8 @@ export async function preview(vite, vite_config, svelte_config) { const server = new Server(manifest); await server.init({ - env: loadEnv(vite_config.mode, svelte_config.kit.env.dir, '') + env: loadEnv(vite_config.mode, svelte_config.kit.env.dir, ''), + read: (file) => createReadableStream(`${dir}/${file}`) }); return () => { diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js new file mode 100644 index 000000000000..9865bd8cb4dd --- /dev/null +++ b/packages/kit/src/runtime/app/server/index.js @@ -0,0 +1,58 @@ +import { read_implementation, manifest } from '__sveltekit/server'; +import { base } from '__sveltekit/paths'; +import { DEV } from 'esm-env'; + +/** + * Read the contents of an imported asset from the filesystem + * @example + * ```js + * import { read } from '$app/server'; + * import somefile from './somefile.txt'; + * + * const asset = read(somefile); + * const text = await asset.text(); + * ``` + * @param {string} asset + * @returns {Response} + * @since 2.4.0 + */ +export function read(asset) { + __SVELTEKIT_TRACK__('$app/server:read'); + + if (!read_implementation) { + throw new Error( + 'No `read` implementation was provided. Please ensure that your adapter is up to date and supports this feature' + ); + } + + // handle inline assets internally + if (asset.startsWith('data:')) { + const [prelude, data] = asset.split(';'); + const type = prelude.slice(5) || 'application/octet-stream'; + + const decoded = data.startsWith('base64,') ? atob(data.slice(7)) : decodeURIComponent(data); + + return new Response(decoded, { + headers: { + 'Content-Length': decoded.length.toString(), + 'Content-Type': type + } + }); + } + + const file = DEV && asset.startsWith('/@fs') ? asset : asset.slice(base.length + 1); + + if (file in manifest._.server_assets) { + const length = manifest._.server_assets[file]; + const type = manifest.mimeTypes[file.slice(file.lastIndexOf('.'))]; + + return new Response(read_implementation(file), { + headers: { + 'Content-Length': '' + length, + 'Content-Type': type + } + }); + } + + throw new Error(`Asset does not exist: ${file}`); +} diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 84257a8c5f36..36cbd04be16f 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -4,6 +4,7 @@ import { options, get_hooks } from '__SERVER__/internal.js'; import { DEV } from 'esm-env'; import { filter_private_env, filter_public_env } from '../../utils/env.js'; import { prerendering } from '__sveltekit/environment'; +import { set_read_implementation, set_manifest } from '__sveltekit/server'; /** @type {ProxyHandler<{ type: 'public' | 'private' }>} */ const prerender_env_handler = { @@ -26,14 +27,17 @@ export class Server { /** @type {import('types').SSROptions} */ this.#options = options; this.#manifest = manifest; + + set_manifest(manifest); } /** * @param {{ - * env: Record + * env: Record; + * read?: (file: string) => ReadableStream; * }} opts */ - async init({ env }) { + async init({ env, read }) { // Take care: Some adapters may have to call `Server.init` per-request to set env vars, // so anything that shouldn't be rerun should be wrapped in an `if` block to make sure it hasn't // been done already. @@ -55,6 +59,10 @@ export class Server { ); set_safe_public_env(public_env); + if (read) { + set_read_implementation(read); + } + if (!this.#options.hooks) { try { const module = await get_hooks(); diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index f8878e112328..b90bf67eef58 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -15,6 +15,7 @@ import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; import { get_option } from '../../../utils/options.js'; import { get_data_json } from '../data/index.js'; +import { load_page_nodes } from './load_page_nodes.js'; /** * The maximum request depth permitted before assuming we're stuck in an infinite loop @@ -44,11 +45,7 @@ export async function render_page(event, page, options, manifest, state, resolve } try { - const nodes = await Promise.all([ - // we use == here rather than === because [undefined] serializes as "[null]" - ...page.layouts.map((n) => (n == undefined ? n : manifest._.nodes[n]())), - manifest._.nodes[page.leaf]() - ]); + const nodes = await load_page_nodes(page, manifest); const leaf_node = /** @type {import('types').SSRNode} */ (nodes.at(-1)); diff --git a/packages/kit/src/runtime/server/page/load_page_nodes.js b/packages/kit/src/runtime/server/page/load_page_nodes.js new file mode 100644 index 000000000000..2af3f6e4c2c2 --- /dev/null +++ b/packages/kit/src/runtime/server/page/load_page_nodes.js @@ -0,0 +1,11 @@ +/** + * @param {import('types').PageNodeIndexes} page + * @param {import('@sveltejs/kit').SSRManifest} manifest + */ +export function load_page_nodes(page, manifest) { + return Promise.all([ + // we use == here rather than === because [undefined] serializes as "[null]" + ...page.layouts.map((n) => (n == undefined ? n : manifest._.nodes[n]())), + manifest._.nodes[page.leaf]() + ]); +} diff --git a/packages/kit/src/runtime/server/respond.js b/packages/kit/src/runtime/server/respond.js index f80eca2dea5f..bf59ba4cfd30 100644 --- a/packages/kit/src/runtime/server/respond.js +++ b/packages/kit/src/runtime/server/respond.js @@ -31,6 +31,8 @@ import { json, text } from '../../exports/index.js'; import { action_json_redirect, is_action_json_request } from './page/actions.js'; import { INVALIDATED_PARAM, TRAILING_SLASH_PARAM } from '../shared.js'; import { get_public_env } from './env_module.js'; +import { load_page_nodes } from './page/load_page_nodes.js'; +import { get_page_config } from '../../utils/route_config.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ @@ -217,11 +219,7 @@ export async function respond(request, options, manifest, state) { if (url.pathname === base || url.pathname === base + '/') { trailing_slash = 'always'; } else if (route.page) { - const nodes = await Promise.all([ - // we use == here rather than === because [undefined] serializes as "[null]" - ...route.page.layouts.map((n) => (n == undefined ? n : manifest._.nodes[n]())), - manifest._.nodes[route.page.leaf]() - ]); + const nodes = await load_page_nodes(route.page, manifest); if (DEV) { const layouts = nodes.slice(0, -1); @@ -272,6 +270,25 @@ export async function respond(request, options, manifest, state) { }); } } + + if (DEV && state.before_handle) { + let config = {}; + + /** @type {import('types').PrerenderOption} */ + let prerender = false; + + if (route.endpoint) { + const node = await route.endpoint(); + config = node.config ?? config; + prerender = node.prerender ?? prerender; + } else if (route.page) { + const nodes = await load_page_nodes(route.page, manifest); + config = get_page_config(nodes); + prerender = get_option(nodes, 'prerender') ?? false; + } + + state.before_handle(event, config, prerender); + } } const { cookies, new_cookies, get_cookie_header, set_internal } = get_cookies( diff --git a/packages/kit/src/types/ambient-private.d.ts b/packages/kit/src/types/ambient-private.d.ts index 544c9a73b78a..4f1491475355 100644 --- a/packages/kit/src/types/ambient-private.d.ts +++ b/packages/kit/src/types/ambient-private.d.ts @@ -16,3 +16,13 @@ declare module '__sveltekit/paths' { export function override(paths: { base: string; assets: string }): void; export function set_assets(path: string): void; } + +/** Internal version of $app/server */ +declare module '__sveltekit/server' { + import { SSRManifest } from '@sveltejs/kit'; + + export let manifest: SSRManifest; + export function read_implementation(path: string): ReadableStream; + export function set_manifest(manifest: SSRManifest): void; + export function set_read_implementation(fn: (path: string) => ReadableStream): void; +} diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts index 843cc94d342b..66e1d41edd1f 100644 --- a/packages/kit/src/types/global-private.d.ts +++ b/packages/kit/src/types/global-private.d.ts @@ -4,6 +4,21 @@ declare global { const __SVELTEKIT_APP_VERSION_POLL_INTERVAL__: number; const __SVELTEKIT_DEV__: boolean; const __SVELTEKIT_EMBEDDED__: boolean; + /** + * This makes the use of specific features visible at both dev and build time, in such a + * way that we can error when they are not supported by the target platform. + * + * During dev, `globalThis.__SVELTEKIT_TRACK__` is a function that grabs the current `event` + * and route `config` (from an AsyncLocalStorage instance) and calls the relevant `supports` + * function on the adapter (e.g. `adapter.supports.read(...)`). + * + * At build time, if the function containing the `__SVELTEKIT_TRACK__` call is untreeshaken, + * we locate it in the `renderChunk` build hook and a) make a note of the chunk that contains + * it and b) replace it with a comment. Later, we can use this information to establish + * which routes use which feature, and use the same `adapter.supports.read(...)` function + * to throw an error if the feature would fail in production. + */ + var __SVELTEKIT_TRACK__: (label: string) => void; var Bun: object; var Deno: object; } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 22f7209be380..22e11e906ea8 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -13,7 +13,9 @@ import { HandleFetch, Actions, HandleClientError, - Reroute + Reroute, + RequestEvent, + SSRManifest } from '@sveltejs/kit'; import { HttpMethod, @@ -30,9 +32,11 @@ export interface ServerModule { export interface ServerInternalModule { set_assets(path: string): void; set_building(): void; + set_manifest(manifest: SSRManifest): void; set_prerendering(): void; set_private_env(environment: Record): void; set_public_env(environment: Record): void; + set_read_implementation(implementation: (path: string) => ReadableStream): void; set_safe_public_env(environment: Record): void; set_version(version: string): void; set_fix_stack_trace(fix_stack_trace: (error: unknown) => string): void; @@ -45,6 +49,7 @@ export interface Asset { } export interface AssetDependencies { + assets: string[]; file: string; imports: string[]; stylesheets: string[]; @@ -55,6 +60,7 @@ export interface BuildData { app_dir: string; app_path: string; manifest_data: ManifestData; + out_dir: string; service_worker: string | null; client: { start: string; @@ -120,12 +126,19 @@ export class InternalServer extends Server { options: RequestOptions & { prerendering?: PrerenderOptions; read: (file: string) => Buffer; + /** A hook called before `handle` during dev, so that `AsyncLocalStorage` can be populated */ + before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void; } ): Promise; } export interface ManifestData { assets: Asset[]; + hooks: { + client: string | null; + server: string | null; + universal: string | null; + }; nodes: PageNode[]; routes: RouteData[]; matchers: Record; @@ -404,6 +417,7 @@ export interface SSRState { */ prerender_default?: PrerenderOption; read?: (file: string) => Buffer; + before_handle?: (event: RequestEvent, config: any, prerender: PrerenderOption) => void; } export type StrictBody = string | ArrayBufferView; diff --git a/packages/kit/src/utils/features.js b/packages/kit/src/utils/features.js new file mode 100644 index 000000000000..4a8530d22bbb --- /dev/null +++ b/packages/kit/src/utils/features.js @@ -0,0 +1,24 @@ +/** + * @param {string} route_id + * @param {any} config + * @param {string} feature + * @param {import('@sveltejs/kit').Adapter | undefined} adapter + */ +export function check_feature(route_id, config, feature, adapter) { + if (!adapter) return; + + switch (feature) { + case '$app/server:read': { + const supported = adapter.supports?.read?.({ + route: { id: route_id }, + config + }); + + if (!supported) { + throw new Error( + `Cannot use \`read\` from \`$app/server\` in ${route_id} when using ${adapter.name}. Please ensure that your adapter is up to date and supports this feature.` + ); + } + } + } +} diff --git a/packages/kit/src/utils/route_config.js b/packages/kit/src/utils/route_config.js new file mode 100644 index 000000000000..30563018c1d5 --- /dev/null +++ b/packages/kit/src/utils/route_config.js @@ -0,0 +1,20 @@ +/** + * Do a shallow merge (first level) of the config object + * @param {Array} nodes + */ +export function get_page_config(nodes) { + /** @type {any} */ + let current = {}; + + for (const node of nodes) { + if (!node?.universal?.config && !node?.server?.config) continue; + + current = { + ...current, + ...node?.universal?.config, + ...node?.server?.config + }; + } + + return Object.keys(current).length ? current : undefined; +} diff --git a/packages/kit/test/apps/basics/src/routes/read-file/+page.server.js b/packages/kit/test/apps/basics/src/routes/read-file/+page.server.js new file mode 100644 index 000000000000..e6e59d503a8a --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/read-file/+page.server.js @@ -0,0 +1,15 @@ +import { dev } from '$app/environment'; +import { read } from '$app/server'; +import auto from './auto.txt'; +import url from './url.txt?url'; + +export async function load() { + if (!dev && !auto.startsWith('data:')) { + throw new Error('expected auto.txt to be inlined'); + } + + return { + auto: await read(auto).text(), + url: await read(url).text() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/read-file/+page.svelte b/packages/kit/test/apps/basics/src/routes/read-file/+page.svelte new file mode 100644 index 000000000000..a23efdda786c --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/read-file/+page.svelte @@ -0,0 +1,6 @@ + + +

{data.auto}

+

{data.url}

diff --git a/packages/kit/test/apps/basics/src/routes/read-file/auto.txt b/packages/kit/test/apps/basics/src/routes/read-file/auto.txt new file mode 100644 index 000000000000..f48c4ff3fa09 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/read-file/auto.txt @@ -0,0 +1 @@ +Imported without ?url diff --git a/packages/kit/test/apps/basics/src/routes/read-file/url.txt b/packages/kit/test/apps/basics/src/routes/read-file/url.txt new file mode 100644 index 000000000000..eeb75878cc2e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/read-file/url.txt @@ -0,0 +1 @@ +Imported with ?url diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index ccfdd3d6ca73..e35a7e6f8cec 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -706,6 +706,18 @@ test.describe('$app/paths', () => { }); }); +test.describe('$app/server', () => { + test('can read a file', async ({ page }) => { + await page.goto('/read-file'); + + const auto = await page.textContent('[data-testid="auto"]'); + const url = await page.textContent('[data-testid="url"]'); + + expect(auto.trim()).toBe('Imported without ?url'); + expect(url.trim()).toBe('Imported with ?url'); + }); +}); + test.describe('$app/stores', () => { test('can access page.url', async ({ baseURL, page }) => { await page.goto('/origin'); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index acb9442b783d..231e2ea8ee23 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -17,6 +17,16 @@ declare module '@sveltejs/kit' { * @param builder An object provided by SvelteKit that contains methods for adapting the app */ adapt(builder: Builder): MaybePromise; + /** + * Checks called during dev and build to determine whether specific features will work in production with this adapter + */ + supports?: { + /** + * Test support for `read` from `$app/server` + * @param config The merged route config + */ + read?: (details: { config: any; route: { id: string } }) => boolean; + }; } export type LoadProperties | void> = input extends void @@ -71,6 +81,7 @@ declare module '@sveltejs/kit' { /** An array of all routes (including prerendered) */ routes: RouteDefinition[]; + // TODO 3.0 remove this method /** * Create separate functions that map to one or more routes of your app. * @param fn A function that groups a set of routes into an entry point @@ -78,6 +89,11 @@ declare module '@sveltejs/kit' { */ createEntries(fn: (route: RouteDefinition) => AdapterEntry): Promise; + /** + * Find all the assets imported by server files belonging to `routes` + */ + findServerAssets(routes: RouteDefinition[]): string[]; + /** * Generate a fallback page for a static webserver to use when no route is matched. Useful for single-page apps. */ @@ -1126,7 +1142,10 @@ declare module '@sveltejs/kit' { } export interface ServerInitOptions { + /** A map of environment variables */ env: Record; + /** A function that turns an asset filename into a `ReadableStream`. Required for the `read` export from `$app/server` to work */ + read?: (file: string) => ReadableStream; } export interface SSRManifest { @@ -1141,6 +1160,8 @@ declare module '@sveltejs/kit' { nodes: SSRNodeLoader[]; routes: SSRRoute[]; matchers(): Promise>; + /** A `[file]: size` map of all assets imported by server code */ + server_assets: Record; }; } @@ -1549,6 +1570,7 @@ declare module '@sveltejs/kit' { app_dir: string; app_path: string; manifest_data: ManifestData; + out_dir: string; service_worker: string | null; client: { start: string; @@ -1563,6 +1585,11 @@ declare module '@sveltejs/kit' { interface ManifestData { assets: Asset[]; + hooks: { + client: string | null; + server: string | null; + universal: string | null; + }; nodes: PageNode[]; routes: RouteData[]; matchers: Record; @@ -1873,6 +1900,11 @@ declare module '@sveltejs/kit/node' { }): Promise; export function setResponse(res: import('http').ServerResponse, response: Response): Promise; + /** + * Converts a file on disk to a readable stream + * @since 2.4.0 + */ + export function createReadableStream(file: string): ReadableStream; } declare module '@sveltejs/kit/node/polyfills' { @@ -2107,6 +2139,22 @@ declare module '$app/paths' { export function resolveRoute(id: string, params: Record): string; } +declare module '$app/server' { + /** + * Read the contents of an imported asset from the filesystem + * @example + * ```js + * import { read } from '$app/server'; + * import somefile from './somefile.txt'; + * + * const asset = read(somefile); + * const text = await asset.text(); + * ``` + * @since 2.4.0 + */ + export function read(asset: string): Response; +} + declare module '$app/stores' { export function getStores(): { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0976bf822cc9..bed64323f968 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -289,7 +289,7 @@ importers: dependencies: '@sveltejs/kit': specifier: ^1.0.0 || ^2.0.0 - version: link:../kit + version: 2.3.5(@sveltejs/vite-plugin-svelte@3.0.1)(svelte@4.2.8)(vite@5.0.11) packages/create-svelte: dependencies: @@ -1266,9 +1266,6 @@ importers: svelte: specifier: ^4.2.8 version: 4.2.8 - tiny-glob: - specifier: ^0.2.9 - version: 0.2.9 typescript: specifier: 5.0.4 version: 5.0.4 @@ -2317,6 +2314,33 @@ packages: typescript: 5.3.3 dev: true + /@sveltejs/kit@2.3.5(@sveltejs/vite-plugin-svelte@3.0.1)(svelte@4.2.8)(vite@5.0.11): + resolution: {integrity: sha512-Vu+ckGQu/t+pcuueryaHYacgoYsYq6KB1k1p4w1xDp1eQ2aYz3POcx3GSuqxWjJfOwMMM83xiuAnDWBKyOn1Tg==} + engines: {node: '>=18.13'} + hasBin: true + requiresBuild: true + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^3.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + vite: ^5.0.3 + dependencies: + '@sveltejs/vite-plugin-svelte': 3.0.1(svelte@4.2.8)(vite@5.0.11) + '@types/cookie': 0.6.0 + cookie: 0.6.0 + devalue: 4.3.2 + esm-env: 1.0.0 + import-meta-resolve: 4.0.0 + kleur: 4.1.5 + magic-string: 0.30.5 + mrmime: 2.0.0 + sade: 1.8.1 + set-cookie-parser: 2.6.0 + sirv: 2.0.4 + svelte: 4.2.8 + tiny-glob: 0.2.9 + vite: 5.0.11(@types/node@18.19.3)(lightningcss@1.22.1) + dev: false + /@sveltejs/site-kit@6.0.0-next.59(@sveltejs/kit@packages+kit)(svelte@4.2.8): resolution: {integrity: sha512-nAUCuunhN0DmurQBxbsauqvdvv4mL0F/Aluxq0hFf6gB3iSn9WdaUZdPMXoujy+8cy+m6UvKuyhkgApZhmOLvw==} peerDependencies: @@ -2343,7 +2367,6 @@ packages: vite: 5.0.11(@types/node@18.19.3)(lightningcss@1.22.1) transitivePeerDependencies: - supports-color - dev: true /@sveltejs/vite-plugin-svelte@3.0.1(svelte@4.2.8)(vite@5.0.11): resolution: {integrity: sha512-CGURX6Ps+TkOovK6xV+Y2rn8JKa8ZPUHPZ/NKgCxAmgBrXReavzFl8aOSCj3kQ1xqT7yGJj53hjcV/gqwDAaWA==} @@ -2363,7 +2386,6 @@ packages: vitefu: 0.2.5(vite@5.0.11) transitivePeerDependencies: - supports-color - dev: true /@svitejs/changesets-changelog-github-compact@1.1.0: resolution: {integrity: sha512-qhUGGDHcpbY2zpjW3SwqchuW8J/5EzlPFud7xNntHKA7f3a/mx5+g+ruJKFHSAiVZYo30PALt+AyhmPUNKH/Og==} @@ -2435,7 +2457,6 @@ packages: resolution: {integrity: sha512-k5fggr14DwAytoA/t8rPrIz++lXK7/DqckthCmoZOKNsEbJkId4Z//BqgApXBUGrGddrigYa1oqheo/7YmW4rg==} dependencies: undici-types: 5.26.5 - dev: true /@types/normalize-package-data@2.4.4: resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -3354,7 +3375,6 @@ packages: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} hasBin: true - dev: true /detect-libc@2.0.2: resolution: {integrity: sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==} @@ -4517,7 +4537,6 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true - dev: true optional: true /lightningcss-darwin-x64@1.22.1: @@ -4526,7 +4545,6 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true - dev: true optional: true /lightningcss-freebsd-x64@1.22.1: @@ -4535,7 +4553,6 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true - dev: true optional: true /lightningcss-linux-arm-gnueabihf@1.22.1: @@ -4544,7 +4561,6 @@ packages: cpu: [arm] os: [linux] requiresBuild: true - dev: true optional: true /lightningcss-linux-arm64-gnu@1.22.1: @@ -4553,7 +4569,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: true optional: true /lightningcss-linux-arm64-musl@1.22.1: @@ -4562,7 +4577,6 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true - dev: true optional: true /lightningcss-linux-x64-gnu@1.22.1: @@ -4571,7 +4585,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /lightningcss-linux-x64-musl@1.22.1: @@ -4580,7 +4593,6 @@ packages: cpu: [x64] os: [linux] requiresBuild: true - dev: true optional: true /lightningcss-win32-x64-msvc@1.22.1: @@ -4589,7 +4601,6 @@ packages: cpu: [x64] os: [win32] requiresBuild: true - dev: true optional: true /lightningcss@1.22.1: @@ -4607,7 +4618,6 @@ packages: lightningcss-linux-x64-gnu: 1.22.1 lightningcss-linux-x64-musl: 1.22.1 lightningcss-win32-x64-msvc: 1.22.1 - dev: true /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} @@ -4889,7 +4899,6 @@ packages: resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - dev: true /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -5279,7 +5288,6 @@ packages: nanoid: 3.3.7 picocolors: 1.0.0 source-map-js: 1.0.2 - dev: true /preferred-pm@3.1.2: resolution: {integrity: sha512-nk7dKrcW8hfCZ4H6klWcdRknBOXWzNQByJ0oJyX97BOupsYD+FzLS4hflgEu/uPUEHZCuRfMxzCBsuWd7OzT8Q==} @@ -5978,7 +5986,6 @@ packages: svelte: ^3.19.0 || ^4.0.0 dependencies: svelte: 4.2.8 - dev: true /svelte-local-storage-store@0.6.4(svelte@4.2.8): resolution: {integrity: sha512-45WoY2vSGPQM1sIQJ9jTkPPj20hYeqm+af6mUGRFSPP5WglZf36YYoZqwmZZ8Dt/2SU8lem+BTA8/Z/8TkqNLg==} @@ -6316,7 +6323,6 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} - dev: true /universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} @@ -6440,7 +6446,6 @@ packages: rollup: 4.9.5 optionalDependencies: fsevents: 2.3.3 - dev: true /vitefu@0.2.5(vite@5.0.11): resolution: {integrity: sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==} @@ -6451,7 +6456,6 @@ packages: optional: true dependencies: vite: 5.0.11(@types/node@18.19.3)(lightningcss@1.22.1) - dev: true /vitest@1.2.0(@types/node@18.19.3)(lightningcss@1.22.1): resolution: {integrity: sha512-Ixs5m7BjqvLHXcibkzKRQUvD/XLw0E3rvqaCMlrm/0LMsA0309ZqYvTlPzkhh81VlEyVZXFlwWnkhb6/UMtcaQ==} diff --git a/sites/kit.svelte.dev/package.json b/sites/kit.svelte.dev/package.json index fa9fc16254b7..c782f65b42f0 100644 --- a/sites/kit.svelte.dev/package.json +++ b/sites/kit.svelte.dev/package.json @@ -31,7 +31,6 @@ "prismjs": "^1.29.0", "shiki-twoslash": "^3.1.2", "svelte": "^4.2.8", - "tiny-glob": "^0.2.9", "typescript": "5.0.4", "vite": "^5.0.11", "vitest": "^1.2.0" diff --git a/sites/kit.svelte.dev/src/constants.js b/sites/kit.svelte.dev/src/constants.js deleted file mode 100644 index a50a135a6641..000000000000 --- a/sites/kit.svelte.dev/src/constants.js +++ /dev/null @@ -1,6 +0,0 @@ -export const CONTENT_BASE = '../../documentation'; - -/** All the paths are relative to the project root when being run on server or built */ -export const CONTENT_BASE_PATHS = { - DOCS: `${CONTENT_BASE}/docs` -}; diff --git a/sites/kit.svelte.dev/src/lib/server/docs/index.js b/sites/kit.svelte.dev/src/lib/server/docs/index.js index d78eba9b767b..c0f721e719e4 100644 --- a/sites/kit.svelte.dev/src/lib/server/docs/index.js +++ b/sites/kit.svelte.dev/src/lib/server/docs/index.js @@ -1,4 +1,4 @@ -import { base as app_base } from '$app/paths'; +import { base } from '$app/paths'; import { modules } from '$lib/generated/type-info.js'; import { escape, @@ -8,108 +8,97 @@ import { removeMarkdown, replaceExportTypePlaceholders } from '@sveltejs/site-kit/markdown'; -import { readFile, readdir } from 'node:fs/promises'; -import { CONTENT_BASE_PATHS } from '../../../constants.js'; import { render_content } from '../renderer'; +import { read } from '$app/server'; +import { error } from '@sveltejs/kit'; -/** - * @param {import('./types.js').DocsData} docs_data - * @param {string} slug - */ -export async function get_parsed_docs(docs_data, slug) { - for (const { pages } of docs_data) { - for (const page of pages) { - if (page.slug === slug) { - return { - ...page, - content: await render_content(page.file, page.content) - }; - } - } - } -} - -/** @return {Promise} */ -export async function get_docs_data(base = CONTENT_BASE_PATHS.DOCS) { - /** @type {import('./types.js').DocsData} */ - const docs_data = []; - - for (const category_dir of await readdir(base)) { - const match = /\d{2}-(.+)/.exec(category_dir); - if (!match) continue; - - const category_slug = match[1]; - - // Read the meta.json - const { title: category_title, draft = 'false' } = JSON.parse( - await readFile(`${base}/${category_dir}/meta.json`, 'utf-8') - ); +const meta = import.meta.glob('../../../../../../documentation/docs/*/meta.json', { + as: 'url', + eager: true +}); - if (draft === 'true') continue; +const markdown = import.meta.glob('../../../../../../documentation/docs/*/*.md', { + as: 'url', + eager: true +}); - /** @type {import('./types.js').Category} */ - const category = { - title: category_title, - slug: category_slug, - pages: [] - }; +export const categories = {}; +export const pages = {}; - for (const page_md of (await readdir(`${base}/${category_dir}`)).filter( - (filename) => filename !== 'meta.json' - )) { - const match = /\d{2}-(.+)/.exec(page_md); - if (!match) continue; +for (const [file, asset] of Object.entries(meta)) { + const slug = /\/\d{2}-(.+)\/meta\.json$/.exec(file)[1]; - const page_slug = match[1].replace('.md', ''); + const { title, draft } = await read(asset).json(); - const page_data = extractFrontmatter( - await readFile(`${base}/${category_dir}/${page_md}`, 'utf-8') - ); + if (draft) continue; - if (page_data.metadata.draft === 'true') continue; - - const page_title = page_data.metadata.title; - const page_content = page_data.body; - - category.pages.push({ - title: page_title, - slug: page_slug, - content: page_content, - category: category_title, - sections: await get_sections(page_content), - path: `${app_base}/docs/${page_slug}`, - file: `${category_dir}/${page_md}` - }); - } + categories[slug] = { + title, + pages: [] + }; +} - docs_data.push(category); - } +for (const [file, asset] of Object.entries(markdown)) { + const [, category_dir, basename] = /\/(\d{2}-.+?)\/(\d{2}-.+\.md)$/.exec(file); + const category_slug = category_dir.slice(3); + const slug = basename.slice(3, -3); // strip the number prefix and .md suffix + + const category = categories[category_slug]; + if (!category) continue; // draft + + const { + metadata: { draft, title, rank }, + body + } = extractFrontmatter(await read(asset).text()); + + if (draft === 'true') continue; + + category.pages.push({ + title, + path: `${base}/docs/${slug}` + }); + + pages[slug] = { + rank: +rank || undefined, + category: category.title, + title, + file: `${category_dir}/${basename}`, + sections: await get_sections(body), + body + }; +} - return docs_data; +/** @param {string} slug */ +export async function get_parsed_docs(slug) { + const page = pages[slug]; + if (!page) error(404); + + // TODO this should probably use a type from site-kit + return { + category: page.category, + title: page.title, + file: page.file, + sections: page.sections, + content: await render_content(page.file, page.body) + }; } -/** @param {import('./types.js').DocsData} docs_data */ -export function get_docs_list(docs_data) { - return docs_data.map((category) => ({ - title: category.title, - pages: category.pages.map((page) => ({ - title: page.title, - path: page.path - })) - })); +export function get_docs_list() { + return Object.values(categories); } /** @param {string} markdown */ async function get_sections(markdown) { - const headingRegex = /^##\s+(.*)$/gm; /** @type {import('./types.js').Section[]} */ - const secondLevelHeadings = []; + const second_level_headings = []; + + const pattern = /^##\s+(.*)$/gm; let match; const placeholders_rendered = await replaceExportTypePlaceholders(markdown, modules); - while ((match = headingRegex.exec(placeholders_rendered)) !== null) { - secondLevelHeadings.push({ + while ((match = pattern.exec(placeholders_rendered)) !== null) { + second_level_headings.push({ title: removeMarkdown( escape(await markedTransform(match[1], { paragraph: (txt) => txt })) .replace(/<\/?code>/g, '') @@ -123,5 +112,5 @@ async function get_sections(markdown) { }); } - return secondLevelHeadings; + return second_level_headings; } diff --git a/sites/kit.svelte.dev/src/routes/+layout.server.js b/sites/kit.svelte.dev/src/routes/+layout.server.js index 274c04f7f124..8479779fcf81 100644 --- a/sites/kit.svelte.dev/src/routes/+layout.server.js +++ b/sites/kit.svelte.dev/src/routes/+layout.server.js @@ -3,7 +3,7 @@ import { fetchBanner } from '@sveltejs/site-kit/components'; export const prerender = true; export const load = async ({ url, fetch }) => { - const nav_links = fetch('/nav.json').then((r) => r.json()); + const nav_links = fetch('/nav.json').then((r) => r.json()); // TODO why is this behind a `fetch`? does `nav.json` need to be exposed publicly? const banner = fetchBanner('kit.svelte.dev', fetch); return { diff --git a/sites/kit.svelte.dev/src/routes/content.json/content.server.js b/sites/kit.svelte.dev/src/routes/content.json/content.server.js index b24e7ea2ae50..788b95f65dc7 100644 --- a/sites/kit.svelte.dev/src/routes/content.json/content.server.js +++ b/sites/kit.svelte.dev/src/routes/content.json/content.server.js @@ -1,13 +1,10 @@ import { modules } from '$lib/generated/type-info.js'; +import { pages } from '$lib/server/docs/index.js'; import { - extractFrontmatter, markedTransform, replaceExportTypePlaceholders, slugify } from '@sveltejs/site-kit/markdown'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import glob from 'tiny-glob'; const categories = [ { @@ -25,27 +22,15 @@ export async function content() { for (const category of categories) { const breadcrumbs = category.label ? [category.label] : []; - for (const file of await glob('**/*.md', { cwd: `../../documentation/${category.slug}` })) { - const basename = path.basename(file); - const match = /\d{2}-(.+)\.md/.exec(basename); - if (!match) continue; - - const slug = match[1]; - - const filepath = `../../documentation/${category.slug}/${file}`; - const markdown = await replaceExportTypePlaceholders( - await readFile(filepath, 'utf-8'), - modules - ); - - const { body, metadata } = extractFrontmatter(markdown); + for (const [slug, page] of Object.entries(pages)) { + const body = await replaceExportTypePlaceholders(page.body, modules); const sections = body.trim().split(/^## /m); const intro = sections.shift().trim(); - const rank = +metadata.rank || undefined; + const rank = page.rank; blocks.push({ - breadcrumbs: [...breadcrumbs, metadata.title], + breadcrumbs: [...breadcrumbs, page.title], href: category.href([slug]), content: await plaintext(intro), rank @@ -61,7 +46,7 @@ export async function content() { const intro = subsections.shift().trim(); blocks.push({ - breadcrumbs: [...breadcrumbs, metadata.title, h3], + breadcrumbs: [...breadcrumbs, page.title, h3], href: category.href([slug, slugify(h3)]), content: await plaintext(intro), rank @@ -72,7 +57,7 @@ export async function content() { const h4 = lines.shift(); blocks.push({ - breadcrumbs: [...breadcrumbs, metadata.title, h3, h4], + breadcrumbs: [...breadcrumbs, page.title, h3, h4], href: category.href([slug, slugify(h3), slugify(h4)]), content: await plaintext(lines.join('\n').trim()), rank diff --git a/sites/kit.svelte.dev/src/routes/docs/+layout.server.js b/sites/kit.svelte.dev/src/routes/docs/+layout.server.js index 2e3ec09c82c9..77f6b8ffcfb1 100644 --- a/sites/kit.svelte.dev/src/routes/docs/+layout.server.js +++ b/sites/kit.svelte.dev/src/routes/docs/+layout.server.js @@ -1,9 +1,9 @@ -import { get_docs_data, get_docs_list } from '$lib/server/docs/index.js'; +import { get_docs_list } from '$lib/server/docs/index.js'; export const prerender = true; export async function load() { return { - sections: get_docs_list(await get_docs_data()) + sections: get_docs_list() }; } diff --git a/sites/kit.svelte.dev/src/routes/docs/+layout.svelte b/sites/kit.svelte.dev/src/routes/docs/+layout.svelte index 2b328c7fad91..d95c0a861249 100644 --- a/sites/kit.svelte.dev/src/routes/docs/+layout.svelte +++ b/sites/kit.svelte.dev/src/routes/docs/+layout.svelte @@ -4,8 +4,7 @@ export let data; - $: pageData = $page.data.page; - $: category = pageData?.category; + $: category = $page.data.page?.category;
diff --git a/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.server.js b/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.server.js index fb156a880399..5d0fdb672a75 100644 --- a/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.server.js +++ b/sites/kit.svelte.dev/src/routes/docs/[slug]/+page.server.js @@ -1,12 +1,7 @@ -import { get_docs_data, get_parsed_docs } from '$lib/server/docs/index.js'; -import { error } from '@sveltejs/kit'; +import { get_parsed_docs } from '$lib/server/docs/index.js'; export const prerender = true; export async function load({ params }) { - const processed_page = await get_parsed_docs(await get_docs_data(), params.slug); - - if (!processed_page) error(404); - - return { page: processed_page }; + return { page: await get_parsed_docs(params.slug) }; } diff --git a/sites/kit.svelte.dev/src/routes/nav.json/+server.js b/sites/kit.svelte.dev/src/routes/nav.json/+server.js index f4752b31a056..c6f78ca01153 100644 --- a/sites/kit.svelte.dev/src/routes/nav.json/+server.js +++ b/sites/kit.svelte.dev/src/routes/nav.json/+server.js @@ -1,23 +1,13 @@ -import { get_docs_data, get_docs_list } from '$lib/server/docs/index.js'; +import { get_docs_list } from '$lib/server/docs/index.js'; import { json } from '@sveltejs/kit'; export const prerender = true; export const GET = async () => { - return json(await get_nav_list()); -}; - -/** - * @returns {Promise} - */ -async function get_nav_list() { - const docs_list = get_docs_list(await get_docs_data()); - const processed_docs_list = docs_list.map(({ title, pages }) => ({ - title, - sections: pages.map(({ title, path }) => ({ title, path })) - })); + const docs_list = get_docs_list(); - return [ + /** @type {import('@sveltejs/site-kit').NavigationLink[]} */ + const nav_list = [ { title: 'Docs', prefix: 'docs', @@ -25,9 +15,14 @@ async function get_nav_list() { sections: [ { title: 'DOCS', - sections: processed_docs_list + sections: docs_list.map(({ title, pages }) => ({ + title, + sections: pages.map(({ title, path }) => ({ title, path })) + })) } ] } ]; -} + + return json(nav_list); +};