sveltekit-adapter-middy 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,42 +29,179 @@ Creates a lambda that supports a Function URL with streaming responses.
29
29
  - Extendable with Middy middlewares:
30
30
  - `http-content-encoding`
31
31
  - `http-security-headers`
32
- - `ssm`/`secrets-manger`
32
+ - `ssm`/`secrets-manager`
33
33
  - Removes `x-sveltekit-page` headers
34
34
  - Multiple `Set-Cookies`
35
- - Override `Origin` header with `ORIGIN` env
35
+ - Trusted request origin via `HEADER_ORIGIN`, keeping SvelteKit's CSRF check intact
36
+ - Lambda event and context available as `platform`
37
+ - Server instrumentation (`src/instrumentation.server.js`, eg OpenTelemetry)
36
38
 
37
- Note: Bring your own deployment.
39
+ Note: Bring your own deployment. The build writes one bundle per lambda to `out/<name>.mjs` (`index.mjs`, plus one per split entry), each exporting `handler`, so the Lambda handler setting is `index.handler`. The response is streamed, so create the Function URL with `InvokeMode: RESPONSE_STREAM`.
38
40
 
39
41
  ## Getting started
40
42
 
41
43
  ```bash
42
- npm i -D sveltekit-adapter-middy
44
+ npm i -D sveltekit-adapter-middy @middy/core
43
45
  ```
44
46
 
47
+ Requires SvelteKit 3, Middy 8, and Node.js 26 (to build and as the Lambda runtime). `@middy/core` is a peer dependency: the handler is bundled from your project, so it uses your copy.
48
+
49
+ As of October 2026, the Lambda `nodejs26.x` runtime is in public preview, which AWS says not to use for production. Until it's generally available, you can set `esbuildOptions: { target: 'node24' }` and deploy on `nodejs24.x`.
50
+
45
51
  ```js
52
+ // vite.config.js
53
+ import { sveltekit } from '@sveltejs/kit/vite'
46
54
  import adapter from 'sveltekit-adapter-middy'
47
55
 
48
56
  export default {
49
- kit: {
50
- adapter: adapter({
51
- // options
57
+ plugins: [
58
+ sveltekit({
59
+ adapter: adapter({
60
+ // options
61
+ })
52
62
  })
53
- }
63
+ ]
54
64
  }
55
65
  ```
56
66
 
57
67
  ### Options
58
68
 
59
- - `handlerPath` (string): Relative path to handler override file. Overriding allows you to add in Content-Encoding, Security Headers, and pass in secrets more securely. Defaults to build-in minimalist handler.
69
+ - `handlerPath` (string): Relative path to handler override file. Overriding allows you to add in Content-Encoding, Security Headers, and pass in secrets more securely. Defaults to the built-in minimalist handler. See [Handler resolution](#handler-resolution).
60
70
  - `out` (string): Relative path to build dir. Defaults to `build`
61
- - `esbuildOptions` (object): `esbuild` option overrides. See [code]() for defaults.
71
+ - `esbuildOptions` (object): `esbuild` option overrides. See [`index.js`](index.js) for defaults (ESM bundle targeting `node26`).
72
+ - `split` (object): Route splitting, `name` to route prefix. Defaults to `{}` (single lambda). `index` is reserved for the remainder lambda.
73
+
74
+ ### Request details
75
+
76
+ - `getClientAddress()` returns `requestContext.http.sourceIp`, the TCP peer, unless `XFF_DEPTH` is set. A Function URL is public, so anyone can call it with their own `x-forwarded-for`. Set `XFF_DEPTH` to the number of trusted proxies in front of the lambda (`1` for CloudFront) to use the entry that many places from the right. A trailing entry equal to `sourceIp` is skipped, since that's the Function URL recording its own peer rather than a proxy you trust. A value below `1` is treated as unset. Only do this when those proxies are the only way in, eg the Function URL uses `AWS_IAM` auth with CloudFront origin access control.
77
+ - `platform` is `{ event, context }`: the Function URL event and the Lambda context, including anything middleware added (eg `ssm` with `setToContext`). Read it as `event.platform` in hooks, `load` and actions, and type it by declaring `App.Platform` in `src/app.d.ts`. Outside Lambda (`vite dev`, prerendering) it is `{ event: {}, context: {} }`, so reads don't throw, but values middleware adds only exist in Lambda.
78
+ - `HEADER_ORIGIN` should be the origin browsers use, eg `https://example.com`. Without it the url origin comes from the `host` header, which behind CloudFront is the Function URL domain, so every form post fails the CSRF check. A warning is logged at cold start when it's unset.
79
+ - `read()` from `$app/server` is not supported. Static assets are written to `out/assets` for S3/CloudFront rather than bundled into the lambda, so there's no file for it to read. Using it fails the build rather than the request.
80
+ - A body on a `GET` or `HEAD` is dropped; `Request` refuses to carry one.
81
+ - Function URLs move the `cookie` header into `event.cookies`; it's joined back into `cookie` for SvelteKit.
82
+ - `src/instrumentation.server.js` is bundled into each lambda and runs at cold start, after the private env is set and before any app code. OpenTelemetry auto-instrumentation patches modules as they load, so it can only patch what isn't bundled: Node.js built-ins, and any package you mark `esbuildOptions.external` and ship with the lambda (eg in a layer).
83
+ - `request.signal` aborts when Middy's early timeout fires, so fetches in `load` can stop work the response will never carry.
84
+ - AWS documents the query string characters Function URLs support as `a-z A-Z 0-9 . _ - % & = +`, which leaves out a raw `/`. `sveltekitMiddleware` encodes the `?/action` form action URLs SvelteKit renders. It rewrites every `action="?/` in the html, so text that reads that way, eg a code sample, comes out as `action="?%2F` too. That only covers html rendered on the server: a form rendered in the browser after client-side navigation still has `action="?/login"`, and `use:enhance` posts to it. Write form actions pre-encoded, eg `<form method="POST" action="?%2Flogin">`, so they work either way. Encode any `/` in your own query strings too, eg `?redirectTo=%2Fadmin`, or in `fetch('?%2Faction')` calls from client code.
85
+
86
+ ### Route splitting
87
+
88
+ Build separate lambdas per route prefix, each with its own handler (and so its own middleware, IAM role, memory, and concurrency).
89
+
90
+ ```js
91
+ adapter({
92
+ split: {
93
+ // `/admin/*` -> build/admin.mjs, using the default handler
94
+ admin: '/admin',
95
+ // `/api/*` -> build/api.mjs, using a custom handler
96
+ api: { prefix: '/api', handlerPath: './lambda/api.js' }
97
+ }
98
+ })
99
+ ```
100
+
101
+ Routes not matching any prefix go to `build/index.mjs`, as before. Static assets and prerendered pages are unaffected.
102
+
103
+ Prefixes match SvelteKit **route ids**, not request paths, so param segments are written out literally:
104
+
105
+ ```js
106
+ adapter({
107
+ split: {
108
+ // src/routes/[[lang]]/admin/**
109
+ admin: '/[[lang]]/admin',
110
+ // anything a prefix can't express
111
+ api: (route) => /(^|\/)api(\/|$)/.test(route.id)
112
+ }
113
+ })
114
+ ```
115
+
116
+ When prefixes overlap, the longest string prefix wins, so `/admin/reports` beats `/admin` whatever the key order. Function prefixes are tried after every string prefix, in the order given. An entry that matches no routes logs a warning; it's usually a typo, such as a trailing `/`.
117
+
118
+ Routing requests to the right lambda is up to your infrastructure (eg CloudFront behaviours per path pattern). Note a localised prefix needs a pattern per shape — `/admin*` and `/*/admin*` for an optional `[[lang]]`. A split lambda only knows its own routes, so anything else sent to it renders a 404.
119
+
120
+ ### Handler resolution
121
+
122
+ Each entry picks its handler from the first of these that exists. Specific beats global, and within each tier explicit beats implicit:
123
+
124
+ 1. that entry's own `handlerPath`
125
+ 2. `handler.js` beside the route it serves, eg `src/routes/admin/handler.js` for prefix `/admin`
126
+ 3. the top-level `handlerPath`
127
+ 4. `src/handler.js`
128
+ 5. the built-in minimalist handler
129
+
130
+ So once a route has a `split` entry, its middleware stack needs no more config. Drop a `handler.js` next to the route (one beside a route that no `split` entry matches is not used):
131
+
132
+ ```js
133
+ // src/routes/admin/handler.js — only this lambda loads these parameters
134
+ import middy from '@middy/core'
135
+ import { executionModeStreamifyResponse } from '@middy/core/StreamifyResponse'
136
+ import ssm from '@middy/ssm'
137
+ import sveltekitHandler from './sveltekitHandler.js'
138
+ import sveltekitMiddleware from './sveltekitMiddleware.js'
139
+
140
+ export const handler = middy({ executionMode: executionModeStreamifyResponse })
141
+ .use([
142
+ // `event.platform.context.dbUrl` in hooks, `load` and actions
143
+ ssm({ fetchData: { dbUrl: '/app/admin/db-url' }, setToContext: true }),
144
+ sveltekitMiddleware()
145
+ ])
146
+ .handler(sveltekitHandler)
147
+ ```
148
+
149
+ Notes:
150
+
151
+ - Keep `sveltekitMiddleware()` last in `.use([...])`. Middy runs `after` hooks in reverse order, so it then rewrites the html before anything else sees it. Compression from `http-content-encoding` listed after it would run first, and the rewrite would corrupt the compressed bytes.
152
+ - `./sveltekitHandler.js` and `./sveltekitMiddleware.js` always resolve to the adapter's copies in the build directory, not to files next to your handler. Any other import, relative or bare, resolves from where your handler lives.
153
+ - A `handlerPath` that doesn't exist throws rather than silently falling back.
154
+ - Prefixes match route ids, so layout groups are ignored: `src/routes/(app)/admin/handler.js` serves prefix `/admin`.
155
+ - A function prefix has no route directory, so it never picks up a route-local `handler.js`. Give it a `handlerPath`.
156
+ - SvelteKit ignores non-`+` files in the routes directory, so a colocated `handler.js` doesn't become a route.
62
157
 
63
158
  ## Recommended Infrastructure
64
159
 
65
- - CloudFront: Route to static assets / pages, with fallback to server side rendering
160
+ - CloudFront: Route to static assets / pages, with fallback to server side rendering. Not all of `/_app/*` is static: send `/_app/immutable/*` to S3, but remote function calls (`query`, `command`, `form`) go to `/_app/remote/*` and must reach the lambda (both under `paths.base`, with `_app` being `appDir`)
66
161
  - S3: store static assets and pages
67
- - Lambda Function URL: server side rendering
162
+ - Lambda Function URL: server side rendering, with `InvokeMode: RESPONSE_STREAM`, and with `AWS_IAM` auth and CloudFront origin access control so CloudFront is the only way in
163
+
164
+ ## Upgrading to 0.5
165
+
166
+ This release requires SvelteKit 3, Middy 8, and Node.js 26; use 0.4 for SvelteKit 2. `@middy/core` is now a peer dependency, so install it in your project: `npm i -D @middy/core`.
167
+
168
+ Changes that need action:
169
+
170
+ - **Set `XFF_DEPTH=1` behind CloudFront.** `getClientAddress()` now ignores `x-forwarded-for` unless `XFF_DEPTH` is set, because anyone calling the Function URL directly could forge it. Without it you get CloudFront's edge ip.
171
+
172
+ Fixes that change behaviour:
173
+
174
+ - Lambda event and context now reach SvelteKit as `platform`, so values middleware puts on the context are usable in hooks, `load` and actions.
175
+ - esbuild warnings and errors are reported through the SvelteKit build log, once.
176
+ - Request cookies now reach SvelteKit. They arrive in `event.cookies` and were previously dropped.
177
+ - Streamed pages flush their shell as soon as it renders, instead of waiting for the next chunk.
178
+ - An html response with no body (`HEAD`, `204`) no longer crashes the lambda.
179
+ - Overlapping split prefixes resolve to the longest one rather than the first declared.
180
+ - A `handler.js` in a root layout group (`src/routes/(app)/handler.js`) now serves prefix `/`.
181
+
182
+ Server instrumentation (`src/instrumentation.server.js`) is supported; previously the build failed with `adapter_instrumentation_unsupported`.
183
+
184
+ `event.platform` is `{ event: {}, context: {} }` under `vite dev` and while prerendering, rather than `undefined`.
185
+
186
+ A request `Request` can't represent, eg a header value beyond Latin-1, gets a `400` rather than failing the lambda.
187
+
188
+ Build logs and SvelteKit errors name the adapter `sveltekit-adapter-middy`, its package name, rather than `@middy/sveltekit`.
189
+
190
+ Types now ship with the package, and the package resolves for `require()` as well as `import`.
191
+
192
+ A custom handler is bundled from where it lives, so it can import your own project code by relative path.
193
+
194
+ The server is now built with `builder.generateServerInstance`, so the build directory has `server.js` (exporting a ready `server`) in place of `manifest.js`. The bundled `sveltekitMiddleware.js` already uses it; a custom handler that imported `./manifest.js` or `Server` from `./index.js` should import `{ server }` from `./server.js` instead.
195
+
196
+ ## Upgrading to 0.4
197
+
198
+ Two behaviour changes need action:
199
+
200
+ **Set `HEADER_ORIGIN`.** It previously overwrote the client's `Origin` header and was used as the request url origin, which made SvelteKit's CSRF check compare a value against itself — cross-site form posts were never rejected. The url origin now comes from `HEADER_ORIGIN` (or `host`) and the client's header is left alone, so the check works. If `HEADER_ORIGIN` doesn't match the origin browsers actually use, your own form posts will start returning 403.
201
+
202
+ **`read()` from `$app/server` now fails the build.** It was declared as supported but never wired up, so it failed at runtime instead. If a route uses it, either drop the call or fetch the asset from your CDN.
203
+
204
+ Also: a request body arriving on a `GET`/`HEAD` is dropped rather than throwing, `content-encoding` is no longer misread as a body encoding (`gzip` threw), and `getClientAddress()` returns a single ip rather than the raw `x-forwarded-for` list.
68
205
 
69
206
  ## Roadmap
70
207
 
package/index.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ // Copyright 2026 will Farrell, and sveltekit-adapter-middy contributors.
2
+ // SPDX-License-Identifier: MIT
3
+ import type { Adapter, RouteDefinition } from "@sveltejs/kit";
4
+ import type { BuildOptions } from "esbuild";
5
+
6
+ type Prefix = string | ((route: RouteDefinition) => boolean);
7
+
8
+ interface SplitEntry {
9
+ prefix: Prefix;
10
+ handlerPath?: string;
11
+ }
12
+
13
+ export interface Options {
14
+ out?: string;
15
+ handlerPath?: string;
16
+ esbuildOptions?: BuildOptions;
17
+ split?: Record<string, Prefix | SplitEntry>;
18
+ }
19
+
20
+ declare function sveltekitAdapterMiddy(opts?: Options): Adapter;
21
+ export default sveltekitAdapterMiddy;
package/index.js CHANGED
@@ -1,18 +1,92 @@
1
1
  // Copyright 2026 will Farrell, and sveltekit-adapter-middy contributors.
2
2
  // SPDX-License-Identifier: MIT
3
- import { writeFileSync } from "node:fs";
3
+ import { existsSync, readdirSync, writeFileSync } from "node:fs";
4
+ import { basename, dirname, join, sep } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import esbuild from "esbuild";
6
7
 
7
- const name = "@middy/sveltekit";
8
- const files = fileURLToPath(new URL("./", import.meta.url).href);
8
+ const name = "sveltekit-adapter-middy";
9
+ const files = fileURLToPath(new URL("./", import.meta.url));
10
+
11
+ // `src/routes/admin/handler.js` -> `/admin`, matching the route id: layout
12
+ // groups `(app)` are stripped, param segments `[[lang]]` are kept as written
13
+ const routeHandlers = (routesDir) =>
14
+ new Map(
15
+ readdirSync(routesDir, { recursive: true })
16
+ .filter((file) => basename(file) === "handler.js")
17
+ .map((file) => [
18
+ `/${dirname(file)
19
+ .split(sep)
20
+ .filter((segment) => segment !== "." && !segment.startsWith("("))
21
+ .join("/")}`,
22
+ join(routesDir, file),
23
+ ]),
24
+ );
25
+
26
+ // Specific beats global, explicit beats implicit: the entry's own `handlerPath`,
27
+ // then a handler beside the route, then the top-level `handlerPath`, then
28
+ // `src/handler.js`, then the built-in. A configured path that isn't there throws
29
+ // rather than silently deploying the wrong middleware stack.
30
+ export const resolveHandler = (entry, { routesDir, handlerPath, builtin }) => {
31
+ const configured = (path) => {
32
+ if (!existsSync(path)) {
33
+ throw new Error(`${name}: handlerPath not found: ${path}`);
34
+ }
35
+ return path;
36
+ };
37
+
38
+ if (entry.handlerPath) return configured(entry.handlerPath);
39
+ // Function prefixes and `index` (no prefix) never match a route id key
40
+ const local = routeHandlers(routesDir).get(entry.prefix);
41
+ if (local) return local;
42
+ if (handlerPath) return configured(handlerPath);
43
+ const project = join(dirname(routesDir), "handler.js");
44
+ return existsSync(project) ? project : builtin;
45
+ };
46
+
47
+ // Routes matching an entry prefix go to that entry, everything else to `index`.
48
+ // Prefixes match route ids, so param segments are written out: `/[[lang]]/admin`.
49
+ // A function prefix covers anything a string can't express.
50
+ const matches = ({ prefix }, route) =>
51
+ typeof prefix === "function"
52
+ ? prefix(route)
53
+ : route.id === prefix || route.id.startsWith(`${prefix}/`);
54
+
55
+ // Specific beats global: the longest string prefix is tried first, function
56
+ // prefixes last in the order given
57
+ const specificity = ({ prefix }) =>
58
+ typeof prefix === "string" ? prefix.length : -1;
59
+
60
+ export const splitRoutes = (routes, entries) => {
61
+ const split = { index: [] };
62
+ for (const { name } of entries) split[name] = [];
63
+ const ordered = entries.toSorted((a, b) => specificity(b) - specificity(a));
64
+ for (const route of routes) {
65
+ const owner = ordered.find((entry) => matches(entry, route));
66
+ split[owner?.name ?? "index"].push(route);
67
+ }
68
+ return split;
69
+ };
70
+
71
+ // Stryker disable all: build glue (log text, esbuild options), exercised by the adapt tests
72
+
73
+ // The handler is bundled where it lives, so its own relative imports resolve;
74
+ // only the adapter's files point into the build directory
75
+ const adapterFiles = (dir) => ({
76
+ name: "adapter-middy",
77
+ setup(build) {
78
+ build.onResolve(
79
+ { filter: /^\.\/sveltekit(Handler|Middleware)\.js$/ },
80
+ ({ path }) => ({ path: join(dir, path) }),
81
+ );
82
+ },
83
+ });
9
84
 
10
85
  const sveltekitAdapterMiddy = (opts = {}) => {
11
- const {
12
- out = "build",
13
- handlerPath = `${files}/handler.js`,
14
- esbuildOptions = {},
15
- } = opts;
86
+ const { out = "build", handlerPath, esbuildOptions = {}, split = {} } = opts;
87
+ if (Object.hasOwn(split, "index")) {
88
+ throw new Error(`${name}: split entry name "index" is reserved`);
89
+ }
16
90
 
17
91
  return {
18
92
  name,
@@ -23,65 +97,102 @@ const sveltekitAdapterMiddy = (opts = {}) => {
23
97
  builder.rimraf(tmp);
24
98
 
25
99
  builder.log.minor("Copying static assets");
26
- const _clientFiles = await builder.writeClient(
27
- `${out}/assets${builder.config.kit.paths.base}`,
28
- );
100
+ await builder.writeClient(`${out}/assets${builder.config.paths.base}`);
29
101
 
30
- builder.log.minor("Building server");
31
102
  await builder.writeServer(tmp);
32
- writeFileSync(
33
- `${tmp}/manifest.js`,
34
- [
35
- `export const manifest = ${builder.generateManifest({
36
- relativePath: "./",
37
- })};`,
38
- `export const prerendered = new Set(${JSON.stringify(
39
- builder.prerendered.paths,
40
- )});`,
41
- `export const base = ${JSON.stringify(
42
- builder.config.kit.paths.base,
43
- )};`,
44
- ].join("\n\n"),
45
- );
46
103
 
47
104
  builder.copy(
48
- `${files}/sveltekitMiddleware.js`,
105
+ `${files}sveltekitMiddleware.js`,
49
106
  `${tmp}/sveltekitMiddleware.js`,
50
107
  );
51
- builder.copy(
52
- `${files}/sveltekitHandler.js`,
53
- `${tmp}/sveltekitHandler.js`,
108
+ builder.copy(`${files}sveltekitHandler.js`, `${tmp}/sveltekitHandler.js`);
109
+
110
+ const entries = Object.entries(split).map(([name, value]) => {
111
+ const config = typeof value === "object" ? value : { prefix: value };
112
+ return {
113
+ name,
114
+ prefix: config.prefix,
115
+ handlerPath: config.handlerPath,
116
+ };
117
+ });
118
+ // Like Kit's default, fully prerendered routes stay out of the server
119
+ const routes = splitRoutes(
120
+ builder.routes.filter((route) => route.prerender !== true),
121
+ entries,
54
122
  );
55
- builder.copy(handlerPath, `${tmp}/handler.js`);
123
+ for (const { name: entryName } of entries) {
124
+ if (routes[entryName].length === 0) {
125
+ builder.log.warn(
126
+ `${name}: split entry ${entryName} matches no routes`,
127
+ );
128
+ }
129
+ }
130
+ entries.push({ name: "index" });
56
131
 
57
- try {
58
- const result = await esbuild.build({
59
- target: "node24",
60
- bundle: true,
61
- platform: "node",
62
- format: "esm",
63
- treeShaking: true,
64
- ...esbuildOptions,
65
- entryPoints: [`${tmp}/handler.js`],
66
- outfile: `${out}/index.mjs`,
67
- external: ["node:*", ...(esbuildOptions?.external ?? [])],
132
+ // Instrumentation (eg OpenTelemetry) loads before the app, with
133
+ // the private env (`$app/env/private`) set. One bundle, so the app shares its env module.
134
+ const initializer =
135
+ builder.hasServerInstrumentationFile() &&
136
+ builder.createInstrumentationInitializer({
137
+ outputDirectory: tmp,
138
+ serverDirectory: tmp,
68
139
  });
140
+ const instrumented = (entryHandler) => {
141
+ const entry = join(tmp, "instrumented.js");
142
+ writeFileSync(
143
+ entry,
144
+ [
145
+ `import ${JSON.stringify(initializer)};`,
146
+ `import ${JSON.stringify(join(tmp, "instrumentation.server.js"))};`,
147
+ `export const { handler } = await import(${JSON.stringify(entryHandler)});`,
148
+ ].join("\n"),
149
+ );
150
+ return entry;
151
+ };
69
152
 
70
- if (result.warnings.length > 0) {
71
- const formatted = await esbuild.formatMessages(result.warnings, {
72
- kind: "warning",
73
- color: true,
153
+ const resolveOptions = {
154
+ routesDir: builder.config.files.routes,
155
+ handlerPath,
156
+ builtin: `${files}handler.js`,
157
+ };
158
+
159
+ try {
160
+ for (const entry of entries) {
161
+ const entryHandler = resolveHandler(entry, resolveOptions);
162
+ builder.log.minor(`Building server: ${entry.name} (${entryHandler})`);
163
+ builder.generateServerInstance(`${tmp}/server.js`, {
164
+ routes: routes[entry.name],
165
+ serverDirectory: tmp,
166
+ });
167
+ const result = await esbuild.build({
168
+ target: "node26",
169
+ bundle: true,
170
+ platform: "node",
171
+ format: "esm",
172
+ treeShaking: true,
173
+ // Reported once, through the builder log, below
174
+ logLevel: "silent",
175
+ ...esbuildOptions,
176
+ entryPoints: [
177
+ initializer ? instrumented(entryHandler) : entryHandler,
178
+ ],
179
+ outfile: `${out}/${entry.name}.mjs`,
180
+ external: ["node:*", ...(esbuildOptions?.external ?? [])],
181
+ plugins: [adapterFiles(tmp), ...(esbuildOptions?.plugins ?? [])],
74
182
  });
75
183
 
76
- console.error(formatted.join("\n"));
184
+ const warnings = await esbuild.formatMessages(result.warnings, {
185
+ kind: "warning",
186
+ });
187
+ for (const warning of warnings) builder.log.warn(warning);
77
188
  }
78
189
  } catch (err) {
79
- const formatted = await esbuild.formatMessages(err.errors, {
190
+ // Only esbuild failures carry `errors`, anything else is rethrown as is
191
+ if (!Array.isArray(err?.errors)) throw err;
192
+ const errors = await esbuild.formatMessages(err.errors, {
80
193
  kind: "error",
81
- color: true,
82
194
  });
83
-
84
- console.error(formatted.join("\n"));
195
+ for (const error of errors) builder.log.error(error);
85
196
 
86
197
  throw new Error(
87
198
  `Bundling with esbuild failed with ${err.errors.length} ${
@@ -91,13 +202,17 @@ const sveltekitAdapterMiddy = (opts = {}) => {
91
202
  }
92
203
 
93
204
  builder.log.minor("Prerendering static pages");
94
- const _prerenderedFiles = await builder.writePrerendered(
95
- `${out}/prerendered${builder.config.kit.paths.base}`,
205
+ await builder.writePrerendered(
206
+ `${out}/prerendered${builder.config.paths.base}`,
96
207
  );
97
208
  },
98
209
  supports: {
99
- read: () => true,
210
+ read: () => false,
211
+ instrumentation: () => true,
100
212
  },
213
+ // `vite dev` and prerendering run outside Lambda; middleware values on
214
+ // `context` only exist there
215
+ emulate: () => ({ platform: () => ({ event: {}, context: {} }) }),
101
216
  };
102
217
  };
103
218
  export default sveltekitAdapterMiddy;
package/package.json CHANGED
@@ -1,28 +1,27 @@
1
1
  {
2
2
  "name": "sveltekit-adapter-middy",
3
- "version": "0.2.0",
3
+ "version": "0.5.0",
4
4
  "description": "SvelteKit adapter for AWS Lambda with Middy",
5
5
  "type": "module",
6
+ "packageManager": "npm@12.0.1+sha512.L5T9i/YAQWQQWqTS/xZxJkei/9zcu99hCeE4qi41IyBVV7mRQad3qc2JfuOktwmH+qwGI/V2rbCL+/UYxb1+RQA==",
6
7
  "engines": {
7
- "node": ">=24"
8
+ "node": ">=26"
8
9
  },
9
- "engineStrict": true,
10
10
  "workspaces": [
11
11
  ".github"
12
12
  ],
13
13
  "publishConfig": {
14
14
  "access": "public"
15
15
  },
16
- "module": "./index.js",
17
16
  "exports": {
18
17
  ".": {
19
- "import": {
20
- "default": "./index.js"
21
- }
18
+ "types": "./index.d.ts",
19
+ "default": "./index.js"
22
20
  }
23
21
  },
24
22
  "files": [
25
23
  "index.js",
24
+ "index.d.ts",
26
25
  "handler.js",
27
26
  "sveltekitMiddleware.js",
28
27
  "sveltekitHandler.js"
@@ -31,11 +30,11 @@
31
30
  "prepare": "husky",
32
31
  "git:pre-commit": "npm run git:lint-staged && npm run git:test-staged",
33
32
  "git:commit-msg": "commitlint --config commitlint.config.cjs --edit",
34
- "git:lint-staged": "npm run test:lint",
35
- "git:unit-staged": "node --test",
33
+ "git:lint-staged": "biome check --staged --no-errors-on-unmatched",
34
+ "git:unit-staged": "node --test ./*.test.js",
36
35
  "git:test-staged": "npm run git:unit-staged",
37
36
  "lint": "biome check --write --no-errors-on-unmatched",
38
- "test": "npm run test:lint && npm run test:unit && npm run test:mutation && npm run test:types && npm run test:sast && npm run test:perf && npm run test:dast",
37
+ "test": "npm run test:lint && npm run test:unit && npm run test:e2e && npm run test:mutation && npm run test:types && npm run test:sast && npm run test:bench && npm run test:dast",
39
38
  "test:sast": "npm run test:sast:license && npm run test:sast:lockfile && npm run test:sast:semgrep && npm run test:sast:trufflehog && npm run test:sast:gitleaks && npm run test:sast:actionlint && npm run test:sast:zizmor && npm run test:sast:trivy",
40
39
  "test:sast:actionlint": "actionlint",
41
40
  "test:sast:gitleaks": "npm run test:sast:gitleaks:dir && npm run test:sast:gitleaks:git",
@@ -44,22 +43,26 @@
44
43
  "test:sast:license": "license-check-and-add check -f .license.config.json",
45
44
  "test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https",
46
45
  "test:sast:semgrep": "semgrep scan --config auto --error",
47
- "test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,Python-2.0,LGPL-3.0-or-later,MPL-2.0,BlueOak-1.0.0,Unlicense --exit-code 1 --skip-files '**/bun.lock' --disable-telemetry .",
46
+ "test:sast:trivy": "trivy fs --scanners vuln,license --include-dev-deps --ignored-licenses 0BSD,Apache-2.0,BSD-1-Clause,BSD-2-Clause,BSD-3-Clause,CC0-1.0,CC-BY-4.0,ISC,MIT,Python-2.0,LGPL-3.0-or-later,MPL-2.0,BlueOak-1.0.0,Unlicense --exit-code 1 --disable-telemetry .",
48
47
  "test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./",
49
48
  "test:sast:zizmor": "zizmor .github",
50
- "test:lint": "biome check --staged --no-errors-on-unmatched",
51
- "test:unit": "node --test --test-force-exit --experimental-test-coverage --test-coverage-lines=80 --test-coverage-branches=70 --test-coverage-functions=80 --test-coverage-exclude=sveltekitMiddleware.js --test-coverage-exclude=handler.js --test-coverage-exclude=index.js ./*.test.js",
49
+ "test:lint": "biome ci --no-errors-on-unmatched",
50
+ "test:unit": "node --test --test-force-exit --experimental-test-coverage --test-coverage-lines=100 --test-coverage-branches=100 --test-coverage-functions=100 --test-coverage-include=index.js --test-coverage-include=sveltekitHandler.js ./*.test.js",
51
+ "test:e2e": "node --test ./e2e/*.test.js",
52
52
  "test:mutation": "stryker run",
53
53
  "test:types": "tstyche",
54
- "test:perf": "node --test --test-concurrency=1 ./*.perf.js",
54
+ "test:bench": "node ./handler.bench.js",
55
55
  "test:dast": "npm run test:dast:fuzz",
56
56
  "test:dast:fuzz": "node --test ./*.fuzz.js",
57
57
  "rm": "npm run rm:macos && npm run rm:node_modules && npm run rm:lock",
58
58
  "rm:macos": "find . -name '.DS_Store' -type f -delete",
59
59
  "rm:lock": "find . -name 'package-lock.json' -type f -delete",
60
60
  "rm:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
61
+ "release:staged": "npm whoami >/dev/null && npm stage list $(npm pkg get name | tr -d '\"') --json | jq -r '.[] | \"\\(.packageName)@\\(.version) \\(.actor) \\(.shasum)\"'",
62
+ "release:approve": "npm stage list $(npm pkg get name | tr -d '\"') --json | jq -r --arg v \"$npm_package_version\" '.[] | select(.version == $v) | .id' | xargs -n1 -o npm stage approve",
61
63
  "release:license:add": "license-check-and-add add -f .license.config.json",
62
- "release:license:remove": "license-check-and-add remove -f .license.config.json"
64
+ "release:license:remove": "license-check-and-add remove -f .license.config.json",
65
+ "release:tag": "git tag -s -m $npm_package_version $npm_package_version"
63
66
  },
64
67
  "license": "MIT",
65
68
  "keywords": [
@@ -70,7 +73,6 @@
70
73
  "Serverless",
71
74
  "AWS",
72
75
  "AWS Lambda",
73
- "Middy",
74
76
  "HTTP",
75
77
  "SSR"
76
78
  ],
@@ -80,8 +82,7 @@
80
82
  },
81
83
  "repository": {
82
84
  "type": "git",
83
- "url": "git+https://github.com/willfarrell/sveltekit-adapter-middy.git",
84
- "directory": ""
85
+ "url": "git+https://github.com/willfarrell/sveltekit-adapter-middy.git"
85
86
  },
86
87
  "bugs": {
87
88
  "url": "https://github.com/willfarrell/sveltekit-adapter-middy/issues"
@@ -92,23 +93,27 @@
92
93
  "url": "https://github.com/sponsors/willfarrell"
93
94
  },
94
95
  "devDependencies": {
95
- "@biomejs/biome": "^2.5.3",
96
- "@commitlint/cli": "^21.2.1",
97
- "@commitlint/config-conventional": "^21.2.0",
98
- "@stryker-mutator/core": "^9.0.0",
99
- "fast-check": "^4.9.0",
100
- "husky": "^9.1.7",
96
+ "@biomejs/biome": "^2.0.0",
97
+ "@commitlint/cli": "^21.0.0",
98
+ "@commitlint/config-conventional": "^21.0.0",
99
+ "@stryker-mutator/core": "^10.0.0",
100
+ "fast-check": "^4.0.0",
101
+ "husky": "^9.0.0",
101
102
  "license-check-and-add": "4.0.5",
102
- "tinybench": "^6.0.2",
103
- "tstyche": "^7.2.1",
104
- "typescript": "^6.0.3"
103
+ "tstyche": "^7.0.0",
104
+ "typescript": "^6.0.0"
105
105
  },
106
106
  "overrides": {
107
- "qs": "^6.15.2"
107
+ "qs": "^6.15.2",
108
+ "brace-expansion": "^5.0.12",
109
+ "fast-uri": "^3.1.7"
110
+ },
111
+ "peerDependencies": {
112
+ "@middy/core": "^8.0.0-alpha.1",
113
+ "@sveltejs/kit": "^3.0.0"
108
114
  },
109
115
  "dependencies": {
110
- "@datastream/string": "0.5.0",
111
- "@middy/core": "^7.7.0",
112
- "esbuild": "^0.28.1"
113
- }
116
+ "esbuild": "^0.28.0"
117
+ },
118
+ "types": "./index.d.ts"
114
119
  }
@@ -2,64 +2,87 @@
2
2
  // SPDX-License-Identifier: MIT
3
3
  import { Readable } from "node:stream";
4
4
 
5
- const lambdaHandler = async (event, context, { signal: _signal }) => {
6
- const { server, env: _env } = context;
7
- const { headers, rawQueryString, body: rawBody, isBase64Encoded } = event;
8
- const { method, path } = event.requestContext.http;
5
+ const lambdaHandler = async (event, context, { signal }) => {
6
+ const { server } = context;
7
+ const {
8
+ headers,
9
+ cookies,
10
+ rawQueryString,
11
+ body: rawBody,
12
+ isBase64Encoded,
13
+ } = event;
14
+ const { method, path, sourceIp } = event.requestContext.http;
9
15
 
10
- // Override origin
11
- headers.origin =
12
- process.env.HEADER_ORIGIN ?? headers.origin ?? "https://example.com";
16
+ // The url origin must come from a trusted source. SvelteKit's CSRF check
17
+ // rejects form posts where the `origin` header doesn't match `url.origin`,
18
+ // so deriving one from the other would make the check unfailable. Set
19
+ // `HEADER_ORIGIN` in production; `host` is only as trustworthy as what fronts it.
20
+ const origin =
21
+ process.env.HEADER_ORIGIN ?? `https://${headers.host ?? "example.com"}`;
13
22
 
14
23
  const queryString = rawQueryString ? `?${rawQueryString}` : "";
15
- const url = `${headers.origin}${path}${queryString}`;
24
+ const url = `${origin}${path}${queryString}`;
16
25
 
17
- // Buffer.from defaults a missing encoding to utf-8
18
- const encoding = isBase64Encoded ? "base64" : headers["content-encoding"];
26
+ // `content-encoding` is a compression scheme, not a Buffer encoding
19
27
  const body =
20
- typeof rawBody === "string" ? Buffer.from(rawBody, encoding) : rawBody;
28
+ isBase64Encoded && rawBody ? Buffer.from(rawBody, "base64") : rawBody;
21
29
 
22
- const rendered = await server.respond(
23
- new Request(url, {
30
+ // Anyone can call a public Function URL with their own `x-forwarded-for`, so
31
+ // only the `XFF_DEPTH` entries appended by trusted proxies (eg CloudFront: 1)
32
+ // count. Unset, the TCP peer is the client.
33
+ const xffDepth = Math.max(0, Number(process.env.XFF_DEPTH) || 0);
34
+
35
+ // The header object goes straight in: building a `Headers` first means
36
+ // Request copies every header twice
37
+ let request;
38
+ try {
39
+ request = new Request(url, {
24
40
  method,
25
- headers: new Headers(headers),
26
- body,
27
- }),
28
- {
29
- getClientAddress() {
30
- return headers["x-forwarded-for"];
31
- },
32
- },
33
- );
41
+ signal,
42
+ headers,
43
+ // Request throws if a GET/HEAD carries one, however it arrived
44
+ body: method === "GET" || method === "HEAD" ? null : body,
45
+ });
46
+ // Function URLs move the `cookie` header into `event.cookies`
47
+ if (cookies?.length) request.headers.set("cookie", cookies.join("; "));
48
+ } catch {
49
+ // Only the client's input can fail here, eg a header beyond Latin-1
50
+ return { statusCode: 400, body: "" };
51
+ }
34
52
 
35
- if (rendered) {
36
- const response = {
37
- statusCode: rendered.status,
38
- headers: {
39
- "cache-control": "no-cache",
40
- },
41
- body: "",
42
- };
53
+ const rendered = await server.respond(request, {
54
+ platform: { event, context },
55
+ getClientAddress() {
56
+ const forwarded =
57
+ headers["x-forwarded-for"]?.split(",").map((ip) => ip.trim()) ?? [];
58
+ // The Function URL may append its own peer, which no trusted proxy wrote
59
+ if (forwarded.at(-1) === sourceIp) forwarded.pop();
60
+ return (xffDepth && forwarded.at(-xffDepth)) || sourceIp;
61
+ },
62
+ });
43
63
 
44
- for (const [key, value] of rendered.headers.entries()) {
45
- if (key === "set-cookie") {
46
- response.cookies ??= [];
47
- response.cookies.push(value);
48
- } else if (key !== "x-sveltekit-page") {
49
- // `x-sveltekit-page` excluded, security
50
- response.headers[key] = value;
51
- }
52
- }
64
+ const response = {
65
+ statusCode: rendered.status,
66
+ headers: {
67
+ "cache-control": "no-cache",
68
+ },
69
+ body: "",
70
+ };
53
71
 
54
- if (rendered.body) {
55
- response.body = Readable.fromWeb(rendered.body);
72
+ for (const [key, value] of rendered.headers.entries()) {
73
+ if (key === "set-cookie") {
74
+ response.cookies ??= [];
75
+ response.cookies.push(value);
76
+ } else if (key !== "x-sveltekit-page") {
77
+ // `x-sveltekit-page` excluded, security
78
+ response.headers[key] = value;
56
79
  }
80
+ }
57
81
 
58
- return response;
82
+ if (rendered.body) {
83
+ response.body = Readable.fromWeb(rendered.body);
59
84
  }
60
85
 
61
- return {
62
- statusCode: 404,
63
- };
86
+ return response;
64
87
  };
65
88
  export default lambdaHandler;
@@ -1,28 +1,82 @@
1
1
  // Copyright 2026 will Farrell, and sveltekit-adapter-middy contributors.
2
2
  // SPDX-License-Identifier: MIT
3
- import { stringReplaceStream } from "@datastream/string";
4
- import { Server } from "./index.js"; // From build directory
5
- import { manifest } from "./manifest.js"; // From build directory
3
+ import { pipeline, Transform } from "node:stream";
4
+ import { StringDecoder } from "node:string_decoder";
5
+ import { server } from "./server.js"; // From build directory
6
6
 
7
- const server = new Server(manifest);
8
- const init = server.init({ env: process.env });
7
+ // Started at cold start; a failure is dropped so the next request retries
8
+ const start = () =>
9
+ server.init({ env: process.env }).catch((err) => {
10
+ init = undefined;
11
+ throw err;
12
+ });
13
+ let init = start();
14
+ // Handled here, as no request awaits it yet; the failure is dropped, so the
15
+ // first request starts init again
16
+ init.catch(() => {});
9
17
 
10
- const formActionPattern = /action="\?\//g;
18
+ if (!process.env.HEADER_ORIGIN) {
19
+ console.warn(
20
+ "sveltekit-adapter-middy: HEADER_ORIGIN is not set, the url origin falls back to the `host` header. Behind CloudFront that is the Function URL domain, and form posts will fail the CSRF check.",
21
+ );
22
+ }
23
+
24
+ const formAction = 'action="?/';
11
25
  const formActionReplacement = 'action="?%2F';
26
+
27
+ // Length of the suffix of `text` that could be the start of a `formAction`
28
+ // completed by the next chunk. `formAction` holds a single `a`, so only the
29
+ // suffix from the last `a` can be one.
30
+ const partialMatchLength = (text) => {
31
+ const suffix = text.slice(text.lastIndexOf("a"));
32
+ return formAction.startsWith(suffix) ? suffix.length : 0;
33
+ };
34
+
35
+ // Emits each chunk as it arrives, holding back only a possible partial match,
36
+ // so streamed pages still flush their shell before awaited data resolves.
37
+ // Decoding here rather than with `setEncoding` saves a utf8 round trip per
38
+ // chunk, since a Transform turns written strings back into Buffers.
39
+ const formActionStream = () => {
40
+ let tail = "";
41
+ const decoder = new StringDecoder(); // utf8
42
+ return new Transform({
43
+ transform(chunk, _encoding, callback) {
44
+ const text = (tail + decoder.write(chunk)).replaceAll(
45
+ formAction,
46
+ formActionReplacement,
47
+ );
48
+ const split = text.length - partialMatchLength(text);
49
+ tail = text.slice(split);
50
+ callback(null, text.slice(0, split));
51
+ },
52
+ flush(callback) {
53
+ callback(null, tail + decoder.end());
54
+ },
55
+ });
56
+ };
57
+
12
58
  const sveltekitMiddleware = () => {
13
59
  const sveltekitMiddlewareBefore = async (request) => {
14
60
  request.context.server = server;
61
+ init ??= start();
15
62
  await init;
16
63
  };
17
64
 
18
65
  const sveltekitMiddlewareAfter = async (request) => {
19
66
  // Workaround: AWS Function URLs doesn't support querystring keys that contain `/`
20
- if (request.response.headers?.["content-type"]?.includes("text/html")) {
21
- const stream = stringReplaceStream({
22
- pattern: formActionPattern,
23
- replacement: formActionReplacement,
24
- });
25
- request.response.body = request.response.body.pipe(stream);
67
+ // A string body is the empty one `sveltekitHandler` sets, nothing to rewrite
68
+ if (
69
+ typeof request.response.body !== "string" &&
70
+ request.response.headers?.["content-type"]?.includes("text/html")
71
+ ) {
72
+ // `pipeline` passes a render error on, so the response ends rather than hangs
73
+ request.response.body = pipeline(
74
+ request.response.body,
75
+ formActionStream(),
76
+ () => {},
77
+ );
78
+ // The rewrite lengthens the body; streamed, it needs no length
79
+ delete request.response.headers["content-length"];
26
80
  }
27
81
  };
28
82
  return {