lambder 4.4.1 → 4.6.1
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 +38 -16
- package/dist/client/LambderCaller.js +3 -0
- package/dist/core/Lambder.d.ts +15 -7
- package/dist/core/Lambder.js +14 -12
- package/dist/core/LambderContext.d.ts +9 -0
- package/dist/core/LambderContext.js +15 -5
- package/dist/core/LambderCookie.d.ts +44 -0
- package/dist/core/LambderCookie.js +28 -0
- package/dist/core/LambderFiles.d.ts +85 -0
- package/dist/core/LambderFiles.js +116 -0
- package/dist/core/LambderPublicFiles.d.ts +8 -48
- package/dist/core/LambderPublicFiles.js +9 -83
- package/dist/core/LambderResponseBuilder.d.ts +29 -13
- package/dist/core/LambderResponseBuilder.js +43 -54
- package/dist/core/LambderTemplatingEngine.d.ts +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -1
- package/dist/session/LambderSessionController.d.ts +22 -13
- package/dist/session/LambderSessionController.js +44 -37
- package/dist/stores/LambderS3FileSource.d.ts +3 -3
- package/package.json +1 -1
package/Readme.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Lambder is a highly opinionated dynamic serverless framework designed to facilitate the management and implementation of routes and APIs within AWS Lambda functions, specifically tailored for TypeScript projects. It provides a streamlined approach to handling HTTP requests, managing sessions, and defining API routes, making serverless application development more intuitive and structured.
|
|
4
4
|
|
|
5
|
+
**New in 4.6:**
|
|
6
|
+
|
|
7
|
+
- **Cookies as a first-class concern**: `res.setCookie(name, value, options)` and `res.clearCookie(name, options)` serialize Set-Cookie headers through the `cookie` package (defaults Path=/, SameSite=Lax, Secure; a function-form `domain` resolves against the request hostname, the same option the session takes), replacing hand-built header strings; `serializeCookie`/`serializeClearCookie` are exported for code holding a response. `ctx.cookieList` keeps every value a cookie name arrived with beside the first-wins `ctx.cookie`.
|
|
8
|
+
- **Session cookie scope changes heal**: a cookie's identity is (name, domain, path), so changing the session's `cookie.domain` or `path` on a live deployment leaves the old copy in every browser beside the new one, and a whole-header parse silently picks whichever the browser lists first. The controller now tries every copy of the session cookie (record and CSRF pairing checked per copy), logs the ambiguity, and evicts the stale host-only twin from the response, so a migrated browser recovers on its first request instead of answering `sessionExpired` until the old cookie expires.
|
|
9
|
+
|
|
10
|
+
**New in 4.5:**
|
|
11
|
+
|
|
12
|
+
- **`files` at creation replaces `publicPath`** (and `servePublicFiles({ source })`): one `LambderFileSource` configured once, `files: new LambderLocalFileSource({ root: path.resolve("./public") })` for the folder bundled with the deployment, `new LambderS3FileSource({...})` for S3 or R2, or your own `{ read(relativePath) }`. The instance owns one reader over it (`lambder.files`): path rule, in-memory file cache and compiled-template cache in one place, shared by `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile`, so a build hosted from a bucket serves its index.html and templates from the bucket too, cached the same way as its assets. The cache is tuned or disabled beside the source, `files: { source, memoryCache }`, and `memoryCache` leaves `servePublicFiles`; `res.file` loses its SPA-era `fallback` option (the fallback chain replaced it).
|
|
13
|
+
|
|
5
14
|
**New in 4.4:**
|
|
6
15
|
|
|
7
16
|
- **`guardInputsProvider`** on `LambderCaller`: supply guardInput-mode guard values for every call from one place (the organization the UI is on, a device token) instead of at each call site; per-call `guardInputs` merge on top. Name the covered guards in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider, ... })`: calls to APIs whose guardInput guards are all covered no longer require the options argument, uncovered ones (a Turnstile token) still do, and naming guards makes the provider itself mandatory.
|
|
@@ -92,7 +101,7 @@ would silently widen the inferred policy types, which is why the curried
|
|
|
92
101
|
creator is the canonical entry.
|
|
93
102
|
|
|
94
103
|
```typescript
|
|
95
|
-
import { initLambder } from 'lambder';
|
|
104
|
+
import { initLambder, LambderLocalFileSource } from 'lambder';
|
|
96
105
|
import { z } from 'zod';
|
|
97
106
|
import * as path from 'path';
|
|
98
107
|
|
|
@@ -100,7 +109,7 @@ interface SessionData { userId: string; }
|
|
|
100
109
|
|
|
101
110
|
const lambder = initLambder<SessionData>().create({
|
|
102
111
|
apiPath: "/api",
|
|
103
|
-
|
|
112
|
+
files: new LambderLocalFileSource({ root: path.resolve(`./public`) }),
|
|
104
113
|
session: {
|
|
105
114
|
tableName: "website-session",
|
|
106
115
|
tableRegion: "us-east-1",
|
|
@@ -166,9 +175,9 @@ lambder
|
|
|
166
175
|
.addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
|
|
167
176
|
return res.json({ received: true });
|
|
168
177
|
})
|
|
169
|
-
// Serve real files
|
|
170
|
-
// below
|
|
171
|
-
//
|
|
178
|
+
// Serve real files from the files source (see "Public file sources"
|
|
179
|
+
// below). This is a terminal fallback slot, NOT a catch-all route, so it
|
|
180
|
+
// can never shadow routes registered after it.
|
|
172
181
|
.servePublicFiles()
|
|
173
182
|
// Serve the app shell for GET/HEAD page requests nothing else handled
|
|
174
183
|
// (see "Hosting a frontend build" below).
|
|
@@ -216,7 +225,7 @@ For larger applications, split your APIs into separate modules:
|
|
|
216
225
|
```typescript
|
|
217
226
|
// user-api.ts
|
|
218
227
|
import { z } from "zod";
|
|
219
|
-
import Lambder from "lambder";
|
|
228
|
+
import Lambder, { LambderLocalFileSource } from "lambder";
|
|
220
229
|
|
|
221
230
|
export const userApi = <T>(l: Lambder<T>) => {
|
|
222
231
|
return l
|
|
@@ -237,7 +246,7 @@ export const userApi = <T>(l: Lambder<T>) => {
|
|
|
237
246
|
// index.ts
|
|
238
247
|
import { userApi } from "./user-api";
|
|
239
248
|
|
|
240
|
-
const lambder = new Lambder({
|
|
249
|
+
const lambder = new Lambder({ files: new LambderLocalFileSource({ root: './public' }) })
|
|
241
250
|
.use(userApi);
|
|
242
251
|
|
|
243
252
|
export type ApiContractType = typeof lambder.ApiContract;
|
|
@@ -331,6 +340,12 @@ const lambder = initLambder<SessionData>().create({
|
|
|
331
340
|
|
|
332
341
|
See [docs/DYNAMODB_SETUP.md](docs/DYNAMODB_SETUP.md) for detailed setup instructions.
|
|
333
342
|
|
|
343
|
+
#### Cookie scope
|
|
344
|
+
|
|
345
|
+
`session.cookie` sets the scope of the two session cookies: `{ domain: ".example.com" }` shares a login across subdomains, and `domain` may be a `(hostname) => string | undefined` function when one deployment serves several apex domains (return undefined for a host-only cookie); `path`, `sameSite` (default `Lax`) and `secure` (default true) complete it. `LambderCaller` takes the same `sessionCookieDomain` so it can clear the CSRF cookie where the server set it.
|
|
346
|
+
|
|
347
|
+
Changing `domain` or `path` on a live deployment is a migration, because a browser identifies a cookie by (name, domain, path): the old copy stays beside the new one, both arrive on every request, and the browser's order says nothing about which is current. The controller handles the overlap: when the session cookie name arrives more than once it tries every copy (record lookup and CSRF pairing per copy), takes the live one, logs the ambiguity, and evicts the stale host-only twin from the response when a domain is configured. The reverse move, from a domain cookie back to host-only, cannot be evicted (this host cannot name the parent domain), so that copy is tolerated on every request until its own expiry. Renaming the cookies (`tokenCookieKey`, `csrfCookieKey`) alongside the scope change avoids the overlap entirely.
|
|
348
|
+
|
|
334
349
|
#### How the secrets are stored
|
|
335
350
|
|
|
336
351
|
The session cookie is `pkHash:secret`: `pkHash = sha256(sessionKey + sessionSalt)` and `secret` is 256 random bits. At rest the record stores only HASHES of the bearer secrets: the range key is `sha256(secret)` (so the lookup itself proves possession of the raw secret) and the CSRF token is stored as `csrfTokenHash`. The raw values exist only in the client's cookies and, transiently, on the `LambderCreatedSession` result the manager returns at creation; a read of the session table (backup leak, over-broad IAM, insider) therefore yields no usable cookies. Fast sha256 is the correct construction here rather than a password KDF: the secrets are 256-bit random, so there is nothing to brute-force, while `sessionSalt` peppers the identity-to-partition-key mapping so partition keys and cookie prefixes cannot be derived from (or linked to) known user ids.
|
|
@@ -443,27 +458,31 @@ Lambder has no SPA-specific machinery; hosting a frontend build is a recipe buil
|
|
|
443
458
|
|
|
444
459
|
#### Public file sources
|
|
445
460
|
|
|
446
|
-
`
|
|
461
|
+
The `files` option at creation is a `LambderFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null`. The instance owns one reader over it, `lambder.files`, and `servePublicFiles`, `serveIndexHtml`, `res.file` and `res.templateFile` all go through that reader, which does everything else for every source: traversal check, in-memory file cache for warm invocations (default 32MB, 2MB per file), compiled-template cache, mime fallback from the extension. Cache-Control (immutable for content-hashed names), ETag and compression are applied by the serving slot and the response pipeline. Built in:
|
|
447
462
|
|
|
448
463
|
```typescript
|
|
449
|
-
//
|
|
450
|
-
|
|
464
|
+
// A folder, typically the build output bundled with the deployment.
|
|
465
|
+
initLambder().create({ files: new LambderLocalFileSource({ root: path.resolve("./public") }) });
|
|
451
466
|
|
|
452
467
|
// S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
|
|
453
|
-
|
|
454
|
-
|
|
468
|
+
initLambder().create({
|
|
469
|
+
files: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
|
|
455
470
|
});
|
|
456
471
|
|
|
457
472
|
// Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
|
|
458
|
-
|
|
459
|
-
|
|
473
|
+
initLambder().create({
|
|
474
|
+
files: new LambderS3FileSource({
|
|
460
475
|
bucket: "myapp-web",
|
|
461
476
|
clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
|
|
462
477
|
}),
|
|
463
478
|
});
|
|
464
479
|
|
|
465
480
|
// Anything else: implement read().
|
|
466
|
-
|
|
481
|
+
initLambder().create({ files: { read: async (relativePath) => myStore.get(relativePath) } });
|
|
482
|
+
|
|
483
|
+
// The in-memory file cache, tuned or off, beside any source.
|
|
484
|
+
initLambder().create({ files: { source: new LambderS3FileSource({ bucket: "myapp-web" }), memoryCache: { maxBytes: 64_000_000, maxFileBytes: 4_000_000 } } });
|
|
485
|
+
initLambder().create({ files: { source: new LambderLocalFileSource({ root }), memoryCache: false } });
|
|
467
486
|
```
|
|
468
487
|
|
|
469
488
|
A missing S3 object reads as null; grant `s3:ListBucket` besides `s3:GetObject`, otherwise S3 answers a missing key with AccessDenied, which propagates as an error instead of falling through. The object's Content-Type is used unless it is a generic octet-stream, in which case the extension decides. Lambda's ~6MB response cap still applies to anything proxied this way: redirect large downloads to the bucket or CDN URL instead of serving them.
|
|
@@ -496,7 +515,8 @@ The `ctx` object provides access to request data:
|
|
|
496
515
|
| `rawBody` | Decoded request body as received (webhook signatures) | `'{"a":1}'` |
|
|
497
516
|
| `ip` | Client IP (CF-Connecting-IP / X-Forwarded-For / source IP) | `"1.2.3.4"` |
|
|
498
517
|
| `header(name)` | Case-insensitive request header lookup | `ctx.header("accept-language")` |
|
|
499
|
-
| `cookie` | Cookies | `{ rememberMe: "true" }` |
|
|
518
|
+
| `cookie` | Cookies (the first value when a name arrived more than once) | `{ rememberMe: "true" }` |
|
|
519
|
+
| `cookieList` | Every value per cookie name, in header order (a name held at several scopes arrives several times) | `{ rememberMe: ["true"] }` |
|
|
500
520
|
| `headers` | Request headers | `{ "Content-Type": "..." }` |
|
|
501
521
|
| `event` | Raw Lambda event (APIGatewayProxyEvent or APIGatewayProxyEventV2) | - |
|
|
502
522
|
| `lambdaContext` | AWS Lambda Context | - |
|
|
@@ -509,6 +529,8 @@ The `ctx` object provides access to request data:
|
|
|
509
529
|
**Header Manipulation** (call before returning response):
|
|
510
530
|
- `res.addHeader(key, value)` - Adds a header value (can be called multiple times for same key)
|
|
511
531
|
- `res.setHeader(key, value)` - Sets a header (replaces existing values)
|
|
532
|
+
- `res.setCookie(name, value, options?)` - Adds a Set-Cookie header. Options: `domain` (a string, or a `(hostname) => string | undefined` function resolved against the request host), `path` (default `/`), `sameSite` (default `Lax`), `secure` (default true), `httpOnly`, `maxAge` (seconds), `expires` (Date), `encode` (default encodeURIComponent, which `ctx.cookie` reverses)
|
|
533
|
+
- `res.clearCookie(name, options?)` - Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and `path` it was set with: a cookie's identity is (name, domain, path), so a deletion under another scope deletes nothing
|
|
512
534
|
- `res.logToApiResponse(data)` - Adds data to logList in API responses (debugging)
|
|
513
535
|
|
|
514
536
|
**Response Methods** (all accept an options object: `{ statusCode?, headers?, cacheControl?, compress?, etag? }`):
|
|
@@ -101,6 +101,9 @@ export default class LambderCaller {
|
|
|
101
101
|
const resolvedDomain = typeof domainOption === "function" ? domainOption(hostname) : domainOption;
|
|
102
102
|
for (const key of [this.sessionTokenCookieKey, this.sessionCsrfCookieKey]) {
|
|
103
103
|
// Host-only and domain-scoped cookies are distinct entries; clear both.
|
|
104
|
+
// Only the CSRF cookie is reachable from here: the token cookie is
|
|
105
|
+
// HttpOnly, so its removal is the server's (a Set-Cookie on the
|
|
106
|
+
// session-expired or logout response).
|
|
104
107
|
Cookies.remove(key);
|
|
105
108
|
if (resolvedDomain)
|
|
106
109
|
Cookies.remove(key, { domain: resolvedDomain, path: "/" });
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { type LambderSessionDataRefreshConfig } from "../session/LambderSessionM
|
|
|
9
9
|
import type { LambderCompressionOption } from "../stores/LambderDdbCompression.js";
|
|
10
10
|
import LambderSessionController, { type LambderSessionCookieOptions } from "../session/LambderSessionController.js";
|
|
11
11
|
import { type LambderPublicFilesOptions } from "./LambderPublicFiles.js";
|
|
12
|
+
import { LambderFiles, type LambderFilesOption } from "./LambderFiles.js";
|
|
12
13
|
import type { LambderApiGuard, LambderGuardMetaMap, LambderGuardsOption, LambderGuardDataOf, LambderGuardInputsOf } from "../policies/LambderApiGuards.js";
|
|
13
14
|
import type { LambderApiRateLimitPolicyConfig, LambderApiRateLimitsConfig, LambderRateLimitOption } from "../policies/LambderApiRateLimits.js";
|
|
14
15
|
import type { LambderApiIdempotencyConfig } from "../policies/LambderApiIdempotency.js";
|
|
@@ -107,7 +108,14 @@ export type LambderSessionOptions<TSessionData = any> = {
|
|
|
107
108
|
* instance type ever needs a name.
|
|
108
109
|
*/
|
|
109
110
|
export type LambderCreateOptions<TSessionData = any> = {
|
|
110
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Where the app's files come from, for servePublicFiles, serveIndexHtml,
|
|
113
|
+
* res.file and res.templateFile: a LambderLocalFileSource over a folder
|
|
114
|
+
* (the build output bundled with the deployment), a LambderS3FileSource
|
|
115
|
+
* (S3, R2), or any LambderFileSource; or `{ source, memoryCache }` to
|
|
116
|
+
* tune or disable the in-memory file cache. Required by those features.
|
|
117
|
+
*/
|
|
118
|
+
files?: LambderFilesOption;
|
|
111
119
|
apiPath?: string;
|
|
112
120
|
apiVersion?: string;
|
|
113
121
|
/** Automatic gzip for compressible responses. `true` (the default) is `{ minBytes: 860 }`; `false` disables it. */
|
|
@@ -153,7 +161,8 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
153
161
|
export default class Lambder<TSessionData = any, _TContract extends Record<string, any> = {}, _TRateLimitPolicies extends Record<string, LambderApiRateLimitPolicyConfig> = {}, _TGuards extends Record<string, any> = {}, _TIdempotencyEnabled extends boolean = false> {
|
|
154
162
|
apiPath: string;
|
|
155
163
|
apiVersion: null | string;
|
|
156
|
-
|
|
164
|
+
/** The instance's file reader (source + caches), or null without the files option. */
|
|
165
|
+
files: LambderFiles | null;
|
|
157
166
|
/**
|
|
158
167
|
* Type property for extracting the API contract
|
|
159
168
|
* Use this to export your API types to the frontend
|
|
@@ -194,9 +203,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
194
203
|
setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
|
|
195
204
|
/**
|
|
196
205
|
* Terminal public-file layer. Runs only when no route matched, so it can
|
|
197
|
-
* never shadow routes registered after it. Serves files from `
|
|
198
|
-
*
|
|
199
|
-
* R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
|
|
206
|
+
* never shadow routes registered after it. Serves files from the `files`
|
|
207
|
+
* source configured at creation, traversal-safe, mime-typed,
|
|
200
208
|
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
201
209
|
* assets; when the source has no such file the request falls through to
|
|
202
210
|
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
@@ -209,8 +217,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
209
217
|
* gone; everything left is an app route (option `skipFilePaths` opts back
|
|
210
218
|
* into 404ing dotted paths). Only configured methods reach it, default
|
|
211
219
|
* GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
|
|
212
|
-
* Without a handler,
|
|
213
|
-
* (markers optional) with no-cache.
|
|
220
|
+
* Without a handler, index.html from the files source is served via
|
|
221
|
+
* res.templateFile (markers optional) with no-cache.
|
|
214
222
|
*/
|
|
215
223
|
serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
|
|
216
224
|
/** Apply the serveIndexHtml gates; null means fall through. */
|
package/dist/core/Lambder.js
CHANGED
|
@@ -5,7 +5,8 @@ import { compileRouteMatcher } from "./LambderRouting.js";
|
|
|
5
5
|
import { applyCorsHeaders } from "./LambderCors.js";
|
|
6
6
|
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
7
|
import LambderSessionController from "../session/LambderSessionController.js";
|
|
8
|
-
import { LambderPublicFilesHandler
|
|
8
|
+
import { LambderPublicFilesHandler } from "./LambderPublicFiles.js";
|
|
9
|
+
import { LambderFiles } from "./LambderFiles.js";
|
|
9
10
|
import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
10
11
|
import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
|
|
11
12
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
@@ -33,7 +34,8 @@ import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
|
33
34
|
export default class Lambder {
|
|
34
35
|
apiPath;
|
|
35
36
|
apiVersion;
|
|
36
|
-
|
|
37
|
+
/** The instance's file reader (source + caches), or null without the files option. */
|
|
38
|
+
files;
|
|
37
39
|
/**
|
|
38
40
|
* Type property for extracting the API contract
|
|
39
41
|
* Use this to export your API types to the frontend
|
|
@@ -66,7 +68,7 @@ export default class Lambder {
|
|
|
66
68
|
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
67
69
|
sessionCsrfCookieKey = "LMDRSESSIONCSTK";
|
|
68
70
|
constructor(options = {}) {
|
|
69
|
-
this.
|
|
71
|
+
this.files = options.files ? new LambderFiles(options.files) : null;
|
|
70
72
|
this.apiPath = options.apiPath ?? "/api";
|
|
71
73
|
this.apiVersion = options.apiVersion ?? null;
|
|
72
74
|
this.finalizeOptions = {
|
|
@@ -129,17 +131,17 @@ export default class Lambder {
|
|
|
129
131
|
}
|
|
130
132
|
/**
|
|
131
133
|
* Terminal public-file layer. Runs only when no route matched, so it can
|
|
132
|
-
* never shadow routes registered after it. Serves files from `
|
|
133
|
-
*
|
|
134
|
-
* R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
|
|
134
|
+
* never shadow routes registered after it. Serves files from the `files`
|
|
135
|
+
* source configured at creation, traversal-safe, mime-typed,
|
|
135
136
|
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
136
137
|
* assets; when the source has no such file the request falls through to
|
|
137
138
|
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
138
139
|
* render an app shell with res.templateFile).
|
|
139
140
|
*/
|
|
140
141
|
servePublicFiles(options = {}) {
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
if (!this.files)
|
|
143
|
+
throw new Error("servePublicFiles requires the files option at creation (e.g. files: new LambderLocalFileSource({ root }))");
|
|
144
|
+
this.publicFilesHandler = new LambderPublicFilesHandler(this.files, options);
|
|
143
145
|
return this;
|
|
144
146
|
}
|
|
145
147
|
/**
|
|
@@ -148,8 +150,8 @@ export default class Lambder {
|
|
|
148
150
|
* gone; everything left is an app route (option `skipFilePaths` opts back
|
|
149
151
|
* into 404ing dotted paths). Only configured methods reach it, default
|
|
150
152
|
* GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
|
|
151
|
-
* Without a handler,
|
|
152
|
-
* (markers optional) with no-cache.
|
|
153
|
+
* Without a handler, index.html from the files source is served via
|
|
154
|
+
* res.templateFile (markers optional) with no-cache.
|
|
153
155
|
*/
|
|
154
156
|
serveIndexHtml(handler, options = {}) {
|
|
155
157
|
this.indexHtmlConfig = { handler: handler ?? null, options };
|
|
@@ -331,7 +333,7 @@ export default class Lambder {
|
|
|
331
333
|
}
|
|
332
334
|
getResponseBuilder(ctx) {
|
|
333
335
|
return new LambderResponseBuilder({
|
|
334
|
-
|
|
336
|
+
files: this.files,
|
|
335
337
|
apiVersion: this.apiVersion,
|
|
336
338
|
ctx,
|
|
337
339
|
});
|
|
@@ -339,7 +341,7 @@ export default class Lambder {
|
|
|
339
341
|
;
|
|
340
342
|
getResolver(ctx) {
|
|
341
343
|
return new LambderResolver({
|
|
342
|
-
|
|
344
|
+
files: this.files,
|
|
343
345
|
apiVersion: this.apiVersion,
|
|
344
346
|
ctx,
|
|
345
347
|
});
|
|
@@ -11,7 +11,16 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
11
11
|
method: string;
|
|
12
12
|
get: Record<string, string | undefined>;
|
|
13
13
|
post: Record<string, any>;
|
|
14
|
+
/** Cookies by name (the first value when a name arrived more than once; see cookieList). */
|
|
14
15
|
cookie: Record<string, string>;
|
|
16
|
+
/**
|
|
17
|
+
* Every value the request carried per cookie name, in header order. A
|
|
18
|
+
* name normally maps to one value; several arrive when the browser holds
|
|
19
|
+
* that name at more than one scope (host-only beside Domain=, or two
|
|
20
|
+
* paths), typically after a cookie's Domain or Path was changed. The
|
|
21
|
+
* browser's order says nothing about which copy is current.
|
|
22
|
+
*/
|
|
23
|
+
cookieList: Record<string, string[]>;
|
|
15
24
|
session: null;
|
|
16
25
|
apiName: string | null;
|
|
17
26
|
apiPayload: TApiPayload;
|
|
@@ -10,7 +10,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
10
10
|
let path;
|
|
11
11
|
let method;
|
|
12
12
|
let get;
|
|
13
|
-
let
|
|
13
|
+
let cookiePairs;
|
|
14
14
|
let sourceIp;
|
|
15
15
|
const headers = event.headers ?? {};
|
|
16
16
|
if (isV2HttpEvent(event)) {
|
|
@@ -26,7 +26,8 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
26
26
|
for (const [key, value] of new URLSearchParams(event.rawQueryString ?? "").entries()) {
|
|
27
27
|
get[key] = value;
|
|
28
28
|
}
|
|
29
|
-
|
|
29
|
+
// v2 delivers the Cookie header pre-split into name=value pairs.
|
|
30
|
+
cookiePairs = event.cookies ?? [];
|
|
30
31
|
sourceIp = event.requestContext.http.sourceIp || "";
|
|
31
32
|
}
|
|
32
33
|
else {
|
|
@@ -34,10 +35,19 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
34
35
|
path = event.path;
|
|
35
36
|
method = event.httpMethod;
|
|
36
37
|
get = event.queryStringParameters || {};
|
|
37
|
-
|
|
38
|
+
cookiePairs = (headers.Cookie || headers.cookie || "").split(";");
|
|
38
39
|
sourceIp = event.requestContext?.identity?.sourceIp || "";
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
+
// Parsed pair by pair so a name that arrived more than once keeps every
|
|
42
|
+
// value; a whole-header parse keeps only the first.
|
|
43
|
+
const cookieList = {};
|
|
44
|
+
for (const pair of cookiePairs) {
|
|
45
|
+
for (const [name, value] of Object.entries(cookieParser.parse(pair))) {
|
|
46
|
+
if (value !== undefined)
|
|
47
|
+
(cookieList[name] ??= []).push(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const cookie = Object.fromEntries(Object.entries(cookieList).map(([name, values]) => [name, values[0]]));
|
|
41
51
|
const lowercasedHeaders = {};
|
|
42
52
|
for (const [key, value] of Object.entries(headers)) {
|
|
43
53
|
if (value !== undefined)
|
|
@@ -74,7 +84,7 @@ export const createContext = (event, lambdaContext, apiPath) => {
|
|
|
74
84
|
const requestVersion = isApiCall ? (post.version ?? null) : null;
|
|
75
85
|
return {
|
|
76
86
|
host, path, pathParams: {}, method,
|
|
77
|
-
get, post, cookie, event,
|
|
87
|
+
get, post, cookie, cookieList, event,
|
|
78
88
|
session: null,
|
|
79
89
|
apiName, apiPayload,
|
|
80
90
|
guardData: {},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A cookie's Domain attribute: a fixed value such as ".example.com", or a
|
|
3
|
+
* function of the request hostname for one deployment serving several apex
|
|
4
|
+
* domains. Return undefined (or null) for a host-only cookie.
|
|
5
|
+
*/
|
|
6
|
+
export type LambderCookieDomain = string | ((hostname: string) => string | undefined | null);
|
|
7
|
+
/**
|
|
8
|
+
* Attributes of a Set-Cookie header. A cookie's identity in the browser is
|
|
9
|
+
* (name, domain, path): a write under a different domain or path creates a
|
|
10
|
+
* second cookie beside the first instead of replacing it, and a deletion
|
|
11
|
+
* only reaches the cookie whose domain and path it names.
|
|
12
|
+
*/
|
|
13
|
+
export type LambderCookieOptions = {
|
|
14
|
+
domain?: LambderCookieDomain;
|
|
15
|
+
/** Default "/". */
|
|
16
|
+
path?: string;
|
|
17
|
+
/** Default "Lax". */
|
|
18
|
+
sameSite?: "Strict" | "Lax" | "None";
|
|
19
|
+
/** Default true. */
|
|
20
|
+
secure?: boolean;
|
|
21
|
+
/** Default false. */
|
|
22
|
+
httpOnly?: boolean;
|
|
23
|
+
/** Lifetime in seconds (Max-Age). A cookie with neither maxAge nor expires lasts the browser session. */
|
|
24
|
+
maxAge?: number;
|
|
25
|
+
/** Absolute expiry (Expires). */
|
|
26
|
+
expires?: Date;
|
|
27
|
+
/** Value encoder. Default encodeURIComponent, which ctx.cookie reverses on the way back in. */
|
|
28
|
+
encode?: (value: string) => string;
|
|
29
|
+
};
|
|
30
|
+
/** Options of a deleting Set-Cookie: only the scope matters. */
|
|
31
|
+
export type LambderClearCookieOptions = Omit<LambderCookieOptions, "maxAge" | "expires" | "encode">;
|
|
32
|
+
/** The Domain attribute for this request, or undefined for a host-only cookie. */
|
|
33
|
+
export declare const resolveCookieDomain: (domain: LambderCookieDomain | undefined, host?: string) => string | undefined;
|
|
34
|
+
/**
|
|
35
|
+
* One Set-Cookie header value. `host` resolves a function-form domain; a
|
|
36
|
+
* string domain needs none.
|
|
37
|
+
*/
|
|
38
|
+
export declare const serializeCookie: (name: string, value: string, options?: LambderCookieOptions, host?: string) => string;
|
|
39
|
+
/**
|
|
40
|
+
* A Set-Cookie header value that deletes the cookie. Domain and path must
|
|
41
|
+
* match the cookie being deleted: a mismatch targets a different cookie and
|
|
42
|
+
* deletes nothing.
|
|
43
|
+
*/
|
|
44
|
+
export declare const serializeClearCookie: (name: string, options?: LambderClearCookieOptions, host?: string) => string;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import cookieParser from "cookie";
|
|
2
|
+
/** The Domain attribute for this request, or undefined for a host-only cookie. */
|
|
3
|
+
export const resolveCookieDomain = (domain, host = "") => {
|
|
4
|
+
// Host header can carry a port; browsers match the Domain attribute on hostname only.
|
|
5
|
+
const hostname = host.split(":")[0] ?? "";
|
|
6
|
+
const resolved = typeof domain === "function" ? domain(hostname) : domain;
|
|
7
|
+
return resolved || undefined;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* One Set-Cookie header value. `host` resolves a function-form domain; a
|
|
11
|
+
* string domain needs none.
|
|
12
|
+
*/
|
|
13
|
+
export const serializeCookie = (name, value, options = {}, host) => cookieParser.serialize(name, value, {
|
|
14
|
+
domain: resolveCookieDomain(options.domain, host),
|
|
15
|
+
path: options.path ?? "/",
|
|
16
|
+
sameSite: (options.sameSite ?? "Lax").toLowerCase(),
|
|
17
|
+
secure: options.secure ?? true,
|
|
18
|
+
httpOnly: options.httpOnly ?? false,
|
|
19
|
+
maxAge: options.maxAge,
|
|
20
|
+
expires: options.expires,
|
|
21
|
+
encode: options.encode,
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* A Set-Cookie header value that deletes the cookie. Domain and path must
|
|
25
|
+
* match the cookie being deleted: a mismatch targets a different cookie and
|
|
26
|
+
* deletes nothing.
|
|
27
|
+
*/
|
|
28
|
+
export const serializeClearCookie = (name, options = {}, host) => serializeCookie(name, "", { ...options, maxAge: 0, expires: new Date(0) }, host);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
|
|
2
|
+
/** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
|
|
3
|
+
export type LambderFile = {
|
|
4
|
+
body: Buffer;
|
|
5
|
+
mimeType?: string;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Where an app's files come from: the `files` option at creation, read by
|
|
9
|
+
* servePublicFiles, serveIndexHtml, res.file and res.templateFile alike,
|
|
10
|
+
* through the instance's one reader (LambderFiles). Implement `read` over
|
|
11
|
+
* any backing store: LambderLocalFileSource (a folder), LambderS3FileSource
|
|
12
|
+
* (S3, or R2 and other S3-compatible stores), or your own. The reader does
|
|
13
|
+
* the rest for every source: traversal check, memory cache, mime fallback
|
|
14
|
+
* from the extension.
|
|
15
|
+
*/
|
|
16
|
+
export interface LambderFileSource {
|
|
17
|
+
/**
|
|
18
|
+
* The file at a relative path (no leading slash, no ".." segments: the
|
|
19
|
+
* reader rejects those before calling), or null when there is no such
|
|
20
|
+
* file, which lets a request fall through to the route fallback.
|
|
21
|
+
*/
|
|
22
|
+
read(relativePath: string): Promise<LambderFile | null>;
|
|
23
|
+
}
|
|
24
|
+
/** In-memory cache of files for warm invocations. Default: { maxBytes: 32MB, maxFileBytes: 2MB }. false disables it. */
|
|
25
|
+
export type LambderFileMemoryCacheOption = false | {
|
|
26
|
+
maxBytes?: number;
|
|
27
|
+
maxFileBytes?: number;
|
|
28
|
+
};
|
|
29
|
+
/** The `files` option at creation: a source, or a source with its memory cache tuned or off. */
|
|
30
|
+
export type LambderFilesOption = LambderFileSource | {
|
|
31
|
+
source: LambderFileSource;
|
|
32
|
+
memoryCache?: LambderFileMemoryCacheOption;
|
|
33
|
+
};
|
|
34
|
+
/** A file as the reader hands it out: path normalized, mime type resolved. */
|
|
35
|
+
export type LambderReadFile = {
|
|
36
|
+
body: Buffer;
|
|
37
|
+
mimeType: string;
|
|
38
|
+
relativePath: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
42
|
+
* bundled into the deployment package. Reads stay under root.
|
|
43
|
+
*/
|
|
44
|
+
export declare class LambderLocalFileSource implements LambderFileSource {
|
|
45
|
+
private root;
|
|
46
|
+
constructor({ root }: {
|
|
47
|
+
root: string;
|
|
48
|
+
});
|
|
49
|
+
read(relativePath: string): Promise<LambderFile | null>;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The path a source is asked for: leading slash stripped, traversal
|
|
53
|
+
* rejected; null for a path that names no file (empty, or a directory).
|
|
54
|
+
*/
|
|
55
|
+
export declare const toRelativePath: (target: string) => string | null;
|
|
56
|
+
/**
|
|
57
|
+
* The app's file reader, owned by the Lambder instance: one source, one
|
|
58
|
+
* path rule, one memory cache and one compiled-template cache, shared by
|
|
59
|
+
* every feature that reads files. Both caches live as long as the instance,
|
|
60
|
+
* i.e. across warm invocations.
|
|
61
|
+
*/
|
|
62
|
+
export declare class LambderFiles {
|
|
63
|
+
private source;
|
|
64
|
+
private cache;
|
|
65
|
+
private cacheBytes;
|
|
66
|
+
private maxBytes;
|
|
67
|
+
private maxFileBytes;
|
|
68
|
+
private templates;
|
|
69
|
+
constructor(option: LambderFilesOption);
|
|
70
|
+
/**
|
|
71
|
+
* The file at a request or handler path (leading slash optional), mime
|
|
72
|
+
* type resolved; null when the path is invalid or the source has none.
|
|
73
|
+
*/
|
|
74
|
+
read(path: string): Promise<LambderReadFile | null>;
|
|
75
|
+
/**
|
|
76
|
+
* The compiled template for an HTML file, compiled once per instance.
|
|
77
|
+
* A missing file throws: it is a server-side configuration error, not a
|
|
78
|
+
* client 404.
|
|
79
|
+
*/
|
|
80
|
+
template(path: string, options?: {
|
|
81
|
+
htmlVirtualSlots?: boolean;
|
|
82
|
+
}): Promise<LambderTemplatingEngine>;
|
|
83
|
+
/** Cache small files within the byte budget, evicting the oldest entries first. */
|
|
84
|
+
private remember;
|
|
85
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import mimeTypeResolver from "mime-types";
|
|
2
|
+
import { getFS, getPath } from "../shared/node-polyfills.js";
|
|
3
|
+
import { LambderTemplatingEngine } from "./LambderTemplatingEngine.js";
|
|
4
|
+
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
5
|
+
const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
6
|
+
/**
|
|
7
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
8
|
+
* bundled into the deployment package. Reads stay under root.
|
|
9
|
+
*/
|
|
10
|
+
export class LambderLocalFileSource {
|
|
11
|
+
root;
|
|
12
|
+
constructor({ root }) {
|
|
13
|
+
this.root = root;
|
|
14
|
+
}
|
|
15
|
+
async read(relativePath) {
|
|
16
|
+
const fs = await getFS();
|
|
17
|
+
const path = await getPath();
|
|
18
|
+
if (!fs || !path)
|
|
19
|
+
throw new Error("LambderLocalFileSource requires a Node.js environment.");
|
|
20
|
+
const base = path.resolve(this.root);
|
|
21
|
+
const absolute = path.resolve(base, relativePath);
|
|
22
|
+
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
23
|
+
return null;
|
|
24
|
+
const stat = await fs.promises.stat(absolute).catch(() => null);
|
|
25
|
+
if (!stat?.isFile())
|
|
26
|
+
return null;
|
|
27
|
+
return { body: await fs.promises.readFile(absolute) };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The path a source is asked for: leading slash stripped, traversal
|
|
32
|
+
* rejected; null for a path that names no file (empty, or a directory).
|
|
33
|
+
*/
|
|
34
|
+
export const toRelativePath = (target) => {
|
|
35
|
+
if (target.split("/").some((segment) => segment === ".."))
|
|
36
|
+
return null;
|
|
37
|
+
const relative = target.startsWith("/") ? target.slice(1) : target;
|
|
38
|
+
if (relative === "" || relative.endsWith("/"))
|
|
39
|
+
return null;
|
|
40
|
+
return relative;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* The app's file reader, owned by the Lambder instance: one source, one
|
|
44
|
+
* path rule, one memory cache and one compiled-template cache, shared by
|
|
45
|
+
* every feature that reads files. Both caches live as long as the instance,
|
|
46
|
+
* i.e. across warm invocations.
|
|
47
|
+
*/
|
|
48
|
+
export class LambderFiles {
|
|
49
|
+
source;
|
|
50
|
+
cache;
|
|
51
|
+
cacheBytes = 0;
|
|
52
|
+
maxBytes;
|
|
53
|
+
maxFileBytes;
|
|
54
|
+
templates = new Map();
|
|
55
|
+
constructor(option) {
|
|
56
|
+
const { source, memoryCache } = "source" in option ? option : { source: option, memoryCache: undefined };
|
|
57
|
+
this.source = source;
|
|
58
|
+
this.cache = memoryCache === false ? null : new Map();
|
|
59
|
+
this.maxBytes = memoryCache === false ? 0 : (memoryCache?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES);
|
|
60
|
+
this.maxFileBytes = memoryCache === false ? 0 : (memoryCache?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The file at a request or handler path (leading slash optional), mime
|
|
64
|
+
* type resolved; null when the path is invalid or the source has none.
|
|
65
|
+
*/
|
|
66
|
+
async read(path) {
|
|
67
|
+
const relativePath = toRelativePath(path);
|
|
68
|
+
if (relativePath === null)
|
|
69
|
+
return null;
|
|
70
|
+
const cached = this.cache?.get(relativePath);
|
|
71
|
+
if (cached)
|
|
72
|
+
return cached;
|
|
73
|
+
const file = await this.source.read(relativePath);
|
|
74
|
+
if (!file)
|
|
75
|
+
return null;
|
|
76
|
+
const entry = {
|
|
77
|
+
body: file.body,
|
|
78
|
+
mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
|
|
79
|
+
relativePath,
|
|
80
|
+
};
|
|
81
|
+
this.remember(entry);
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The compiled template for an HTML file, compiled once per instance.
|
|
86
|
+
* A missing file throws: it is a server-side configuration error, not a
|
|
87
|
+
* client 404.
|
|
88
|
+
*/
|
|
89
|
+
async template(path, options = {}) {
|
|
90
|
+
const key = `${toRelativePath(path)}|${options.htmlVirtualSlots ? "v" : ""}`;
|
|
91
|
+
const cached = this.templates.get(key);
|
|
92
|
+
if (cached)
|
|
93
|
+
return cached;
|
|
94
|
+
const file = await this.read(path);
|
|
95
|
+
if (!file)
|
|
96
|
+
throw new Error(`templateFile: file not found in the files source: ${path}`);
|
|
97
|
+
const template = new LambderTemplatingEngine(file.body.toString("utf8"), { htmlVirtualSlots: options.htmlVirtualSlots });
|
|
98
|
+
this.templates.set(key, template);
|
|
99
|
+
return template;
|
|
100
|
+
}
|
|
101
|
+
/** Cache small files within the byte budget, evicting the oldest entries first. */
|
|
102
|
+
remember(entry) {
|
|
103
|
+
if (!this.cache || entry.body.length > this.maxFileBytes)
|
|
104
|
+
return;
|
|
105
|
+
for (const [key, value] of this.cache) {
|
|
106
|
+
if (this.cacheBytes + entry.body.length <= this.maxBytes)
|
|
107
|
+
break;
|
|
108
|
+
this.cache.delete(key);
|
|
109
|
+
this.cacheBytes -= value.body.length;
|
|
110
|
+
}
|
|
111
|
+
if (this.cacheBytes + entry.body.length <= this.maxBytes) {
|
|
112
|
+
this.cache.set(entry.relativePath, entry);
|
|
113
|
+
this.cacheBytes += entry.body.length;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|