Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-adapters-handoff.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-node': patch
---

chore: hand adapt-time values to the server as a module instead of patching built chunks
2 changes: 1 addition & 1 deletion documentation/docs/25-build-and-deploy/40-adapter-node.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ WantedBy=sockets.target

## Custom server

The adapter creates two files in your build directory — `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.
The build directory contains two entry points, `index.js` and `handler.js`. Running `index.js` — e.g. `node build`, if you use the default build directory — will start a server on the configured port.

Alternatively, you can import the `handler.js` file, which exports a handler suitable for use with [Express](https://github.com/expressjs/express), [Connect](https://github.com/senchalabs/connect) or [Polka](https://github.com/lukeed/polka) (or even just the built-in [`http.createServer`](https://nodejs.org/dist/latest/docs/api/http.html#httpcreateserveroptions-requestlistener)) and set up your own server:

Expand Down
11 changes: 0 additions & 11 deletions packages/adapter-node/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,6 @@
import { Adapter } from '@sveltejs/kit';
import './ambient.js';

declare global {
const ENV_PREFIX: string;
const PRECOMPRESS: boolean;
const ORIGIN: string | undefined;
const UNCOMPRESSED_EXTENSIONS: Set<string>;
const BASE_PATH: string;
const APP_PATH: string;
const PRERENDERED: Set<string>;
const MIME_TYPES: Record<string, string>;
}

interface AdapterOptions {
/**
* The directory to build the server to. It defaults to `build` — i.e.
Expand Down
300 changes: 67 additions & 233 deletions packages/adapter-node/index.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,10 @@
/** @import { TopLevelFilterExpression } from '@rolldown/pluginutils' */
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, extname, relative } from 'node:path';
import * as fs from 'node:fs';
import { extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { and, id, importerId, include } from '@rolldown/pluginutils';
import remapping from '@jridgewell/remapping';
import MagicString from 'magic-string';

const files = fileURLToPath(new URL('./files', import.meta.url).href);
const posixified_files = posixify(files);
const dir_id = posixify(`${files}/dir.js`);
// posix so it matches the module ids Vite reports on every platform
const files = fileURLToPath(new URL('./files', import.meta.url).href).replaceAll('\\', '/');
const handoff = '#@sveltejs/adapter-node';

/** @type {typeof import('./index.js').default} */
export default function (opts = {}) {
Expand All @@ -17,11 +13,7 @@ export default function (opts = {}) {
return {
name: '@sveltejs/adapter-node',
async adapt(builder) {
const tmp = builder.getBuildDirectory('adapter-node');

rmSync(out, { force: true, recursive: true });
rmSync(tmp, { force: true, recursive: true });
mkdirSync(tmp, { recursive: true });
fs.rmSync(out, { force: true, recursive: true });

builder.log.minor('Copying assets');
const written = [
Expand Down Expand Up @@ -50,15 +42,12 @@ export default function (opts = {}) {
);

const server = builder.getServerDirectory();
const manifest = JSON.parse(readFileSync(`${server}/.vite/manifest.json`, 'utf8'));
const adapter_index = find_entry(manifest, 'adapter-index');
const handler = find_entry(manifest, 'handler');

builder.generateServerInstance(`${server}/server.js`);

if (builder.hasServerInstrumentationFile()) {
builder.instrument({
entrypoint: `${server}/${adapter_index.file}`,
entrypoint: `${server}/adapter-index.js`,
instrumentation: `${server}/instrumentation.server.js`,
initializer: builder.createInstrumentationInitializer({ outputDirectory: server }),
module: {
Expand All @@ -67,235 +56,80 @@ export default function (opts = {}) {
});
}

const output_server = `${out}/server`;
builder.copy(server, output_server);

// `dir.js` must evaluate at the output root because it anchors the asset directories
renameSync(`${output_server}/dir.js`, `${out}/dir.js`);
if (existsSync(`${output_server}/dir.js.map`)) {
renameSync(`${output_server}/dir.js.map`, `${out}/dir.js.map`);
}
writeFileSync(`${output_server}/dir.js`, `export * from '../dir.js';\n`);

// replace the stubs whose values are only known after the build
replace_stubs(output_server, handler.chunks, {
__SVELTEKIT_ADAPTER_NODE_UNCOMPRESSED_EXTENSIONS__: `new Set(${JSON.stringify([...uncompressed_extensions])})`,
__SVELTEKIT_ADAPTER_NODE_PRERENDERED__: `new Set(${JSON.stringify([...builder.prerendered.paths])})`,
__SVELTEKIT_ADAPTER_NODE_MIMETYPES__: JSON.stringify(builder.mimeTypes),
__SVELTEKIT_ADAPTER_NODE_SERVER__: (chunk) => {
const server = posixify(relative(dirname(chunk), 'server.js'));
return server.startsWith('.') ? server : `./${server}`;
}
});
builder.copy(server, `${out}/server`);

// values only known after the build. `dir` needs the output root
fs.writeFileSync(
`${out}/adapter-node.js`,
[
`import { dirname } from 'node:path';`,
`import { fileURLToPath } from 'node:url';`,
`export { server } from './server/server.js';`,
`export const dir = dirname(fileURLToPath(import.meta.url));`,
`export const base = ${JSON.stringify(builder.config.paths.base)};`,
`export const app_path = ${JSON.stringify(builder.getAppPath())};`,
`export const origin = ${JSON.stringify(builder.config.paths.origin)};`,
`export const env_prefix = ${JSON.stringify(envPrefix)};`,
`export const precompress = ${precompress};`,
`export const uncompressed_extensions = new Set(${JSON.stringify([...uncompressed_extensions])});`,
`export const prerendered = new Set(${JSON.stringify(builder.prerendered.paths)});`,
`export const mime_types = ${JSON.stringify(builder.mimeTypes)};`
].join('\n')
);

writeFileSync(`${out}/index.js`, `export * from './server/${adapter_index.file}';\n`);
writeFileSync(`${out}/handler.js`, `export * from './server/${handler.file}';\n`);
fs.writeFileSync(`${out}/index.js`, `export * from './server/adapter-index.js';\n`);
fs.writeFileSync(`${out}/handler.js`, `export * from './server/handler.js';\n`);
},

supports: {
read: () => true,
instrumentation: () => true
},

vite({ config }) {
/** @type {Record<string, string>} */
const defines = {
// these get replaced later in the adapt step
UNCOMPRESSED_EXTENSIONS: '__SVELTEKIT_ADAPTER_NODE_UNCOMPRESSED_EXTENSIONS__',
PRERENDERED: '__SVELTEKIT_ADAPTER_NODE_PRERENDERED__',
MIME_TYPES: '__SVELTEKIT_ADAPTER_NODE_MIMETYPES__',

BASE_PATH: JSON.stringify(config.paths.base),
APP_PATH: JSON.stringify(
`${config.paths.base.slice(1)}${config.paths.base ? '/' : ''}${config.appDir}`
),
ORIGIN: JSON.stringify(config.paths.origin) || 'undefined',
ENV_PREFIX: JSON.stringify(envPrefix),
PRECOMPRESS: JSON.stringify(precompress)
};

return {
plugins: {
post: [
{
name: 'vite-plugin-sveltekit-adapter-node',
apply: 'build',
config() {
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));

return {
environments: {
ssr: {
build: {
rolldownOptions: {
// Copy the prebuilt entrypoints into the build directory so that the
// adapter's own bundled dependencies resolve correctly, then bundle them
// together with the app's server code. Bundling everything in a single
// pass means shared modules (e.g. `SvelteKitError` from `@sveltejs/kit`)
// aren't duplicated. See https://github.com/sveltejs/kit/issues/15755
input: {
'adapter-index': `${files}/index.js`,
'adapter-env': `${files}/adapter-env.js`,
handler: `${files}/handler.js`
},
output: {
chunkFileNames(chunk) {
if (chunk.name === 'dir') return '[name].js';
return 'chunks/[name].js';
},
codeSplitting: {
groups: [
{
name: 'dir',
test: dir_id
}
]
}
},
// deployments only need their production dependencies.
// Anything in devDependencies will get included in the
// bundled code
external: [
// dependencies could have deep exports, so we need a regex
...Object.keys(pkg.dependencies || {}).map(
(d) => new RegExp(`^${d}(\\/.*)?$`)
)
]
vite: {
plugins: {
post: [
{
name: 'vite-plugin-sveltekit-adapter-node',
apply: 'build',
config() {
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));

return {
environments: {
ssr: {
build: {
rolldownOptions: {
// bundled with the app's server code so shared modules aren't duplicated (#15755)
input: {
'adapter-index': `${files}/index.js`,
'adapter-env': `${files}/adapter-env.js`,
handler: `${files}/handler.js`
},
// only production dependencies (and their deep imports) stay external
external: [
handoff,
...Object.keys(pkg.dependencies || {}).map(
(d) => new RegExp(`^${d}(\\/.*)?$`)
)
],
output: {
paths: { [handoff]: '../adapter-node.js' },
// the hand-off path only holds at the output root, so adapter chunks may not nest
chunkFileNames: (chunk) =>
chunk.moduleIds.some((id) => id.startsWith(files))
? 'adapter-node-[name].js'
: 'chunks/[name].js'
}
}
}
}
};
},
applyToEnvironment(environment) {
return environment.name === 'ssr';
},
transform: {
filter: { id: new RegExp(`^${escape_regex(posixified_files)}/`) },
handler(code) {
const s = new MagicString(code);

for (const [from, to] of Object.entries(defines)) {
for (const match of code.matchAll(new RegExp(`\\b${from}\\b`, 'g'))) {
s.overwrite(match.index, match.index + from.length, to);
}
}

return {
code: s.toString(),
map: s.generateMap({ hires: 'boundary' })
};
}
},
resolveId: {
// composable filters are not accepted type-wise but still work during build
// see https://github.com/vitejs/rolldown-vite/issues/605
filter: /** @type {any} */ (
/** @satisfies {TopLevelFilterExpression[]} */ ([
include(
and(
importerId(new RegExp(`^${escape_regex(posixified_files)}/`)),
id(/^SERVER$/)
)
)
])
),
handler() {
return { external: true, id: '__SVELTEKIT_ADAPTER_NODE_SERVER__' };
}
}
};
}
]
}
};
}
]
}
}
};
}

/**
* @typedef {{ file: string; imports?: string[]; isEntry?: boolean; name?: string }} ManifestEntry
*/

/**
* @param {Record<string, ManifestEntry>} manifest
* @param {string} name
*/
function find_entry(manifest, name) {
const match = Object.entries(manifest).find(([, entry]) => entry.isEntry && entry.name === name);
if (!match) throw new Error(`Could not find adapter-node entry ${name} in the Vite manifest`);

/** @type {string[]} */
const chunks = [];
const seen = new Set();

/** @param {string} key */
const visit = (key) => {
if (seen.has(key)) return;
seen.add(key);

const chunk = manifest[key];
if (!chunk) throw new Error(`Could not find ${key} in the Vite manifest`);

chunks.push(chunk.file);
for (const dependency of chunk.imports ?? []) visit(dependency);
};

visit(match[0]);
return { file: match[1].file, chunks };
}

/**
* @param {string} dir
* @param {string[]} chunks
* @param {Record<string, string | ((chunk: string) => string)>} replacements
*/
function replace_stubs(dir, chunks, replacements) {
const pattern = new RegExp(`\\b(${Object.keys(replacements).join('|')})\\b`, 'g');
const found = new Set();

for (const chunk of chunks) {
const file = `${dir}/${chunk}`;
const code = readFileSync(file, 'utf8');
const matches = [...code.matchAll(pattern)];
if (matches.length === 0) continue;

const s = new MagicString(code);

for (const match of matches) {
found.add(match[0]);
const replacement = replacements[match[0]];
const value = typeof replacement === 'function' ? replacement(chunk) : replacement;
s.overwrite(match.index, match.index + match[0].length, value);
}

writeFileSync(file, s.toString());

const map_file = `${file}.map`;
if (!existsSync(map_file)) continue;

const map = remapping(
[
JSON.parse(s.generateMap({ hires: 'boundary', source: chunk }).toString()),
JSON.parse(readFileSync(map_file, 'utf8'))
],
() => null
);
writeFileSync(map_file, map.toString());
}

const missing = Object.keys(replacements).filter((stub) => !found.has(stub));
if (missing.length > 0) {
throw new Error(`Could not find adapter-node stubs: ${missing.join(', ')}`);
}
}

/** @param {string} str */
function escape_regex(str) {
// TODO replace with `RegExp.escape(str)` when we require Node >= 24
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** @param {string} str */
function posixify(str) {
return str.replace(/\\/g, '/');
}
13 changes: 12 additions & 1 deletion packages/adapter-node/internal.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
declare module 'SERVER' {
// generated by `adapt` in the output directory
declare module '#@sveltejs/adapter-node' {
export const server: import('@sveltejs/kit').Server;
/** the output directory */
export const dir: string;
export const base: string;
export const app_path: string;
export const origin: string | undefined;
export const env_prefix: string;
export const precompress: boolean;
export const uncompressed_extensions: Set<string>;
export const prerendered: Set<string>;
export const mime_types: Record<string, string>;
}
Loading
Loading