lambder 14.1.1 → 15.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 +407 -0
- package/README.md +14 -17
- package/dist/api/LambderApiDeclarations.d.ts +11 -5
- package/dist/api/LambderApiDefinition.d.ts +2 -1
- package/dist/api/LambderApiEnvelope.d.ts +2 -2
- package/dist/api/LambderApiEnvelope.js +8 -2
- package/dist/api/LambderApiGuards.d.ts +44 -5
- package/dist/api/LambderApiGuards.js +27 -0
- package/dist/api/LambderApiIdempotency.d.ts +14 -7
- package/dist/api/LambderApiIdempotency.js +39 -27
- package/dist/api/LambderApiPipeline.d.ts +25 -11
- package/dist/api/LambderApiPipeline.js +34 -17
- package/dist/api/LambderApiRateLimits.d.ts +28 -21
- package/dist/api/LambderApiRateLimits.js +55 -35
- package/dist/api/LambderApiRefusals.d.ts +10 -4
- package/dist/api/LambderApiRefusals.js +31 -5
- package/dist/api/LambderApiRequest.d.ts +21 -1
- package/dist/api/LambderApiRequest.js +31 -2
- package/dist/build/checkApiRefusals.d.ts +7 -2
- package/dist/build/checkApiRefusals.js +56 -29
- package/dist/build/contractPrinterProcess.d.ts +11 -0
- package/dist/build/contractPrinterProcess.js +17 -0
- package/dist/build/generateApiFiles.d.ts +26 -5
- package/dist/build/generateApiFiles.js +68 -7
- package/dist/build/writeApiSchemas.d.ts +109 -0
- package/dist/build/writeApiSchemas.js +308 -0
- package/dist/build.d.ts +3 -1
- package/dist/build.js +2 -0
- package/dist/client/LambderCaller.d.ts +25 -11
- package/dist/client/LambderCaller.js +57 -49
- package/dist/client/LambderUploadRunner.d.ts +55 -27
- package/dist/client/LambderUploadRunner.js +89 -34
- package/dist/client.d.ts +6 -6
- package/dist/client.js +2 -2
- package/dist/core/Lambder.d.ts +31 -21
- package/dist/core/Lambder.js +79 -23
- package/dist/core/LambderCallSummary.d.ts +1 -1
- package/dist/core/LambderContext.d.ts +59 -7
- package/dist/core/LambderContext.js +31 -15
- package/dist/core/LambderCrashHandling.d.ts +5 -0
- package/dist/core/LambderCrashHandling.js +11 -1
- package/dist/core/LambderCreateOptions.d.ts +28 -17
- package/dist/core/LambderCreateOptions.js +14 -7
- package/dist/core/LambderResolver.d.ts +3 -2
- package/dist/core/LambderResponseBuilder.d.ts +20 -9
- package/dist/core/LambderResponseBuilder.js +39 -8
- package/dist/core/LambderTemplatingEngine.d.ts +24 -6
- package/dist/core/LambderTemplatingEngine.js +23 -3
- package/dist/index.d.ts +15 -12
- package/dist/index.js +7 -5
- package/dist/invoke/LambderInvokeCaller.d.ts +25 -4
- package/dist/invoke/LambderInvokeCaller.js +47 -9
- package/dist/invoke/LambderInvokeOutcome.d.ts +7 -6
- package/dist/mock/LambderMockAdapterDoor.d.ts +40 -0
- package/dist/mock/LambderMockAdapterDoor.js +10 -0
- package/dist/mock/LambderMockApiSchemas.d.ts +28 -0
- package/dist/mock/LambderMockApiSchemas.js +150 -0
- package/dist/mock/LambderMockApp.d.ts +56 -48
- package/dist/mock/LambderMockApp.js +107 -62
- package/dist/mock/LambderMockBrowserCookies.js +18 -5
- package/dist/mock/LambderMockCreateOptions.d.ts +51 -4
- package/dist/mock/LambderMockFailureInjector.d.ts +12 -3
- package/dist/mock/LambderMockFailureInjector.js +12 -8
- package/dist/mock/LambderMockTypes.d.ts +8 -4
- package/dist/mock/lambderMockInvokeTransport.js +2 -9
- package/dist/mock/lambderMockMswHandler.d.ts +19 -13
- package/dist/mock/lambderMockMswHandler.js +25 -22
- package/dist/mock.d.ts +1 -0
- package/dist/secrets/LambderOneShotSecrets.d.ts +1 -1
- package/dist/secrets/LambderOneShotSecrets.js +6 -36
- package/dist/secrets/LambderPasswordHasher.d.ts +56 -0
- package/dist/secrets/LambderPasswordHasher.js +168 -0
- package/dist/session/LambderSessionController.d.ts +37 -4
- package/dist/session/LambderSessionController.js +36 -6
- package/dist/session/LambderSessionManager.d.ts +33 -3
- package/dist/session/LambderSessionManager.js +55 -16
- package/dist/shared/LambderHtml.d.ts +23 -4
- package/dist/shared/LambderHtml.js +17 -5
- package/dist/shared/LambderI18n.d.ts +106 -16
- package/dist/shared/LambderI18n.js +259 -74
- package/dist/shared/contracts/LambderCache.d.ts +14 -1
- package/dist/shared/contracts/LambderRateLimiter.d.ts +5 -6
- package/dist/shared/transport/LambderApiTransport.d.ts +3 -2
- package/dist/shared/util/LambderBackoffTimer.d.ts +24 -13
- package/dist/shared/util/LambderBackoffTimer.js +40 -12
- package/dist/shared/util/LambderCallAbort.d.ts +22 -6
- package/dist/shared/util/LambderCallAbort.js +37 -9
- package/dist/shared/util/LambderErrorReportMark.d.ts +4 -0
- package/dist/shared/util/LambderErrorReportMark.js +22 -0
- package/dist/shared/util/LambderKeyFieldDigest.d.ts +46 -0
- package/dist/shared/util/LambderKeyFieldDigest.js +75 -0
- package/dist/shared/util/LambderSignedClaims.d.ts +19 -0
- package/dist/shared/util/LambderSignedClaims.js +54 -0
- package/dist/shared/util/LambderSwappableInstances.d.ts +29 -0
- package/dist/shared/util/LambderSwappableInstances.js +54 -0
- package/dist/shared/util/LambderTestingDoors.d.ts +6 -2
- package/dist/shared/util/LambderTestingDoors.js +6 -2
- package/dist/shared/util/LambderTextDigest.d.ts +5 -5
- package/dist/shared/util/LambderTextDigest.js +5 -5
- package/dist/shared/wire/LambderApiContract.d.ts +1 -1
- package/dist/shared/wire/LambderApiOptionEntries.d.ts +9 -1
- package/dist/shared/wire/LambderApiOptionEntries.js +15 -0
- package/dist/shared/wire/LambderApiOptionValues.d.ts +3 -2
- package/dist/shared/wire/LambderApiOutcome.d.ts +21 -12
- package/dist/shared/wire/LambderApiOutcome.js +1 -1
- package/dist/shared/wire/LambderApiRefusal.d.ts +39 -12
- package/dist/shared/wire/LambderApiRefusal.js +3 -3
- package/dist/shared/wire/LambderApiSchemaEntries.d.ts +21 -0
- package/dist/shared/wire/LambderApiSchemaEntries.js +18 -0
- package/dist/shared/wire/LambderCallOptions.d.ts +3 -3
- package/dist/shared/wire/LambderCallOutcome.d.ts +2 -1
- package/dist/shared/wire/LambderCallOutcome.js +3 -2
- package/dist/shared/wire/LambderIdempotencyKeyScope.d.ts +5 -3
- package/dist/shared/wire/LambderIdempotencyKeyScope.js +5 -3
- package/dist/shared/wire/LambderInvokeApiId.d.ts +3 -3
- package/dist/shared/wire/LambderInvokeApiId.js +3 -3
- package/dist/shared/wire/LambderOutcomeAssertions.d.ts +7 -6
- package/dist/shared/wire/LambderOutcomeAssertions.js +1 -1
- package/dist/stores/LambderCacheValues.d.ts +2 -5
- package/dist/stores/LambderDdbCache.d.ts +15 -9
- package/dist/stores/LambderDdbCache.js +25 -6
- package/dist/stores/LambderDdbIdempotencyStore.d.ts +6 -4
- package/dist/stores/LambderDdbIdempotencyStore.js +32 -52
- package/dist/stores/LambderDdbOneShotSecretStore.d.ts +11 -0
- package/dist/stores/LambderDdbOneShotSecretStore.js +17 -5
- package/dist/stores/LambderDdbSdk.d.ts +59 -29
- package/dist/stores/LambderDdbSdk.js +116 -44
- package/dist/stores/LambderDdbSessionStore.d.ts +26 -11
- package/dist/stores/LambderDdbSessionStore.js +133 -68
- package/dist/stores/LambderMemoryCache.d.ts +2 -2
- package/dist/stores/LambderS3FileSource.d.ts +3 -2
- package/dist/stores/LambderS3FileSource.js +11 -13
- package/dist/stores/LambderS3UploadBucket.d.ts +12 -0
- package/dist/stores/LambderS3UploadBucket.js +23 -0
- package/dist/stores/LambderStorageBackedCache.d.ts +2 -2
- package/dist/stores/LambderStoredText.d.ts +26 -0
- package/dist/stores/LambderStoredText.js +25 -0
- package/dist/testing/LambderConformanceRunner.d.ts +9 -2
- package/dist/testing/LambderConformanceRunner.js +9 -2
- package/dist/testing/LambderTestApp.d.ts +65 -7
- package/dist/testing/LambderTestApp.js +101 -6
- package/dist/testing/LambderTestVisitor.d.ts +43 -12
- package/dist/testing/LambderTestVisitor.js +35 -11
- package/dist/testing/bootLambdaPackage.d.ts +105 -0
- package/dist/testing/bootLambdaPackage.js +265 -0
- package/dist/testing/lambdaPackageBootProcess.d.ts +39 -0
- package/dist/testing/lambdaPackageBootProcess.js +130 -0
- package/dist/testing/lambderCacheConformance.d.ts +26 -0
- package/dist/testing/lambderCacheConformance.js +287 -0
- package/dist/testing/lambderCacheStorageConformance.d.ts +25 -0
- package/dist/testing/lambderCacheStorageConformance.js +159 -0
- package/dist/testing/lambderSessionStoreConformance.d.ts +3 -3
- package/dist/testing/lambderSessionStoreConformance.js +3 -3
- package/dist/testing.d.ts +7 -1
- package/dist/testing.js +5 -0
- package/package.json +1 -6
- package/dist/shared/util/boundKeyField.d.ts +0 -20
- package/dist/shared/util/boundKeyField.js +0 -34
|
@@ -5,6 +5,7 @@ import { LambderAnswerHeaders } from "../shared/wire/LambderAnswerHeaders.js";
|
|
|
5
5
|
import { type LambderResponseTools } from "../api/LambderApiCallContext.js";
|
|
6
6
|
import type LambderSessionController from "../session/LambderSessionController.js";
|
|
7
7
|
import type { LambderApiRateLimitPolicyConfig, LambderContextRateLimit, LambderContextRateLimitCheck, LambderRateLimitCheckResult } from "../api/LambderApiRateLimits.js";
|
|
8
|
+
import type { LambderAppTypes } from "../api/LambderApiDeclarations.js";
|
|
8
9
|
export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
|
|
9
10
|
/**
|
|
10
11
|
* Which API Gateway payload format an event arrived in, and the format its
|
|
@@ -15,6 +16,21 @@ export type LambderHttpEvent = APIGatewayProxyEvent | APIGatewayProxyEventV2;
|
|
|
15
16
|
export type LambderHttpEventFormat = "v1" | "v2";
|
|
16
17
|
/** True for API Gateway HTTP API / Lambda Function URL (payload v2) events. */
|
|
17
18
|
export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxyEventV2;
|
|
19
|
+
/**
|
|
20
|
+
* How a request reached the function, as `ctx.arrivedVia` says it:
|
|
21
|
+
* - "proxy": over HTTP, carrying a valid `originProof`, so it came through
|
|
22
|
+
* the proxy in front of the app;
|
|
23
|
+
* - "direct": over HTTP without a valid proof, sent to the gateway's own
|
|
24
|
+
* address (an `execute-api` or `lambda-url` domain) rather than through the
|
|
25
|
+
* proxy, so the proxy's headers on it are whatever its sender wrote and
|
|
26
|
+
* have been taken off;
|
|
27
|
+
* - "invoke": a Lambda invoke, told by the event's `requestContext.apiId`,
|
|
28
|
+
* which a gateway writes itself and no HTTP client can set, so only a
|
|
29
|
+
* caller IAM let invoke the function sends one;
|
|
30
|
+
* - "unverified": over HTTP on an instance with no `originProof`, where
|
|
31
|
+
* nothing tells a proxied request from a direct one.
|
|
32
|
+
*/
|
|
33
|
+
export type LambderRequestArrival = "proxy" | "direct" | "invoke" | "unverified";
|
|
18
34
|
/**
|
|
19
35
|
* Everything a route or API handler knows about the request. Extends the
|
|
20
36
|
* API core's call context (session, guardData, responseHeaders, logList),
|
|
@@ -24,15 +40,17 @@ export declare const isV2HttpEvent: (event: unknown) => event is APIGatewayProxy
|
|
|
24
40
|
* and the response tools (setResponseHeader, addResponseHeader, setCookie,
|
|
25
41
|
* clearCookie) that write onto whatever answer the request ends with.
|
|
26
42
|
*
|
|
27
|
-
* TRateLimitPolicies is the app's policies map on
|
|
28
|
-
*
|
|
29
|
-
* checked where it is charged; anywhere else (a
|
|
30
|
-
*
|
|
43
|
+
* TRateLimitPolicies is the app's policies map on an API handler (defineApi)
|
|
44
|
+
* and on a route registered by a path string (addRoute, addSessionRoute), so
|
|
45
|
+
* a policy name is checked where it is charged; anywhere else (a route
|
|
46
|
+
* matched by a RegExp, a predicate or a matcher object, a hook, a guard) the
|
|
47
|
+
* names are any string. LambderRenderContextOf names the typed form for a
|
|
48
|
+
* helper that takes one instance's context.
|
|
31
49
|
*/
|
|
32
50
|
export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}, TSessionData = any, TRateLimitPolicies = Record<string, LambderApiRateLimitPolicyConfig>> = {
|
|
33
51
|
/**
|
|
34
52
|
* The Host the gateway received, or the first header named in
|
|
35
|
-
* `trustedHostHeaders` that carries a well-formed host. On a
|
|
53
|
+
* `trustedHostHeaders` that carries a well-formed host. On a Lambda
|
|
36
54
|
* invoke, the invoking caller's `host`, whatever headers it forwarded.
|
|
37
55
|
*/
|
|
38
56
|
host: string;
|
|
@@ -93,7 +111,7 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
93
111
|
/**
|
|
94
112
|
* The address the gateway observed, or the leftmost entry of the first
|
|
95
113
|
* header named in `trustedClientIpHeaders` that carries one; nothing is
|
|
96
|
-
* trusted by default, and no header on a
|
|
114
|
+
* trusted by default, and no header on a Lambda invoke, whose address is
|
|
97
115
|
* the invoking caller's `clientIp`. One spelling per address (port and brackets
|
|
98
116
|
* stripped, lowercased, length-bounded), so a `per: "ip"` limit keys one
|
|
99
117
|
* counter per client.
|
|
@@ -105,6 +123,12 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
105
123
|
lambdaContext: Context;
|
|
106
124
|
/** Which API Gateway payload format the event arrived in, and the response leaves in. */
|
|
107
125
|
eventFormat: LambderHttpEventFormat;
|
|
126
|
+
/**
|
|
127
|
+
* How the request reached the function (see LambderRequestArrival):
|
|
128
|
+
* through the proxy with a valid `originProof`, without it, by a
|
|
129
|
+
* Lambda invoke, or, with no `originProof` configured, unverified.
|
|
130
|
+
*/
|
|
131
|
+
readonly arrivedVia: LambderRequestArrival;
|
|
108
132
|
/** Response headers written during the request (the response tools below, session cookies), applied onto the response at the end. */
|
|
109
133
|
responseHeaders: LambderAnswerHeaders;
|
|
110
134
|
/** Entries for the API envelope's logList channel: a handler pushes what it wants the caller's debug log to show. */
|
|
@@ -132,6 +156,25 @@ export type LambderRenderContext<TApiPayload = any, TPathParams extends Record<s
|
|
|
132
156
|
export type LambderSessionRenderContext<TApiPayload = any, SessionData = any, TPathParams extends Record<string, string> = Record<string, string>, TGuardData = {}, TRateLimitPolicies = Record<string, LambderApiRateLimitPolicyConfig>> = Omit<LambderRenderContext<TApiPayload, TPathParams, TGuardData, SessionData, TRateLimitPolicies>, 'session'> & {
|
|
133
157
|
session: LambderSessionRecord<SessionData>;
|
|
134
158
|
};
|
|
159
|
+
/** What the context helpers below read off an instance: the AppTypes property every instance carries. */
|
|
160
|
+
type LambderInstanceTypes = {
|
|
161
|
+
readonly AppTypes: LambderAppTypes;
|
|
162
|
+
};
|
|
163
|
+
/**
|
|
164
|
+
* One instance's render context, read off `typeof lambderApp`: its session
|
|
165
|
+
* data and its rate-limit policy names, any payload, path parameters and
|
|
166
|
+
* guard data. What a helper in another file takes to be handed a context of
|
|
167
|
+
* that instance, without writing LambderRenderContext's parameters out: an
|
|
168
|
+
* API handler's, a route's, a hook's and a guard's context are each
|
|
169
|
+
* assignable to it.
|
|
170
|
+
*/
|
|
171
|
+
export type LambderRenderContextOf<TInstance extends LambderInstanceTypes> = LambderRenderContext<any, Record<string, string>, {}, TInstance["AppTypes"]["session"], TInstance["AppTypes"]["policies"]>;
|
|
172
|
+
/**
|
|
173
|
+
* LambderRenderContextOf with the session present: what a helper takes to be
|
|
174
|
+
* handed the context of a session route, a session endpoint's handler or a
|
|
175
|
+
* guard that needs a session, each assignable to it.
|
|
176
|
+
*/
|
|
177
|
+
export type LambderSessionRenderContextOf<TInstance extends LambderInstanceTypes> = LambderSessionRenderContext<any, TInstance["AppTypes"]["session"], Record<string, string>, {}, TInstance["AppTypes"]["policies"]>;
|
|
135
178
|
/** The members of a render context that are bound onto it rather than read from its event. */
|
|
136
179
|
type LambderContextToolName = "sessionController" | "rateLimit" | "isRateLimited" | keyof LambderResponseTools;
|
|
137
180
|
/** What an instance binds onto each context it renders: see bindContextTools. */
|
|
@@ -157,6 +200,15 @@ export type LambderOriginProof = {
|
|
|
157
200
|
header: string;
|
|
158
201
|
/** The values it may carry: the current secret, and during a rotation the one before it. */
|
|
159
202
|
secrets: readonly string[];
|
|
203
|
+
/**
|
|
204
|
+
* Headers the proxy writes that the app reads, beside the trusted client
|
|
205
|
+
* address and host headers, such as "cf-ipcountry" or
|
|
206
|
+
* "cloudfront-viewer-country". A request without the proof carries
|
|
207
|
+
* whatever its sender wrote under these names, so they are taken off
|
|
208
|
+
* `ctx.headers` and `ctx.header()` there, as the trusted headers are.
|
|
209
|
+
* Default: none.
|
|
210
|
+
*/
|
|
211
|
+
proxyHeaders?: readonly string[];
|
|
160
212
|
};
|
|
161
213
|
/** What createContext reads a request with: the instance's own settings for where an API call goes and which forwarded headers it trusts. */
|
|
162
214
|
export type LambderContextOptions = {
|
|
@@ -166,7 +218,7 @@ export type LambderContextOptions = {
|
|
|
166
218
|
trustedClientIpHeaders?: readonly string[];
|
|
167
219
|
/** See `trustedHostHeaders` at create(). Default: none. */
|
|
168
220
|
trustedHostHeaders?: readonly string[];
|
|
169
|
-
/** See `originProof` at create(). Default: none, so the trusted headers are read on every request. */
|
|
221
|
+
/** See `originProof` at create(). Default: none, so the trusted headers are read on every request and `arrivedVia` is "unverified" on all but an invoke. */
|
|
170
222
|
originProof?: LambderOriginProof | null;
|
|
171
223
|
};
|
|
172
224
|
/** The render context for one request: everything a route handler, an API handler, a hook or a guard reads about it, built once from the Lambda event. */
|
|
@@ -131,25 +131,31 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
131
131
|
// The origin proof is read, then taken off the headers the app reads
|
|
132
132
|
// (ctx.headers, ctx.header()), so no handler, hook or log of them meets
|
|
133
133
|
// the secret; the raw event keeps it. A request that does not carry it
|
|
134
|
-
// came to the origin some other way than through the proxy, and
|
|
135
|
-
// proxy
|
|
134
|
+
// came to the origin some other way than through the proxy, and every
|
|
135
|
+
// header the proxy writes is, on it, whatever its sender wrote: those
|
|
136
|
+
// are taken off too, so a handler reading one (a country, a viewer's
|
|
137
|
+
// address) reads what the proxy said or nothing.
|
|
136
138
|
let proven = true;
|
|
137
139
|
if (originProof) {
|
|
138
|
-
const
|
|
139
|
-
const presented = lowercasedHeaders[proofHeader] ?? "";
|
|
140
|
+
const presented = lowercasedHeaders[originProof.header.toLowerCase()] ?? "";
|
|
140
141
|
proven = originProof.secrets.some((secret) => constantTimeEquals(presented, secret));
|
|
141
|
-
|
|
142
|
-
|
|
142
|
+
const senderWritten = proven ? [] : [...trustedClientIpHeaders, ...trustedHostHeaders, ...(originProof.proxyHeaders ?? [])];
|
|
143
|
+
const removed = new Set([originProof.header, ...senderWritten].map((name) => name.toLowerCase()));
|
|
144
|
+
for (const name of removed)
|
|
145
|
+
delete lowercasedHeaders[name];
|
|
146
|
+
headers = Object.fromEntries(Object.entries(headers).filter(([name]) => !removed.has(name.toLowerCase())));
|
|
143
147
|
}
|
|
144
148
|
const header = (name) => lowercasedHeaders[name.toLowerCase()];
|
|
145
149
|
// A trusted forwarding header is trusted because a proxy in front of this
|
|
146
|
-
// function writes it. A
|
|
150
|
+
// function writes it. A Lambda invoke has no such proxy: its headers are
|
|
147
151
|
// whatever the invoking code passed on, and a gateway lambda forwarding a
|
|
148
152
|
// browser's request passes on the browser's own. So on an invoke the
|
|
149
153
|
// address and the host are the ones the invoker named (clientIp and host,
|
|
150
|
-
// delivered as sourceIp and Host), and no header is read for either.
|
|
154
|
+
// delivered as sourceIp and Host), and no header is read for either. An
|
|
155
|
+
// invoke is told by the apiId, which a gateway writes itself.
|
|
151
156
|
const invokedDirectly = event.requestContext?.apiId === LAMBDER_INVOKE_API_ID;
|
|
152
157
|
const readsForwarding = proven && !invokedDirectly;
|
|
158
|
+
const arrivedVia = invokedDirectly ? "invoke" : !originProof ? "unverified" : proven ? "proxy" : "direct";
|
|
153
159
|
// The first trusted header carrying a well-formed host, leftmost entry,
|
|
154
160
|
// else the host the gateway saw. Behind CloudFront a Function URL sees
|
|
155
161
|
// its own lambda-url domain, since CloudFront sends an origin its own
|
|
@@ -163,21 +169,30 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
163
169
|
}
|
|
164
170
|
}
|
|
165
171
|
const ip = resolveClientIp(lowercasedHeaders, sourceIp, readsForwarding ? trustedClientIpHeaders : []);
|
|
166
|
-
// Decode body: keep the raw string, then
|
|
172
|
+
// Decode body: keep the raw string, then read it as a JSON object, or as
|
|
173
|
+
// urlencoded fields when it is not JSON. ctx.post is an object whatever
|
|
174
|
+
// the body was: a JSON number, string, boolean, null or array is not
|
|
175
|
+
// fields, and every reader (a handler, a hook, the API path writing the
|
|
176
|
+
// restored payload onto it) indexes into it. Such a body stays readable
|
|
177
|
+
// as ctx.rawBody. What it means for an API call is readApiEnvelope's to
|
|
178
|
+
// say, from the parse itself.
|
|
167
179
|
const rawBody = event.isBase64Encoded
|
|
168
180
|
? (event.body ? base64ToText(event.body) : "")
|
|
169
181
|
: (event.body || "");
|
|
182
|
+
let posted;
|
|
183
|
+
let bodyNotJson = false;
|
|
170
184
|
let post = {};
|
|
171
185
|
try {
|
|
172
|
-
|
|
186
|
+
posted = JSON.parse(rawBody || "{}");
|
|
173
187
|
}
|
|
174
|
-
catch
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
for (const [key, value] of params.entries()) {
|
|
188
|
+
catch {
|
|
189
|
+
bodyNotJson = true;
|
|
190
|
+
for (const [key, value] of new URLSearchParams(rawBody).entries()) {
|
|
178
191
|
post[key] = value;
|
|
179
192
|
}
|
|
180
193
|
}
|
|
194
|
+
if (posted !== null && typeof posted === "object" && !Array.isArray(posted))
|
|
195
|
+
post = posted;
|
|
181
196
|
// A JSON POST to `{apiPath}/{group}/{action}` is a call to that endpoint;
|
|
182
197
|
// the core reads the envelope, and everything downstream reads ctx.api.
|
|
183
198
|
// JSON only (isApiCallContentType): any site can submit a plain HTML form
|
|
@@ -188,7 +203,7 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
188
203
|
const isJsonPost = method === "POST" && !!apiPath && isApiCallContentType(lowercasedHeaders);
|
|
189
204
|
const calledName = isJsonPost ? apiNameOfCallPath(apiPath, path) : null;
|
|
190
205
|
const requestInfo = { headers: lowercasedHeaders, cookies: cookieList, ip, host };
|
|
191
|
-
const api = calledName !== null ? readApiEnvelope(
|
|
206
|
+
const api = calledName !== null ? readApiEnvelope(posted, requestInfo, calledName, bodyNotJson ? { bodyNotJson: true } : {})
|
|
192
207
|
: isJsonPost && path === apiPath && typeof post.apiName === "string" && post.apiName !== ""
|
|
193
208
|
? readApiEnvelope(post, requestInfo, post.apiName, { retiredPath: true })
|
|
194
209
|
: null;
|
|
@@ -203,6 +218,7 @@ export const createContext = (event, lambdaContext, { apiPath = DEFAULT_API_PATH
|
|
|
203
218
|
headers, rawBody, ip, header,
|
|
204
219
|
lambdaContext,
|
|
205
220
|
eventFormat,
|
|
221
|
+
arrivedVia,
|
|
206
222
|
responseHeaders: new LambderAnswerHeaders(),
|
|
207
223
|
logList: [],
|
|
208
224
|
}, UNBOUND_CONTEXT_TOOLS);
|
|
@@ -22,6 +22,11 @@ export declare class LambderCrashHandling {
|
|
|
22
22
|
* with it as unfinished, and either way the request goes on to be
|
|
23
23
|
* answered. The report itself cannot be cancelled and runs on; only the
|
|
24
24
|
* wait ends.
|
|
25
|
+
*
|
|
26
|
+
* An error already reported where it arose (an invoke failure its
|
|
27
|
+
* caller's onFailure took, which api() then threw) is not handed over
|
|
28
|
+
* again: it is still a crash, answered as one, but reporting it here
|
|
29
|
+
* would record the one failure twice.
|
|
25
30
|
*/
|
|
26
31
|
report(error: Error, site: LambderCrashSite): Promise<void>;
|
|
27
32
|
/**
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { crashAnswer } from "../api/LambderApiEnvelope.js";
|
|
2
2
|
import { describeCrash } from "../shared/wire/LambderCrashDetail.js";
|
|
3
3
|
import { stopWaitingWhenAborted } from "../shared/util/LambderCallAbort.js";
|
|
4
|
+
import { isErrorReported } from "../shared/util/LambderErrorReportMark.js";
|
|
4
5
|
import { LambderResponse, responseFromAnswer } from "./LambderResponse.js";
|
|
5
6
|
/**
|
|
6
7
|
* How long a crash's answer waits for the reporter by default: long enough
|
|
@@ -33,10 +34,15 @@ export class LambderCrashHandling {
|
|
|
33
34
|
* with it as unfinished, and either way the request goes on to be
|
|
34
35
|
* answered. The report itself cannot be cancelled and runs on; only the
|
|
35
36
|
* wait ends.
|
|
37
|
+
*
|
|
38
|
+
* An error already reported where it arose (an invoke failure its
|
|
39
|
+
* caller's onFailure took, which api() then threw) is not handed over
|
|
40
|
+
* again: it is still a crash, answered as one, but reporting it here
|
|
41
|
+
* would record the one failure twice.
|
|
36
42
|
*/
|
|
37
43
|
async report(error, site) {
|
|
38
44
|
const report = this.options.report;
|
|
39
|
-
if (!report)
|
|
45
|
+
if (!report || isErrorReported(error))
|
|
40
46
|
return;
|
|
41
47
|
const deadline = AbortSignal.timeout(this.reportTimeoutMs);
|
|
42
48
|
try {
|
|
@@ -74,8 +80,12 @@ export class LambderCrashHandling {
|
|
|
74
80
|
if (ctx?.api) {
|
|
75
81
|
return responseFromAnswer(crashAnswer(this.apiVersion, revealed ? { crash: revealed, logList: ctx.logList } : undefined));
|
|
76
82
|
}
|
|
83
|
+
// Typed as the text it is, as the framework's own 404 is: with no
|
|
84
|
+
// Content-Type a browser sniffs the body, and a crash message that
|
|
85
|
+
// reads like markup would render as a page.
|
|
77
86
|
return new LambderResponse({
|
|
78
87
|
statusCode: 500,
|
|
88
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
79
89
|
body: revealed
|
|
80
90
|
? ["Internal Server Error.", "", revealed.stack ?? `${revealed.name}: ${revealed.message}`,
|
|
81
91
|
...(revealed.causeList ?? []).map((cause) => `Caused by: ${cause.stack ?? `${cause.name}: ${cause.message}`}`)].join("\n")
|
|
@@ -128,7 +128,14 @@ export type LambderSessionOptions<TSessionData = any> = {
|
|
|
128
128
|
* the store, so ctx.session.data would be typed by the table.
|
|
129
129
|
*/
|
|
130
130
|
store: LambderSessionStore<any>;
|
|
131
|
-
/**
|
|
131
|
+
/**
|
|
132
|
+
* The HMAC key that turns a sessionKey into the store's partition key, so
|
|
133
|
+
* a table read does not reveal whose sessions it holds. A subkey derived
|
|
134
|
+
* from it (HKDF) also keys the at-rest digests of the caller's fields in
|
|
135
|
+
* every rate-limit key and idempotency scope. Changing it signs every
|
|
136
|
+
* session out and starts every rate-limit counter and idempotency record
|
|
137
|
+
* afresh. Treat as a secret.
|
|
138
|
+
*/
|
|
132
139
|
sessionSalt: string;
|
|
133
140
|
enableSlidingExpiration?: boolean;
|
|
134
141
|
/** Min seconds between sliding-expiration writes. Default: max(60, 5% of TTL). */
|
|
@@ -203,8 +210,8 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
203
210
|
* a change the signatures cannot see (a security fix, a field whose
|
|
204
211
|
* meaning changed under the same shape). Dotted numbers ("1.2.10"),
|
|
205
212
|
* compared segment by segment; a call naming no version is not judged. A
|
|
206
|
-
* floor above `apiVersion`
|
|
207
|
-
*
|
|
213
|
+
* floor above `apiVersion` throws at creation, since it would refuse this
|
|
214
|
+
* build's own clients. Default: none.
|
|
208
215
|
*/
|
|
209
216
|
minApiVersion?: string;
|
|
210
217
|
/**
|
|
@@ -248,7 +255,7 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
248
255
|
* `per: "ip"` rate limits key off ctx.ip, and an address the caller picks
|
|
249
256
|
* per request is not a limit. API Gateway APPENDS to x-forwarded-for, so
|
|
250
257
|
* behind API Gateway alone the leftmost entry is the client's own claim
|
|
251
|
-
* and the header should be left out. A
|
|
258
|
+
* and the header should be left out. A Lambda invoke reads none of these:
|
|
252
259
|
* its ctx.ip is the invoker's `clientIp`.
|
|
253
260
|
*/
|
|
254
261
|
trustedClientIpHeaders?: readonly string[];
|
|
@@ -260,24 +267,28 @@ export type LambderCreateOptions<TSessionData = any> = {
|
|
|
260
267
|
*
|
|
261
268
|
* The same rule as trustedClientIpHeaders: ctx.host decides cookie
|
|
262
269
|
* domains and host-matched routes, and a host a client picks is a tenant
|
|
263
|
-
* a client picks. A
|
|
270
|
+
* a client picks. A Lambda invoke reads none of these: its ctx.host is the
|
|
264
271
|
* invoker's `host`.
|
|
265
272
|
*/
|
|
266
273
|
trustedHostHeaders?: readonly string[];
|
|
267
274
|
/**
|
|
268
275
|
* Proof that a request came through the proxy the trusted headers are
|
|
269
|
-
* trusted for: `{ header, secrets }`, a header the proxy
|
|
270
|
-
* request it forwards (a Cloudflare transform rule, a
|
|
271
|
-
* custom header) and the secret it carries, with the
|
|
272
|
-
* it during a rotation. The trusted client address
|
|
273
|
-
* read only from a request that carries it. One
|
|
274
|
-
* directly (an API Gateway's own execute-api URL, a
|
|
275
|
-
* its address and host from the gateway, whatever
|
|
276
|
-
* a `per: "ip"` limit counts it by the address it
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
* on
|
|
280
|
-
*
|
|
276
|
+
* trusted for: `{ header, secrets, proxyHeaders? }`, a header the proxy
|
|
277
|
+
* sets on every request it forwards (a Cloudflare transform rule, a
|
|
278
|
+
* CloudFront origin custom header) and the secret it carries, with the
|
|
279
|
+
* previous one beside it during a rotation. The trusted client address
|
|
280
|
+
* and host headers are read only from a request that carries it. One
|
|
281
|
+
* sent to the origin directly (an API Gateway's own execute-api URL, a
|
|
282
|
+
* Function URL) has its address and host from the gateway, whatever
|
|
283
|
+
* headers it wrote, so a `per: "ip"` limit counts it by the address it
|
|
284
|
+
* really came from, and the trusted headers and `proxyHeaders` (the
|
|
285
|
+
* other headers only the proxy writes) are taken off `ctx.headers` and
|
|
286
|
+
* `ctx.header()` on it. `ctx.arrivedVia` says which it was: "proxy" or
|
|
287
|
+
* "direct", or "invoke" for a Lambda invoke. The proof header itself is
|
|
288
|
+
* always taken off, so no handler or hook meets the secret. Default:
|
|
289
|
+
* none, so the trusted headers are read on every request, which is right
|
|
290
|
+
* only for an origin nothing but the proxy can reach, and
|
|
291
|
+
* `ctx.arrivedVia` is "unverified" on all but an invoke.
|
|
281
292
|
*/
|
|
282
293
|
originProof?: LambderOriginProof;
|
|
283
294
|
/**
|
|
@@ -54,10 +54,16 @@ export const assertCreateOptions = (options) => {
|
|
|
54
54
|
};
|
|
55
55
|
/** Shortest secret an origin proof takes: one a sender could guess is no proof. 32 characters of hex or base64 is 128 bits or more. */
|
|
56
56
|
export const MIN_ORIGIN_PROOF_SECRET_LENGTH = 32;
|
|
57
|
-
/**
|
|
57
|
+
/** A header name as a proxy writes one. */
|
|
58
|
+
const HEADER_NAME_PATTERN = /^[A-Za-z0-9-]+$/;
|
|
59
|
+
/**
|
|
60
|
+
* An origin proof that proves something: a header name, secrets long enough
|
|
61
|
+
* not to guess, and proxy headers that are header names. It needs no
|
|
62
|
+
* trusted header to guard: ctx.arrivedVia is worth having on its own.
|
|
63
|
+
*/
|
|
58
64
|
const assertOriginProof = (options) => {
|
|
59
65
|
const proof = options.originProof;
|
|
60
|
-
if (typeof proof?.header !== "string" ||
|
|
66
|
+
if (typeof proof?.header !== "string" || !HEADER_NAME_PATTERN.test(proof.header)) {
|
|
61
67
|
throw new Error("Lambder: originProof.header must be a header name, such as \"x-origin-proof\".");
|
|
62
68
|
}
|
|
63
69
|
if (!Array.isArray(proof.secrets) || proof.secrets.length === 0) {
|
|
@@ -68,11 +74,12 @@ const assertOriginProof = (options) => {
|
|
|
68
74
|
throw new Error(`Lambder: every originProof secret must be a string of at least ${MIN_ORIGIN_PROOF_SECRET_LENGTH} characters; a shorter one could be guessed, and a guessed proof proves nothing.`);
|
|
69
75
|
}
|
|
70
76
|
}
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
73
|
-
throw new Error("Lambder: originProof
|
|
77
|
+
const proxyHeaders = proof.proxyHeaders ?? [];
|
|
78
|
+
if (!Array.isArray(proxyHeaders) || proxyHeaders.some((name) => typeof name !== "string" || !HEADER_NAME_PATTERN.test(name))) {
|
|
79
|
+
throw new Error("Lambder: originProof.proxyHeaders must list header names the proxy writes, such as [\"cf-ipcountry\"].");
|
|
74
80
|
}
|
|
75
|
-
|
|
76
|
-
|
|
81
|
+
const proxyWritten = [...(options.trustedClientIpHeaders ?? []), ...(options.trustedHostHeaders ?? []), ...proxyHeaders];
|
|
82
|
+
if (proxyWritten.some((name) => name.toLowerCase() === proof.header.toLowerCase())) {
|
|
83
|
+
throw new Error(`Lambder: originProof.header "${proof.header}" is also a trusted or proxy header; the proof is its own header, which the app never reads.`);
|
|
77
84
|
}
|
|
78
85
|
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import LambderResponseBuilder from "./LambderResponseBuilder.js";
|
|
1
|
+
import LambderResponseBuilder, { type LambderTemplateFileOptions } from "./LambderResponseBuilder.js";
|
|
2
2
|
import type { LambderResponse } from "./LambderResponse.js";
|
|
3
|
+
import type { LambderTemplateData } from "./LambderTemplatingEngine.js";
|
|
3
4
|
type SyncDie<T extends (...args: any[]) => LambderResponse> = (...args: Parameters<T>) => never;
|
|
4
5
|
type AsyncDie<T extends (...args: any[]) => Promise<LambderResponse>> = (...args: Parameters<T>) => Promise<never>;
|
|
5
6
|
/** The `res.die.*` surface: every builder method, throwing what it built. Internal to the resolver, which is the only thing that has one. */
|
|
@@ -16,7 +17,7 @@ interface DieResolverMethods {
|
|
|
16
17
|
fileBase64: SyncDie<LambderResponseBuilder["fileBase64"]>;
|
|
17
18
|
apiRefusal: SyncDie<LambderResponseBuilder["apiRefusal"]>;
|
|
18
19
|
file: AsyncDie<LambderResponseBuilder["file"]>;
|
|
19
|
-
templateFile:
|
|
20
|
+
templateFile: <TNames extends string = string>(filePath: string, data?: LambderTemplateData<TNames>, options?: LambderTemplateFileOptions) => Promise<never>;
|
|
20
21
|
}
|
|
21
22
|
/**
|
|
22
23
|
* Response builder passed to route handlers and hooks.
|
|
@@ -16,6 +16,11 @@ export type LambderResponseOptions = {
|
|
|
16
16
|
/** "auto" (default): ETag on GET/HEAD 200 when globally enabled. true: force. false: never. */
|
|
17
17
|
etag?: boolean | "auto";
|
|
18
18
|
};
|
|
19
|
+
/** res.templateFile's options: a response's, and how the file is compiled. */
|
|
20
|
+
export type LambderTemplateFileOptions = LambderResponseOptions & {
|
|
21
|
+
/** Expose the <title> content as slot "title" and the point before </head> as slot "head" (see LambderTemplatingEngineOptions). */
|
|
22
|
+
htmlVirtualSlots?: boolean;
|
|
23
|
+
};
|
|
19
24
|
export type LambderRawResponseInit = {
|
|
20
25
|
statusCode: LambderHttpStatusCode;
|
|
21
26
|
headers?: LambderHeadersInput;
|
|
@@ -44,11 +49,16 @@ export default class LambderResponseBuilder {
|
|
|
44
49
|
private requireFiles;
|
|
45
50
|
raw(init: LambderRawResponseInit): LambderResponse;
|
|
46
51
|
json(data: Record<string, any>, options?: LambderResponseOptions): LambderResponse;
|
|
52
|
+
/** A plain-text body, sent as text: the place for a message, whatever status it goes out with (`{ statusCode }`). */
|
|
47
53
|
text(data: string, options?: LambderResponseOptions): LambderResponse;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
54
|
+
/** An XML document (a sitemap, a feed, SVG) built with xml`...` or marked safe with raw(). */
|
|
55
|
+
xml(data: LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
56
|
+
/** An HTML page built with html`...` or marked safe with raw(). */
|
|
57
|
+
html(data: LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
58
|
+
/** An HTML response with any status code, its body built like res.html's; no body sends an empty one. */
|
|
59
|
+
status(statusCode: LambderHttpStatusCode, body?: LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
60
|
+
/** An HTML 404, its body built like res.html's. */
|
|
61
|
+
status404(data: LambderSafeHtml, options?: LambderResponseOptions): LambderResponse;
|
|
52
62
|
/**
|
|
53
63
|
* A redirect to `url`, which may be a path or a whole URL. A path stays on
|
|
54
64
|
* this origin: a leading run of slashes and backslashes collapses to one
|
|
@@ -72,12 +82,13 @@ export default class LambderResponseBuilder {
|
|
|
72
82
|
* LambderTemplatingEngine (comment-based slots/conditionals) and return
|
|
73
83
|
* it as an HTML response. The compiled template is cached on the
|
|
74
84
|
* instance across warm invocations; a missing file throws (it is a
|
|
75
|
-
* server-side configuration error, not a client 404)
|
|
76
|
-
*
|
|
85
|
+
* server-side configuration error, not a client 404), and so does a data
|
|
86
|
+
* key the file has no slot or condition for, naming the file. TNames, when
|
|
87
|
+
* given, is the file's slot and condition names, so a misspelled key is a
|
|
88
|
+
* compile error. Set htmlVirtualSlots to expose "title"/"head" slots on
|
|
89
|
+
* marker-less files.
|
|
77
90
|
*/
|
|
78
|
-
templateFile(filePath: string, data?: LambderTemplateData
|
|
79
|
-
htmlVirtualSlots?: boolean;
|
|
80
|
-
}): Promise<LambderResponse>;
|
|
91
|
+
templateFile<TNames extends string = string>(filePath: string, data?: LambderTemplateData<TNames>, options?: LambderTemplateFileOptions): Promise<LambderResponse>;
|
|
81
92
|
/**
|
|
82
93
|
* An API call answered from outside its handler: what a hook, a fallback,
|
|
83
94
|
* the input validation handler or the global error handler answers with.
|
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { outcomeOfEnvelope } from "../shared/wire/LambderCallOutcome.js";
|
|
2
2
|
import { LambderResponse } from "./LambderResponse.js";
|
|
3
|
+
import { LambderSafeHtml } from "../shared/LambderHtml.js";
|
|
3
4
|
import { plainRefusalEnvelope } from "../api/LambderApiEnvelope.js";
|
|
5
|
+
/**
|
|
6
|
+
* The body of a response a browser renders as markup: safe markup only. A
|
|
7
|
+
* plain string is refused rather than sent, since nothing can tell markup an
|
|
8
|
+
* author wrote from text a request supplied (`"No match at " + ctx.path`),
|
|
9
|
+
* and the latter, sent as a page, runs as script. The tag escapes what it
|
|
10
|
+
* interpolates, raw() is the one visible place where trusted markup passes as
|
|
11
|
+
* it is, and text goes out as text.
|
|
12
|
+
*/
|
|
13
|
+
const markupBody = (method, tag, body) => {
|
|
14
|
+
if (body instanceof LambderSafeHtml)
|
|
15
|
+
return body.value;
|
|
16
|
+
throw new TypeError(`Lambder: ${method} takes safe ${tag === "html" ? "HTML" : "XML"}, and was given ${body === null ? "null" : typeof body}. Build the body with ${tag}\`...\`, which ` +
|
|
17
|
+
`escapes what it interpolates, mark markup you trust with raw(), or send plain text with res.text(body, { statusCode }).`);
|
|
18
|
+
};
|
|
4
19
|
/**
|
|
5
20
|
* Builds the responses of routes, hooks and error handlers. An API handler
|
|
6
21
|
* never holds one: it returns its output or refuses, and writes headers and
|
|
@@ -52,23 +67,28 @@ export default class LambderResponseBuilder {
|
|
|
52
67
|
json(data, options) {
|
|
53
68
|
return this.buildResponse(200, "application/json; charset=utf-8", JSON.stringify(data), options);
|
|
54
69
|
}
|
|
70
|
+
/** A plain-text body, sent as text: the place for a message, whatever status it goes out with (`{ statusCode }`). */
|
|
55
71
|
text(data, options) {
|
|
56
72
|
return this.buildResponse(200, "text/plain; charset=utf-8", data, options);
|
|
57
73
|
}
|
|
74
|
+
/** An XML document (a sitemap, a feed, SVG) built with xml`...` or marked safe with raw(). */
|
|
58
75
|
xml(data, options) {
|
|
59
|
-
return this.buildResponse(200, "application/xml; charset=utf-8",
|
|
76
|
+
return this.buildResponse(200, "application/xml; charset=utf-8", markupBody("res.xml", "xml", data), options);
|
|
60
77
|
}
|
|
61
78
|
;
|
|
79
|
+
/** An HTML page built with html`...` or marked safe with raw(). */
|
|
62
80
|
html(data, options) {
|
|
63
|
-
return this.buildResponse(200, "text/html; charset=utf-8",
|
|
81
|
+
return this.buildResponse(200, "text/html; charset=utf-8", markupBody("res.html", "html", data), options);
|
|
64
82
|
}
|
|
65
83
|
;
|
|
84
|
+
/** An HTML response with any status code, its body built like res.html's; no body sends an empty one. */
|
|
66
85
|
status(statusCode, body, options) {
|
|
67
|
-
return this.buildResponse(statusCode, "text/html; charset=utf-8", body
|
|
86
|
+
return this.buildResponse(statusCode, "text/html; charset=utf-8", body === undefined ? "" : markupBody("res.status", "html", body), options);
|
|
68
87
|
}
|
|
69
88
|
;
|
|
89
|
+
/** An HTML 404, its body built like res.html's. */
|
|
70
90
|
status404(data, options) {
|
|
71
|
-
return this.buildResponse(404, "text/html; charset=utf-8", data, options);
|
|
91
|
+
return this.buildResponse(404, "text/html; charset=utf-8", markupBody("res.status404", "html", data), options);
|
|
72
92
|
}
|
|
73
93
|
;
|
|
74
94
|
/**
|
|
@@ -118,7 +138,7 @@ export default class LambderResponseBuilder {
|
|
|
118
138
|
async file(filePath, options) {
|
|
119
139
|
const file = await this.requireFiles("res.file").read(filePath);
|
|
120
140
|
if (!file)
|
|
121
|
-
return this.
|
|
141
|
+
return this.text("File not found", { statusCode: 404, etag: false });
|
|
122
142
|
return this.buildResponse(200, file.mimeType, file.body, options);
|
|
123
143
|
}
|
|
124
144
|
;
|
|
@@ -127,12 +147,23 @@ export default class LambderResponseBuilder {
|
|
|
127
147
|
* LambderTemplatingEngine (comment-based slots/conditionals) and return
|
|
128
148
|
* it as an HTML response. The compiled template is cached on the
|
|
129
149
|
* instance across warm invocations; a missing file throws (it is a
|
|
130
|
-
* server-side configuration error, not a client 404)
|
|
131
|
-
*
|
|
150
|
+
* server-side configuration error, not a client 404), and so does a data
|
|
151
|
+
* key the file has no slot or condition for, naming the file. TNames, when
|
|
152
|
+
* given, is the file's slot and condition names, so a misspelled key is a
|
|
153
|
+
* compile error. Set htmlVirtualSlots to expose "title"/"head" slots on
|
|
154
|
+
* marker-less files.
|
|
132
155
|
*/
|
|
133
156
|
async templateFile(filePath, data, options) {
|
|
134
157
|
const template = await this.requireFiles("res.templateFile").template(filePath, { htmlVirtualSlots: options?.htmlVirtualSlots });
|
|
135
|
-
|
|
158
|
+
let body;
|
|
159
|
+
try {
|
|
160
|
+
body = template.render(data);
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
// The engine knows its source, not the file it came from.
|
|
164
|
+
throw new Error(`Lambder: res.templateFile(${JSON.stringify(filePath)}): ${error.message}`, { cause: error });
|
|
165
|
+
}
|
|
166
|
+
return this.buildResponse(200, "text/html; charset=utf-8", body, options);
|
|
136
167
|
}
|
|
137
168
|
;
|
|
138
169
|
/**
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { type LambderHtmlValue } from "../shared/LambderHtml.js";
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* What a template renders with: a value per slot or condition name. TNames,
|
|
4
|
+
* when given, is the template's names, so a key outside them is a compile
|
|
5
|
+
* error; left out, any name is accepted here and checked at render.
|
|
6
|
+
*/
|
|
7
|
+
export type LambderTemplateData<TNames extends string = string> = {
|
|
8
|
+
[Name in TNames]?: LambderHtmlValue;
|
|
9
|
+
};
|
|
3
10
|
export type LambderTemplatingEngineOptions = {
|
|
4
11
|
/**
|
|
5
12
|
* For full HTML documents without declared markers: expose the <title>
|
|
@@ -8,18 +15,29 @@ export type LambderTemplatingEngineOptions = {
|
|
|
8
15
|
*/
|
|
9
16
|
htmlVirtualSlots?: boolean;
|
|
10
17
|
};
|
|
11
|
-
|
|
18
|
+
/**
|
|
19
|
+
* A comment-only HTML template, compiled once. TNames is the caller's
|
|
20
|
+
* statement of its slot and condition names, which types render()'s data;
|
|
21
|
+
* left out, any name compiles, and render() checks every key either way.
|
|
22
|
+
*/
|
|
23
|
+
export declare class LambderTemplatingEngine<TNames extends string = string> {
|
|
12
24
|
private nodes;
|
|
13
|
-
/**
|
|
25
|
+
/** Every slot and condition name: what a data key has to be one of. */
|
|
26
|
+
private names;
|
|
27
|
+
/** Slot names discovered at compile time. */
|
|
14
28
|
readonly slotNames: readonly string[];
|
|
15
29
|
/** Condition names discovered at compile time. */
|
|
16
30
|
readonly conditionNames: readonly string[];
|
|
17
31
|
/** Parse `source`; throws on unclosed or mismatched blocks. */
|
|
18
32
|
constructor(source: string, options?: LambderTemplatingEngineOptions);
|
|
19
33
|
/** Read and parse a template file (compile once, render many times). */
|
|
20
|
-
static fromFile(filePath: string, options?: LambderTemplatingEngineOptions): Promise<LambderTemplatingEngine
|
|
34
|
+
static fromFile<TNames extends string = string>(filePath: string, options?: LambderTemplatingEngineOptions): Promise<LambderTemplatingEngine<TNames>>;
|
|
21
35
|
/** True when the template declares `name` as a slot or condition. */
|
|
22
36
|
has(name: string): boolean;
|
|
23
|
-
/**
|
|
24
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Render with escaped-by-default data; omitted slots keep their defaults.
|
|
39
|
+
* Throws for a key the template has no slot or condition for, naming it
|
|
40
|
+
* and the template's names.
|
|
41
|
+
*/
|
|
42
|
+
render(data?: LambderTemplateData<TNames>): string;
|
|
25
43
|
}
|