diff --git a/packages/adapter-cloudflare/index.js b/packages/adapter-cloudflare/index.js index fdbdf9a769ed..647bb9cdda13 100644 --- a/packages/adapter-cloudflare/index.js +++ b/packages/adapter-cloudflare/index.js @@ -113,6 +113,13 @@ export default function (options = {}) { ASSETS: assets_binding } }); + if (builder.hasServerTracingFile()) { + builder.trace({ + entrypoint: worker_dest, + tracing: `${builder.getServerDirectory()}/tracing.server.js`, + tla: false + }); + } // _headers if (existsSync('_headers')) { @@ -184,7 +191,8 @@ export default function (options = {}) { } return true; - } + }, + tracing: () => true } }; } diff --git a/packages/adapter-netlify/index.js b/packages/adapter-netlify/index.js index 876d57f3372c..59d6db2bc84d 100644 --- a/packages/adapter-netlify/index.js +++ b/packages/adapter-netlify/index.js @@ -1,3 +1,4 @@ +/** @import { BuildOptions } from 'esbuild' */ import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve, posix } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -106,7 +107,8 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) { } return true; - } + }, + tracing: () => true } }; } @@ -174,9 +176,8 @@ async function generate_edge_functions({ builder }) { version: 1 }; - await esbuild.build({ - entryPoints: [`${tmp}/entry.js`], - outfile: '.netlify/edge-functions/render.js', + /** @type {BuildOptions} */ + const esbuild_config = { bundle: true, format: 'esm', platform: 'browser', @@ -194,7 +195,29 @@ async function generate_edge_functions({ builder }) { // https://docs.netlify.com/edge-functions/api/#runtime-environment external: builtinModules.map((id) => `node:${id}`), alias: Object.fromEntries(builtinModules.map((id) => [id, `node:${id}`])) - }); + }; + await Promise.all([ + esbuild.build({ + entryPoints: [`${tmp}/entry.js`], + outfile: '.netlify/edge-functions/render.js', + ...esbuild_config + }), + builder.hasServerTracingFile() && + esbuild.build({ + entryPoints: [`${builder.getServerDirectory()}/tracing.server.js`], + outfile: '.netlify/edge/tracing.server.js', + ...esbuild_config + }) + ]); + + if (builder.hasServerTracingFile()) { + builder.trace({ + entrypoint: '.netlify/edge-functions/render.js', + tracing: '.netlify/edge/tracing.server.js', + tla: false, + start: '.netlify/edge/start.js' + }); + } writeFileSync('.netlify/edge-functions/manifest.json', JSON.stringify(edge_manifest)); } @@ -272,6 +295,14 @@ function generate_lambda_functions({ builder, publish, split }) { writeFileSync(`.netlify/functions-internal/${name}.mjs`, fn); writeFileSync(`.netlify/functions-internal/${name}.json`, fn_config); + if (builder.hasServerTracingFile()) { + builder.trace({ + entrypoint: `.netlify/functions-internal/${name}.mjs`, + tracing: '.netlify/server/tracing.server.js', + start: `.netlify/functions-start/${name}.start.mjs`, + exports: ['handler'] + }); + } const redirect = `/.netlify/functions/${name} 200`; redirects.push(`${pattern} ${redirect}`); @@ -286,6 +317,15 @@ function generate_lambda_functions({ builder, publish, split }) { writeFileSync(`.netlify/functions-internal/${FUNCTION_PREFIX}render.json`, fn_config); writeFileSync(`.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, fn); + if (builder.hasServerTracingFile()) { + builder.trace({ + entrypoint: `.netlify/functions-internal/${FUNCTION_PREFIX}render.mjs`, + tracing: '.netlify/server/tracing.server.js', + start: `.netlify/functions-start/${FUNCTION_PREFIX}render.start.mjs`, + exports: ['handler'] + }); + } + redirects.push(`* /.netlify/functions/${FUNCTION_PREFIX}render 200`); } diff --git a/packages/adapter-node/index.js b/packages/adapter-node/index.js index 9b0b3158ab82..7ac3fcea1b06 100644 --- a/packages/adapter-node/index.js +++ b/packages/adapter-node/index.js @@ -48,14 +48,21 @@ export default function (opts = {}) { const pkg = JSON.parse(readFileSync('package.json', 'utf8')); + /** @type {Record} */ + const input = { + index: `${tmp}/index.js`, + manifest: `${tmp}/manifest.js` + }; + + if (builder.hasServerTracingFile()) { + input['tracing.server'] = `${tmp}/tracing.server.js`; + } + // we bundle the Vite output so that deployments only need // their production dependencies. Anything in devDependencies // will get included in the bundled code const bundle = await rollup({ - input: { - index: `${tmp}/index.js`, - manifest: `${tmp}/manifest.js` - }, + input, external: [ // dependencies could have deep exports, so we need a regex ...Object.keys(pkg.dependencies || {}).map((d) => new RegExp(`^${d}(\\/.*)?$`)) @@ -89,10 +96,19 @@ export default function (opts = {}) { ENV_PREFIX: JSON.stringify(envPrefix) } }); + + if (builder.hasServerTracingFile()) { + builder.trace({ + entrypoint: `${out}/index.js`, + tracing: `${out}/server/tracing.server.js`, + exports: ['path', 'host', 'port', 'server'] + }); + } }, supports: { - read: () => true + read: () => true, + tracing: () => true } }; } diff --git a/packages/adapter-vercel/index.js b/packages/adapter-vercel/index.js index 1586ef327d10..055b7e205d5a 100644 --- a/packages/adapter-vercel/index.js +++ b/packages/adapter-vercel/index.js @@ -1,3 +1,4 @@ +/** @import { BuildOptions } from 'esbuild' */ import fs from 'node:fs'; import path from 'node:path'; import process from 'node:process'; @@ -93,13 +94,18 @@ const plugin = function (defaults = {}) { const dir = `${dirs.functions}/${name}.func`; const relativePath = path.posix.relative(tmp, builder.getServerDirectory()); - builder.copy(`${files}/serverless.js`, `${tmp}/index.js`, { replace: { SERVER: `${relativePath}/index.js`, MANIFEST: './manifest.js' } }); + if (builder.hasServerTracingFile()) { + builder.trace({ + entrypoint: `${tmp}/index.js`, + tracing: `${builder.getServerDirectory()}/tracing.server.js` + }); + } write( `${tmp}/manifest.js`, @@ -136,9 +142,9 @@ const plugin = function (defaults = {}) { ); try { - const result = await esbuild.build({ - entryPoints: [`${tmp}/edge.js`], - outfile: `${dirs.functions}/${name}.func/index.js`, + const outdir = `${dirs.functions}/${name}.func`; + /** @type {BuildOptions} */ + const esbuild_config = { // minimum Node.js version supported is v14.6.0 that is mapped to ES2019 // https://edge-runtime.vercel.app/features/polyfills // TODO verify the latest ES version the edge runtime supports @@ -168,10 +174,34 @@ const plugin = function (defaults = {}) { '.eot': 'copy', '.otf': 'copy' } + }; + const result = await esbuild.build({ + entryPoints: [`${tmp}/edge.js`], + outfile: `${outdir}/index.js`, + ...esbuild_config }); - if (result.warnings.length > 0) { - const formatted = await esbuild.formatMessages(result.warnings, { + let instrumentation_result; + if (builder.hasServerTracingFile()) { + instrumentation_result = await esbuild.build({ + entryPoints: [`${builder.getServerDirectory()}/tracing.server.js`], + outfile: `${outdir}/tracing.server.js`, + ...esbuild_config + }); + + builder.trace({ + entrypoint: `${outdir}/index.js`, + tracing: `${outdir}/tracing.server.js`, + tla: false + }); + } + + const warnings = instrumentation_result + ? [...result.warnings, ...instrumentation_result.warnings] + : result.warnings; + + if (warnings.length > 0) { + const formatted = await esbuild.formatMessages(warnings, { kind: 'warning', color: true }); @@ -477,7 +507,8 @@ const plugin = function (defaults = {}) { } return true; - } + }, + tracing: () => true } }; }; diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 69f67ff3a879..57f5025822a4 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -1,6 +1,10 @@ +/** @import { Builder } from '@sveltejs/kit' */ +/** @import { ResolvedConfig } from 'vite' */ +/** @import { RouteDefinition } from '@sveltejs/kit' */ +/** @import { RouteData, ValidatedConfig, BuildData, ServerMetadata, ServerMetadataRoute, Prerendered, PrerenderMap, Logger } from 'types' */ import colors from 'kleur'; import { createReadStream, createWriteStream, existsSync, statSync } from 'node:fs'; -import { extname, resolve } from 'node:path'; +import { extname, resolve, join, dirname, relative } from 'node:path'; import { pipeline } from 'node:stream'; import { promisify } from 'node:util'; import zlib from 'node:zlib'; @@ -12,6 +16,7 @@ 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'; +import { reserved } from '../env.js'; const pipe = promisify(pipeline); const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.wasm']; @@ -19,16 +24,16 @@ const extensions = ['.html', '.js', '.mjs', '.json', '.css', '.svg', '.xml', '.w /** * Creates the Builder which is passed to adapters for building the application. * @param {{ - * config: import('types').ValidatedConfig; - * build_data: import('types').BuildData; - * server_metadata: import('types').ServerMetadata; - * route_data: import('types').RouteData[]; - * prerendered: import('types').Prerendered; - * prerender_map: import('types').PrerenderMap; - * log: import('types').Logger; - * vite_config: import('vite').ResolvedConfig; + * config: ValidatedConfig; + * build_data: BuildData; + * server_metadata: ServerMetadata; + * route_data: RouteData[]; + * prerendered: Prerendered; + * prerender_map: PrerenderMap; + * log: Logger; + * vite_config: ResolvedConfig; * }} opts - * @returns {import('@sveltejs/kit').Builder} + * @returns {Builder} */ export function create_builder({ config, @@ -40,7 +45,7 @@ export function create_builder({ log, vite_config }) { - /** @type {Map} */ + /** @type {Map} */ const lookup = new Map(); /** @@ -48,11 +53,11 @@ export function create_builder({ * we expose a stable type that adapters can use to group/filter routes */ const routes = route_data.map((route) => { - const { config, methods, page, api } = /** @type {import('types').ServerMetadataRoute} */ ( + const { config, methods, page, api } = /** @type {ServerMetadataRoute} */ ( server_metadata.routes.get(route.id) ); - /** @type {import('@sveltejs/kit').RouteDefinition} */ + /** @type {RouteDefinition} */ const facade = { id: route.id, api, @@ -229,6 +234,37 @@ export function create_builder({ writeServer(dest) { return copy(`${config.kit.outDir}/output/server`, dest); + }, + + hasServerTracingFile() { + return existsSync(`${config.kit.outDir}/output/server/tracing.server.js`); + }, + + trace({ + entrypoint, + tracing, + start = join(dirname(entrypoint), 'start.js'), + tla = true, + exports = ['default'] + }) { + if (!existsSync(tracing)) { + throw new Error( + `Tracing file ${tracing} not found. This is probably a bug in your adapter.` + ); + } + if (!existsSync(entrypoint)) { + throw new Error( + `Entrypoint file ${entrypoint} not found. This is probably a bug in your adapter.` + ); + } + + copy(entrypoint, start); + if (existsSync(`${entrypoint}.map`)) { + copy(`${entrypoint}.map`, `${start}.map`); + } + + rimraf(entrypoint); + write(entrypoint, create_tracing_facade({ entrypoint, tracing, start, exports, tla })); } }; } @@ -254,3 +290,78 @@ async function compress_file(file, format = 'gz') { await pipe(source, compress, destination); } + +/** + * Given a list of exports, generate a facade that: + * - Imports the tracing file + * - Imports `exports` from the entrypoint (dynamically, if `tla` is true) + * - Re-exports `exports` from the entrypoint + * + * `default` receives special treatment: It will be imported as `default` and exported with `export default`. + * + * @param {Required[0]>} opts + * @returns {string} + */ +function create_tracing_facade({ entrypoint, tracing, start, exports, tla }) { + const relative_tracing = relative(dirname(entrypoint), tracing); + const relative_start = relative(dirname(entrypoint), start); + const import_tracing = `import './${relative_tracing}';`; + + let alias_index = 0; + const aliases = new Map(); + + for (const name of exports.filter((name) => reserved.has(name))) { + /* + you can do evil things like `export { c as class }`. + in order to import these, you need to alias them, and then un-alias them when re-exporting + this map will allow us to generate the following: + import { class as _1 } from 'entrypoint'; + export { _1 as class }; + */ + let alias = `_${alias_index++}`; + while (exports.includes(alias)) { + alias = `_${alias_index++}`; + } + + aliases.set(name, alias); + } + + const import_statements = []; + const export_statements = []; + + for (const name of exports) { + const alias = aliases.get(name); + if (alias) { + if (tla) { + // TLA generates a `const {} = await import('entrypoint')` so we need to use object destructuring + import_statements.push(`${name}: ${alias}`); + } else { + // non-TLA generates a `import { name as alias } from 'entrypoint'` + import_statements.push(`${name} as ${alias}`); + } + + if (name !== 'default') { + export_statements.push(`${alias} as ${name}`); + } + } else { + import_statements.push(`${name}`); + + if (name !== 'default') { + export_statements.push(`${name}`); + } + } + } + + const default_alias = aliases.get('default'); + const entrypoint_facade = [ + tla + ? `const { ${import_statements.join(', ')} } = await import('./${relative_start}');` + : `import { ${import_statements.join(', ')} } from './${relative_start}';`, + default_alias ? `export default ${default_alias};` : '', + export_statements.length > 0 ? `export { ${export_statements.join(', ')} };` : '' + ] + .filter(Boolean) + .join('\n'); + + return `${import_tracing}\n${entrypoint_facade}`; +} diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index 0db0c8b4f67b..38b6fb36599f 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -194,6 +194,45 @@ export interface Builder { } ) => string[]; + /** + * Check if the server tracing file exists. + * @returns true if the server tracing file exists, false otherwise + */ + hasServerTracingFile: () => boolean; + + /** + * Trace `entrypoint` with `tracing`. + * + * Renames `entrypoint` to `start` and creates a new module at + * `entrypoint` which imports `tracing` and then dynamically imports `start`. This allows + * the module hooks necessary for tracing libraries to be loaded prior to any application code. + * + * Caveats: + * - "Live exports" will not work. If your adapter uses live exports, your users will need to manually import the server instrumentation on startup. + * - If `tla` is `false`, OTEL auto-instrumentation may not work properly. Use it if your environment supports it. + * - Use {@link hasServerTracingFile} to check if the user has a server tracing file; if they don't, you shouldn't do this. + * + * @param options an object containing the following properties: + * @param options.entrypoint the path to the entrypoint to trace. + * @param options.tracing the path to the tracing file. + * @param options.start the name of the start file. This is what `entrypoint` will be renamed to. + * @param options.tla Whether to use top-level await. If `true`, the `tracing` file will be statically imported and then the `start` file will be dynamically imported. If `false`, both files will be serially imported. Auto-instrumentation will not work properly without a dynamic `await`. + * @param options.exports an array of exports to re-export from the entrypoint. `default` represents the default export. Defaults to `['default']`. + */ + trace: ({ + entrypoint, + tracing, + start, + tla, + exports + }: { + entrypoint: string; + tracing: string; + start?: string; + tla?: boolean; + exports?: string[]; + }) => void; + /** * Compress files in `directory` with gzip and brotli, where appropriate. Generates `.gz` and `.br` files alongside the originals. * @param {string} directory The directory containing the files to be compressed diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index a2dea78501b0..cd31d6645d74 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -739,6 +739,16 @@ Tips: input[name] = path.resolve(file); }); + // ...and the server tracing file + const server_tracing = resolve_entry(kit.files.tracing.server); + if (server_tracing) { + const { adapter } = kit; + if (adapter && !adapter.supports?.tracing?.()) { + throw new Error(`${server_tracing} is unsupported in ${adapter.name}.`); + } + input['tracing.server'] = server_tracing; + } + // ...and every .remote file for (const remote of manifest_data.remotes) { input[`remote/${remote.hash}`] = path.resolve(remote.file); diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index 38524b63542d..0be2f2beff48 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -171,6 +171,45 @@ declare module '@sveltejs/kit' { } ) => string[]; + /** + * Check if the server tracing file exists. + * @returns true if the server tracing file exists, false otherwise + */ + hasServerTracingFile: () => boolean; + + /** + * Trace `entrypoint` with `tracing`. + * + * Renames `entrypoint` to `start` and creates a new module at + * `entrypoint` which imports `tracing` and then dynamically imports `start`. This allows + * the module hooks necessary for tracing libraries to be loaded prior to any application code. + * + * Caveats: + * - "Live exports" will not work. If your adapter uses live exports, your users will need to manually import the server instrumentation on startup. + * - If `tla` is `false`, OTEL auto-instrumentation may not work properly. Use it if your environment supports it. + * - Use {@link hasServerTracingFile} to check if the user has a server tracing file; if they don't, you shouldn't do this. + * + * @param options an object containing the following properties: + * @param options.entrypoint the path to the entrypoint to trace. + * @param options.tracing the path to the tracing file. + * @param options.start the name of the start file. This is what `entrypoint` will be renamed to. + * @param options.tla Whether to use top-level await. If `true`, the `tracing` file will be statically imported and then the `start` file will be dynamically imported. If `false`, both files will be serially imported. Auto-instrumentation will not work properly without a dynamic `await`. + * @param options.exports an array of exports to re-export from the entrypoint. `default` represents the default export. Defaults to `['default']`. + */ + trace: ({ + entrypoint, + tracing, + start, + tla, + exports + }: { + entrypoint: string; + tracing: string; + start?: string; + tla?: boolean; + exports?: string[]; + }) => void; + /** * Compress files in `directory` with gzip and brotli, where appropriate. Generates `.gz` and `.br` files alongside the originals. * @param directory The directory containing the files to be compressed