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.
- package/CHANGELOG.md +217 -0
- package/README.md +25 -15
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiDeclarations.d.ts +194 -0
- package/dist/api/LambderApiDeclarations.js +52 -0
- package/dist/api/LambderApiDefinition.d.ts +1 -1
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiGuards.d.ts +21 -22
- package/dist/api/LambderApiGuards.js +1 -1
- package/dist/api/LambderApiRateLimits.d.ts +19 -19
- package/dist/api/LambderApiRateLimits.js +1 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/api/LambderApiRequest.d.ts +15 -6
- package/dist/api/LambderApiRequest.js +8 -9
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +2 -1
- package/dist/build/generatedTables.js +1 -1
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client/LambderCaller.d.ts +36 -23
- package/dist/client/LambderCaller.js +88 -67
- package/dist/client/lambderFetchTransport.js +2 -1
- package/dist/client.d.ts +3 -1
- package/dist/client.js +2 -0
- package/dist/core/Lambder.d.ts +232 -92
- package/dist/core/Lambder.js +400 -103
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +15 -2
- package/dist/core/LambderContext.js +32 -9
- package/dist/core/LambderCreateOptions.d.ts +78 -64
- package/dist/core/LambderCreateOptions.js +39 -21
- package/dist/core/LambderInvocationScope.d.ts +25 -0
- package/dist/core/LambderInvocationScope.js +21 -0
- package/dist/core/LambderResponse.d.ts +11 -0
- package/dist/core/LambderResponse.js +12 -0
- package/dist/core/LambderResponseBuilder.js +5 -1
- package/dist/index.d.ts +13 -7
- package/dist/index.js +3 -1
- package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
- package/dist/invoke/LambderInvokeCaller.js +110 -94
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -1
- package/dist/invoke/LambderLambdaEvent.js +8 -3
- package/dist/invoke/lambderHandlerTransport.js +2 -1
- package/dist/mock/LambderMockApp.d.ts +39 -33
- package/dist/mock/LambderMockApp.js +66 -55
- package/dist/mock/LambderMockCreateOptions.d.ts +8 -10
- package/dist/mock/LambderMockTypes.d.ts +44 -7
- package/dist/mock/lambderMockInvokeTransport.d.ts +9 -1
- package/dist/mock/lambderMockInvokeTransport.js +15 -7
- package/dist/mock/lambderMockMswHandler.js +8 -4
- package/dist/mock.d.ts +2 -2
- package/dist/session/LambderSessionManager.d.ts +46 -3
- package/dist/session/LambderSessionManager.js +62 -11
- package/dist/shared/LambderI18n.js +49 -4
- package/dist/shared/contracts/LambderRateLimiter.d.ts +2 -2
- package/dist/shared/contracts/LambderRateLimiter.js +2 -2
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/transport/LambderApiTransport.js +2 -3
- package/dist/shared/transport/lambderCookieJarTransport.js +2 -1
- package/dist/shared/util/LambderInvocationScope.d.ts +10 -0
- package/dist/shared/util/LambderInvocationScope.js +5 -0
- package/dist/shared/util/LambderNamedMaps.d.ts +22 -0
- package/dist/shared/util/LambderNamedMaps.js +19 -0
- package/dist/shared/util/LambderTypeUtilities.d.ts +1 -1
- package/dist/shared/wire/LambderApiContract.d.ts +4 -18
- package/dist/shared/wire/LambderApiContract.js +1 -1
- package/dist/shared/wire/LambderApiGroupCalls.d.ts +31 -0
- package/dist/shared/wire/LambderApiGroupCalls.js +68 -0
- package/dist/shared/wire/LambderApiNames.d.ts +55 -0
- package/dist/shared/wire/LambderApiNames.js +81 -0
- package/dist/shared/wire/LambderCallOutcome.d.ts +27 -0
- package/dist/shared/wire/LambderCallOutcome.js +47 -0
- package/dist/shared/wire/LambderInvokeApiId.d.ts +10 -0
- package/dist/shared/wire/LambderInvokeApiId.js +10 -0
- package/dist/shared/wire/LambderUploadSchemas.js +2 -1
- package/dist/stores/LambderDdbRateLimiter.d.ts +24 -16
- package/dist/stores/LambderDdbRateLimiter.js +64 -45
- package/dist/stores/LambderMemoryRateLimiter.d.ts +9 -4
- package/dist/stores/LambderMemoryRateLimiter.js +20 -15
- package/dist/testing/LambderTestApp.d.ts +18 -5
- package/dist/testing/LambderTestApp.js +21 -5
- package/dist/testing/LambderTestVisitor.d.ts +16 -8
- package/dist/testing/LambderTestVisitor.js +33 -25
- 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
|
|
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
|
-
})
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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.
|
|
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
|
|
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) | `
|
|
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
|
-
*
|
|
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
|
-
|
|
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. */
|