lambder 6.0.1 → 7.0.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/CHANGELOG.md +2316 -0
- package/README.md +60 -33
- package/dist/api/LambderApiAnswer.d.ts +40 -0
- package/dist/api/LambderApiAnswer.js +19 -0
- package/dist/api/LambderApiCallContext.d.ts +38 -0
- package/dist/api/LambderApiCallContext.js +13 -0
- package/dist/api/LambderApiDefinition.d.ts +18 -0
- package/dist/api/LambderApiDefinition.js +1 -0
- package/dist/api/LambderApiEnvelope.d.ts +67 -0
- package/dist/api/LambderApiEnvelope.js +180 -0
- package/dist/api/LambderApiGuards.d.ts +302 -0
- package/dist/api/LambderApiGuards.js +134 -0
- package/dist/api/LambderApiIdempotency.d.ts +122 -0
- package/dist/api/LambderApiIdempotency.js +330 -0
- package/dist/api/LambderApiPipeline.d.ts +134 -0
- package/dist/api/LambderApiPipeline.js +221 -0
- package/dist/api/LambderApiPolicyEngine.d.ts +36 -0
- package/dist/api/LambderApiPolicyEngine.js +77 -0
- package/dist/api/LambderApiRateLimits.d.ts +206 -0
- package/dist/api/LambderApiRateLimits.js +239 -0
- package/dist/api/LambderApiRequest.d.ts +101 -0
- package/dist/api/LambderApiRequest.js +129 -0
- package/dist/api/LambderApiValidationRefusal.d.ts +32 -0
- package/dist/api/LambderApiValidationRefusal.js +40 -0
- package/dist/client/LambderCaller.d.ts +62 -55
- package/dist/client/LambderCaller.js +147 -90
- package/dist/client/lambderFetchTransport.d.ts +9 -0
- package/dist/client/lambderFetchTransport.js +71 -0
- package/dist/client.d.ts +20 -10
- package/dist/client.js +11 -5
- package/dist/core/Lambder.d.ts +117 -253
- package/dist/core/Lambder.js +374 -341
- package/dist/core/LambderContext.d.ts +54 -44
- package/dist/core/LambderContext.js +41 -110
- package/dist/core/LambderCreateOptions.d.ts +285 -0
- package/dist/core/LambderCreateOptions.js +44 -0
- package/dist/core/LambderFiles.d.ts +1 -45
- package/dist/core/LambderFiles.js +18 -38
- package/dist/core/LambderIndexHtml.d.ts +37 -0
- package/dist/core/LambderIndexHtml.js +87 -0
- package/dist/core/LambderPolicyBuilders.d.ts +17 -0
- package/dist/core/LambderPolicyBuilders.js +16 -0
- package/dist/core/LambderPublicFiles.d.ts +5 -2
- package/dist/core/LambderPublicFiles.js +7 -2
- package/dist/core/LambderResolver.d.ts +8 -6
- package/dist/core/LambderResponse.d.ts +29 -11
- package/dist/core/LambderResponse.js +96 -49
- package/dist/core/LambderResponseBuilder.d.ts +18 -14
- package/dist/core/LambderResponseBuilder.js +19 -25
- package/dist/core/LambderRouting.d.ts +18 -7
- package/dist/core/LambderRouting.js +17 -7
- package/dist/core/LambderTemplatingEngine.d.ts +0 -62
- package/dist/core/LambderTemplatingEngine.js +7 -3
- package/dist/index.d.ts +85 -32
- package/dist/index.js +44 -16
- package/dist/invoke/LambderInvokeCaller.d.ts +46 -139
- package/dist/invoke/LambderInvokeCaller.js +140 -335
- package/dist/invoke/LambderInvokeOutcome.d.ts +165 -0
- package/dist/invoke/LambderInvokeOutcome.js +129 -0
- package/dist/invoke/LambderLambdaEvent.d.ts +81 -0
- package/dist/invoke/LambderLambdaEvent.js +187 -0
- package/dist/invoke/lambderHandlerTransport.d.ts +36 -0
- package/dist/invoke/lambderHandlerTransport.js +89 -0
- package/dist/mock/LambderMockApp.d.ts +352 -0
- package/dist/mock/LambderMockApp.js +815 -0
- package/dist/mock/LambderMockBrowserCookies.d.ts +55 -0
- package/dist/mock/LambderMockBrowserCookies.js +76 -0
- package/dist/mock/LambderMockCallRecorder.d.ts +85 -0
- package/dist/mock/LambderMockCallRecorder.js +183 -0
- package/dist/mock/LambderMockCreateOptions.d.ts +161 -0
- package/dist/mock/LambderMockCreateOptions.js +9 -0
- package/dist/mock/LambderMockEntryRegistry.d.ts +52 -0
- package/dist/mock/LambderMockEntryRegistry.js +126 -0
- package/dist/mock/LambderMockFailureInjector.d.ts +60 -0
- package/dist/mock/LambderMockFailureInjector.js +138 -0
- package/dist/mock/LambderMockTypes.d.ts +421 -0
- package/dist/mock/LambderMockTypes.js +8 -0
- package/dist/mock/lambderMockConsoleLogger.d.ts +16 -0
- package/dist/mock/lambderMockConsoleLogger.js +35 -0
- package/dist/mock/lambderMockInvokeTransport.d.ts +50 -0
- package/dist/mock/lambderMockInvokeTransport.js +52 -0
- package/dist/mock/lambderMockMswHandler.d.ts +99 -0
- package/dist/mock/lambderMockMswHandler.js +126 -0
- package/dist/mock.d.ts +34 -0
- package/dist/mock.js +27 -0
- package/dist/session/LambderSessionController.d.ts +199 -30
- package/dist/session/LambderSessionController.js +396 -82
- package/dist/session/LambderSessionCrypto.d.ts +66 -0
- package/dist/session/LambderSessionCrypto.js +101 -0
- package/dist/session/LambderSessionManager.d.ts +118 -80
- package/dist/session/LambderSessionManager.js +212 -184
- package/dist/shared/LambderI18n.d.ts +6 -6
- package/dist/shared/LambderI18n.js +1 -1
- package/dist/shared/contracts/LambderFileSource.d.ts +33 -0
- package/dist/shared/contracts/LambderFileSource.js +19 -0
- package/dist/shared/contracts/LambderIdempotencyStore.d.ts +66 -0
- package/dist/shared/contracts/LambderIdempotencyStore.js +12 -0
- package/dist/shared/contracts/LambderRateLimiter.d.ts +71 -0
- package/dist/shared/contracts/LambderRateLimiter.js +24 -0
- package/dist/shared/contracts/LambderSessionStore.d.ts +72 -0
- package/dist/shared/contracts/LambderSessionStore.js +13 -0
- package/dist/shared/transport/LambderApiTransport.d.ts +139 -0
- package/dist/shared/transport/LambderApiTransport.js +65 -0
- package/dist/shared/transport/LambderCookieJar.d.ts +121 -0
- package/dist/shared/transport/LambderCookieJar.js +246 -0
- package/dist/shared/transport/lambderCookieJarTransport.d.ts +30 -0
- package/dist/shared/transport/lambderCookieJarTransport.js +60 -0
- package/dist/shared/util/LambderBase64.d.ts +10 -0
- package/dist/shared/util/LambderBase64.js +27 -0
- package/dist/shared/util/LambderCallAbort.d.ts +62 -0
- package/dist/shared/util/LambderCallAbort.js +80 -0
- package/dist/shared/util/LambderClientIp.d.ts +32 -0
- package/dist/shared/util/LambderClientIp.js +56 -0
- package/dist/shared/util/LambderExpiringMap.d.ts +119 -0
- package/dist/shared/util/LambderExpiringMap.js +217 -0
- package/dist/shared/util/LambderKeyFields.d.ts +32 -0
- package/dist/shared/util/LambderKeyFields.js +34 -0
- package/dist/shared/util/LambderNodeModules.d.ts +9 -0
- package/dist/shared/util/LambderNodeModules.js +39 -0
- package/dist/shared/util/LambderOptionChecks.d.ts +17 -0
- package/dist/shared/util/LambderOptionChecks.js +33 -0
- package/dist/shared/util/LambderResponseBrand.d.ts +20 -0
- package/dist/shared/util/LambderResponseBrand.js +18 -0
- package/dist/shared/util/LambderTextDigest.d.ts +17 -0
- package/dist/shared/util/LambderTextDigest.js +34 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +33 -0
- package/dist/shared/util/LambderTypeUtilities.js +8 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +60 -0
- package/dist/shared/wire/LambderAnswerHeaders.js +94 -0
- package/dist/shared/wire/LambderApiContract.d.ts +129 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +39 -0
- package/dist/shared/wire/LambderApiOptionValues.js +11 -0
- package/dist/shared/wire/LambderApiOutcome.d.ts +128 -0
- package/dist/shared/{LambderApiOutcome.js → wire/LambderApiOutcome.js} +16 -9
- package/dist/shared/{LambderApiError.d.ts → wire/LambderApiRefusal.d.ts} +48 -26
- package/dist/shared/{LambderApiError.js → wire/LambderApiRefusal.js} +13 -11
- package/dist/shared/wire/LambderCallOptions.d.ts +171 -0
- package/dist/shared/wire/LambderCallOptions.js +17 -0
- package/dist/shared/{LambderCompressionCodec.d.ts → wire/LambderCompressionCodec.d.ts} +10 -6
- package/dist/shared/{LambderCompressionCodec.js → wire/LambderCompressionCodec.js} +67 -23
- package/dist/shared/{LambderCompressionOption.d.ts → wire/LambderCompressionOption.d.ts} +1 -1
- package/dist/shared/{LambderCompressionOption.js → wire/LambderCompressionOption.js} +3 -4
- package/dist/shared/{LambderCrashDetail.d.ts → wire/LambderCrashDetail.d.ts} +10 -0
- package/dist/shared/{LambderCrashDetail.js → wire/LambderCrashDetail.js} +30 -0
- package/dist/shared/wire/LambderHttpStatus.d.ts +12 -0
- package/dist/shared/wire/LambderHttpStatus.js +1 -0
- package/dist/shared/{LambderRequestPayload.d.ts → wire/LambderRequestPayload.d.ts} +25 -17
- package/dist/shared/{LambderRequestPayload.js → wire/LambderRequestPayload.js} +29 -52
- package/dist/shared/wire/LambderSessionCookieNames.d.ts +9 -0
- package/dist/shared/wire/LambderSessionCookieNames.js +9 -0
- package/dist/stores/LambderDdbCache.d.ts +12 -9
- package/dist/stores/LambderDdbCache.js +56 -47
- package/dist/stores/{LambderDdbIdempotency.d.ts → LambderDdbIdempotencyStore.d.ts} +41 -31
- package/dist/stores/LambderDdbIdempotencyStore.js +319 -0
- package/dist/stores/LambderDdbRateLimiter.d.ts +30 -49
- package/dist/stores/LambderDdbRateLimiter.js +47 -45
- package/dist/stores/LambderDdbSdk.d.ts +83 -6
- package/dist/stores/LambderDdbSdk.js +83 -2
- package/dist/stores/LambderDdbSessionStore.d.ts +65 -0
- package/dist/stores/LambderDdbSessionStore.js +161 -0
- package/dist/stores/LambderHttpFileSource.d.ts +1 -1
- package/dist/stores/LambderHttpFileSource.js +10 -1
- package/dist/stores/LambderLocalFileSource.d.ts +15 -0
- package/dist/stores/LambderLocalFileSource.js +28 -0
- package/dist/stores/LambderMemoryIdempotencyStore.d.ts +63 -0
- package/dist/stores/LambderMemoryIdempotencyStore.js +113 -0
- package/dist/stores/LambderMemoryRateLimiter.d.ts +34 -0
- package/dist/stores/LambderMemoryRateLimiter.js +64 -0
- package/dist/stores/LambderMemorySessionStore.d.ts +48 -0
- package/dist/stores/LambderMemorySessionStore.js +74 -0
- package/dist/stores/LambderS3FileSource.d.ts +1 -1
- package/dist/stores/LambderS3FileSource.js +1 -1
- package/package.json +26 -24
- package/dist/client/LambderMSW.d.ts +0 -69
- package/dist/client/LambderMSW.js +0 -121
- package/dist/policies/LambderApiGuards.d.ts +0 -256
- package/dist/policies/LambderApiGuards.js +0 -94
- package/dist/policies/LambderApiIdempotency.d.ts +0 -58
- package/dist/policies/LambderApiIdempotency.js +0 -219
- package/dist/policies/LambderApiPolicies.d.ts +0 -42
- package/dist/policies/LambderApiPolicies.js +0 -52
- package/dist/policies/LambderApiRateLimits.d.ts +0 -132
- package/dist/policies/LambderApiRateLimits.js +0 -119
- package/dist/shared/LambderApiContract.d.ts +0 -57
- package/dist/shared/LambderApiOutcome.d.ts +0 -69
- package/dist/shared/LambderCallOptions.d.ts +0 -71
- package/dist/shared/LambderCallOptions.js +0 -16
- package/dist/shared/node-polyfills.d.ts +0 -4
- package/dist/shared/node-polyfills.js +0 -58
- package/dist/stores/LambderDdbIdempotency.js +0 -229
- package/dist/testing.d.ts +0 -9
- package/dist/testing.js +0 -8
- /package/dist/shared/{LambderApiContract.js → wire/LambderApiContract.js} +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.d.ts +0 -0
- /package/dist/{core → shared/wire}/LambderCookie.js +0 -0
package/README.md
CHANGED
|
@@ -7,16 +7,14 @@ idempotency), so an application is a set of declarations rather than a pile of
|
|
|
7
7
|
per-handler boilerplate.
|
|
8
8
|
|
|
9
9
|
```typescript
|
|
10
|
-
import { initLambder, LambderLocalFileSource } from "lambder";
|
|
10
|
+
import { initLambder, LambderLocalFileSource, LambderDdbSessionStore } from "lambder";
|
|
11
11
|
import { z } from "zod";
|
|
12
12
|
|
|
13
13
|
const lambder = initLambder<SessionData>().create({
|
|
14
14
|
apiPath: "/api",
|
|
15
15
|
files: new LambderLocalFileSource({ root: "./public" }),
|
|
16
|
-
session: { tableName: "app-session",
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
lambder.addApi("getCompany", {
|
|
16
|
+
session: { store: new LambderDdbSessionStore({ tableName: "app-session", region: "us-east-1" }), sessionSalt: process.env.SESSION_SALT! },
|
|
17
|
+
}).addApi("getCompany", {
|
|
20
18
|
input: z.object({ slug: z.string() }),
|
|
21
19
|
output: z.object({ id: z.string(), name: z.string() }),
|
|
22
20
|
}, async ({ apiPayload }, res) => res.api(await loadCompany(apiPayload.slug)));
|
|
@@ -25,6 +23,10 @@ export type ApiContractType = typeof lambder.ApiContract;
|
|
|
25
23
|
export const handler = lambder.getHandler();
|
|
26
24
|
```
|
|
27
25
|
|
|
26
|
+
Registration chains onto the creation call: every `addApi` returns an instance
|
|
27
|
+
carrying the contract so far, so the whole backend is one declaration and
|
|
28
|
+
`lambder.ApiContract` is the accumulated type.
|
|
29
|
+
|
|
28
30
|
The frontend imports that contract type and gets autocomplete, typed payloads
|
|
29
31
|
and typed results with no hand-written client:
|
|
30
32
|
|
|
@@ -44,8 +46,14 @@ const company = await caller.api("getCompany", { slug: "acme" });
|
|
|
44
46
|
and consumed by the frontend as a type-only import.
|
|
45
47
|
- **Simple route and API declaration.** Paths, regexes, predicates and
|
|
46
48
|
structured matchers, chained fluently.
|
|
47
|
-
- **Sessions.**
|
|
48
|
-
expiration, data refresh and
|
|
49
|
+
- **Sessions.** Over a store of your choosing (DynamoDB, memory, your own),
|
|
50
|
+
with secrets hashed at rest, sliding expiration, data refresh and
|
|
51
|
+
cross-subdomain cookies.
|
|
52
|
+
- **One API core, two runtimes.** The request pipeline (envelope, refusals,
|
|
53
|
+
sessions, guards, rate limits, idempotency) is one isomorphic class; the
|
|
54
|
+
Lambda server and the mock runtime are adapters over it, so a mock behaves
|
|
55
|
+
like the server by construction and the whole policy layer is testable
|
|
56
|
+
in-process with no AWS.
|
|
49
57
|
- **Declarative policies.** Named rate-limit policies, authorization guards and
|
|
50
58
|
idempotency, referenced by name from an API declaration and checked at
|
|
51
59
|
compile time.
|
|
@@ -69,18 +77,19 @@ const company = await caller.api("getCompany", { slug: "acme" });
|
|
|
69
77
|
npm install lambder zod
|
|
70
78
|
```
|
|
71
79
|
|
|
72
|
-
`zod`
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
`zod` is a required peer dependency: the published declarations name its types,
|
|
81
|
+
so npm installs it alongside lambder. The four AWS SDK clients and `msw` are
|
|
82
|
+
optional peers, so installing lambder never drags them into your tree. Add
|
|
83
|
+
whatever the code you actually import needs:
|
|
75
84
|
|
|
76
85
|
| What you import | What to install alongside |
|
|
77
86
|
| --- | --- |
|
|
78
|
-
| `lambder/client` (browser, shared isomorphic code) | `zod` |
|
|
79
|
-
| `lambder` on AWS Lambda (
|
|
87
|
+
| `lambder/client` (browser, shared isomorphic code) | `zod`. `LambderCookieJar` pulls in `tough-cookie` and its public suffix list, so a bundle that never imports the jar never carries either |
|
|
88
|
+
| `lambder` on AWS Lambda (any current Node.js runtime; the package needs Node 20 or later) | `zod`. The runtime already provides the AWS SDK v3, so mark the SDK packages as dev dependencies and keep them out of the deployment package |
|
|
80
89
|
| `lambder` anywhere else (a long-running server, a container, local tests) | `zod`, plus `@aws-sdk/client-dynamodb` and `@aws-sdk/lib-dynamodb` when sessions or the DynamoDB stores are used; both are loaded on the first table access, so an app that uses neither needs neither |
|
|
81
90
|
| `LambderS3FileSource` | `@aws-sdk/client-s3`, loaded on first read |
|
|
82
91
|
| `LambderInvokeCaller` | `@aws-sdk/client-lambda`, loaded on the first call |
|
|
83
|
-
| `lambder/
|
|
92
|
+
| `lambder/mock` | nothing; `msw` only for the optional network-panel adapter |
|
|
84
93
|
|
|
85
94
|
The SDK and its `@smithy` tree are roughly 21MB installed, which is why they are
|
|
86
95
|
peers rather than dependencies: a frontend importing only `lambder/client` has
|
|
@@ -94,19 +103,28 @@ The package ships three entry points; pick by where the code runs:
|
|
|
94
103
|
|
|
95
104
|
| Entry | Runs in | Carries |
|
|
96
105
|
| --- | --- | --- |
|
|
97
|
-
| `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus everything from `lambder/client` |
|
|
98
|
-
| `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `
|
|
99
|
-
| `lambder/
|
|
106
|
+
| `lambder` | Server (Lambda) | The full framework: pipeline, sessions, DDB stores, policies, plus the API core's building blocks and everything from `lambder/client` except the two request-compression helpers, `compressPayloadGzip` and `isRequestCompressionAvailable`, which stay on the client entry where a payload is compressed |
|
|
107
|
+
| `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `html`/`xml` tagged templates, `createLambderI18n` |
|
|
108
|
+
| `lambder/mock` | Browser and Node, in development and tests | `LambderMockApp`, the mock runtime: your typed contract served from mock handlers over the real API pipeline and memory stores |
|
|
100
109
|
|
|
101
110
|
Frontends and shared isomorphic packages should import from `lambder/client`
|
|
102
111
|
only; the entry's module graph contains no AWS SDK, Node built-ins, or server
|
|
103
112
|
pipeline, so the browser boundary is structural rather than left to
|
|
104
113
|
tree-shaking.
|
|
105
114
|
|
|
106
|
-
Source layout mirrors this: `src/
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
115
|
+
Source layout mirrors this: `src/api/` (the isomorphic API core: request,
|
|
116
|
+
answer, envelope, pipeline, and the declarative policies the pipeline runs),
|
|
117
|
+
`src/core/` (the Lambda server adapter: routes, files, hooks, finalization),
|
|
118
|
+
`src/session/` (the session manager, controller and crypto), `src/stores/`
|
|
119
|
+
(every store implementation, DynamoDB and in-memory alike), `src/client/`,
|
|
120
|
+
`src/invoke/` (the lambda-to-lambda caller and the in-process handler
|
|
121
|
+
transport), `src/mock/` (the mock runtime), and `src/shared/` (isomorphic
|
|
122
|
+
modules every entry re-exports, grouped into `wire/` for the format both
|
|
123
|
+
sides speak, `contracts/` for the four store interfaces, `transport/` for the
|
|
124
|
+
caller-to-server seam, and `util/` for helpers).
|
|
125
|
+
Directories are layers and imports only ever point down;
|
|
126
|
+
[docs/api-core.md](./docs/api-core.md#layering) states the order and the test
|
|
127
|
+
that enforces it.
|
|
110
128
|
|
|
111
129
|
## Documentation
|
|
112
130
|
|
|
@@ -119,16 +137,17 @@ guide that matches what you are building. The full index lives in
|
|
|
119
137
|
| [Getting started](./docs/getting-started.md) | The three-step path from a first API to a typed frontend call |
|
|
120
138
|
| [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
|
|
121
139
|
| [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, and non-HTTP invocations |
|
|
122
|
-
| [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `
|
|
140
|
+
| [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiRefusal` |
|
|
123
141
|
| [Responses](./docs/responses.md) | The render context, resolver methods, cookies, compression, ETag and the size cap |
|
|
124
|
-
| [Sessions](./docs/sessions.md) |
|
|
142
|
+
| [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
|
|
125
143
|
| [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
|
|
126
144
|
| [Calling another lambda](./docs/invoke.md) | `LambderInvokeCaller`: invoking a Lambder app in another function, its contract, failures and compression |
|
|
127
|
-
| [
|
|
145
|
+
| [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
|
|
146
|
+
| [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
|
|
128
147
|
| [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
|
|
129
148
|
| [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
|
|
130
149
|
| [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, runtime dictionaries |
|
|
131
|
-
| [
|
|
150
|
+
| [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
|
|
132
151
|
| [DynamoDB tables](./docs/dynamodb-tables.md) | Table shapes, TTL and IAM for sessions, cache, rate limits and idempotency |
|
|
133
152
|
| [Exports reference](./docs/exports.md) | Every name the three entry points export, grouped by purpose |
|
|
134
153
|
|
|
@@ -142,22 +161,30 @@ framework:
|
|
|
142
161
|
| `html` / `xml` tags + `LambderTemplatingEngine` | [Templating](./docs/templating.md) | Type-safe tagged templates and a comment-only HTML template engine (build-pipeline-safe) |
|
|
143
162
|
| `createLambderI18n` | [Translations](./docs/i18n.md) | Typed translations with enforced/optional languages, component-level extension and auto language detection (isomorphic) |
|
|
144
163
|
| `LambderDdbCache` | [DynamoDB cache](./docs/ddb-cache.md) | DynamoDB-backed compressed JSON cache with lease-based single-fill and grouped keys (server-only) |
|
|
145
|
-
| `LambderDdbRateLimiter` | [Rate limiter](./docs/ddb-rate-limiter.md) |
|
|
146
|
-
| `
|
|
147
|
-
| `
|
|
164
|
+
| `LambderDdbRateLimiter` / `LambderMemoryRateLimiter` | [Rate limiter](./docs/ddb-rate-limiter.md) | Fixed-window rate limiter, atomic per window, in DynamoDB (server-only) or in memory |
|
|
165
|
+
| `LambderDdbIdempotencyStore` / `LambderMemoryIdempotencyStore` | [Idempotency store](./docs/ddb-idempotency.md) | Idempotency records with owner-checked claims, in DynamoDB (compressed replays, server-only) or in memory |
|
|
166
|
+
| `LambderMockApp` | [The mock runtime](./docs/mock.md) | The typed contract served from mock handlers over the real API pipeline, with failure injection, sessions and a call log (isomorphic) |
|
|
148
167
|
|
|
149
168
|
## Versioning and changes
|
|
150
169
|
|
|
151
170
|
Released versions and what each one changed are in
|
|
152
|
-
[CHANGELOG.md](./CHANGELOG.md). The current major is
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
171
|
+
[CHANGELOG.md](./CHANGELOG.md). The current major is v7, which moved the API
|
|
172
|
+
pipeline into an isomorphic core, put the session layer behind a store
|
|
173
|
+
interface, dropped the resolver argument from guards, and replaced the MSW
|
|
174
|
+
adapter with a mock runtime. Every break and what to do about it is in the
|
|
175
|
+
7.0.0 entry. The compiler finds most of them. Three it cannot are named there:
|
|
176
|
+
leftover `session` fields that `const` generics stop it from seeing, a
|
|
177
|
+
`region` that went from required to optional, and mock handlers that now take
|
|
178
|
+
the call context rather than the payload.
|
|
156
179
|
|
|
157
180
|
## Contributing
|
|
158
181
|
|
|
159
|
-
Contributions are welcome.
|
|
160
|
-
|
|
182
|
+
Contributions are welcome. Open an issue for a bug or an idea, or send a pull
|
|
183
|
+
request. `npm test` typechecks, builds `dist/` and runs the suite, and it must
|
|
184
|
+
pass before a change is ready: the type system carries a lot of this
|
|
185
|
+
framework's guarantees, so a change that only passes at runtime is not
|
|
186
|
+
finished. New behavior belongs in the matching page under
|
|
187
|
+
[docs/](./docs/README.md) and in [CHANGELOG.md](./CHANGELOG.md) too.
|
|
161
188
|
|
|
162
189
|
## License
|
|
163
190
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { LambderApiHttpAnswer } from "../shared/wire/LambderApiOutcome.js";
|
|
2
|
+
/**
|
|
3
|
+
* The core's output for one API call: what goes back over the wire before
|
|
4
|
+
* any transport-level finalization. The server adapter turns it into a
|
|
5
|
+
* Lambda response (compression, ETag, base64); the mock transport hands it
|
|
6
|
+
* to the caller as it is. It is also the shape the idempotency store
|
|
7
|
+
* persists and replays, which is why it is plain data.
|
|
8
|
+
*
|
|
9
|
+
* Header names keep the casing they were written with; lookups are
|
|
10
|
+
* case-insensitive (see getAnswerHeader), the way LambderResponse treats
|
|
11
|
+
* them.
|
|
12
|
+
*/
|
|
13
|
+
export type LambderApiAnswer = {
|
|
14
|
+
statusCode: number;
|
|
15
|
+
headers: Record<string, string[]>;
|
|
16
|
+
body: string;
|
|
17
|
+
/**
|
|
18
|
+
* Finalization hints for the server adapter, carried through so an
|
|
19
|
+
* answer that started as a LambderResponse loses nothing on the way:
|
|
20
|
+
* `isBodyBase64` marks a body that is base64 of binary bytes (never
|
|
21
|
+
* cached by the idempotency engine, never compressed), `compress` and
|
|
22
|
+
* `etag` are the LambderResponse flags. The mock runtime finalizes
|
|
23
|
+
* nothing, so it hands the body to the caller as it stands; the stores
|
|
24
|
+
* drop the hints.
|
|
25
|
+
*/
|
|
26
|
+
isBodyBase64?: boolean;
|
|
27
|
+
compress?: boolean | "auto";
|
|
28
|
+
etag?: boolean | "auto";
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* An answer in the accessor form resolveApiOutcome() reads, the same view a
|
|
32
|
+
* fetch Response or a decoded Lambda result is given. What the mock
|
|
33
|
+
* transport hands the caller.
|
|
34
|
+
*
|
|
35
|
+
* The body is handed over as it stands, base64 hint or not: the only answers
|
|
36
|
+
* that reach here are the mock runtime's own, which are JSON envelopes, and
|
|
37
|
+
* a caller reading a binary body through the JSON accessors would have
|
|
38
|
+
* nothing to do with what it decoded anyway.
|
|
39
|
+
*/
|
|
40
|
+
export declare const toHttpAnswer: (answer: LambderApiAnswer) => LambderApiHttpAnswer;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { getAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
2
|
+
/**
|
|
3
|
+
* An answer in the accessor form resolveApiOutcome() reads, the same view a
|
|
4
|
+
* fetch Response or a decoded Lambda result is given. What the mock
|
|
5
|
+
* transport hands the caller.
|
|
6
|
+
*
|
|
7
|
+
* The body is handed over as it stands, base64 hint or not: the only answers
|
|
8
|
+
* that reach here are the mock runtime's own, which are JSON envelopes, and
|
|
9
|
+
* a caller reading a binary body through the JSON accessors would have
|
|
10
|
+
* nothing to do with what it decoded anyway.
|
|
11
|
+
*/
|
|
12
|
+
export const toHttpAnswer = (answer) => ({
|
|
13
|
+
status: answer.statusCode,
|
|
14
|
+
statusText: "",
|
|
15
|
+
header: (name) => getAnswerHeader(answer.headers, name)?.join(", ") ?? null,
|
|
16
|
+
json: async () => JSON.parse(answer.body),
|
|
17
|
+
text: async () => answer.body,
|
|
18
|
+
setCookies: getAnswerHeader(answer.headers, "Set-Cookie") ?? [],
|
|
19
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
|
|
2
|
+
import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
3
|
+
/**
|
|
4
|
+
* The context the API core needs from whoever runs it. The server's render
|
|
5
|
+
* context and the mock runtime's handler context both extend it; the
|
|
6
|
+
* pipeline, the policy engines and the session controller read and write
|
|
7
|
+
* nothing else on a context, so they never learn which adapter they run
|
|
8
|
+
* under.
|
|
9
|
+
*
|
|
10
|
+
* - `session` is set by the pipeline on session APIs (and by the session
|
|
11
|
+
* controller when a handler creates or ends one).
|
|
12
|
+
* - `guardData` receives the return values of the guards that ran.
|
|
13
|
+
* - `responseHeaders` collects headers written during the call, applied
|
|
14
|
+
* onto the answer by the pipeline.
|
|
15
|
+
* - `logList` collects entries for the envelope's logList channel
|
|
16
|
+
* (`res.logToApiResponse` on the server).
|
|
17
|
+
*/
|
|
18
|
+
export type LambderApiCallContext<TSessionData = any> = {
|
|
19
|
+
session: LambderSessionRecord<TSessionData> | null;
|
|
20
|
+
guardData: Record<string, unknown>;
|
|
21
|
+
responseHeaders: LambderAnswerHeaders;
|
|
22
|
+
logList: unknown[];
|
|
23
|
+
};
|
|
24
|
+
/** A fresh call context: no session, no guard data, nothing pending. */
|
|
25
|
+
export declare const createApiCallContext: <TSessionData = any>() => LambderApiCallContext<TSessionData>;
|
|
26
|
+
/**
|
|
27
|
+
* What one call recorded about itself while it ran, in the order things
|
|
28
|
+
* happened. Written as the call goes rather than assembled from what each
|
|
29
|
+
* step returned, so a refusal partway through still reports the guards that
|
|
30
|
+
* had already run: the mock's call log shows exactly the calls a developer is
|
|
31
|
+
* looking at when something denied them.
|
|
32
|
+
*/
|
|
33
|
+
export type LambderApiCallTrace = {
|
|
34
|
+
/** The guards that ran, in order, including on a call that a later one refused. */
|
|
35
|
+
guardsRun: string[];
|
|
36
|
+
/** True when a stored idempotent answer was replayed and no handler ran. */
|
|
37
|
+
replayed: boolean;
|
|
38
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
2
|
+
/** A fresh call context: no session, no guard data, nothing pending. */
|
|
3
|
+
export const createApiCallContext = () => ({
|
|
4
|
+
session: null,
|
|
5
|
+
// No prototype, for the reason the guard and policy registries are Maps:
|
|
6
|
+
// guard names are the app's to choose, and on a plain object a guard
|
|
7
|
+
// named "toString" or "constructor" reads back as an inherited function
|
|
8
|
+
// for a handler that only wanted to know whether the guard returned
|
|
9
|
+
// anything.
|
|
10
|
+
guardData: Object.create(null),
|
|
11
|
+
responseHeaders: new LambderAnswerHeaders(),
|
|
12
|
+
logList: [],
|
|
13
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
|
|
3
|
+
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "../shared/wire/LambderApiOptionValues.js";
|
|
4
|
+
/**
|
|
5
|
+
* One endpoint's declaration as the pipeline runs it: what the server's
|
|
6
|
+
* addApi/addSessionApi options carry, minus the handler, in a shape the mock
|
|
7
|
+
* runtime can restate from a type-only contract. The schema is optional
|
|
8
|
+
* because the mock has none; when present, input validation runs and the
|
|
9
|
+
* handler sees the parsed payload.
|
|
10
|
+
*/
|
|
11
|
+
export type LambderApiDefinition = {
|
|
12
|
+
name: string;
|
|
13
|
+
mode: LambderApiMode;
|
|
14
|
+
guards?: LambderGuardsOptionValue;
|
|
15
|
+
rateLimit?: LambderRateLimitOptionValue;
|
|
16
|
+
idempotency?: LambderApiIdempotencyOption;
|
|
17
|
+
input?: z.ZodType;
|
|
18
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { LambderApiEnvelopeBody, LambderApiResponseConfig } from "../shared/wire/LambderApiContract.js";
|
|
3
|
+
import { type LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
4
|
+
import type { LambderApiAnswer } from "./LambderApiAnswer.js";
|
|
5
|
+
export declare const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
6
|
+
/** The envelope's config plus the logList channel the call accumulated. */
|
|
7
|
+
export type LambderApiEnvelopeConfig = LambderApiResponseConfig & {
|
|
8
|
+
logList?: unknown[];
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The wire envelope for one answer. Flags are only present when set, so a
|
|
12
|
+
* plain success is `{ apiVersion, payload }` and nothing else; an empty
|
|
13
|
+
* logList is omitted.
|
|
14
|
+
*/
|
|
15
|
+
export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
|
|
16
|
+
/** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
|
|
17
|
+
export declare const envelopeAnswer: (envelope: LambderApiEnvelopeBody<unknown>, options?: {
|
|
18
|
+
statusCode?: number;
|
|
19
|
+
headers?: Record<string, string | string[]>;
|
|
20
|
+
}) => LambderApiAnswer;
|
|
21
|
+
/**
|
|
22
|
+
* A thrown refusal as an answer: its errorMessage and flags on the envelope,
|
|
23
|
+
* its status (200 unless it set one) and its extra headers (Retry-After on a
|
|
24
|
+
* rate limit). The logList the call accumulated rides along, as it does on
|
|
25
|
+
* a success.
|
|
26
|
+
*/
|
|
27
|
+
export declare const refusalAnswer: (err: LambderApiRefusal, apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
|
|
28
|
+
/** The body a validation refusal carries, the shape resolveApiOutcome reads a 422 by. */
|
|
29
|
+
export type LambderValidationAnswerBody = {
|
|
30
|
+
error: string;
|
|
31
|
+
zodError: {
|
|
32
|
+
name: string;
|
|
33
|
+
message: string;
|
|
34
|
+
issues: z.core.$ZodIssue[];
|
|
35
|
+
};
|
|
36
|
+
/** Present only when the answer was trimmed (issues dropped, or oversized values inside one shortened): how many issues there were. */
|
|
37
|
+
issueCount?: number;
|
|
38
|
+
/** The logList channel the call accumulated, as a success carries it; omitted when empty. */
|
|
39
|
+
logList?: unknown[];
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The standard answer for a rejected input: a 422 whose body spells the
|
|
43
|
+
* ZodError out. Spelled out rather than serialized as-is: zod 4 keeps
|
|
44
|
+
* `issues` as a non-enumerable property, so JSON.stringify(zodError) would
|
|
45
|
+
* carry the issues only inside the message string, and a client's
|
|
46
|
+
* validation handler would receive a ZodError with nothing to branch on.
|
|
47
|
+
*
|
|
48
|
+
* zod's own `message` never ships: it is the whole issue tree re-serialized,
|
|
49
|
+
* so carrying it would send every capped byte a second time. The generated
|
|
50
|
+
* summary takes its place on every answer, trimmed or not.
|
|
51
|
+
*/
|
|
52
|
+
export declare const validationAnswer: (zodError: z.ZodError, logList?: unknown[]) => LambderApiAnswer;
|
|
53
|
+
/** No API is registered under the requested name: a refusal, not a 404, so a typed caller reads it. */
|
|
54
|
+
export declare const apiNotFoundAnswer: (apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
|
|
55
|
+
/** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
|
|
56
|
+
export declare const sessionExpiredAnswer: (apiVersion: string | null | undefined, logList?: unknown[]) => LambderApiAnswer;
|
|
57
|
+
/** The caller's version is behind the server's: the protocol's versionExpired flag, which the caller reloads on. */
|
|
58
|
+
export declare const versionExpiredAnswer: (apiVersion: string | null | undefined) => LambderApiAnswer;
|
|
59
|
+
/** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
|
|
60
|
+
export declare const invalidPayloadAnswer: (apiVersion: string | null | undefined, message: string) => LambderApiAnswer;
|
|
61
|
+
/**
|
|
62
|
+
* The last-resort answer when the call crashed and nothing else could
|
|
63
|
+
* answer: a 500 that is still an envelope, so a caller reads a structured
|
|
64
|
+
* failure rather than a text page. The server sends it only when its global
|
|
65
|
+
* error handler is absent or itself failed.
|
|
66
|
+
*/
|
|
67
|
+
export declare const crashAnswer: (apiVersion: string | null | undefined) => LambderApiAnswer;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { LAMBDER_REFUSAL_CODES } from "../shared/wire/LambderApiRefusal.js";
|
|
2
|
+
import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
3
|
+
/*
|
|
4
|
+
* The one place the API envelope is written, and the one mapping from each
|
|
5
|
+
* kind of protocol outcome onto an answer: a success, a thrown refusal, a
|
|
6
|
+
* rejected input, an unknown name, a missing session, a stale client, a
|
|
7
|
+
* malformed compressed payload, and the last-resort crash. The server's
|
|
8
|
+
* res.api() and the mock runtime's handler wrapping both build through
|
|
9
|
+
* buildApiEnvelope, and the pipeline renders every refusal through the
|
|
10
|
+
* functions below, so the two sides cannot drift on a single byte of the
|
|
11
|
+
* wire format. Pure: no Node built-ins, no response classes.
|
|
12
|
+
*/
|
|
13
|
+
export const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
14
|
+
/**
|
|
15
|
+
* The wire envelope for one answer. Flags are only present when set, so a
|
|
16
|
+
* plain success is `{ apiVersion, payload }` and nothing else; an empty
|
|
17
|
+
* logList is omitted.
|
|
18
|
+
*/
|
|
19
|
+
export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, message, errorMessage, logList, crash, } = {}) => ({
|
|
20
|
+
apiVersion: apiVersion ?? null,
|
|
21
|
+
payload,
|
|
22
|
+
...(versionExpired ? { versionExpired } : {}),
|
|
23
|
+
...(sessionExpired ? { sessionExpired } : {}),
|
|
24
|
+
...(notAuthorized ? { notAuthorized } : {}),
|
|
25
|
+
// Presence, not truthiness: the three channels below carry app values,
|
|
26
|
+
// and an app that refuses with errorMessage: "" (or 0, or a message
|
|
27
|
+
// object it built empty) meant to say something. The flags above are
|
|
28
|
+
// booleans, where false and absent are the same statement.
|
|
29
|
+
...(message !== undefined ? { message } : {}),
|
|
30
|
+
...(errorMessage !== undefined ? { errorMessage } : {}),
|
|
31
|
+
...(crash !== undefined ? { crash } : {}),
|
|
32
|
+
...(logList?.length ? { logList } : {}),
|
|
33
|
+
});
|
|
34
|
+
/** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
|
|
35
|
+
export const envelopeAnswer = (envelope, options = {}) => {
|
|
36
|
+
const headers = { "Content-Type": [API_ANSWER_CONTENT_TYPE] };
|
|
37
|
+
for (const [key, value] of Object.entries(options.headers ?? {})) {
|
|
38
|
+
// Through setAnswerHeader, so a header the refusal names under another
|
|
39
|
+
// casing replaces the envelope's own rather than shipping beside it.
|
|
40
|
+
setAnswerHeader(headers, key, value);
|
|
41
|
+
}
|
|
42
|
+
return { statusCode: options.statusCode ?? 200, headers, body: JSON.stringify(envelope) };
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* A thrown refusal as an answer: its errorMessage and flags on the envelope,
|
|
46
|
+
* its status (200 unless it set one) and its extra headers (Retry-After on a
|
|
47
|
+
* rate limit). The logList the call accumulated rides along, as it does on
|
|
48
|
+
* a success.
|
|
49
|
+
*/
|
|
50
|
+
export const refusalAnswer = (err, apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
|
|
51
|
+
...(err.errorMessage !== undefined ? { errorMessage: err.errorMessage } : {}),
|
|
52
|
+
...(err.notAuthorized ? { notAuthorized: true } : {}),
|
|
53
|
+
...(err.sessionExpired ? { sessionExpired: true } : {}),
|
|
54
|
+
logList,
|
|
55
|
+
}), {
|
|
56
|
+
...(err.statusCode !== undefined ? { statusCode: err.statusCode } : {}),
|
|
57
|
+
...(err.headers ? { headers: err.headers } : {}),
|
|
58
|
+
});
|
|
59
|
+
/**
|
|
60
|
+
* How many issues a 422 spells out before it starts counting instead. A
|
|
61
|
+
* caller fixing their request needs the first few; the rest are the same
|
|
62
|
+
* mistake repeated.
|
|
63
|
+
*/
|
|
64
|
+
const MAX_VALIDATION_ISSUES = 50;
|
|
65
|
+
/**
|
|
66
|
+
* What the whole issue list may cost, serialized. The count cap alone bounds
|
|
67
|
+
* the wrong thing: ONE `unrecognized_keys` issue carries every key the client
|
|
68
|
+
* posted, so a strictObject answered a 1MB body with a 4MB one, unauthenticated
|
|
69
|
+
* and before any guard ran, and past maxResponseBytes the 422 became a 500.
|
|
70
|
+
* Bytes are what the amplification is measured in, so bytes are what is
|
|
71
|
+
* capped.
|
|
72
|
+
*/
|
|
73
|
+
const MAX_VALIDATION_ISSUES_BYTES = 32_000;
|
|
74
|
+
/** How many entries of one issue's own lists (`keys`, `path`) survive. */
|
|
75
|
+
const MAX_VALIDATION_LIST_ENTRIES = 20;
|
|
76
|
+
/** How long one string inside an issue may be. */
|
|
77
|
+
const MAX_VALIDATION_TEXT_CHARS = 200;
|
|
78
|
+
const utf8Encoder = new TextEncoder();
|
|
79
|
+
const clampText = (value) => value.length > MAX_VALIDATION_TEXT_CHARS ? `${value.slice(0, MAX_VALIDATION_TEXT_CHARS)}...` : value;
|
|
80
|
+
/**
|
|
81
|
+
* One issue with its own strings and lists bounded. Applied field by field
|
|
82
|
+
* rather than to the named fields only, because `keys` is merely the one that
|
|
83
|
+
* grows without a bound TODAY: any issue a schema authors itself may carry a
|
|
84
|
+
* list or a message the client chose the size of.
|
|
85
|
+
*/
|
|
86
|
+
const clampIssueValue = (value) => {
|
|
87
|
+
if (typeof value === "string")
|
|
88
|
+
return clampText(value);
|
|
89
|
+
if (Array.isArray(value))
|
|
90
|
+
return value.slice(0, MAX_VALIDATION_LIST_ENTRIES).map(clampIssueValue);
|
|
91
|
+
return value;
|
|
92
|
+
};
|
|
93
|
+
const clampIssue = (issue) =>
|
|
94
|
+
// Structurally an issue with shorter values, so the cast says what the
|
|
95
|
+
// mapping already guarantees: every field is carried through, in kind,
|
|
96
|
+
// and the union's discriminant with it. A mapped object has no way to say
|
|
97
|
+
// that in the type system.
|
|
98
|
+
Object.fromEntries(Object.entries(issue).map(([key, value]) => [key, clampIssueValue(value)]));
|
|
99
|
+
/** The issue list the body may carry: clamped, then cut to the byte budget, whole issues from the end. */
|
|
100
|
+
const boundIssueList = (all) => {
|
|
101
|
+
const issues = [];
|
|
102
|
+
let bytes = 0;
|
|
103
|
+
let trimmed = false;
|
|
104
|
+
for (const issue of all.slice(0, MAX_VALIDATION_ISSUES)) {
|
|
105
|
+
const clamped = clampIssue(issue);
|
|
106
|
+
if (JSON.stringify(clamped) !== JSON.stringify(issue))
|
|
107
|
+
trimmed = true;
|
|
108
|
+
const size = utf8Encoder.encode(JSON.stringify(clamped)).length;
|
|
109
|
+
if (bytes + size > MAX_VALIDATION_ISSUES_BYTES) {
|
|
110
|
+
trimmed = true;
|
|
111
|
+
// A first issue that is over the budget on its own still has to
|
|
112
|
+
// say what it is: the three fields every issue carries, so a
|
|
113
|
+
// client always has a code and a path to branch on.
|
|
114
|
+
if (issues.length === 0) {
|
|
115
|
+
issues.push({ code: clamped.code, path: clamped.path.slice(0, MAX_VALIDATION_LIST_ENTRIES), message: clampText(clamped.message) });
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
issues.push(clamped);
|
|
120
|
+
bytes += size;
|
|
121
|
+
}
|
|
122
|
+
return { issues, trimmed: trimmed || issues.length < all.length };
|
|
123
|
+
};
|
|
124
|
+
/** What the answer says about itself, in place of zod's own message. */
|
|
125
|
+
const summarizeIssues = (total, listed, trimmed) => {
|
|
126
|
+
const head = `${total} validation issue${total === 1 ? "" : "s"}`;
|
|
127
|
+
if (listed < total)
|
|
128
|
+
return `${head}; the first ${listed} are listed.`;
|
|
129
|
+
if (trimmed)
|
|
130
|
+
return `${head}; oversized values are shortened.`;
|
|
131
|
+
return `${head}.`;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* The standard answer for a rejected input: a 422 whose body spells the
|
|
135
|
+
* ZodError out. Spelled out rather than serialized as-is: zod 4 keeps
|
|
136
|
+
* `issues` as a non-enumerable property, so JSON.stringify(zodError) would
|
|
137
|
+
* carry the issues only inside the message string, and a client's
|
|
138
|
+
* validation handler would receive a ZodError with nothing to branch on.
|
|
139
|
+
*
|
|
140
|
+
* zod's own `message` never ships: it is the whole issue tree re-serialized,
|
|
141
|
+
* so carrying it would send every capped byte a second time. The generated
|
|
142
|
+
* summary takes its place on every answer, trimmed or not.
|
|
143
|
+
*/
|
|
144
|
+
export const validationAnswer = (zodError, logList) => {
|
|
145
|
+
const { issues, trimmed } = boundIssueList(zodError.issues);
|
|
146
|
+
return {
|
|
147
|
+
statusCode: 422,
|
|
148
|
+
headers: { "Content-Type": [API_ANSWER_CONTENT_TYPE] },
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
error: "Input validation failed",
|
|
151
|
+
zodError: {
|
|
152
|
+
name: zodError.name,
|
|
153
|
+
message: summarizeIssues(zodError.issues.length, issues.length, trimmed),
|
|
154
|
+
issues,
|
|
155
|
+
},
|
|
156
|
+
...(trimmed ? { issueCount: zodError.issues.length } : {}),
|
|
157
|
+
...(logList?.length ? { logList } : {}),
|
|
158
|
+
}),
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
/** No API is registered under the requested name: a refusal, not a 404, so a typed caller reads it. */
|
|
162
|
+
export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
|
|
163
|
+
errorMessage: { type: "warning", code: LAMBDER_REFUSAL_CODES.apiNotFound, content: "API not found." },
|
|
164
|
+
logList,
|
|
165
|
+
}));
|
|
166
|
+
/** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
|
|
167
|
+
export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { sessionExpired: true, logList }));
|
|
168
|
+
/** The caller's version is behind the server's: the protocol's versionExpired flag, which the caller reloads on. */
|
|
169
|
+
export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { versionExpired: true }));
|
|
170
|
+
/** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
|
|
171
|
+
export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(buildApiEnvelope(apiVersion, null, {
|
|
172
|
+
errorMessage: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content: message },
|
|
173
|
+
}), { statusCode: 400 });
|
|
174
|
+
/**
|
|
175
|
+
* The last-resort answer when the call crashed and nothing else could
|
|
176
|
+
* answer: a 500 that is still an envelope, so a caller reads a structured
|
|
177
|
+
* failure rather than a text page. The server sends it only when its global
|
|
178
|
+
* error handler is absent or itself failed.
|
|
179
|
+
*/
|
|
180
|
+
export const crashAnswer = (apiVersion) => envelopeAnswer(buildApiEnvelope(apiVersion, null, { errorMessage: "Internal server error." }), { statusCode: 500 });
|