sveltekit-adapter-middy 0.1.0 → 0.4.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
@@ -32,7 +32,7 @@ Creates a lambda that supports a Function URL with streaming responses.
32
32
  - `ssm`/`secrets-manger`
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
36
 
37
37
  Note: Bring your own deployment.
38
38
 
@@ -56,9 +56,83 @@ export default {
56
56
 
57
57
  ### Options
58
58
 
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.
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).
60
60
  - `out` (string): Relative path to build dir. Defaults to `build`
61
61
  - `esbuildOptions` (object): `esbuild` option overrides. See [code]() for defaults.
62
+ - `split` (object): Route splitting, `name` to route prefix. Defaults to `{}` (single lambda).
63
+
64
+ ### Request details
65
+
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.
67
+ - `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
+ - A body on a `GET` or `HEAD` is dropped; `Request` refuses to carry one.
69
+
70
+ ### Route splitting
71
+
72
+ Build separate lambdas per route prefix, each with its own handler (and so its own middleware, IAM role, memory, and concurrency).
73
+
74
+ ```js
75
+ adapter({
76
+ split: {
77
+ // `/admin/*` -> build/admin.mjs, using the default handler
78
+ admin: '/admin',
79
+ // `/api/*` -> build/api.mjs, using a custom handler
80
+ api: { prefix: '/api', handlerPath: './lambda/api.js' }
81
+ }
82
+ })
83
+ ```
84
+
85
+ Routes not matching any prefix go to `build/index.mjs`, as before. Static assets and prerendered pages are unaffected.
86
+
87
+ Prefixes match SvelteKit **route ids**, not request paths, so param segments are written out literally:
88
+
89
+ ```js
90
+ adapter({
91
+ split: {
92
+ // src/routes/[[lang]]/admin/**
93
+ admin: '/[[lang]]/admin',
94
+ // anything a prefix can't express
95
+ api: (route) => /(^|\/)api(\/|$)/.test(route.id)
96
+ }
97
+ })
98
+ ```
99
+
100
+ 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
+
102
+ ### Handler resolution
103
+
104
+ Each entry picks its handler from the first of these that exists. Specific beats global, and within each tier explicit beats implicit:
105
+
106
+ 1. that entry's own `handlerPath`
107
+ 2. `handler.js` beside the route it serves, eg `src/routes/admin/handler.js` for prefix `/admin`
108
+ 3. the top-level `handlerPath`
109
+ 4. `src/handler.js`
110
+ 5. the built-in minimalist handler
111
+
112
+ So a per-entry middleware stack needs no config at all — drop a `handler.js` next to the route:
113
+
114
+ ```js
115
+ // src/routes/admin/handler.js — only this lambda loads these parameters
116
+ import middy from '@middy/core'
117
+ import { executionModeStreamifyResponse } from '@middy/core/StreamifyResponse'
118
+ import ssm from '@middy/ssm'
119
+ import sveltekitHandler from './sveltekitHandler.js'
120
+ import sveltekitMiddleware from './sveltekitMiddleware.js'
121
+
122
+ export const handler = middy({ executionMode: executionModeStreamifyResponse })
123
+ .use([
124
+ ssm({ fetchData: { dbUrl: '/app/admin/db-url' }, setToContext: true }),
125
+ sveltekitMiddleware()
126
+ ])
127
+ .handler(sveltekitHandler)
128
+ ```
129
+
130
+ Notes:
131
+
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.
133
+ - A `handlerPath` that doesn't exist throws rather than silently falling back.
134
+ - Prefixes match route ids, so layout groups are ignored: `src/routes/(app)/admin/handler.js` serves prefix `/admin`.
135
+ - SvelteKit ignores non-`+` files in the routes directory, so a colocated `handler.js` doesn't become a route.
62
136
 
63
137
  ## Recommended Infrastructure
64
138
 
@@ -66,6 +140,16 @@ export default {
66
140
  - S3: store static assets and pages
67
141
  - Lambda Function URL: server side rendering
68
142
 
143
+ ## Upgrading to 0.4
144
+
145
+ Two behaviour changes need action:
146
+
147
+ **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.
148
+
149
+ **`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.
150
+
151
+ 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.
152
+
69
153
  ## Roadmap
70
154
 
71
155
  - infra diagram
package/index.js CHANGED
@@ -1,18 +1,71 @@
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
8
  const name = "@middy/sveltekit";
8
- const files = fileURLToPath(new URL("./", import.meta.url).href);
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
+ .join("/")
21
+ .replace(/\/\([^/]+\)/g, "")
22
+ .replace(/^\/\.$/, "/"),
23
+ join(routesDir, file),
24
+ ]),
25
+ );
26
+
27
+ // Specific beats global, explicit beats implicit: the entry's own `handlerPath`,
28
+ // then a handler beside the route, then the top-level `handlerPath`, then
29
+ // `src/handler.js`, then the built-in. A configured path that isn't there throws
30
+ // rather than silently deploying the wrong middleware stack.
31
+ export const resolveHandler = (entry, { routesDir, handlerPath, builtin }) => {
32
+ const configured = (path) => {
33
+ if (!existsSync(path)) {
34
+ throw new Error(`${name}: handlerPath not found: ${path}`);
35
+ }
36
+ return path;
37
+ };
38
+
39
+ if (entry.handlerPath) return configured(entry.handlerPath);
40
+ const local =
41
+ typeof entry.prefix === "string" &&
42
+ routeHandlers(routesDir).get(entry.prefix);
43
+ if (local) return local;
44
+ if (handlerPath) return configured(handlerPath);
45
+ const project = join(dirname(routesDir), "handler.js");
46
+ return existsSync(project) ? project : builtin;
47
+ };
48
+
49
+ // Routes matching an entry prefix go to that entry, everything else to `index`.
50
+ // Prefixes match route ids, so param segments are written out: `/[[lang]]/admin`.
51
+ // A function prefix covers anything a string can't express.
52
+ const matches = ({ prefix }, route) =>
53
+ typeof prefix === "function"
54
+ ? prefix(route)
55
+ : route.id === prefix || route.id.startsWith(`${prefix}/`);
56
+
57
+ export const splitRoutes = (routes, entries) => {
58
+ const split = { index: [] };
59
+ for (const { name } of entries) split[name] = [];
60
+ for (const route of routes) {
61
+ const owner = entries.find((entry) => matches(entry, route));
62
+ split[owner?.name ?? "index"].push(route);
63
+ }
64
+ return split;
65
+ };
9
66
 
10
67
  const sveltekitAdapterMiddy = (opts = {}) => {
11
- const {
12
- out = "build",
13
- handlerPath = `${files}/handler.js`,
14
- esbuildOptions = {},
15
- } = opts;
68
+ const { out = "build", handlerPath, esbuildOptions = {}, split = {} } = opts;
16
69
 
17
70
  return {
18
71
  name,
@@ -27,53 +80,72 @@ const sveltekitAdapterMiddy = (opts = {}) => {
27
80
  `${out}/assets${builder.config.kit.paths.base}`,
28
81
  );
29
82
 
30
- builder.log.minor("Building server");
31
83
  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
84
 
47
85
  builder.copy(
48
- `${files}/sveltekitMiddleware.js`,
86
+ `${files}sveltekitMiddleware.js`,
49
87
  `${tmp}/sveltekitMiddleware.js`,
50
88
  );
51
- builder.copy(
52
- `${files}/sveltekitHandler.js`,
53
- `${tmp}/sveltekitHandler.js`,
54
- );
55
- builder.copy(handlerPath, `${tmp}/handler.js`);
89
+ builder.copy(`${files}sveltekitHandler.js`, `${tmp}/sveltekitHandler.js`);
90
+
91
+ const entries = Object.entries(split).map(([name, value]) => {
92
+ const config = typeof value === "object" ? value : { prefix: value };
93
+ return {
94
+ name,
95
+ prefix: config.prefix,
96
+ handlerPath: config.handlerPath,
97
+ };
98
+ });
99
+ const routes = splitRoutes(builder.routes, entries);
100
+ entries.push({ name: "index" });
101
+
102
+ const resolveOptions = {
103
+ routesDir: builder.config.kit.files.routes,
104
+ handlerPath,
105
+ builtin: `${files}handler.js`,
106
+ };
56
107
 
57
108
  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 ?? [])],
68
- });
109
+ for (const entry of entries) {
110
+ const entryHandler = resolveHandler(entry, resolveOptions);
111
+ 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`);
69
128
 
70
- if (result.warnings.length > 0) {
71
- const formatted = await esbuild.formatMessages(result.warnings, {
72
- kind: "warning",
73
- color: true,
129
+ const result = await esbuild.build({
130
+ target: "node24",
131
+ bundle: true,
132
+ platform: "node",
133
+ format: "esm",
134
+ treeShaking: true,
135
+ ...esbuildOptions,
136
+ entryPoints: [`${tmp}/handler.js`],
137
+ outfile: `${out}/${entry.name}.mjs`,
138
+ external: ["node:*", ...(esbuildOptions?.external ?? [])],
74
139
  });
75
140
 
76
- console.error(formatted.join("\n"));
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
+ }
77
149
  }
78
150
  } catch (err) {
79
151
  const formatted = await esbuild.formatMessages(err.errors, {
@@ -96,7 +168,7 @@ const sveltekitAdapterMiddy = (opts = {}) => {
96
168
  );
97
169
  },
98
170
  supports: {
99
- read: () => true,
171
+ read: () => false,
100
172
  },
101
173
  };
102
174
  };
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "sveltekit-adapter-middy",
3
- "version": "0.1.0",
3
+ "version": "0.4.0",
4
4
  "description": "SvelteKit adapter for AWS Lambda with Middy",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": ">=24"
8
8
  },
9
- "engineStrict": true,
10
9
  "workspaces": [
11
10
  ".github"
12
11
  ],
@@ -35,21 +34,31 @@
35
34
  "git:unit-staged": "node --test",
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:types && npm run test:sast && npm run test:perf && npm run test:dast",
39
- "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:trivy",
40
- "test:sast:license": "license-check-and-add check -f license.json",
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",
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
+ "test:sast:actionlint": "actionlint",
40
+ "test:sast:gitleaks": "npm run test:sast:gitleaks:dir && npm run test:sast:gitleaks:git",
41
+ "test:sast:gitleaks:dir": "gitleaks dir . --redact --no-banner",
42
+ "test:sast:gitleaks:git": "gitleaks git . --redact --no-banner",
43
+ "test:sast:license": "license-check-and-add check -f .license.config.json",
41
44
  "test:sast:lockfile": "lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --validate-https",
42
- "test:sast:semgrep": "semgrep scan --config auto",
43
- "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 --exit-code 1 --disable-telemetry .",
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 .",
44
47
  "test:sast:trufflehog": "trufflehog filesystem --only-verified --log-level=-1 ./",
48
+ "test:sast:zizmor": "zizmor .github",
45
49
  "test:lint": "biome check --staged --no-errors-on-unmatched",
46
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",
51
+ "test:mutation": "stryker run",
47
52
  "test:types": "tstyche",
48
53
  "test:perf": "node --test --test-concurrency=1 ./*.perf.js",
49
54
  "test:dast": "npm run test:dast:fuzz",
50
55
  "test:dast:fuzz": "node --test ./*.fuzz.js",
51
- "release:license:add": "license-check-and-add add -f license.json",
52
- "release:license:remove": "license-check-and-add remove -f license.json"
56
+ "rm": "npm run rm:macos && npm run rm:node_modules && npm run rm:lock",
57
+ "rm:macos": "find . -name '.DS_Store' -type f -delete",
58
+ "rm:lock": "find . -name 'package-lock.json' -type f -delete",
59
+ "rm:node_modules": "find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +",
60
+ "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"
53
62
  },
54
63
  "license": "MIT",
55
64
  "keywords": [
@@ -83,17 +92,24 @@
83
92
  },
84
93
  "devDependencies": {
85
94
  "@biomejs/biome": "^2.0.0",
86
- "@commitlint/cli": "^20.0.0",
87
- "@commitlint/config-conventional": "^20.0.0",
95
+ "@commitlint/cli": "^21.0.0",
96
+ "@commitlint/config-conventional": "^21.0.0",
97
+ "@stryker-mutator/core": "^9.0.0",
88
98
  "fast-check": "^4.0.0",
89
99
  "husky": "^9.0.0",
90
100
  "license-check-and-add": "4.0.5",
91
101
  "tinybench": "^6.0.0",
92
- "tstyche": "^6.0.0"
102
+ "tstyche": "^7.0.0",
103
+ "typescript": "^7.0.0"
104
+ },
105
+ "overrides": {
106
+ "qs": "^6.15.2",
107
+ "brace-expansion": ">=5.0.9",
108
+ "fast-uri": "^3.1.5"
93
109
  },
94
110
  "dependencies": {
95
- "@datastream/string": "0.0.42",
111
+ "@datastream/string": "0.7.0",
96
112
  "@middy/core": "^7.0.0",
97
- "esbuild": "^0.27.0"
113
+ "esbuild": "^0.28.0"
98
114
  }
99
115
  }
@@ -5,30 +5,37 @@ import { Readable } from "node:stream";
5
5
  const lambdaHandler = async (event, context, { signal: _signal }) => {
6
6
  const { server, env: _env } = context;
7
7
  const { headers, rawQueryString, body: rawBody, isBase64Encoded } = event;
8
- const { method, path } = event.requestContext.http;
8
+ const { method, path, sourceIp } = event.requestContext.http;
9
9
 
10
- // Override origin
11
- headers.origin =
12
- process.env.HEADER_ORIGIN ?? headers.origin ?? "https://example.com";
10
+ // The url origin must come from a trusted source. SvelteKit's CSRF check
11
+ // rejects form posts where the `origin` header doesn't match `url.origin`,
12
+ // so deriving one from the other would make the check unfailable. Set
13
+ // `HEADER_ORIGIN` in production; `host` is only as trustworthy as what fronts it.
14
+ const origin =
15
+ process.env.HEADER_ORIGIN ?? `https://${headers.host ?? "example.com"}`;
13
16
 
14
17
  const queryString = rawQueryString ? `?${rawQueryString}` : "";
15
- const url = `${headers.origin}${path}${queryString}`;
18
+ const url = `${origin}${path}${queryString}`;
16
19
 
17
- const encoding = isBase64Encoded
18
- ? "base64"
19
- : (headers["content-encoding"] ?? "utf-8");
20
+ // `content-encoding` is a compression scheme, not a Buffer encoding
20
21
  const body =
21
- typeof rawBody === "string" ? Buffer.from(rawBody, encoding) : rawBody;
22
+ typeof rawBody === "string"
23
+ ? Buffer.from(rawBody, isBase64Encoded ? "base64" : "utf8")
24
+ : rawBody;
22
25
 
23
26
  const rendered = await server.respond(
24
27
  new Request(url, {
25
28
  method,
26
29
  headers: new Headers(headers),
27
- body,
30
+ // Request throws if a GET/HEAD carries one, however it arrived
31
+ body: method === "GET" || method === "HEAD" ? null : body,
28
32
  }),
29
33
  {
30
34
  getClientAddress() {
31
- return headers["x-forwarded-for"];
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;
32
39
  },
33
40
  },
34
41
  );
@@ -53,7 +60,7 @@ const lambdaHandler = async (event, context, { signal: _signal }) => {
53
60
  }
54
61
 
55
62
  if (rendered.body) {
56
- response.body = Readable.from(rendered.body);
63
+ response.body = Readable.fromWeb(rendered.body);
57
64
  }
58
65
 
59
66
  return response;
@@ -22,7 +22,9 @@ const sveltekitMiddleware = () => {
22
22
  pattern: formActionPattern,
23
23
  replacement: formActionReplacement,
24
24
  });
25
- request.response.body.pipe(stream);
25
+ request.response.body = request.response.body
26
+ .setEncoding("utf8")
27
+ .pipe(stream);
26
28
  }
27
29
  };
28
30
  return {
package/license.json DELETED
@@ -1,26 +0,0 @@
1
- {
2
- "license": "license.template",
3
- "licenseFormats": {
4
- "js|ts": {
5
- "eachLine": {
6
- "prepend": "// "
7
- }
8
- }
9
- },
10
- "ignoreFile": ".gitignore",
11
- "ignore": [
12
- ".github/**/*",
13
- ".husky/**/*",
14
- "fixtures/*",
15
- "commitlint.config.cjs",
16
- "LICENSE",
17
- "license.template",
18
- "**/.gitignore",
19
- "**/*.fuzz.js",
20
- "**/*.perf.js",
21
- "**/*.test.js",
22
- "**/*.md",
23
- "**/*.yml",
24
- ".DS_Store"
25
- ]
26
- }
package/license.template DELETED
@@ -1,2 +0,0 @@
1
- Copyright 2026 will Farrell, and sveltekit-adapter-middy contributors.
2
- SPDX-License-Identifier: MIT