lambder 4.3.2 → 4.4.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 +39 -3
- package/dist/client/LambderCaller.d.ts +75 -27
- package/dist/client/LambderCaller.js +18 -2
- package/dist/client.d.ts +1 -1
- package/dist/core/Lambder.d.ts +7 -5
- package/dist/core/Lambder.js +10 -7
- package/dist/core/LambderPublicFiles.d.ts +44 -12
- package/dist/core/LambderPublicFiles.js +60 -40
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/session/LambderSessionController.d.ts +7 -0
- package/dist/session/LambderSessionController.js +10 -0
- package/dist/session/LambderSessionManager.d.ts +11 -0
- package/dist/session/LambderSessionManager.js +38 -2
- package/dist/stores/LambderS3FileSource.d.ts +35 -0
- package/dist/stores/LambderS3FileSource.js +53 -0
- package/package.json +3 -1
package/Readme.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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.4:**
|
|
6
|
+
|
|
7
|
+
- **`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.
|
|
8
|
+
- **Public file sources**: `servePublicFiles({ source })` serves from any `LambderPublicFileSource`: `LambderLocalFileSource` (a folder; the default, over `publicPath`), `LambderS3FileSource` (S3, or Cloudflare R2 and other S3-compatible stores via `clientConfig.endpoint`; `@aws-sdk/client-s3` is an optional peer dependency loaded on first read), or your own `{ read(relativePath) }`. The handler's traversal check, memory cache, mime fallback from the extension, Cache-Control, ETag and compression apply to every source. The `cacheControl` callback receives the relative file path.
|
|
9
|
+
- **`expireSessionDataAllByKey(sessionKey)`** on the session manager and controller: marks the data of every session of a subject stale, so each renews via `dataRefresh` on its next read. The way to apply a role or permission change to a user immediately, without logging them out (`deleteSessionAllByKey`) and without waiting for the data TTL.
|
|
10
|
+
|
|
5
11
|
**New in 4.3:**
|
|
6
12
|
|
|
7
13
|
- **Compressed sessions**: `session.data` is stored Brotli-compressed by default, as `dataBr` + `dataBytes` on the record, the same scheme LambderDdbCache and LambderDdbIdempotency use (one shared implementation). A session that caches roles, permissions or product lists shrinks 2-3x and stays within one DynamoDB read unit for longer. `session.compression` is `true` by default (the same as `{ minBytes: 0 }`: every record compressed); `false` turns it off and `{ minBytes }` compresses only from that JSON size. Records written under either setting read back, so it can be switched on or off on a live table.
|
|
@@ -160,8 +166,9 @@ lambder
|
|
|
160
166
|
.addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
|
|
161
167
|
return res.json({ received: true });
|
|
162
168
|
})
|
|
163
|
-
// Serve real files from publicPath
|
|
164
|
-
//
|
|
169
|
+
// Serve real files (from publicPath by default; see "Public file sources"
|
|
170
|
+
// below for S3/R2). This is a terminal fallback slot, NOT a catch-all
|
|
171
|
+
// route, so it can never shadow routes registered after it.
|
|
165
172
|
.servePublicFiles()
|
|
166
173
|
// Serve the app shell for GET/HEAD page requests nothing else handled
|
|
167
174
|
// (see "Hosting a frontend build" below).
|
|
@@ -359,6 +366,7 @@ Semantics:
|
|
|
359
366
|
- The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
|
|
360
367
|
- Records created before `dataRefresh` was enabled renew on their first read.
|
|
361
368
|
- `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
|
|
369
|
+
- `expireSessionDataAllByKey(sessionKey)` stamps every session of a subject stale at once: call it after changing that subject's roles or permissions, and the change applies on their next request instead of within `ttlSeconds`, with no logout. It updates only `dataExpiresAt`, conditionally on the record still existing, so it neither resurrects a deleted session nor clobbers a concurrent write.
|
|
362
370
|
|
|
363
371
|
#### Session data at rest (`compression`)
|
|
364
372
|
|
|
@@ -389,6 +397,7 @@ Access the session controller with `lambder.getSessionController(ctx)`:
|
|
|
389
397
|
| `endSession()` | End session, delete from DDB |
|
|
390
398
|
| `endSessionAll()` | End all sessions for this sessionKey (all devices) |
|
|
391
399
|
| `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
|
|
400
|
+
| `expireSessionDataAllByKey(sessionKey)` | Mark the data of all sessions of a sessionKey stale, so each renews via `dataRefresh` on its next read (no logout) |
|
|
392
401
|
| `regenerateSession()` | Regenerate token (use after password change) |
|
|
393
402
|
|
|
394
403
|
### Type-Safe Templating (html / xml)
|
|
@@ -432,6 +441,33 @@ const output = template.render({
|
|
|
432
441
|
|
|
433
442
|
Lambder has no SPA-specific machinery; hosting a frontend build is a recipe built from three generic primitives: `servePublicFiles()` (terminal slot serving real files: memory-cached, immutable Cache-Control for hashed assets, ETag/gzip, falls through when missing), `serveIndexHtml()` (next fallback slot, GET/HEAD + non-file-path gated) and `res.templateFile()` (render an HTML file through the templating engine, compiled once and cached). **Full guide with the multi-tenant recipe: [docs/TEMPLATING.md](./docs/TEMPLATING.md).**
|
|
434
443
|
|
|
444
|
+
#### Public file sources
|
|
445
|
+
|
|
446
|
+
`servePublicFiles` reads through a `LambderPublicFileSource`, an object with one method, `read(relativePath)`, returning `{ body, mimeType? }` or `null` (the request then falls through). The handler does everything else for every source: traversal check, memory cache for warm invocations, mime fallback from the extension, Cache-Control (immutable for content-hashed names), ETag and compression. Built in:
|
|
447
|
+
|
|
448
|
+
```typescript
|
|
449
|
+
// Default: the publicPath folder bundled with the deployment.
|
|
450
|
+
lambder.servePublicFiles();
|
|
451
|
+
|
|
452
|
+
// S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
|
|
453
|
+
lambder.servePublicFiles({
|
|
454
|
+
source: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
|
|
458
|
+
lambder.servePublicFiles({
|
|
459
|
+
source: new LambderS3FileSource({
|
|
460
|
+
bucket: "myapp-web",
|
|
461
|
+
clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
|
|
462
|
+
}),
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// Anything else: implement read().
|
|
466
|
+
lambder.servePublicFiles({ source: { read: async (relativePath) => myStore.get(relativePath) } });
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
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.
|
|
470
|
+
|
|
435
471
|
```typescript
|
|
436
472
|
// Zero-config single-tenant hosting:
|
|
437
473
|
lambder.servePublicFiles().serveIndexHtml();
|
|
@@ -772,7 +808,7 @@ Also available:
|
|
|
772
808
|
|
|
773
809
|
- **Timeouts**: pass `timeoutMs` in the constructor for a default (API Gateway caps around 29s, so ~30000 is sensible) and/or per call; timed-out calls abort the fetch and report `reason: 'timeout'`. A per-call `signal` combines with the timeout.
|
|
774
810
|
- **Per-call handler overrides**: every constructor handler (`errorHandler`, `sessionExpiredHandler`, `errorMessageHandler`, ...) can be overridden in the options of a single `api`/`apiOutcome` call.
|
|
775
|
-
- **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs.
|
|
811
|
+
- **Guard inputs**: for APIs whose guards run in guardInput mode, pass their values per call as `guardInputs: { <guardName>: value }`; the typed contract makes the options argument (and the correct value shape) mandatory for those APIs. A `guardInputsProvider` on the caller supplies values for every call from one place, keyed by guard name, with per-call `guardInputs` merged on top; name the guards it covers in the caller's second type parameter, `new LambderCaller<Contract, "orgPermission">({ guardInputsProvider: () => ({ orgPermission: { orgSlug } }), ... })`, and calls to APIs whose guardInput guards are all covered take an optional options argument again.
|
|
776
812
|
- **Idempotency keys**: pass `idempotencyKey` per call for APIs declared idempotent on the server (see Declarative API Policies). Generate it once per logical operation with `LambderCaller.createIdempotencyKey()` (safe in insecure contexts where `crypto.randomUUID` is missing) and send the same key on retries; rotate after a confirmed success. `LambderCaller.createIdempotencyKeyScope()` packages that pattern for a component performing one operation repeatedly: read `scope.current` on every attempt, call `scope.rotate()` after a confirmed success. Keys must be unguessable random and 16-200 characters (they scope the replay record for logged-out clients); the server refuses shorter keys with a 400.
|
|
777
813
|
|
|
778
814
|
### Benefits
|
|
@@ -5,14 +5,52 @@ type IsAny<T> = 0 extends (1 & T) ? true : false;
|
|
|
5
5
|
type GuardInputsOf<TEntry> = TEntry extends {
|
|
6
6
|
guardInputs: infer G;
|
|
7
7
|
} ? G : never;
|
|
8
|
+
/** Input type of guard G on one contract entry; never when that API does not declare it. */
|
|
9
|
+
type GuardInputOf<TEntry, G extends string> = GuardInputsOf<TEntry> extends infer I ? (G extends keyof I ? I[G] : never) : never;
|
|
10
|
+
/**
|
|
11
|
+
* What guardInputsProvider returns: for every provided guard name, the value
|
|
12
|
+
* the contract's APIs expect for it (a union across APIs when they differ).
|
|
13
|
+
* Naming a guard no API declares in guardInput mode resolves to never, so a
|
|
14
|
+
* typo fails the provider's return type instead of going missing at runtime.
|
|
15
|
+
*/
|
|
16
|
+
export type LambderProvidedGuardInputs<TContract, TProvided extends string> = IsAny<TContract> extends true ? Record<TProvided, unknown> : {
|
|
17
|
+
[G in TProvided]: {
|
|
18
|
+
[K in keyof TContract]: GuardInputOf<TContract[K], G>;
|
|
19
|
+
}[keyof TContract];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Supplies guardInputs for every call from one place (the organization the
|
|
23
|
+
* UI is on, a device token), keyed by guard name; per-call guardInputs
|
|
24
|
+
* merge on top. Name the guards it covers in the caller's second type
|
|
25
|
+
* parameter, `new LambderCaller<Contract, "orgPermission">`, and calls to
|
|
26
|
+
* APIs whose guardInput guards are all covered no longer require the
|
|
27
|
+
* options argument. May be async; a throw fails the call as an unknown
|
|
28
|
+
* error before anything is sent.
|
|
29
|
+
*/
|
|
30
|
+
export type LambderGuardInputsProvider<TContract, TProvided extends string> = (apiName: keyof TContract & string) => LambderProvidedGuardInputs<TContract, TProvided> | Promise<LambderProvidedGuardInputs<TContract, TProvided>>;
|
|
31
|
+
/** Optional until the caller names provided guards: naming them without a provider would send nothing. */
|
|
32
|
+
type GuardInputsProviderOption<TContract, TProvided extends string> = [
|
|
33
|
+
TProvided
|
|
34
|
+
] extends [never] ? {
|
|
35
|
+
guardInputsProvider?: LambderGuardInputsProvider<TContract, TProvided>;
|
|
36
|
+
} : {
|
|
37
|
+
guardInputsProvider: LambderGuardInputsProvider<TContract, TProvided>;
|
|
38
|
+
};
|
|
39
|
+
/** An API's guardInput guards the provider does not cover: those the call must still pass. */
|
|
40
|
+
type RemainingGuardInputs<TEntry, TProvided extends string> = Omit<GuardInputsOf<TEntry>, TProvided>;
|
|
8
41
|
/**
|
|
9
42
|
* The options argument: optional normally, REQUIRED (with guardInputs) when
|
|
10
|
-
* the API's contract declares guardInput-mode guards
|
|
11
|
-
* a guard's value is a compile error at the
|
|
43
|
+
* the API's contract declares guardInput-mode guards the provider does not
|
|
44
|
+
* cover, so forgetting to send a guard's value is a compile error at the
|
|
45
|
+
* call site. Provided guards may still be overridden per call.
|
|
12
46
|
*/
|
|
13
|
-
type CallOptionsArg<TContract, TApiName> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [options
|
|
14
|
-
guardInputs
|
|
15
|
-
}] : [
|
|
47
|
+
type CallOptionsArg<TContract, TApiName, TProvided extends string> = IsAny<TContract> extends true ? [options?: LambderCallOptions] : TApiName extends keyof TContract ? [GuardInputsOf<TContract[TApiName]>] extends [never] ? [options?: LambderCallOptions] : [keyof RemainingGuardInputs<TContract[TApiName], TProvided>] extends [never] ? [options?: LambderCallOptions & {
|
|
48
|
+
guardInputs?: Partial<GuardInputsOf<TContract[TApiName]>>;
|
|
49
|
+
}] : [
|
|
50
|
+
options: LambderCallOptions & {
|
|
51
|
+
guardInputs: RemainingGuardInputs<TContract[TApiName], TProvided> & Partial<GuardInputsOf<TContract[TApiName]>>;
|
|
52
|
+
}
|
|
53
|
+
] : [options?: LambderCallOptions];
|
|
16
54
|
type VoidFunction = () => void | Promise<void>;
|
|
17
55
|
type FetchTracker = {
|
|
18
56
|
apiName: string;
|
|
@@ -79,7 +117,9 @@ export type LambderCallOptions = {
|
|
|
79
117
|
/**
|
|
80
118
|
* Values for the API's guardInput-mode guards, keyed by guard name; sent
|
|
81
119
|
* beside the payload and consumed by the guards before validation. The
|
|
82
|
-
* typed contract makes this REQUIRED for APIs that declare such guards
|
|
120
|
+
* typed contract makes this REQUIRED for APIs that declare such guards,
|
|
121
|
+
* except the guards a guardInputsProvider covers (these merge on top of
|
|
122
|
+
* the provider's values).
|
|
83
123
|
*/
|
|
84
124
|
guardInputs?: Record<string, unknown>;
|
|
85
125
|
/**
|
|
@@ -102,7 +142,31 @@ export type LambderCallOptions = {
|
|
|
102
142
|
fetchStartedHandler?: FetchStartEventHandler;
|
|
103
143
|
fetchEndedHandler?: FetchEndEventHandler;
|
|
104
144
|
};
|
|
105
|
-
|
|
145
|
+
type LambderCallerBaseOptions = {
|
|
146
|
+
apiPath: string;
|
|
147
|
+
apiVersion?: string;
|
|
148
|
+
isCorsEnabled: boolean;
|
|
149
|
+
/** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
|
|
150
|
+
timeoutMs?: number;
|
|
151
|
+
versionExpiredHandler?: VoidFunction;
|
|
152
|
+
sessionExpiredHandler?: VoidFunction;
|
|
153
|
+
messageHandler?: MessageHandler;
|
|
154
|
+
errorMessageHandler?: MessageHandler;
|
|
155
|
+
notAuthorizedHandler?: VoidFunction;
|
|
156
|
+
errorHandler?: ErrorHandler;
|
|
157
|
+
fetchStartedHandler?: FetchStartEventHandler;
|
|
158
|
+
fetchEndedHandler?: FetchEndEventHandler;
|
|
159
|
+
apiInputValidationErrorHandler?: ValidationErrorHandler;
|
|
160
|
+
/** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
|
|
161
|
+
sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
|
|
162
|
+
};
|
|
163
|
+
/** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
|
|
164
|
+
export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions & GuardInputsProviderOption<TContract, TProvided>;
|
|
165
|
+
/**
|
|
166
|
+
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
167
|
+
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
168
|
+
*/
|
|
169
|
+
export default class LambderCaller<TContract extends ApiContractShape = any, TProvidedGuards extends string = never> {
|
|
106
170
|
private isCorsEnabled;
|
|
107
171
|
private apiPath;
|
|
108
172
|
private apiVersion?;
|
|
@@ -118,27 +182,11 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
|
|
|
118
182
|
private apiInputValidationErrorHandler?;
|
|
119
183
|
private fetchStartedHandler?;
|
|
120
184
|
private fetchEndedHandler?;
|
|
185
|
+
private guardInputsProvider?;
|
|
121
186
|
private sessionTokenCookieKey;
|
|
122
187
|
private sessionCsrfCookieKey;
|
|
123
188
|
private sessionCookieDomain?;
|
|
124
|
-
constructor(
|
|
125
|
-
apiPath: string;
|
|
126
|
-
apiVersion?: string;
|
|
127
|
-
isCorsEnabled: boolean;
|
|
128
|
-
/** Default per-request timeout in ms (none unless set; API Gateway caps around 29s, so ~30000 is a sensible value). Overridable per call. */
|
|
129
|
-
timeoutMs?: number;
|
|
130
|
-
versionExpiredHandler?: VoidFunction;
|
|
131
|
-
sessionExpiredHandler?: VoidFunction;
|
|
132
|
-
messageHandler?: MessageHandler;
|
|
133
|
-
errorMessageHandler?: MessageHandler;
|
|
134
|
-
notAuthorizedHandler?: VoidFunction;
|
|
135
|
-
errorHandler?: ErrorHandler;
|
|
136
|
-
fetchStartedHandler?: FetchStartEventHandler;
|
|
137
|
-
fetchEndedHandler?: FetchEndEventHandler;
|
|
138
|
-
apiInputValidationErrorHandler?: ValidationErrorHandler;
|
|
139
|
-
/** Must mirror the server's session cookie Domain, otherwise expired cookies cannot be cleared. */
|
|
140
|
-
sessionCookieDomain?: string | ((hostname: string) => string | undefined | null);
|
|
141
|
-
});
|
|
189
|
+
constructor(options: LambderCallerOptions<TContract, TProvidedGuards>);
|
|
142
190
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
|
|
143
191
|
/**
|
|
144
192
|
* A self-rotating idempotency key for a component or form that performs
|
|
@@ -171,8 +219,8 @@ export default class LambderCaller<TContract extends ApiContractShape = any> {
|
|
|
171
219
|
* Full-fidelity call: resolves to a discriminated LambderApiOutcome
|
|
172
220
|
* instead of collapsing every failure to null. Never throws.
|
|
173
221
|
*/
|
|
174
|
-
apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<LambderApiOutcome<TOutput>>;
|
|
222
|
+
apiOutcome<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName, TProvidedGuards>): Promise<LambderApiOutcome<TOutput>>;
|
|
175
223
|
/** Payload on success, null/undefined otherwise (indistinguishable from a null payload; prefer apiOutcome() when that matters). */
|
|
176
|
-
api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName>): Promise<TOutput | null | undefined>;
|
|
224
|
+
api<TApiName extends keyof TContract & string = string, TOutput = TApiName extends keyof TContract ? TContract[TApiName]['output'] : any>(apiName: TApiName, payload?: TApiName extends keyof TContract ? TContract[TApiName]['input'] : any, ...rest: CallOptionsArg<TContract, TApiName, TProvidedGuards>): Promise<TOutput | null | undefined>;
|
|
177
225
|
}
|
|
178
226
|
export {};
|
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import Cookies from 'js-cookie';
|
|
2
|
+
/**
|
|
3
|
+
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
4
|
+
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
5
|
+
*/
|
|
2
6
|
export default class LambderCaller {
|
|
3
7
|
isCorsEnabled;
|
|
4
8
|
apiPath;
|
|
@@ -15,10 +19,14 @@ export default class LambderCaller {
|
|
|
15
19
|
apiInputValidationErrorHandler;
|
|
16
20
|
fetchStartedHandler;
|
|
17
21
|
fetchEndedHandler;
|
|
22
|
+
guardInputsProvider;
|
|
18
23
|
sessionTokenCookieKey = "LMDRSESSIONTKID";
|
|
19
24
|
sessionCsrfCookieKey = "LMDRSESSIONCSTK";
|
|
20
25
|
sessionCookieDomain;
|
|
21
|
-
constructor(
|
|
26
|
+
constructor(options) {
|
|
27
|
+
// The conditional provider option is resolved per instantiation;
|
|
28
|
+
// inside the class it is read through the plain shape.
|
|
29
|
+
const { apiPath, apiVersion, isCorsEnabled = false, timeoutMs, versionExpiredHandler, sessionExpiredHandler, messageHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, guardInputsProvider, } = options;
|
|
22
30
|
this.apiPath = apiPath ?? "/api";
|
|
23
31
|
this.apiVersion = apiVersion;
|
|
24
32
|
this.isCorsEnabled = isCorsEnabled;
|
|
@@ -33,6 +41,7 @@ export default class LambderCaller {
|
|
|
33
41
|
this.apiInputValidationErrorHandler = apiInputValidationErrorHandler;
|
|
34
42
|
this.fetchStartedHandler = fetchStartedHandler;
|
|
35
43
|
this.fetchEndedHandler = fetchEndedHandler;
|
|
44
|
+
this.guardInputsProvider = guardInputsProvider;
|
|
36
45
|
}
|
|
37
46
|
;
|
|
38
47
|
setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
|
|
@@ -159,6 +168,13 @@ export default class LambderCaller {
|
|
|
159
168
|
const version = this.apiVersion;
|
|
160
169
|
const token = Cookies.get(this.sessionCsrfCookieKey) || "";
|
|
161
170
|
const siteHost = window.location.hostname;
|
|
171
|
+
// Provider values underneath, per-call values on top.
|
|
172
|
+
const providedGuardInputs = this.guardInputsProvider
|
|
173
|
+
? await this.guardInputsProvider(apiName)
|
|
174
|
+
: undefined;
|
|
175
|
+
const guardInputs = providedGuardInputs !== undefined || options?.guardInputs !== undefined
|
|
176
|
+
? { ...providedGuardInputs, ...options?.guardInputs }
|
|
177
|
+
: undefined;
|
|
162
178
|
let res;
|
|
163
179
|
try {
|
|
164
180
|
res = await fetch(this.apiPath, {
|
|
@@ -170,7 +186,7 @@ export default class LambderCaller {
|
|
|
170
186
|
headers: { 'Content-Type': 'application/json', ...(headers || {}) },
|
|
171
187
|
body: JSON.stringify({
|
|
172
188
|
apiName, version, token, siteHost, payload,
|
|
173
|
-
...(
|
|
189
|
+
...(guardInputs !== undefined ? { guardInputs } : {}),
|
|
174
190
|
...(options?.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
|
|
175
191
|
}),
|
|
176
192
|
...(signal ? { signal } : {}),
|
package/dist/client.d.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the root entry (`"lambder"`) is the server surface.
|
|
8
8
|
*/
|
|
9
9
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
10
|
-
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
|
|
10
|
+
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope, } from "./client/LambderCaller.js";
|
|
11
11
|
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
12
12
|
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
13
13
|
export type { ApiContractShape, LambderApiResponse, LambderApiResponseConfig } from "./shared/LambderApiContract.js";
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -194,11 +194,13 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
194
194
|
setSessionExpiredRouteHandler(handler: FallbackHandlerFunction): this;
|
|
195
195
|
/**
|
|
196
196
|
* Terminal public-file layer. Runs only when no route matched, so it can
|
|
197
|
-
* never shadow routes registered after it. Serves
|
|
198
|
-
* publicPath
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
197
|
+
* never shadow routes registered after it. Serves files from `source`
|
|
198
|
+
* (default: the publicPath folder; also LambderS3FileSource for S3 and
|
|
199
|
+
* R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
|
|
200
|
+
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
201
|
+
* assets; when the source has no such file the request falls through to
|
|
202
|
+
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
203
|
+
* render an app shell with res.templateFile).
|
|
202
204
|
*/
|
|
203
205
|
servePublicFiles(options?: LambderPublicFilesOptions): this;
|
|
204
206
|
/**
|
package/dist/core/Lambder.js
CHANGED
|
@@ -5,7 +5,7 @@ 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 } from "./LambderPublicFiles.js";
|
|
8
|
+
import { LambderPublicFilesHandler, LambderLocalFileSource } from "./LambderPublicFiles.js";
|
|
9
9
|
import { isLambderApiError, LAMBDER_REFUSAL_CODES } from "../shared/LambderApiError.js";
|
|
10
10
|
import { LambderApiPolicyEngine } from "../policies/LambderApiPolicies.js";
|
|
11
11
|
import { createContext, isV2HttpEvent } from "./LambderContext.js";
|
|
@@ -129,14 +129,17 @@ export default class Lambder {
|
|
|
129
129
|
}
|
|
130
130
|
/**
|
|
131
131
|
* Terminal public-file layer. Runs only when no route matched, so it can
|
|
132
|
-
* never shadow routes registered after it. Serves
|
|
133
|
-
* publicPath
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
132
|
+
* never shadow routes registered after it. Serves files from `source`
|
|
133
|
+
* (default: the publicPath folder; also LambderS3FileSource for S3 and
|
|
134
|
+
* R2, or any LambderPublicFileSource), traversal-safe, mime-typed,
|
|
135
|
+
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
136
|
+
* assets; when the source has no such file the request falls through to
|
|
137
|
+
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
138
|
+
* render an app shell with res.templateFile).
|
|
137
139
|
*/
|
|
138
140
|
servePublicFiles(options = {}) {
|
|
139
|
-
|
|
141
|
+
const source = options.source ?? new LambderLocalFileSource({ root: this.publicPath });
|
|
142
|
+
this.publicFilesHandler = new LambderPublicFilesHandler(source, options);
|
|
140
143
|
return this;
|
|
141
144
|
}
|
|
142
145
|
/**
|
|
@@ -1,14 +1,36 @@
|
|
|
1
1
|
import type { LambderRenderContext } from "./LambderContext.js";
|
|
2
2
|
import { LambderResponse } from "./LambderResponse.js";
|
|
3
|
+
/** A file a source serves: its bytes, and its mime type when the source knows it (otherwise resolved from the extension). */
|
|
4
|
+
export type LambderPublicFile = {
|
|
5
|
+
body: Buffer;
|
|
6
|
+
mimeType?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Where servePublicFiles gets its files. Implement `read` over any backing
|
|
10
|
+
* store: LambderLocalFileSource (a folder, the default), LambderS3FileSource
|
|
11
|
+
* (S3, or R2 and other S3-compatible stores), or your own. The handler does
|
|
12
|
+
* the rest for every source: traversal check, memory cache, mime fallback
|
|
13
|
+
* from the extension, Cache-Control, ETag and compression.
|
|
14
|
+
*/
|
|
15
|
+
export interface LambderPublicFileSource {
|
|
16
|
+
/**
|
|
17
|
+
* The file at a relative path (no leading slash, no ".." segments: the
|
|
18
|
+
* handler rejects those before calling), or null when there is no such
|
|
19
|
+
* file, which lets the request fall through to the route fallback.
|
|
20
|
+
*/
|
|
21
|
+
read(relativePath: string): Promise<LambderPublicFile | null>;
|
|
22
|
+
}
|
|
3
23
|
export type LambderPublicFilesOptions = {
|
|
24
|
+
/** Where files come from. Default: LambderLocalFileSource over publicPath. */
|
|
25
|
+
source?: LambderPublicFileSource;
|
|
4
26
|
/**
|
|
5
|
-
* Map the request to a file path
|
|
6
|
-
*
|
|
27
|
+
* Map the request to a file path (app-owned logic, e.g. per-tenant
|
|
28
|
+
* roots: (ctx) => `${brand(ctx.host)}${ctx.path}`). Return
|
|
7
29
|
* null/undefined to skip. Default: (ctx) => ctx.path.
|
|
8
30
|
*/
|
|
9
31
|
path?: (ctx: LambderRenderContext) => string | null | undefined;
|
|
10
|
-
/** Cache-Control for served files. Default: "public, max-age=3600". */
|
|
11
|
-
cacheControl?: string | ((ctx: LambderRenderContext,
|
|
32
|
+
/** Cache-Control for served files; the function receives the relative file path. Default: "public, max-age=3600". */
|
|
33
|
+
cacheControl?: string | ((ctx: LambderRenderContext, relativePath: string) => string);
|
|
12
34
|
/** Filenames matching this get immutableCacheControl. Default: content-hash heuristic. Set false to disable. */
|
|
13
35
|
immutablePattern?: RegExp | false;
|
|
14
36
|
/** Default: "public, max-age=31536000, immutable". */
|
|
@@ -24,24 +46,34 @@ export type LambderPublicFilesOptions = {
|
|
|
24
46
|
*/
|
|
25
47
|
compress?: boolean | "auto" | ((ctx: LambderRenderContext) => boolean | "auto");
|
|
26
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
51
|
+
* bundled into the deployment package. Reads stay under root. The default
|
|
52
|
+
* source of servePublicFiles, over publicPath.
|
|
53
|
+
*/
|
|
54
|
+
export declare class LambderLocalFileSource implements LambderPublicFileSource {
|
|
55
|
+
private root;
|
|
56
|
+
constructor({ root }: {
|
|
57
|
+
root: string;
|
|
58
|
+
});
|
|
59
|
+
read(relativePath: string): Promise<LambderPublicFile | null>;
|
|
60
|
+
}
|
|
27
61
|
/**
|
|
28
62
|
* Terminal public-file handler registered via lambder.servePublicFiles().
|
|
29
63
|
* Runs only when no route matched, so it can never shadow routes registered
|
|
30
|
-
* after it. Serves
|
|
64
|
+
* after it. Serves files from its source (traversal-safe, mime-typed,
|
|
31
65
|
* memory-cached, immutable-cache heuristic for content-hashed assets) and
|
|
32
|
-
* falls through to the route fallback when the
|
|
66
|
+
* falls through to the route fallback when the source has no such file.
|
|
33
67
|
*/
|
|
34
68
|
export declare class LambderPublicFilesHandler {
|
|
35
|
-
private
|
|
69
|
+
private source;
|
|
36
70
|
private options;
|
|
37
71
|
private fileCache;
|
|
38
72
|
private fileCacheBytes;
|
|
39
|
-
constructor(
|
|
73
|
+
constructor(source: LambderPublicFileSource, options: LambderPublicFilesOptions);
|
|
40
74
|
/** Serve the mapped file, or return null to fall through. */
|
|
41
75
|
handle(ctx: LambderRenderContext): Promise<LambderResponse | null>;
|
|
42
|
-
/**
|
|
43
|
-
private
|
|
44
|
-
/** Read a file, caching small files in memory for warm invocations. */
|
|
45
|
-
private readFileCached;
|
|
76
|
+
/** Read from the source, caching small files in memory for warm invocations. */
|
|
77
|
+
private readCached;
|
|
46
78
|
private cacheControlFor;
|
|
47
79
|
}
|
|
@@ -8,36 +8,65 @@ const DEFAULT_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
|
8
8
|
const DEFAULT_CACHE_CONTROL = "public, max-age=3600";
|
|
9
9
|
const DEFAULT_MEMORY_CACHE_MAX_BYTES = 32 * 1024 * 1024;
|
|
10
10
|
const DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
/**
|
|
12
|
+
* Files from a folder on the Lambda's filesystem, typically the build output
|
|
13
|
+
* bundled into the deployment package. Reads stay under root. The default
|
|
14
|
+
* source of servePublicFiles, over publicPath.
|
|
15
|
+
*/
|
|
16
|
+
export class LambderLocalFileSource {
|
|
17
|
+
root;
|
|
18
|
+
constructor({ root }) {
|
|
19
|
+
this.root = root;
|
|
20
|
+
}
|
|
21
|
+
async read(relativePath) {
|
|
22
|
+
const fs = await getFS();
|
|
23
|
+
const path = await getPath();
|
|
24
|
+
if (!fs || !path)
|
|
25
|
+
throw new Error("LambderLocalFileSource requires a Node.js environment.");
|
|
26
|
+
const base = path.resolve(this.root);
|
|
27
|
+
const absolute = path.resolve(base, relativePath);
|
|
28
|
+
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
29
|
+
return null;
|
|
30
|
+
const stat = await fs.promises.stat(absolute).catch(() => null);
|
|
31
|
+
if (!stat?.isFile())
|
|
32
|
+
return null;
|
|
33
|
+
return { body: await fs.promises.readFile(absolute) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Strip the leading slash and reject traversal; null for a path that names no file (empty, or a directory). */
|
|
37
|
+
const toRelativePath = (target) => {
|
|
38
|
+
if (target.split("/").some((segment) => segment === ".."))
|
|
39
|
+
return null;
|
|
40
|
+
const relative = target.startsWith("/") ? target.slice(1) : target;
|
|
41
|
+
if (relative === "" || relative.endsWith("/"))
|
|
42
|
+
return null;
|
|
43
|
+
return relative;
|
|
44
|
+
};
|
|
11
45
|
/**
|
|
12
46
|
* Terminal public-file handler registered via lambder.servePublicFiles().
|
|
13
47
|
* Runs only when no route matched, so it can never shadow routes registered
|
|
14
|
-
* after it. Serves
|
|
48
|
+
* after it. Serves files from its source (traversal-safe, mime-typed,
|
|
15
49
|
* memory-cached, immutable-cache heuristic for content-hashed assets) and
|
|
16
|
-
* falls through to the route fallback when the
|
|
50
|
+
* falls through to the route fallback when the source has no such file.
|
|
17
51
|
*/
|
|
18
52
|
export class LambderPublicFilesHandler {
|
|
19
|
-
|
|
53
|
+
source;
|
|
20
54
|
options;
|
|
21
55
|
fileCache = new Map();
|
|
22
56
|
fileCacheBytes = 0;
|
|
23
|
-
constructor(
|
|
24
|
-
this.
|
|
57
|
+
constructor(source, options) {
|
|
58
|
+
this.source = source;
|
|
25
59
|
this.options = options;
|
|
26
60
|
}
|
|
27
61
|
/** Serve the mapped file, or return null to fall through. */
|
|
28
62
|
async handle(ctx) {
|
|
29
|
-
const fs = await getFS();
|
|
30
|
-
const path = await getPath();
|
|
31
|
-
if (!fs || !path)
|
|
32
|
-
throw new Error("servePublicFiles requires a Node.js environment.");
|
|
33
63
|
const mappedPath = this.options.path ? this.options.path(ctx) : ctx.path;
|
|
34
64
|
if (!mappedPath)
|
|
35
65
|
return null;
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
if (!filePath)
|
|
66
|
+
const relativePath = toRelativePath(mappedPath);
|
|
67
|
+
if (relativePath === null)
|
|
39
68
|
return null;
|
|
40
|
-
const file = await this.
|
|
69
|
+
const file = await this.readCached(relativePath);
|
|
41
70
|
if (!file)
|
|
42
71
|
return null;
|
|
43
72
|
const compressOption = this.options.compress;
|
|
@@ -46,61 +75,52 @@ export class LambderPublicFilesHandler {
|
|
|
46
75
|
statusCode: 200,
|
|
47
76
|
headers: {
|
|
48
77
|
"Content-Type": file.mimeType,
|
|
49
|
-
"Cache-Control": this.cacheControlFor(ctx,
|
|
78
|
+
"Cache-Control": this.cacheControlFor(ctx, relativePath),
|
|
50
79
|
},
|
|
51
80
|
body: file.body,
|
|
52
81
|
compress,
|
|
53
82
|
});
|
|
54
83
|
}
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
return null;
|
|
59
|
-
const normalizedTarget = target.startsWith("/") ? target.slice(1) : target;
|
|
60
|
-
const absolute = path.resolve(base, normalizedTarget);
|
|
61
|
-
if (absolute !== base && !absolute.startsWith(base + path.sep))
|
|
62
|
-
return null;
|
|
63
|
-
return absolute;
|
|
64
|
-
}
|
|
65
|
-
/** Read a file, caching small files in memory for warm invocations. */
|
|
66
|
-
async readFileCached(fs, filePath) {
|
|
67
|
-
const cached = this.fileCache.get(filePath);
|
|
84
|
+
/** Read from the source, caching small files in memory for warm invocations. */
|
|
85
|
+
async readCached(relativePath) {
|
|
86
|
+
const cached = this.fileCache.get(relativePath);
|
|
68
87
|
if (cached)
|
|
69
88
|
return cached;
|
|
70
|
-
const
|
|
71
|
-
if (!
|
|
89
|
+
const file = await this.source.read(relativePath);
|
|
90
|
+
if (!file)
|
|
72
91
|
return null;
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
const entry = {
|
|
93
|
+
body: file.body,
|
|
94
|
+
mimeType: file.mimeType || mimeTypeResolver.lookup(relativePath) || "application/octet-stream",
|
|
95
|
+
};
|
|
76
96
|
const cacheConfig = this.options.memoryCache;
|
|
77
97
|
if (cacheConfig !== false) {
|
|
78
98
|
const maxBytes = cacheConfig?.maxBytes ?? DEFAULT_MEMORY_CACHE_MAX_BYTES;
|
|
79
99
|
const maxFileBytes = cacheConfig?.maxFileBytes ?? DEFAULT_MEMORY_CACHE_MAX_FILE_BYTES;
|
|
80
|
-
if (body.length <= maxFileBytes) {
|
|
100
|
+
if (entry.body.length <= maxFileBytes) {
|
|
81
101
|
// Evict oldest entries until the new file fits the budget.
|
|
82
102
|
for (const [key, value] of this.fileCache) {
|
|
83
|
-
if (this.fileCacheBytes + body.length <= maxBytes)
|
|
103
|
+
if (this.fileCacheBytes + entry.body.length <= maxBytes)
|
|
84
104
|
break;
|
|
85
105
|
this.fileCache.delete(key);
|
|
86
106
|
this.fileCacheBytes -= value.body.length;
|
|
87
107
|
}
|
|
88
|
-
if (this.fileCacheBytes + body.length <= maxBytes) {
|
|
89
|
-
this.fileCache.set(
|
|
90
|
-
this.fileCacheBytes += body.length;
|
|
108
|
+
if (this.fileCacheBytes + entry.body.length <= maxBytes) {
|
|
109
|
+
this.fileCache.set(relativePath, entry);
|
|
110
|
+
this.fileCacheBytes += entry.body.length;
|
|
91
111
|
}
|
|
92
112
|
}
|
|
93
113
|
}
|
|
94
114
|
return entry;
|
|
95
115
|
}
|
|
96
|
-
cacheControlFor(ctx,
|
|
116
|
+
cacheControlFor(ctx, relativePath) {
|
|
97
117
|
const cacheOption = this.options.cacheControl;
|
|
98
118
|
if (typeof cacheOption === "function")
|
|
99
|
-
return cacheOption(ctx,
|
|
119
|
+
return cacheOption(ctx, relativePath);
|
|
100
120
|
const immutablePattern = this.options.immutablePattern === false
|
|
101
121
|
? null
|
|
102
122
|
: (this.options.immutablePattern ?? DEFAULT_IMMUTABLE_PATTERN);
|
|
103
|
-
if (immutablePattern && immutablePattern.test(
|
|
123
|
+
if (immutablePattern && immutablePattern.test(relativePath)) {
|
|
104
124
|
return this.options.immutableCacheControl ?? DEFAULT_IMMUTABLE_CACHE_CONTROL;
|
|
105
125
|
}
|
|
106
126
|
return cacheOption ?? DEFAULT_CACHE_CONTROL;
|
package/dist/index.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ import Lambder from './core/Lambder.js';
|
|
|
2
2
|
export default Lambder;
|
|
3
3
|
export { initLambder } from './core/Lambder.js';
|
|
4
4
|
export { default as LambderCaller } from "./client/LambderCaller.js";
|
|
5
|
-
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
5
|
+
export type { LambderApiOutcome, LambderApiFailureReason, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderIdempotencyKeyScope } from "./client/LambderCaller.js";
|
|
6
6
|
export { LambderApiError, isLambderApiError, refuse, LAMBDER_REFUSAL_CODES } from "./shared/LambderApiError.js";
|
|
7
7
|
export type { LambderApiErrorOptions, LambderRefusalMessage, LambderRefusalCode, LambderRefuseOptions } from "./shared/LambderApiError.js";
|
|
8
8
|
export { default as LambderResponseBuilder } from "./core/LambderResponseBuilder.js";
|
|
@@ -15,8 +15,10 @@ export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
|
15
15
|
export type { LambderTemplateData, LambderTemplatingEngineOptions } from "./core/LambderTemplatingEngine.js";
|
|
16
16
|
export type { LambderResponseOptions, LambderRawResponseInit, } from "./core/LambderResponseBuilder.js";
|
|
17
17
|
export type { LambderRouteMatcher, LambderCorsConfig, LambderCreateOptions, LambderSessionOptions, ConditionFunction, RouteCondition, PathParamsOf, LambderActionTools, LambderHandler, LambderIndexHtmlOptions, } from "./core/Lambder.js";
|
|
18
|
-
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
19
|
-
export type { LambderPublicFilesOptions } from "./core/LambderPublicFiles.js";
|
|
18
|
+
export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
|
|
19
|
+
export type { LambderPublicFilesOptions, LambderPublicFileSource, LambderPublicFile } from "./core/LambderPublicFiles.js";
|
|
20
|
+
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
21
|
+
export type { LambderS3FileSourceOptions } from "./stores/LambderS3FileSource.js";
|
|
20
22
|
export type { LambderSessionCookieOptions } from "./session/LambderSessionController.js";
|
|
21
23
|
export type { LambderSessionContext, LambderCreatedSession, LambderSessionDataRefreshConfig } from "./session/LambderSessionManager.js";
|
|
22
24
|
export type { LambderCompressionOption, LambderCompressionConfig } from "./stores/LambderDdbCompression.js";
|
package/dist/index.js
CHANGED
|
@@ -15,7 +15,8 @@ export { html, xml, raw, jsonScript, escapeHtml, renderHtmlValue, LambderSafeHtm
|
|
|
15
15
|
// Comment-based HTML templating engine (build-pipeline-safe slots and conditionals, standalone)
|
|
16
16
|
export { LambderTemplatingEngine } from "./core/LambderTemplatingEngine.js";
|
|
17
17
|
// Public file serving
|
|
18
|
-
export { LambderPublicFilesHandler } from "./core/LambderPublicFiles.js";
|
|
18
|
+
export { LambderPublicFilesHandler, LambderLocalFileSource } from "./core/LambderPublicFiles.js";
|
|
19
|
+
export { LambderS3FileSource } from "./stores/LambderS3FileSource.js";
|
|
19
20
|
export { LambderSessionDataRefreshError, LambderSessionReadError } from "./session/LambderSessionManager.js";
|
|
20
21
|
// DynamoDB-backed compressed cache (standalone, server-only)
|
|
21
22
|
export { LambderDdbCache } from "./stores/LambderDdbCache.js";
|
|
@@ -49,6 +49,13 @@ export default class LambderSessionController<TSessionData = any> {
|
|
|
49
49
|
* session and touches no cookies, so it works on any subject.
|
|
50
50
|
*/
|
|
51
51
|
deleteSessionAllByKey(sessionKey: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
54
|
+
* renews via dataRefresh on its next read: the way to apply a change to
|
|
55
|
+
* a subject's roles or permissions immediately, without logging them
|
|
56
|
+
* out. Needs no fetched session; requires dataRefresh.
|
|
57
|
+
*/
|
|
58
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<void>;
|
|
52
59
|
endSession(): Promise<void>;
|
|
53
60
|
endSessionAll(): Promise<void>;
|
|
54
61
|
}
|
|
@@ -151,6 +151,16 @@ export default class LambderSessionController {
|
|
|
151
151
|
await this.lambderSessionManager.deleteSessionAllByKey(sessionKey);
|
|
152
152
|
}
|
|
153
153
|
;
|
|
154
|
+
/**
|
|
155
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
156
|
+
* renews via dataRefresh on its next read: the way to apply a change to
|
|
157
|
+
* a subject's roles or permissions immediately, without logging them
|
|
158
|
+
* out. Needs no fetched session; requires dataRefresh.
|
|
159
|
+
*/
|
|
160
|
+
async expireSessionDataAllByKey(sessionKey) {
|
|
161
|
+
await this.lambderSessionManager.expireSessionDataAllByKey(sessionKey);
|
|
162
|
+
}
|
|
163
|
+
;
|
|
154
164
|
async endSession() {
|
|
155
165
|
if (!this.ctx.session)
|
|
156
166
|
throw new Error("Session not found.");
|
|
@@ -111,6 +111,7 @@ export default class LambderSessionManager {
|
|
|
111
111
|
*/
|
|
112
112
|
private ddbPutItem;
|
|
113
113
|
private ddbDeleteItem;
|
|
114
|
+
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
114
115
|
private ddbQueryAllByPartitionKey;
|
|
115
116
|
private ddbDeleteAllByPartitionKey;
|
|
116
117
|
createSession(sessionKey: string, data?: any, ttlInSeconds?: number, options?: {
|
|
@@ -135,5 +136,15 @@ export default class LambderSessionManager {
|
|
|
135
136
|
* session record.
|
|
136
137
|
*/
|
|
137
138
|
deleteSessionAllByKey(sessionKey: string): Promise<boolean>;
|
|
139
|
+
/**
|
|
140
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
141
|
+
* renews via dataRefresh on its next read: "this subject's roles or
|
|
142
|
+
* permissions changed, apply it now", without logging the subject out
|
|
143
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
144
|
+
* dataExpiresAt only, conditionally on the record still existing, so it
|
|
145
|
+
* neither resurrects a session deleted in between nor overwrites a
|
|
146
|
+
* concurrent write. Requires dataRefresh to be configured.
|
|
147
|
+
*/
|
|
148
|
+
expireSessionDataAllByKey(sessionKey: string): Promise<boolean>;
|
|
138
149
|
regenerateSession(session: LambderSessionContext): Promise<LambderCreatedSession>;
|
|
139
150
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
3
|
-
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
|
|
3
|
+
import { DynamoDBDocumentClient, QueryCommand, DeleteCommand, PutCommand, GetCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
|
|
4
4
|
import { brotliCompressText, brotliRestoreText, resolveCompressionOption, } from "../stores/LambderDdbCompression.js";
|
|
5
5
|
/**
|
|
6
6
|
* Session compression defaults: every record compressed (see
|
|
@@ -108,11 +108,13 @@ export default class LambderSessionManager {
|
|
|
108
108
|
return await this.ddbDocumentClient.send(new DeleteCommand({ TableName: this.tableName, Key: key, }));
|
|
109
109
|
}
|
|
110
110
|
;
|
|
111
|
+
/** Sort keys of every session under a partition (the callers only need the keys). */
|
|
111
112
|
async ddbQueryAllByPartitionKey(partitionValue) {
|
|
112
113
|
const params = {
|
|
113
114
|
TableName: this.tableName,
|
|
114
115
|
KeyConditionExpression: "#pk = :pv",
|
|
115
|
-
|
|
116
|
+
ProjectionExpression: "#sk",
|
|
117
|
+
ExpressionAttributeNames: { "#pk": this.partitionKey, "#sk": this.sortKey },
|
|
116
118
|
ExpressionAttributeValues: { ":pv": partitionValue },
|
|
117
119
|
};
|
|
118
120
|
const queryResults = [];
|
|
@@ -341,6 +343,40 @@ export default class LambderSessionManager {
|
|
|
341
343
|
return true;
|
|
342
344
|
}
|
|
343
345
|
;
|
|
346
|
+
/**
|
|
347
|
+
* Marks the data of every session of the given sessionKey stale, so each
|
|
348
|
+
* renews via dataRefresh on its next read: "this subject's roles or
|
|
349
|
+
* permissions changed, apply it now", without logging the subject out
|
|
350
|
+
* (deleteSessionAllByKey) and without waiting for the data TTL. Stamps
|
|
351
|
+
* dataExpiresAt only, conditionally on the record still existing, so it
|
|
352
|
+
* neither resurrects a session deleted in between nor overwrites a
|
|
353
|
+
* concurrent write. Requires dataRefresh to be configured.
|
|
354
|
+
*/
|
|
355
|
+
async expireSessionDataAllByKey(sessionKey) {
|
|
356
|
+
if (!this.dataRefresh)
|
|
357
|
+
throw new Error("dataRefresh is not configured. Pass session.dataRefresh at creation to enable.");
|
|
358
|
+
const partitionValue = this.sessionUserKeyHasher(sessionKey);
|
|
359
|
+
const now = Math.floor(Date.now() / 1000);
|
|
360
|
+
for (const item of await this.ddbQueryAllByPartitionKey(partitionValue)) {
|
|
361
|
+
try {
|
|
362
|
+
await this.ddbDocumentClient.send(new UpdateCommand({
|
|
363
|
+
TableName: this.tableName,
|
|
364
|
+
Key: { [this.partitionKey]: partitionValue, [this.sortKey]: item[this.sortKey] },
|
|
365
|
+
UpdateExpression: "SET #dataExpiresAt = :now",
|
|
366
|
+
ConditionExpression: "attribute_exists(#sk)",
|
|
367
|
+
ExpressionAttributeNames: { "#dataExpiresAt": "dataExpiresAt", "#sk": this.sortKey },
|
|
368
|
+
ExpressionAttributeValues: { ":now": now },
|
|
369
|
+
}));
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// Deleted between the query and the update: nothing left to expire.
|
|
373
|
+
if (err.name !== "ConditionalCheckFailedException")
|
|
374
|
+
throw err;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
;
|
|
344
380
|
async regenerateSession(session) {
|
|
345
381
|
if (!session)
|
|
346
382
|
throw new Error("Invalid session");
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { S3Client, S3ClientConfig } from "@aws-sdk/client-s3";
|
|
2
|
+
import type { LambderPublicFile, LambderPublicFileSource } from "../core/LambderPublicFiles.js";
|
|
3
|
+
export type LambderS3FileSourceOptions = {
|
|
4
|
+
bucket: string;
|
|
5
|
+
/** Literal key prefix the relative path is appended to, so include the trailing slash: "web/v42/". Default: none. */
|
|
6
|
+
prefix?: string;
|
|
7
|
+
/** A ready client, e.g. one shared with the rest of the app. */
|
|
8
|
+
client?: S3Client;
|
|
9
|
+
/**
|
|
10
|
+
* Otherwise the client is created from this on first read: `{ region }`
|
|
11
|
+
* for S3; for Cloudflare R2 or another S3-compatible store,
|
|
12
|
+
* `{ region: "auto", endpoint, credentials }`.
|
|
13
|
+
*/
|
|
14
|
+
clientConfig?: S3ClientConfig;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
18
|
+
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
19
|
+
* optional peer dependency loaded on first read, so apps that serve from a
|
|
20
|
+
* folder never load it. A missing object reads as null and the request
|
|
21
|
+
* falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
|
|
22
|
+
* answers a missing key with AccessDenied, which propagates as an error.
|
|
23
|
+
* An object's Content-Type is used unless it is a generic octet-stream, in
|
|
24
|
+
* which case the extension decides, as for local files.
|
|
25
|
+
*/
|
|
26
|
+
export declare class LambderS3FileSource implements LambderPublicFileSource {
|
|
27
|
+
private readonly bucket;
|
|
28
|
+
private readonly prefix;
|
|
29
|
+
private readonly clientConfig;
|
|
30
|
+
private client;
|
|
31
|
+
private sdk;
|
|
32
|
+
constructor({ bucket, prefix, client, clientConfig }: LambderS3FileSourceOptions);
|
|
33
|
+
private loadSdk;
|
|
34
|
+
read(relativePath: string): Promise<LambderPublicFile | null>;
|
|
35
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Files from an S3 bucket, or any S3-compatible store such as Cloudflare
|
|
3
|
+
* R2 (pass its endpoint in clientConfig). Needs @aws-sdk/client-s3, an
|
|
4
|
+
* optional peer dependency loaded on first read, so apps that serve from a
|
|
5
|
+
* folder never load it. A missing object reads as null and the request
|
|
6
|
+
* falls through; grant s3:ListBucket besides s3:GetObject, otherwise S3
|
|
7
|
+
* answers a missing key with AccessDenied, which propagates as an error.
|
|
8
|
+
* An object's Content-Type is used unless it is a generic octet-stream, in
|
|
9
|
+
* which case the extension decides, as for local files.
|
|
10
|
+
*/
|
|
11
|
+
export class LambderS3FileSource {
|
|
12
|
+
bucket;
|
|
13
|
+
prefix;
|
|
14
|
+
clientConfig;
|
|
15
|
+
client;
|
|
16
|
+
sdk;
|
|
17
|
+
constructor({ bucket, prefix = "", client, clientConfig }) {
|
|
18
|
+
if (!bucket.trim())
|
|
19
|
+
throw new Error("bucket is required");
|
|
20
|
+
this.bucket = bucket;
|
|
21
|
+
this.prefix = prefix;
|
|
22
|
+
this.client = client;
|
|
23
|
+
this.clientConfig = clientConfig;
|
|
24
|
+
}
|
|
25
|
+
loadSdk() {
|
|
26
|
+
if (!this.sdk) {
|
|
27
|
+
this.sdk = import("@aws-sdk/client-s3").catch(() => {
|
|
28
|
+
throw new Error("LambderS3FileSource requires @aws-sdk/client-s3: npm install @aws-sdk/client-s3");
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return this.sdk;
|
|
32
|
+
}
|
|
33
|
+
async read(relativePath) {
|
|
34
|
+
const { S3Client, GetObjectCommand } = await this.loadSdk();
|
|
35
|
+
if (!this.client)
|
|
36
|
+
this.client = new S3Client(this.clientConfig ?? {});
|
|
37
|
+
let output;
|
|
38
|
+
try {
|
|
39
|
+
output = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: `${this.prefix}${relativePath}` }));
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
const name = err.name;
|
|
43
|
+
if (name === "NoSuchKey" || name === "NotFound")
|
|
44
|
+
return null;
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
if (!output.Body)
|
|
48
|
+
return null;
|
|
49
|
+
const body = Buffer.from(await output.Body.transformToByteArray());
|
|
50
|
+
const contentType = output.ContentType;
|
|
51
|
+
return contentType && !contentType.endsWith("octet-stream") ? { body, mimeType: contentType } : { body };
|
|
52
|
+
}
|
|
53
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lambder",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.4.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -61,6 +61,7 @@
|
|
|
61
61
|
"zod": "^4.1.12"
|
|
62
62
|
},
|
|
63
63
|
"peerDependencies": {
|
|
64
|
+
"@aws-sdk/client-s3": "^3.574.0",
|
|
64
65
|
"msw": "^2.0.0"
|
|
65
66
|
},
|
|
66
67
|
"peerDependenciesMeta": {
|
|
@@ -69,6 +70,7 @@
|
|
|
69
70
|
}
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
73
|
+
"@aws-sdk/client-s3": "^3.1127.0",
|
|
72
74
|
"@types/aws-lambda": "^8.10.136",
|
|
73
75
|
"@types/cookie": "^0.6.0",
|
|
74
76
|
"@types/js-cookie": "^3.0.6",
|