diff --git a/.changeset/new-bun-adapter.md b/.changeset/new-bun-adapter.md new file mode 100644 index 000000000000..c68d9ce16589 --- /dev/null +++ b/.changeset/new-bun-adapter.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-bun': minor +--- + +feat: add a Bun-native adapter with static file serving and single-executable support diff --git a/.github/workflows/platform-tests-all.yml b/.github/workflows/platform-tests-all.yml index ce9f494cc2a2..5384c733695e 100644 --- a/.github/workflows/platform-tests-all.yml +++ b/.github/workflows/platform-tests-all.yml @@ -29,9 +29,14 @@ jobs: with: sha: ${{ inputs.sha }} + bun: + uses: ./.github/workflows/platform-tests-bun.yml + with: + sha: ${{ inputs.sha }} + report-status: if: always() - needs: [vercel, netlify, node] + needs: [vercel, netlify, node, bun] permissions: contents: write # to create the repository_dispatch event runs-on: ubuntu-latest diff --git a/.github/workflows/platform-tests-bun.yml b/.github/workflows/platform-tests-bun.yml new file mode 100644 index 000000000000..ed28bce1aec8 --- /dev/null +++ b/.github/workflows/platform-tests-bun.yml @@ -0,0 +1,59 @@ +name: Platform Tests (Bun) + +on: + workflow_dispatch: + inputs: + sha: + description: 'Commit SHA to test' + required: false + default: '' + workflow_call: + inputs: + sha: + description: 'Commit SHA to test' + required: false + type: string + default: '' + +permissions: + contents: read + +env: + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + +jobs: + test-basic: + if: github.repository == 'sveltejs/kit' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ inputs.sha || github.sha }} + persist-credentials: false + + - uses: ./.github/actions/node-setup + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + + - uses: ./.github/actions/platform-test + with: + test-app-dir: packages/adapter-bun/test/apps/basic + os: ubuntu-latest + + - name: Test compiled executable + working-directory: packages/adapter-bun/test/apps/basic + env: + COMPILE: 'true' + run: pnpm test:platform + + - name: Compile optimized cross-target executable + working-directory: packages/adapter-bun/test/apps/basic + env: + ADVANCED_COMPILE: 'true' + COMPILE_TARGET: 'bun-linux-x64' + run: | + bun run --bun build + test -x build/advanced-app diff --git a/documentation/docs/25-build-and-deploy/20-adapters.md b/documentation/docs/25-build-and-deploy/20-adapters.md index ae9999044ccb..9d53dbf146b8 100644 --- a/documentation/docs/25-build-and-deploy/20-adapters.md +++ b/documentation/docs/25-build-and-deploy/20-adapters.md @@ -9,6 +9,7 @@ Official adapters exist for a variety of platforms — these are documented on t - [`@sveltejs/adapter-cloudflare`](adapter-cloudflare) for Cloudflare Workers and Cloudflare Pages - [`@sveltejs/adapter-netlify`](adapter-netlify) for Netlify - [`@sveltejs/adapter-node`](adapter-node) for Node servers +- [`@sveltejs/adapter-bun`](adapter-bun) for Bun servers - [`@sveltejs/adapter-static`](adapter-static) for static site generation (SSG) - [`@sveltejs/adapter-vercel`](adapter-vercel) for Vercel diff --git a/documentation/docs/25-build-and-deploy/45-adapter-bun.md b/documentation/docs/25-build-and-deploy/45-adapter-bun.md new file mode 100644 index 000000000000..bbd9da070a00 --- /dev/null +++ b/documentation/docs/25-build-and-deploy/45-adapter-bun.md @@ -0,0 +1,254 @@ +--- +title: Bun servers +--- + +[`adapter-bun`](https://github.com/sveltejs/kit/tree/main/packages/adapter-bun) builds a SvelteKit application into a standalone [Bun](https://bun.com/) server. The generated server uses `Bun.serve` for requests and `Bun.file` responses for client assets, prerendered output, and files read with [`read`](https://svelte.dev/docs/kit/$app-server#read) from `$app/server`. + +## Usage + +Install the adapter: + +```sh +bun add -D @sveltejs/adapter-bun +``` + +Configure it in `vite.config.js`: + +```js +// @errors: 2307 2554 +/// file: vite.config.js +import adapter from '@sveltejs/adapter-bun'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + adapter: adapter() + }) + ] +}); +``` + +The adapter calls Bun's build API, so the production build itself must run in Bun. The `--bun` flag overrides Vite's Node.js shebang: + +```sh +bun run --bun build +``` + +The default build is written to `build`. Start it with: + +```sh +bun ./build +``` + +The JavaScript server, client files, and prerendered files in the output directory are all required at runtime. Application imports are processed according to Bun's bundler behavior. + +Client assets and prerendered output are registered as native Bun routes. Only `GET` and `HEAD` requests are served by those routes; other methods continue to SvelteKit. Every asset carries an ETag computed during the build, so conditional requests revalidate with an empty `304` response. Bun supplies MIME types, byte ranges for filesystem-backed files, and streaming without buffering every asset in memory. Files below SvelteKit's `immutable` directory receive `Cache-Control: public,max-age=31536000,immutable`. + +> [!NOTE] Bun treats `*` in a route pathname as a wildcard. The adapter rejects client and prerendered filenames that contain a literal `*`; rename those files before building. + +## Options + +```js +// @errors: 2307 2554 +/// file: vite.config.js +import adapter from '@sveltejs/adapter-bun'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [ + sveltekit({ + adapter: adapter({ + out: 'build', + envPrefix: '', + serverOptions: { + idleTimeout: 30 + }, + buildOptions: { + sourcemap: 'external' + } + }) + }) + ] +}); +``` + +### out + +The output directory. It defaults to `build`. + +### precompress + +Set `precompress: true` to generate `.br` and `.gz` variants of client and prerendered assets during the build. The generated routes negotiate `Accept-Encoding` per request, preferring brotli over gzip, and each variant carries its own ETag. The option is ignored when `buildOptions.compile` is set, because embedded assets are imported by identity path. + +### envPrefix + +A prefix for every deployment environment variable documented below. This is useful when the unprefixed names conflict with variables managed by your host: + +```js +adapter({ envPrefix: 'MY_APP_' }); +``` + +```sh +MY_APP_HOST=127.0.0.1 MY_APP_PORT=4000 bun ./build +``` + +When a prefix is configured, the server fails at startup if it finds an unknown environment variable with that prefix. This catches collisions and misspellings. + +### serverOptions + +JSON-serializable defaults passed to `Bun.serve`. The supported properties are: + +- `hostname` +- `port` +- `unix` +- `reusePort` +- `ipv6Only` +- `idleTimeout` +- `maxRequestBodySize` +- `development` + +Environment variables take precedence over these defaults. A configured Unix socket takes precedence over `hostname`, `port`, `reusePort`, and `ipv6Only`. + +The generated server owns `fetch` and `routes`. It does not expose `websocket`, `error`, TLS, HTTP/3, or HTTP/1 configuration through `serverOptions`. Use a custom Bun integration if your application requires those `Bun.serve` options. + +### buildOptions + +Advanced Bun build settings can be supplied with `buildOptions`. The adapter currently accepts `sourcemap`, `minify`, `bytecode`, `banner`, `footer`, `drop`, `features`, `optimizeImports`, `splitting`, and `compile`. Code splitting is enabled by default; `splitting: false` bundles the server into a single file, which works around [`Bun.build` output path collisions](https://github.com/oven-sh/bun/issues/17674) on applications whose module graph produces identically-hashed chunks. + +The generated entrypoint, output directory, top-level `target`, and module `format` are reserved. Generated servers target Bun and use ESM. Source maps default to `external`; set `sourcemap: 'none'` to disable them. + +#### Compiled executables + +Set `compile: true` to generate a single executable at `/server`: + +```js +adapter({ + buildOptions: { + compile: true + } +}); +``` + +Build and run it without a separately installed Bun runtime: + +```sh +bun run --bun build +./build/server +``` + +The executable embeds the server code, client assets, prerendered output, and Bun runtime. `compile` can also be a Bun target string, which keeps the default `server` filename, or an options object. To change the executable name or cross-compile, provide an options object: + +```js +adapter({ + out: 'dist', + buildOptions: { + compile: { + outfile: 'application', + target: 'bun-linux-x64' + }, + minify: true, + bytecode: true, + sourcemap: 'linked' + } +}); +``` + +The result in this example is `dist/application`. Platform targets, native dependencies, and other limitations follow [Bun's executable compilation rules](https://bun.com/docs/bundler/executables). + +## Environment variables + +Bun loads `.env` files automatically. If `envPrefix` is set, add that prefix to each name in this section. + +### Listener + +`HOST` and `PORT` configure the TCP listener. Without either value or a `serverOptions` default, the server listens on port `3000`. + +```sh +HOST=127.0.0.1 PORT=4000 bun ./build +``` + +`SOCKET_PATH` selects a Unix domain socket instead. When it is present, TCP-only options are ignored: + +```sh +SOCKET_PATH=/tmp/sveltekit.sock bun ./build +``` + +`REUSE_PORT` enables Bun's `reusePort` option and `IPV6_ONLY` enables `ipv6Only`. Boolean variables accept `1`, `true`, `yes`, and `on`, or `0`, `false`, `no`, and `off`, without regard to letter case. + +### Request limits and diagnostics + +`BODY_SIZE_LIMIT` controls `Bun.serve`'s `maxRequestBodySize`. It defaults to `512K`. The value must resolve to a whole number of bytes and may use a case-insensitive binary `K`, `M`, or `G` suffix, such as `768K` or `1.5M`. `Infinity` disables the limit. + +`CONNECTION_IDLE_TIMEOUT` sets Bun's per-request inactivity timeout in seconds. It must be an integer from `0` through `255`; `0` disables the timeout. The generated handler disables the timeout for responses whose content type starts with `text/event-stream` and also adds `X-Accel-Buffering: no`. It is deliberately not called `IDLE_TIMEOUT`, which on adapter-node means something different (shut the server down after N seconds without requests). + +`DEVELOPMENT` enables Bun's development-mode error pages. It defaults to `false` for the generated server. + +### Public origin behind a proxy + +If [`paths.origin`](configuration#paths) is configured, that value is the trusted origin for every request. Otherwise, the adapter derives the host from the `Host` header and assumes the scheme is `https`, since production deployments usually terminate TLS upstream. Configure `paths.origin` or `PROTOCOL_HEADER` if that assumption is wrong, for example when serving plain HTTP directly. + +Behind a trusted reverse proxy, `PROTOCOL_HEADER`, `HOST_HEADER`, and `PORT_HEADER` name headers that contain the public scheme, host, and port: + +```sh +PROTOCOL_HEADER=x-forwarded-proto \ +HOST_HEADER=x-forwarded-host \ +PORT_HEADER=x-forwarded-port \ +bun ./build +``` + +The protocol header must contain `http` or `https`, without a colon. The port header must contain a number. Invalid values produce a `400 Bad Request` response. A header that is present but empty is ignored in favour of the fallback. + +> [!CAUTION] Only trust forwarded headers when requests can reach the server through a proxy you control. A direct client can spoof these headers. + +### Client addresses behind a proxy + +[`event.getClientAddress()`](https://svelte.dev/docs/kit/@sveltejs-kit#RequestEvent) uses `server.requestIP(request).address` by default. Set `ADDRESS_HEADER` to the name of a trusted proxy header when the direct peer is a proxy: + +```sh +ADDRESS_HEADER=true-client-ip bun ./build +``` + +For `x-forwarded-for`, also set `XFF_DEPTH` to the number of trusted proxies. The default depth is `1`, and the adapter selects from the right side of the comma-separated list so client-supplied entries to the left cannot change the trusted result: + +```sh +ADDRESS_HEADER=x-forwarded-for XFF_DEPTH=2 bun ./build +``` + +`XFF_DEPTH` must be an integer of at least `1`. `getClientAddress()` throws if the configured header is absent or contains fewer addresses than the configured depth. + +## Platform API + +The request event's `platform` property exposes the original Web API request received by Bun and the Bun server instance: + +```js +/** @type {import('./$types').RequestHandler} */ +export function GET({ getClientAddress, platform }) { + return Response.json({ + address: getClientAddress(), + requestUrl: platform.request.url, + serverId: platform.server.id, + pendingRequests: platform.server.pendingRequests, + pendingWebSockets: platform.server.pendingWebSockets + }); +} +``` + +`platform.request` remains the original request even when the adapter normalizes the request URL to a configured or proxy-derived public origin before passing it to SvelteKit. + +## Graceful shutdown + +On `SIGINT` or `SIGTERM`, the generated server calls `server.stop()`. Bun stops accepting new connections and the adapter waits for pending requests before emitting a `sveltekit:shutdown` process event with the signal name: + +```js +process.on('sveltekit:shutdown', async (reason) => { + await jobs.stop(); + await db.close(); +}); +``` + +Connections that are still open after `SHUTDOWN_TIMEOUT` seconds are closed forcefully, so idle connections such as open event streams cannot delay the shutdown indefinitely. The value must be a non-negative integer and defaults to `30`. + +Sending a second shutdown signal forces the process to exit with status `1`. diff --git a/packages/adapter-bun/.gitignore b/packages/adapter-bun/.gitignore new file mode 100644 index 000000000000..7b1c9a0ade28 --- /dev/null +++ b/packages/adapter-bun/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +node_modules +/files +coverage/ + diff --git a/packages/adapter-bun/CHANGELOG.md b/packages/adapter-bun/CHANGELOG.md new file mode 100644 index 000000000000..de4bffe01c21 --- /dev/null +++ b/packages/adapter-bun/CHANGELOG.md @@ -0,0 +1,7 @@ +# @sveltejs/adapter-bun + +## 1.0.0-next.0 + +### Patch Changes + +- Initial release diff --git a/packages/adapter-bun/README.md b/packages/adapter-bun/README.md new file mode 100644 index 000000000000..98d1b28dda77 --- /dev/null +++ b/packages/adapter-bun/README.md @@ -0,0 +1,30 @@ +# @sveltejs/adapter-bun + +SvelteKit adapter that builds a standalone server for the [Bun](https://bun.com/) runtime. + +```sh +bun add -D @sveltejs/adapter-bun +``` + +```js +import adapter from '@sveltejs/adapter-bun'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit({ adapter: adapter() })] +}); +``` + +Build with `bun run --bun build`, then run the default output with `bun ./build`. + +See the [adapter-bun documentation](https://svelte.dev/docs/kit/adapter-bun) for configuration, +environment variables, compiled executables, proxy setup, and the Bun-specific platform API. + +## Changelog + +[View the package changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-bun/CHANGELOG.md). + +## License + +[MIT](LICENSE) diff --git a/packages/adapter-bun/ambient.d.ts b/packages/adapter-bun/ambient.d.ts new file mode 100644 index 000000000000..7cc997913082 --- /dev/null +++ b/packages/adapter-bun/ambient.d.ts @@ -0,0 +1,14 @@ +/// + +import type { Server } from 'bun'; + +declare global { + namespace App { + export interface Platform { + /** The original Web API request received by `Bun.serve`. */ + request: Request; + /** The Bun HTTP server handling the request. */ + server: Server; + } + } +} diff --git a/packages/adapter-bun/index.d.ts b/packages/adapter-bun/index.d.ts new file mode 100644 index 000000000000..47898a6355fc --- /dev/null +++ b/packages/adapter-bun/index.d.ts @@ -0,0 +1,71 @@ +import type { Adapter } from '@sveltejs/kit'; +import './ambient.js'; + +interface AdapterOptions { + /** + * The directory to build the server to. + * @default 'build' + */ + out?: string; + /** + * Generate `.br` and `.gz` variants of client and prerendered assets during the + * build. The generated routes negotiate `Accept-Encoding` per request, preferring + * brotli over gzip. Ignored when `buildOptions.compile` is set, because embedded + * assets are imported by identity path. + * @default false + */ + precompress?: boolean; + /** + * If you need to change the name of the environment variables used to configure + * the deployment (for example, to deconflict with environment variables you + * don't control), you can specify a prefix: + * + * ```js + * envPrefix: 'MY_CUSTOM_' + * ``` + * + * ```sh + * MY_CUSTOM_HOST=127.0.0.1 \ + * MY_CUSTOM_PORT=4000 \ + * bun ./build + * ``` + */ + envPrefix?: string; + /** + * Default options passed to `Bun.serve`. Environment variables take precedence. + * The options must be JSON-serializable. + */ + serverOptions?: Pick< + import('bun').Serve.Options, + | 'development' + | 'hostname' + | 'port' + | 'idleTimeout' + | 'maxRequestBodySize' + | 'reusePort' + | 'unix' + | 'ipv6Only' + >; + /** + * Pass Bun build options directly for advanced configuration. The generated entrypoint, + * output directory, top-level target, and module format are reserved. Set `compile` to + * create an executable; if it does not specify an outfile, the executable is written to + * `/server`. + * @default {} + */ + buildOptions?: Pick< + import('bun').BuildConfig, + | 'sourcemap' + | 'minify' + | 'bytecode' + | 'banner' + | 'footer' + | 'drop' + | 'features' + | 'optimizeImports' + | 'splitting' + | 'compile' + >; +} + +export default function plugin(options?: AdapterOptions): Adapter; diff --git a/packages/adapter-bun/index.js b/packages/adapter-bun/index.js new file mode 100644 index 000000000000..4f6ccf666cf7 --- /dev/null +++ b/packages/adapter-bun/index.js @@ -0,0 +1,418 @@ +/** @import { Builder } from '@sveltejs/kit' */ +/** @import { BunPlugin } from 'bun' */ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * @param {string} dir + * @returns {{abs: string, rel: string}[]} + */ +function read_files_recursive(dir) { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => { + const abs = path.resolve(entry.parentPath, entry.name); + const rel = posixify(path.relative(dir, abs)); + return { abs, rel }; + }) + .filter(({ rel }) => rel.split('/').every((segment) => segment !== '.vite')); +} + +/** + * Matches sirv's default behaviour in adapter-node: dotfiles are not served, + * with an exception for the `.well-known` directory. + * @param {string} file + */ +function is_dotfile(file) { + return file + .split('/') + .some((segment, i) => segment.startsWith('.') && !(i === 0 && segment === '.well-known')); +} + +// bounds open file handles while every asset hashes concurrently +const MAX_OPEN_FILES = 64; +let open_files = 0; +/** @type {Array<() => void>} */ +const file_waiters = []; + +/** + * Streams the file through the hasher so build memory stays bounded by chunk + * size instead of total asset size. + * @param {string} file + * @returns {Promise} + */ +async function hash_file(file) { + if (open_files === MAX_OPEN_FILES) { + await new Promise((resolve) => { + file_waiters.push(() => resolve(undefined)); + }); + } + open_files++; + try { + const hasher = new Bun.CryptoHasher('blake2b256'); + for await (const chunk of Bun.file(file).stream()) { + hasher.update(chunk); + } + return hasher.digest('hex').slice(0, 16); + } finally { + open_files--; + file_waiters.shift()?.(); + } +} + +/** + * The build-time validator for conditional requests: Bun only generates ETags for + * in-memory static routes, not file-backed responses, so the adapter ships its own. + * @param {string} file + * @param {boolean} [precompress] + * @returns {Promise<{ hash: string, mtime: number, br?: boolean, gz?: boolean }>} + */ +async function asset_meta(file, precompress = false) { + const hash = await hash_file(file); + + /** @type {{ hash: string, mtime: number, br?: boolean, gz?: boolean }} */ + const meta = { hash, mtime: Bun.file(file).lastModified }; + if (precompress) { + if (fs.existsSync(`${file}.br`)) meta.br = true; + if (fs.existsSync(`${file}.gz`)) meta.gz = true; + } + + return meta; +} + +/** @param {string[]} files */ +function validate_file_paths(files) { + for (const file of files) { + if (file.includes('*')) { + throw new Error( + `Cannot build with ${JSON.stringify(file)} because Bun treats literal \`*\` characters in route paths as wildcards. Rename the file or route to remove the \`*\` character.` + ); + } + // a leading ':' would need percent-encoding, but browsers request the colon raw + if (file.split('/').some((segment) => segment.startsWith(':'))) { + throw new Error( + `Cannot build with ${JSON.stringify(file)} because Bun treats a route segment starting with \`:\` as a parameter. Rename the file or route so no segment starts with \`:\`.` + ); + } + } +} + +/** @type {import('./index.js').default} */ +export default function (opts = {}) { + const { + out = 'build', + envPrefix = '', + precompress = false, + serverOptions = {}, + buildOptions = {} + } = opts; + + return { + name: '@sveltejs/adapter-bun', + async adapt(builder) { + if (typeof Bun === 'undefined') { + throw new Error( + 'adapter-bun requires running the SvelteKit build with Bun. Use `bun run --bun build`.' + ); + } + + fs.rmSync(out, { recursive: true, force: true }); + + builder.log.minor('Building server'); + + if (precompress && buildOptions.compile) { + builder.log.warn( + 'precompress is ignored with buildOptions.compile: embedded assets are imported by identity path' + ); + } + + const server = builder.getServerDirectory(); + + const src_dir = path.resolve(import.meta.dirname, 'src'); + const index_file = path.resolve(src_dir, 'index.js'); + const routes_file = path.resolve(src_dir, 'routes.js'); + const manifest_file = path.resolve(server, 'manifest.js'); + const server_options_file = path.resolve(src_dir, 'options.js'); + + const virtual_files = { + [manifest_file]: + `export const manifest = ${builder.generateManifest({ relativePath: './' })};\n` + + `export const base = ${JSON.stringify(builder.config.paths.base || '/')};\n` + + `export const embed = ${JSON.stringify(!!buildOptions.compile)};\n` + + `export const env_prefix = ${JSON.stringify(envPrefix)};\n` + + `export const origin = ${JSON.stringify(builder.config.paths.origin) ?? 'undefined'};`, + [server_options_file]: `export default ${JSON.stringify(serverOptions)};`, + [routes_file]: await create_routes({ + builder, + out, + embed: !!buildOptions.compile, + precompress: precompress && !buildOptions.compile + }) + }; + + const instrumentation = builder.hasServerInstrumentationFile() + ? `${server}/instrumentation.server.js` + : undefined; + + const entrypoints = [index_file]; + + if (instrumentation) { + const start_file = path.resolve(src_dir, 'start.js'); // Virtual only + virtual_files[start_file] = await Bun.file(index_file).text(); + virtual_files[index_file] = [ + `import ${JSON.stringify(instrumentation)};`, + `await import(${JSON.stringify(start_file)});` + ].join('\n'); + + // as a split chunk, start.js would resolve assets from server/chunks/ instead of the output root + if (!buildOptions.compile) entrypoints.push(start_file); + } + + const chunks_dir = path.resolve(server, 'chunks'); + /** @type {Map} */ + const side_effect_sources = new Map(); + + /** @type {BunPlugin} */ + const adapter_plugin = { + name: 'adapter-bun', + setup(build) { + build.onResolve({ filter: /^(SERVER|MANIFEST|ROUTES|SERVER_OPTIONS)$/ }, ({ path }) => { + if (path === 'SERVER') return { path: `${server}/index.js` }; + if (path === 'MANIFEST') return { path: manifest_file }; + if (path === 'ROUTES') return { path: routes_file }; + if (path === 'SERVER_OPTIONS') return { path: server_options_file }; + }); + + // Side-effect-only chunks (e.g. Svelte's events.js, kit's env re-export) compile to + // identical stubs whose content hashes collide on one output path, failing the build + // with "Multiple files share the same output path" (oven-sh/bun#37576). Resolving a + // distinct identity per importer keeps every emitted copy unique; delete this once + // the Bun fix ships. + build.onResolve({ filter: /\.js$/ }, (args) => { + const file = path.resolve(args.resolveDir, args.path); + if (path.dirname(file) !== chunks_dir) return; + let source = side_effect_sources.get(file); + if (source === undefined) { + const text = fs.readFileSync(file, 'utf8'); + source = /^import\s+["'][^"']+["'];\s*export\s*\{\s*\};?\s*$/.test(text) && text; + side_effect_sources.set(file, source); + } + if (source === false) return; + // The `?` suffix keeps dirname(path) inside chunks/ — Bun resolves the synthetic + // module's relative imports against that, ignoring onLoad's resolveDir. + return { + path: `${file}?${Bun.hash(args.importer).toString(16)}`, + namespace: 'adapter-bun-side-effect' + }; + }); + build.onLoad({ filter: /.*/, namespace: 'adapter-bun-side-effect' }, (args) => { + const file = args.path.slice(0, args.path.indexOf('?')); + return { + loader: 'js', + contents: `${side_effect_sources.get(file)}\nSymbol.for('adapter-bun:${Bun.hash(args.path).toString(16)}');` + }; + }); + } + }; + + const result = await Bun.build({ + ...buildOptions, + splitting: buildOptions.splitting ?? true, + sourcemap: buildOptions.sourcemap ?? 'external', + entrypoints, + target: 'bun', + format: 'esm', + naming: { + entry: '[name].[ext]', + chunk: 'server/chunks/[name]-[hash].[ext]', + asset: 'server/assets/[name]-[hash].[ext]' + }, + plugins: [adapter_plugin], + conditions: ['bun', 'node'], + throw: false, + files: virtual_files, + outdir: out, + compile: buildOptions.compile + ? { + outfile: 'server', + ...(typeof buildOptions.compile === 'string' ? { target: buildOptions.compile } : {}), + ...(typeof buildOptions.compile === 'object' ? buildOptions.compile : {}) + } + : false + }); + if (!result.success) { + for (const log of result.logs) { + // BuildMessage properties are not enumerable, so console.error(log) prints `{}` + const message = log.message ?? String(log); + if (log.level === 'error') builder.log.error(message); + else if (log.level === 'warning') builder.log.warn(message); + else builder.log.info(message); + } + throw new AggregateError(result.logs); + } + }, + + supports: { + read: () => true, + instrumentation: () => true + } + }; +} + +/** + * @param {object} options + * @param {Builder} options.builder + * @param {string[]} options.server_assets + * @returns {Promise<{imports: string[], entries: string[], server_assets: string[]}>} + */ +async function get_embed_entries({ builder, server_assets }) { + const built_files = `${builder.config.outDir}/output`; + + const all_cl_files = read_files_recursive(`${built_files}/client`); + const pr_pages = read_files_recursive(`${built_files}/prerendered/pages`); + const pr_deps = read_files_recursive(`${built_files}/prerendered/dependencies`); + const pr_data = read_files_recursive(`${built_files}/prerendered/data`); + + const cl_files = all_cl_files.filter(({ rel }) => !is_dotfile(rel)); + + const assets = [...cl_files, ...pr_pages, ...pr_deps, ...pr_data]; + validate_file_paths(assets.map(({ rel }) => rel)); + + // keyed by identity: client and prerendered trees can contain the same relative path + const asset_index = new Map(assets.map((file, i) => [file, i])); + const imports = assets.map(({ abs }, i) => { + return `import asset_${i} from ${JSON.stringify(abs)} with { type: 'file' };`; + }); + + /** + * @param {{ abs: string, rel: string }} file + * @param {string} helper + * @param {string} [url] + */ + const entry = async (file, helper, url = file.rel) => + `...${helper}(${JSON.stringify(url)}, asset_${asset_index.get(file)}, ${JSON.stringify(await asset_meta(file.abs))})`; + + const page_files = new Map(pr_pages.map((file) => [file.rel, file])); + const page_rels = new Set([...builder.prerendered.pages].map(([_, { file }]) => file)); + + const entries = await Promise.all([ + ...cl_files.map((file) => entry(file, 'client_asset')), + ...[...builder.prerendered.pages].map(([path, { file }]) => { + const page = page_files.get(file); + if (page === undefined) + throw new Error(`Could not find prerendered page ${file} for route ${path}`); + return entry(page, 'prerendered_page', path); + }), + ...pr_pages + .filter(({ rel }) => !page_rels.has(rel)) + .map((file) => entry(file, 'prerendered_asset')), + ...[...pr_deps, ...pr_data].map((file) => entry(file, 'prerendered_asset')) + ]); + + const index_by_rel = new Map( + assets.map(({ rel }, i) => /** @type {[string, number]} */ ([rel, i])).reverse() + ); + + return { + imports, + entries, + server_assets: server_assets.map((file) => { + const idx = index_by_rel.get(file); + if (idx === undefined) throw new Error(`Could not find server asset ${file}`); + return `[${JSON.stringify(file)}, server_asset(${JSON.stringify(file)}, asset_${idx})]`; + }) + }; +} + +/** + * @param {object} options + * @param {Builder} options.builder + * @param {string[]} options.server_assets + * @param {string} options.out + * @param {boolean} options.precompress + * @returns {Promise<{imports: string[], entries: string[], server_assets: string[]}>} + */ +async function get_no_embed_entries({ builder, server_assets, out, precompress }) { + const client_files = builder.writeClient(`${out}/client`).filter((file) => !is_dotfile(file)); + const prerendered_files = builder.writePrerendered(`${out}/prerendered`); + validate_file_paths([...client_files, ...prerendered_files]); + + if (precompress) { + await Promise.all([builder.compress(`${out}/client`), builder.compress(`${out}/prerendered`)]); + } + + /** + * @param {string} helper + * @param {string} url + * @param {string} dir + * @param {string} [filename] + */ + const entry = async (helper, url, dir, filename) => + `...${helper}(${JSON.stringify(url)}, ${JSON.stringify(filename)}, ${JSON.stringify(await asset_meta(`${out}/${dir}/${filename ?? url}`, precompress))})`; + + const pages = [...builder.prerendered.pages]; + const page_files = new Set(pages.map(([_, { file }]) => file)); + + const entries = await Promise.all([ + ...client_files.map((file) => entry('client_asset', file, 'client')), + ...pages.map(([path, { file }]) => entry('prerendered_page', path, 'prerendered', file)), + ...prerendered_files + .filter((file) => !page_files.has(file)) + .map((file) => entry('prerendered_asset', file, 'prerendered')) + ]); + + return { + imports: [], + entries, + server_assets: server_assets.map((file) => { + return `[${JSON.stringify(file)}, server_asset(${JSON.stringify(file)})]`; + }) + }; +} + +/** + * @param {object} options + * @param {Builder} options.builder + * @param {string} options.out + * @param {boolean} options.embed + * @param {boolean} options.precompress + * @returns {Promise} + */ +async function create_routes({ builder, out, embed, precompress }) { + validate_file_paths([ + ...builder.prerendered.pages.keys(), + ...builder.prerendered.redirects.keys() + ]); + + const server_assets = builder.findServerAssets( + builder.routes.filter((route) => route.prerender !== true) + ); + + const { + imports, + entries, + server_assets: resolved_server_assets + } = embed + ? await get_embed_entries({ builder, server_assets }) + : await get_no_embed_entries({ builder, out, server_assets, precompress }); + + const redirects = [...builder.prerendered.redirects].map(([src, { status, location }]) => { + return `...prerendered_redirect(${JSON.stringify(src)}, ${status}, ${JSON.stringify(location)})`; + }); + + return [ + `import { client_asset, prerendered_asset, prerendered_page, prerendered_redirect, server_asset } from './routes-util.js';`, + ...imports, + // reversed because Object.fromEntries keeps the last duplicate: the first generated + // entry for a path must win so exact files beat aliases, like sirv's lookup order + `export const routes = Object.fromEntries([${[...entries, ...redirects].join(',\n')}].reverse());`, + `export const server_assets = new Map([${resolved_server_assets.join(',\n')}]);` + ].join('\n'); +} + +/** @param {string} path */ +function posixify(path) { + return path.replace(/\\/g, '/'); +} diff --git a/packages/adapter-bun/internal.d.ts b/packages/adapter-bun/internal.d.ts new file mode 100644 index 000000000000..b3e0ceed137a --- /dev/null +++ b/packages/adapter-bun/internal.d.ts @@ -0,0 +1,31 @@ +declare module 'MANIFEST' { + export const manifest: import('@sveltejs/kit').SSRManifest; + export const base: string; + export const embed: boolean; + export const env_prefix: string; + export const origin: string | undefined; +} + +declare module 'ROUTES' { + export const server_assets: Map; + export const routes: import('bun').Serve.Routes; +} + +declare module 'SERVER' { + export { Server } from '@sveltejs/kit'; +} + +declare module 'SERVER_OPTIONS' { + const options: Pick< + import('bun').Serve.Options, + | 'development' + | 'hostname' + | 'port' + | 'idleTimeout' + | 'maxRequestBodySize' + | 'reusePort' + | 'unix' + | 'ipv6Only' + >; + export default options; +} diff --git a/packages/adapter-bun/package.json b/packages/adapter-bun/package.json new file mode 100644 index 000000000000..50e09c9001da --- /dev/null +++ b/packages/adapter-bun/package.json @@ -0,0 +1,54 @@ +{ + "name": "@sveltejs/adapter-bun", + "version": "1.0.0-next.0", + "description": "Adapter for SvelteKit apps that generates a standalone Bun server", + "keywords": [ + "adapter", + "bun", + "deploy", + "hosting", + "svelte", + "sveltekit" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/sveltejs/kit.git", + "directory": "packages/adapter-bun" + }, + "license": "MIT", + "homepage": "https://svelte.dev/docs/kit/adapter-bun", + "type": "module", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js" + }, + "./package.json": "./package.json" + }, + "types": "index.d.ts", + "files": [ + "src/*.js", + "index.js", + "index.d.ts", + "ambient.d.ts" + ], + "scripts": { + "test": "vitest run", + "check": "tsc", + "lint": "prettier --check .", + "format": "pnpm lint --write" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@sveltejs/kit": "workspace:^", + "@types/node": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "dependencies": { + "bun-types": "^1.3.14" + }, + "peerDependencies": { + "@sveltejs/kit": "^3.0.0-next.0" + } +} diff --git a/packages/adapter-bun/src/env.js b/packages/adapter-bun/src/env.js new file mode 100644 index 000000000000..dc28490d1a68 --- /dev/null +++ b/packages/adapter-bun/src/env.js @@ -0,0 +1,143 @@ +import process from 'node:process'; +import { env_prefix } from 'MANIFEST'; + +const expected = new Set([ + 'SOCKET_PATH', + 'HOST', + 'PORT', + 'REUSE_PORT', + 'IPV6_ONLY', + 'CONNECTION_IDLE_TIMEOUT', + 'BODY_SIZE_LIMIT', + 'SHUTDOWN_TIMEOUT', + 'DEVELOPMENT', + 'XFF_DEPTH', + 'ADDRESS_HEADER', + 'PROTOCOL_HEADER', + 'HOST_HEADER', + 'PORT_HEADER' +]); + +if (env_prefix) { + for (const name in process.env) { + if (name.startsWith(env_prefix) && !expected.has(name.slice(env_prefix.length))) { + throw new Error( + `You should change envPrefix (${env_prefix}) to avoid conflicts with existing environment variables — unexpectedly saw ${name}` + ); + } + } +} + +/** + * @param {string} name + * @param {string} value + * @param {string} expected + * @returns {never} + */ +function parsing_error(name, value, expected) { + throw new Error( + `Invalid value for environment variable ${env_prefix + name}: ${JSON.stringify(value)} (expected ${expected})` + ); +} + +/** + * @template {string | undefined} [T=undefined] + * @param {string} name + * @param {T} [fallback] + * @returns {string | T} + */ +export function env(name, fallback) { + const prefixed = env_prefix + name; + return prefixed in process.env + ? /** @type {string} */ (process.env[prefixed]) + : /** @type {T} */ (fallback); +} + +/** @type {Record} */ +const BOOLEANS = { + 1: true, + true: true, + yes: true, + on: true, + 0: false, + false: false, + no: false, + off: false +}; + +/** + * @template {boolean | undefined} [T=undefined] + * @param {string} name + * @param {T} [fallback] + * @returns {boolean | T} + */ +export function boolean_env(name, fallback) { + const value = env(name); + if (value === undefined) return /** @type {T} */ (fallback); + return BOOLEANS[value.toLowerCase()] ?? parsing_error(name, value, 'a boolean'); +} + +/** + * @template {number | undefined} [T=undefined] + * @param {string} name + * @param {T} [fallback] + * @param {{ min?: number; max?: number }} [limits] + * @returns {number | T} + */ +export function number_env(name, fallback, limits = {}) { + const value = env(name); + if (value === undefined) return /** @type {T} */ (fallback); + if (!/^\d+$/.test(value)) { + parsing_error(name, value, 'a non-negative integer'); + } + + const number = Number(value); + if ( + !Number.isSafeInteger(number) || + number < (limits.min ?? 0) || + number > (limits.max ?? Infinity) + ) { + const range = + limits.max === undefined + ? `at least ${limits.min ?? 0}` + : `between ${limits.min ?? 0} and ${limits.max}`; + parsing_error(name, value, `an integer ${range}`); + } + + return number; +} + +/** + * @template {number | undefined} [T=undefined] + * @param {string} name + * @param {T} [fallback] + * @returns {number | T} + */ +export function bytes_env(name, fallback) { + const value = env(name); + if (value === undefined) return /** @type {T} */ (fallback); + // adapter-node documents Infinity as the value that disables the limit + if (value === 'Infinity') return Infinity; + if (!/^(?:\d+(?:\.\d*)?|\.\d+)(?:[KMG])?$/i.test(value)) { + parsing_error( + name, + value, + 'a non-negative number with an optional K, M, or G suffix, or Infinity' + ); + } + + const suffix = value.at(-1)?.toUpperCase(); + const multiplier = + { + K: 1024, + M: 1024 * 1024, + G: 1024 * 1024 * 1024 + }[/** @type {'K' | 'M' | 'G'} */ (suffix)] ?? 1; + const number = Number(multiplier === 1 ? value : value.slice(0, -1)) * multiplier; + + if (!Number.isSafeInteger(number)) { + parsing_error(name, value, 'a non-negative number of whole bytes'); + } + + return number; +} diff --git a/packages/adapter-bun/src/handler.js b/packages/adapter-bun/src/handler.js new file mode 100644 index 000000000000..467bb8cabe64 --- /dev/null +++ b/packages/adapter-bun/src/handler.js @@ -0,0 +1,128 @@ +/** @import { Server as BunServer } from 'bun' */ +import { Server } from 'SERVER'; +import { manifest, origin, env_prefix } from 'MANIFEST'; +import { server_assets } from 'ROUTES'; +import { env, number_env } from './env.js'; + +const server = new Server(manifest); + +const address_header = env('ADDRESS_HEADER', '').toLowerCase(); +const protocol_header = env('PROTOCOL_HEADER', '').toLowerCase(); +const host_header = env('HOST_HEADER', '').toLowerCase(); +const port_header = env('PORT_HEADER', '').toLowerCase(); +const xff_depth = number_env('XFF_DEPTH', 1, { min: 1 }); + +await server.init({ + env: Bun.env, + read: (file) => server_assets.get(file)?.stream() ?? null +}); + +/** + * The Bun-native SvelteKit request handler used by the generated server. + * @param {Request} request + * @param {BunServer} bun_server + * @returns {Promise} + */ +export async function handler(request, bun_server) { + const normalized_request = normalize_request(request); + if (normalized_request instanceof Response) return normalized_request; + + const response = await server.respond(normalized_request, { + platform: { + request, + server: bun_server + }, + getClientAddress: () => get_client_address(request, bun_server) + }); + + if (response.headers.get('content-type')?.startsWith('text/event-stream')) { + bun_server.timeout(request, 0); + response.headers.set('x-accel-buffering', 'no'); + } + + return response; +} + +/** + * Rewrites the request onto the public origin the user actually requested. + * @param {Request} request + * @returns {Request | Response} + */ +function normalize_request(request) { + try { + // an empty Host header makes request.url relative, so parsing belongs in the try + const url = new URL(request.url); + const request_origin = origin || get_origin(request, url); + return request_origin === url.origin + ? request + : new Request(request_origin + url.pathname + url.search, request); + } catch (error) { + console.error( + `Could not determine request origin: ${error instanceof Error ? error.message : String(error)}` + ); + return new Response('Bad Request', { status: 400 }); + } +} + +/** + * @param {Request} request + * @param {URL} url + * @returns {string} + */ +function get_origin(request, url) { + // assume TLS terminates upstream, like adapter-node; an http origin would fail CSRF checks + const protocol = decodeURIComponent( + (protocol_header && request.headers.get(protocol_header)) || 'https' + ); + if (!/^https?$/i.test(protocol)) { + throw new Error( + `The ${protocol_header} header specified ${protocol} which is an invalid protocol scheme. It should only contain the protocol scheme (e.g. \`https\`)` + ); + } + + const host = + (host_header && request.headers.get(host_header)) || (request.headers.get('host') ?? url.host); + if (!host) { + throw new Error( + `Could not determine host from the ${host_header ? `${host_header} or ` : ''}host header` + ); + } + + const port = port_header ? request.headers.get(port_header) : null; + if (port && isNaN(+port)) { + throw new Error( + `The ${port_header} header specified ${port} which is an invalid port because it is not a number. The value should only contain the port number (e.g. 443)` + ); + } + + // canonicalized so the caller's comparison with url.origin matches (case, default ports) + return new URL(`${protocol}://${host}${port ? `:${port}` : ''}`).origin; +} + +/** + * @param {Request} request + * @param {BunServer} bun_server + * @returns {string} + */ +function get_client_address(request, bun_server) { + if (!address_header) { + // requestIP() is null over unix sockets; undefined matches adapter-node + return /** @type {string} */ (bun_server.requestIP(request)?.address); + } + + const value = request.headers.get(address_header); + if (value === null) { + throw new Error( + `Address header was specified with ${env_prefix}ADDRESS_HEADER=${address_header} but is absent from request` + ); + } + if (address_header !== 'x-forwarded-for') return value; + + const addresses = value.split(','); + if (xff_depth > addresses.length) { + throw new Error( + `${env_prefix}XFF_DEPTH is ${xff_depth}, but only found ${addresses.length} addresses` + ); + } + return addresses[addresses.length - xff_depth].trim(); +} diff --git a/packages/adapter-bun/src/index.js b/packages/adapter-bun/src/index.js new file mode 100644 index 000000000000..4cfa4998f72e --- /dev/null +++ b/packages/adapter-bun/src/index.js @@ -0,0 +1,100 @@ +/** @import { Serve } from 'bun' */ +import fs from 'node:fs'; +import process from 'node:process'; +import server_options from 'SERVER_OPTIONS'; +import { routes } from 'ROUTES'; +import { handler } from './handler.js'; +import { boolean_env, bytes_env, env, number_env } from './env.js'; + +const options = /** @type {Serve.Options} */ ({ ...server_options }); + +const unix = env('SOCKET_PATH', options.unix); + +if (unix) { + options.unix = unix; + delete options.hostname; + delete options.port; + delete options.reusePort; + delete options.ipv6Only; + + // an unclean shutdown leaves the socket file behind and the next listen would + // fail with EADDRINUSE; the zero-size check (same heuristic as adapter-node) + // avoids deleting a regular file that happens to sit at this path + try { + if (fs.statSync(unix).size === 0) fs.rmSync(unix); + } catch { + // ignore + } +} else { + delete options.unix; + options.hostname = env('HOST', options.hostname); + // always set an explicit port: left undefined, Bun.serve reads the unprefixed + // BUN_PORT/PORT/NODE_PORT itself, bypassing envPrefix isolation + options.port = env('PORT', options.port?.toString()) ?? 3000; + options.reusePort = boolean_env('REUSE_PORT', options.reusePort); + options.ipv6Only = boolean_env('IPV6_ONLY', options.ipv6Only); +} + +// not IDLE_TIMEOUT: that name means idle-shutdown seconds on adapter-node, and a +// carried-over value would crash on the 255 cap or silently kill slow requests +options.idleTimeout = number_env('CONNECTION_IDLE_TIMEOUT', options.idleTimeout, { max: 255 }); +options.development = boolean_env('DEVELOPMENT') ?? options.development ?? false; + +options.maxRequestBodySize = bytes_env('BODY_SIZE_LIMIT', options.maxRequestBodySize ?? 512 * 1024); + +const shutdown_timeout = number_env('SHUTDOWN_TIMEOUT', 30); + +options.fetch = handler; +options.routes = routes; + +const server = Bun.serve(options); + +console.log(unix ? `Listening on ${unix}` : `Listening on ${server.url}`); + +let shutting_down = false; + +/** @param {'SIGINT' | 'SIGTERM'} reason */ +async function graceful_shutdown(reason) { + if (shutting_down) return process.exit(1); + shutting_down = true; + + if (server.pendingRequests !== 0) { + console.log( + `Waiting for ${server.pendingRequests} requests to finish before shutting down...\n` + + 'Press Ctrl+C again to force shutdown.' + ); + } + + // stop() waits forever on idle connections such as open event streams, and once it + // is pending its promise never settles even after a force-close, so race it instead + /** @type {ReturnType | undefined} */ + let deadline; + const drained = await Promise.race([ + server.stop().then(() => true), + new Promise((resolve) => { + deadline = setTimeout(() => resolve(false), shutdown_timeout * 1000); + }) + ]); + clearTimeout(deadline); + + if (!drained) { + // give the force-close a moment to abort in-flight handlers, so shutdown + // listeners do not tear down resources those handlers still hold; the stop + // promise may never settle, so the timer bounds the wait + /** @type {ReturnType | undefined} */ + let grace; + await Promise.race([ + server.stop(true), + new Promise((resolve) => { + grace = setTimeout(resolve, 1000); + }) + ]); + clearTimeout(grace); + } + + // @ts-expect-error custom events cannot be typed + process.emit('sveltekit:shutdown', reason); +} + +process.on('SIGTERM', () => graceful_shutdown('SIGTERM')); +process.on('SIGINT', () => graceful_shutdown('SIGINT')); diff --git a/packages/adapter-bun/src/routes-util.js b/packages/adapter-bun/src/routes-util.js new file mode 100644 index 000000000000..d20d2fbb0ed1 --- /dev/null +++ b/packages/adapter-bun/src/routes-util.js @@ -0,0 +1,259 @@ +/** @import { BunFile, BunRequest, Serve } from 'bun' */ +import { manifest, base, embed } from 'MANIFEST'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// not Bun.main: when the built server is imported from a wrapper script rather than +// run directly, Bun.main is the wrapper and every asset path resolves wrong +const dir = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Embedded assets are imported by identity path; on-disk assets live under the + * output directory next to this module. + * @param {string} subdir + * @param {string} filename + * @returns {string} + */ +function resolve_file(subdir, filename) { + return embed ? filename : path.resolve(dir, subdir, filename); +} + +/** + * @typedef {Serve.Routes[string]} RouteHandler + * @typedef {{ hash: string, mtime: number, br?: boolean, gz?: boolean }} AssetMeta + */ + +const CONTENT_ENCODING = { br: 'br', gz: 'gzip' }; + +// the URL Standard's path percent-encode set, plus `%` and `\` so they stay literal +// eslint-disable-next-line no-control-regex -- control characters are part of the encode set +const ESCAPED_PATH_CHAR = /[\u0000-\u001f\u007f-\u{10ffff} "#<>?`{}%\\]/gu; + +/** + * WHATWG path serialization: the exact bytes user agents put on the wire, because + * Bun matches route keys against the raw request pathname. + * @param {string} pathname + * @returns {string} + */ +function encode_pathname(pathname) { + return pathname.replace(ESCAPED_PATH_CHAR, (char) => encodeURIComponent(char)); +} + +/** + * Registers a path both as user agents send it and fully percent-encoded, + * so clients that escape sub-delims still match. + * @param {string} pathname + * @returns {string[]} + */ +function route_paths(pathname) { + const minimal = encode_pathname(pathname); + const full = pathname.split('/').map(encodeURIComponent).join('/'); + return minimal === full ? [minimal] : [minimal, full]; +} + +/** + * @param {string} url + * @returns {string[]} + */ +function to_paths(url) { + return route_paths(path.posix.join(base, url)); +} + +/** + * @param {string} url + * @returns {string[]} + */ +function to_directory_paths(url) { + const directory = `${path.posix.join(base, url).replace(/\/$/, '')}/`; + const paths = route_paths(directory); + + if (directory !== '/') { + paths.push(...route_paths(directory.slice(0, -1))); + } + + return paths; +} + +/** + * If-None-Match takes precedence over If-Modified-Since (RFC 9110 §13.1.3); + * dates compare at whole-second precision because HTTP dates have none finer. + * @param {Request} request + * @param {string} etag + * @param {number} mtime + * @returns {boolean} + */ +function is_fresh(request, etag, mtime) { + const header = request.headers.get('if-none-match'); + if (header !== null) { + return header + .split(',') + .some((value) => ['*', etag].includes(value.trim().replace(/^W\//, ''))); + } + + const since = Date.parse(request.headers.get('if-modified-since') ?? ''); + return Number.isFinite(since) && Math.trunc(mtime / 1000) <= Math.trunc(since / 1000); +} + +/** + * @param {string | null} accept + * @param {AssetMeta} meta + * @returns {'br' | 'gz' | null} + */ +function negotiate(accept, meta) { + if (accept === null || (!meta.br && !meta.gz)) return null; + + const accepted = new Set(); + for (const part of accept.split(',')) { + const [name = '', ...params] = part.trim().toLowerCase().split(';'); + if (params.some((param) => /^q=0(\.0*)?$/.test(param.trim()))) continue; + accepted.add(name.trim()); + } + + if (meta.br && (accepted.has('br') || accepted.has('*'))) return 'br'; + if (meta.gz && (accepted.has('gzip') || accepted.has('*'))) return 'gz'; + return null; +} + +/** + * Bun does not route HEAD requests to a GET function handler, so every route + * registers both methods. + * @param {((request: BunRequest) => Response) | Response} handler + * @returns {RouteHandler} + */ +function handlers(handler) { + return { GET: handler, HEAD: handler }; +} + +/** + * @param {string[]} paths + * @param {RouteHandler} route + * @returns {Array<[string, RouteHandler]>} + */ +function route_entries(paths, route) { + return paths.map((route_path) => [route_path, route]); +} + +/** + * Serves one file with its build-time validator and precompressed variants. + * @param {string} file + * @param {AssetMeta} meta + * @param {Record} [extra_headers] + * @returns {RouteHandler} + */ +function file_route(file, meta, extra_headers = {}) { + const content_type = Bun.file(file).type; + const last_modified = new Date(meta.mtime).toUTCString(); + + /** @param {BunRequest} request */ + const handler = (request) => { + // Bun serializes Range itself for file bodies; ranges apply to the identity representation + const encoding = + request.headers.get('range') === null + ? negotiate(request.headers.get('accept-encoding'), meta) + : null; + const etag = encoding === null ? `"${meta.hash}"` : `"${meta.hash}-${encoding}"`; + + /** @type {Record} */ + const response_headers = { + 'content-type': content_type, + ...extra_headers, + etag, + 'last-modified': last_modified + }; + if (meta.br || meta.gz) response_headers['vary'] = 'accept-encoding'; + + if (is_fresh(request, etag, meta.mtime)) { + return new Response(null, { status: 304, headers: response_headers }); + } + + let body_file = file; + if (encoding !== null) { + response_headers['content-encoding'] = CONTENT_ENCODING[encoding]; + body_file = `${file}.${encoding}`; + } + return new Response(Bun.file(body_file), { headers: response_headers }); + }; + + return handlers(handler); +} + +/** + * @param {string} url + * @param {string | undefined} filename + * @param {AssetMeta} meta + * @returns {Array<[string, RouteHandler]>} + */ +export function client_asset(url, filename = url, meta) { + const immutable = url.startsWith(`${manifest.appDir}/immutable/`); + const route = file_route( + resolve_file('client', filename), + meta, + immutable ? { 'cache-control': 'public,max-age=31536000,immutable' } : {} + ); + + const paths = to_paths(url); + if (url.endsWith('/index.html') || url === 'index.html') { + paths.push(...to_directory_paths(url.slice(0, -'index.html'.length))); + } else if (url.endsWith('.html')) { + // sirv also serves `page.html` at `/page` + paths.push(...to_paths(url.slice(0, -'.html'.length))); + } + + return route_entries(paths, route); +} + +/** + * @param {string} url + * @param {string} [filename] + * @returns {BunFile} + */ +export function server_asset(url, filename = url) { + return Bun.file(resolve_file('client', filename)); +} + +/** + * @param {string} url + * @param {string | undefined} filename + * @param {AssetMeta} meta + * @returns {Array<[string, RouteHandler]>} + */ +export function prerendered_asset(url, filename = url, meta) { + const route = file_route(resolve_file('prerendered', filename), meta); + return route_entries(to_paths(url), route); +} + +/** + * @param {string} url + * @param {string} filename + * @param {AssetMeta} meta + * @returns {Array<[string, RouteHandler]>} + */ +export function prerendered_page(url, filename, meta) { + const route = file_route(resolve_file('prerendered', filename), meta); + // path already contains base, no need to add it here + const entries = route_entries(route_paths(url), route); + + const inverted = url.endsWith('/') ? url.slice(0, -1) : `${url}/`; + if (inverted) { + const canonical = encode_pathname(url); + const redirect = handlers((req) => { + const location = canonical + new URL(req.url).search; + return new Response(null, { status: 308, headers: { location } }); + }); + entries.push(...route_entries(route_paths(inverted), redirect)); + } + + return entries; +} + +/** + * @param {string} url + * @param {number} status + * @param {string} location + * @returns {Array<[string, RouteHandler]>} + */ +export function prerendered_redirect(url, status, location) { + const route = handlers(new Response(null, { status, headers: { location } })); + // path already contains base, no need to add it here + return route_entries(route_paths(url), route); +} diff --git a/packages/adapter-bun/test/adapter.spec.ts b/packages/adapter-bun/test/adapter.spec.ts new file mode 100644 index 000000000000..0c325e3a48c8 --- /dev/null +++ b/packages/adapter-bun/test/adapter.spec.ts @@ -0,0 +1,566 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import adapter from '../index.js'; + +const package_dir = new URL('..', import.meta.url).pathname.replace(/\/$/, ''); +const index_file = `${package_dir}/src/index.js`; +const manifest_file = `${package_dir}/.svelte-kit/output/server/manifest.js`; +const routes_file = `${package_dir}/src/routes.js`; +const options_file = `${package_dir}/src/options.js`; +const start_file = `${package_dir}/src/start.js`; + +vi.mock('node:fs', async (import_original) => { + const actual = await import_original(); + const mocked = { + ...actual, + readdirSync: vi.fn(), + existsSync: vi.fn(), + rmSync: vi.fn(), + readFileSync: vi.fn() + }; + return { ...mocked, default: mocked }; +}); + +const bun = vi.hoisted(() => ({ + entrypoint: '// generated server entrypoint', + build: vi.fn(async (_options: any): Promise => ({ success: true, logs: [], outputs: [] })), + file: vi.fn((_path: string) => ({ + text: async () => '// generated server entrypoint', + stream: () => new Blob([]).stream(), + lastModified: 0 + })), + CryptoHasher: class { + update() {} + digest() { + return 'abc'; + } + }, + hash: (input: string) => { + let hash = 0n; + for (const char of input) hash = hash * 31n + BigInt(char.charCodeAt(0)); + return hash; + } +})); + +beforeEach(() => { + vi.stubGlobal('Bun', { + build: bun.build, + file: bun.file, + CryptoHasher: bun.CryptoHasher, + hash: bun.hash + }); + vi.mocked(fs.readdirSync).mockReturnValue([]); + vi.mocked(fs.existsSync).mockReturnValue(true); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('adapter contract', () => { + test('identifies itself and declares supported SvelteKit features', () => { + const instance = adapter(); + + expect(instance.name).toBe('@sveltejs/adapter-bun'); + expect(instance.supports?.read?.({ route: { id: '/file' }, config: {} })).toBe(true); + expect(instance.supports?.instrumentation?.()).toBe(true); + }); + + test('requires the SvelteKit build to run in Bun', async () => { + vi.stubGlobal('Bun', undefined); + + await expect(adapter().adapt(create_builder())).rejects.toThrow( + 'adapter-bun requires running the SvelteKit build with Bun' + ); + }); +}); + +describe('Bun build configuration', () => { + test('cleans the output and supplies production defaults', async () => { + const builder = create_builder(); + await adapter().adapt(builder); + + expect(fs.rmSync).toHaveBeenCalledWith('build', { recursive: true, force: true }); + expect(builder.log.minor).toHaveBeenCalledWith('Building server'); + + const options = bun.build.mock.calls[0][0]; + expect(options).toMatchObject({ + entrypoints: [index_file], + outdir: 'build', + target: 'bun', + format: 'esm', + splitting: true, + sourcemap: 'external', + conditions: ['bun', 'node'], + throw: false, + compile: false + }); + expect(options.naming).toEqual({ + entry: '[name].[ext]', + chunk: 'server/chunks/[name]-[hash].[ext]', + asset: 'server/assets/[name]-[hash].[ext]' + }); + expect(options.plugins).toHaveLength(1); + expect(options.plugins[0].name).toBe('adapter-bun'); + }); + + test('generates manifest and server-option modules', async () => { + const builder = create_builder({ base: '/docs', origin: 'https://example.com' }); + await adapter({ + envPrefix: 'APP_', + serverOptions: { hostname: '127.0.0.1', port: 4000, development: true } + }).adapt(builder); + + const files = bun.build.mock.calls[0][0].files; + expect(files[manifest_file]).toBe( + 'export const manifest = {"appDir":"_app"};\n' + + 'export const base = "/docs";\n' + + 'export const embed = false;\n' + + 'export const env_prefix = "APP_";\n' + + 'export const origin = "https://example.com";' + ); + expect(files[options_file]).toBe( + 'export default {"hostname":"127.0.0.1","port":4000,"development":true};' + ); + expect(builder.generateManifest).toHaveBeenCalledWith({ relativePath: './' }); + }); + + test('resolves generated runtime modules through the Bun plugin', async () => { + await adapter().adapt(create_builder()); + const on_resolve = vi.fn(); + bun.build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: vi.fn() }); + + expect(on_resolve).toHaveBeenCalledWith( + { filter: /^(SERVER|MANIFEST|ROUTES|SERVER_OPTIONS)$/ }, + expect.any(Function) + ); + const resolve_module = on_resolve.mock.calls[0][1]; + expect(resolve_module({ path: 'SERVER' })).toEqual({ + path: '.svelte-kit/output/server/index.js' + }); + expect(resolve_module({ path: 'MANIFEST' })).toEqual({ path: manifest_file }); + expect(resolve_module({ path: 'ROUTES' })).toEqual({ path: routes_file }); + expect(resolve_module({ path: 'SERVER_OPTIONS' })).toEqual({ path: options_file }); + }); + + test('gives side-effect-only chunks a per-importer identity so their copies cannot collide', async () => { + await adapter().adapt(create_builder()); + const on_resolve = vi.fn(); + const on_load = vi.fn(); + bun.build.mock.calls[0][0].plugins[0].setup({ onResolve: on_resolve, onLoad: on_load }); + + const chunks_dir = path.resolve('.svelte-kit/output/server/chunks'); + const resolve_chunk = on_resolve.mock.calls.find( + ([options]) => String(options.filter) === String(/\.js$/) + )![1]; + const load_chunk = on_load.mock.calls.find( + ([options]) => options.namespace === 'adapter-bun-side-effect' + )![1]; + + vi.mocked(fs.readFileSync).mockReturnValue("import './other.js';\nexport {};\n"); + const first = resolve_chunk({ + path: './events.js', + resolveDir: chunks_dir, + importer: `${chunks_dir}/a.js` + }); + const second = resolve_chunk({ + path: './events.js', + resolveDir: chunks_dir, + importer: `${chunks_dir}/b.js` + }); + expect(first.namespace).toBe('adapter-bun-side-effect'); + expect(first.path.startsWith(`${chunks_dir}/events.js?`)).toBe(true); + expect(first.path).not.toBe(second.path); + expect(fs.readFileSync).toHaveBeenCalledTimes(1); + + const first_load = load_chunk({ path: first.path }); + const second_load = load_chunk({ path: second.path }); + expect(first_load.contents).toContain("import './other.js';"); + expect(first_load.contents).not.toBe(second_load.contents); + + // chunks with real exports keep their shared identity + vi.mocked(fs.readFileSync).mockReturnValue('export const x = 1;\n'); + expect( + resolve_chunk({ path: './real.js', resolveDir: chunks_dir, importer: `${chunks_dir}/a.js` }) + ).toBeUndefined(); + + // modules outside chunks/ are never rewritten + expect( + resolve_chunk({ + path: './index.js', + resolveDir: path.resolve('.svelte-kit/output/server'), + importer: `${chunks_dir}/a.js` + }) + ).toBeUndefined(); + }); + + test('passes supported advanced options while retaining reserved options', async () => { + await adapter({ + out: 'dist', + buildOptions: { + compile: { outfile: 'application', target: 'bun-linux-x64' }, + minify: true, + bytecode: true, + sourcemap: 'linked', + drop: ['debugger'] + } + }).adapt(create_builder()); + + expect(bun.build.mock.calls[0][0]).toMatchObject({ + outdir: 'dist', + target: 'bun', + format: 'esm', + minify: true, + bytecode: true, + sourcemap: 'linked', + drop: ['debugger'], + compile: { outfile: 'application', target: 'bun-linux-x64' } + }); + }); + + test.each([ + [true, { outfile: 'server' }], + ['bun-linux-x64', { outfile: 'server', target: 'bun-linux-x64' }], + [ + { target: 'bun-windows-x64', windows: { hideConsole: true } }, + { + outfile: 'server', + target: 'bun-windows-x64', + windows: { hideConsole: true } + } + ] + ] as const)('normalizes compile option %j', async (compile, expected) => { + await adapter({ buildOptions: { compile } }).adapt(create_builder()); + + expect(bun.build.mock.calls[0][0].compile).toEqual(expected); + }); + + test('loads instrumentation before the generated server entrypoint', async () => { + const builder = create_builder({ instrumentation: true }); + await adapter().adapt(builder); + + const files = bun.build.mock.calls[0][0].files; + expect(files[index_file]).toBe( + `import ".svelte-kit/output/server/instrumentation.server.js";\nawait import(${JSON.stringify(start_file)});` + ); + expect(files[start_file]).toBe(bun.entrypoint); + expect(builder.instrument).not.toHaveBeenCalled(); + + // start.js must be its own entrypoint so asset paths resolve from the output root + expect(bun.build.mock.calls[0][0].entrypoints).toEqual([index_file, start_file]); + }); + + test('keeps a single entrypoint when compiling with instrumentation', async () => { + const builder = create_builder({ instrumentation: true }); + await adapter({ buildOptions: { compile: true } }).adapt(builder); + + expect(bun.build.mock.calls[0][0].entrypoints).toEqual([index_file]); + }); + + test('reports every Bun diagnostic before failing the build', async () => { + bun.build.mockResolvedValueOnce({ + success: false, + logs: [ + { level: 'error', message: 'broken' }, + { level: 'warning', message: 'careful' }, + { level: 'info', message: 'context' } + ], + outputs: [] + }); + const builder = create_builder(); + + await expect(adapter().adapt(builder)).rejects.toBeInstanceOf(AggregateError); + expect(builder.log.error).toHaveBeenCalledWith('broken'); + expect(builder.log.warn).toHaveBeenCalledWith('careful'); + expect(builder.log.info).toHaveBeenCalledWith('context'); + }); +}); + +describe('generated routes', () => { + test('writes regular-build assets and excludes prerendered routes from server reads', async () => { + const dynamic = { id: '/read', prerender: false }; + const prerendered = { id: '/prerendered', prerender: true }; + const builder = create_builder({ + client_files: ['data.json', '_app/immutable/read.txt'], + prerendered_files: ['page/index.html', 'icon.png'], + prerendered_pages: [['/page/', { file: 'page/index.html' }]], + prerendered_redirects: [['/old', { status: 301, location: '/new' }]], + routes: [dynamic, prerendered], + server_assets: ['_app/immutable/read.txt'] + }); + + await adapter().adapt(builder); + + expect(builder.findServerAssets).toHaveBeenCalledWith([dynamic]); + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).toContain('...client_asset("data.json", undefined, {"hash":"abc","mtime":0})'); + expect(source).toContain( + '...client_asset("_app/immutable/read.txt", undefined, {"hash":"abc","mtime":0})' + ); + expect(source).toContain( + '...prerendered_page("/page/", "page/index.html", {"hash":"abc","mtime":0})' + ); + expect(source).toContain('prerendered_asset("icon.png", undefined, {"hash":"abc","mtime":0})'); + expect(source).toContain('prerendered_redirect("/old", 301, "/new")'); + expect(source).toContain( + '["_app/immutable/read.txt", server_asset("_app/immutable/read.txt")]' + ); + }); + + test('does not prepend the base to prerendered route paths a second time', async () => { + await adapter().adapt( + create_builder({ + base: '/base', + prerendered_files: ['page/index.html'], + prerendered_pages: [['/base/page/', { file: 'page/index.html' }]] + }) + ); + + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).toContain( + '...prerendered_page("/base/page/", "page/index.html", {"hash":"abc","mtime":0})' + ); + expect(source).not.toContain('/base/base/'); + }); + + test('embeds assets in compiled executables and ignores Vite metadata', async () => { + mock_files({ + client: ['data.json', '.vite/manifest.json', '.well-known/asset.txt', '_app/read.txt'], + pages: ['page/index.html', 'favicon.ico'], + dependencies: ['dependency.json'], + data: ['page/__data.json'] + }); + + await adapter({ buildOptions: { compile: true } }).adapt( + create_builder({ + prerendered_pages: [['/page/', { file: 'page/index.html' }]], + server_assets: ['_app/read.txt'] + }) + ); + + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).toContain("with { type: 'file' }"); + expect(source).toContain('...client_asset("data.json", asset_0, {"hash":"abc","mtime":0})'); + expect(source).toContain( + '...client_asset(".well-known/asset.txt", asset_1, {"hash":"abc","mtime":0})' + ); + expect(source).toContain('...prerendered_page("/page/", asset_3, {"hash":"abc","mtime":0})'); + expect(source).toContain('prerendered_asset("favicon.ico", asset_4, {"hash":"abc","mtime":0})'); + expect(source).toContain( + 'prerendered_asset("dependency.json", asset_5, {"hash":"abc","mtime":0})' + ); + expect(source).toContain( + 'prerendered_asset("page/__data.json", asset_6, {"hash":"abc","mtime":0})' + ); + expect(source).toContain('["_app/read.txt", server_asset("_app/read.txt", asset_2)]'); + expect(source).not.toContain('.vite/manifest.json'); + }); + + test.each([false, true])('rejects wildcard filenames when compile is %s', async (compile) => { + if (compile) mock_files({ client: ['literal*.txt'] }); + const builder = create_builder({ client_files: ['literal*.txt'] }); + + await expect(adapter({ buildOptions: { compile } }).adapt(builder)).rejects.toThrow( + 'Bun treats literal `*` characters in route paths as wildcards' + ); + expect(bun.build).not.toHaveBeenCalled(); + }); + + test('precompresses assets and marks the variants in the generated routes', async () => { + const builder = create_builder({ client_files: ['app.js'] }); + + await adapter({ precompress: true }).adapt(builder); + + expect(builder.compress).toHaveBeenCalledWith('build/client'); + expect(builder.compress).toHaveBeenCalledWith('build/prerendered'); + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).toContain( + '...client_asset("app.js", undefined, {"hash":"abc","mtime":0,"br":true,"gz":true})' + ); + }); + + test('warns when precompress is combined with compile', async () => { + const builder = create_builder(); + + await adapter({ precompress: true, buildOptions: { compile: true } }).adapt(builder); + + expect(builder.log.warn).toHaveBeenCalledWith( + expect.stringContaining('precompress is ignored') + ); + expect(builder.compress).not.toHaveBeenCalled(); + }); + + test('does not compress by default', async () => { + const builder = create_builder({ client_files: ['app.js'] }); + + await adapter().adapt(builder); + + expect(builder.compress).not.toHaveBeenCalled(); + }); + + test('does not register dotfiles apart from .well-known', async () => { + const builder = create_builder({ + client_files: ['.env', '.well-known/security.txt', 'ok.txt'] + }); + + await adapter().adapt(builder); + + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).not.toContain('.env'); + expect(source).toContain( + '...client_asset(".well-known/security.txt", undefined, {"hash":"abc","mtime":0})' + ); + expect(source).toContain('...client_asset("ok.txt", undefined, {"hash":"abc","mtime":0})'); + }); + + test('embedded builds tolerate absent output directories but propagate readdir errors', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + await adapter({ buildOptions: { compile: true } }).adapt(create_builder()); + expect(bun.build).toHaveBeenCalledOnce(); + expect(fs.readdirSync).not.toHaveBeenCalled(); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readdirSync).mockImplementation(() => { + throw Object.assign(new Error('denied'), { code: 'EACCES' }); + }); + await expect( + adapter({ buildOptions: { compile: true } }).adapt(create_builder()) + ).rejects.toThrow('denied'); + }); + + test('excludes dotfiles from embedded assets', async () => { + mock_files({ client: ['.secret', 'public.txt'] }); + + await adapter({ buildOptions: { compile: true } }).adapt(create_builder()); + + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).not.toContain('.secret'); + expect(source).toContain('...client_asset("public.txt", asset_0, {"hash":"abc","mtime":0})'); + }); + + test('rejects route segments starting with a colon', async () => { + const builder = create_builder({ client_files: [':tag.txt'] }); + + await expect(adapter().adapt(builder)).rejects.toThrow('starts with `:`'); + expect(bun.build).not.toHaveBeenCalled(); + }); + + test('embedded assets with the same relative path keep distinct imports', async () => { + mock_files({ client: ['page.html'], pages: ['page.html'] }); + + await adapter({ buildOptions: { compile: true } }).adapt( + create_builder({ prerendered_pages: [['/page/', { file: 'page.html' }]] }) + ); + + const source = bun.build.mock.calls[0][0].files[routes_file]; + expect(source).toContain('...client_asset("page.html", asset_0, {"hash":"abc","mtime":0})'); + expect(source).toContain('...prerendered_page("/page/", asset_1, {"hash":"abc","mtime":0})'); + }); + + test('rejects wildcard characters in prerendered redirect sources', async () => { + const builder = create_builder({ + prerendered_redirects: [['/docs/*', { status: 308, location: '/new' }]] + }); + + await expect(adapter().adapt(builder)).rejects.toThrow( + 'Bun treats literal `*` characters in route paths as wildcards' + ); + expect(bun.build).not.toHaveBeenCalled(); + }); + + test('fails when a prerendered page is absent from compiled build output', async () => { + await expect( + adapter({ buildOptions: { compile: true } }).adapt( + create_builder({ prerendered_pages: [['/missing', { file: 'missing.html' }]] }) + ) + ).rejects.toThrow('Could not find prerendered page missing.html for route /missing'); + }); + + test('fails when a server-readable asset is absent from compiled build output', async () => { + await expect( + adapter({ buildOptions: { compile: true } }).adapt( + create_builder({ server_assets: ['missing.txt'] }) + ) + ).rejects.toThrow('Could not find server asset missing.txt'); + }); +}); + +function mock_files({ + client = [], + pages = [], + dependencies = [], + data = [] +}: { + client?: string[]; + pages?: string[]; + dependencies?: string[]; + data?: string[]; +}) { + vi.mocked(fs.readdirSync).mockImplementation((path) => { + const directory = String(path); + const files = directory.endsWith('/client') + ? client + : directory.endsWith('/prerendered/pages') + ? pages + : directory.endsWith('/prerendered/dependencies') + ? dependencies + : data; + + return files.map((file) => { + const segments = file.split('/'); + const name = /** @type {string} */ segments.pop(); + return { + name, + parentPath: [directory, ...segments].join('/'), + isFile: () => true + }; + }) as unknown as ReturnType; + }); +} + +function create_builder({ + client_files = [], + prerendered_files = [], + prerendered_pages = [], + prerendered_redirects = [], + routes = [], + server_assets = [], + base = '', + origin, + instrumentation = false +}: { + client_files?: string[]; + prerendered_files?: string[]; + prerendered_pages?: Array<[string, { file: string }]>; + prerendered_redirects?: Array<[string, { status: number; location: string }]>; + routes?: Array<{ id: string; prerender: boolean | string }>; + server_assets?: string[]; + base?: string; + origin?: string; + instrumentation?: boolean; +} = {}) { + return { + config: { outDir: '.svelte-kit', paths: { base, origin } }, + routes, + prerendered: { + pages: new Map(prerendered_pages), + redirects: new Map(prerendered_redirects) + }, + log: { + minor: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + info: vi.fn() + }, + getServerDirectory: () => '.svelte-kit/output/server', + writeClient: vi.fn(() => client_files), + writePrerendered: vi.fn(() => prerendered_files), + compress: vi.fn(async () => {}), + findServerAssets: vi.fn(() => server_assets), + generateManifest: vi.fn(() => '{"appDir":"_app"}'), + hasServerInstrumentationFile: () => instrumentation, + instrument: vi.fn() + } as any; +} diff --git a/packages/adapter-bun/test/apps/basic/.gitignore b/packages/adapter-bun/test/apps/basic/.gitignore new file mode 100644 index 000000000000..00d16428cb9c --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/.gitignore @@ -0,0 +1,5 @@ +.svelte-kit +build +node_modules +test-results + diff --git a/packages/adapter-bun/test/apps/basic/package.json b/packages/adapter-bun/test/apps/basic/package.json new file mode 100644 index 000000000000..d5c7aec8c807 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/package.json @@ -0,0 +1,20 @@ +{ + "name": "test-bun-basic", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "MY_CUSTOM_PORT=4174 vite build", + "preview": "MY_CUSTOM_PORT=4174 bun ./build", + "prepare": "svelte-kit sync || echo ''", + "test:platform": "playwright test" + }, + "devDependencies": { + "@playwright/test": "catalog:", + "@sveltejs/kit": "workspace:^", + "@sveltejs/vite-plugin-svelte": "catalog:", + "svelte": "catalog:", + "vite": "catalog:" + }, + "type": "module" +} diff --git a/packages/adapter-bun/test/apps/basic/playwright.config.js b/packages/adapter-bun/test/apps/basic/playwright.config.js new file mode 100644 index 000000000000..33d36b651014 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/playwright.config.js @@ -0,0 +1 @@ +export { config as default } from '../../utils.js'; diff --git a/packages/adapter-bun/test/apps/basic/src/app.html b/packages/adapter-bun/test/apps/basic/src/app.html new file mode 100644 index 000000000000..f273cc58f7eb --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/app.html @@ -0,0 +1,11 @@ + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/packages/adapter-bun/test/apps/basic/src/instrumentation.server.js b/packages/adapter-bun/test/apps/basic/src/instrumentation.server.js new file mode 100644 index 000000000000..00368839194c --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/instrumentation.server.js @@ -0,0 +1 @@ +globalThis.__INSTRUMENTATION_RAN__ = true; diff --git a/packages/adapter-bun/test/apps/basic/src/routes/+page.js b/packages/adapter-bun/test/apps/basic/src/routes/+page.js new file mode 100644 index 000000000000..189f71e2e1b3 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/+page.js @@ -0,0 +1 @@ +export const prerender = true; diff --git a/packages/adapter-bun/test/apps/basic/src/routes/+page.svelte b/packages/adapter-bun/test/apps/basic/src/routes/+page.svelte new file mode 100644 index 000000000000..f1ef75ba7a5d --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/+page.svelte @@ -0,0 +1,6 @@ + + +

Hello from Bun!

+ diff --git a/packages/adapter-bun/test/apps/basic/src/routes/data.json/+server.js b/packages/adapter-bun/test/apps/basic/src/routes/data.json/+server.js new file mode 100644 index 000000000000..5dae4aac8917 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/data.json/+server.js @@ -0,0 +1,3 @@ +export function POST() { + return Response.json({ message: 'hello from a server endpoint' }); +} diff --git a/packages/adapter-bun/test/apps/basic/src/routes/event-stream/+server.js b/packages/adapter-bun/test/apps/basic/src/routes/event-stream/+server.js new file mode 100644 index 000000000000..cc152e0033ba --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/event-stream/+server.js @@ -0,0 +1,7 @@ +export function GET() { + return new Response('data: hello\n\n', { + headers: { + 'content-type': 'text/event-stream' + } + }); +} diff --git a/packages/adapter-bun/test/apps/basic/src/routes/instrumented/+server.js b/packages/adapter-bun/test/apps/basic/src/routes/instrumented/+server.js new file mode 100644 index 000000000000..def88b135b5f --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/instrumented/+server.js @@ -0,0 +1,3 @@ +export function GET() { + return new Response(String(globalThis.__INSTRUMENTATION_RAN__ === true)); +} diff --git a/packages/adapter-bun/test/apps/basic/src/routes/platform/+server.js b/packages/adapter-bun/test/apps/basic/src/routes/platform/+server.js new file mode 100644 index 000000000000..fdb393aa68a2 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/platform/+server.js @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; + +/** @type {import('./$types').RequestHandler} */ +export function GET({ getClientAddress, platform }) { + return json({ + address: getClientAddress(), + request: platform?.request instanceof Request, + server: typeof platform?.server?.requestIP === 'function', + id: platform?.server.id, + protocol: platform?.server.protocol, + pendingRequests: platform?.server.pendingRequests, + pendingWebSockets: platform?.server.pendingWebSockets, + subscribers: platform?.server.subscriberCount('adapter-bun-test') + }); +} diff --git a/packages/adapter-bun/test/apps/basic/src/routes/prerendered.ico/+server.js b/packages/adapter-bun/test/apps/basic/src/routes/prerendered.ico/+server.js new file mode 100644 index 000000000000..ea5bb293b791 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/prerendered.ico/+server.js @@ -0,0 +1,7 @@ +export const prerender = true; + +export function GET() { + return new Response(new Uint8Array([0, 0, 1, 0]), { + headers: { 'content-type': 'image/x-icon' } + }); +} diff --git a/packages/adapter-bun/test/apps/basic/src/routes/prerendered/+page.js b/packages/adapter-bun/test/apps/basic/src/routes/prerendered/+page.js new file mode 100644 index 000000000000..ba58d86071e4 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/prerendered/+page.js @@ -0,0 +1,2 @@ +export const prerender = true; +export const trailingSlash = 'always'; diff --git a/packages/adapter-bun/test/apps/basic/src/routes/prerendered/+page.svelte b/packages/adapter-bun/test/apps/basic/src/routes/prerendered/+page.svelte new file mode 100644 index 000000000000..a994afef288b --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/prerendered/+page.svelte @@ -0,0 +1 @@ +

Prerendered

diff --git a/packages/adapter-bun/test/apps/basic/src/routes/read/+server.js b/packages/adapter-bun/test/apps/basic/src/routes/read/+server.js new file mode 100644 index 000000000000..3b844ed60555 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/read/+server.js @@ -0,0 +1,6 @@ +import { read } from '$app/server'; +import file from './file.txt?url&no-inline'; + +export function GET() { + return read(file); +} diff --git a/packages/adapter-bun/test/apps/basic/src/routes/read/file.txt b/packages/adapter-bun/test/apps/basic/src/routes/read/file.txt new file mode 100644 index 000000000000..7ef31b114b64 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/src/routes/read/file.txt @@ -0,0 +1 @@ +Hello from $app/server read diff --git a/packages/adapter-bun/test/apps/basic/static/.well-known/adapter-bun.txt b/packages/adapter-bun/test/apps/basic/static/.well-known/adapter-bun.txt new file mode 100644 index 000000000000..f57fb296b834 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/static/.well-known/adapter-bun.txt @@ -0,0 +1 @@ +adapter bun diff --git a/packages/adapter-bun/test/apps/basic/static/data.json b/packages/adapter-bun/test/apps/basic/static/data.json new file mode 100644 index 000000000000..e3adbd4e3a31 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/static/data.json @@ -0,0 +1 @@ +{ "message": "hello from a static file" } diff --git a/packages/adapter-bun/test/apps/basic/static/encoded name.txt b/packages/adapter-bun/test/apps/basic/static/encoded name.txt new file mode 100644 index 000000000000..4851e9b09386 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/static/encoded name.txt @@ -0,0 +1 @@ +hello from an encoded filename diff --git a/packages/adapter-bun/test/apps/basic/static/sub/index.html b/packages/adapter-bun/test/apps/basic/static/sub/index.html new file mode 100644 index 000000000000..c3b5888bb16d --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/static/sub/index.html @@ -0,0 +1,3 @@ + +Static directory index +

Static directory index

diff --git a/packages/adapter-bun/test/apps/basic/test/browser.test.js b/packages/adapter-bun/test/apps/basic/test/browser.test.js new file mode 100644 index 000000000000..981b5e09e8cc --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/test/browser.test.js @@ -0,0 +1,10 @@ +import { expect, test } from '@playwright/test'; + +test('renders on the server and hydrates in the browser', async ({ page }) => { + await page.goto('/'); + + await expect(page.locator('h1')).toHaveText('Hello from Bun!'); + await expect(page.getByRole('button')).toHaveText('Toggle: false'); + await page.getByRole('button').click(); + await expect(page.getByRole('button')).toHaveText('Toggle: true'); +}); diff --git a/packages/adapter-bun/test/apps/basic/test/server.test.js b/packages/adapter-bun/test/apps/basic/test/server.test.js new file mode 100644 index 000000000000..b22a2f3b0210 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/test/server.test.js @@ -0,0 +1,124 @@ +import { expect, test } from '@playwright/test'; +import process from 'node:process'; + +const compiled = process.env.COMPILE === 'true'; + +test('provides the original request and Bun server on platform', async ({ request }) => { + const response = await request.get('/platform'); + const platform = await response.json(); + + expect(platform.address).toBeTruthy(); + expect(platform.request).toBe(true); + expect(platform.server).toBe(true); + expect(platform.id).toEqual(expect.any(String)); + expect(platform.protocol).toBe('http'); + expect(platform.pendingRequests).toBeGreaterThanOrEqual(1); + expect(platform.pendingWebSockets).toBe(0); + expect(platform.subscribers).toBe(0); +}); + +test('runs server instrumentation before accepting requests', async ({ request }) => { + const response = await request.get('/instrumented'); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('true'); +}); + +test('serves static files and implements HEAD natively', async ({ request }) => { + const response = await request.get('/data.json'); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toContain('application/json'); + expect(await response.json()).toEqual({ message: 'hello from a static file' }); + + const head = await request.head('/data.json'); + expect(head.status()).toBe(200); + expect(head.headers()['content-length']).toBe(response.headers()['content-length']); + expect(await head.text()).toBe(''); +}); + +for (const [url, content_type, body] of [ + ['/sub/', 'text/html;charset=utf-8', 'directory index'], + ['/encoded%20name.txt', 'text/plain;charset=utf-8', 'encoded filename'], + ['/.well-known/adapter-bun.txt', 'text/plain;charset=utf-8', 'adapter bun'] +]) { + test(`serves ${url} with its MIME type`, async ({ request }) => { + const response = await request.get(url); + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe(content_type); + expect(await response.text()).toContain(body); + }); +} + +test('uses Bun conditional requests and byte ranges for filesystem assets', async ({ request }) => { + const initial = await request.get('/data.json'); + const body = await initial.text(); + + if (compiled) { + const etag = initial.headers()['etag']; + expect(etag).toBeTruthy(); + const cached = await request.get('/data.json', { headers: { 'if-none-match': etag } }); + expect(cached.status()).toBe(304); + } else { + const last_modified = initial.headers()['last-modified']; + expect(last_modified).toBeTruthy(); + const cached = await request.get('/data.json', { + headers: { 'if-modified-since': last_modified } + }); + expect(cached.status()).toBe(304); + + const range = await request.get('/data.json', { headers: { range: 'bytes=0-3' } }); + expect(range.status()).toBe(206); + expect(range.headers()['accept-ranges']).toBe('bytes'); + expect(range.headers()['content-range']).toBe(`bytes 0-3/${body.length}`); + expect(await range.text()).toBe(body.slice(0, 4)); + } +}); + +test('serves prerendered pages, endpoints, and canonical redirects', async ({ request }) => { + const page = await request.get('/prerendered/'); + expect(page.status()).toBe(200); + expect(await page.text()).toContain('Prerendered'); + + const icon = await request.get('/prerendered.ico'); + expect(icon.status()).toBe(200); + expect(icon.headers()['content-type']).toBe('image/x-icon'); + expect(await icon.body()).toEqual(Buffer.from([0, 0, 1, 0])); + + const redirect = await request.get('/prerendered?via=test', { maxRedirects: 0 }); + expect(redirect.status()).toBe(308); + expect(redirect.headers()['location']).toBe('/prerendered/?via=test'); +}); + +test('uses SvelteKit for non-GET requests that share a static pathname', async ({ request }) => { + const response = await request.post('/data.json', { + headers: { origin: 'http://localhost:4174' } + }); + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ message: 'hello from a server endpoint' }); +}); + +test('makes imported assets available to $app/server read', async ({ request }) => { + const response = await request.get('/read'); + expect(response.status()).toBe(200); + expect(await response.text()).toBe('Hello from $app/server read\n'); +}); + +test('sets immutable caching only on generated immutable assets', async ({ request }) => { + const document = await request.get('/'); + const asset = /["']([^"']*_app\/immutable\/[^"']+)["']/.exec(await document.text())?.[1]; + expect(asset).toBeTruthy(); + + const immutable = await request.get(/** @type {string} */ (asset)); + expect(immutable.headers()['cache-control']).toBe('public,max-age=31536000,immutable'); + + const regular = await request.get('/data.json'); + expect(regular.headers()['cache-control']).toBeUndefined(); +}); + +test('disables timeouts and proxy buffering for server-sent events', async ({ request }) => { + const events = await request.get('/event-stream'); + expect(events.headers()['content-type']).toContain('text/event-stream'); + expect(events.headers()['x-accel-buffering']).toBe('no'); + + const regular = await request.get('/platform'); + expect(regular.headers()['x-accel-buffering']).toBeUndefined(); +}); diff --git a/packages/adapter-bun/test/apps/basic/tsconfig.json b/packages/adapter-bun/test/apps/basic/tsconfig.json new file mode 100644 index 000000000000..00ef9b61a37b --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "$app/tsconfig", + "include": ["src"] +} diff --git a/packages/adapter-bun/test/apps/basic/vite.config.js b/packages/adapter-bun/test/apps/basic/vite.config.js new file mode 100644 index 000000000000..c90053293e35 --- /dev/null +++ b/packages/adapter-bun/test/apps/basic/vite.config.js @@ -0,0 +1,38 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; +import adapter from '../../../index.js'; + +const buildOptions = + process.env.ADVANCED_COMPILE === 'true' + ? { + compile: { + outfile: 'advanced-app', + ...(process.env.COMPILE_TARGET + ? { + target: /** @type {import('bun').Build.CompileTarget} */ ( + process.env.COMPILE_TARGET + ) + } + : {}) + }, + minify: true, + bytecode: true, + sourcemap: /** @type {const} */ ('linked') + } + : process.env.COMPILE === 'true' + ? { compile: true } + : {}; + +export default defineConfig({ + build: { + minify: false + }, + plugins: [ + sveltekit({ + adapter: adapter({ + envPrefix: 'MY_CUSTOM_', + buildOptions + }) + }) + ] +}); diff --git a/packages/adapter-bun/test/env.spec.ts b/packages/adapter-bun/test/env.spec.ts new file mode 100644 index 000000000000..413e46070ca4 --- /dev/null +++ b/packages/adapter-bun/test/env.spec.ts @@ -0,0 +1,159 @@ +import process from 'node:process'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +const changed = new Set(); + +afterEach(() => { + for (const name of changed) delete process.env[name]; + changed.clear(); + vi.resetModules(); + vi.doUnmock('MANIFEST'); +}); + +describe('env', () => { + test('uses the prefixed value when present and otherwise returns the fallback', async () => { + set_env('APP_PORT', '4000'); + const { env } = await load_env('APP_'); + + expect(env('PORT', '3000')).toBe('4000'); + expect(env('HOST', 'localhost')).toBe('localhost'); + }); + + test('treats an explicitly empty value as present', async () => { + set_env('APP_HOST', ''); + const { env } = await load_env('APP_'); + + expect(env('HOST', 'localhost')).toBe(''); + }); + + test('rejects unexpected variables that use a configured prefix', async () => { + set_env('UNIQUE_ADAPTER_OPTION', 'value'); + + await expect(load_env('UNIQUE_ADAPTER_')).rejects.toThrow( + 'unexpectedly saw UNIQUE_ADAPTER_OPTION' + ); + }); +}); + +describe('boolean_env', () => { + test('parses the accepted truthy and falsy spellings', async () => { + const { boolean_env } = await load_env(); + + for (const value of ['1', 'true', 'TRUE', 'yes', 'YES', 'on', 'ON']) { + set_env('OPTION', value); + expect(boolean_env('OPTION'), value).toBe(true); + } + for (const value of ['0', 'false', 'FALSE', 'no', 'NO', 'off', 'OFF']) { + set_env('OPTION', value); + expect(boolean_env('OPTION'), value).toBe(false); + } + }); + + test('returns the fallback for an absent variable', async () => { + const { boolean_env } = await load_env(); + expect(boolean_env('OPTION', true)).toBe(true); + }); + + test('rejects any other value', async () => { + set_env('OPTION', 'enabled'); + const { boolean_env } = await load_env(); + expect(() => boolean_env('OPTION')).toThrow( + 'Invalid value for environment variable OPTION: "enabled" (expected a boolean)' + ); + }); +}); + +describe('number_env', () => { + test.each([ + ['0', 0], + ['42', 42], + ['9007199254740991', Number.MAX_SAFE_INTEGER] + ])('parses %s as %d', async (value, expected) => { + set_env('OPTION', value); + const { number_env } = await load_env(); + expect(number_env('OPTION')).toBe(expected); + }); + + test('returns the fallback for an absent variable', async () => { + const { number_env } = await load_env(); + expect(number_env('OPTION', 10)).toBe(10); + }); + + test.each(['-1', '+1', '1.5', '1e2', ' 1', ''])( + 'rejects non-integer syntax %j', + async (value) => { + set_env('OPTION', value); + const { number_env } = await load_env(); + expect(() => number_env('OPTION')).toThrow('expected a non-negative integer'); + } + ); + + test('enforces a minimum', async () => { + set_env('OPTION', '0'); + const { number_env } = await load_env(); + expect(() => number_env('OPTION', undefined, { min: 1 })).toThrow( + 'expected an integer at least 1' + ); + }); + + test('enforces a bounded range', async () => { + set_env('OPTION', '256'); + const { number_env } = await load_env(); + expect(() => number_env('OPTION', undefined, { max: 255 })).toThrow( + 'expected an integer between 0 and 255' + ); + }); + + test('rejects integers beyond the safe range', async () => { + set_env('OPTION', '9007199254740992'); + const { number_env } = await load_env(); + expect(() => number_env('OPTION')).toThrow('expected an integer at least 0'); + }); +}); + +describe('bytes_env', () => { + test.each([ + ['0', 0], + ['512', 512], + ['.5K', 512], + ['512K', 512 * 1024], + ['1.5M', 1.5 * 1024 * 1024], + ['2g', 2 * 1024 * 1024 * 1024], + ['Infinity', Infinity] + ])('parses %s as %d bytes', async (value, expected) => { + set_env('OPTION', value); + const { bytes_env } = await load_env(); + expect(bytes_env('OPTION')).toBe(expected); + }); + + test('returns the fallback for an absent variable', async () => { + const { bytes_env } = await load_env(); + expect(bytes_env('OPTION', 512 * 1024)).toBe(512 * 1024); + }); + + test.each(['', '-1', '1KB', '1T', 'one'])('rejects invalid syntax %j', async (value) => { + set_env('OPTION', value); + const { bytes_env } = await load_env(); + expect(() => bytes_env('OPTION')).toThrow('expected a non-negative number'); + }); + + test.each(['0.1', '9007199254740992'])( + 'rejects a non-whole or unsafe byte count %s', + async (value) => { + set_env('OPTION', value); + const { bytes_env } = await load_env(); + expect(() => bytes_env('OPTION')).toThrow('expected a non-negative number of whole bytes'); + } + ); +}); + +async function load_env(prefix = '') { + vi.resetModules(); + vi.doMock('MANIFEST', () => ({ env_prefix: prefix })); + return import('../src/env.js'); +} + +function set_env(name: string, value: string) { + changed.add(name); + process.env[name] = value; +} diff --git a/packages/adapter-bun/test/handler.spec.ts b/packages/adapter-bun/test/handler.spec.ts new file mode 100644 index 000000000000..f277a314f25e --- /dev/null +++ b/packages/adapter-bun/test/handler.spec.ts @@ -0,0 +1,250 @@ +import process from 'node:process'; +import { afterEach, expect, test, vi } from 'vitest'; + +const environment = new Set(); + +afterEach(() => { + for (const name of environment) delete process.env[name]; + environment.clear(); + vi.resetModules(); + vi.doUnmock('SERVER'); + vi.doUnmock('MANIFEST'); + vi.doUnmock('ROUTES'); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test('initializes SvelteKit with Bun environment variables and server-readable assets', async () => { + const loaded = await load_handler(); + + expect(loaded.construct).toHaveBeenCalledWith(loaded.manifest); + expect(loaded.init).toHaveBeenCalledWith({ + env: loaded.bun_env, + read: expect.any(Function) + }); + const { read } = loaded.init.mock.calls[0][0]; + expect(read('asset.txt')).toBe(loaded.stream); + expect(read('missing.txt')).toBeNull(); +}); + +test('normalizes the request origin from the Host header', async () => { + const loaded = await load_handler(); + const original = new Request('http://127.0.0.1:3000/path?query=yes', { + headers: { host: 'public.example:8080' } + }); + + await loaded.handler(original, loaded.bun_server); + + const [request, options] = loaded.respond.mock.calls[0]; + expect(request.url).toBe('https://public.example:8080/path?query=yes'); + expect(options.platform).toEqual({ request: original, server: loaded.bun_server }); + loaded.request_ip.mockReturnValue({ address: '127.0.0.1', port: 5000, family: 'IPv4' }); + expect(options.getClientAddress()).toBe('127.0.0.1'); +}); + +test('uses paths.origin as the trusted request origin when configured', async () => { + const loaded = await load_handler({ origin: 'https://canonical.example' }); + + await loaded.handler(new Request('http://internal/path'), loaded.bun_server); + + expect(loaded.respond.mock.calls[0][0].url).toBe('https://canonical.example/path'); +}); + +test('derives the public origin from configured proxy headers', async () => { + set_env('APP_PROTOCOL_HEADER', 'x-forwarded-proto'); + set_env('APP_HOST_HEADER', 'x-forwarded-host'); + set_env('APP_PORT_HEADER', 'x-forwarded-port'); + const loaded = await load_handler({ envPrefix: 'APP_' }); + const request = new Request('http://internal/path', { + headers: { + 'x-forwarded-proto': 'https', + 'x-forwarded-host': 'public.example', + 'x-forwarded-port': '8443' + } + }); + + await loaded.handler(request, loaded.bun_server); + + expect(loaded.respond.mock.calls[0][0].url).toBe('https://public.example:8443/path'); +}); + +test.each([ + ['APP_PROTOCOL_HEADER', 'x-proto', { 'x-proto': 'https%3A' }, 'invalid protocol scheme'], + ['APP_PROTOCOL_HEADER', 'x-proto', { 'x-proto': 'foo' }, 'invalid protocol scheme'], + ['APP_PORT_HEADER', 'x-port', { 'x-port': 'not-a-port' }, 'invalid port'] +])('returns 400 for an invalid origin from %s', async (name, value, headers, message) => { + set_env(name, value); + const loaded = await load_handler({ envPrefix: 'APP_' }); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const response = await loaded.handler( + new Request('http://internal/path', { headers }), + loaded.bun_server + ); + + expect(response.status).toBe(400); + expect(await response.text()).toBe('Bad Request'); + expect(loaded.respond).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining(message)); +}); + +test('rejects a present but empty Host header', async () => { + const loaded = await load_handler(); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const request = new Request('http://internal/path'); + request.headers.set('host', ''); + + const response = await loaded.handler(request, loaded.bun_server); + + expect(response.status).toBe(400); + expect(loaded.respond).not.toHaveBeenCalled(); + expect(error).toHaveBeenCalledWith(expect.stringContaining('Could not determine host')); +}); + +test('falls back past proxy headers that are present but empty', async () => { + set_env('APP_PROTOCOL_HEADER', 'x-forwarded-proto'); + set_env('APP_HOST_HEADER', 'x-forwarded-host'); + const loaded = await load_handler({ envPrefix: 'APP_' }); + const request = new Request('http://internal/path', { + headers: { 'x-forwarded-proto': '', 'x-forwarded-host': '' } + }); + + await loaded.handler(request, loaded.bun_server); + + expect(loaded.respond.mock.calls[0][0].url).toBe('https://internal/path'); +}); + +test('reads a configured client address header', async () => { + set_env('APP_ADDRESS_HEADER', 'true-client-ip'); + const loaded = await load_handler({ envPrefix: 'APP_' }); + + await loaded.handler( + new Request('http://localhost/', { headers: { 'true-client-ip': '203.0.113.10' } }), + loaded.bun_server + ); + + const get_client_address = loaded.respond.mock.calls[0][1].getClientAddress; + expect(get_client_address()).toBe('203.0.113.10'); +}); + +test('selects a trusted X-Forwarded-For address from the right', async () => { + set_env('APP_ADDRESS_HEADER', 'x-forwarded-for'); + set_env('APP_XFF_DEPTH', '2'); + const loaded = await load_handler({ envPrefix: 'APP_' }); + + await loaded.handler( + new Request('http://localhost/', { + headers: { 'x-forwarded-for': 'spoofed, 203.0.113.10, 10.0.0.2' } + }), + loaded.bun_server + ); + + expect(loaded.respond.mock.calls[0][1].getClientAddress()).toBe('203.0.113.10'); +}); + +test('reports absent and too-short forwarded address headers', async () => { + set_env('APP_ADDRESS_HEADER', 'x-forwarded-for'); + set_env('APP_XFF_DEPTH', '3'); + const loaded = await load_handler({ envPrefix: 'APP_' }); + + await loaded.handler(new Request('http://localhost/'), loaded.bun_server); + let get_client_address = loaded.respond.mock.calls[0][1].getClientAddress; + expect(() => get_client_address()).toThrow( + 'APP_ADDRESS_HEADER=x-forwarded-for but is absent from request' + ); + + loaded.respond.mockClear(); + await loaded.handler( + new Request('http://localhost/', { headers: { 'x-forwarded-for': 'client, proxy' } }), + loaded.bun_server + ); + get_client_address = loaded.respond.mock.calls[0][1].getClientAddress; + expect(() => get_client_address()).toThrow('APP_XFF_DEPTH is 3, but only found 2 addresses'); +}); + +test('returns undefined when Bun cannot determine the peer address', async () => { + const loaded = await load_handler(); + loaded.request_ip.mockReturnValue(null); + + await loaded.handler(new Request('http://localhost/'), loaded.bun_server); + + expect(loaded.respond.mock.calls[0][1].getClientAddress()).toBeUndefined(); +}); + +test('disables timeouts and proxy buffering for event streams', async () => { + const loaded = await load_handler({ + response: new Response('data: ready\n\n', { + headers: { 'content-type': 'text/event-stream; charset=utf-8' } + }) + }); + const request = new Request('http://localhost/events'); + + const response = await loaded.handler(request, loaded.bun_server); + + expect(loaded.timeout).toHaveBeenCalledWith(request, 0); + expect(response.headers.get('x-accel-buffering')).toBe('no'); +}); + +test('leaves ordinary responses and their timeouts unchanged', async () => { + const loaded = await load_handler({ response: new Response('ok') }); + + const response = await loaded.handler(new Request('http://localhost/'), loaded.bun_server); + + expect(loaded.timeout).not.toHaveBeenCalled(); + expect(response.headers.has('x-accel-buffering')).toBe(false); +}); + +async function load_handler({ + origin, + envPrefix = '', + response = new Response('ok') +}: { origin?: string; envPrefix?: string; response?: Response } = {}) { + vi.resetModules(); + const manifest = { appDir: '_app' }; + const bun_env = { PUBLIC_VALUE: 'available' }; + const stream = new ReadableStream(); + const asset = { stream: vi.fn(() => stream) }; + const construct = vi.fn(); + const init = vi.fn(async (_options: any) => {}); + const respond = vi.fn(async (_request: Request, _options: any) => response); + + class Server { + constructor(value: unknown) { + construct(value); + } + init = init; + respond = respond; + } + + vi.doMock('SERVER', () => ({ Server })); + vi.doMock('MANIFEST', () => ({ manifest, origin, env_prefix: envPrefix })); + vi.doMock('ROUTES', () => ({ server_assets: new Map([['asset.txt', asset]]) })); + vi.stubGlobal('Bun', { env: bun_env }); + + const request_ip = vi.fn((_request: Request): any => ({ + address: '127.0.0.1', + port: 5000, + family: 'IPv4' + })); + const timeout = vi.fn((_request: Request, _seconds: number) => {}); + const bun_server = { requestIP: request_ip, timeout } as any; + const { handler } = await import('../src/handler.js'); + + return { + handler, + manifest, + bun_env, + stream, + construct, + init, + respond, + request_ip, + timeout, + bun_server + }; +} + +function set_env(name: string, value: string) { + environment.add(name); + process.env[name] = value; +} diff --git a/packages/adapter-bun/test/routes.spec.ts b/packages/adapter-bun/test/routes.spec.ts new file mode 100644 index 000000000000..9efe18447dcf --- /dev/null +++ b/packages/adapter-bun/test/routes.spec.ts @@ -0,0 +1,303 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, expect, test, vi } from 'vitest'; + +const meta = { hash: 'abc', mtime: 0 }; +// the module resolves assets from its own directory, which is src/ under vitest +const dir = path.dirname(fileURLToPath(new URL('../src/routes-util.js', import.meta.url))); + +afterEach(() => { + vi.resetModules(); + vi.doUnmock('MANIFEST'); + vi.unstubAllGlobals(); +}); + +test('client assets use the configured base and URL-encode path segments', async () => { + const { routes, file } = await load_routes({ base: '/base' }); + + const entries = routes.client_asset('folder/encoded name#1.txt', undefined, meta); + + expect(entries).toHaveLength(1); + expect(entries[0][0]).toBe('/base/folder/encoded%20name%231.txt'); + expect(file).toHaveBeenCalledWith(`${dir}/client/folder/encoded name#1.txt`); + expect(entries[0][1]).toHaveProperty('GET'); + const response = (entries[0][1] as any).GET(new Request('http://localhost/')); + expect(response.headers.get('content-type')).toBe('text/plain;charset=utf-8'); +}); + +test('client index files are also available at their directory URL', async () => { + const { routes } = await load_routes({ base: '/base' }); + + expect(routes.client_asset('index.html', undefined, meta).map(([path]) => path)).toEqual([ + '/base/index.html', + '/base/', + '/base' + ]); + expect(routes.client_asset('docs/index.html', undefined, meta).map(([path]) => path)).toEqual([ + '/base/docs/index.html', + '/base/docs/', + '/base/docs' + ]); +}); + +test('other client HTML files are also available without their extension', async () => { + const { routes } = await load_routes({ base: '/base' }); + + expect(routes.client_asset('page.html', undefined, meta).map(([path]) => path)).toEqual([ + '/base/page.html', + '/base/page' + ]); +}); + +test('sub-delims stay raw in route paths with a fully-encoded alias', async () => { + const { routes } = await load_routes({ base: '/base' }); + + expect(routes.client_asset('a&b.txt', undefined, meta).map(([path]) => path)).toEqual([ + '/base/a&b.txt', + '/base/a%26b.txt' + ]); +}); + +test('route paths use WHATWG serialization, the form user agents send', async () => { + const { routes } = await load_routes({ base: '/base' }); + + expect(routes.client_asset('photo[1]^a|b.png', undefined, meta).map(([path]) => path)).toEqual([ + '/base/photo[1]^a|b.png', + '/base/photo%5B1%5D%5Ea%7Cb.png' + ]); +}); + +test('a root deployment registers routes with a leading slash', async () => { + const { routes } = await load_routes(); + + expect(routes.client_asset('data.json', undefined, meta)[0][0]).toBe('/data.json'); + expect(routes.client_asset('index.html', undefined, meta).map(([path]) => path)).toEqual([ + '/index.html', + '/' + ]); +}); + +test('immutable SvelteKit assets receive a long-lived cache policy', async () => { + const { routes } = await load_routes({ appDir: '_app' }); + + const request = new Request('http://localhost/'); + const immutable = ( + routes.client_asset('_app/immutable/chunk.js', undefined, meta)[0][1] as any + ).GET(request); + const mutable = (routes.client_asset('favicon.ico', undefined, meta)[0][1] as any).GET(request); + + expect(immutable.headers.get('cache-control')).toBe('public,max-age=31536000,immutable'); + expect(mutable.headers.has('cache-control')).toBe(false); +}); + +test('static routes revalidate against the build-time hash', async () => { + const { routes } = await load_routes(); + + const route = routes.client_asset('data.json', undefined, meta)[0][1] as any; + + const fresh = route.GET(new Request('http://localhost/data.json')); + expect(fresh.status).toBe(200); + expect(fresh.headers.get('etag')).toBe('"abc"'); + + const revalidated = route.GET( + new Request('http://localhost/data.json', { headers: { 'if-none-match': '"abc"' } }) + ); + expect(revalidated.status).toBe(304); + expect(revalidated.headers.get('etag')).toBe('"abc"'); + + const weak = route.GET( + new Request('http://localhost/data.json', { headers: { 'if-none-match': 'W/"abc", "other"' } }) + ); + expect(weak.status).toBe(304); + + const stale = route.GET( + new Request('http://localhost/data.json', { headers: { 'if-none-match': '"old"' } }) + ); + expect(stale.status).toBe(200); + + const wildcard = route.GET( + new Request('http://localhost/data.json', { headers: { 'if-none-match': '*' } }) + ); + expect(wildcard.status).toBe(304); +}); + +test('static routes revalidate by date when the client has no ETag', async () => { + const { routes } = await load_routes(); + + const route = routes.client_asset('data.json', undefined, meta)[0][1] as any; + + const fresh = route.GET(new Request('http://localhost/data.json')); + expect(fresh.headers.get('last-modified')).toBe('Thu, 01 Jan 1970 00:00:00 GMT'); + + const dated = route.GET( + new Request('http://localhost/data.json', { + headers: { 'if-modified-since': 'Thu, 01 Jan 1970 00:00:00 GMT' } + }) + ); + expect(dated.status).toBe(304); + + const stale_etag_wins = route.GET( + new Request('http://localhost/data.json', { + headers: { + 'if-modified-since': 'Thu, 01 Jan 1970 00:00:00 GMT', + 'if-none-match': '"old"' + } + }) + ); + expect(stale_etag_wins.status).toBe(200); +}); + +test('static routes answer HEAD with the same handler', async () => { + const { routes } = await load_routes(); + + const route = routes.client_asset('data.json', undefined, meta)[0][1] as any; + expect(route.HEAD).toBe(route.GET); +}); + +test('precompressed variants are negotiated with their own validators', async () => { + const { routes, file } = await load_routes(); + + const route = routes.client_asset('app.js', undefined, { + hash: 'abc', + mtime: 0, + br: true, + gz: true + })[0][1] as any; + + const br = route.GET( + new Request('http://localhost/app.js', { headers: { 'accept-encoding': 'br, gzip' } }) + ); + expect(br.headers.get('content-encoding')).toBe('br'); + expect(br.headers.get('etag')).toBe('"abc-br"'); + expect(br.headers.get('vary')).toBe('accept-encoding'); + expect(file).toHaveBeenLastCalledWith(`${dir}/client/app.js.br`); + + const gzip = route.GET( + new Request('http://localhost/app.js', { headers: { 'accept-encoding': 'br;q=0, gzip' } }) + ); + expect(gzip.headers.get('content-encoding')).toBe('gzip'); + expect(gzip.headers.get('etag')).toBe('"abc-gz"'); + expect(file).toHaveBeenLastCalledWith(`${dir}/client/app.js.gz`); + + const any = route.GET( + new Request('http://localhost/app.js', { headers: { 'accept-encoding': '*' } }) + ); + expect(any.headers.get('content-encoding')).toBe('br'); + + const identity = route.GET(new Request('http://localhost/app.js')); + expect(identity.headers.has('content-encoding')).toBe(false); + expect(identity.headers.get('etag')).toBe('"abc"'); + + const revalidated = route.GET( + new Request('http://localhost/app.js', { + headers: { 'accept-encoding': 'br', 'if-none-match': '"abc-br"' } + }) + ); + expect(revalidated.status).toBe(304); +}); + +test('range requests are served from the identity representation', async () => { + const { routes, file } = await load_routes(); + + const route = routes.client_asset('app.js', undefined, { + hash: 'abc', + mtime: 0, + br: true + })[0][1] as any; + const response = route.GET( + new Request('http://localhost/app.js', { + headers: { 'accept-encoding': 'br', range: 'bytes=0-9' } + }) + ); + + expect(response.headers.has('content-encoding')).toBe(false); + expect(response.headers.get('etag')).toBe('"abc"'); + expect(file).toHaveBeenLastCalledWith(`${dir}/client/app.js`); +}); + +test('embedded routes use the imported asset instead of a filesystem path', async () => { + const { routes, file } = await load_routes({ embed: true }); + + routes.client_asset('asset.txt', '/embedded/client.txt', meta); + routes.prerendered_asset('asset.txt', '/embedded/prerendered.txt', meta); + const server_file = routes.server_asset('asset.txt', '/embedded/server.txt'); + + expect(file).toHaveBeenNthCalledWith(1, '/embedded/client.txt'); + expect(file).toHaveBeenNthCalledWith(2, '/embedded/prerendered.txt'); + expect(file).toHaveBeenNthCalledWith(3, '/embedded/server.txt'); + expect(server_file).toMatchObject({ path: '/embedded/server.txt' }); +}); + +test('server assets resolve from the client output in regular builds', async () => { + const { routes, file } = await load_routes(); + + const result = routes.server_asset('nested/read.txt'); + + expect(file).toHaveBeenCalledWith(`${dir}/client/nested/read.txt`); + expect(result).toMatchObject({ path: `${dir}/client/nested/read.txt` }); +}); + +test('prerendered assets use the base path and preserve their content type', async () => { + const { routes, file } = await load_routes({ base: '/base' }); + file.mockImplementationOnce((path) => ({ path, type: 'image/x-icon' })); + + const [[path, handler]] = routes.prerendered_asset('icon.ico', undefined, meta); + + expect(path).toBe('/base/icon.ico'); + const response = (handler as any).GET(new Request('http://localhost/base/icon.ico')); + expect(response.headers.get('content-type')).toBe('image/x-icon'); +}); + +test.each([ + ['/page/', '/page', '/page/?from=test'], + ['/page', '/page/', '/page?from=test'] +])( + 'prerendered page %s redirects its alternate form %s to the canonical URL', + async (canonical, alternate, location) => { + const { routes } = await load_routes(); + const entries = routes.prerendered_page(canonical, 'page.html', meta); + + expect(entries[0][0]).toBe(canonical); + expect(entries[1][0]).toBe(alternate); + expect((entries[1][1] as any).HEAD).toBe((entries[1][1] as any).GET); + const response = (entries[1][1] as any).GET( + new Request(`http://localhost${alternate}?from=test`) + ); + expect(response.status).toBe(308); + expect(response.headers.get('location')).toBe(location); + } +); + +test('redirects to non-ASCII canonical URLs use a percent-encoded location', async () => { + const { routes } = await load_routes(); + const entries = routes.prerendered_page('/café/', 'cafe.html', meta); + + const response = (entries[1][1] as any).GET(new Request('http://localhost/caf%C3%A9')); + expect(response.headers.get('location')).toBe('/caf%C3%A9/'); +}); + +test('a prerendered root page has no duplicate alternate route', async () => { + const { routes } = await load_routes(); + + expect(routes.prerendered_page('/', 'index.html', meta)).toHaveLength(1); +}); + +test('prerendered redirects retain their status and location', async () => { + const { routes } = await load_routes(); + + const [[path, handler]] = routes.prerendered_redirect('/old path', 307, '/new'); + + expect(path).toBe('/old%20path'); + expect((handler as any).GET.status).toBe(307); + expect((handler as any).GET.headers.get('location')).toBe('/new'); + expect((handler as any).HEAD).toBe((handler as any).GET); +}); + +async function load_routes({ base = '/', embed = false, appDir = '_app' } = {}) { + vi.resetModules(); + vi.doMock('MANIFEST', () => ({ manifest: { appDir }, base, embed })); + const file = vi.fn((path: string) => ({ path, type: 'text/plain;charset=utf-8' })); + vi.stubGlobal('Bun', { file }); + + return { routes: await import('../src/routes-util.js'), file }; +} diff --git a/packages/adapter-bun/test/start.spec.ts b/packages/adapter-bun/test/start.spec.ts new file mode 100644 index 000000000000..59ae924624f1 --- /dev/null +++ b/packages/adapter-bun/test/start.spec.ts @@ -0,0 +1,202 @@ +import { afterEach, expect, test, vi } from 'vitest'; + +afterEach(() => { + vi.resetModules(); + vi.doUnmock('node:fs'); + vi.doUnmock('node:process'); + vi.doUnmock('MANIFEST'); + vi.doUnmock('ROUTES'); + vi.doUnmock('SERVER_OPTIONS'); + vi.doUnmock('../src/handler.js'); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test('starts Bun with production defaults and generated request routes', async () => { + const loaded = await load_start(); + + expect(loaded.serve).toHaveBeenCalledWith( + expect.objectContaining({ + development: false, + port: 3000, + maxRequestBodySize: 512 * 1024, + fetch: loaded.handler, + routes: loaded.routes + }) + ); + expect(loaded.log).toHaveBeenCalledWith('Listening on http://localhost:3000/'); +}); + +test('environment variables override TCP server defaults', async () => { + const loaded = await load_start({ + serverOptions: { + hostname: 'default-host', + port: 3000, + reusePort: false, + ipv6Only: false, + idleTimeout: 10, + maxRequestBodySize: 1000, + development: false + }, + env: { + APP_HOST: '127.0.0.1', + APP_PORT: '4000', + APP_REUSE_PORT: 'true', + APP_IPV6_ONLY: 'yes', + APP_CONNECTION_IDLE_TIMEOUT: '30', + APP_BODY_SIZE_LIMIT: '2M', + APP_DEVELOPMENT: 'on' + }, + envPrefix: 'APP_' + }); + + expect(loaded.serve).toHaveBeenCalledWith( + expect.objectContaining({ + hostname: '127.0.0.1', + port: '4000', + reusePort: true, + ipv6Only: true, + idleTimeout: 30, + maxRequestBodySize: 2 * 1024 * 1024, + development: true + }) + ); +}); + +test('a Unix socket takes precedence over TCP-only options', async () => { + const loaded = await load_start({ + serverOptions: { + hostname: 'default-host', + port: 3000, + reusePort: true, + ipv6Only: true + }, + env: { SOCKET_PATH: '/tmp/application.sock' } + }); + + const options = loaded.serve.mock.calls[0][0]; + expect(options.unix).toBe('/tmp/application.sock'); + expect(options).not.toHaveProperty('hostname'); + expect(options).not.toHaveProperty('port'); + expect(options).not.toHaveProperty('reusePort'); + expect(options).not.toHaveProperty('ipv6Only'); + expect(loaded.log).toHaveBeenCalledWith('Listening on /tmp/application.sock'); +}); + +test('removes a stale socket file before listening', async () => { + const statSync = vi.fn(() => ({ size: 0 })); + const rmSync = vi.fn(); + vi.doMock('node:fs', () => ({ default: { statSync, rmSync } })); + + await load_start({ env: { SOCKET_PATH: '/tmp/application.sock' } }); + + expect(rmSync).toHaveBeenCalledWith('/tmp/application.sock'); +}); + +test.each([ + [{ CONNECTION_IDLE_TIMEOUT: '256' }, 'between 0 and 255'], + [{ BODY_SIZE_LIMIT: '1.1' }, 'whole bytes'], + [{ DEVELOPMENT: 'sometimes' }, 'expected a boolean'] +])('rejects invalid startup environment %j', async (env, message) => { + await expect(load_start({ env })).rejects.toThrow(message); +}); + +test.each(['SIGINT', 'SIGTERM'] as const)( + 'gracefully stops the server and emits sveltekit:shutdown for %s', + async (signal) => { + const loaded = await load_start({ pendingRequests: 2 }); + + await loaded.listeners.get(signal)?.(); + + expect(loaded.stop).toHaveBeenCalledOnce(); + expect(loaded.emit).toHaveBeenCalledWith('sveltekit:shutdown', signal); + expect(loaded.log).toHaveBeenCalledWith( + expect.stringContaining('Waiting for 2 requests to finish before shutting down...') + ); + } +); + +test('force-closes lingering connections after SHUTDOWN_TIMEOUT', async () => { + vi.useFakeTimers(); + try { + const loaded = await load_start({ + env: { SHUTDOWN_TIMEOUT: '5' }, + stop: () => new Promise(() => {}) + }); + + const shutdown = loaded.listeners.get('SIGTERM')?.(); + await vi.advanceTimersByTimeAsync(5000); + expect(loaded.stop).toHaveBeenCalledTimes(2); + expect(loaded.stop).toHaveBeenLastCalledWith(true); + expect(loaded.emit).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1000); + await shutdown; + expect(loaded.emit).toHaveBeenCalledWith('sveltekit:shutdown', 'SIGTERM'); + } finally { + vi.useRealTimers(); + } +}); + +test('a second shutdown signal forces the process to exit', async () => { + let finish_stop: (() => void) | undefined; + const loaded = await load_start({ + stop: () => new Promise((resolve) => (finish_stop = resolve)) + }); + + const first = loaded.listeners.get('SIGINT')?.(); + await loaded.listeners.get('SIGTERM')?.(); + expect(loaded.exit).toHaveBeenCalledWith(1); + + finish_stop?.(); + await first; +}); + +async function load_start({ + serverOptions = {}, + env = {}, + envPrefix = '', + pendingRequests = 0, + stop: stop_implementation +}: { + serverOptions?: Record; + env?: Record; + envPrefix?: string; + pendingRequests?: number; + stop?: () => Promise; +} = {}) { + vi.resetModules(); + const listeners = new Map Promise | void>(); + const emit = vi.fn(); + const exit = vi.fn(); + const fake_process = { + env, + on: vi.fn((name: string, callback: () => Promise | void) => + listeners.set(name, callback) + ), + emit, + exit + }; + vi.doMock('node:process', () => ({ default: fake_process })); + vi.doMock('MANIFEST', () => ({ env_prefix: envPrefix })); + + const routes = { '/asset': { GET: new Response('asset') } }; + const handler = vi.fn(); + vi.doMock('ROUTES', () => ({ routes })); + vi.doMock('SERVER_OPTIONS', () => ({ default: serverOptions })); + vi.doMock('../src/handler.js', () => ({ handler })); + + const stop = vi.fn(stop_implementation ?? (async () => {})); + const server = { + url: new URL('http://localhost:3000'), + pendingRequests, + stop + }; + const serve = vi.fn((_options: any) => server); + vi.stubGlobal('Bun', { serve }); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await import('../src/index.js'); + + return { listeners, emit, exit, routes, handler, stop, serve, log }; +} diff --git a/packages/adapter-bun/test/utils.js b/packages/adapter-bun/test/utils.js new file mode 100644 index 000000000000..212bd3745797 --- /dev/null +++ b/packages/adapter-bun/test/utils.js @@ -0,0 +1,30 @@ +import { devices } from '@playwright/test'; +import process from 'node:process'; +import { number_from_env } from '../../../test-utils/index.js'; + +const compiled = process.env.COMPILE === 'true'; +const port = 4174; + +/** @type {import('@playwright/test').PlaywrightTestConfig} */ +export const config = { + forbidOnly: !!process.env.CI, + timeout: process.env.CI ? 45000 : 15000, + webServer: { + command: compiled + ? `bun run --bun build && MY_CUSTOM_PORT=${port} ./build/server` + : 'bun run --bun build && bun run preview', + port + }, + retries: process.env.CI ? 2 : number_from_env('KIT_E2E_RETRIES', 0), + projects: [{ name: 'chromium' }], + use: { + ...devices['Desktop Chrome'], + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + channel: process.env.KIT_E2E_BROWSER ?? 'chromium' + }, + workers: process.env.CI ? 2 : number_from_env('KIT_E2E_WORKERS', undefined), + reporter: 'list', + testDir: 'test', + testMatch: /(.+\.)?(test|spec)\.[jt]s/ +}; diff --git a/packages/adapter-bun/tests/types.ts b/packages/adapter-bun/tests/types.ts new file mode 100644 index 000000000000..f5918c0e8c4e --- /dev/null +++ b/packages/adapter-bun/tests/types.ts @@ -0,0 +1,44 @@ +import adapter from '../index.js'; + +adapter(); + +adapter({ + out: 'dist', + envPrefix: 'APP_', + serverOptions: { + development: false, + hostname: '127.0.0.1', + port: 4000, + idleTimeout: 30, + maxRequestBodySize: 1024, + reusePort: true, + ipv6Only: false + }, + buildOptions: { + compile: { outfile: 'application', target: 'bun-linux-x64' }, + minify: true, + bytecode: true, + sourcemap: 'linked', + drop: ['debugger'] + } +}); + +adapter({ + serverOptions: { unix: '/tmp/application.sock' }, + buildOptions: { compile: false } +}); + +adapter({ + buildOptions: { + compile: true, + // @ts-expect-error the adapter reserves the top-level runtime target + target: 'node' + } +}); + +adapter({ + serverOptions: { + // @ts-expect-error the generated server owns its fetch handler + fetch: () => new Response('custom') + } +}); diff --git a/packages/adapter-bun/tsconfig.json b/packages/adapter-bun/tsconfig.json new file mode 100644 index 000000000000..d97024f0a4a0 --- /dev/null +++ b/packages/adapter-bun/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "noEmit": true, + "target": "es2022", + "module": "node16", + "moduleResolution": "node16", + "skipLibCheck": true, + "paths": { + "@sveltejs/kit": ["../kit/types/index"] + }, + "types": ["bun-types", "node"] + }, + "include": [ + "index.js", + "vitest.config.js", + "src/**/*.js", + "test/*.js", + "test/*.ts", + "tests/**/*.js", + "tests/**/*.ts", + "./internal.d.ts" + ] +} diff --git a/packages/adapter-bun/vitest.config.js b/packages/adapter-bun/vitest.config.js new file mode 100644 index 000000000000..34663efc6c8b --- /dev/null +++ b/packages/adapter-bun/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/*.spec.ts'] + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb600534f2a7..e5274880dec7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -142,6 +142,46 @@ importers: specifier: 'catalog:' version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + packages/adapter-bun: + dependencies: + bun-types: + specifier: ^1.3.14 + version: 1.3.14 + devDependencies: + '@playwright/test': + specifier: 'catalog:' + version: 1.62.1 + '@sveltejs/kit': + specifier: workspace:^ + version: link:../kit + '@types/node': + specifier: 'catalog:' + version: 22.19.19 + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@22.19.19)(@vitest/browser-playwright@4.1.10)(jsdom@30.0.1)(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + + packages/adapter-bun/test/apps/basic: + devDependencies: + '@playwright/test': + specifier: 'catalog:' + version: 1.62.1 + '@sveltejs/kit': + specifier: workspace:^ + version: link:../../../../kit + '@sveltejs/vite-plugin-svelte': + specifier: 'catalog:' + version: 7.3.0(svelte@5.56.8(@typescript-eslint/types@8.61.1))(vite@8.2.1(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0)) + svelte: + specifier: 'catalog:' + version: 5.56.8(@typescript-eslint/types@8.61.1) + vite: + specifier: 'catalog:' + version: 8.2.1(@types/node@22.19.19)(esbuild@0.28.1)(jiti@2.4.2)(yaml@2.9.0) + packages/adapter-cloudflare: dependencies: '@cloudflare/workers-types': @@ -3300,6 +3340,9 @@ packages: resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} engines: {node: 20 || >=22} + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -5960,6 +6003,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + bun-types@1.3.14: + dependencies: + '@types/node': 22.19.19 + cac@7.0.0: {} chai@6.2.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5fdfdee07738..4c0c083d3321 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -37,6 +37,7 @@ engineStrict: true packages: - packages/* + - packages/adapter-bun/test/apps/* - packages/adapter-cloudflare/test/apps/* - packages/adapter-netlify/test/apps/* - packages/adapter-node/test/apps/*