lambder 12.0.1 → 14.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 +119 -0
- package/README.md +7 -2
- package/dist/api/LambderApiAnswer.d.ts +7 -0
- package/dist/api/LambderApiEnvelope.js +4 -1
- package/dist/api/LambderApiRefusals.d.ts +22 -0
- package/dist/api/LambderApiRefusals.js +13 -0
- package/dist/build/generateApiFiles.d.ts +60 -0
- package/dist/build/generateApiFiles.js +85 -0
- package/dist/build/generatedTables.d.ts +1 -1
- package/dist/build/writeApiGuardParams.d.ts +11 -10
- package/dist/build/writeApiGuardParams.js +135 -31
- package/dist/build.d.ts +4 -1
- package/dist/build.js +3 -1
- package/dist/client.d.ts +2 -2
- package/dist/client.js +1 -1
- package/dist/core/Lambder.d.ts +32 -8
- package/dist/core/Lambder.js +135 -17
- package/dist/core/LambderCallSummary.d.ts +43 -0
- package/dist/core/LambderCallSummary.js +10 -0
- package/dist/core/LambderContext.d.ts +14 -1
- package/dist/core/LambderContext.js +19 -4
- package/dist/core/LambderCreateOptions.d.ts +50 -25
- package/dist/core/LambderCreateOptions.js +37 -18
- 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 +8 -6
- package/dist/index.js +2 -2
- package/dist/invoke/LambderLambdaEvent.d.ts +2 -0
- package/dist/invoke/LambderLambdaEvent.js +8 -2
- package/dist/mock/LambderMockApp.d.ts +8 -8
- package/dist/mock/LambderMockApp.js +14 -10
- package/dist/mock/LambderMockCreateOptions.d.ts +2 -3
- 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/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/wire/LambderApiOptionEntries.d.ts +24 -0
- package/dist/shared/wire/LambderApiOptionEntries.js +13 -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/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 +13 -4
- package/dist/testing/LambderTestApp.js +21 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,125 @@ 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
|
+
## [14.0.1] - 2026-09-30
|
|
13
|
+
|
|
14
|
+
A major: `writeApiGuardParams` writes one export per API in place of one
|
|
15
|
+
table, so a browser carries only the guard parameters its screens import,
|
|
16
|
+
and none of the API names behind them.
|
|
17
|
+
|
|
18
|
+
### Changed (breaking)
|
|
19
|
+
|
|
20
|
+
- **`writeApiGuardParams` writes one export per API** that declares the
|
|
21
|
+
guard, in place of the `guardParams` table: `orders.list` exports
|
|
22
|
+
`ordersListGuardParam`, holding the parameter as declared (`true` for the
|
|
23
|
+
guard named without one) and typed
|
|
24
|
+
`LambderApiGuardParam<"orders.list", "store", literal>`. A bundler keeps
|
|
25
|
+
only the exports a client imports, and none of them carries its API's name,
|
|
26
|
+
which sits in the export's type and doc comment alone, so a page no longer
|
|
27
|
+
ships the name of every API behind the guard and what each asks. The
|
|
28
|
+
module imports the tag type from `lambder/client`, as a type. Two APIs
|
|
29
|
+
whose names would export under one identifier fail the write, and so does
|
|
30
|
+
a `null` parameter, which the tag cannot carry; a guard no API declares
|
|
31
|
+
writes an empty module. A file still in the table form reads as one to
|
|
32
|
+
regenerate, on a check and on a write.
|
|
33
|
+
- To move: regenerate the file, and import the exports a screen gates on
|
|
34
|
+
in place of reading `guardParams[name]`. A function that takes any of a
|
|
35
|
+
guard's parameters reads their union off the module with a type-only
|
|
36
|
+
import (`(typeof params)[keyof typeof params]`, over
|
|
37
|
+
`import type * as params`). Code that needs every API's name, a test
|
|
38
|
+
walking them all, reads it from the `writeApiOptions` file.
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- **`LambderApiGuardParam`** (`lambder/client`, `lambder`): the type of each
|
|
43
|
+
export `writeApiGuardParams` writes, a guard's parameter tagged in types
|
|
44
|
+
alone with the API's name and the guard's, so a function typed to a
|
|
45
|
+
guard's parameters refuses a hand-written literal.
|
|
46
|
+
- **`apiGuardParamExportName`** (`lambder/client`, `lambder`): the identifier
|
|
47
|
+
`writeApiGuardParams` exports an API's parameter under, for code that reads
|
|
48
|
+
a generated module by API name, such as a test comparing it with the
|
|
49
|
+
server.
|
|
50
|
+
|
|
51
|
+
## [13.0.1] - 2026-09-30
|
|
52
|
+
|
|
53
|
+
A major: session data is checked against a schema on every read, and
|
|
54
|
+
`dataRefresh` requires that schema. Beside it, one summary line per API call
|
|
55
|
+
by default, an origin proof for the trusted forwarding headers, refusal codes
|
|
56
|
+
declared per part of an app, a check that every translation keeps its
|
|
57
|
+
placeholders, one round trip for a rate limit's windows, and every generated
|
|
58
|
+
file in one call.
|
|
59
|
+
|
|
60
|
+
### Changed (breaking)
|
|
61
|
+
|
|
62
|
+
- **`session.dataRefresh` requires `session.dataSchema`**, the zod schema of
|
|
63
|
+
`session.data`, at compile time and at creation. Every read checks the
|
|
64
|
+
record's data against it and hands the request the schema's output; data
|
|
65
|
+
that does not match (a record written before a deploy changed its shape)
|
|
66
|
+
is refreshed on that read, whatever its deadline, and a refresh whose
|
|
67
|
+
output the schema refuses fails the read as a
|
|
68
|
+
`LambderSessionDataRefreshError`, writing nothing. Without `dataRefresh`
|
|
69
|
+
the schema is optional, and data that does not match ends the session.
|
|
70
|
+
With a schema, `createSession` and `updateSessionData` throw on data it
|
|
71
|
+
refuses and write nothing, rather than store a session its next read
|
|
72
|
+
would end. A record keeps its data as written or refreshed, and requests
|
|
73
|
+
are served the schema's output, so every read parses exactly what its
|
|
74
|
+
write was checked with. The same holds for the mock runtime's sessions.
|
|
75
|
+
- To move: give `dataSchema` beside `dataRefresh`, the schema your
|
|
76
|
+
`SessionData` type is, and derive the type from it
|
|
77
|
+
(`z.infer<typeof sessionDataSchema>`) so the two cannot drift.
|
|
78
|
+
- **The DynamoDB rate limiter sends every window's write at once**, so a
|
|
79
|
+
policy capping several windows costs one round trip rather than one per
|
|
80
|
+
window. A window under its limit now counts an attempt another window
|
|
81
|
+
refuses (the smaller windows were counted first, and the larger ones
|
|
82
|
+
spared, before), and a window the table refused an attempt in is
|
|
83
|
+
remembered by the process until it resets, so a flood's later attempts
|
|
84
|
+
are refused without touching the table and stop raising the other
|
|
85
|
+
windows' counters. The memory limiter checks every window before counting
|
|
86
|
+
any, so an attempt it refuses counts nowhere.
|
|
87
|
+
- **`LambderApiAnswer` and `LambderResponse` carry the answer's outcome**
|
|
88
|
+
(`outcome`, `callOutcome`), which the envelope builders and
|
|
89
|
+
`res.apiRefusal()` set, and `LambderInstanceBackends` takes a
|
|
90
|
+
`callSummary` writer.
|
|
91
|
+
|
|
92
|
+
### Added
|
|
93
|
+
|
|
94
|
+
- **One summary line per API call** (`callSummary`), written when the call
|
|
95
|
+
is answered: the endpoint, how it ended (`success`, `refusal` and its
|
|
96
|
+
code, a flag, `validation`, `crash`, a failing `created` hook's included),
|
|
97
|
+
the status, the duration and the handler's own time, whether an
|
|
98
|
+
idempotent answer was replayed, whether the invocation was the process's
|
|
99
|
+
first, and its request id with the id of the invocation that invoked it.
|
|
100
|
+
Nothing from the input, the session, the cookies or the caller's address.
|
|
101
|
+
By default each is one JSON line on stdout, which CloudWatch Logs Insights
|
|
102
|
+
reads field by field; a function receives each instead, and `false`
|
|
103
|
+
writes none. A test app collects them in `app.callSummaries`.
|
|
104
|
+
- **The calling invocation's request id travels over invoke**
|
|
105
|
+
(`LAMBDER_PARENT_REQUEST_HEADER`), read by the callee from an invoke
|
|
106
|
+
alone, so the two functions' summary lines join.
|
|
107
|
+
- **`originProof: { header, secrets }`**: the trusted client address and
|
|
108
|
+
host headers are read only from a request carrying the secret the proxy in
|
|
109
|
+
front of the app sets, so a request sent to the origin directly (an
|
|
110
|
+
`execute-api` or `lambda-url` address) is counted by the address it came
|
|
111
|
+
from. The header is taken off `ctx.headers`; `secrets` takes the previous
|
|
112
|
+
secret during a rotation; a secret under 32 characters, a proof guarding
|
|
113
|
+
no trusted header, and a proof header that is also a trusted one are
|
|
114
|
+
refused at creation.
|
|
115
|
+
- **Refusal codes as a list of maps**: `declareRefusals([orderRefusals,
|
|
116
|
+
walletRefusals])`, on the server's init and the mock's alike, so each part
|
|
117
|
+
of an app declares its own codes, as guards and rate-limit policies are
|
|
118
|
+
declared. A code two maps declare is a compile error and a throw.
|
|
119
|
+
- **Every translation keeps the default language's placeholders.** A block
|
|
120
|
+
whose text drops or adds a `{token}` against the default language's text
|
|
121
|
+
of the same key is refused where it arrives: an inline block when the
|
|
122
|
+
instance or an extension is created, a loaded one when its loader answers
|
|
123
|
+
(`loadLanguage` rejects, and the language stays on the default one), a
|
|
124
|
+
registered one at `registerDictionary`. Each error lists every mismatch of
|
|
125
|
+
the block.
|
|
126
|
+
- **`generateApiFiles(config, { check })`** in `lambder/build`: every
|
|
127
|
+
contract, signature file, options file and guard-parameter file of every
|
|
128
|
+
app a generator script names, each app's module given once, in one call
|
|
129
|
+
that names every stale or broken file and does not stop at the first.
|
|
130
|
+
|
|
12
131
|
## [12.0.1] - 2026-09-30
|
|
13
132
|
|
|
14
133
|
A major: endpoints are values. An app declares each endpoint with its
|
package/README.md
CHANGED
|
@@ -65,7 +65,8 @@ const company = await caller.companies.get({ slug: "acme" });
|
|
|
65
65
|
- **Simple route and API declaration.** Paths, regexes, predicates and
|
|
66
66
|
structured matchers, chained fluently.
|
|
67
67
|
- **Sessions.** Over a store of your choosing (DynamoDB, memory, your own),
|
|
68
|
-
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
|
|
69
70
|
cross-subdomain cookies.
|
|
70
71
|
- **One API core, two runtimes.** The request pipeline (envelope, refusals,
|
|
71
72
|
sessions, guards, rate limits, idempotency) is one isomorphic class; the
|
|
@@ -79,6 +80,10 @@ const company = await caller.companies.get({ slug: "acme" });
|
|
|
79
80
|
cookies, and a guard against Lambda's response size cap.
|
|
80
81
|
- **Hooks and actions.** Lifecycle hooks, plus `addAction()` for the non-HTTP
|
|
81
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.
|
|
82
87
|
- **Lambda to lambda calls.** `LambderInvokeCaller` invokes a Lambder app in
|
|
83
88
|
another function directly, with no API Gateway in between, typed from the
|
|
84
89
|
callee's own contract and carrying its refusals, crash detail and logs back.
|
|
@@ -135,7 +140,7 @@ The package ships five entry points; pick by where the code runs:
|
|
|
135
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` |
|
|
136
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 |
|
|
137
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 |
|
|
138
|
-
| `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,
|
|
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, one export per API and nothing else, so a browser gating on that guard carries only the ones it imports; `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 |
|
|
139
144
|
|
|
140
145
|
Frontends and shared isomorphic packages should import from `lambder/client`
|
|
141
146
|
only; the entry's module graph contains no AWS SDK, Node built-ins, or server
|
|
@@ -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
|
|
@@ -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. */
|
|
@@ -3,6 +3,7 @@ import type { LambderJsonOf } from "../shared/wire/LambderApiContract.js";
|
|
|
3
3
|
import type { LambderRefusalStatusCode } from "../shared/wire/LambderHttpStatus.js";
|
|
4
4
|
import type { LambderNoExtraKeys } from "../shared/util/LambderTypeUtilities.js";
|
|
5
5
|
import { LambderApiRefusal } from "../shared/wire/LambderApiRefusal.js";
|
|
6
|
+
import { type LambderMergedNamedMaps, type LambderNamedMapsOption, type LambderNoRepeatedNames } from "../shared/util/LambderNamedMaps.js";
|
|
6
7
|
/**
|
|
7
8
|
* One code of an app's refusal vocabulary: the schema of the data it carries,
|
|
8
9
|
* when it carries data, and how every refusal with the code leaves the
|
|
@@ -69,6 +70,27 @@ export type LambderRefusalVocabularyChecks<TVocabulary> = {
|
|
|
69
70
|
readonly "lambder: a refusal code may not start with lambder/, the prefix of the framework's own codes": never;
|
|
70
71
|
} : LambderNoExtraKeys<TVocabulary[TCode], LambderRefusalDeclaration> & LambderRefusalDataCheck<TVocabulary[TCode]>;
|
|
71
72
|
};
|
|
73
|
+
/**
|
|
74
|
+
* The vocabulary as declareRefusals() takes it: one map of codes, or a list of
|
|
75
|
+
* them, so each part of an app declares its own codes beside its APIs. A
|
|
76
|
+
* code two maps declare is refused.
|
|
77
|
+
*/
|
|
78
|
+
export type LambderRefusalVocabularyOption = LambderNamedMapsOption<LambderRefusalVocabulary>;
|
|
79
|
+
/** The one vocabulary a declareRefusals() option declares: a list's maps merged, a lone map as it is. */
|
|
80
|
+
export type LambderMergedRefusalVocabulary<TOption> = LambderMergedNamedMaps<TOption> extends infer TMerged extends LambderRefusalVocabulary ? TMerged : never;
|
|
81
|
+
/** A declareRefusals() option checked code by code, in each map of a list, and a list declaring no code twice. */
|
|
82
|
+
export type LambderRefusalVocabularyOptionChecks<TOption> = TOption extends readonly unknown[] ? {
|
|
83
|
+
[TIndex in keyof TOption]: LambderRefusalVocabularyChecks<TOption[TIndex]>;
|
|
84
|
+
} & LambderNoRepeatedNames<TOption> : LambderRefusalVocabularyChecks<TOption>;
|
|
85
|
+
/**
|
|
86
|
+
* A declareRefusals() option as the one vocabulary it declares, checked
|
|
87
|
+
* (see readRefusalVocabulary) and keyed. `declarer` names the init in the
|
|
88
|
+
* errors.
|
|
89
|
+
*/
|
|
90
|
+
export declare const declaredRefusalVocabulary: (option: LambderRefusalVocabularyOption, declarer: string) => {
|
|
91
|
+
refusals: LambderRefusalVocabulary;
|
|
92
|
+
vocabulary: Map<string, LambderRefusalDeclaration>;
|
|
93
|
+
};
|
|
72
94
|
/** One code's declaration as checkedRefusal reads it: how a refusal with the code leaves, and its schema. */
|
|
73
95
|
export declare const allowedRefusalOf: (declaration: LambderRefusalDeclaration) => LambderApiAllowedRefusal;
|
|
74
96
|
/**
|
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
import { LambderApiRefusal, isLambderRefusalCode } from "../shared/wire/LambderApiRefusal.js";
|
|
2
2
|
import { describePayloadKind, isObjectPayload } from "../shared/wire/LambderObjectPayload.js";
|
|
3
|
+
import { mergeNamedMaps } from "../shared/util/LambderNamedMaps.js";
|
|
4
|
+
/**
|
|
5
|
+
* A declareRefusals() option as the one vocabulary it declares, checked
|
|
6
|
+
* (see readRefusalVocabulary) and keyed. `declarer` names the init in the
|
|
7
|
+
* errors.
|
|
8
|
+
*/
|
|
9
|
+
export const declaredRefusalVocabulary = (option, declarer) => {
|
|
10
|
+
if (option === null || typeof option !== "object") {
|
|
11
|
+
throw new Error(`${declarer}: declareRefusals() takes the vocabulary, an object of codes or a list of them.`);
|
|
12
|
+
}
|
|
13
|
+
const refusals = mergeNamedMaps(option, "refusal code", "declareRefusals()");
|
|
14
|
+
return { refusals, vocabulary: readRefusalVocabulary(refusals) };
|
|
15
|
+
};
|
|
3
16
|
/** One code's declaration as checkedRefusal reads it: how a refusal with the code leaves, and its schema. */
|
|
4
17
|
export const allowedRefusalOf = (declaration) => {
|
|
5
18
|
const leaves = {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { LambderModuleLocation } from "./moduleLocation.js";
|
|
2
|
+
import { type LambderApiContractFileOptions } from "./writeApiContract.js";
|
|
3
|
+
import { type LambderApiSignatureFileOptions } from "./writeApiSignatures.js";
|
|
4
|
+
import { type LambderApiOptionsFileOptions } from "./writeApiOptions.js";
|
|
5
|
+
import { type LambderApiGuardParamsFileOptions } from "./writeApiGuardParams.js";
|
|
6
|
+
/** What a file's writer takes beyond where the instance is, which the app gives once for all its files. */
|
|
7
|
+
type LambderAppFile<TOptions> = Omit<TOptions, "module" | "exportName" | "tsconfig" | "check">;
|
|
8
|
+
/** One app: where its instance is, and which files it is written to. Each file is optional. */
|
|
9
|
+
export type LambderApiFilesApp = {
|
|
10
|
+
/** The module that exports the instance, usually the server's entry: a path relative to the working directory, or a file URL. */
|
|
11
|
+
module: LambderModuleLocation;
|
|
12
|
+
/** The export that holds the instance. Default: "default". */
|
|
13
|
+
exportName?: string;
|
|
14
|
+
/** The tsconfig.json the module compiles under, which the contract is read through. Default: the nearest one at or above the module. */
|
|
15
|
+
tsconfig?: string;
|
|
16
|
+
/** The contract a client compiles against, as plain types (writeApiContract). */
|
|
17
|
+
contract?: LambderAppFile<LambderApiContractFileOptions>;
|
|
18
|
+
/** The signature file both sides ship (writeApiSignatures). */
|
|
19
|
+
signatures?: LambderAppFile<LambderApiSignatureFileOptions>;
|
|
20
|
+
/** The declared options as plain data, for a mock and tests (writeApiOptions). */
|
|
21
|
+
options?: LambderAppFile<LambderApiOptionsFileOptions>;
|
|
22
|
+
/** One file per guard whose parameters a browser needs (writeApiGuardParams). */
|
|
23
|
+
guardParams?: readonly LambderAppFile<LambderApiGuardParamsFileOptions>[];
|
|
24
|
+
};
|
|
25
|
+
/** The files generateApiFiles writes: every app's, by a name the output lines use. */
|
|
26
|
+
export type LambderApiFilesConfig = {
|
|
27
|
+
apps: Record<string, LambderApiFilesApp>;
|
|
28
|
+
};
|
|
29
|
+
export type LambderApiFilesResult = {
|
|
30
|
+
/** False when a check found a file stale, or a writer failed. */
|
|
31
|
+
ok: boolean;
|
|
32
|
+
/** What happened, per app and file, as lines to print. */
|
|
33
|
+
lines: string[];
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Writes every file the config names, or with `check` writes nothing and
|
|
37
|
+
* answers whether each one on disk is current. Paths are relative to the
|
|
38
|
+
* working directory, as each writer takes them.
|
|
39
|
+
*
|
|
40
|
+
* ```ts
|
|
41
|
+
* import { generateApiFiles } from "lambder/build";
|
|
42
|
+
*
|
|
43
|
+
* const result = await generateApiFiles({
|
|
44
|
+
* apps: {
|
|
45
|
+
* server: {
|
|
46
|
+
* module: "backend/index.ts",
|
|
47
|
+
* exportName: "lambder",
|
|
48
|
+
* contract: { file: "shared/generated/apiContract.generated.ts" },
|
|
49
|
+
* signatures: { file: "shared/generated/apiSignatures.generated.ts" },
|
|
50
|
+
* },
|
|
51
|
+
* },
|
|
52
|
+
* }, { check: process.argv.includes("--check") });
|
|
53
|
+
* console.log(result.lines.join("\n"));
|
|
54
|
+
* process.exit(result.ok ? 0 : 1);
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare const generateApiFiles: (config: LambderApiFilesConfig, options?: {
|
|
58
|
+
check?: boolean;
|
|
59
|
+
}) => Promise<LambderApiFilesResult>;
|
|
60
|
+
export {};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { writeApiContract } from "./writeApiContract.js";
|
|
2
|
+
import { writeApiSignatures } from "./writeApiSignatures.js";
|
|
3
|
+
import { writeApiOptions } from "./writeApiOptions.js";
|
|
4
|
+
import { writeApiGuardParams } from "./writeApiGuardParams.js";
|
|
5
|
+
const APP_KEYS = ["module", "exportName", "tsconfig", "contract", "signatures", "options", "guardParams"];
|
|
6
|
+
const FILE_KINDS = ["contract", "signatures", "options", "guardParams"];
|
|
7
|
+
/** A config that names apps, each with its module and at least one file, and no key the generator does not read. */
|
|
8
|
+
function assertConfig(config) {
|
|
9
|
+
const apps = config?.apps;
|
|
10
|
+
if (apps === null || typeof apps !== "object" || Array.isArray(apps) || Object.keys(apps).length === 0) {
|
|
11
|
+
throw new Error("generateApiFiles: the config names no apps. Give `apps`, one entry per Lambder instance: { module, exportName, and the files it is written to }.");
|
|
12
|
+
}
|
|
13
|
+
for (const [name, app] of Object.entries(apps)) {
|
|
14
|
+
if (app === null || typeof app !== "object")
|
|
15
|
+
throw new Error(`generateApiFiles: the app "${name}" is not an object.`);
|
|
16
|
+
const unknownKeys = Object.keys(app).filter((key) => !APP_KEYS.includes(key));
|
|
17
|
+
if (unknownKeys.length)
|
|
18
|
+
throw new Error(`generateApiFiles: the app "${name}" has ${unknownKeys.map((key) => `"${key}"`).join(", ")}, which the generator does not read (it reads ${APP_KEYS.join(", ")}).`);
|
|
19
|
+
if (!app.module)
|
|
20
|
+
throw new Error(`generateApiFiles: the app "${name}" names no module to read its instance from.`);
|
|
21
|
+
if (!FILE_KINDS.some((kind) => app[kind] !== undefined)) {
|
|
22
|
+
throw new Error(`generateApiFiles: the app "${name}" is written to no file; give it a contract, signatures, options or guardParams.`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** A writer's run as lines: its own on success or a stale check, the reason when it threw. */
|
|
27
|
+
const settle = async (label, write) => {
|
|
28
|
+
try {
|
|
29
|
+
return await write();
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const reasons = [];
|
|
33
|
+
for (let cause = err; cause instanceof Error && reasons.length < 4; cause = cause.cause)
|
|
34
|
+
reasons.push(cause.message);
|
|
35
|
+
return { ok: false, lines: [`✗ ${label} failed: ${reasons.join(": ") || String(err)}`] };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Writes every file the config names, or with `check` writes nothing and
|
|
40
|
+
* answers whether each one on disk is current. Paths are relative to the
|
|
41
|
+
* working directory, as each writer takes them.
|
|
42
|
+
*
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { generateApiFiles } from "lambder/build";
|
|
45
|
+
*
|
|
46
|
+
* const result = await generateApiFiles({
|
|
47
|
+
* apps: {
|
|
48
|
+
* server: {
|
|
49
|
+
* module: "backend/index.ts",
|
|
50
|
+
* exportName: "lambder",
|
|
51
|
+
* contract: { file: "shared/generated/apiContract.generated.ts" },
|
|
52
|
+
* signatures: { file: "shared/generated/apiSignatures.generated.ts" },
|
|
53
|
+
* },
|
|
54
|
+
* },
|
|
55
|
+
* }, { check: process.argv.includes("--check") });
|
|
56
|
+
* console.log(result.lines.join("\n"));
|
|
57
|
+
* process.exit(result.ok ? 0 : 1);
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
export const generateApiFiles = async (config, options = {}) => {
|
|
61
|
+
assertConfig(config);
|
|
62
|
+
const check = options.check ?? false;
|
|
63
|
+
const apps = Object.entries(config.apps);
|
|
64
|
+
const runs = [];
|
|
65
|
+
// The contracts first: they compile the app's sources and run none of
|
|
66
|
+
// them, so a module that fails to load still gets its contract checked.
|
|
67
|
+
for (const [name, app] of apps) {
|
|
68
|
+
if (!app.contract)
|
|
69
|
+
continue;
|
|
70
|
+
runs.push(await settle(`${name}: the contract`, () => writeApiContract({ ...app.contract, module: app.module, exportName: app.exportName, tsconfig: app.tsconfig, check })));
|
|
71
|
+
}
|
|
72
|
+
// Then what is read off the instance itself, which importing the module
|
|
73
|
+
// builds once for all three.
|
|
74
|
+
for (const [name, app] of apps) {
|
|
75
|
+
const instance = { module: app.module, exportName: app.exportName, check };
|
|
76
|
+
if (app.signatures)
|
|
77
|
+
runs.push(await settle(`${name}: the signatures`, () => writeApiSignatures({ ...app.signatures, ...instance })));
|
|
78
|
+
if (app.options)
|
|
79
|
+
runs.push(await settle(`${name}: the options`, () => writeApiOptions({ ...app.options, ...instance })));
|
|
80
|
+
for (const guardFile of app.guardParams ?? []) {
|
|
81
|
+
runs.push(await settle(`${name}: the "${guardFile.guard}" guard parameters`, () => writeApiGuardParams({ ...guardFile, ...instance })));
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { ok: runs.every((run) => run.ok), lines: runs.flatMap((run) => run.lines) };
|
|
85
|
+
};
|
|
@@ -52,7 +52,7 @@ export declare const settleGeneratedFile: (run: {
|
|
|
52
52
|
previousText: string | null;
|
|
53
53
|
/** Whether that text held the tables as written; false for a file rewritten by hand. */
|
|
54
54
|
readBack: boolean;
|
|
55
|
-
/** Each table the file holds, under its exported name: how many entries it has now, and which of them moved. */
|
|
55
|
+
/** Each table the file holds, under the name its lines print (a table's exported name): how many entries it has now, and which of them moved. */
|
|
56
56
|
tables: readonly {
|
|
57
57
|
name: string;
|
|
58
58
|
count: number;
|
|
@@ -12,13 +12,13 @@ export type LambderApiGuardParamsFileOptions = {
|
|
|
12
12
|
exportName?: string;
|
|
13
13
|
/** The guard whose parameters the file holds, as the server declares it. A name the server declares no guard under throws. */
|
|
14
14
|
guard: string;
|
|
15
|
-
/** The TypeScript module to write,
|
|
15
|
+
/** The TypeScript module to write, one export per API that declares the guard. Relative to the working directory. */
|
|
16
16
|
file: string;
|
|
17
17
|
/** Write nothing, and answer whether the file on disk holds what the instance reports now. Default: false. */
|
|
18
18
|
check?: boolean;
|
|
19
19
|
/** The comment the file opens with, one `//` line per line. It should name what generates the file. Default: a note naming writeApiGuardParams() and the guard. */
|
|
20
20
|
header?: string;
|
|
21
|
-
/** End
|
|
21
|
+
/** End each generated statement with a semicolon. Default: true. The values are JSON, double quotes included, whatever the project's style: that is what lets a check read them back. */
|
|
22
22
|
semicolons?: boolean;
|
|
23
23
|
};
|
|
24
24
|
export type LambderApiGuardParamsFileResult = {
|
|
@@ -26,7 +26,7 @@ export type LambderApiGuardParamsFileResult = {
|
|
|
26
26
|
ok: boolean;
|
|
27
27
|
/** The absolute path. */
|
|
28
28
|
file: string;
|
|
29
|
-
/** True when the file was (re)written; a file that already holds
|
|
29
|
+
/** True when the file was (re)written; a file that already holds these parameters is left untouched, however it is formatted. */
|
|
30
30
|
written: boolean;
|
|
31
31
|
/** How many APIs declare the guard. */
|
|
32
32
|
count: number;
|
|
@@ -36,8 +36,8 @@ export type LambderApiGuardParamsFileResult = {
|
|
|
36
36
|
lines: string[];
|
|
37
37
|
};
|
|
38
38
|
/**
|
|
39
|
-
* Writes one guard's parameters as a module of
|
|
40
|
-
* on disk, and says which APIs moved.
|
|
39
|
+
* Writes one guard's parameters as a module of one export per API, or checks
|
|
40
|
+
* the one on disk, and says which APIs moved.
|
|
41
41
|
*
|
|
42
42
|
* ```ts
|
|
43
43
|
* import { writeApiGuardParams } from "lambder/build";
|
|
@@ -51,10 +51,11 @@ export type LambderApiGuardParamsFileResult = {
|
|
|
51
51
|
* });
|
|
52
52
|
* ```
|
|
53
53
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
* that is not plain data
|
|
54
|
+
* API `orders.list` exports `ordersListGuardParam`: the literal it declared,
|
|
55
|
+
* `true` for a guard named without a parameter (the string and list forms),
|
|
56
|
+
* typed LambderApiGuardParam<"orders.list", "store", literal>. Two APIs whose
|
|
57
|
+
* names export under one identifier fail the write, and so does a parameter
|
|
58
|
+
* that is not plain data, as it does for writeApiOptions, or that is null,
|
|
59
|
+
* which no tag can carry.
|
|
59
60
|
*/
|
|
60
61
|
export declare const writeApiGuardParams: (options: LambderApiGuardParamsFileOptions) => Promise<LambderApiGuardParamsFileResult>;
|