sveltekit-adapter-middy 0.4.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,43 +29,59 @@ 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
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. See [Handler resolution](#handler-resolution).
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.
62
- - `split` (object): Route splitting, `name` to route prefix. Defaults to `{}` (single lambda).
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.
63
73
 
64
74
  ### Request details
65
75
 
66
- - `getClientAddress()` returns the rightmost entry of `x-forwarded-for`, falling back to `requestContext.http.sourceIp`. CloudFront appends the viewer ip to any list a client supplies, so the rightmost entry is the one a client can't forge. This assumes exactly one trusted hop in front of the lambda — add another proxy and the trustworthy entry moves.
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.
67
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.
68
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.
69
85
 
70
86
  ### Route splitting
71
87
 
@@ -97,6 +113,8 @@ adapter({
97
113
  })
98
114
  ```
99
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
+
100
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.
101
119
 
102
120
  ### Handler resolution
@@ -109,7 +127,7 @@ Each entry picks its handler from the first of these that exists. Specific beats
109
127
  4. `src/handler.js`
110
128
  5. the built-in minimalist handler
111
129
 
112
- So a per-entry middleware stack needs no config at all — drop a `handler.js` next to the route:
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):
113
131
 
114
132
  ```js
115
133
  // src/routes/admin/handler.js — only this lambda loads these parameters
@@ -121,6 +139,7 @@ import sveltekitMiddleware from './sveltekitMiddleware.js'
121
139
 
122
140
  export const handler = middy({ executionMode: executionModeStreamifyResponse })
123
141
  .use([
142
+ // `event.platform.context.dbUrl` in hooks, `load` and actions
124
143
  ssm({ fetchData: { dbUrl: '/app/admin/db-url' }, setToContext: true }),
125
144
  sveltekitMiddleware()
126
145
  ])
@@ -129,16 +148,50 @@ export const handler = middy({ executionMode: executionModeStreamifyResponse })
129
148
 
130
149
  Notes:
131
150
 
132
- - Handler files are copied into the build directory before bundling, so `./sveltekitHandler.js` and `./sveltekitMiddleware.js` resolve there, not next to your source file. Importing your own project code by relative path won't resolve; bare package specifiers are fine.
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.
133
153
  - A `handlerPath` that doesn't exist throws rather than silently falling back.
134
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`.
135
156
  - SvelteKit ignores non-`+` files in the routes directory, so a colocated `handler.js` doesn't become a route.
136
157
 
137
158
  ## Recommended Infrastructure
138
159
 
139
- - 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`)
140
161
  - S3: store static assets and pages
141
- - 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.
142
195
 
143
196
  ## Upgrading to 0.4
144
197
 
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
@@ -5,7 +5,7 @@ import { basename, dirname, join, sep } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import esbuild from "esbuild";
7
7
 
8
- const name = "@middy/sveltekit";
8
+ const name = "sveltekit-adapter-middy";
9
9
  const files = fileURLToPath(new URL("./", import.meta.url));
10
10
 
11
11
  // `src/routes/admin/handler.js` -> `/admin`, matching the route id: layout
@@ -15,11 +15,10 @@ const routeHandlers = (routesDir) =>
15
15
  readdirSync(routesDir, { recursive: true })
16
16
  .filter((file) => basename(file) === "handler.js")
17
17
  .map((file) => [
18
- `/${dirname(file)}`
18
+ `/${dirname(file)
19
19
  .split(sep)
20
- .join("/")
21
- .replace(/\/\([^/]+\)/g, "")
22
- .replace(/^\/\.$/, "/"),
20
+ .filter((segment) => segment !== "." && !segment.startsWith("("))
21
+ .join("/")}`,
23
22
  join(routesDir, file),
24
23
  ]),
25
24
  );
@@ -37,9 +36,8 @@ export const resolveHandler = (entry, { routesDir, handlerPath, builtin }) => {
37
36
  };
38
37
 
39
38
  if (entry.handlerPath) return configured(entry.handlerPath);
40
- const local =
41
- typeof entry.prefix === "string" &&
42
- routeHandlers(routesDir).get(entry.prefix);
39
+ // Function prefixes and `index` (no prefix) never match a route id key
40
+ const local = routeHandlers(routesDir).get(entry.prefix);
43
41
  if (local) return local;
44
42
  if (handlerPath) return configured(handlerPath);
45
43
  const project = join(dirname(routesDir), "handler.js");
@@ -54,18 +52,41 @@ const matches = ({ prefix }, route) =>
54
52
  ? prefix(route)
55
53
  : route.id === prefix || route.id.startsWith(`${prefix}/`);
56
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
+
57
60
  export const splitRoutes = (routes, entries) => {
58
61
  const split = { index: [] };
59
62
  for (const { name } of entries) split[name] = [];
63
+ const ordered = entries.toSorted((a, b) => specificity(b) - specificity(a));
60
64
  for (const route of routes) {
61
- const owner = entries.find((entry) => matches(entry, route));
65
+ const owner = ordered.find((entry) => matches(entry, route));
62
66
  split[owner?.name ?? "index"].push(route);
63
67
  }
64
68
  return split;
65
69
  };
66
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
+ });
84
+
67
85
  const sveltekitAdapterMiddy = (opts = {}) => {
68
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
+ }
69
90
 
70
91
  return {
71
92
  name,
@@ -76,9 +97,7 @@ const sveltekitAdapterMiddy = (opts = {}) => {
76
97
  builder.rimraf(tmp);
77
98
 
78
99
  builder.log.minor("Copying static assets");
79
- const _clientFiles = await builder.writeClient(
80
- `${out}/assets${builder.config.kit.paths.base}`,
81
- );
100
+ await builder.writeClient(`${out}/assets${builder.config.paths.base}`);
82
101
 
83
102
  await builder.writeServer(tmp);
84
103
 
@@ -96,11 +115,43 @@ const sveltekitAdapterMiddy = (opts = {}) => {
96
115
  handlerPath: config.handlerPath,
97
116
  };
98
117
  });
99
- const routes = splitRoutes(builder.routes, entries);
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,
122
+ );
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
+ }
100
130
  entries.push({ name: "index" });
101
131
 
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,
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
+ };
152
+
102
153
  const resolveOptions = {
103
- routesDir: builder.config.kit.files.routes,
154
+ routesDir: builder.config.files.routes,
104
155
  handlerPath,
105
156
  builtin: `${files}handler.js`,
106
157
  };
@@ -109,51 +160,39 @@ const sveltekitAdapterMiddy = (opts = {}) => {
109
160
  for (const entry of entries) {
110
161
  const entryHandler = resolveHandler(entry, resolveOptions);
111
162
  builder.log.minor(`Building server: ${entry.name} (${entryHandler})`);
112
- writeFileSync(
113
- `${tmp}/manifest.js`,
114
- [
115
- `export const manifest = ${builder.generateManifest({
116
- relativePath: "./",
117
- routes: routes[entry.name],
118
- })};`,
119
- `export const prerendered = new Set(${JSON.stringify(
120
- builder.prerendered.paths,
121
- )});`,
122
- `export const base = ${JSON.stringify(
123
- builder.config.kit.paths.base,
124
- )};`,
125
- ].join("\n\n"),
126
- );
127
- builder.copy(entryHandler, `${tmp}/handler.js`);
128
-
163
+ builder.generateServerInstance(`${tmp}/server.js`, {
164
+ routes: routes[entry.name],
165
+ serverDirectory: tmp,
166
+ });
129
167
  const result = await esbuild.build({
130
- target: "node24",
168
+ target: "node26",
131
169
  bundle: true,
132
170
  platform: "node",
133
171
  format: "esm",
134
172
  treeShaking: true,
173
+ // Reported once, through the builder log, below
174
+ logLevel: "silent",
135
175
  ...esbuildOptions,
136
- entryPoints: [`${tmp}/handler.js`],
176
+ entryPoints: [
177
+ initializer ? instrumented(entryHandler) : entryHandler,
178
+ ],
137
179
  outfile: `${out}/${entry.name}.mjs`,
138
180
  external: ["node:*", ...(esbuildOptions?.external ?? [])],
181
+ plugins: [adapterFiles(tmp), ...(esbuildOptions?.plugins ?? [])],
139
182
  });
140
183
 
141
- if (result.warnings.length > 0) {
142
- const formatted = await esbuild.formatMessages(result.warnings, {
143
- kind: "warning",
144
- color: true,
145
- });
146
-
147
- console.error(formatted.join("\n"));
148
- }
184
+ const warnings = await esbuild.formatMessages(result.warnings, {
185
+ kind: "warning",
186
+ });
187
+ for (const warning of warnings) builder.log.warn(warning);
149
188
  }
150
189
  } catch (err) {
151
- 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, {
152
193
  kind: "error",
153
- color: true,
154
194
  });
155
-
156
- console.error(formatted.join("\n"));
195
+ for (const error of errors) builder.log.error(error);
157
196
 
158
197
  throw new Error(
159
198
  `Bundling with esbuild failed with ${err.errors.length} ${
@@ -163,13 +202,17 @@ const sveltekitAdapterMiddy = (opts = {}) => {
163
202
  }
164
203
 
165
204
  builder.log.minor("Prerendering static pages");
166
- const _prerenderedFiles = await builder.writePrerendered(
167
- `${out}/prerendered${builder.config.kit.paths.base}`,
205
+ await builder.writePrerendered(
206
+ `${out}/prerendered${builder.config.paths.base}`,
168
207
  );
169
208
  },
170
209
  supports: {
171
210
  read: () => false,
211
+ instrumentation: () => true,
172
212
  },
213
+ // `vite dev` and prerendering run outside Lambda; middleware values on
214
+ // `context` only exist there
215
+ emulate: () => ({ platform: () => ({ event: {}, context: {} }) }),
173
216
  };
174
217
  };
175
218
  export default sveltekitAdapterMiddy;
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "sveltekit-adapter-middy",
3
- "version": "0.4.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
10
  "workspaces": [
10
11
  ".github"
@@ -12,16 +13,15 @@
12
13
  "publishConfig": {
13
14
  "access": "public"
14
15
  },
15
- "module": "./index.js",
16
16
  "exports": {
17
17
  ".": {
18
- "import": {
19
- "default": "./index.js"
20
- }
18
+ "types": "./index.d.ts",
19
+ "default": "./index.js"
21
20
  }
22
21
  },
23
22
  "files": [
24
23
  "index.js",
24
+ "index.d.ts",
25
25
  "handler.js",
26
26
  "sveltekitMiddleware.js",
27
27
  "sveltekitHandler.js"
@@ -30,11 +30,11 @@
30
30
  "prepare": "husky",
31
31
  "git:pre-commit": "npm run git:lint-staged && npm run git:test-staged",
32
32
  "git:commit-msg": "commitlint --config commitlint.config.cjs --edit",
33
- "git:lint-staged": "npm run test:lint",
34
- "git:unit-staged": "node --test",
33
+ "git:lint-staged": "biome check --staged --no-errors-on-unmatched",
34
+ "git:unit-staged": "node --test ./*.test.js",
35
35
  "git:test-staged": "npm run git:unit-staged",
36
36
  "lint": "biome check --write --no-errors-on-unmatched",
37
- "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",
38
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",
39
39
  "test:sast:actionlint": "actionlint",
40
40
  "test:sast:gitleaks": "npm run test:sast:gitleaks:dir && npm run test:sast:gitleaks:git",
@@ -43,22 +43,26 @@
43
43
  "test:sast:license": "license-check-and-add check -f .license.config.json",
44
44
  "test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https",
45
45
  "test:sast:semgrep": "semgrep scan --config auto --error",
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 --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 .",
47
47
  "test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./",
48
48
  "test:sast:zizmor": "zizmor .github",
49
- "test:lint": "biome check --staged --no-errors-on-unmatched",
50
- "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",
51
52
  "test:mutation": "stryker run",
52
53
  "test:types": "tstyche",
53
- "test:perf": "node --test --test-concurrency=1 ./*.perf.js",
54
+ "test:bench": "node ./handler.bench.js",
54
55
  "test:dast": "npm run test:dast:fuzz",
55
56
  "test:dast:fuzz": "node --test ./*.fuzz.js",
56
57
  "rm": "npm run rm:macos && npm run rm:node_modules && npm run rm:lock",
57
58
  "rm:macos": "find . -name '.DS_Store' -type f -delete",
58
59
  "rm:lock": "find . -name 'package-lock.json' -type f -delete",
59
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",
60
63
  "release:license:add": "license-check-and-add add -f .license.config.json",
61
- "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"
62
66
  },
63
67
  "license": "MIT",
64
68
  "keywords": [
@@ -69,7 +73,6 @@
69
73
  "Serverless",
70
74
  "AWS",
71
75
  "AWS Lambda",
72
- "Middy",
73
76
  "HTTP",
74
77
  "SSR"
75
78
  ],
@@ -79,8 +82,7 @@
79
82
  },
80
83
  "repository": {
81
84
  "type": "git",
82
- "url": "git+https://github.com/willfarrell/sveltekit-adapter-middy.git",
83
- "directory": ""
85
+ "url": "git+https://github.com/willfarrell/sveltekit-adapter-middy.git"
84
86
  },
85
87
  "bugs": {
86
88
  "url": "https://github.com/willfarrell/sveltekit-adapter-middy/issues"
@@ -94,22 +96,24 @@
94
96
  "@biomejs/biome": "^2.0.0",
95
97
  "@commitlint/cli": "^21.0.0",
96
98
  "@commitlint/config-conventional": "^21.0.0",
97
- "@stryker-mutator/core": "^9.0.0",
99
+ "@stryker-mutator/core": "^10.0.0",
98
100
  "fast-check": "^4.0.0",
99
101
  "husky": "^9.0.0",
100
102
  "license-check-and-add": "4.0.5",
101
- "tinybench": "^6.0.0",
102
103
  "tstyche": "^7.0.0",
103
- "typescript": "^7.0.0"
104
+ "typescript": "^6.0.0"
104
105
  },
105
106
  "overrides": {
106
107
  "qs": "^6.15.2",
107
- "brace-expansion": ">=5.0.9",
108
- "fast-uri": "^3.1.5"
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"
109
114
  },
110
115
  "dependencies": {
111
- "@datastream/string": "0.7.0",
112
- "@middy/core": "^7.0.0",
113
116
  "esbuild": "^0.28.0"
114
- }
117
+ },
118
+ "types": "./index.d.ts"
115
119
  }
@@ -2,9 +2,15 @@
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;
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;
8
14
  const { method, path, sourceIp } = event.requestContext.http;
9
15
 
10
16
  // The url origin must come from a trusted source. SvelteKit's CSRF check
@@ -19,55 +25,64 @@ const lambdaHandler = async (event, context, { signal: _signal }) => {
19
25
 
20
26
  // `content-encoding` is a compression scheme, not a Buffer encoding
21
27
  const body =
22
- typeof rawBody === "string"
23
- ? Buffer.from(rawBody, isBase64Encoded ? "base64" : "utf8")
24
- : rawBody;
28
+ isBase64Encoded && rawBody ? Buffer.from(rawBody, "base64") : rawBody;
25
29
 
26
- const rendered = await server.respond(
27
- 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, {
28
40
  method,
29
- headers: new Headers(headers),
41
+ signal,
42
+ headers,
30
43
  // Request throws if a GET/HEAD carries one, however it arrived
31
44
  body: method === "GET" || method === "HEAD" ? null : body,
32
- }),
33
- {
34
- getClientAddress() {
35
- // CloudFront appends the viewer ip to any client-supplied list, so
36
- // the rightmost entry is the only one a client can't forge.
37
- const forwarded = headers["x-forwarded-for"]?.split(",").at(-1)?.trim();
38
- return forwarded || sourceIp;
39
- },
40
- },
41
- );
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
+ }
42
52
 
43
- if (rendered) {
44
- const response = {
45
- statusCode: rendered.status,
46
- headers: {
47
- "cache-control": "no-cache",
48
- },
49
- body: "",
50
- };
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
+ });
51
63
 
52
- for (const [key, value] of rendered.headers.entries()) {
53
- if (key === "set-cookie") {
54
- response.cookies ??= [];
55
- response.cookies.push(value);
56
- } else if (key !== "x-sveltekit-page") {
57
- // `x-sveltekit-page` excluded, security
58
- response.headers[key] = value;
59
- }
60
- }
64
+ const response = {
65
+ statusCode: rendered.status,
66
+ headers: {
67
+ "cache-control": "no-cache",
68
+ },
69
+ body: "",
70
+ };
61
71
 
62
- if (rendered.body) {
63
- 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;
64
79
  }
80
+ }
65
81
 
66
- return response;
82
+ if (rendered.body) {
83
+ response.body = Readable.fromWeb(rendered.body);
67
84
  }
68
85
 
69
- return {
70
- statusCode: 404,
71
- };
86
+ return response;
72
87
  };
73
88
  export default lambdaHandler;
@@ -1,30 +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
26
- .setEncoding("utf8")
27
- .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"];
28
80
  }
29
81
  };
30
82
  return {