lambder 11.0.1 → 12.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 +154 -0
- package/README.md +18 -13
- 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/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/LambderApiRequest.d.ts +15 -6
- package/dist/api/LambderApiRequest.js +8 -9
- package/dist/build/generatedTables.d.ts +2 -1
- package/dist/build/generatedTables.js +1 -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 +203 -87
- package/dist/core/Lambder.js +265 -86
- package/dist/core/LambderContext.d.ts +1 -1
- package/dist/core/LambderContext.js +13 -5
- package/dist/core/LambderCreateOptions.d.ts +28 -39
- package/dist/core/LambderCreateOptions.js +2 -3
- package/dist/core/LambderPublicFiles.d.ts +8 -1
- package/dist/core/LambderPublicFiles.js +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.js +2 -0
- package/dist/invoke/LambderInvokeCaller.d.ts +48 -31
- package/dist/invoke/LambderInvokeCaller.js +110 -94
- package/dist/invoke/LambderLambdaEvent.d.ts +0 -1
- package/dist/invoke/LambderLambdaEvent.js +0 -1
- package/dist/invoke/lambderHandlerTransport.js +2 -1
- package/dist/mock/LambderMockApp.d.ts +31 -25
- package/dist/mock/LambderMockApp.js +52 -45
- package/dist/mock/LambderMockCreateOptions.d.ts +6 -7
- 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/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/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/LambderUploadSchemas.js +2 -1
- package/dist/testing/LambderTestApp.d.ts +5 -1
- package/dist/testing/LambderTestVisitor.d.ts +16 -8
- package/dist/testing/LambderTestVisitor.js +33 -25
- package/package.json +1 -1
|
@@ -209,7 +209,7 @@ export class LambderApiRateLimitsEngine {
|
|
|
209
209
|
throw new Error(`Lambder: API "${apiName}" references rate-limit policy "${name}", which declares no per: its key is the one a handler passes to ctx.rateLimit("${name}", key), so the request alone cannot be counted against it.`);
|
|
210
210
|
}
|
|
211
211
|
if (policy.per === "session" && mode !== "session") {
|
|
212
|
-
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which
|
|
212
|
+
throw new Error(`Lambder: API "${apiName}" uses rate-limit policy "${name}" (per "session"), which counts per session, and none of its guards needs a session.`);
|
|
213
213
|
}
|
|
214
214
|
if (override)
|
|
215
215
|
assertWindowLimits(`API "${apiName}" override of rate-limit policy "${name}"`, override);
|
|
@@ -18,7 +18,15 @@ export declare const lowercaseHeaderNames: (headers: Record<string, string | und
|
|
|
18
18
|
* transport request, and from here on nothing in the pipeline knows which.
|
|
19
19
|
*/
|
|
20
20
|
export type LambderApiRequest = {
|
|
21
|
+
/** The endpoint called: `group.action`, read off the path the call was posted to (`{apiPath}/{group}/{action}`). */
|
|
21
22
|
apiName: string;
|
|
23
|
+
/**
|
|
24
|
+
* Set on a call posted to apiPath itself with the endpoint named in the
|
|
25
|
+
* body, the way callers did before endpoints had paths: such a caller is a
|
|
26
|
+
* page loaded from an older build, and it is answered that its version
|
|
27
|
+
* expired, which reloads it.
|
|
28
|
+
*/
|
|
29
|
+
retiredPath?: true;
|
|
22
30
|
/** The caller's apiVersion, informational; null when it sent none. */
|
|
23
31
|
version: string | null;
|
|
24
32
|
/** The signature the caller carries for this endpoint (see LambderApiSignatureMap), for the signature gate; null when it sent none. */
|
|
@@ -79,13 +87,14 @@ export type LambderApiRequestInfo = {
|
|
|
79
87
|
*/
|
|
80
88
|
export declare const isApiCallContentType: (lowercasedHeaders: Record<string, string | undefined>) => boolean;
|
|
81
89
|
/**
|
|
82
|
-
* Reads the posted envelope
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* message they already give.
|
|
90
|
+
* Reads the posted envelope of a call to `apiName` into a request; the name
|
|
91
|
+
* comes from where the call was posted, never from the body. Everything is
|
|
92
|
+
* taken as posted: a malformed idempotencyKey or guardInputs value is the
|
|
93
|
+
* engines' to refuse, with the client-facing message they already give.
|
|
87
94
|
*/
|
|
88
|
-
export declare const readApiEnvelope: (post: Record<string, unknown> | null | undefined, info: LambderApiRequestInfo
|
|
95
|
+
export declare const readApiEnvelope: (post: Record<string, unknown> | null | undefined, info: LambderApiRequestInfo, apiName: string, flags?: {
|
|
96
|
+
retiredPath?: true;
|
|
97
|
+
}) => LambderApiRequest;
|
|
89
98
|
/** Outcome of restoring a compressed request payload; the message is client-facing. */
|
|
90
99
|
export type LambderRestorePayloadResult = {
|
|
91
100
|
ok: true;
|
|
@@ -43,20 +43,19 @@ export const lowercaseHeaderNames = (headers) => {
|
|
|
43
43
|
*/
|
|
44
44
|
export const isApiCallContentType = (lowercasedHeaders) => (lowercasedHeaders["content-type"] ?? "").split(";")[0].trim().toLowerCase() === "application/json";
|
|
45
45
|
/**
|
|
46
|
-
* Reads the posted envelope
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* message they already give.
|
|
46
|
+
* Reads the posted envelope of a call to `apiName` into a request; the name
|
|
47
|
+
* comes from where the call was posted, never from the body. Everything is
|
|
48
|
+
* taken as posted: a malformed idempotencyKey or guardInputs value is the
|
|
49
|
+
* engines' to refuse, with the client-facing message they already give.
|
|
51
50
|
*/
|
|
52
|
-
export const readApiEnvelope = (post, info) => {
|
|
53
|
-
|
|
54
|
-
return null;
|
|
51
|
+
export const readApiEnvelope = (post, info, apiName, flags = {}) => {
|
|
52
|
+
post ??= {};
|
|
55
53
|
const hasGzip = post[COMPRESSED_PAYLOAD_GZ_FIELD] !== undefined;
|
|
56
54
|
const hasBrotli = post[COMPRESSED_PAYLOAD_BR_FIELD] !== undefined;
|
|
57
55
|
const guardInputs = post.guardInputs;
|
|
58
56
|
return {
|
|
59
|
-
apiName
|
|
57
|
+
apiName,
|
|
58
|
+
...(flags.retiredPath ? { retiredPath: true } : {}),
|
|
60
59
|
version: typeof post.version === "string" ? post.version : null,
|
|
61
60
|
signature: typeof post.signature === "string" ? post.signature : null,
|
|
62
61
|
token: typeof post.token === "string" ? post.token : "",
|
|
@@ -2,7 +2,8 @@ import type { LambderApiOptionEntries } from "../shared/wire/LambderApiOptionEnt
|
|
|
2
2
|
import { type LambderModuleLocation } from "./moduleLocation.js";
|
|
3
3
|
/** What the generators read the options from: a Lambder instance, or anything else that reports them the same way. */
|
|
4
4
|
export type LambderApiOptionsSource = {
|
|
5
|
-
|
|
5
|
+
/** Asynchronous, since a Lambder instance loads its lazy groups before it can report every endpoint. */
|
|
6
|
+
apiOptionEntries(): Promise<LambderApiOptionEntries> | LambderApiOptionEntries;
|
|
6
7
|
};
|
|
7
8
|
/** Which names of one table moved: entries that changed, entries the file did not have, entries the file had and the instance no longer reports. */
|
|
8
9
|
export type LambderNameChanges = {
|
|
@@ -16,7 +16,7 @@ export const loadApiOptionEntries = async (location, generator) => {
|
|
|
16
16
|
if (typeof source?.apiOptionEntries !== "function") {
|
|
17
17
|
throw new Error(`${moduleUrl} has no export "${exportName}" that reports API options: name the export holding the instance in exportName`);
|
|
18
18
|
}
|
|
19
|
-
return source.apiOptionEntries();
|
|
19
|
+
return await source.apiOptionEntries();
|
|
20
20
|
};
|
|
21
21
|
/** One table of a generated file, read back as data, or null for a file that does not hold it as written. */
|
|
22
22
|
export const readGeneratedTable = (contents, exportName) => {
|
|
@@ -5,6 +5,7 @@ import { type LambderApiOutcome, type LambderValidationError } from '../shared/w
|
|
|
5
5
|
import { type LambderCallArgs, type LambderContractOutputOf, type LambderGuardInputsProviderOption, type LambderSharedCallOptions } from '../shared/wire/LambderCallOptions.js';
|
|
6
6
|
import { type LambderApiTransport } from '../shared/transport/LambderApiTransport.js';
|
|
7
7
|
import { type LambderApiSignatureMap } from '../shared/wire/LambderApiSignatureMap.js';
|
|
8
|
+
import { type LambderContractActionOf, type LambderContractGroupsOf, type LambderContractNamesInGroup } from '../shared/wire/LambderApiGroupCalls.js';
|
|
8
9
|
export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError } from '../shared/wire/LambderApiOutcome.js';
|
|
9
10
|
export type { LambderProvidedGuardInputs, LambderGuardInputsProvider } from '../shared/wire/LambderCallOptions.js';
|
|
10
11
|
/** A handler told that something happened, with nothing to hand it. */
|
|
@@ -110,40 +111,23 @@ type LambderCallerBaseOptions<TMessage extends LambderUncheckedRefusalMessage =
|
|
|
110
111
|
/** Constructor options: the base options plus guardInputsProvider, mandatory once TProvided names guards. */
|
|
111
112
|
export type LambderCallerOptions<TContract, TProvided extends string = never> = LambderCallerBaseOptions<LambderContractAnyRefusalMessage<TContract>> & LambderGuardInputsProviderOption<TContract, TProvided>;
|
|
112
113
|
/**
|
|
114
|
+
* The caller itself, before the groups: what LambderCaller is, less the
|
|
115
|
+
* endpoints it reaches by group (LambderCallerGroupCalls), which the
|
|
116
|
+
* constructor adds.
|
|
117
|
+
*
|
|
113
118
|
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
114
119
|
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
115
120
|
*/
|
|
116
|
-
|
|
117
|
-
private
|
|
118
|
-
private apiVersion?;
|
|
119
|
-
private apiSignatures?;
|
|
120
|
-
private timeoutMs?;
|
|
121
|
+
declare class LambderCallerCore<TContract extends LambderApiContractShape = any, TProvidedGuards extends string = never> {
|
|
122
|
+
#private;
|
|
121
123
|
/** The calls currently in flight, in the order they started. */
|
|
122
124
|
fetchTrackerList: FetchTracker[];
|
|
123
125
|
/** Whether any call is in flight. Derived from the list, so the two cannot drift apart. */
|
|
124
126
|
get isLoading(): boolean;
|
|
125
|
-
private versionExpiredHandler?;
|
|
126
|
-
private sessionExpiredHandler?;
|
|
127
|
-
private refusalHandler?;
|
|
128
|
-
private notAuthorizedHandler?;
|
|
129
|
-
private errorHandler?;
|
|
130
|
-
private apiInputValidationErrorHandler?;
|
|
131
|
-
private logListHandler?;
|
|
132
|
-
private fetchStartedHandler?;
|
|
133
|
-
private fetchEndedHandler?;
|
|
134
|
-
private guardInputsProvider?;
|
|
135
|
-
private sessionTokenCookieKey;
|
|
136
|
-
private sessionCsrfCookieKey;
|
|
137
|
-
private sessionCookieDomain?;
|
|
138
|
-
private requestCompression;
|
|
139
|
-
private transport;
|
|
140
127
|
constructor(options: LambderCallerOptions<TContract, TProvidedGuards>);
|
|
141
128
|
setSessionCookieKey(sessionTokenCookieKey: string, sessionCsrfCookieKey: string): void;
|
|
142
129
|
/** Replaces how calls reach the server: a mock runtime, an in-process handler, a decorated transport. */
|
|
143
130
|
setTransport(transport: LambderApiTransport): this;
|
|
144
|
-
private clearSessionCookies;
|
|
145
|
-
/** One call, one outcome. Never throws; every failure path resolves to { ok: false }. */
|
|
146
|
-
private dispatch;
|
|
147
131
|
/**
|
|
148
132
|
* Full-fidelity call: resolves to a discriminated LambderApiOutcome
|
|
149
133
|
* instead of collapsing every failure to undefined. Never throws. A
|
|
@@ -164,3 +148,32 @@ export default class LambderCaller<TContract extends LambderApiContractShape = a
|
|
|
164
148
|
*/
|
|
165
149
|
api<TApiName extends keyof TContract & string = string>(apiName: TApiName, ...rest: LambderCallArgs<TContract, TApiName, TProvidedGuards, LambderCallOptions<LambderContractRefusalMessage<TContract, TApiName>>>): Promise<LambderContractOutputOf<TContract, TApiName> | undefined>;
|
|
166
150
|
}
|
|
151
|
+
/**
|
|
152
|
+
* One endpoint as a caller hands it out on its group: called, it is `api`
|
|
153
|
+
* for that endpoint (the output, or undefined on a failure); `.outcome` is
|
|
154
|
+
* `apiOutcome` (the full outcome, never throwing).
|
|
155
|
+
*/
|
|
156
|
+
export type LambderCallerEndpoint<TContract, TName extends keyof TContract & string, TProvidedGuards extends string> = {
|
|
157
|
+
(...args: LambderCallArgs<TContract, TName, TProvidedGuards, LambderCallOptions<LambderContractRefusalMessage<TContract, TName>>>): Promise<LambderContractOutputOf<TContract, TName> | undefined>;
|
|
158
|
+
outcome(...args: LambderCallArgs<TContract, TName, TProvidedGuards, LambderCallOptions<LambderContractRefusalMessage<TContract, TName>>>): Promise<LambderApiOutcome<LambderContractOutputOf<TContract, TName>, LambderContractRefusalMessage<TContract, TName>>>;
|
|
159
|
+
};
|
|
160
|
+
/** Every endpoint of a contract, by group: `caller.orders.place(input)`. */
|
|
161
|
+
export type LambderCallerGroupCalls<TContract, TProvidedGuards extends string> = {
|
|
162
|
+
readonly [TGroup in LambderContractGroupsOf<TContract>]: {
|
|
163
|
+
readonly [TName in LambderContractNamesInGroup<TContract, TGroup> as LambderContractActionOf<TName>]: LambderCallerEndpoint<TContract, TName, TProvidedGuards>;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
/**
|
|
167
|
+
* A typed client of a Lambder app: `caller.orders.place(input)` for the
|
|
168
|
+
* endpoint `orders.place`, `caller.orders.place.outcome(input)` for its full
|
|
169
|
+
* outcome, and `caller.api("orders.place", input)` for code that has the
|
|
170
|
+
* name as a value.
|
|
171
|
+
*/
|
|
172
|
+
type LambderCaller<TContract extends LambderApiContractShape = any, TProvidedGuards extends string = never> = LambderCallerCore<TContract, TProvidedGuards> & LambderCallerGroupCalls<TContract, TProvidedGuards>;
|
|
173
|
+
/** The caller's own members, without the groups: what a wrapper of a caller (the test visitor) types its `api` and `apiOutcome` by. */
|
|
174
|
+
export type LambderCallerMembers<TContract extends LambderApiContractShape = any, TProvidedGuards extends string = never> = LambderCallerCore<TContract, TProvidedGuards>;
|
|
175
|
+
declare const LambderCaller: {
|
|
176
|
+
new <TContract extends LambderApiContractShape = any, TProvidedGuards extends string = never>(options: LambderCallerOptions<TContract, TProvidedGuards>): LambderCaller<TContract, TProvidedGuards>;
|
|
177
|
+
readonly prototype: LambderCallerCore<any, any>;
|
|
178
|
+
};
|
|
179
|
+
export default LambderCaller;
|
|
@@ -11,6 +11,8 @@ import { DEFAULT_SESSION_TOKEN_COOKIE_KEY, DEFAULT_SESSION_CSRF_COOKIE_KEY } fro
|
|
|
11
11
|
import { readApiSignature } from '../shared/wire/LambderApiSignatureMap.js';
|
|
12
12
|
import { LambderReloadLoopBreaker, RELOAD_LOOP_WINDOW_MS } from './LambderReloadLoopBreaker.js';
|
|
13
13
|
import { lambderFetchTransport } from './lambderFetchTransport.js';
|
|
14
|
+
import { withApiGroupCalls } from '../shared/wire/LambderApiGroupCalls.js';
|
|
15
|
+
import { splitApiName } from '../shared/wire/LambderApiNames.js';
|
|
14
16
|
/**
|
|
15
17
|
* What keeps a stale bundle from reloading itself forever (see the class):
|
|
16
18
|
* one for the page, shared by every caller it builds, since a reload is the
|
|
@@ -19,74 +21,82 @@ import { lambderFetchTransport } from './lambderFetchTransport.js';
|
|
|
19
21
|
*/
|
|
20
22
|
const pageReloadLoopBreaker = new LambderReloadLoopBreaker();
|
|
21
23
|
/**
|
|
24
|
+
* The caller itself, before the groups: what LambderCaller is, less the
|
|
25
|
+
* endpoints it reaches by group (LambderCallerGroupCalls), which the
|
|
26
|
+
* constructor adds.
|
|
27
|
+
*
|
|
22
28
|
* @typeParam TContract - The API contract, for typed names, payloads and guard inputs.
|
|
23
29
|
* @typeParam TProvidedGuards - Guard names guardInputsProvider covers; those APIs' options argument becomes optional.
|
|
24
30
|
*/
|
|
25
|
-
|
|
26
|
-
apiPath;
|
|
27
|
-
apiVersion;
|
|
28
|
-
apiSignatures;
|
|
29
|
-
timeoutMs;
|
|
31
|
+
class LambderCallerCore {
|
|
32
|
+
#apiPath;
|
|
33
|
+
#apiVersion;
|
|
34
|
+
#apiSignatures;
|
|
35
|
+
#timeoutMs;
|
|
30
36
|
/** The calls currently in flight, in the order they started. */
|
|
31
37
|
fetchTrackerList = [];
|
|
32
38
|
/** Whether any call is in flight. Derived from the list, so the two cannot drift apart. */
|
|
33
39
|
get isLoading() { return this.fetchTrackerList.length > 0; }
|
|
34
|
-
versionExpiredHandler;
|
|
35
|
-
sessionExpiredHandler;
|
|
40
|
+
#versionExpiredHandler;
|
|
41
|
+
#sessionExpiredHandler;
|
|
36
42
|
// Hears every endpoint's refusals, so held at the widest message: the
|
|
37
43
|
// constructor option types it to the contract's codes, and dispatch,
|
|
38
44
|
// generic over one endpoint's message, hands it one of those.
|
|
39
|
-
refusalHandler;
|
|
40
|
-
notAuthorizedHandler;
|
|
41
|
-
errorHandler;
|
|
42
|
-
apiInputValidationErrorHandler;
|
|
43
|
-
logListHandler;
|
|
44
|
-
fetchStartedHandler;
|
|
45
|
-
fetchEndedHandler;
|
|
46
|
-
guardInputsProvider;
|
|
47
|
-
sessionTokenCookieKey = DEFAULT_SESSION_TOKEN_COOKIE_KEY;
|
|
48
|
-
sessionCsrfCookieKey = DEFAULT_SESSION_CSRF_COOKIE_KEY;
|
|
49
|
-
sessionCookieDomain;
|
|
50
|
-
requestCompression;
|
|
51
|
-
transport;
|
|
45
|
+
#refusalHandler;
|
|
46
|
+
#notAuthorizedHandler;
|
|
47
|
+
#errorHandler;
|
|
48
|
+
#apiInputValidationErrorHandler;
|
|
49
|
+
#logListHandler;
|
|
50
|
+
#fetchStartedHandler;
|
|
51
|
+
#fetchEndedHandler;
|
|
52
|
+
#guardInputsProvider;
|
|
53
|
+
#sessionTokenCookieKey = DEFAULT_SESSION_TOKEN_COOKIE_KEY;
|
|
54
|
+
#sessionCsrfCookieKey = DEFAULT_SESSION_CSRF_COOKIE_KEY;
|
|
55
|
+
#sessionCookieDomain;
|
|
56
|
+
#requestCompression;
|
|
57
|
+
#transport;
|
|
52
58
|
constructor(options) {
|
|
53
59
|
// The conditional provider option is resolved per instantiation;
|
|
54
60
|
// inside the class it is read through the plain shape.
|
|
55
61
|
const { apiPath, apiVersion, apiSignatures, isCorsEnabled, timeoutMs, versionExpiredHandler, sessionExpiredHandler, refusalHandler, notAuthorizedHandler, errorHandler, logListHandler, fetchStartedHandler, fetchEndedHandler, apiInputValidationErrorHandler, sessionCookieDomain, requestCompression, guardInputsProvider, transport, } = options;
|
|
56
|
-
this
|
|
57
|
-
this
|
|
58
|
-
this
|
|
59
|
-
this
|
|
60
|
-
this
|
|
62
|
+
this.#apiPath = apiPath;
|
|
63
|
+
this.#apiVersion = apiVersion;
|
|
64
|
+
this.#apiSignatures = apiSignatures;
|
|
65
|
+
this.#timeoutMs = timeoutMs;
|
|
66
|
+
this.#sessionCookieDomain = sessionCookieDomain;
|
|
61
67
|
// `?? false`: unlike the at-rest stores, this one is off unless asked for.
|
|
62
|
-
this
|
|
63
|
-
this
|
|
64
|
-
this
|
|
65
|
-
this
|
|
66
|
-
this
|
|
67
|
-
this
|
|
68
|
-
this
|
|
69
|
-
this
|
|
70
|
-
this
|
|
71
|
-
this
|
|
72
|
-
this
|
|
73
|
-
this
|
|
68
|
+
this.#requestCompression = resolveCompressionOption(requestCompression ?? false, DEFAULT_REQUEST_COMPRESSION_SETTINGS);
|
|
69
|
+
this.#transport = transport ?? lambderFetchTransport({ cors: isCorsEnabled });
|
|
70
|
+
this.#versionExpiredHandler = versionExpiredHandler;
|
|
71
|
+
this.#sessionExpiredHandler = sessionExpiredHandler;
|
|
72
|
+
this.#refusalHandler = refusalHandler;
|
|
73
|
+
this.#notAuthorizedHandler = notAuthorizedHandler;
|
|
74
|
+
this.#errorHandler = errorHandler;
|
|
75
|
+
this.#apiInputValidationErrorHandler = apiInputValidationErrorHandler;
|
|
76
|
+
this.#logListHandler = logListHandler;
|
|
77
|
+
this.#fetchStartedHandler = fetchStartedHandler;
|
|
78
|
+
this.#fetchEndedHandler = fetchEndedHandler;
|
|
79
|
+
this.#guardInputsProvider = guardInputsProvider;
|
|
80
|
+
// Each group of the contract, as a property: caller.orders.place(input)
|
|
81
|
+
// is caller.api("orders.place", input), and .outcome the apiOutcome.
|
|
82
|
+
const byName = this;
|
|
83
|
+
return withApiGroupCalls(this, (apiName, args) => byName.api(apiName, ...args), (apiName, args) => byName.apiOutcome(apiName, ...args));
|
|
74
84
|
}
|
|
75
85
|
;
|
|
76
86
|
setSessionCookieKey(sessionTokenCookieKey, sessionCsrfCookieKey) {
|
|
77
|
-
this
|
|
78
|
-
this
|
|
87
|
+
this.#sessionTokenCookieKey = sessionTokenCookieKey;
|
|
88
|
+
this.#sessionCsrfCookieKey = sessionCsrfCookieKey;
|
|
79
89
|
}
|
|
80
90
|
/** Replaces how calls reach the server: a mock runtime, an in-process handler, a decorated transport. */
|
|
81
91
|
setTransport(transport) {
|
|
82
|
-
this
|
|
92
|
+
this.#transport = transport;
|
|
83
93
|
return this;
|
|
84
94
|
}
|
|
85
|
-
clearSessionCookies() {
|
|
86
|
-
const domainOption = this
|
|
95
|
+
#clearSessionCookies() {
|
|
96
|
+
const domainOption = this.#sessionCookieDomain;
|
|
87
97
|
const hostname = globalThis.location?.hostname ?? "";
|
|
88
98
|
const resolvedDomain = typeof domainOption === "function" ? domainOption(hostname) : domainOption;
|
|
89
|
-
for (const key of [this
|
|
99
|
+
for (const key of [this.#sessionTokenCookieKey, this.#sessionCsrfCookieKey]) {
|
|
90
100
|
// Host-only and domain-scoped cookies are distinct entries; clear both.
|
|
91
101
|
// Only the CSRF cookie is reachable from here: the token cookie is
|
|
92
102
|
// HttpOnly, so its removal is the server's (a Set-Cookie on the
|
|
@@ -97,17 +107,17 @@ export default class LambderCaller {
|
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
/** One call, one outcome. Never throws; every failure path resolves to { ok: false }. */
|
|
100
|
-
async dispatch(apiName, payload, options) {
|
|
110
|
+
async #dispatch(apiName, payload, options) {
|
|
101
111
|
// Per-call overrides win over the constructor handlers.
|
|
102
|
-
const versionExpiredHandler = options?.versionExpiredHandler ?? this
|
|
103
|
-
const sessionExpiredHandler = options?.sessionExpiredHandler ?? this
|
|
104
|
-
const refusalHandler = options?.refusalHandler ?? this
|
|
105
|
-
const notAuthorizedHandler = options?.notAuthorizedHandler ?? this
|
|
106
|
-
const errorHandler = options?.errorHandler ?? this
|
|
107
|
-
const apiInputValidationErrorHandler = options?.apiInputValidationErrorHandler ?? this
|
|
108
|
-
const logListHandler = options?.logListHandler ?? this
|
|
109
|
-
const fetchStartedHandler = options?.fetchStartedHandler ?? this
|
|
110
|
-
const fetchEndedHandler = options?.fetchEndedHandler ?? this
|
|
112
|
+
const versionExpiredHandler = options?.versionExpiredHandler ?? this.#versionExpiredHandler;
|
|
113
|
+
const sessionExpiredHandler = options?.sessionExpiredHandler ?? this.#sessionExpiredHandler;
|
|
114
|
+
const refusalHandler = options?.refusalHandler ?? this.#refusalHandler;
|
|
115
|
+
const notAuthorizedHandler = options?.notAuthorizedHandler ?? this.#notAuthorizedHandler;
|
|
116
|
+
const errorHandler = options?.errorHandler ?? this.#errorHandler;
|
|
117
|
+
const apiInputValidationErrorHandler = options?.apiInputValidationErrorHandler ?? this.#apiInputValidationErrorHandler;
|
|
118
|
+
const logListHandler = options?.logListHandler ?? this.#logListHandler;
|
|
119
|
+
const fetchStartedHandler = options?.fetchStartedHandler ?? this.#fetchStartedHandler;
|
|
120
|
+
const fetchEndedHandler = options?.fetchEndedHandler ?? this.#fetchEndedHandler;
|
|
111
121
|
const headers = options?.headers;
|
|
112
122
|
const fetchTracker = { apiName };
|
|
113
123
|
// A key scope sends its current key and is told how the attempt
|
|
@@ -147,7 +157,7 @@ export default class LambderCaller {
|
|
|
147
157
|
};
|
|
148
158
|
// Timeout and abort wiring, shared with LambderInvokeCaller so the two
|
|
149
159
|
// cannot drift on what a late or abandoned call means.
|
|
150
|
-
const abort = createCallAbort({ timeoutMs: options?.timeoutMs ?? this
|
|
160
|
+
const abort = createCallAbort({ timeoutMs: options?.timeoutMs ?? this.#timeoutMs, signal: options?.signal });
|
|
151
161
|
const signal = abort.signal;
|
|
152
162
|
/** Reports a call that was given up on, or null while it still stands. */
|
|
153
163
|
const abandonedOutcome = async (stage) => {
|
|
@@ -161,21 +171,30 @@ export default class LambderCaller {
|
|
|
161
171
|
return outcome;
|
|
162
172
|
};
|
|
163
173
|
try {
|
|
174
|
+
// A name that is not group.action names no endpoint and no path:
|
|
175
|
+
// a caller's mistake, reported as one before anything is sent,
|
|
176
|
+
// rather than as the network failure its transport would throw.
|
|
177
|
+
if (!splitApiName(apiName)) {
|
|
178
|
+
idempotentAttempt.settle(IDEMPOTENT_ATTEMPT_NOT_SENT);
|
|
179
|
+
const error = new Error(`Lambder: "${apiName}" is not an endpoint name. An endpoint is named group.action, both identifiers.`);
|
|
180
|
+
await reportError(error);
|
|
181
|
+
return { ok: false, reason: 'unknown', error };
|
|
182
|
+
}
|
|
164
183
|
this.fetchTrackerList.push(fetchTracker);
|
|
165
184
|
if (fetchStartedHandler)
|
|
166
185
|
await fetchStartedHandler({
|
|
167
186
|
fetchParams: { apiName, payload, headers, },
|
|
168
187
|
activeFetchList: [...this.fetchTrackerList],
|
|
169
188
|
});
|
|
170
|
-
const version = this
|
|
189
|
+
const version = this.#apiVersion;
|
|
171
190
|
// The server's signature for this endpoint, when this build
|
|
172
191
|
// carries the map. A name the map lacks fails the call here, as a
|
|
173
192
|
// provider that threw would: the map predates the endpoint.
|
|
174
|
-
const signature = this
|
|
193
|
+
const signature = this.#apiSignatures ? await readApiSignature(this.#apiSignatures, apiName) : undefined;
|
|
175
194
|
const siteHost = globalThis.location?.hostname ?? "";
|
|
176
195
|
// Provider values underneath, per-call values on top.
|
|
177
|
-
const providedGuardInputs = this
|
|
178
|
-
? await this
|
|
196
|
+
const providedGuardInputs = this.#guardInputsProvider
|
|
197
|
+
? await this.#guardInputsProvider(apiName)
|
|
179
198
|
: undefined;
|
|
180
199
|
const guardInputs = mergeGuardInputs(providedGuardInputs, options?.guardInputs);
|
|
181
200
|
// Compressed when enabled and the payload's JSON reaches the
|
|
@@ -183,7 +202,7 @@ export default class LambderCaller {
|
|
|
183
202
|
// without CompressionStream always sends the payload plainly.
|
|
184
203
|
// Nothing here runs (the extra stringify included) unless
|
|
185
204
|
// compression is actually a possibility for this call.
|
|
186
|
-
const compressionMinBytes = resolveRequestCompressionMinBytes(options?.compressRequest, this
|
|
205
|
+
const compressionMinBytes = resolveRequestCompressionMinBytes(options?.compressRequest, this.#requestCompression);
|
|
187
206
|
const compressedPayload = compressionMinBytes !== null && payload !== undefined && isRequestCompressionAvailable()
|
|
188
207
|
? await compressPayloadGzip(JSON.stringify(payload), compressionMinBytes)
|
|
189
208
|
: null;
|
|
@@ -199,15 +218,15 @@ export default class LambderCaller {
|
|
|
199
218
|
// cookie with the old token. js-cookie reads nothing without a
|
|
200
219
|
// document: the token is "" then, and a transport that carries a
|
|
201
220
|
// cookie jar fills it in from there.
|
|
202
|
-
const token = Cookies.get(this
|
|
221
|
+
const token = Cookies.get(this.#sessionCsrfCookieKey) || "";
|
|
203
222
|
let answer;
|
|
204
223
|
try {
|
|
205
224
|
sent = true;
|
|
206
|
-
answer = await this
|
|
207
|
-
apiPath: this
|
|
225
|
+
answer = await this.#transport({
|
|
226
|
+
apiPath: this.#apiPath,
|
|
208
227
|
apiName, version, token, siteHost,
|
|
209
228
|
...(signature !== undefined ? { signature } : {}),
|
|
210
|
-
csrfCookieKey: this
|
|
229
|
+
csrfCookieKey: this.#sessionCsrfCookieKey,
|
|
211
230
|
...(compressedPayload ? { compressed: compressedPayload } : { payload }),
|
|
212
231
|
...(guardInputs !== undefined ? { guardInputs } : {}),
|
|
213
232
|
...(idempotencyKey !== undefined ? { idempotencyKey } : {}),
|
|
@@ -307,10 +326,10 @@ export default class LambderCaller {
|
|
|
307
326
|
// token it posted and the one it holds, since the page's
|
|
308
327
|
// cookie is not where that session lives.
|
|
309
328
|
const postedToken = answer.csrfTokens?.posted ?? token;
|
|
310
|
-
const heldToken = answer.csrfTokens ? answer.csrfTokens.held() : Cookies.get(this
|
|
329
|
+
const heldToken = answer.csrfTokens ? answer.csrfTokens.held() : Cookies.get(this.#sessionCsrfCookieKey) || "";
|
|
311
330
|
if (heldToken !== "" && heldToken !== postedToken)
|
|
312
331
|
return outcome;
|
|
313
|
-
this
|
|
332
|
+
this.#clearSessionCookies();
|
|
314
333
|
if (sessionExpiredHandler) {
|
|
315
334
|
await sessionExpiredHandler();
|
|
316
335
|
}
|
|
@@ -372,7 +391,7 @@ export default class LambderCaller {
|
|
|
372
391
|
// elements read as unknown from inside; the contract shaped them on
|
|
373
392
|
// the way in, which is where the guarantee belongs.
|
|
374
393
|
const [payload, options] = rest;
|
|
375
|
-
return await this
|
|
394
|
+
return await this.#dispatch(apiName, payload, options);
|
|
376
395
|
}
|
|
377
396
|
;
|
|
378
397
|
/**
|
|
@@ -384,7 +403,9 @@ export default class LambderCaller {
|
|
|
384
403
|
*/
|
|
385
404
|
async api(apiName, ...rest) {
|
|
386
405
|
const [payload, options] = rest;
|
|
387
|
-
const outcome = await this
|
|
406
|
+
const outcome = await this.#dispatch(apiName, payload, options);
|
|
388
407
|
return outcome.ok ? outcome.payload : undefined;
|
|
389
408
|
}
|
|
390
409
|
}
|
|
410
|
+
const LambderCaller = LambderCallerCore;
|
|
411
|
+
export default LambderCaller;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { buildTransportEnvelope, LambderTransportFailure } from "../shared/transport/LambderApiTransport.js";
|
|
2
|
+
import { apiCallPath } from "../shared/wire/LambderApiNames.js";
|
|
2
3
|
/**
|
|
3
4
|
* fetch, with a clearer error for one failure. Outside a page a relative
|
|
4
5
|
* apiPath has nothing to resolve against, so fetch rejects with a URL parse
|
|
@@ -51,7 +52,7 @@ export const lambderFetchTransport = (options = {}) => async (request) => {
|
|
|
51
52
|
// ("text/plain, application/json") rather than give way to it.
|
|
52
53
|
const owned = new Set(['content-type', ...(request.cookies?.length ? ['cookie'] : [])]);
|
|
53
54
|
const callerHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([name]) => !owned.has(name.toLowerCase())));
|
|
54
|
-
const response = await fetchOrExplain(request.apiPath, {
|
|
55
|
+
const response = await fetchOrExplain(apiCallPath(request.apiPath, request.apiName), {
|
|
55
56
|
method: 'POST', cache: 'no-cache',
|
|
56
57
|
// Cross-origin API hosts need CORS mode and included credentials.
|
|
57
58
|
mode: cors ? 'cors' : 'same-origin',
|
package/dist/client.d.ts
CHANGED
|
@@ -20,7 +20,9 @@ export type { LambderApiSignatureMap } from "./shared/wire/LambderApiSignatureMa
|
|
|
20
20
|
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
21
21
|
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
22
22
|
export type { LambderApiAnswerOutcome, LambderApiSuccessOutcome, LambderApiCallFailure, LambderApiValidationFailure, LambderApiEnvelopeFailure, LambderApiHttpAnswer, } from "./shared/wire/LambderApiOutcome.js";
|
|
23
|
-
export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderLogListHandler, } from "./client/LambderCaller.js";
|
|
23
|
+
export type { LambderApiOutcome, LambderApiFailureReason, LambderValidationError, LambderCallOptions, LambderCallerOptions, LambderGuardInputsProvider, LambderProvidedGuardInputs, LambderLogListHandler, LambderCallerEndpoint, LambderCallerGroupCalls, LambderCallerMembers, } from "./client/LambderCaller.js";
|
|
24
|
+
export { apiCallPath, splitApiName, LAMBDER_RESERVED_GROUP_NAMES, LAMBDER_RESERVED_ACTION_NAMES } from "./shared/wire/LambderApiNames.js";
|
|
25
|
+
export type { LambderContractGroupsOf, LambderContractNamesInGroup, LambderContractActionOf } from "./shared/wire/LambderApiGroupCalls.js";
|
|
24
26
|
export { createIdempotencyKey, createIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
|
|
25
27
|
export type { LambderIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
|
|
26
28
|
export { LambderApiRefusal, isLambderApiRefusal, isLambderRefusalCode, refuse, refusalMessageOf, LAMBDER_REFUSAL_CODES } from "./shared/wire/LambderApiRefusal.js";
|
package/dist/client.js
CHANGED
|
@@ -19,6 +19,8 @@ export { resolveApiOutcome } from "./shared/wire/LambderApiOutcome.js";
|
|
|
19
19
|
export { apiNameKeyOf, lookupApiSignature, readApiSignature, API_SIGNATURE_HEX_LENGTH, extensibleEnum } from "./shared/wire/LambderApiSignatureMap.js";
|
|
20
20
|
export { RELOAD_LOOP_WINDOW_MS } from "./client/LambderReloadLoopBreaker.js";
|
|
21
21
|
export { compareDottedVersions, isDottedVersion } from "./shared/wire/LambderVersionOrder.js";
|
|
22
|
+
// How an endpoint is named, and where a call to it goes.
|
|
23
|
+
export { apiCallPath, splitApiName, LAMBDER_RESERVED_GROUP_NAMES, LAMBDER_RESERVED_ACTION_NAMES } from "./shared/wire/LambderApiNames.js";
|
|
22
24
|
export { createIdempotencyKey, createIdempotencyKeyScope } from "./shared/wire/LambderIdempotencyKeyScope.js";
|
|
23
25
|
// Typed API refusals (isomorphic: shared code may throw them from anywhere;
|
|
24
26
|
// in the browser they are plain Errors).
|