lambder 4.3.2 → 4.5.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 +51 -7
- 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 +19 -9
- package/dist/core/Lambder.js +17 -12
- package/dist/core/LambderFiles.d.ts +85 -0
- package/dist/core/LambderFiles.js +116 -0
- package/dist/core/LambderPublicFiles.d.ts +12 -20
- package/dist/core/LambderPublicFiles.js +12 -66
- package/dist/core/LambderResponseBuilder.d.ts +15 -13
- package/dist/core/LambderResponseBuilder.js +19 -54
- package/dist/core/LambderTemplatingEngine.d.ts +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- 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,16 @@
|
|
|
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.5:**
|
|
6
|
+
|
|
7
|
+
- **`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).
|
|
8
|
+
|
|
9
|
+
**New in 4.4:**
|
|
10
|
+
|
|
11
|
+
- **`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.
|
|
12
|
+
- **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.
|
|
13
|
+
- **`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.
|
|
14
|
+
|
|
5
15
|
**New in 4.3:**
|
|
6
16
|
|
|
7
17
|
- **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.
|
|
@@ -86,7 +96,7 @@ would silently widen the inferred policy types, which is why the curried
|
|
|
86
96
|
creator is the canonical entry.
|
|
87
97
|
|
|
88
98
|
```typescript
|
|
89
|
-
import { initLambder } from 'lambder';
|
|
99
|
+
import { initLambder, LambderLocalFileSource } from 'lambder';
|
|
90
100
|
import { z } from 'zod';
|
|
91
101
|
import * as path from 'path';
|
|
92
102
|
|
|
@@ -94,7 +104,7 @@ interface SessionData { userId: string; }
|
|
|
94
104
|
|
|
95
105
|
const lambder = initLambder<SessionData>().create({
|
|
96
106
|
apiPath: "/api",
|
|
97
|
-
|
|
107
|
+
files: new LambderLocalFileSource({ root: path.resolve(`./public`) }),
|
|
98
108
|
session: {
|
|
99
109
|
tableName: "website-session",
|
|
100
110
|
tableRegion: "us-east-1",
|
|
@@ -160,8 +170,9 @@ lambder
|
|
|
160
170
|
.addRoute({ path: "/stripe-webhook", method: "POST" }, (ctx, res) => {
|
|
161
171
|
return res.json({ received: true });
|
|
162
172
|
})
|
|
163
|
-
// Serve real files from
|
|
164
|
-
// a catch-all route, so it
|
|
173
|
+
// Serve real files from the files source (see "Public file sources"
|
|
174
|
+
// below). This is a terminal fallback slot, NOT a catch-all route, so it
|
|
175
|
+
// can never shadow routes registered after it.
|
|
165
176
|
.servePublicFiles()
|
|
166
177
|
// Serve the app shell for GET/HEAD page requests nothing else handled
|
|
167
178
|
// (see "Hosting a frontend build" below).
|
|
@@ -209,7 +220,7 @@ For larger applications, split your APIs into separate modules:
|
|
|
209
220
|
```typescript
|
|
210
221
|
// user-api.ts
|
|
211
222
|
import { z } from "zod";
|
|
212
|
-
import Lambder from "lambder";
|
|
223
|
+
import Lambder, { LambderLocalFileSource } from "lambder";
|
|
213
224
|
|
|
214
225
|
export const userApi = <T>(l: Lambder<T>) => {
|
|
215
226
|
return l
|
|
@@ -230,7 +241,7 @@ export const userApi = <T>(l: Lambder<T>) => {
|
|
|
230
241
|
// index.ts
|
|
231
242
|
import { userApi } from "./user-api";
|
|
232
243
|
|
|
233
|
-
const lambder = new Lambder({
|
|
244
|
+
const lambder = new Lambder({ files: new LambderLocalFileSource({ root: './public' }) })
|
|
234
245
|
.use(userApi);
|
|
235
246
|
|
|
236
247
|
export type ApiContractType = typeof lambder.ApiContract;
|
|
@@ -359,6 +370,7 @@ Semantics:
|
|
|
359
370
|
- The renewal write and the sliding-expiration write share a single DynamoDB put when both are due.
|
|
360
371
|
- Records created before `dataRefresh` was enabled renew on their first read.
|
|
361
372
|
- `updateSessionData()` marks data fresh (it was just written deliberately); `regenerateSession()` carries the old freshness stamp over.
|
|
373
|
+
- `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
374
|
|
|
363
375
|
#### Session data at rest (`compression`)
|
|
364
376
|
|
|
@@ -389,6 +401,7 @@ Access the session controller with `lambder.getSessionController(ctx)`:
|
|
|
389
401
|
| `endSession()` | End session, delete from DDB |
|
|
390
402
|
| `endSessionAll()` | End all sessions for this sessionKey (all devices) |
|
|
391
403
|
| `deleteSessionAllByKey(sessionKey)` | Delete all sessions of any sessionKey (e.g. "log user X out everywhere") |
|
|
404
|
+
| `expireSessionDataAllByKey(sessionKey)` | Mark the data of all sessions of a sessionKey stale, so each renews via `dataRefresh` on its next read (no logout) |
|
|
392
405
|
| `regenerateSession()` | Regenerate token (use after password change) |
|
|
393
406
|
|
|
394
407
|
### Type-Safe Templating (html / xml)
|
|
@@ -432,6 +445,37 @@ const output = template.render({
|
|
|
432
445
|
|
|
433
446
|
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
447
|
|
|
448
|
+
#### Public file sources
|
|
449
|
+
|
|
450
|
+
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:
|
|
451
|
+
|
|
452
|
+
```typescript
|
|
453
|
+
// A folder, typically the build output bundled with the deployment.
|
|
454
|
+
initLambder().create({ files: new LambderLocalFileSource({ root: path.resolve("./public") }) });
|
|
455
|
+
|
|
456
|
+
// S3. @aws-sdk/client-s3 is an optional peer dependency, loaded on first read.
|
|
457
|
+
initLambder().create({
|
|
458
|
+
files: new LambderS3FileSource({ bucket: "myapp-web", prefix: "v42/", clientConfig: { region: "eu-central-1" } }),
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// Cloudflare R2, or any S3-compatible store: point the client at its endpoint.
|
|
462
|
+
initLambder().create({
|
|
463
|
+
files: new LambderS3FileSource({
|
|
464
|
+
bucket: "myapp-web",
|
|
465
|
+
clientConfig: { region: "auto", endpoint: "https://<account>.r2.cloudflarestorage.com", credentials: { accessKeyId, secretAccessKey } },
|
|
466
|
+
}),
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
// Anything else: implement read().
|
|
470
|
+
initLambder().create({ files: { read: async (relativePath) => myStore.get(relativePath) } });
|
|
471
|
+
|
|
472
|
+
// The in-memory file cache, tuned or off, beside any source.
|
|
473
|
+
initLambder().create({ files: { source: new LambderS3FileSource({ bucket: "myapp-web" }), memoryCache: { maxBytes: 64_000_000, maxFileBytes: 4_000_000 } } });
|
|
474
|
+
initLambder().create({ files: { source: new LambderLocalFileSource({ root }), memoryCache: false } });
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
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.
|
|
478
|
+
|
|
435
479
|
```typescript
|
|
436
480
|
// Zero-config single-tenant hosting:
|
|
437
481
|
lambder.servePublicFiles().serveIndexHtml();
|
|
@@ -772,7 +816,7 @@ Also available:
|
|
|
772
816
|
|
|
773
817
|
- **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
818
|
- **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.
|
|
819
|
+
- **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
820
|
- **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
821
|
|
|
778
822
|
### 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
|
@@ -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,11 +203,12 @@ 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
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* what remains (e.g.
|
|
206
|
+
* never shadow routes registered after it. Serves files from the `files`
|
|
207
|
+
* source configured at creation, traversal-safe, mime-typed,
|
|
208
|
+
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
209
|
+
* assets; when the source has no such file the request falls through to
|
|
210
|
+
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
211
|
+
* render an app shell with res.templateFile).
|
|
202
212
|
*/
|
|
203
213
|
servePublicFiles(options?: LambderPublicFilesOptions): this;
|
|
204
214
|
/**
|
|
@@ -207,8 +217,8 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
207
217
|
* gone; everything left is an app route (option `skipFilePaths` opts back
|
|
208
218
|
* into 404ing dotted paths). Only configured methods reach it, default
|
|
209
219
|
* GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
|
|
210
|
-
* Without a handler,
|
|
211
|
-
* (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.
|
|
212
222
|
*/
|
|
213
223
|
serveIndexHtml(handler?: FallbackHandlerFunction, options?: LambderIndexHtmlOptions): this;
|
|
214
224
|
/** Apply the serveIndexHtml gates; null means fall through. */
|
package/dist/core/Lambder.js
CHANGED
|
@@ -6,6 +6,7 @@ import { applyCorsHeaders } from "./LambderCors.js";
|
|
|
6
6
|
import LambderSessionManager from "../session/LambderSessionManager.js";
|
|
7
7
|
import LambderSessionController from "../session/LambderSessionController.js";
|
|
8
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,14 +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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
* what remains (e.g.
|
|
134
|
+
* never shadow routes registered after it. Serves files from the `files`
|
|
135
|
+
* source configured at creation, traversal-safe, mime-typed,
|
|
136
|
+
* memory-cached, with the immutable-cache heuristic for content-hashed
|
|
137
|
+
* assets; when the source has no such file the request falls through to
|
|
138
|
+
* setRouteFallbackHandler, where the app decides what remains (e.g.
|
|
139
|
+
* render an app shell with res.templateFile).
|
|
137
140
|
*/
|
|
138
141
|
servePublicFiles(options = {}) {
|
|
139
|
-
|
|
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);
|
|
140
145
|
return this;
|
|
141
146
|
}
|
|
142
147
|
/**
|
|
@@ -145,8 +150,8 @@ export default class Lambder {
|
|
|
145
150
|
* gone; everything left is an app route (option `skipFilePaths` opts back
|
|
146
151
|
* into 404ing dotted paths). Only configured methods reach it, default
|
|
147
152
|
* GET/HEAD. Gated-out requests fall through to setRouteFallbackHandler.
|
|
148
|
-
* Without a handler,
|
|
149
|
-
* (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.
|
|
150
155
|
*/
|
|
151
156
|
serveIndexHtml(handler, options = {}) {
|
|
152
157
|
this.indexHtmlConfig = { handler: handler ?? null, options };
|
|
@@ -328,7 +333,7 @@ export default class Lambder {
|
|
|
328
333
|
}
|
|
329
334
|
getResponseBuilder(ctx) {
|
|
330
335
|
return new LambderResponseBuilder({
|
|
331
|
-
|
|
336
|
+
files: this.files,
|
|
332
337
|
apiVersion: this.apiVersion,
|
|
333
338
|
ctx,
|
|
334
339
|
});
|
|
@@ -336,7 +341,7 @@ export default class Lambder {
|
|
|
336
341
|
;
|
|
337
342
|
getResolver(ctx) {
|
|
338
343
|
return new LambderResolver({
|
|
339
|
-
|
|
344
|
+
files: this.files,
|
|
340
345
|
apiVersion: this.apiVersion,
|
|
341
346
|
ctx,
|
|
342
347
|
});
|
|
@@ -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
|
+
}
|