lambder 11.1.1 → 13.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.
Files changed (87) hide show
  1. package/CHANGELOG.md +217 -0
  2. package/README.md +25 -15
  3. package/dist/api/LambderApiAnswer.d.ts +7 -0
  4. package/dist/api/LambderApiDeclarations.d.ts +194 -0
  5. package/dist/api/LambderApiDeclarations.js +52 -0
  6. package/dist/api/LambderApiDefinition.d.ts +1 -1
  7. package/dist/api/LambderApiEnvelope.js +4 -1
  8. package/dist/api/LambderApiGuards.d.ts +21 -22
  9. package/dist/api/LambderApiGuards.js +1 -1
  10. package/dist/api/LambderApiRateLimits.d.ts +19 -19
  11. package/dist/api/LambderApiRateLimits.js +1 -1
  12. package/dist/api/LambderApiRefusals.d.ts +22 -0
  13. package/dist/api/LambderApiRefusals.js +13 -0
  14. package/dist/api/LambderApiRequest.d.ts +15 -6
  15. package/dist/api/LambderApiRequest.js +8 -9
  16. package/dist/build/generateApiFiles.d.ts +60 -0
  17. package/dist/build/generateApiFiles.js +85 -0
  18. package/dist/build/generatedTables.d.ts +2 -1
  19. package/dist/build/generatedTables.js +1 -1
  20. package/dist/build.d.ts +4 -1
  21. package/dist/build.js +3 -1
  22. package/dist/client/LambderCaller.d.ts +36 -23
  23. package/dist/client/LambderCaller.js +88 -67
  24. package/dist/client/lambderFetchTransport.js +2 -1
  25. package/dist/client.d.ts +3 -1
  26. package/dist/client.js +2 -0
  27. package/dist/core/Lambder.d.ts +232 -92
  28. package/dist/core/Lambder.js +400 -103
  29. package/dist/core/LambderCallSummary.d.ts +43 -0
  30. package/dist/core/LambderCallSummary.js +10 -0
  31. package/dist/core/LambderContext.d.ts +15 -2
  32. package/dist/core/LambderContext.js +32 -9
  33. package/dist/core/LambderCreateOptions.d.ts +78 -64
  34. package/dist/core/LambderCreateOptions.js +39 -21
  35. package/dist/core/LambderInvocationScope.d.ts +25 -0
  36. package/dist/core/LambderInvocationScope.js +21 -0
  37. package/dist/core/LambderResponse.d.ts +11 -0
  38. package/dist/core/LambderResponse.js +12 -0
  39. package/dist/core/LambderResponseBuilder.js +5 -1
  40. package/dist/index.d.ts +13 -7
  41. package/dist/index.js +3 -1
  42. package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
  43. package/dist/invoke/LambderInvokeCaller.js +110 -94
  44. package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
  45. package/dist/invoke/LambderLambdaEvent.js +8 -3
  46. package/dist/invoke/lambderHandlerTransport.js +2 -1
  47. package/dist/mock/LambderMockApp.d.ts +39 -33
  48. package/dist/mock/LambderMockApp.js +66 -55
  49. package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
  50. package/dist/mock/LambderMockTypes.d.ts +44 -7
  51. package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
  52. package/dist/mock/lambderMockInvokeTransport.js +15 -7
  53. package/dist/mock/lambderMockMswHandler.js +8 -4
  54. package/dist/mock.d.ts +2 -2
  55. package/dist/session/LambderSessionManager.d.ts +46 -3
  56. package/dist/session/LambderSessionManager.js +62 -11
  57. package/dist/shared/LambderI18n.js +49 -4
  58. package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
  59. package/dist/shared/contracts/LambderRateLimiter.js +2 -2
  60. package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
  61. package/dist/shared/transport/LambderApiTransport.js +2 -3
  62. package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
  63. package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
  64. package/dist/shared/util/LambderInvocationScope.js +5 -0
  65. package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
  66. package/dist/shared/util/LambderNamedMaps.js +19 -0
  67. package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
  68. package/dist/shared/wire/LambderApiContract.d.ts +4 -18
  69. package/dist/shared/wire/LambderApiContract.js +1 -1
  70. package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
  71. package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
  72. package/dist/shared/wire/LambderApiNames.d.ts +55 -0
  73. package/dist/shared/wire/LambderApiNames.js +81 -0
  74. package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
  75. package/dist/shared/wire/LambderCallOutcome.js +47 -0
  76. package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
  77. package/dist/shared/wire/LambderInvokeApiId.js +10 -0
  78. package/dist/shared/wire/LambderUploadSchemas.js +2 -1
  79. package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
  80. package/dist/stores/LambderDdbRateLimiter.js +64 -45
  81. package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
  82. package/dist/stores/LambderMemoryRateLimiter.js +20 -15
  83. package/dist/testing/LambderTestApp.d.ts +18 -5
  84. package/dist/testing/LambderTestApp.js +21 -5
  85. package/dist/testing/LambderTestVisitor.d.ts +16 -8
  86. package/dist/testing/LambderTestVisitor.js +33 -25
  87. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -9,6 +9,223 @@ 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
+ ## [13.0.1] - 2026-09-30
13
+
14
+ A major: session data is checked against a schema on every read, and
15
+ `dataRefresh` requires that schema. Beside it, one summary line per API call
16
+ by default, an origin proof for the trusted forwarding headers, refusal codes
17
+ declared per part of an app, a check that every translation keeps its
18
+ placeholders, one round trip for a rate limit's windows, and every generated
19
+ file in one call.
20
+
21
+ ### Changed (breaking)
22
+
23
+ - **`session.dataRefresh` requires `session.dataSchema`**, the zod schema of
24
+ `session.data`, at compile time and at creation. Every read checks the
25
+ record's data against it and hands the request the schema's output; data
26
+ that does not match (a record written before a deploy changed its shape)
27
+ is refreshed on that read, whatever its deadline, and a refresh whose
28
+ output the schema refuses fails the read as a
29
+ `LambderSessionDataRefreshError`, writing nothing. Without `dataRefresh`
30
+ the schema is optional, and data that does not match ends the session.
31
+ With a schema, `createSession` and `updateSessionData` throw on data it
32
+ refuses and write nothing, rather than store a session its next read
33
+ would end. A record keeps its data as written or refreshed, and requests
34
+ are served the schema's output, so every read parses exactly what its
35
+ write was checked with. The same holds for the mock runtime's sessions.
36
+ - To move: give `dataSchema` beside `dataRefresh`, the schema your
37
+ `SessionData` type is, and derive the type from it
38
+ (`z.infer<typeof sessionDataSchema>`) so the two cannot drift.
39
+ - **The DynamoDB rate limiter sends every window's write at once**, so a
40
+ policy capping several windows costs one round trip rather than one per
41
+ window. A window under its limit now counts an attempt another window
42
+ refuses (the smaller windows were counted first, and the larger ones
43
+ spared, before), and a window the table refused an attempt in is
44
+ remembered by the process until it resets, so a flood's later attempts
45
+ are refused without touching the table and stop raising the other
46
+ windows' counters. The memory limiter checks every window before counting
47
+ any, so an attempt it refuses counts nowhere.
48
+ - **`LambderApiAnswer` and `LambderResponse` carry the answer's outcome**
49
+ (`outcome`, `callOutcome`), which the envelope builders and
50
+ `res.apiRefusal()` set, and `LambderInstanceBackends` takes a
51
+ `callSummary` writer.
52
+
53
+ ### Added
54
+
55
+ - **One summary line per API call** (`callSummary`), written when the call
56
+ is answered: the endpoint, how it ended (`success`, `refusal` and its
57
+ code, a flag, `validation`, `crash`, a failing `created` hook's included),
58
+ the status, the duration and the handler's own time, whether an
59
+ idempotent answer was replayed, whether the invocation was the process's
60
+ first, and its request id with the id of the invocation that invoked it.
61
+ Nothing from the input, the session, the cookies or the caller's address.
62
+ By default each is one JSON line on stdout, which CloudWatch Logs Insights
63
+ reads field by field; a function receives each instead, and `false`
64
+ writes none. A test app collects them in `app.callSummaries`.
65
+ - **The calling invocation's request id travels over invoke**
66
+ (`LAMBDER_PARENT_REQUEST_HEADER`), read by the callee from an invoke
67
+ alone, so the two functions' summary lines join.
68
+ - **`originProof: { header, secrets }`**: the trusted client address and
69
+ host headers are read only from a request carrying the secret the proxy in
70
+ front of the app sets, so a request sent to the origin directly (an
71
+ `execute-api` or `lambda-url` address) is counted by the address it came
72
+ from. The header is taken off `ctx.headers`; `secrets` takes the previous
73
+ secret during a rotation; a secret under 32 characters, a proof guarding
74
+ no trusted header, and a proof header that is also a trusted one are
75
+ refused at creation.
76
+ - **Refusal codes as a list of maps**: `declareRefusals([orderRefusals,
77
+ walletRefusals])`, on the server's init and the mock's alike, so each part
78
+ of an app declares its own codes, as guards and rate-limit policies are
79
+ declared. A code two maps declare is a compile error and a throw.
80
+ - **Every translation keeps the default language's placeholders.** A block
81
+ whose text drops or adds a `{token}` against the default language's text
82
+ of the same key is refused where it arrives: an inline block when the
83
+ instance or an extension is created, a loaded one when its loader answers
84
+ (`loadLanguage` rejects, and the language stays on the default one), a
85
+ registered one at `registerDictionary`. Each error lists every mismatch of
86
+ the block.
87
+ - **`generateApiFiles(config, { check })`** in `lambder/build`: every
88
+ contract, signature file, options file and guard-parameter file of every
89
+ app a generator script names, each app's module given once, in one call
90
+ that names every stale or broken file and does not stop at the first.
91
+
92
+ ## [12.0.1] - 2026-09-30
93
+
94
+ A major: endpoints are values. An app declares each endpoint with its
95
+ instance's `defineApi`, gathers them into named groups with `defineApiGroup`,
96
+ and registers every group in one `registerApiGroups()` call. An endpoint's
97
+ guards decide its mode, its name is `group.action`, a call goes to
98
+ `{apiPath}/{group}/{action}`, and every caller reaches it through its group:
99
+ `caller.orders.place(input)`.
100
+
101
+ ### Changed (breaking)
102
+
103
+ - **`addApi` and `addSessionApi` are replaced by `defineApi`,
104
+ `defineApiGroup` and `registerApiGroups`.** `defineApi(options, handler)`
105
+ takes the options and the handler `addApi` took, and registers nothing by
106
+ itself; `defineApiGroup(name, { action: declaration })` gathers a group
107
+ (`defineApiGroup(name, partA, partB)` for one declared across files, an
108
+ action two parts declare being refused); `registerApiGroups(...groups)`
109
+ registers them and returns the instance typed with their contract, one flat
110
+ mapped type. The three builders are arrow properties, so an app hands them
111
+ out with `export const { defineApi, defineApiGroup, lazyApiGroup } =
112
+ lambderApp;`.
113
+ - To move: turn each chain of `addApi`/`addSessionApi` calls into a group of
114
+ `defineApi` declarations and register the groups in one call. Handler
115
+ bodies do not change.
116
+ - **An endpoint's mode is its guards'.** An endpoint declaring a guard that
117
+ needs a session (`session: true`) is a session endpoint: its session is read
118
+ first, a call without one is answered `sessionExpired`, and its handler's
119
+ `ctx.session` is typed present. Every other endpoint is public. A
120
+ per-session rate limit on an endpoint none of whose guards needs a session,
121
+ and a session guard on an instance created without the `session` option,
122
+ are compile errors and registration errors.
123
+ - To move: give an endpoint that was an `addSessionApi` and declares no
124
+ session guard one: a no-op `lambderGuard({ session: true, handler: () => {} })`
125
+ where the session is its whole authorization.
126
+ - **`requireSessionApiGuards` and `requirePublicApiGuards` are replaced by
127
+ `requireApiGuards`**, which makes `guards` a required field of every
128
+ endpoint.
129
+ - **An endpoint is named `group.action`**, both identifiers (a letter, then
130
+ letters, digits or underscores). A group may not take the name of a
131
+ caller's public member or one a runtime asks of any object
132
+ (`LAMBDER_RESERVED_GROUP_NAMES`), nor an action one a function or any
133
+ object answers for (`then`, `call`, `toString`, `valueOf`:
134
+ `LAMBDER_RESERVED_ACTION_NAMES`), so converting a group to a string calls
135
+ no endpoint. The callers keep their state in
136
+ `#private` fields, so a group may take any name their public members leave
137
+ free (`transport`, `client`, `headers`).
138
+ - **A call is `POST {apiPath}/{group}/{action}`**, the envelope in the body
139
+ and no `apiName` in it, so a gateway, a CDN and a log can meter, limit and
140
+ read calls per endpoint. `buildTransportEnvelope` writes no `apiName`, and
141
+ `readApiEnvelope(post, info, apiName)` takes the name from the path. A JSON
142
+ POST to `apiPath` itself naming an endpoint in its body is a page built
143
+ before endpoints had paths, and is answered `versionExpired`, which reloads
144
+ it.
145
+ - To move: an API Gateway route or a CloudFront behavior that matches
146
+ `apiPath` exactly (`POST /api`) now misses every call; widen it to what
147
+ is under it (`/api/*`, `ANY /api/{proxy+}`). A cookie an API handler sets
148
+ with a raw `Set-Cookie` header and no `Path` now defaults to the call's
149
+ directory (`/api/orders`) rather than `/`, in a browser as in the cookie
150
+ jar; `ctx.setCookie` and the session cookies always write `Path=/`.
151
+ - **A request under `apiPath` that nothing matched is the API's**: a GET to a
152
+ call path, or a path of another depth, is answered as an unknown API rather
153
+ than by the public files, the shell or the route fallback. A root `apiPath`
154
+ shares every path with the site, so there only `apiPath` itself and the
155
+ calls are.
156
+ - **The instance takes one type parameter**, `Lambder<TApp, TContract>`,
157
+ where `TApp` is `LambderAppTypes` (the session data, policies, guards,
158
+ idempotency, the guard requirement, sessions and the refusal vocabulary),
159
+ in place of ten positional generics. It is readable as the type-only
160
+ `AppTypes` property beside `ApiContract`.
161
+ - **`use(plugin)` registers routes, hooks and actions**, and hands back the
162
+ same instance: endpoints are registered as groups, so a plugin adds nothing
163
+ to the contract.
164
+ - **A `registerApiGroups()` call's groups take their place in the first-match
165
+ chain where the call stands**, as each `addApi` did: a route registered
166
+ before them sees their calls first, one registered after them never does.
167
+ The beforeRender hooks run before a lazy group loads, and a call to an
168
+ action its group does not have is answered as an unmatched API call, the
169
+ fallback hooks first.
170
+ - **`apiOptionEntries()` is asynchronous**, as `apiSignatures()` and
171
+ `apiSignatureEntries()` are: all three load lazy groups first.
172
+ - **The mock's `publicApi` and `sessionApi` are replaced by `api`, and
173
+ `sessionNotMocked` by `notMocked`.** An entry's mode is the `apiOptions`
174
+ table's when the mock was given it, and otherwise the server's rule applied
175
+ to the guards the entry restates. `LambderMockPublicNames` and
176
+ `LambderMockSessionNames` are removed. Every session endpoint must name at
177
+ least one mock guard declared `session: true`, a compile error at the
178
+ `guards` option otherwise (`LambderMockSessionGuardsCheck`), and a session
179
+ endpoint left unmocked on a mock without the table restates its guards,
180
+ `notMocked(name, { reason, guards })`, so a signed-out call answers
181
+ `sessionExpired` as the server does. `lambderMockInvokeTransport(mockApp,
182
+ { apiPath })` answers what the callee's server reads as a call, a JSON POST
183
+ to a call path under `apiPath` (default `"/api"`), and 404s anything else.
184
+ - **`LambderMergeContract` is removed**, and `LambderAllowedGuardNames`,
185
+ `LambderParamlessGuardNames`, `LambderGuardsOption`,
186
+ `LambderAllowedPolicyNames` and `LambderRateLimitOption` lose their second
187
+ type parameter: every guard and every policy with a `per` may be declared
188
+ on any endpoint, a session guard making it a session one.
189
+
190
+ ### Added
191
+
192
+ - **Every caller reaches an endpoint through its group.**
193
+ `caller.orders.place(input)` is `caller.api("orders.place", input)`, and
194
+ `caller.orders.place.outcome(input)` its `apiOutcome`, on `LambderCaller`,
195
+ `LambderInvokeCaller` and the test visitor alike. The groups are read off
196
+ the contract's type, so a client that imports its contract as a type ships
197
+ no endpoint list. A name that is not `group.action` is refused before it is
198
+ sent.
199
+ - **Lazy groups.** `lazyApiGroup(name, () => import(...).then((m) => m.group))`
200
+ registers a group loaded on the first call to one of its endpoints, so a
201
+ cold start parses none of it until then; `loadApiGroups()` loads every one,
202
+ for a build step or a boot check. A hook that answers a call spares it the
203
+ import; one that throws a refusal, or charges a per-API budget, loads the
204
+ group, so the refusal is checked and the budget counted against the
205
+ endpoint called, as for an eager group. A load that fails is forgotten, and
206
+ the next call tries again.
207
+ - **Compile-time registration checks**: a group typed `any`, a lazy group
208
+ loading one, or a group whose endpoints are (its endpoints would be `any` to
209
+ every client), and a group name given twice, are refused where
210
+ `registerApiGroups()` is written, and a group registration is atomic:
211
+ refused anywhere, it leaves the instance as it was. A lazy group written
212
+ where a list of groups is expected (a `satisfies`, an annotated array)
213
+ keeps its loaded group's type, rather than taking the expected `any`.
214
+ - **`beforeCall` on `LambderInvokeCaller`**: a check run before every call,
215
+ `api()`, `apiOutcome()`, a group's call and `request()` alike, before
216
+ anything is built or sent. What it throws is thrown to the caller, from
217
+ `apiOutcome()` too, and `onFailure` is not told, since nothing was sent. It
218
+ is where a rule the calling code must keep goes, such as never calling out
219
+ while a database transaction is open (`LambderInvokeCallCheck`).
220
+ - **What registering endpoints costs the compiler grows linearly**: each
221
+ declaration is typed on its own, and eight hundred endpoints cost under four
222
+ times what two hundred do, which the type-cost suite holds.
223
+ - Exports: `apiCallPath`, `apiNameOfCallPath`, `splitApiName`, `isGroupName`,
224
+ `isActionName`, `LAMBDER_API_NAME_SEGMENT`, `LAMBDER_CALLER_MEMBER_NAMES`,
225
+ `LAMBDER_RESERVED_GROUP_NAMES`, `LAMBDER_RESERVED_ACTION_NAMES`, and the
226
+ types of declarations, groups and group calls (see
227
+ [Exports](./docs/exports.md#endpoints-declarations-groups-and-names)).
228
+
12
229
  ## [11.1.1] - 2026-09-30
13
230
 
14
231
  A minor for an app whose shell files sit behind a shared cache: a file whose
package/README.md CHANGED
@@ -10,25 +10,30 @@ per-handler boilerplate.
10
10
  import { initLambder, LambderLocalFileSource, LambderDdbSessionStore } from "lambder";
11
11
  import { z } from "zod";
12
12
 
13
- const lambder = initLambder<SessionData>().create({
13
+ const app = initLambder<SessionData>().create({
14
14
  apiPath: "/api",
15
15
  files: new LambderLocalFileSource({ root: "./public" }),
16
16
  session: { store: new LambderDdbSessionStore({ tableName: "app-session", region: "us-east-1" }), sessionSalt: process.env.SESSION_SALT! },
17
- }).addApi("getCompany", {
18
- input: z.object({ slug: z.string() }),
19
- output: z.object({ id: z.string(), name: z.string() }),
20
- }, async ({ apiPayload }) => await loadCompany(apiPayload.slug));
17
+ });
21
18
 
19
+ const companyApis = app.defineApiGroup("companies", {
20
+ get: app.defineApi({
21
+ input: z.object({ slug: z.string() }),
22
+ output: z.object({ id: z.string(), name: z.string() }),
23
+ }, async ({ apiPayload }) => await loadCompany(apiPayload.slug)),
24
+ });
25
+
26
+ export const lambder = app.registerApiGroups(companyApis);
22
27
  export type ApiContractType = typeof lambder.ApiContract;
23
28
  export const handler = lambder.getHandler();
24
29
  ```
25
30
 
26
- A handler returns its output, which is parsed through the output schema before
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
29
- creation call: every `addApi` returns an instance carrying the contract so
30
- far, so the whole backend is one declaration and `lambder.ApiContract` is the
31
- accumulated type.
31
+ An endpoint is a value: declared with `defineApi`, gathered into a named
32
+ group, registered with its groups in one call, and called at
33
+ `/api/companies/get`. A handler returns its output, which is parsed through
34
+ the output schema before it is sent, and says no by throwing a refusal:
35
+ `ctx.refuse()`, typed to the codes the API declares, or `refuse()` from
36
+ anywhere. Its guards say who may call it, and so whether it needs a session.
32
37
 
33
38
  The frontend imports that contract type and gets autocomplete, typed payloads
34
39
  and typed results with no hand-written client:
@@ -38,7 +43,7 @@ import { LambderCaller } from "lambder/client";
38
43
  import type { ApiContractType } from "./backend/handler";
39
44
 
40
45
  const caller = new LambderCaller<ApiContractType>({ apiPath: "/api" });
41
- const company = await caller.api("getCompany", { slug: "acme" });
46
+ const company = await caller.companies.get({ slug: "acme" });
42
47
  ```
43
48
 
44
49
  ## Features
@@ -60,7 +65,8 @@ const company = await caller.api("getCompany", { slug: "acme" });
60
65
  - **Simple route and API declaration.** Paths, regexes, predicates and
61
66
  structured matchers, chained fluently.
62
67
  - **Sessions.** Over a store of your choosing (DynamoDB, memory, your own),
63
- with secrets hashed at rest, sliding expiration, data refresh and
68
+ with secrets hashed at rest, sliding expiration, session data checked
69
+ against its schema on every read and refreshed from its source, and
64
70
  cross-subdomain cookies.
65
71
  - **One API core, two runtimes.** The request pipeline (envelope, refusals,
66
72
  sessions, guards, rate limits, idempotency) is one isomorphic class; the
@@ -74,6 +80,10 @@ const company = await caller.api("getCompany", { slug: "acme" });
74
80
  cookies, and a guard against Lambda's response size cap.
75
81
  - **Hooks and actions.** Lifecycle hooks, plus `addAction()` for the non-HTTP
76
82
  invocations (EventBridge, SQS, custom events) the same function receives.
83
+ - **A summary line per call.** Every API call is logged once, as JSON, with
84
+ its endpoint, outcome, refusal code, status and timings, and the request
85
+ ids that join it to the calls it made; nothing from its input or its
86
+ caller.
77
87
  - **Lambda to lambda calls.** `LambderInvokeCaller` invokes a Lambder app in
78
88
  another function directly, with no API Gateway in between, typed from the
79
89
  callee's own contract and carrying its refusals, crash detail and logs back.
@@ -130,7 +140,7 @@ The package ships five entry points; pick by where the code runs:
130
140
  | `lambder/client` | Browser and isomorphic shared code | `LambderCaller`, `LambderApiRefusal`/`refuse`, the API contract and envelope types, `LambderUploadRunner`, `LambderBackoffTimer`, `LambderSignedClaims`, `html`/`xml` tagged templates, `createLambderI18n` |
131
141
  | `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 |
132
142
  | `lambder/testing` | Node, in tests | `lambderTestApp`: your real instance under test in this process, memory stores put under it in place, simulated browsers with typed callers in front of it, and the outcome assertions; the store conformance suites, to hold a store you write to the rules Lambder's own meet |
133
- | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters and nothing else, what a browser gating on that guard carries; `writeApiContract`: the contract as plain types a client compiles instead of the server |
143
+ | `lambder/build` | Node, in a build step | `writeApiSignatures`: the signature file both sides ship, written or checked from your instance; `writeApiOptions`: every API's declared options, policies and guard declarations as plain data, for the code that decides with them; `writeApiGuardParams`: one guard's parameters and nothing else, what a browser gating on that guard carries; `writeApiContract`: the contract as plain types a client compiles instead of the server; `generateApiFiles`: every one of them, for every app a script names, in one call |
134
144
 
135
145
  Frontends and shared isomorphic packages should import from `lambder/client`
136
146
  only; the entry's module graph contains no AWS SDK, Node built-ins, or server
@@ -166,7 +176,7 @@ guide that matches what you are building. The full index lives in
166
176
  | [Getting started](./docs/getting-started.md) | The three-step path from a first API to a typed frontend call |
167
177
  | [Configuration](./docs/configuration.md) | Every `initLambder().create({...})` option, in one reference |
168
178
  | [Routing and actions](./docs/routing.md) | Routes, matchers, hooks, fallbacks, crash reporting, and non-HTTP invocations |
169
- | [APIs and refusals](./docs/apis.md) | `addApi`/`addSessionApi`, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
179
+ | [APIs and refusals](./docs/apis.md) | `defineApi`, groups and lazy groups, the mode the guards decide, the inferred contract, `refuse()` and `LambderApiRefusal`, the signature file |
170
180
  | [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 |
171
181
  | [Sessions](./docs/sessions.md) | Sessions over a store, cookie scope, secrets at rest, `dataRefresh`, the controller API |
172
182
  | [API policies](./docs/api-policies.md) | Declarative rate limits, guards and idempotency, and mandatory authorization declarations |
@@ -1,3 +1,4 @@
1
+ import type { LambderCallOutcomeHint } from "../shared/wire/LambderCallOutcome.js";
1
2
  import type { LambderApiHttpAnswer } from "../shared/wire/LambderApiOutcome.js";
2
3
  /**
3
4
  * The core's output for one API call, before any transport-level
@@ -23,6 +24,12 @@ export type LambderApiAnswer = {
23
24
  isBodyBase64?: boolean;
24
25
  compress?: boolean | "auto";
25
26
  etag?: boolean | "auto";
27
+ /**
28
+ * What the answer is, as the code that wrote it knows it, for the call's
29
+ * summary line: set by the envelope and validation builders, dropped by
30
+ * the stores like the other hints (a replay reads it back from the text).
31
+ */
32
+ outcome?: LambderCallOutcomeHint;
26
33
  };
27
34
  /**
28
35
  * An answer in the accessor form resolveApiOutcome() reads (the same view a
@@ -0,0 +1,194 @@
1
+ import type { z } from "zod";
2
+ import type { MaybePromise } from "../shared/util/LambderTypeUtilities.js";
3
+ import type { LambderApiMode, LambderGuardNamesIn } from "../shared/wire/LambderApiContract.js";
4
+ import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue, LambderRefusalsOptionValue } from "../shared/wire/LambderApiOptionValues.js";
5
+ import type { LambderApiRateLimitPolicyConfig, LambderPolicyPerOf } from "./LambderApiRateLimits.js";
6
+ import type { LambderRefusalVocabulary } from "./LambderApiRefusals.js";
7
+ import { type LambderReservedActionName, type LambderReservedGroupName } from "../shared/wire/LambderApiNames.js";
8
+ /**
9
+ * What an instance's declarations are typed against, fixed at create(): the
10
+ * session data, the rate-limit policies, the guards (as LambderGuardMetaMap
11
+ * records them), whether idempotency is configured, whether every endpoint
12
+ * must declare a guard, whether sessions are configured, and the refusal
13
+ * vocabulary with whether every refusal must name a code.
14
+ */
15
+ export type LambderAppTypes = {
16
+ session: unknown;
17
+ policies: Record<string, LambderApiRateLimitPolicyConfig>;
18
+ guards: Record<string, any>;
19
+ idempotency: boolean;
20
+ guardsRequired: boolean;
21
+ sessions: boolean;
22
+ refusals: LambderRefusalVocabulary;
23
+ refusalCodesRequired: boolean;
24
+ };
25
+ /** The types of an instance built without create(): no policies, guards or vocabulary, sessions allowed. */
26
+ export type LambderPlainAppTypes<TSession = any> = {
27
+ session: TSession;
28
+ policies: {};
29
+ guards: {};
30
+ idempotency: false;
31
+ guardsRequired: false;
32
+ sessions: true;
33
+ refusals: {};
34
+ refusalCodesRequired: false;
35
+ };
36
+ /** The guards among the names an endpoint declares that need a session (`session: true`). */
37
+ export type LambderSessionGuardNamesIn<TGuards, TGuardsOpt> = {
38
+ [K in LambderGuardNamesIn<TGuardsOpt> & keyof TGuards]: TGuards[K] extends {
39
+ session: true;
40
+ } ? K : never;
41
+ }[LambderGuardNamesIn<TGuardsOpt> & keyof TGuards];
42
+ /**
43
+ * An endpoint's mode, from the guards it declares: "session" when any of
44
+ * them needs a session, "public" otherwise. The guard is the one place the
45
+ * endpoint says who may call it, and a session is part of that answer, so
46
+ * nothing else states the mode and nothing can disagree with it.
47
+ */
48
+ export type LambderApiModeOf<TGuards, TGuardsOpt> = [
49
+ LambderSessionGuardNamesIn<TGuards, TGuardsOpt>
50
+ ] extends [never] ? "public" : "session";
51
+ /** The per-session policies among the names an endpoint's rateLimit option declares. */
52
+ type LambderSessionPolicyNamesIn<TPolicies, TRateOpt> = {
53
+ [K in LambderGuardNamesIn<TRateOpt> & keyof TPolicies]: [LambderPolicyPerOf<TPolicies[K]>] extends ["session"] ? K : never;
54
+ }[LambderGuardNamesIn<TRateOpt> & keyof TPolicies];
55
+ /**
56
+ * A public endpoint's per-session rate limits, refused on the option: a
57
+ * public call carries no session to count against. The property name is the
58
+ * message and its value the policies at fault.
59
+ */
60
+ export type LambderPublicSessionPolicyCheck<TMode extends LambderApiMode, TPolicies, TRateOpt> = TMode extends "session" ? unknown : [LambderSessionPolicyNamesIn<TPolicies, TRateOpt>] extends [never] ? unknown : {
61
+ rateLimit: {
62
+ readonly "lambder: these rate limits count per session, and none of this API's guards needs a session": LambderSessionPolicyNamesIn<TPolicies, TRateOpt>;
63
+ };
64
+ };
65
+ /** What a session endpoint needs of its instance: sessions configured at create(). */
66
+ type LambderSessionsConfigured = {
67
+ readonly "lambder: a guard of this API needs a session, and sessions are not configured on this instance. Pass the session option to create().": never;
68
+ };
69
+ /** A session endpoint on an instance without sessions, refused where it is declared. */
70
+ export type LambderSessionModeCheck<TMode extends LambderApiMode, TSessions extends boolean> = TMode extends "session" ? (TSessions extends true ? unknown : LambderSessionsConfigured) : unknown;
71
+ /** One endpoint's options as registration reads them. */
72
+ export type LambderApiDeclarationOptions = {
73
+ input: z.ZodType;
74
+ output: z.ZodType;
75
+ guards?: LambderGuardsOptionValue;
76
+ rateLimit?: LambderRateLimitOptionValue;
77
+ idempotency?: LambderApiIdempotencyOption;
78
+ refusals?: LambderRefusalsOptionValue;
79
+ compress?: boolean | "auto";
80
+ };
81
+ /** The key the contract entry of a declaration is typed under. It exists only in types: nothing reads or writes it. */
82
+ declare const LAMBDER_CONTRACT_ENTRY: unique symbol;
83
+ /**
84
+ * One endpoint, declared: its options and its handler, and in its type the
85
+ * contract entry a client calls it by. Built by an instance's defineApi(),
86
+ * registered by being put in a group.
87
+ */
88
+ export type LambderApiDeclaration<TEntry = unknown> = {
89
+ readonly kind: "lambderApi";
90
+ readonly options: LambderApiDeclarationOptions;
91
+ readonly handler: (ctx: never) => MaybePromise<unknown>;
92
+ readonly [LAMBDER_CONTRACT_ENTRY]: TEntry;
93
+ };
94
+ /** A group's endpoints by action name. */
95
+ export type LambderApiDeclarations = Record<string, LambderApiDeclaration<any>>;
96
+ /** A group of endpoints: `name.action` for each of its actions. Built by defineApiGroup(). */
97
+ export type LambderApiGroup<TName extends string = string, TApis extends LambderApiDeclarations = LambderApiDeclarations> = {
98
+ readonly kind: "lambderApiGroup";
99
+ readonly name: TName;
100
+ readonly apis: TApis;
101
+ };
102
+ /**
103
+ * A group loaded on the first call to one of its endpoints: its name, and
104
+ * how to load it. Built by lazyApiGroup(). What a cold start parses then
105
+ * leaves out every group no request has called yet.
106
+ */
107
+ export type LambderLazyApiGroup<TName extends string = string, TGroup extends LambderApiGroup<TName, any> = LambderApiGroup<TName, any>> = {
108
+ readonly kind: "lambderLazyApiGroup";
109
+ readonly name: TName;
110
+ readonly load: () => Promise<TGroup>;
111
+ };
112
+ /** Either kind of group registerApiGroups() takes. */
113
+ export type LambderRegistrableApiGroup = LambderApiGroup<string, any> | LambderLazyApiGroup<string, any>;
114
+ /** A declaration's contract entry. */
115
+ export type LambderEntryOf<TDeclaration> = TDeclaration extends {
116
+ readonly [LAMBDER_CONTRACT_ENTRY]: infer TEntry;
117
+ } ? TEntry : never;
118
+ type LambderLoadedGroupOf<TGroup> = TGroup extends LambderLazyApiGroup<string, infer TLoaded> ? TLoaded : TGroup;
119
+ /**
120
+ * One group's part of the contract: `group.action` for each action, its
121
+ * declaration's entry. Distributed over a union of groups (the `TGroup
122
+ * extends unknown`), so each group is read on its own: read as one union,
123
+ * the actions would be only those every group shares.
124
+ */
125
+ type LambderGroupContract<TGroup> = TGroup extends unknown ? (LambderLoadedGroupOf<TGroup> extends LambderApiGroup<infer TName, infer TApis> ? {
126
+ [TAction in keyof TApis & string as `${TName}.${TAction}`]: LambderEntryOf<TApis[TAction]>;
127
+ } : never) : never;
128
+ type LambderUnionToIntersection<TUnion> = (TUnion extends unknown ? (value: TUnion) => void : never) extends (value: infer TIntersection) => void ? TIntersection : never;
129
+ /** The contract of every group registered together, as one flat object type keyed by endpoint name. */
130
+ export type LambderContractOfGroups<TGroups extends readonly unknown[]> = LambderUnionToIntersection<LambderGroupContract<TGroups[number]>> extends infer TMerged ? {
131
+ [TName in keyof TMerged]: TMerged[TName];
132
+ } : never;
133
+ /**
134
+ * Whether a group, the group a lazy one loads, or that group's endpoints is
135
+ * typed any. A part typed any, merged with the rest, comes out as an index
136
+ * signature rather than as any, and no group declared action by action has
137
+ * one, so that counts too.
138
+ */
139
+ type LambderGroupTypedAny<TGroup> = 0 extends 1 & TGroup ? true : 0 extends 1 & LambderLoadedGroupOf<TGroup> ? true : LambderLoadedGroupOf<TGroup> extends {
140
+ readonly apis: infer TApis;
141
+ } ? (0 extends 1 & TApis ? true : string extends keyof TApis ? true : false) : false;
142
+ /** The group names one registerApiGroups() call names twice. */
143
+ type LambderRepeatedGroupNames<TGroups extends readonly unknown[], TSeen = never> = TGroups extends readonly [infer THead, ...infer TRest] ? (THead extends {
144
+ name: infer TName;
145
+ } ? ([TName] extends [TSeen] ? TName : never) | LambderRepeatedGroupNames<TRest, TSeen | TName> : never) : never;
146
+ /**
147
+ * What registerApiGroups() asks of its receiver: the instance itself, unless
148
+ * a group, what a lazy group loads or a group's endpoints is typed `any` (a
149
+ * module whose types were lost would otherwise make its endpoints `any` to
150
+ * every client) or a group name is given twice (two
151
+ * groups of one name would be one namespace with two owners). Then it asks
152
+ * for an object no instance is, whose property name is the message, so the
153
+ * call is refused where it is written.
154
+ */
155
+ export type LambderRegisteredGroupsCheck<TGroups extends readonly unknown[], TSelf> = true extends {
156
+ [TIndex in keyof TGroups]: LambderGroupTypedAny<TGroups[TIndex]>;
157
+ }[number] ? {
158
+ readonly "lambder: a group given to registerApiGroups, what a lazy group loads, or a group's endpoints is typed any, which would make its part of the contract any. Build it with defineApiGroup() and let its type through.": never;
159
+ } : [LambderRepeatedGroupNames<TGroups>] extends [never] ? TSelf : {
160
+ readonly "lambder: these group names are registered twice": LambderRepeatedGroupNames<TGroups>;
161
+ };
162
+ /** A group's name held to the rule a caller's properties need. */
163
+ export type LambderGroupNameCheck<TName extends string> = TName extends LambderReservedGroupName ? {
164
+ readonly "lambder: a caller already has a member of this name, so no group may take it": TName;
165
+ } : unknown;
166
+ /** The actions more than one of a group's parts declare. */
167
+ type LambderRepeatedActions<TParts extends readonly unknown[], TSeen = never> = TParts extends readonly [infer THead, ...infer TRest] ? (Extract<keyof THead, TSeen> | LambderRepeatedActions<TRest, TSeen | keyof THead>) : never;
168
+ /** A group's parts held to declaring each action once: a part would otherwise replace another's endpoint of the same name. */
169
+ export type LambderGroupPartsCheck<TParts extends readonly unknown[]> = [
170
+ LambderRepeatedActions<TParts>
171
+ ] extends [never] ? unknown : {
172
+ readonly "lambder: these actions are declared by more than one part of the group": LambderRepeatedActions<TParts>;
173
+ };
174
+ /** One group's endpoints, from the parts it was declared in. */
175
+ export type LambderMergedParts<TParts extends readonly unknown[]> = LambderUnionToIntersection<TParts[number]> extends infer TMerged ? {
176
+ [TAction in keyof TMerged]: TMerged[TAction];
177
+ } : never;
178
+ /** A group's action names held to the rule the functions a caller hands out need. */
179
+ export type LambderActionNamesCheck<TApis> = [
180
+ Extract<keyof TApis, LambderReservedActionName>
181
+ ] extends [never] ? unknown : {
182
+ readonly "lambder: a function already has a member of these names, so no action may take them": Extract<keyof TApis, LambderReservedActionName>;
183
+ };
184
+ /**
185
+ * Builds a group from its parts, refusing a name no caller could carry, an
186
+ * action two parts declare, and a value that is not an endpoint. A group is
187
+ * usually one part; one declared across files gives each file's part.
188
+ */
189
+ export declare const buildApiGroup: <TName extends string, TApis extends LambderApiDeclarations>(name: TName, parts: readonly LambderApiDeclarations[]) => LambderApiGroup<TName, TApis>;
190
+ /** Builds a lazy group; the name is checked here, the group it loads when it loads. */
191
+ export declare const buildLazyApiGroup: <TName extends string, TGroup extends LambderApiGroup<TName, any>>(name: TName, load: () => Promise<TGroup>) => LambderLazyApiGroup<TName, TGroup>;
192
+ /** Whether a value is either kind of group registerApiGroups() takes. */
193
+ export declare const isRegistrableApiGroup: (value: unknown) => value is LambderRegistrableApiGroup;
194
+ export {};
@@ -0,0 +1,52 @@
1
+ import { isActionName, isGroupName, LAMBDER_API_NAME_SEGMENT } from "../shared/wire/LambderApiNames.js";
2
+ const describeName = (name) => typeof name === "string" ? `"${name}"` : String(name);
3
+ /**
4
+ * Builds a group from its parts, refusing a name no caller could carry, an
5
+ * action two parts declare, and a value that is not an endpoint. A group is
6
+ * usually one part; one declared across files gives each file's part.
7
+ */
8
+ export const buildApiGroup = (name, parts) => {
9
+ if (typeof name !== "string" || !isGroupName(name)) {
10
+ throw new Error(`Lambder: ${describeName(name)} cannot name a group. A group is a letter then letters, digits or underscores, ` +
11
+ `and not a name a caller already answers for (api, apiOutcome, then, ...).`);
12
+ }
13
+ const apis = {};
14
+ for (const part of parts) {
15
+ if (part === null || typeof part !== "object") {
16
+ throw new Error(`Lambder: a part of group "${name}" is not an object of endpoints by action name.`);
17
+ }
18
+ for (const [action, declaration] of Object.entries(part)) {
19
+ if (Object.prototype.hasOwnProperty.call(apis, action)) {
20
+ throw new Error(`Lambder: "${name}.${action}" is declared by two parts of group "${name}". Each action is declared once.`);
21
+ }
22
+ apis[action] = declaration;
23
+ }
24
+ }
25
+ if (Object.keys(apis).length === 0) {
26
+ throw new Error(`Lambder: group "${name}" declares no endpoints. A group is its endpoints by action name: defineApiGroup("${name}", { action: defineApi(...) }).`);
27
+ }
28
+ for (const [action, declaration] of Object.entries(apis)) {
29
+ if (!isActionName(action)) {
30
+ throw new Error(`Lambder: "${name}.${action}" cannot name an endpoint. An action is a letter then letters, digits or underscores, ` +
31
+ `and not a name a function already answers for (then, outcome, call, ...).`);
32
+ }
33
+ if (declaration?.kind !== "lambderApi") {
34
+ throw new Error(`Lambder: "${name}.${action}" is not an endpoint declaration. Declare it with the instance's defineApi().`);
35
+ }
36
+ }
37
+ return { kind: "lambderApiGroup", name, apis: apis };
38
+ };
39
+ /** Builds a lazy group; the name is checked here, the group it loads when it loads. */
40
+ export const buildLazyApiGroup = (name, load) => {
41
+ if (typeof name !== "string" || !isGroupName(name)) {
42
+ throw new Error(`Lambder: ${describeName(name)} cannot name a group. A group is a letter then letters, digits or underscores, and not a name a caller already answers for.`);
43
+ }
44
+ if (typeof load !== "function")
45
+ throw new Error(`Lambder: lazy group "${name}" has no loader. Pass a function that imports the group: () => import("./orders.js").then((m) => m.orderApis).`);
46
+ return { kind: "lambderLazyApiGroup", name, load };
47
+ };
48
+ /** Whether a value is either kind of group registerApiGroups() takes. */
49
+ export const isRegistrableApiGroup = (value) => {
50
+ const kind = value?.kind;
51
+ return (kind === "lambderApiGroup" || kind === "lambderLazyApiGroup") && typeof value.name === "string" && LAMBDER_API_NAME_SEGMENT.test(value.name);
52
+ };
@@ -4,7 +4,7 @@ import type { LambderApiMode } from "../shared/wire/LambderApiContract.js";
4
4
  import type { LambderApiIdempotencyOption, LambderGuardsOptionValue, LambderRateLimitOptionValue } from "../shared/wire/LambderApiOptionValues.js";
5
5
  /**
6
6
  * One endpoint's declaration as the pipeline runs it: what the server's
7
- * addApi/addSessionApi options carry, minus the handler, in a shape the mock
7
+ * defineApi options carry, minus the handler, in a shape the mock
8
8
  * runtime can restate from a type-only contract. The schemas are optional
9
9
  * because the mock has none; when input is present, validation runs and the
10
10
  * handler sees the parsed payload. Output is part of the endpoint's
@@ -1,3 +1,4 @@
1
+ import { outcomeOfEnvelope } from "../shared/wire/LambderCallOutcome.js";
1
2
  import { LAMBDER_REFUSAL_CODES, isLambderRefusalCode, refusalMessageOf } from "../shared/wire/LambderApiRefusal.js";
2
3
  import { setAnswerHeader } from "../shared/wire/LambderAnswerHeaders.js";
3
4
  /*
@@ -73,7 +74,8 @@ export const envelopeAnswer = (envelope, options = {}) => {
73
74
  // casing replaces the envelope's own rather than shipping beside it.
74
75
  setAnswerHeader(headers, key, value);
75
76
  }
76
- return { statusCode: options.statusCode ?? 200, headers, body: JSON.stringify(envelope) };
77
+ const statusCode = options.statusCode ?? 200;
78
+ return { statusCode, headers, body: JSON.stringify(envelope), outcome: outcomeOfEnvelope(envelope, statusCode) };
77
79
  };
78
80
  /**
79
81
  * A thrown refusal as an answer: its refusal and flags on the envelope,
@@ -187,6 +189,7 @@ export const validationAnswer = (zodError, logList) => {
187
189
  ...(trimmed ? { issueCount: zodError.issues.length } : {}),
188
190
  ...(logList?.length ? { logList } : {}),
189
191
  }),
192
+ outcome: { outcome: "validation", code: null },
190
193
  };
191
194
  };
192
195
  /** No API is registered under the requested name: a refusal, not a 404, so a typed caller reads it. */