sveltekit-adapter-middy 0.2.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 +86 -2
- package/index.js +117 -45
- package/package.json +15 -14
- package/sveltekitHandler.js +18 -10
- package/sveltekitMiddleware.js +3 -1
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
|
-
-
|
|
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)
|
|
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}
|
|
86
|
+
`${files}sveltekitMiddleware.js`,
|
|
49
87
|
`${tmp}/sveltekitMiddleware.js`,
|
|
50
88
|
);
|
|
51
|
-
builder.copy(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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.
|
|
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
|
],
|
|
@@ -92,23 +91,25 @@
|
|
|
92
91
|
"url": "https://github.com/sponsors/willfarrell"
|
|
93
92
|
},
|
|
94
93
|
"devDependencies": {
|
|
95
|
-
"@biomejs/biome": "^2.
|
|
96
|
-
"@commitlint/cli": "^21.
|
|
97
|
-
"@commitlint/config-conventional": "^21.
|
|
94
|
+
"@biomejs/biome": "^2.0.0",
|
|
95
|
+
"@commitlint/cli": "^21.0.0",
|
|
96
|
+
"@commitlint/config-conventional": "^21.0.0",
|
|
98
97
|
"@stryker-mutator/core": "^9.0.0",
|
|
99
|
-
"fast-check": "^4.
|
|
100
|
-
"husky": "^9.
|
|
98
|
+
"fast-check": "^4.0.0",
|
|
99
|
+
"husky": "^9.0.0",
|
|
101
100
|
"license-check-and-add": "4.0.5",
|
|
102
|
-
"tinybench": "^6.0.
|
|
103
|
-
"tstyche": "^7.
|
|
104
|
-
"typescript": "^
|
|
101
|
+
"tinybench": "^6.0.0",
|
|
102
|
+
"tstyche": "^7.0.0",
|
|
103
|
+
"typescript": "^7.0.0"
|
|
105
104
|
},
|
|
106
105
|
"overrides": {
|
|
107
|
-
"qs": "^6.15.2"
|
|
106
|
+
"qs": "^6.15.2",
|
|
107
|
+
"brace-expansion": ">=5.0.9",
|
|
108
|
+
"fast-uri": "^3.1.5"
|
|
108
109
|
},
|
|
109
110
|
"dependencies": {
|
|
110
|
-
"@datastream/string": "0.
|
|
111
|
-
"@middy/core": "^7.
|
|
112
|
-
"esbuild": "^0.28.
|
|
111
|
+
"@datastream/string": "0.7.0",
|
|
112
|
+
"@middy/core": "^7.0.0",
|
|
113
|
+
"esbuild": "^0.28.0"
|
|
113
114
|
}
|
|
114
115
|
}
|
package/sveltekitHandler.js
CHANGED
|
@@ -5,29 +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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
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 = `${
|
|
18
|
+
const url = `${origin}${path}${queryString}`;
|
|
16
19
|
|
|
17
|
-
//
|
|
18
|
-
const encoding = isBase64Encoded ? "base64" : headers["content-encoding"];
|
|
20
|
+
// `content-encoding` is a compression scheme, not a Buffer encoding
|
|
19
21
|
const body =
|
|
20
|
-
typeof rawBody === "string"
|
|
22
|
+
typeof rawBody === "string"
|
|
23
|
+
? Buffer.from(rawBody, isBase64Encoded ? "base64" : "utf8")
|
|
24
|
+
: rawBody;
|
|
21
25
|
|
|
22
26
|
const rendered = await server.respond(
|
|
23
27
|
new Request(url, {
|
|
24
28
|
method,
|
|
25
29
|
headers: new Headers(headers),
|
|
26
|
-
|
|
30
|
+
// Request throws if a GET/HEAD carries one, however it arrived
|
|
31
|
+
body: method === "GET" || method === "HEAD" ? null : body,
|
|
27
32
|
}),
|
|
28
33
|
{
|
|
29
34
|
getClientAddress() {
|
|
30
|
-
|
|
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;
|
|
31
39
|
},
|
|
32
40
|
},
|
|
33
41
|
);
|
package/sveltekitMiddleware.js
CHANGED
|
@@ -22,7 +22,9 @@ const sveltekitMiddleware = () => {
|
|
|
22
22
|
pattern: formActionPattern,
|
|
23
23
|
replacement: formActionReplacement,
|
|
24
24
|
});
|
|
25
|
-
request.response.body = request.response.body
|
|
25
|
+
request.response.body = request.response.body
|
|
26
|
+
.setEncoding("utf8")
|
|
27
|
+
.pipe(stream);
|
|
26
28
|
}
|
|
27
29
|
};
|
|
28
30
|
return {
|