lambder 9.0.2 → 10.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 +195 -0
- package/README.md +29 -14
- package/dist/api/LambderApiDefinition.d.ts +9 -0
- package/dist/api/LambderApiEnvelope.d.ts +22 -10
- package/dist/api/LambderApiEnvelope.js +57 -22
- package/dist/api/LambderApiGuards.d.ts +77 -22
- package/dist/api/LambderApiGuards.js +13 -1
- package/dist/api/LambderApiIdempotency.js +4 -4
- package/dist/api/LambderApiOutputValidationError.d.ts +10 -4
- package/dist/api/LambderApiOutputValidationError.js +14 -6
- package/dist/api/LambderApiPipeline.d.ts +2 -1
- package/dist/api/LambderApiPipeline.js +10 -2
- package/dist/api/LambderApiRateLimits.d.ts +15 -11
- package/dist/api/LambderApiRateLimits.js +31 -7
- package/dist/api/LambderApiRefusals.d.ts +204 -0
- package/dist/api/LambderApiRefusals.js +223 -0
- package/dist/api/LambderApiSignature.d.ts +13 -4
- package/dist/api/LambderApiSignature.js +21 -4
- package/dist/build/writeApiOptions.js +7 -5
- package/dist/client/LambderCaller.d.ts +28 -17
- package/dist/client/LambderCaller.js +19 -15
- package/dist/client/LambderUploadRunner.d.ts +5 -5
- package/dist/client/LambderUploadRunner.js +25 -16
- package/dist/client.d.ts +6 -5
- package/dist/client.js +2 -1
- package/dist/core/Lambder.d.ts +121 -23
- package/dist/core/Lambder.js +105 -51
- package/dist/core/LambderContext.d.ts +1 -1
- package/dist/core/LambderContext.js +5 -0
- package/dist/core/LambderCreateOptions.d.ts +58 -15
- package/dist/core/LambderCreateOptions.js +10 -0
- package/dist/core/LambderPolicyBuilders.d.ts +11 -8
- package/dist/core/LambderPolicyBuilders.js +10 -8
- package/dist/core/LambderResolver.d.ts +1 -1
- package/dist/core/LambderResolver.js +1 -1
- package/dist/core/LambderResponseBuilder.d.ts +11 -9
- package/dist/core/LambderResponseBuilder.js +12 -11
- package/dist/index.d.ts +13 -9
- package/dist/index.js +6 -2
- package/dist/invoke/LambderInvokeCaller.d.ts +5 -6
- package/dist/invoke/LambderInvokeCaller.js +13 -12
- package/dist/invoke/LambderInvokeOutcome.d.ts +32 -23
- package/dist/invoke/LambderInvokeOutcome.js +4 -4
- package/dist/mock/LambderMockApp.d.ts +86 -24
- package/dist/mock/LambderMockApp.js +108 -16
- package/dist/mock/LambderMockCallRecorder.js +6 -3
- package/dist/mock/LambderMockCreateOptions.d.ts +4 -5
- package/dist/mock/LambderMockFailureInjector.d.ts +9 -2
- package/dist/mock/LambderMockFailureInjector.js +18 -4
- package/dist/mock/LambderMockTypes.d.ts +34 -11
- package/dist/mock.d.ts +4 -3
- package/dist/mock.js +4 -3
- package/dist/shared/contracts/LambderUploadBucket.d.ts +18 -3
- package/dist/shared/contracts/LambderUploadBucket.js +2 -2
- package/dist/shared/util/LambderTypeUtilities.d.ts +11 -0
- package/dist/shared/wire/LambderApiContract.d.ts +96 -31
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +19 -12
- package/dist/shared/wire/LambderApiOptionValues.d.ts +15 -6
- package/dist/shared/wire/LambderApiOptionValues.js +3 -3
- package/dist/shared/wire/LambderApiOutcome.d.ts +49 -27
- package/dist/shared/wire/LambderApiOutcome.js +28 -11
- package/dist/shared/wire/LambderApiRefusal.d.ts +127 -42
- package/dist/shared/wire/LambderApiRefusal.js +15 -7
- package/dist/shared/wire/LambderCrashDetail.d.ts +1 -1
- package/dist/shared/wire/LambderCrashDetail.js +1 -1
- package/dist/shared/wire/LambderHttpStatus.d.ts +6 -0
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -5
- package/dist/shared/wire/LambderObjectPayload.d.ts +15 -0
- package/dist/shared/wire/LambderObjectPayload.js +24 -0
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +52 -7
- package/dist/shared/wire/LambderOutcomeAssertions.js +34 -8
- package/dist/shared/wire/LambderUploadObjectFields.d.ts +10 -1
- package/dist/shared/wire/LambderUploadObjectFields.js +22 -1
- package/dist/shared/wire/LambderUploadSchemas.d.ts +8 -2
- package/dist/shared/wire/LambderUploadSchemas.js +18 -7
- package/dist/stores/LambderMemoryUploadBucket.d.ts +21 -7
- package/dist/stores/LambderMemoryUploadBucket.js +60 -12
- package/dist/stores/LambderS3UploadBucket.d.ts +22 -3
- package/dist/stores/LambderS3UploadBucket.js +51 -4
- package/dist/testing/LambderTestApp.d.ts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,201 @@ sit on its first published patch, and later patches list only what they changed.
|
|
|
9
9
|
Releases up to 3.2.6 carry git tags; the ones after it were published without
|
|
10
10
|
one, so versions are not cross-linked to tag comparisons here.
|
|
11
11
|
|
|
12
|
+
## [10.0.1] - 2026-09-28
|
|
13
|
+
|
|
14
|
+
A major that makes both sides of an API answer exact. A success is only ever
|
|
15
|
+
the handler's parsed output, an object or an array, so a caller's success is
|
|
16
|
+
typed as exactly the endpoint's output and is never null or falsy. A refusal
|
|
17
|
+
names a code the app declared once, with the schema of its data and the
|
|
18
|
+
status and flag it leaves with: the contract carries every code an endpoint
|
|
19
|
+
can refuse with, a caller narrows `refusal.data` on the code, and a refusal
|
|
20
|
+
outside its declaration is a crash rather than an answer, which is what keeps
|
|
21
|
+
the caller's type honest. The envelope field that carries it is now called
|
|
22
|
+
what it is, `refusal`.
|
|
23
|
+
|
|
24
|
+
### Changed (breaking)
|
|
25
|
+
|
|
26
|
+
- **The envelope's `errorMessage` is `refusal`,** on every surface: the wire
|
|
27
|
+
field, an outcome's `refusal` and its reason `'refusal'` (not
|
|
28
|
+
`'errorMessage'`), the callers' `refusalHandler`, `res.apiRefusal({
|
|
29
|
+
refusal })`, a `LambderApiRefusal`'s `refusal` and the option that sets it,
|
|
30
|
+
a rate-limit policy's and an override's `refusal`, the generated policy
|
|
31
|
+
table's `refusal`, and `LambderIdempotentAttemptOutcome.refusal`. The field
|
|
32
|
+
carries a typed refusal with a code and data, and it may be an "info" or a
|
|
33
|
+
"warning", so "error message" named neither its role nor its type. Lambda's
|
|
34
|
+
own `errorMessage` on a function error is Lambda's and keeps its name.
|
|
35
|
+
- **An API's output is an object or an array.** An output schema whose JSON
|
|
36
|
+
form is anything else (`z.void()`, `z.boolean()`, `z.number()`, a nullable
|
|
37
|
+
or an optional object, `z.date()`) is a compile error on the `output`
|
|
38
|
+
option, and a handler that answers one anyway is a crash
|
|
39
|
+
(`LambderApiOutputValidationError`, which gains that kind). `caller.api()`
|
|
40
|
+
is then truthy exactly on success.
|
|
41
|
+
- To move: an API with nothing to answer declares `output: z.object({})`
|
|
42
|
+
and returns `{}`; one that answered a primitive wraps it
|
|
43
|
+
(`{ count }`); one whose output was nullable answers `{ order: null }`.
|
|
44
|
+
- **A success outcome's payload is exactly the output.**
|
|
45
|
+
`LambderApiSuccessOutcome<T>` is `payload: T`, not `T | null | undefined`,
|
|
46
|
+
and `LambderCaller.api()` returns `T | undefined`, `undefined` on every
|
|
47
|
+
failure, a refusal included (it no longer hands back the refusal
|
|
48
|
+
envelope's payload). `LambderInvokeCaller` no longer turns an absent
|
|
49
|
+
payload into `null`.
|
|
50
|
+
- **A reader takes a success only when its payload is an object or an
|
|
51
|
+
array.** A 2xx envelope with no flag and no refusal whose payload is
|
|
52
|
+
null, a primitive or missing reads as reason `server`: no handler wrote it.
|
|
53
|
+
- **`res.api(payload, config, options)` is `res.apiRefusal(config,
|
|
54
|
+
options)`.** An API call answered from outside its handler (a hook, a
|
|
55
|
+
fallback, the input validation handler, the global error handler) is
|
|
56
|
+
always a refusal: the config carries a `refusal` or one of the
|
|
57
|
+
`versionExpired`, `sessionExpired` and `notAuthorized` flags (a compile
|
|
58
|
+
error and a thrown error otherwise), the payload is null, and its message
|
|
59
|
+
carries a framework code or none, never an app code or data.
|
|
60
|
+
`res.die.api` is `res.die.apiRefusal`, and `LambderApiResponseConfig` is
|
|
61
|
+
`LambderApiRefusalConfig`.
|
|
62
|
+
- To move: `res.api(null, config, options)` becomes `res.apiRefusal(config,
|
|
63
|
+
options)`. A hook that answered an API call with a cached success moves
|
|
64
|
+
the cache into the handler, which answers from it.
|
|
65
|
+
- **The mock's `onInvalidInput` answers `{ config, statusCode? }`,** a
|
|
66
|
+
refusal held to the same rule, with no payload. A mock handler that
|
|
67
|
+
answers anything but an object or an array crashes the call, as the
|
|
68
|
+
server does, instead of answering a null payload.
|
|
69
|
+
- **The envelope is two shapes.** `LambderApiEnvelopeBody<T>` is a
|
|
70
|
+
`LambderApiSuccessEnvelope<T>` (the payload) or a
|
|
71
|
+
`LambderApiRefusalEnvelope` (a null payload beside the flags, the
|
|
72
|
+
refusal and a crash). A failure outcome's `response` is the refusal
|
|
73
|
+
envelope. `buildApiEnvelope` is replaced by `successEnvelope`,
|
|
74
|
+
`refusalEnvelope` and `plainRefusalEnvelope`, and
|
|
75
|
+
`LambderApiEnvelopeConfig` by `LambderRefusalEnvelopeFields`.
|
|
76
|
+
`LambderJsonOutputOf` is removed: an output is `LambderJsonOf` of the
|
|
77
|
+
schema's output.
|
|
78
|
+
- **A coded refusal must be declared, and leaves as declared.** A refusal
|
|
79
|
+
whose code is neither a framework code nor one its endpoint declares, data
|
|
80
|
+
on a refusal whose code declares none, no data on one whose code carries
|
|
81
|
+
data, data its code's schema rejects, or a declared code raised with a
|
|
82
|
+
status or `notAuthorized` flag of its own (its declaration owns them) is
|
|
83
|
+
now a crash (`LambderApiRefusalValidationError`) rather than an answer. The
|
|
84
|
+
same check runs on a refusal a hook throws for an API call, and in the
|
|
85
|
+
mock where it has the generated options.
|
|
86
|
+
- To move: declare every code with `initLambder().declareRefusals()`
|
|
87
|
+
(below), name each in the `refusals` option of the APIs and guards that
|
|
88
|
+
raise it, and move a code's status and `notAuthorized` from its raise
|
|
89
|
+
sites into its declaration.
|
|
90
|
+
- **`create()` takes no `refusals`.** The vocabulary is declared on the init,
|
|
91
|
+
`initLambder<SessionData>().declareRefusals(vocabulary)`, before any guard
|
|
92
|
+
is built, so guards and shared helpers can be typed to it; `create()` on
|
|
93
|
+
that init hands it to the instance. `LambderCreateOptions.refusals` stays
|
|
94
|
+
for the class constructed directly.
|
|
95
|
+
- **`LambderRefusalMessage` is generic over an endpoint's declared codes.**
|
|
96
|
+
Its argument is the contract's map of code to `{ data }` or `{}`
|
|
97
|
+
(`LambderContractRefusalsOf`), not a union of code strings, and the
|
|
98
|
+
message is one arm per code, so `data` narrows on `code`. The message
|
|
99
|
+
shape gains `data`. `LambderAppRefusalMessage` is
|
|
100
|
+
`LambderUncheckedRefusalMessage`: the message before any check, what a
|
|
101
|
+
thrown refusal carries and what a reader gets off a body no server
|
|
102
|
+
vouched for; an app's own message is a declared one.
|
|
103
|
+
- **A rate-limit message carries no code.** A policy's and an override's
|
|
104
|
+
`refusal` is a `LambderRateLimitMessage` (type, title, content); every
|
|
105
|
+
rate-limit refusal is `lambder/rate-limited`. A code or data there is a
|
|
106
|
+
compile error on a policy and a creation or registration error on both.
|
|
107
|
+
- **An endpoint's signature covers its declared refusals,** codes, data
|
|
108
|
+
schemas, statuses and flags, so every endpoint's signature changes once
|
|
109
|
+
with this version, and adding a code to an endpoint or a guard, or
|
|
110
|
+
changing how a code leaves, reloads its callers.
|
|
111
|
+
- **The instance has a ninth and a tenth type parameter,** the refusal
|
|
112
|
+
vocabulary and whether every refusal names a code. Code that names
|
|
113
|
+
`Lambder<...>` with every parameter written out adds two.
|
|
114
|
+
- **An entry of the mock whose `apiOptions` entry declares guards needs
|
|
115
|
+
`guardDeclarations` beside the table,** which holds the codes those guards
|
|
116
|
+
add, and one that can refuse with a declared code needs the vocabulary
|
|
117
|
+
declared on the mock init (`initLambderMock().declareRefusals()`, the same
|
|
118
|
+
object the server's init declares); registering one without them throws,
|
|
119
|
+
as does a table naming a code the vocabulary does not hold.
|
|
120
|
+
- **`LambderContractRefusalsFor` is `LambderWireRefusalsOf`** (it pairs with
|
|
121
|
+
`LambderHandlerRefusalsOf`: the same codes in the handler's form and in
|
|
122
|
+
wire form), and **`handWrittenRefusalEnvelope` is `plainRefusalEnvelope`**
|
|
123
|
+
(what it enforces is a plain refusal message: a framework code or none,
|
|
124
|
+
and no data).
|
|
125
|
+
|
|
126
|
+
### Added
|
|
127
|
+
|
|
128
|
+
- **Declared refusals.** `initLambder().declareRefusals(vocabulary)` is the
|
|
129
|
+
app's vocabulary: each code once, `{}` or `{ data: schema }` (an object or
|
|
130
|
+
an array, as an output), with the `status` every refusal with it leaves
|
|
131
|
+
with (200 by default, never 422 or a 5xx) and whether it sets
|
|
132
|
+
`notAuthorized`. The init it returns is bound to it: its `guard()` types a
|
|
133
|
+
guard's `ctx.refuse` to the guard's `refusals` and refuses a guard naming a
|
|
134
|
+
code outside the vocabulary as it is built, its `refuse` is typed to the
|
|
135
|
+
whole vocabulary for shared helpers and hooks, and its `create()` hands the
|
|
136
|
+
vocabulary to the instance. `addApi` and `addSessionApi` take `refusals`,
|
|
137
|
+
one code or a non-empty list, and a guard takes `refusals` too; a guard's
|
|
138
|
+
codes join every API that declares it. The handler's context carries
|
|
139
|
+
`ctx.refuse`, typed to those codes with `data` required where a code
|
|
140
|
+
declares it and no status or flag of its own; the free `refuse()` and
|
|
141
|
+
`LambderApiRefusal` take `code` and `data` from a shared helper. A declared
|
|
142
|
+
code's data is parsed through its schema before it is sent, and the
|
|
143
|
+
refusal leaves with its declaration's status and flag. The contract entry
|
|
144
|
+
carries `refusals`, every code the endpoint can refuse with;
|
|
145
|
+
`LambderContractRefusalsOf`, `LambderContractRefusalMessage`,
|
|
146
|
+
`LambderContractRefusals` and `LambderContractAnyRefusalMessage` read it;
|
|
147
|
+
an outcome's `refusal` (on the browser caller, the invoke caller, the test
|
|
148
|
+
visitor and the mock) and a per-call `refusalHandler` are typed per
|
|
149
|
+
endpoint, and the constructor's `refusalHandler` across the contract. The
|
|
150
|
+
generated options record each API's `refusals` and each guard declaration's;
|
|
151
|
+
the vocabulary itself is not written, since it is shared code the mock
|
|
152
|
+
declares as the server does.
|
|
153
|
+
- **`declareRefusals(vocabulary, { requireCodes: true })`** makes every
|
|
154
|
+
refusal an API answers with name a declared or framework code: an uncoded
|
|
155
|
+
`refuse("...")` from a handler, a guard or a helper is then a crash rather
|
|
156
|
+
than an answer, and `ctx.refuse` and the init's `refuse` require a code.
|
|
157
|
+
The mock's `declareRefusals` takes the same option.
|
|
158
|
+
- **`assertApiRefusal(outcome, code)`** in `lambder/testing` and
|
|
159
|
+
`lambder/mock`: asserts a refusal's code on any failure arm and narrows its
|
|
160
|
+
`refusal` to that code, data typed. `LambderExpectedFailure.code` is
|
|
161
|
+
typed to the outcome's codes.
|
|
162
|
+
- **The mock declares the vocabulary and refuses as the server does.**
|
|
163
|
+
`initLambderMock().declareRefusals(vocabulary, { requireCodes? })` takes
|
|
164
|
+
the same object and option the server's init does, from shared code. A
|
|
165
|
+
mock handler's `ctx.refuse` and the mock init's `refuse` are then typed to
|
|
166
|
+
the codes the contract gives the endpoint with each code's data in the
|
|
167
|
+
schema's input form, and the mock checks and sends a refusal exactly as the
|
|
168
|
+
server would: the code among the codes the generated tables give the entry,
|
|
169
|
+
the data parsed through the code's schema (defaults filled, transforms run
|
|
170
|
+
once), and the declaration's status and flag. `failNext` and `setFailure`
|
|
171
|
+
take an injected refusal the same way. A mock that declared no vocabulary
|
|
172
|
+
types `ctx.refuse` from the contract's wire form and checks nothing.
|
|
173
|
+
`LambderMockRefusalsOf` is the map behind both.
|
|
174
|
+
- `checkedRefusal`, `LambderApiRefusalValidationError`,
|
|
175
|
+
`LambderRefusalDeclaration`, `LambderRefusalVocabulary`,
|
|
176
|
+
`LambderRefusalStatusCode`,
|
|
177
|
+
`LambderApiAllowedRefusal`, `LambderApiAllowedRefusals`,
|
|
178
|
+
`LambderEndpointRefusals`, `LambderRefusalsOption`,
|
|
179
|
+
`LambderRefusalNamesIn`, `LambderRefusalsOptionValue`,
|
|
180
|
+
`LambderHandlerRefusalsOf`, `LambderWireRefusalsOf`,
|
|
181
|
+
`LambderGuardRefusals`, `LambderGuardRefusalNamesOf`,
|
|
182
|
+
`LambderPlainRefusalMessage`, `LambderUncheckedRefusalMessage`,
|
|
183
|
+
`LambderDeclaredRefuse`, `LambderDeclaredRefuseOptions`,
|
|
184
|
+
`LambderRateLimitMessage`, `LambderRefusalEnvelopeFields`,
|
|
185
|
+
`isLambderRefusalCode`, `isObjectPayload`, `successEnvelope`,
|
|
186
|
+
`refusalEnvelope` and `plainRefusalEnvelope`.
|
|
187
|
+
|
|
188
|
+
## [9.0.3] - 2026-09-27
|
|
189
|
+
|
|
190
|
+
An upload bucket can now sign a ticket that sends the file with a PUT instead
|
|
191
|
+
of a form POST, for a store without S3's presigned POST (Cloudflare R2 among
|
|
192
|
+
them).
|
|
193
|
+
|
|
194
|
+
### Added
|
|
195
|
+
|
|
196
|
+
- **`LambderUploadTicket` is `{ method: "POST", ... }` or `{ method: "PUT",
|
|
197
|
+
... }`.** A POST ticket carries `formFields` as before; a PUT ticket carries
|
|
198
|
+
`headers`, every one signed into the URL, that `LambderUploadRunner` sends
|
|
199
|
+
unchanged with the file as the body. `LambderMemoryUploadBucket` and
|
|
200
|
+
`LambderS3UploadBucket` both take a `uploadMethod` option (default `POST`)
|
|
201
|
+
and sign whichever kind of ticket that asks for.
|
|
202
|
+
- **`uploadObjectHeaders`** builds a PUT ticket's headers the way
|
|
203
|
+
`uploadObjectFormFields` builds a POST ticket's fields: the tag set as
|
|
204
|
+
`x-amz-tagging`, each metadata entry as `x-amz-meta-<name>`, `cache-control`
|
|
205
|
+
and `content-disposition`.
|
|
206
|
+
|
|
12
207
|
## [9.0.2] - 2026-09-27
|
|
13
208
|
|
|
14
209
|
Nothing a handler or a caller does changes; the package, its docs and its
|
package/README.md
CHANGED
|
@@ -24,7 +24,8 @@ export const handler = lambder.getHandler();
|
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
A handler returns its output, which is parsed through the output schema before
|
|
27
|
-
it is sent, and says no by throwing `refuse()
|
|
27
|
+
it is sent, and says no by throwing a refusal: `ctx.refuse()`, typed to the
|
|
28
|
+
codes the API declares, or `refuse()` from anywhere. Registration chains onto the
|
|
28
29
|
creation call: every `addApi` returns an instance carrying the contract so
|
|
29
30
|
far, so the whole backend is one declaration and `lambder.ApiContract` is the
|
|
30
31
|
accumulated type.
|
|
@@ -46,6 +47,16 @@ const company = await caller.api("getCompany", { slug: "acme" });
|
|
|
46
47
|
runtime validation and compile-time inference on both sides of the wire.
|
|
47
48
|
- **One inferred contract.** The API contract is derived from the backend code
|
|
48
49
|
and consumed by the frontend as a type-only import.
|
|
50
|
+
- **Exact results.** A success is only ever the handler's parsed output, an
|
|
51
|
+
object or an array, so a caller's success is typed as exactly the output and
|
|
52
|
+
is never null or falsy; everything a hook or an error handler answers is a
|
|
53
|
+
refusal.
|
|
54
|
+
- **Declared refusals.** An app declares its refusal codes once, each with the
|
|
55
|
+
schema of its data, the status it leaves with and whether it flags
|
|
56
|
+
`notAuthorized`; every API and guard names the codes it may refuse with, and
|
|
57
|
+
a guard's or a handler's `ctx.refuse` is typed to them. The contract carries
|
|
58
|
+
them, a caller narrows `refusal.data` on the code, and a refusal outside its
|
|
59
|
+
declaration is a crash, never an answer.
|
|
49
60
|
- **Simple route and API declaration.** Paths, regexes, predicates and
|
|
50
61
|
structured matchers, chained fluently.
|
|
51
62
|
- **Sessions.** Over a store of your choosing (DynamoDB, memory, your own),
|
|
@@ -69,8 +80,9 @@ const company = await caller.api("getCompany", { slug: "acme" });
|
|
|
69
80
|
- **Frontend hosting.** Serve a build from a folder, S3, R2 or any HTTP
|
|
70
81
|
origin, with an app shell rendered through a build-pipeline-safe template
|
|
71
82
|
engine.
|
|
72
|
-
- **Direct uploads.** Files go from the browser straight to S3 on
|
|
73
|
-
that pin their size, type and SHA-256, with a
|
|
83
|
+
- **Direct uploads.** Files go from the browser straight to S3, or to R2 on
|
|
84
|
+
presigned PUTs, on tickets that pin their size, type and SHA-256, with a
|
|
85
|
+
browser runner that hashes,
|
|
74
86
|
retries and reports progress, and a memory bucket that holds tests and the
|
|
75
87
|
mock to the same rules.
|
|
76
88
|
- **Runs anywhere Lambda does.** API Gateway REST APIs (payload v1), HTTP APIs
|
|
@@ -163,7 +175,7 @@ guide that matches what you are building. The full index lives in
|
|
|
163
175
|
| [The API core](./docs/api-core.md) | `LambderApiPipeline`: the one pipeline the server and the mock runtime run, the store interfaces, the transports |
|
|
164
176
|
| [Frontend client](./docs/client.md) | `LambderCaller`: typed calls, failure outcomes, timeouts, guard inputs, request compression, transports |
|
|
165
177
|
| [Frontend hosting](./docs/frontend-hosting.md) | File sources, `servePublicFiles`, `serveIndexHtml`, `res.templateFile` |
|
|
166
|
-
| [Direct uploads](./docs/uploads.md) | Files the browser
|
|
178
|
+
| [Direct uploads](./docs/uploads.md) | Files the browser sends straight to S3 or R2 with tickets the server signs, verified before they count |
|
|
167
179
|
| [Templating](./docs/templating.md) | `html`/`xml` tagged templates and `LambderTemplatingEngine` |
|
|
168
180
|
| [Translations](./docs/i18n.md) | `createLambderI18n`: typed keys, extension, detection, on-demand languages, runtime dictionaries |
|
|
169
181
|
| [The mock runtime](./docs/mock.md) | `LambderMockApp`: the typed contract served from mock handlers over the real pipeline, in the browser and in tests |
|
|
@@ -187,16 +199,19 @@ framework:
|
|
|
187
199
|
## Versioning and changes
|
|
188
200
|
|
|
189
201
|
Released versions and what each one changed are in
|
|
190
|
-
[CHANGELOG.md](./CHANGELOG.md). The current major is
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
202
|
+
[CHANGELOG.md](./CHANGELOG.md). The current major is v10, which makes both
|
|
203
|
+
sides of an answer exact. A success is only ever the handler's parsed output,
|
|
204
|
+
and an output is an object or an array, so `caller.api()` is truthy exactly
|
|
205
|
+
on success and a success's payload is typed as exactly the output; a hook, a
|
|
206
|
+
fallback or an error handler answers an API call with `res.apiRefusal()`,
|
|
207
|
+
never with a payload. A refusal names a code the app declared, which the
|
|
208
|
+
contract carries and a caller narrows on, with its data typed. Every break
|
|
209
|
+
and what to do about it is in the 10.0.1 entry, and the compiler finds most
|
|
210
|
+
of them. The ones it cannot are changes of behavior: a refusal whose code its
|
|
211
|
+
API does not declare is now a crash rather than an answer, and a success
|
|
212
|
+
whose payload is not an object or an array now reads as a server failure. An
|
|
213
|
+
app still on v8 goes through the 9.0.1 entry first, and one on an older
|
|
214
|
+
major through the entries before it.
|
|
200
215
|
|
|
201
216
|
## Contributing
|
|
202
217
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
+
import type { LambderEndpointRefusals } from "./LambderApiRefusals.js";
|
|
2
3
|
import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
|
|
3
4
|
import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "../shared/wire/LambderApiOptionValues.js";
|
|
4
5
|
/**
|
|
@@ -10,6 +11,13 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
|
|
|
10
11
|
* signature (apiSignatureOf), which is what a client's build is checked
|
|
11
12
|
* against; the server also parses every output a handler returns through it
|
|
12
13
|
* before it is sent.
|
|
14
|
+
*
|
|
15
|
+
* `refusals` is every code the endpoint may refuse with (its own option and
|
|
16
|
+
* its guards'), resolved against the vocabulary, and whether every refusal
|
|
17
|
+
* has to name one: what every refusal it answers with is checked against
|
|
18
|
+
* (checkedRefusal); the codes are part of its signature. Undefined where the
|
|
19
|
+
* codes are not known (a mock without generated options), which checks
|
|
20
|
+
* nothing.
|
|
13
21
|
*/
|
|
14
22
|
export type LambderApiDefinition = {
|
|
15
23
|
name: string;
|
|
@@ -19,4 +27,5 @@ export type LambderApiDefinition = {
|
|
|
19
27
|
idempotency?: LambderApiIdempotencyOption;
|
|
20
28
|
input?: z.ZodType;
|
|
21
29
|
output?: z.ZodType;
|
|
30
|
+
refusals?: LambderEndpointRefusals;
|
|
22
31
|
};
|
|
@@ -1,26 +1,38 @@
|
|
|
1
1
|
import type { z } from "zod";
|
|
2
|
-
import type { LambderApiEnvelopeBody,
|
|
2
|
+
import type { LambderApiEnvelopeBody, LambderRefusalEnvelopeFields, LambderApiRefusalConfig, LambderApiRefusalEnvelope, LambderApiSuccessEnvelope } from "../shared/wire/LambderApiContract.js";
|
|
3
3
|
import { type LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
4
4
|
import type { LambderCrashDetail } from "../shared/wire/LambderCrashDetail.js";
|
|
5
5
|
import type { LambderApiAnswer } from "./LambderApiAnswer.js";
|
|
6
6
|
export declare const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
7
|
-
/** The envelope's config plus the logList channel the call accumulated. */
|
|
8
|
-
export type LambderApiEnvelopeConfig = LambderApiResponseConfig & {
|
|
9
|
-
logList?: unknown[];
|
|
10
|
-
};
|
|
11
7
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* logList is omitted.
|
|
8
|
+
* A handler's answer as the wire envelope: its parsed output and the logList
|
|
9
|
+
* the call accumulated, nothing else. The only envelope a reader takes for a
|
|
10
|
+
* success; an empty logList is omitted.
|
|
11
|
+
*/
|
|
12
|
+
export declare const successEnvelope: <T>(apiVersion: string | null | undefined, payload: T, logList?: unknown[]) => LambderApiSuccessEnvelope<T>;
|
|
13
|
+
/**
|
|
14
|
+
* Every other answer as the wire envelope: a null payload beside the
|
|
15
|
+
* refusal's message and flags. Flags are only present when set, and an
|
|
16
|
+
* empty logList is omitted.
|
|
17
|
+
*/
|
|
18
|
+
export declare const refusalEnvelope: (apiVersion: string | null | undefined, { versionExpired, sessionExpired, notAuthorized, refusal, logList, crash }: LambderRefusalEnvelopeFields) => LambderApiRefusalEnvelope;
|
|
19
|
+
/**
|
|
20
|
+
* An API call answered from outside its handler (res.apiRefusal on the
|
|
21
|
+
* server, onInvalidInput in the mock) as its envelope, once the config is
|
|
22
|
+
* known to be a refusal: a refusal message or one of the three flags, so a
|
|
23
|
+
* reader never takes it for the handler's output, and a message with a
|
|
24
|
+
* framework code or none and no data, since it answers outside any one
|
|
25
|
+
* endpoint's declared refusals. `writer` names the call in the error, and
|
|
26
|
+
* the logList is the config's own or the one given.
|
|
15
27
|
*/
|
|
16
|
-
export declare const
|
|
28
|
+
export declare const plainRefusalEnvelope: (apiVersion: string | null | undefined, config: LambderApiRefusalConfig, writer: string, logList?: unknown[]) => LambderApiRefusalEnvelope;
|
|
17
29
|
/** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
|
|
18
30
|
export declare const envelopeAnswer: (envelope: LambderApiEnvelopeBody<unknown>, options?: {
|
|
19
31
|
statusCode?: number;
|
|
20
32
|
headers?: Record<string, string | string[]>;
|
|
21
33
|
}) => LambderApiAnswer;
|
|
22
34
|
/**
|
|
23
|
-
* A thrown refusal as an answer: its
|
|
35
|
+
* A thrown refusal as an answer: its refusal and flags on the envelope,
|
|
24
36
|
* its status (200 unless it set one) and its extra headers (Retry-After on a
|
|
25
37
|
* rate limit). The logList the call accumulated rides along, as it does on
|
|
26
38
|
* a success.
|
|
@@ -1,35 +1,70 @@
|
|
|
1
|
-
import { LAMBDER_REFUSAL_CODES, refusalMessageOf } from "../shared/wire/LambderApiRefusal.js";
|
|
1
|
+
import { LAMBDER_REFUSAL_CODES, isLambderRefusalCode, refusalMessageOf } from "../shared/wire/LambderApiRefusal.js";
|
|
2
2
|
import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
3
3
|
/*
|
|
4
4
|
* The one place the API envelope is written, and the one mapping from each
|
|
5
5
|
* kind of protocol outcome onto an answer: a success, a thrown refusal, a
|
|
6
6
|
* rejected input, an unknown name, a missing session, a stale client, a
|
|
7
7
|
* malformed compressed payload, and the last-resort crash. The server's API
|
|
8
|
-
* path and res.
|
|
8
|
+
* path and res.apiRefusal(), the mock runtime and the pipeline all build through these
|
|
9
9
|
* functions, so server and mock cannot drift on a single byte of the wire
|
|
10
10
|
* format. Pure: no Node built-ins, no response classes.
|
|
11
11
|
*/
|
|
12
12
|
export const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* logList is omitted.
|
|
14
|
+
* A handler's answer as the wire envelope: its parsed output and the logList
|
|
15
|
+
* the call accumulated, nothing else. The only envelope a reader takes for a
|
|
16
|
+
* success; an empty logList is omitted.
|
|
17
17
|
*/
|
|
18
|
-
export const
|
|
18
|
+
export const successEnvelope = (apiVersion, payload, logList) => ({
|
|
19
19
|
apiVersion: apiVersion ?? null,
|
|
20
20
|
payload,
|
|
21
|
-
...(
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
...(logList?.length ? { logList } : {}),
|
|
22
|
+
});
|
|
23
|
+
/**
|
|
24
|
+
* Every other answer as the wire envelope: a null payload beside the
|
|
25
|
+
* refusal's message and flags. Flags are only present when set, and an
|
|
26
|
+
* empty logList is omitted.
|
|
27
|
+
*/
|
|
28
|
+
export const refusalEnvelope = (apiVersion, { versionExpired, sessionExpired, notAuthorized, refusal, logList, crash }) => ({
|
|
29
|
+
apiVersion: apiVersion ?? null,
|
|
30
|
+
payload: null,
|
|
31
|
+
...(versionExpired ? { versionExpired: true } : {}),
|
|
32
|
+
...(sessionExpired ? { sessionExpired: true } : {}),
|
|
33
|
+
...(notAuthorized ? { notAuthorized: true } : {}),
|
|
24
34
|
// Presence, not truthiness: these channels carry app values, and an app
|
|
25
|
-
// that refuses with
|
|
35
|
+
// that refuses with refusal: "" (or 0) meant to say something. The
|
|
26
36
|
// flags above are booleans, where false and absent mean the same. An
|
|
27
|
-
//
|
|
37
|
+
// refusal goes out as a message object whatever form it was written
|
|
28
38
|
// in, so every reader meets one shape.
|
|
29
|
-
...(
|
|
39
|
+
...(refusal !== undefined ? { refusal: refusalMessageOf(refusal) } : {}),
|
|
30
40
|
...(crash !== undefined ? { crash } : {}),
|
|
31
41
|
...(logList?.length ? { logList } : {}),
|
|
32
42
|
});
|
|
43
|
+
/**
|
|
44
|
+
* An API call answered from outside its handler (res.apiRefusal on the
|
|
45
|
+
* server, onInvalidInput in the mock) as its envelope, once the config is
|
|
46
|
+
* known to be a refusal: a refusal message or one of the three flags, so a
|
|
47
|
+
* reader never takes it for the handler's output, and a message with a
|
|
48
|
+
* framework code or none and no data, since it answers outside any one
|
|
49
|
+
* endpoint's declared refusals. `writer` names the call in the error, and
|
|
50
|
+
* the logList is the config's own or the one given.
|
|
51
|
+
*/
|
|
52
|
+
export const plainRefusalEnvelope = (apiVersion, config, writer, logList) => {
|
|
53
|
+
const { refusal, versionExpired, sessionExpired, notAuthorized } = config;
|
|
54
|
+
if (refusal === undefined && !versionExpired && !sessionExpired && !notAuthorized) {
|
|
55
|
+
throw new Error(`Lambder: ${writer} needs a refusal message or one of the versionExpired, sessionExpired and notAuthorized flags. An API call answered from outside its handler is always a refusal.`);
|
|
56
|
+
}
|
|
57
|
+
if (refusal !== undefined) {
|
|
58
|
+
const message = refusalMessageOf(refusal);
|
|
59
|
+
if (message.code !== undefined && !isLambderRefusalCode(message.code)) {
|
|
60
|
+
throw new Error(`Lambder: ${writer} was given the code "${message.code}". It answers outside any one API's declared refusals, so its message carries a framework code or none.`);
|
|
61
|
+
}
|
|
62
|
+
if (message.data !== undefined) {
|
|
63
|
+
throw new Error(`Lambder: ${writer} was given a message with data. Only a code an API declares carries data, and this answer is outside any one API's declaration.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return refusalEnvelope(apiVersion, { ...config, logList: config.logList ?? logList });
|
|
67
|
+
};
|
|
33
68
|
/** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
|
|
34
69
|
export const envelopeAnswer = (envelope, options = {}) => {
|
|
35
70
|
const headers = { "Content-Type": [API_ANSWER_CONTENT_TYPE] };
|
|
@@ -41,13 +76,13 @@ export const envelopeAnswer = (envelope, options = {}) => {
|
|
|
41
76
|
return { statusCode: options.statusCode ?? 200, headers, body: JSON.stringify(envelope) };
|
|
42
77
|
};
|
|
43
78
|
/**
|
|
44
|
-
* A thrown refusal as an answer: its
|
|
79
|
+
* A thrown refusal as an answer: its refusal and flags on the envelope,
|
|
45
80
|
* its status (200 unless it set one) and its extra headers (Retry-After on a
|
|
46
81
|
* rate limit). The logList the call accumulated rides along, as it does on
|
|
47
82
|
* a success.
|
|
48
83
|
*/
|
|
49
|
-
export const refusalAnswer = (err, apiVersion, logList) => envelopeAnswer(
|
|
50
|
-
...(err.
|
|
84
|
+
export const refusalAnswer = (err, apiVersion, logList) => envelopeAnswer(refusalEnvelope(apiVersion, {
|
|
85
|
+
...(err.refusal !== undefined ? { refusal: err.refusal } : {}),
|
|
51
86
|
...(err.notAuthorized ? { notAuthorized: true } : {}),
|
|
52
87
|
...(err.sessionExpired ? { sessionExpired: true } : {}),
|
|
53
88
|
logList,
|
|
@@ -155,17 +190,17 @@ export const validationAnswer = (zodError, logList) => {
|
|
|
155
190
|
};
|
|
156
191
|
};
|
|
157
192
|
/** No API is registered under the requested name: a refusal, not a 404, so a typed caller reads it. */
|
|
158
|
-
export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(
|
|
159
|
-
|
|
193
|
+
export const apiNotFoundAnswer = (apiVersion, logList) => envelopeAnswer(refusalEnvelope(apiVersion, {
|
|
194
|
+
refusal: { type: "warning", code: LAMBDER_REFUSAL_CODES.apiNotFound, content: "API not found." },
|
|
160
195
|
logList,
|
|
161
196
|
}));
|
|
162
197
|
/** A session API called without a live session: the protocol's sessionExpired flag, which the caller clears its cookies on. */
|
|
163
|
-
export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(
|
|
198
|
+
export const sessionExpiredAnswer = (apiVersion, logList) => envelopeAnswer(refusalEnvelope(apiVersion, { sessionExpired: true, logList }));
|
|
164
199
|
/** The caller was built against another shape of the endpoint (the signature gate), or the app judged it stale: the protocol's versionExpired flag, which the caller reloads on. */
|
|
165
|
-
export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(
|
|
200
|
+
export const versionExpiredAnswer = (apiVersion) => envelopeAnswer(refusalEnvelope(apiVersion, { versionExpired: true }));
|
|
166
201
|
/** A compressed request payload that could not be restored: a 400 with the reason, never a crash. */
|
|
167
|
-
export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(
|
|
168
|
-
|
|
202
|
+
export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(refusalEnvelope(apiVersion, {
|
|
203
|
+
refusal: { type: "error", code: LAMBDER_REFUSAL_CODES.invalidRequestPayload, content: message },
|
|
169
204
|
}), { statusCode: 400 });
|
|
170
205
|
/**
|
|
171
206
|
* The last-resort answer when the call crashed and nothing else could
|
|
@@ -175,4 +210,4 @@ export const invalidPayloadAnswer = (apiVersion, message) => envelopeAnswer(buil
|
|
|
175
210
|
* with the call's logList) is passed only for a caller the app's
|
|
176
211
|
* `crashes.reveal` trusts.
|
|
177
212
|
*/
|
|
178
|
-
export const crashAnswer = (apiVersion, revealed) => envelopeAnswer(
|
|
213
|
+
export const crashAnswer = (apiVersion, revealed) => envelopeAnswer(refusalEnvelope(apiVersion, { refusal: "Internal server error.", ...revealed }), { statusCode: 500 });
|