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 +68 -15
- package/index.d.ts +21 -0
- package/index.js +90 -47
- package/package.json +29 -25
- package/sveltekitHandler.js +57 -42
- package/sveltekitMiddleware.js +66 -14
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-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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 [
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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 = "
|
|
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
|
-
.
|
|
21
|
-
.
|
|
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
|
-
|
|
41
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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: "
|
|
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: [
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
`${out}/prerendered${builder.config.
|
|
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.
|
|
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": ">=
|
|
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
|
-
"
|
|
19
|
-
|
|
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": "
|
|
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:
|
|
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 --
|
|
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
|
|
50
|
-
"test:unit": "node --test --test-force-exit --experimental-test-coverage --test-coverage-lines=
|
|
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:
|
|
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": "^
|
|
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": "^
|
|
104
|
+
"typescript": "^6.0.0"
|
|
104
105
|
},
|
|
105
106
|
"overrides": {
|
|
106
107
|
"qs": "^6.15.2",
|
|
107
|
-
"brace-expansion": "
|
|
108
|
-
"fast-uri": "^3.1.
|
|
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
|
}
|
package/sveltekitHandler.js
CHANGED
|
@@ -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
|
|
6
|
-
const { server
|
|
7
|
-
const {
|
|
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
|
-
|
|
23
|
-
? Buffer.from(rawBody, isBase64Encoded ? "base64" : "utf8")
|
|
24
|
-
: rawBody;
|
|
28
|
+
isBase64Encoded && rawBody ? Buffer.from(rawBody, "base64") : rawBody;
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
}
|
|
64
|
+
const response = {
|
|
65
|
+
statusCode: rendered.status,
|
|
66
|
+
headers: {
|
|
67
|
+
"cache-control": "no-cache",
|
|
68
|
+
},
|
|
69
|
+
body: "",
|
|
70
|
+
};
|
|
61
71
|
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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;
|
package/sveltekitMiddleware.js
CHANGED
|
@@ -1,30 +1,82 @@
|
|
|
1
1
|
// Copyright 2026 will Farrell, and sveltekit-adapter-middy contributors.
|
|
2
2
|
// SPDX-License-Identifier: MIT
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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
|
-
|
|
8
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
.
|
|
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 {
|