lambder 8.3.1 → 9.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 +72 -0
- package/README.md +15 -24
- package/dist/api/LambderApiCallContext.d.ts +31 -1
- package/dist/api/LambderApiCallContext.js +8 -0
- package/dist/api/LambderApiDefinition.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.d.ts +1 -1
- package/dist/api/LambderApiEnvelope.js +3 -4
- package/dist/api/LambderApiIdempotency.js +5 -7
- package/dist/client/LambderCaller.d.ts +0 -4
- package/dist/client/LambderCaller.js +1 -9
- package/dist/core/Lambder.d.ts +50 -12
- package/dist/core/Lambder.js +47 -38
- package/dist/core/LambderContext.d.ts +9 -6
- package/dist/core/LambderContext.js +2 -1
- package/dist/core/LambderResolver.d.ts +6 -12
- package/dist/core/LambderResolver.js +2 -14
- package/dist/core/LambderResponseBuilder.d.ts +15 -70
- package/dist/core/LambderResponseBuilder.js +15 -99
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/invoke/LambderInvokeCaller.js +3 -4
- package/dist/mock/LambderMockApp.js +2 -3
- package/dist/mock/LambderMockCreateOptions.d.ts +2 -2
- package/dist/mock/LambderMockTypes.d.ts +2 -11
- package/dist/shared/util/LambderTypeUtilities.d.ts +18 -0
- package/dist/shared/wire/LambderAnswerHeaders.d.ts +3 -2
- package/dist/shared/wire/LambderAnswerHeaders.js +3 -2
- package/dist/shared/wire/LambderApiContract.d.ts +9 -14
- package/dist/shared/wire/LambderApiRefusal.d.ts +3 -4
- package/dist/shared/wire/LambderApiRefusal.js +3 -4
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,78 @@ 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
|
+
## [9.0.1] - 2026-09-27
|
|
13
|
+
|
|
14
|
+
A major that gives an API handler one shape. It takes its context and returns
|
|
15
|
+
its output; it says no with `refuse()`; it writes headers, cookies and log
|
|
16
|
+
entries through the context. The response builder stays with routes, hooks,
|
|
17
|
+
fallbacks and error handlers, which build HTTP responses. An API is typed data
|
|
18
|
+
in and typed data out, and a handler that has no response builder cannot
|
|
19
|
+
answer any other way, so the two ways a handler used to refuse (throwing, or
|
|
20
|
+
answering null beside a reason) are one, and the untyped side channels are
|
|
21
|
+
gone. A server handler and its mock twin now read alike.
|
|
22
|
+
|
|
23
|
+
### Changed (breaking)
|
|
24
|
+
|
|
25
|
+
- **An API handler returns its output.** `addApi` and `addSessionApi` take
|
|
26
|
+
`async (ctx) => output` instead of `async (ctx, res) => res.api(output)`.
|
|
27
|
+
The return is checked against the output schema's input form and parsed
|
|
28
|
+
through the schema before it is sent, as `res.api()`'s payload was:
|
|
29
|
+
undeclared fields are stripped, defaults filled, transforms run once, and
|
|
30
|
+
an output the schema rejects is answered as a crash
|
|
31
|
+
(`LambderApiOutputValidationError`). A literal in a returned object keeps
|
|
32
|
+
its type (`{ status: "open" }` is checked as `"open"`, not `string`), which
|
|
33
|
+
is why the handler's return is its own `const` type parameter; arrays in a
|
|
34
|
+
returned literal are read as readonly, which is all an answer needs.
|
|
35
|
+
- To move: drop the second parameter and return what was passed to
|
|
36
|
+
`res.api()`. An answer that was `res.api(null, { errorMessage })`,
|
|
37
|
+
`{ notAuthorized }` or `{ sessionExpired }` becomes `refuse(content, {
|
|
38
|
+
notAuthorized, sessionExpired, code, type })`. A handler that answered
|
|
39
|
+
null where the output does not allow null no longer compiles; it refuses
|
|
40
|
+
instead, or the output becomes nullable.
|
|
41
|
+
- **A refusal is never stored for replay.** Only an answer (a returned
|
|
42
|
+
output) is stored under an idempotency key; a refusal is thrown, the claim
|
|
43
|
+
is released, and a retry runs the handler again, which decides afresh. A
|
|
44
|
+
corrected request after a refusal goes through under the same key instead
|
|
45
|
+
of being refused as a reused key.
|
|
46
|
+
- **Response headers and cookies are written through the context.**
|
|
47
|
+
`ctx.setResponseHeader(key, value)`, `ctx.addResponseHeader(key, value)`,
|
|
48
|
+
`ctx.setCookie(name, value, options?)` and `ctx.clearCookie(name,
|
|
49
|
+
options?)` (the type `LambderResponseTools`) are on every context: routes,
|
|
50
|
+
API handlers, hooks, and mock handlers alike. `res.setHeader`,
|
|
51
|
+
`res.addHeader`, `res.setCookie` and `res.clearCookie` are removed. The
|
|
52
|
+
writers say "Response" because `ctx.header(name)` reads a request header.
|
|
53
|
+
- **The log channel is `ctx.logList`.** `res.logToApiResponse(entry)` is
|
|
54
|
+
removed; push the entry onto `ctx.logList`, which the mock's context has
|
|
55
|
+
always had.
|
|
56
|
+
- **Answer compression is declared per API.** `addApi` and `addSessionApi`
|
|
57
|
+
take `compress?: boolean | "auto"` beside the other options: "auto" (the
|
|
58
|
+
default) compresses for an accepting caller when the body is large enough,
|
|
59
|
+
false never (an answer carrying base64 bytes), true always. It covers
|
|
60
|
+
refusals and replayed answers too, and is a transport setting of the
|
|
61
|
+
server, not part of the contract. `res.apiBinary()` and the per-answer
|
|
62
|
+
response options of an API handler (status code, compression, cache
|
|
63
|
+
headers) are removed; a header goes through `ctx.setResponseHeader`.
|
|
64
|
+
- **`res.api()` writes an envelope by hand, for code outside an API
|
|
65
|
+
handler.** A hook, the input validation handler or a global error handler
|
|
66
|
+
answering an API call still uses it; the payload goes out as given. The
|
|
67
|
+
typed overloads and `LambderResolver`'s output type parameter are removed,
|
|
68
|
+
with the types `LambderResolverApiMethod` and `LambderApiNullAnswerConfig`.
|
|
69
|
+
A binary or file answer is a route's job.
|
|
70
|
+
|
|
71
|
+
### Removed
|
|
72
|
+
|
|
73
|
+
- **The `message` envelope channel.** The untyped `message` field of the
|
|
74
|
+
envelope and of `LambderApiResponseConfig`, `LambderCaller`'s
|
|
75
|
+
`messageHandler` option and per-call override, and the mock's
|
|
76
|
+
`ctx.envelope`. What a success says belongs in the output schema, where it
|
|
77
|
+
is typed and parsed.
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
- `LambderResponseTools`, the type of the four response writers every
|
|
82
|
+
context carries, exported from `lambder`.
|
|
83
|
+
|
|
12
84
|
## [8.3.1] - 2026-09-27
|
|
13
85
|
|
|
14
86
|
Six additions, each a thing an app otherwise writes for itself once per kind
|
package/README.md
CHANGED
|
@@ -17,15 +17,17 @@ const lambder = initLambder<SessionData>().create({
|
|
|
17
17
|
}).addApi("getCompany", {
|
|
18
18
|
input: z.object({ slug: z.string() }),
|
|
19
19
|
output: z.object({ id: z.string(), name: z.string() }),
|
|
20
|
-
}, async ({ apiPayload }
|
|
20
|
+
}, async ({ apiPayload }) => await loadCompany(apiPayload.slug));
|
|
21
21
|
|
|
22
22
|
export type ApiContractType = typeof lambder.ApiContract;
|
|
23
23
|
export const handler = lambder.getHandler();
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
`
|
|
26
|
+
A handler returns its output, which is parsed through the output schema before
|
|
27
|
+
it is sent, and says no by throwing `refuse()`. Registration chains onto the
|
|
28
|
+
creation call: every `addApi` returns an instance carrying the contract so
|
|
29
|
+
far, so the whole backend is one declaration and `lambder.ApiContract` is the
|
|
30
|
+
accumulated type.
|
|
29
31
|
|
|
30
32
|
The frontend imports that contract type and gets autocomplete, typed payloads
|
|
31
33
|
and typed results with no hand-written client:
|
|
@@ -153,7 +155,7 @@ guide that matches what you are building. The full index lives in
|
|
|
153
155
|
| [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
|
|
154
156
|
| [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, crash reporting, and non-HTTP invocations |
|
|
155
157
|
| [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
|
|
156
|
-
| [Responses](./docs/responses.md) | The render context
|
|
158
|
+
| [Responses](./docs/responses.md) | The render context and its response tools (headers, cookies, log entries), the response builder routes and hooks use, compression, ETag and the size cap |
|
|
157
159
|
| [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
|
|
158
160
|
| [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
|
|
159
161
|
| [Calling another lambda](./docs/invoke.md) | `LambderInvokeCaller`: invoking a Lambder app in another function, its contract, failures and compression |
|
|
@@ -185,25 +187,14 @@ framework:
|
|
|
185
187
|
## Versioning and changes
|
|
186
188
|
|
|
187
189
|
Released versions and what each one changed are in
|
|
188
|
-
[CHANGELOG.md](./CHANGELOG.md). The current major is
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
handlers whose payload does not match their output schema, code that decoded
|
|
197
|
-
`ctx.path` itself, string routes that match case-sensitively, compression
|
|
198
|
-
behind a REST API, two more IAM actions (`UpdateItem` on the session table,
|
|
199
|
-
`GetItem` on the rate-limit table), custom-key limits charged after the
|
|
200
|
-
guards, idempotency keys bound to the request they were first sent with,
|
|
201
|
-
`errorMessage` always being an object, `credentials: true` needing named
|
|
202
|
-
origins, template slots and `html` interpolations refused or checked in
|
|
203
|
-
more attribute positions, `refreshSessionData()` throwing where it answered
|
|
204
|
-
null, and `z.ZodType<T>` annotations leaving a field unchecked. Every live
|
|
205
|
-
session is signed out once by the
|
|
206
|
-
upgrade. An app still on v6 goes through the 7.0.0 entry first.
|
|
190
|
+
[CHANGELOG.md](./CHANGELOG.md). The current major is v9, which gives an API
|
|
191
|
+
handler one shape: it takes its context and returns its output, says no with
|
|
192
|
+
`refuse()`, and writes headers, cookies and log entries through the context,
|
|
193
|
+
while the response builder stays with routes, hooks and error handlers. Every
|
|
194
|
+
break and what to do about it is in the 9.0.1 entry, and the compiler finds
|
|
195
|
+
most of them. An app still on v7 goes through the 8.0.2 entry first, which
|
|
196
|
+
names the breaks of v8 the compiler cannot find, and one on v6 through the
|
|
197
|
+
7.0.0 entry before that.
|
|
207
198
|
|
|
208
199
|
## Contributing
|
|
209
200
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LambderSessionRecord } from "../shared/contracts/LambderSessionStore.js";
|
|
2
2
|
import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
3
|
+
import { type LambderCookieOptions, type LambderClearCookieOptions } from "../shared/wire/LambderCookie.js";
|
|
3
4
|
/**
|
|
4
5
|
* The context the API core needs from whoever runs it. The server's render
|
|
5
6
|
* context and the mock runtime's handler context both extend it; the
|
|
@@ -12,7 +13,7 @@ import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
|
12
13
|
* - `responseHeaders` collects headers written during the call, applied
|
|
13
14
|
* onto the answer by the pipeline.
|
|
14
15
|
* - `logList` collects entries for the envelope's logList channel
|
|
15
|
-
* (`
|
|
16
|
+
* (`ctx.logList.push(entry)` from a handler).
|
|
16
17
|
*/
|
|
17
18
|
export type LambderApiCallContext<TSessionData = any> = {
|
|
18
19
|
session: LambderSessionRecord<TSessionData> | null;
|
|
@@ -22,6 +23,35 @@ export type LambderApiCallContext<TSessionData = any> = {
|
|
|
22
23
|
};
|
|
23
24
|
/** A fresh call context: no session, no guard data, nothing pending. */
|
|
24
25
|
export declare const createApiCallContext: <TSessionData = any>() => LambderApiCallContext<TSessionData>;
|
|
26
|
+
/**
|
|
27
|
+
* What a handler writes onto its answer beside the body: headers and cookies,
|
|
28
|
+
* collected on `responseHeaders` and applied to whatever answer the request
|
|
29
|
+
* ends with. On every context a handler receives, the server's and the
|
|
30
|
+
* mock's alike, since an API handler returns its output and has no response
|
|
31
|
+
* builder to write them on.
|
|
32
|
+
*/
|
|
33
|
+
export type LambderResponseTools = {
|
|
34
|
+
/** Replaces a response header. */
|
|
35
|
+
setResponseHeader(key: string, value: string | string[]): void;
|
|
36
|
+
/** Appends a response header value (repeatable for one key). */
|
|
37
|
+
addResponseHeader(key: string, value: string): void;
|
|
38
|
+
/**
|
|
39
|
+
* Adds a Set-Cookie header. A function-form `domain` is resolved against
|
|
40
|
+
* the request host. Defaults: Path=/, SameSite=Lax, Secure, not HttpOnly,
|
|
41
|
+
* browser-session lifetime.
|
|
42
|
+
*/
|
|
43
|
+
setCookie(name: string, value: string, options?: LambderCookieOptions): void;
|
|
44
|
+
/**
|
|
45
|
+
* Adds a Set-Cookie header that deletes the cookie. Pass the `domain` and
|
|
46
|
+
* `path` it was set with: a cookie's identity is (name, domain, path), and
|
|
47
|
+
* a deletion under another scope deletes nothing.
|
|
48
|
+
*/
|
|
49
|
+
clearCookie(name: string, options?: LambderClearCookieOptions): void;
|
|
50
|
+
};
|
|
51
|
+
/** The response tools of one context, writing into its own responseHeaders; `host` resolves a function-form cookie domain. */
|
|
52
|
+
export declare const responseToolsOf: (ctx: {
|
|
53
|
+
responseHeaders: LambderAnswerHeaders;
|
|
54
|
+
}, host: string) => LambderResponseTools;
|
|
25
55
|
/**
|
|
26
56
|
* Binds an adapter's tools onto one call context: `getters` run when read
|
|
27
57
|
* (`ctx.sessionController` is built over the object it was read from),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
2
|
+
import { serializeCookie, serializeClearCookie } from "../shared/wire/LambderCookie.js";
|
|
2
3
|
/** A fresh call context: no session, no guard data, nothing pending. */
|
|
3
4
|
export const createApiCallContext = () => ({
|
|
4
5
|
session: null,
|
|
@@ -10,6 +11,13 @@ export const createApiCallContext = () => ({
|
|
|
10
11
|
responseHeaders: new LambderAnswerHeaders(),
|
|
11
12
|
logList: [],
|
|
12
13
|
});
|
|
14
|
+
/** The response tools of one context, writing into its own responseHeaders; `host` resolves a function-form cookie domain. */
|
|
15
|
+
export const responseToolsOf = (ctx, host) => ({
|
|
16
|
+
setResponseHeader: (key, value) => { ctx.responseHeaders.set(key, value); },
|
|
17
|
+
addResponseHeader: (key, value) => { ctx.responseHeaders.add(key, value); },
|
|
18
|
+
setCookie: (name, value, options) => { ctx.responseHeaders.add("Set-Cookie", serializeCookie(name, value, options, host)); },
|
|
19
|
+
clearCookie: (name, options) => { ctx.responseHeaders.add("Set-Cookie", serializeClearCookie(name, options, host)); },
|
|
20
|
+
});
|
|
13
21
|
/**
|
|
14
22
|
* Binds an adapter's tools onto one call context: `getters` run when read
|
|
15
23
|
* (`ctx.sessionController` is built over the object it was read from),
|
|
@@ -8,8 +8,8 @@ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRate
|
|
|
8
8
|
* because the mock has none; when input is present, validation runs and the
|
|
9
9
|
* handler sees the parsed payload. Output is part of the endpoint's
|
|
10
10
|
* signature (apiSignatureOf), which is what a client's build is checked
|
|
11
|
-
* against; the server
|
|
12
|
-
*
|
|
11
|
+
* against; the server also parses every output a handler returns through it
|
|
12
|
+
* before it is sent.
|
|
13
13
|
*/
|
|
14
14
|
export type LambderApiDefinition = {
|
|
15
15
|
name: string;
|
|
@@ -13,7 +13,7 @@ export type LambderApiEnvelopeConfig = LambderApiResponseConfig & {
|
|
|
13
13
|
* plain success is `{ apiVersion, payload }` and nothing else; an empty
|
|
14
14
|
* logList is omitted.
|
|
15
15
|
*/
|
|
16
|
-
export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized,
|
|
16
|
+
export declare const buildApiEnvelope: <T>(apiVersion: string | null | undefined, payload: T | null, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, }?: LambderApiEnvelopeConfig) => LambderApiEnvelopeBody<T>;
|
|
17
17
|
/** An envelope as an answer: JSON body, JSON content type, the status and headers given (200 and none by default). */
|
|
18
18
|
export declare const envelopeAnswer: (envelope: LambderApiEnvelopeBody<unknown>, options?: {
|
|
19
19
|
statusCode?: number;
|
|
@@ -4,8 +4,8 @@ import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
|
|
|
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
|
-
* malformed compressed payload, and the last-resort crash. The server's
|
|
8
|
-
* res.api(), the mock runtime and the pipeline all build through these
|
|
7
|
+
* malformed compressed payload, and the last-resort crash. The server's API
|
|
8
|
+
* path and res.api(), 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
|
*/
|
|
@@ -15,7 +15,7 @@ export const API_ANSWER_CONTENT_TYPE = "application/json; charset=utf-8";
|
|
|
15
15
|
* plain success is `{ apiVersion, payload }` and nothing else; an empty
|
|
16
16
|
* logList is omitted.
|
|
17
17
|
*/
|
|
18
|
-
export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized,
|
|
18
|
+
export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionExpired, notAuthorized, errorMessage, logList, crash, } = {}) => ({
|
|
19
19
|
apiVersion: apiVersion ?? null,
|
|
20
20
|
payload,
|
|
21
21
|
...(versionExpired ? { versionExpired } : {}),
|
|
@@ -26,7 +26,6 @@ export const buildApiEnvelope = (apiVersion, payload, { versionExpired, sessionE
|
|
|
26
26
|
// flags above are booleans, where false and absent mean the same. An
|
|
27
27
|
// errorMessage goes out as a message object whatever form it was written
|
|
28
28
|
// in, so every reader meets one shape.
|
|
29
|
-
...(message !== undefined ? { message } : {}),
|
|
30
29
|
...(errorMessage !== undefined ? { errorMessage: refusalMessageOf(errorMessage) } : {}),
|
|
31
30
|
...(crash !== undefined ? { crash } : {}),
|
|
32
31
|
...(logList?.length ? { logList } : {}),
|
|
@@ -386,13 +386,11 @@ export class LambderApiIdempotencyEngine {
|
|
|
386
386
|
}
|
|
387
387
|
// A crash or a thrown refusal (LambderApiRefusal, refuse())
|
|
388
388
|
// releases the claim so a retry retries. The rule is deliberate:
|
|
389
|
-
// ANSWERS
|
|
390
|
-
//
|
|
391
|
-
// on retry and the handler decides afresh.
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
// since a cleanup error in its place would hide why the call
|
|
395
|
-
// failed.
|
|
389
|
+
// ANSWERS (the output a handler returned) are stored and
|
|
390
|
+
// replayed; EXCEPTIONS are not, so a thrown refusal re-executes
|
|
391
|
+
// on retry and the handler decides afresh. The handler's own
|
|
392
|
+
// error is what gets rethrown, since a cleanup error in its place
|
|
393
|
+
// would hide why the call failed.
|
|
396
394
|
try {
|
|
397
395
|
await store.abandon(scopeKey, ownerToken);
|
|
398
396
|
}
|
|
@@ -29,7 +29,6 @@ type FetchEndEventHandler = (params: {
|
|
|
29
29
|
}) => void | Promise<void>;
|
|
30
30
|
type ErrorHandler = (err: Error) => void | Promise<void>;
|
|
31
31
|
type ValidationErrorHandler = (zodError: LambderValidationError) => (void | false) | Promise<(void | false)>;
|
|
32
|
-
type MessageHandler = (message: LambderAppRefusalMessage | string) => void | Promise<void>;
|
|
33
32
|
/** Handed the refusal as its message object, a plain-string errorMessage having been read as one (refusalMessageOf). */
|
|
34
33
|
type ErrorMessageHandler = (message: LambderAppRefusalMessage) => void | Promise<void>;
|
|
35
34
|
/** The logListHandler option: an answer's logList, success or failure, when it has entries. The invoke caller's onLogList, for a browser. */
|
|
@@ -41,7 +40,6 @@ export type LambderLogListHandler = (apiName: string, logList: unknown[]) => voi
|
|
|
41
40
|
export type LambderCallOptions = LambderSharedCallOptions & {
|
|
42
41
|
versionExpiredHandler?: NotifyHandler;
|
|
43
42
|
sessionExpiredHandler?: NotifyHandler;
|
|
44
|
-
messageHandler?: MessageHandler;
|
|
45
43
|
errorMessageHandler?: ErrorMessageHandler;
|
|
46
44
|
apiInputValidationErrorHandler?: ValidationErrorHandler;
|
|
47
45
|
notAuthorizedHandler?: NotifyHandler;
|
|
@@ -73,7 +71,6 @@ type LambderCallerBaseOptions = {
|
|
|
73
71
|
timeoutMs?: number;
|
|
74
72
|
versionExpiredHandler?: NotifyHandler;
|
|
75
73
|
sessionExpiredHandler?: NotifyHandler;
|
|
76
|
-
messageHandler?: MessageHandler;
|
|
77
74
|
errorMessageHandler?: ErrorMessageHandler;
|
|
78
75
|
notAuthorizedHandler?: NotifyHandler;
|
|
79
76
|
errorHandler?: ErrorHandler;
|
|
@@ -119,7 +116,6 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
|
|
|
119
116
|
get isLoading(): boolean;
|
|
120
117
|
private versionExpiredHandler?;
|
|
121
118
|
private sessionExpiredHandler?;
|
|
122
|
-
private messageHandler?;
|
|
123
119
|
private errorMessageHandler?;
|
|
124
120
|
private notAuthorizedHandler?;
|
|
125
121
|
private errorHandler?;
|
|
@@ -33,7 +33,6 @@ export default class LambderCaller {
|
|
|
33
33
|
get isLoading() { return this.fetchTrackerList.length > 0; }
|
|
34
34
|
versionExpiredHandler;
|
|
35
35
|
sessionExpiredHandler;
|
|
36
|
-
messageHandler;
|
|
37
36
|
errorMessageHandler;
|
|
38
37
|
notAuthorizedHandler;
|
|
39
38
|
errorHandler;
|
|
@@ -50,7 +49,7 @@ export default class LambderCaller {
|
|
|
50
49
|
constructor(options) {
|
|
51
50
|
// The conditional provider option is resolved per instantiation;
|
|
52
51
|
// inside the class it is read through the plain shape.
|
|
53
|
-
const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler,
|
|
52
|
+
const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, errorMessageHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
|
|
54
53
|
this.apiPath = apiPath;
|
|
55
54
|
this.apiVersion = apiVersion;
|
|
56
55
|
this.apiSignatures = apiSignatures;
|
|
@@ -61,7 +60,6 @@ export default class LambderCaller {
|
|
|
61
60
|
this.transport = transport ?? lambderFetchTransport({ cors: isCorsEnabled });
|
|
62
61
|
this.versionExpiredHandler = versionExpiredHandler;
|
|
63
62
|
this.sessionExpiredHandler = sessionExpiredHandler;
|
|
64
|
-
this.messageHandler = messageHandler;
|
|
65
63
|
this.errorMessageHandler = errorMessageHandler;
|
|
66
64
|
this.notAuthorizedHandler = notAuthorizedHandler;
|
|
67
65
|
this.errorHandler = errorHandler;
|
|
@@ -100,7 +98,6 @@ export default class LambderCaller {
|
|
|
100
98
|
// Per-call overrides win over the constructor handlers.
|
|
101
99
|
const versionExpiredHandler = options?.versionExpiredHandler ?? this.versionExpiredHandler;
|
|
102
100
|
const sessionExpiredHandler = options?.sessionExpiredHandler ?? this.sessionExpiredHandler;
|
|
103
|
-
const messageHandler = options?.messageHandler ?? this.messageHandler;
|
|
104
101
|
const errorMessageHandler = options?.errorMessageHandler ?? this.errorMessageHandler;
|
|
105
102
|
const notAuthorizedHandler = options?.notAuthorizedHandler ?? this.notAuthorizedHandler;
|
|
106
103
|
const errorHandler = options?.errorHandler ?? this.errorHandler;
|
|
@@ -328,11 +325,6 @@ export default class LambderCaller {
|
|
|
328
325
|
}
|
|
329
326
|
return outcome;
|
|
330
327
|
}
|
|
331
|
-
// Presence, not truthiness: the envelope keeps a message an app
|
|
332
|
-
// spelled out as the empty string, so the handler runs for it.
|
|
333
|
-
if (data.message !== undefined && messageHandler) {
|
|
334
|
-
await messageHandler(data.message);
|
|
335
|
-
}
|
|
336
328
|
if (!outcome.ok && outcome.reason === 'errorMessage') {
|
|
337
329
|
if (errorMessageHandler && outcome.errorMessage !== undefined) {
|
|
338
330
|
await errorMessageHandler(outcome.errorMessage);
|
package/dist/core/Lambder.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { LambderApiRateLimitPolicyConfig, LambderRateLimitOption } from "..
|
|
|
21
21
|
import type { LambderApiIdempotencyConfig } from "../api/LambderApiIdempotency.js";
|
|
22
22
|
import type { LambderContractEntry, LambderJsonOutputOf, LambderMergeContract } from "../shared/wire/LambderApiContract.js";
|
|
23
23
|
import { type LambderHttpEvent, type LambderRenderContext, type LambderSessionRenderContext } from "./LambderContext.js";
|
|
24
|
-
import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
24
|
+
import type { LambderReadonlyDeep, MaybePromise } from "../shared/util/LambderTypeUtilities.js";
|
|
25
25
|
import { type LambderRouteHandler, type LambderInputValidationHandler, type LambderFallbackHandler, type LambderGlobalErrorHandler, type LambderAfterRenderHook, type LambderBeforeRenderHook, type LambderFallbackHook, type LambderActionTools, type LambderCreateOptions, type LambderGivenOption, type LambderHandler, type LambderNestedOptionChecks, type LambderNoExtraKeys, type LambderRequirableGuardsField, type LambderSessionEnabledInstance, type LambderSessionRouteHandler } from "./LambderCreateOptions.js";
|
|
26
26
|
/** Everything `lambder/testing` may put under a built instance: the pipeline's stores, and the source its files are read from. */
|
|
27
27
|
export type LambderInstanceBackends = LambderPipelineBackends & {
|
|
@@ -155,7 +155,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
155
155
|
addRoute(condition: RegExp | LambderRouteConditionFn | LambderRouteMatcher, actionFn: LambderRouteHandler): this;
|
|
156
156
|
addSessionRoute<TPath extends LambderRoutePath>(condition: TPath, actionFn: ((ctx: LambderSessionRenderContext<any, TSessionData, LambderPathParamsOf<TPath>, {}, _TRateLimitPolicies>, resolver: LambderResolver) => MaybePromise<LambderResponse>) & LambderSessionEnabledInstance<_TSessionsEnabled>): this;
|
|
157
157
|
addSessionRoute(condition: RegExp | LambderRouteConditionFn | LambderRouteMatcher, actionFn: LambderSessionRouteHandler<TSessionData> & LambderSessionEnabledInstance<_TSessionsEnabled>): this;
|
|
158
|
-
addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, false> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
|
|
158
|
+
addApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, false> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, false> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
|
|
159
159
|
input: TInput;
|
|
160
160
|
output: TOutput;
|
|
161
161
|
} & {
|
|
@@ -163,8 +163,18 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
163
163
|
rateLimit?: TRateOpt;
|
|
164
164
|
/** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
|
|
165
165
|
idempotency?: _TIdempotencyEnabled extends true ? TIdempotencyOpt : never;
|
|
166
|
-
|
|
167
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Whether this API's answers are compressed for a caller that accepts it: "auto" (the default) when the
|
|
168
|
+
* body is large enough to gain, false never, true always. false suits an answer of base64 bytes: once
|
|
169
|
+
* compressed it leaves the function base64-encoded again, so it is no smaller under Lambda's response
|
|
170
|
+
* cap or to a lambda caller, and a browser gets it only about a quarter smaller for the time spent at
|
|
171
|
+
* both ends. A transport setting of this server's, not part of the API's contract.
|
|
172
|
+
*/
|
|
173
|
+
compress?: boolean | "auto";
|
|
174
|
+
} & LambderRequirableGuardsField<_TPublicGuardsRequired, TGuardsOpt>,
|
|
175
|
+
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
|
|
176
|
+
handler: (ctx: LambderRenderContext<z.infer<TInput>, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, TSessionData, _TRateLimitPolicies>) => MaybePromise<TAnswer>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOutputOf<z.output<TOutput>>, "public", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled>;
|
|
177
|
+
addSessionApi<TName extends string, TInput extends z.ZodType, TOutput extends z.ZodType, const TAnswer extends LambderReadonlyDeep<z.input<TOutput>>, const TRateOpt extends LambderRateLimitOption<_TRateLimitPolicies, z.input<TInput>, true> = never, const TGuardsOpt extends LambderGuardsOption<_TGuards, z.input<TInput>, true> = never, const TIdempotencyOpt extends LambderApiIdempotencyOption = never>(name: TName, schema: {
|
|
168
178
|
input: TInput;
|
|
169
179
|
output: TOutput;
|
|
170
180
|
} & {
|
|
@@ -172,7 +182,17 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
172
182
|
rateLimit?: TRateOpt;
|
|
173
183
|
/** Replay-protect this API per client idempotencyKey. Requires the idempotency option at creation. */
|
|
174
184
|
idempotency?: _TIdempotencyEnabled extends true ? TIdempotencyOpt : never;
|
|
175
|
-
|
|
185
|
+
/**
|
|
186
|
+
* Whether this API's answers are compressed for a caller that accepts it: "auto" (the default) when the
|
|
187
|
+
* body is large enough to gain, false never, true always. false suits an answer of base64 bytes: once
|
|
188
|
+
* compressed it leaves the function base64-encoded again, so it is no smaller under Lambda's response
|
|
189
|
+
* cap or to a lambda caller, and a browser gets it only about a quarter smaller for the time spent at
|
|
190
|
+
* both ends. A transport setting of this server's, not part of the API's contract.
|
|
191
|
+
*/
|
|
192
|
+
compress?: boolean | "auto";
|
|
193
|
+
} & LambderRequirableGuardsField<_TSessionGuardsRequired, TGuardsOpt> & LambderSessionEnabledInstance<_TSessionsEnabled>,
|
|
194
|
+
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
|
|
195
|
+
handler: (ctx: LambderSessionRenderContext<z.infer<TInput>, TSessionData, Record<string, string>, LambderGuardDataOf<_TGuards, TGuardsOpt>, _TRateLimitPolicies>) => MaybePromise<TAnswer>): Lambder<TSessionData, LambderMergeContract<_TContract, TName, LambderContractEntry<z.input<TInput>, LambderJsonOutputOf<z.output<TOutput>>, "session", LambderGuardInputsOf<_TGuards, TGuardsOpt>, TGuardsOpt, TRateOpt, TIdempotencyOpt>>, _TRateLimitPolicies, _TGuards, _TIdempotencyEnabled, _TSessionGuardsRequired, _TPublicGuardsRequired, _TSessionsEnabled>;
|
|
176
196
|
/**
|
|
177
197
|
* What registering an API is, for addApi and addSessionApi alike: the
|
|
178
198
|
* checks that can refuse it, then its definition recorded (what
|
|
@@ -267,7 +287,7 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
267
287
|
* and never moves when registrations are reordered.
|
|
268
288
|
*/
|
|
269
289
|
apiOptionEntries(): LambderApiOptionEntries;
|
|
270
|
-
getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder
|
|
290
|
+
getResponseBuilder(ctx?: LambderRenderContext): LambderResponseBuilder;
|
|
271
291
|
private getResolver;
|
|
272
292
|
getHandler(): LambderHandler;
|
|
273
293
|
/** True when the Lambda event is an API Gateway HTTP event (REST API v1 or HTTP API / Function URL v2). */
|
|
@@ -365,12 +385,30 @@ export default class Lambder<TSessionData = any, _TContract extends Record<strin
|
|
|
365
385
|
private inputValidationRefusal;
|
|
366
386
|
/**
|
|
367
387
|
* One API call through the core: the pipeline runs the protocol steps and
|
|
368
|
-
* calls back for the handler, whose
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
388
|
+
* calls back for the handler, whose returned output becomes the answer
|
|
389
|
+
* the pipeline stores and hands back. The context is the pipeline's
|
|
390
|
+
* context, so a session it fetched is on ctx.session and the validated
|
|
391
|
+
* payload is on ctx.apiPayload when the handler runs.
|
|
392
|
+
*
|
|
393
|
+
* The output goes out as the API's schema declares it. The type system
|
|
394
|
+
* accepts a value that carries more than the schema (a row read straight
|
|
395
|
+
* from a table is assignable to a narrower object type), and without the
|
|
396
|
+
* parse the extra fields, a password hash included, would reach the
|
|
397
|
+
* client. zod strips what the schema does not declare, fills its defaults
|
|
398
|
+
* and applies its transforms, so the wire and the idempotency store only
|
|
399
|
+
* see the declared shape. The handler returns the schema's input form, so
|
|
400
|
+
* a transform runs exactly once.
|
|
401
|
+
*
|
|
402
|
+
* An output the schema rejects is the handler breaking its contract,
|
|
403
|
+
* answered as a crash rather than sent (LambderApiOutputValidationError,
|
|
404
|
+
* which an idempotency key records as its answer, since the handler has
|
|
405
|
+
* already run). The parse is synchronous, so an output schema cannot be
|
|
406
|
+
* async: zod throws from a synchronous parse that meets an async
|
|
407
|
+
* refinement or transform, and a transform may throw of its own accord.
|
|
408
|
+
* Either throw becomes the same error, carrying what was thrown as its
|
|
409
|
+
* cause. Left to escape as it is, it would read as the handler crashing
|
|
410
|
+
* before its answer: the idempotency engine would release the key's claim
|
|
411
|
+
* and every retry would run the operation again.
|
|
374
412
|
*/
|
|
375
413
|
private runApi;
|
|
376
414
|
/** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
|
package/dist/core/Lambder.js
CHANGED
|
@@ -16,7 +16,8 @@ import { LAMBDER_BACKEND_SWAP, LAMBDER_CRASH_WATCH } from "../shared/util/Lambde
|
|
|
16
16
|
import { apiSignatureOf } from "../api/LambderApiSignature.js";
|
|
17
17
|
import { apiNameKeyOf } from "../shared/wire/LambderApiSignature.js";
|
|
18
18
|
import { assertPlainData } from "../shared/util/assertPlainData.js";
|
|
19
|
-
import { apiNotFoundAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
19
|
+
import { apiNotFoundAnswer, buildApiEnvelope, envelopeAnswer, refusalAnswer, sessionExpiredAnswer, } from "../api/LambderApiEnvelope.js";
|
|
20
|
+
import { LambderApiOutputValidationError } from "../api/LambderApiOutputValidationError.js";
|
|
20
21
|
import { bindContextTools, createContext, isV2HttpEvent, } from "./LambderContext.js";
|
|
21
22
|
import { COMPRESSED_PAYLOAD_GZ_FIELD, COMPRESSED_PAYLOAD_BR_FIELD, COMPRESSED_PAYLOAD_BYTES_FIELD } from "../shared/wire/LambderRequestPayload.js";
|
|
22
23
|
import { coerceToError } from "../shared/wire/LambderCrashDetail.js";
|
|
@@ -263,12 +264,16 @@ export default class Lambder {
|
|
|
263
264
|
return this;
|
|
264
265
|
}
|
|
265
266
|
// Typed API with Zod
|
|
266
|
-
addApi(name, schema,
|
|
267
|
+
addApi(name, schema,
|
|
268
|
+
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
|
|
269
|
+
handler) {
|
|
267
270
|
this.registerApi(name, "public", schema, handler);
|
|
268
271
|
return this;
|
|
269
272
|
}
|
|
270
273
|
// Typed Session API with Zod
|
|
271
|
-
addSessionApi(name, schema,
|
|
274
|
+
addSessionApi(name, schema,
|
|
275
|
+
/** Answers the call by returning its output (parsed through `output` before it is sent), or refuses it with refuse(). */
|
|
276
|
+
handler) {
|
|
272
277
|
this.registerApi(name, "session", schema, handler);
|
|
273
278
|
return this;
|
|
274
279
|
}
|
|
@@ -303,7 +308,7 @@ export default class Lambder {
|
|
|
303
308
|
this.apiDefinitions.set(name, definition);
|
|
304
309
|
this.actionList.push({
|
|
305
310
|
match: (ctx) => ctx.apiName === name ? {} : false,
|
|
306
|
-
actionFn: (ctx) => this.runApi(ctx, definition, handler),
|
|
311
|
+
actionFn: (ctx) => this.runApi(ctx, definition, schema.output, schema.compress ?? "auto", handler),
|
|
307
312
|
});
|
|
308
313
|
}
|
|
309
314
|
addHook(hookEvent, hookFn, priority = 0) {
|
|
@@ -700,8 +705,8 @@ export default class Lambder {
|
|
|
700
705
|
catch (err) {
|
|
701
706
|
response = (await this.answerThrown(err, ctx, resolver)).copy();
|
|
702
707
|
}
|
|
703
|
-
// Only what the hooks themselves wrote (
|
|
704
|
-
// hook) is left to apply, which leaves their overrides standing.
|
|
708
|
+
// Only what the hooks themselves wrote (ctx.setResponseHeader
|
|
709
|
+
// inside a hook) is left to apply, which leaves their overrides standing.
|
|
705
710
|
// A hook that answered with a different response takes the whole
|
|
706
711
|
// set instead: headers belong to the call, not to the response
|
|
707
712
|
// that first carried them, so the call's session cookie must
|
|
@@ -883,48 +888,52 @@ export default class Lambder {
|
|
|
883
888
|
}
|
|
884
889
|
/**
|
|
885
890
|
* One API call through the core: the pipeline runs the protocol steps and
|
|
886
|
-
* calls back for the handler, whose
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
890
|
-
*
|
|
891
|
-
*
|
|
891
|
+
* calls back for the handler, whose returned output becomes the answer
|
|
892
|
+
* the pipeline stores and hands back. The context is the pipeline's
|
|
893
|
+
* context, so a session it fetched is on ctx.session and the validated
|
|
894
|
+
* payload is on ctx.apiPayload when the handler runs.
|
|
895
|
+
*
|
|
896
|
+
* The output goes out as the API's schema declares it. The type system
|
|
897
|
+
* accepts a value that carries more than the schema (a row read straight
|
|
898
|
+
* from a table is assignable to a narrower object type), and without the
|
|
899
|
+
* parse the extra fields, a password hash included, would reach the
|
|
900
|
+
* client. zod strips what the schema does not declare, fills its defaults
|
|
901
|
+
* and applies its transforms, so the wire and the idempotency store only
|
|
902
|
+
* see the declared shape. The handler returns the schema's input form, so
|
|
903
|
+
* a transform runs exactly once.
|
|
904
|
+
*
|
|
905
|
+
* An output the schema rejects is the handler breaking its contract,
|
|
906
|
+
* answered as a crash rather than sent (LambderApiOutputValidationError,
|
|
907
|
+
* which an idempotency key records as its answer, since the handler has
|
|
908
|
+
* already run). The parse is synchronous, so an output schema cannot be
|
|
909
|
+
* async: zod throws from a synchronous parse that meets an async
|
|
910
|
+
* refinement or transform, and a transform may throw of its own accord.
|
|
911
|
+
* Either throw becomes the same error, carrying what was thrown as its
|
|
912
|
+
* cause. Left to escape as it is, it would read as the handler crashing
|
|
913
|
+
* before its answer: the idempotency engine would release the key's claim
|
|
914
|
+
* and every retry would run the operation again.
|
|
892
915
|
*/
|
|
893
|
-
async runApi(ctx, definition, handler) {
|
|
916
|
+
async runApi(ctx, definition, output, compress, handler) {
|
|
894
917
|
const request = ctx.api;
|
|
895
918
|
if (!request)
|
|
896
919
|
throw new Error(`Lambder: API "${definition.name}" was matched by a request that is not an API call.`);
|
|
897
|
-
const resolver = new LambderResolver({ files: this.files, apiVersion: this.apiVersion, ctx, apiOutput: definition.output });
|
|
898
|
-
// What the handler produced, in both forms: the answer went to the
|
|
899
|
-
// pipeline, and the response is kept so it can carry on unchanged.
|
|
900
|
-
const handled = { output: null };
|
|
901
920
|
const { answer } = await this.pipeline.run(request, ctx, definition, async () => {
|
|
902
921
|
ctx.apiPayload = request.payload;
|
|
903
|
-
|
|
922
|
+
const returned = await handler(ctx);
|
|
923
|
+
let parsed;
|
|
904
924
|
try {
|
|
905
|
-
|
|
925
|
+
parsed = output.safeParse(returned);
|
|
906
926
|
}
|
|
907
|
-
catch (
|
|
908
|
-
|
|
909
|
-
// answer like a returned one, stored and replayed alike.
|
|
910
|
-
if (err instanceof LambderResponse)
|
|
911
|
-
response = err;
|
|
912
|
-
else
|
|
913
|
-
throw err;
|
|
927
|
+
catch (thrown) {
|
|
928
|
+
throw new LambderApiOutputValidationError(definition.name, { thrown });
|
|
914
929
|
}
|
|
915
|
-
|
|
916
|
-
|
|
930
|
+
if (!parsed.success)
|
|
931
|
+
throw new LambderApiOutputValidationError(definition.name, { zodError: parsed.error });
|
|
932
|
+
return envelopeAnswer(buildApiEnvelope(this.apiVersion, parsed.data, { logList: ctx.logList }));
|
|
917
933
|
});
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
// persists), and a response rebuilt from it would hand finalization a
|
|
922
|
-
// base64 string it must pass through uncompressed. Identity decides,
|
|
923
|
-
// since the pipeline may have answered with a stored replay or a
|
|
924
|
-
// refusal instead.
|
|
925
|
-
if (handled.output?.answer === answer)
|
|
926
|
-
return handled.output.response;
|
|
927
|
-
return responseFromAnswer(answer);
|
|
934
|
+
// The API's compress option, on whatever answer the call ended with:
|
|
935
|
+
// a replayed one comes back from its store without the hint.
|
|
936
|
+
return responseFromAnswer({ ...answer, compress });
|
|
928
937
|
}
|
|
929
938
|
/** A thrown LambderApiRefusal (from a hook, say) as the structured API envelope: the core's one mapping. */
|
|
930
939
|
apiErrorResponse(err, ctx) {
|