mppx 0.16.0 → 0.17.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 +36 -0
- package/dist/BodyDigest.js +13 -1
- package/dist/Challenge.d.ts +6 -0
- package/dist/Challenge.js +12 -0
- package/dist/Method.d.ts +7 -0
- package/dist/cli/cli.js +140 -134
- package/dist/cli/plugins/index.js +11 -11
- package/dist/cli/sessions/store.js +7 -1
- package/dist/cli/validate/helpers.d.ts +1 -1
- package/dist/cli/validate/helpers.js +14 -2
- package/dist/cli/validate/payment.js +39 -15
- package/dist/client/Mppx.js +11 -3
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.js +59 -3
- package/dist/client/internal/protocols/Mpp.js +4 -1
- package/dist/identity/Client.js +3 -3
- package/dist/identity/Server.d.ts +13 -0
- package/dist/identity/Server.js +50 -1
- package/dist/identity/Types.d.ts +32 -10
- package/dist/internal/AcceptPayment.d.ts +12 -2
- package/dist/internal/AcceptPayment.js +12 -3
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/mcp/client/McpClient.js +1 -1
- package/dist/proxy/Proxy.d.ts +15 -0
- package/dist/proxy/Proxy.js +89 -1
- package/dist/proxy/internal/Replay.d.ts +105 -0
- package/dist/proxy/internal/Replay.js +257 -0
- package/dist/server/Mppx.d.ts +4 -2
- package/dist/server/Mppx.js +50 -24
- package/dist/server/internal/challengeMeta.d.ts +6 -0
- package/dist/server/internal/challengeMeta.js +17 -1
- package/dist/tempo/client/Charge.d.ts +20 -0
- package/dist/tempo/client/Charge.js +43 -10
- package/dist/tempo/client/Methods.d.ts +2 -0
- package/dist/tempo/internal/defaults.d.ts +9 -0
- package/dist/tempo/internal/defaults.js +9 -0
- package/dist/tempo/internal/fee-payer.js +2 -2
- package/dist/tempo/server/Charge.js +16 -2
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.js +5 -0
- package/dist/tempo/session/client/Session.d.ts +26 -1
- package/dist/tempo/session/client/Session.js +72 -1
- package/dist/tempo/session/client/SessionManager.d.ts +1 -0
- package/dist/tempo/session/client/SessionManager.js +54 -7
- package/dist/tempo/session/client/Transports.d.ts +2 -0
- package/dist/tempo/session/client/Transports.js +31 -2
- package/dist/tempo/session/precompile/Voucher.js +20 -7
- package/dist/tempo/session/server/Session.d.ts +2 -1
- package/dist/tempo/session/server/Session.js +2 -1
- package/dist/tempo/session/server/Settlement.d.ts +4 -2
- package/dist/tempo/session/server/Settlement.js +2 -2
- package/dist/tempo/session/server/Ws.js +29 -12
- package/package.json +1 -1
package/dist/proxy/Proxy.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as Scope from '../server/internal/scope.js';
|
|
|
4
4
|
import * as Mppx from '../server/Mppx.js';
|
|
5
5
|
import * as Request from '../server/Request.js';
|
|
6
6
|
import * as Headers from './internal/Headers.js';
|
|
7
|
+
import * as Replay from './internal/Replay.js';
|
|
7
8
|
import * as Route from './internal/Route.js';
|
|
8
9
|
import * as Service from './Service.js';
|
|
9
10
|
/**
|
|
@@ -34,6 +35,7 @@ import * as Service from './Service.js';
|
|
|
34
35
|
*/
|
|
35
36
|
export function create(config) {
|
|
36
37
|
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
38
|
+
const replay = config.replay ? Replay.resolve(config.replay) : undefined;
|
|
37
39
|
const services = new Map(config.services.map((s) => {
|
|
38
40
|
const proxy = createFetchProxy(s.baseUrl, { fetch: fetchImpl });
|
|
39
41
|
return [s.id, { service: s, proxy }];
|
|
@@ -108,13 +110,59 @@ export function create(config) {
|
|
|
108
110
|
proxy,
|
|
109
111
|
onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
|
|
110
112
|
});
|
|
113
|
+
// With replay, a paid request whose credential already paid for this exact
|
|
114
|
+
// request collects its recorded delivery instead of paying again.
|
|
115
|
+
let paidRequest = request;
|
|
116
|
+
let replayBody;
|
|
117
|
+
let replayKey;
|
|
118
|
+
// Routes with identity verifiers keep paying normally: a replay would skip them.
|
|
119
|
+
const credential = replay && !fallbackMatch && !requiresIdentity(handler)
|
|
120
|
+
? Replay.credentialOf(request, credentialHeaders(handler))
|
|
121
|
+
: undefined;
|
|
122
|
+
if (replay && credential) {
|
|
123
|
+
const body = await Replay.readBody(request, replay.maxRequestBodySize);
|
|
124
|
+
if (!body)
|
|
125
|
+
return new Response('Payload Too Large', { status: 413 });
|
|
126
|
+
replayBody = body;
|
|
127
|
+
paidRequest = Replay.withBody(request, body);
|
|
128
|
+
replayKey = Replay.key({
|
|
129
|
+
body,
|
|
130
|
+
credential,
|
|
131
|
+
method: request.method,
|
|
132
|
+
target: url.origin + url.pathname + url.search,
|
|
133
|
+
});
|
|
134
|
+
const found = await Replay.lookup(replay, replayKey);
|
|
135
|
+
if (found.type === 'respond')
|
|
136
|
+
return found.response;
|
|
137
|
+
if (found.type === 'retry') {
|
|
138
|
+
const options = Service.getOptions(endpoint);
|
|
139
|
+
return Replay.deliver({
|
|
140
|
+
applyReceipt: (response) => Replay.withReceiptHeader(response, found.receipt),
|
|
141
|
+
attempts: found.attempts,
|
|
142
|
+
replay,
|
|
143
|
+
replayKey,
|
|
144
|
+
requestHeaders: found.requestHeaders,
|
|
145
|
+
run: (signal) => {
|
|
146
|
+
// Send the headers of the request that was paid for, not the retry's.
|
|
147
|
+
const upstream = Replay.withBody(paidRequest, body, signal, found.requestHeaders);
|
|
148
|
+
return proxyUpstream({
|
|
149
|
+
request: upstream,
|
|
150
|
+
service,
|
|
151
|
+
ctx: { ...ctx, request: upstream, ...options },
|
|
152
|
+
proxy,
|
|
153
|
+
onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
111
159
|
const scope = getConfiguredScope(handler) ??
|
|
112
160
|
deriveRouteScope({
|
|
113
161
|
basePath: config.basePath,
|
|
114
162
|
routeKey: matched.key,
|
|
115
163
|
serviceId,
|
|
116
164
|
});
|
|
117
|
-
const result = await handler(getConfiguredScope(handler) ?
|
|
165
|
+
const result = await handler(getConfiguredScope(handler) ? paidRequest : Scope.attach(paidRequest, scope));
|
|
118
166
|
if (result.status === 402)
|
|
119
167
|
return result.challenge;
|
|
120
168
|
const managementResponse = (() => {
|
|
@@ -132,6 +180,26 @@ export function create(config) {
|
|
|
132
180
|
if (fallbackMatch)
|
|
133
181
|
return new Response('Method Not Allowed', { status: 405 });
|
|
134
182
|
const options = Service.getOptions(endpoint);
|
|
183
|
+
if (replay && replayKey && replayBody) {
|
|
184
|
+
const body = replayBody;
|
|
185
|
+
return Replay.deliver({
|
|
186
|
+
applyReceipt: (response) => result.withReceipt(response),
|
|
187
|
+
attempts: 1,
|
|
188
|
+
replay,
|
|
189
|
+
replayKey,
|
|
190
|
+
requestHeaders: [...Headers.scrub(paidRequest.headers).entries()],
|
|
191
|
+
run: (signal) => {
|
|
192
|
+
const upstream = Replay.withBody(paidRequest, body, signal);
|
|
193
|
+
return proxyUpstream({
|
|
194
|
+
request: upstream,
|
|
195
|
+
service,
|
|
196
|
+
ctx: { ...ctx, request: upstream, ...options },
|
|
197
|
+
proxy,
|
|
198
|
+
onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
135
203
|
const upstreamRes = await proxyUpstream({
|
|
136
204
|
request,
|
|
137
205
|
service,
|
|
@@ -284,6 +352,26 @@ function buildDiscoveryRoutes(services) {
|
|
|
284
352
|
};
|
|
285
353
|
}));
|
|
286
354
|
}
|
|
355
|
+
/** Headers the handler reads Payment credentials from, lowercased (Authorization by default). */
|
|
356
|
+
function credentialHeaders(handler) {
|
|
357
|
+
const internal = ('_internal' in handler ? handler._internal : undefined);
|
|
358
|
+
const headers = internal?.offers
|
|
359
|
+
? internal.offers.flatMap((offer) => offer.credentialHeaders ?? [])
|
|
360
|
+
: (internal?.credentialHeaders ?? []);
|
|
361
|
+
const names = [...new Set(headers.map((header) => header.toLowerCase()))];
|
|
362
|
+
return names.length > 0 ? names : ['authorization'];
|
|
363
|
+
}
|
|
364
|
+
/** Whether a paid handler, or any offer of a composed one, requires identity verifiers. */
|
|
365
|
+
function requiresIdentity(handler) {
|
|
366
|
+
if (!('_internal' in handler))
|
|
367
|
+
return false;
|
|
368
|
+
const internal = handler._internal;
|
|
369
|
+
if (!internal)
|
|
370
|
+
return false;
|
|
371
|
+
if (internal.offers)
|
|
372
|
+
return internal.offers.some((offer) => offer._identity);
|
|
373
|
+
return internal._identity === true;
|
|
374
|
+
}
|
|
287
375
|
function getConfiguredScope(handler) {
|
|
288
376
|
if (!('_internal' in handler))
|
|
289
377
|
return undefined;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type * as Store from '../../Store.js';
|
|
2
|
+
/** Options for replaying paid proxy deliveries. */
|
|
3
|
+
export type Options = {
|
|
4
|
+
/**
|
|
5
|
+
* Upstream attempts per credential, including the first, before a retry gets `502`.
|
|
6
|
+
* A retry after a failed delivery (an upstream error, a 5xx or a body that broke
|
|
7
|
+
* off) sends the paid request upstream again without charging, so a
|
|
8
|
+
* non-idempotent upstream may run the operation twice; set `1` to only ever
|
|
9
|
+
* replay a recorded response. @default 3
|
|
10
|
+
*/
|
|
11
|
+
maxAttempts?: number | undefined;
|
|
12
|
+
/** Largest response body, in bytes, kept for replay. Larger or streamed bodies are delivered but not kept. @default 1_048_576 */
|
|
13
|
+
maxBodySize?: number | undefined;
|
|
14
|
+
/** Largest request body, in bytes, a paid request may carry; larger ones get `413`. @default 1_048_576 */
|
|
15
|
+
maxRequestBodySize?: number | undefined;
|
|
16
|
+
/** `Retry-After` seconds returned while a delivery is still in flight. @default 5 */
|
|
17
|
+
retryAfter?: number | undefined;
|
|
18
|
+
/** Atomic store shared by every proxy instance that may receive a retry. */
|
|
19
|
+
store: Store.AtomicStore;
|
|
20
|
+
/**
|
|
21
|
+
* How long to wait for the upstream to start responding, and how long its outcome is kept after it is
|
|
22
|
+
* recorded, in milliseconds. Expired records are removed when they are next
|
|
23
|
+
* looked up, as with mppx's replay markers; give the backing store its own
|
|
24
|
+
* key expiry to reclaim records nobody retries. @default 900_000
|
|
25
|
+
*/
|
|
26
|
+
ttl?: number | undefined;
|
|
27
|
+
/** Keeps background work alive after the response returns, such as Cloudflare's `ctx.waitUntil`. */
|
|
28
|
+
waitUntil?: ((promise: Promise<unknown>) => void) | undefined;
|
|
29
|
+
};
|
|
30
|
+
/** Replay options with defaults applied. */
|
|
31
|
+
export type Resolved = {
|
|
32
|
+
maxAttempts: number;
|
|
33
|
+
maxBodySize: number;
|
|
34
|
+
maxRequestBodySize: number;
|
|
35
|
+
retryAfter: number;
|
|
36
|
+
store: Store.AtomicStore;
|
|
37
|
+
ttl: number;
|
|
38
|
+
waitUntil: ((promise: Promise<unknown>) => void) | undefined;
|
|
39
|
+
};
|
|
40
|
+
/** A delivery record, keyed by credential and request. */
|
|
41
|
+
export type Record = {
|
|
42
|
+
attempts: number;
|
|
43
|
+
expires: number;
|
|
44
|
+
receipt: string | null;
|
|
45
|
+
requestHeaders: [string, string][];
|
|
46
|
+
status: 'pending';
|
|
47
|
+
} | {
|
|
48
|
+
attempts: number;
|
|
49
|
+
expires: number;
|
|
50
|
+
receipt: string | null;
|
|
51
|
+
requestHeaders: [string, string][];
|
|
52
|
+
status: 'failed';
|
|
53
|
+
} | {
|
|
54
|
+
expires: number;
|
|
55
|
+
status: 'delivered';
|
|
56
|
+
} | {
|
|
57
|
+
body: string;
|
|
58
|
+
expires: number;
|
|
59
|
+
headers: [string, string][];
|
|
60
|
+
responseStatus: number;
|
|
61
|
+
status: 'complete';
|
|
62
|
+
statusText: string;
|
|
63
|
+
};
|
|
64
|
+
/** Applies defaults to replay options. */
|
|
65
|
+
export declare function resolve(options: Options): Resolved;
|
|
66
|
+
/** Returns the request's Payment credential header value, if it carries one. */
|
|
67
|
+
export declare function credentialOf(request: Request, headerNames?: readonly string[]): string | undefined;
|
|
68
|
+
/** Reads a request body, or returns null when it is larger than maxRequestBodySize. */
|
|
69
|
+
export declare function readBody(request: Request, maxRequestBodySize: number): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
70
|
+
/** Derives the replay key from the credential and the exact request (origin, path, query, method, body) it paid for. */
|
|
71
|
+
export declare function key(parameters: {
|
|
72
|
+
body: Uint8Array;
|
|
73
|
+
credential: string;
|
|
74
|
+
method: string;
|
|
75
|
+
target: string;
|
|
76
|
+
}): string;
|
|
77
|
+
/** Rebuilds a request around an already-read body, optionally with another abort signal. */
|
|
78
|
+
export declare function withBody(request: Request, body: Uint8Array, signal?: AbortSignal, headers?: HeadersInit): Request;
|
|
79
|
+
/** What a request with a known credential should do. */
|
|
80
|
+
export type Lookup = {
|
|
81
|
+
type: 'none';
|
|
82
|
+
} | {
|
|
83
|
+
response: Response;
|
|
84
|
+
type: 'respond';
|
|
85
|
+
} | {
|
|
86
|
+
attempts: number;
|
|
87
|
+
receipt: string | null;
|
|
88
|
+
requestHeaders: [string, string][];
|
|
89
|
+
type: 'retry';
|
|
90
|
+
};
|
|
91
|
+
/** Looks up a delivery and, for a failed one, claims the next upstream attempt. */
|
|
92
|
+
export declare function lookup(replay: Resolved, replayKey: string): Promise<Lookup>;
|
|
93
|
+
/** Runs a paid upstream delivery detached from the client and records its outcome. */
|
|
94
|
+
export declare function deliver(parameters: {
|
|
95
|
+
applyReceipt: (response: Response) => Response | Promise<Response>;
|
|
96
|
+
attempts: number;
|
|
97
|
+
replay: Resolved;
|
|
98
|
+
replayKey: string;
|
|
99
|
+
/** The upstream headers of the paid request; a retried delivery reuses them. */
|
|
100
|
+
requestHeaders: [string, string][];
|
|
101
|
+
run: (signal: AbortSignal) => Promise<Response>;
|
|
102
|
+
}): Promise<Response>;
|
|
103
|
+
/** Adds a stored receipt to a response built for a retried delivery. */
|
|
104
|
+
export declare function withReceiptHeader(response: Response, receipt: string | null): Response;
|
|
105
|
+
//# sourceMappingURL=Replay.d.ts.map
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { Base64, Bytes, Hash } from 'ox';
|
|
2
|
+
import * as Credential from '../../Credential.js';
|
|
3
|
+
const keyPrefix = 'mppx:proxy:replay:';
|
|
4
|
+
/** Applies defaults to replay options. */
|
|
5
|
+
export function resolve(options) {
|
|
6
|
+
const ttl = options.ttl ?? 900_000;
|
|
7
|
+
// AbortSignal.timeout needs a positive integer that fits a 32-bit timer.
|
|
8
|
+
if (!Number.isSafeInteger(ttl) || ttl <= 0 || ttl > 2_147_483_647)
|
|
9
|
+
throw new RangeError('replay.ttl must be a positive integer number of milliseconds up to 2147483647.');
|
|
10
|
+
return {
|
|
11
|
+
maxAttempts: options.maxAttempts ?? 3,
|
|
12
|
+
maxBodySize: options.maxBodySize ?? 1_048_576,
|
|
13
|
+
maxRequestBodySize: options.maxRequestBodySize ?? 1_048_576,
|
|
14
|
+
retryAfter: options.retryAfter ?? 5,
|
|
15
|
+
store: options.store,
|
|
16
|
+
ttl,
|
|
17
|
+
waitUntil: options.waitUntil,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** Returns the request's Payment credential header value, if it carries one. */
|
|
21
|
+
export function credentialOf(request, headerNames = ['authorization']) {
|
|
22
|
+
// Only the headers the route's payment handler verifies can name a replay.
|
|
23
|
+
for (const name of headerNames) {
|
|
24
|
+
const header = request.headers.get(name);
|
|
25
|
+
const payment = header ? Credential.extractPaymentScheme(header) : null;
|
|
26
|
+
if (payment)
|
|
27
|
+
return payment;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Reads a request body, or returns null when it is larger than maxRequestBodySize. */
|
|
32
|
+
export async function readBody(request, maxRequestBodySize) {
|
|
33
|
+
const length = Number(request.headers.get('content-length'));
|
|
34
|
+
if (Number.isFinite(length) && length > maxRequestBodySize)
|
|
35
|
+
return null;
|
|
36
|
+
if (!request.body)
|
|
37
|
+
return new Uint8Array();
|
|
38
|
+
return keep(request.body, maxRequestBodySize);
|
|
39
|
+
}
|
|
40
|
+
/** Derives the replay key from the credential and the exact request (origin, path, query, method, body) it paid for. */
|
|
41
|
+
export function key(parameters) {
|
|
42
|
+
const bodyHash = Hash.sha256(parameters.body, { as: 'Hex' });
|
|
43
|
+
const input = JSON.stringify([
|
|
44
|
+
parameters.credential,
|
|
45
|
+
parameters.method.toUpperCase(),
|
|
46
|
+
parameters.target,
|
|
47
|
+
bodyHash,
|
|
48
|
+
]);
|
|
49
|
+
return keyPrefix + Hash.sha256(Bytes.fromString(input), { as: 'Hex' }).slice(2);
|
|
50
|
+
}
|
|
51
|
+
/** Rebuilds a request around an already-read body, optionally with another abort signal. */
|
|
52
|
+
export function withBody(request, body, signal, headers) {
|
|
53
|
+
const method = request.method.toUpperCase();
|
|
54
|
+
const hasBody = method !== 'GET' && method !== 'HEAD';
|
|
55
|
+
return new globalThis.Request(request.url, {
|
|
56
|
+
// An empty body stays absent, as on the original request: payment
|
|
57
|
+
// transports tell management requests apart by a missing body.
|
|
58
|
+
body: hasBody && body.byteLength > 0 ? body : null,
|
|
59
|
+
headers: headers ?? request.headers,
|
|
60
|
+
method: request.method,
|
|
61
|
+
redirect: request.redirect,
|
|
62
|
+
signal: signal ?? request.signal,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Looks up a delivery and, for a failed one, claims the next upstream attempt. */
|
|
66
|
+
export async function lookup(replay, replayKey) {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
// Read first: only a failed delivery needs an atomic claim, so unknown
|
|
69
|
+
// credentials never cause a store write.
|
|
70
|
+
const seen = (await replay.store.get(replayKey));
|
|
71
|
+
if (!seen)
|
|
72
|
+
return { type: 'none' };
|
|
73
|
+
if (seen.expires > now && seen.status !== 'failed')
|
|
74
|
+
return { response: respond(replay, seen), type: 'respond' };
|
|
75
|
+
const result = await replay.store.update(replayKey, (current) => {
|
|
76
|
+
const record = current;
|
|
77
|
+
if (!record)
|
|
78
|
+
return { op: 'noop', result: { type: 'none' } };
|
|
79
|
+
if (record.expires <= now)
|
|
80
|
+
return { op: 'delete', result: { type: 'none' } };
|
|
81
|
+
if (record.status !== 'failed' || record.attempts >= replay.maxAttempts)
|
|
82
|
+
return { op: 'noop', result: record };
|
|
83
|
+
const attempts = record.attempts + 1;
|
|
84
|
+
return {
|
|
85
|
+
op: 'set',
|
|
86
|
+
value: { ...record, attempts, status: 'pending' },
|
|
87
|
+
result: {
|
|
88
|
+
attempts,
|
|
89
|
+
receipt: record.receipt,
|
|
90
|
+
requestHeaders: record.requestHeaders,
|
|
91
|
+
type: 'retry',
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
if ('type' in result)
|
|
96
|
+
return result;
|
|
97
|
+
return { response: respond(replay, result), type: 'respond' };
|
|
98
|
+
}
|
|
99
|
+
/** Builds the response for a recorded delivery that is not retried. */
|
|
100
|
+
function respond(replay, record) {
|
|
101
|
+
if (record.status === 'complete')
|
|
102
|
+
return fromRecord(record);
|
|
103
|
+
if (record.status === 'delivered')
|
|
104
|
+
return Response.json({ error: 'The paid response was delivered and is not stored for replay.' }, { status: 410 });
|
|
105
|
+
if (record.status === 'pending')
|
|
106
|
+
return new Response(null, {
|
|
107
|
+
headers: { 'Retry-After': String(replay.retryAfter) },
|
|
108
|
+
status: 202,
|
|
109
|
+
});
|
|
110
|
+
return withReceiptHeader(Response.json({ error: 'The paid upstream request failed.' }, { status: 502 }), record.receipt);
|
|
111
|
+
}
|
|
112
|
+
/** Runs a paid upstream delivery detached from the client and records its outcome. */
|
|
113
|
+
export async function deliver(parameters) {
|
|
114
|
+
const { applyReceipt, attempts, replay, replayKey, requestHeaders } = parameters;
|
|
115
|
+
// Outcomes are kept for ttl from when they are recorded, not from the start.
|
|
116
|
+
const expires = () => Date.now() + replay.ttl;
|
|
117
|
+
await record(replay, replayKey, {
|
|
118
|
+
attempts,
|
|
119
|
+
expires: expires(),
|
|
120
|
+
receipt: null,
|
|
121
|
+
requestHeaders,
|
|
122
|
+
status: 'pending',
|
|
123
|
+
});
|
|
124
|
+
let response;
|
|
125
|
+
try {
|
|
126
|
+
// ttl bounds the wait for the upstream to respond, not how long its body
|
|
127
|
+
// streams: a paid stream keeps going once it has started.
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
const timer = setTimeout(() => controller.abort(new DOMException('Replay ttl elapsed.', 'TimeoutError')), replay.ttl);
|
|
130
|
+
let upstream;
|
|
131
|
+
try {
|
|
132
|
+
upstream = await parameters.run(controller.signal);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
}
|
|
137
|
+
response = await applyReceipt(upstream);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
const failed = await applyReceipt(Response.json({ error: 'The paid upstream request failed.' }, { status: 502 }));
|
|
141
|
+
await recordFailure(replay, replayKey, attempts, requestHeaders, expires(), failed.headers.get('Payment-Receipt'));
|
|
142
|
+
return failed;
|
|
143
|
+
}
|
|
144
|
+
const receipt = response.headers.get('Payment-Receipt');
|
|
145
|
+
if (response.status >= 500) {
|
|
146
|
+
await recordFailure(replay, replayKey, attempts, requestHeaders, expires(), receipt);
|
|
147
|
+
return response;
|
|
148
|
+
}
|
|
149
|
+
if (!response.body) {
|
|
150
|
+
await record(replay, replayKey, toRecord(response, new Uint8Array(), expires()));
|
|
151
|
+
return response;
|
|
152
|
+
}
|
|
153
|
+
if (/^text\/event-stream/i.test(response.headers.get('content-type') ?? '')) {
|
|
154
|
+
await record(replay, replayKey, { expires: expires(), status: 'delivered' });
|
|
155
|
+
return response;
|
|
156
|
+
}
|
|
157
|
+
// Keep a copy while the client reads the other branch, so a client that
|
|
158
|
+
// disconnects mid-body can still collect the response on retry.
|
|
159
|
+
const [client, kept] = response.body.tee();
|
|
160
|
+
const stored = keep(kept, replay.maxBodySize)
|
|
161
|
+
.then((body) => replay.store.put(replayKey, body
|
|
162
|
+
? toRecord(response, body, expires())
|
|
163
|
+
: { expires: expires(), status: 'delivered' }),
|
|
164
|
+
// The upstream body broke off: let the payer retry the delivery.
|
|
165
|
+
() => recordFailure(replay, replayKey, attempts, requestHeaders, expires(), receipt))
|
|
166
|
+
// A failed background write leaves the record pending until it expires.
|
|
167
|
+
.catch(() => { });
|
|
168
|
+
replay.waitUntil?.(stored);
|
|
169
|
+
return new Response(client, {
|
|
170
|
+
headers: response.headers,
|
|
171
|
+
status: response.status,
|
|
172
|
+
statusText: response.statusText,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Reads a body branch up to maxBodySize, or returns null when it is larger. */
|
|
176
|
+
async function keep(stream, maxBodySize) {
|
|
177
|
+
const reader = stream.getReader();
|
|
178
|
+
const chunks = [];
|
|
179
|
+
let size = 0;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const { done, value } = await reader.read();
|
|
182
|
+
if (done)
|
|
183
|
+
break;
|
|
184
|
+
size += value.byteLength;
|
|
185
|
+
if (size > maxBodySize) {
|
|
186
|
+
await reader.cancel();
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
chunks.push(value);
|
|
190
|
+
}
|
|
191
|
+
const body = new Uint8Array(size);
|
|
192
|
+
let offset = 0;
|
|
193
|
+
for (const chunk of chunks) {
|
|
194
|
+
body.set(chunk, offset);
|
|
195
|
+
offset += chunk.byteLength;
|
|
196
|
+
}
|
|
197
|
+
return body;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Writes a replay record without letting a store failure break the paid
|
|
201
|
+
* delivery: the payer still gets the upstream response, only replay is lost.
|
|
202
|
+
*/
|
|
203
|
+
async function record(replay, replayKey, value) {
|
|
204
|
+
try {
|
|
205
|
+
await replay.store.put(replayKey, value);
|
|
206
|
+
}
|
|
207
|
+
catch { }
|
|
208
|
+
}
|
|
209
|
+
async function recordFailure(replay, replayKey, attempts, requestHeaders, expires, receipt) {
|
|
210
|
+
await record(replay, replayKey, {
|
|
211
|
+
attempts,
|
|
212
|
+
expires,
|
|
213
|
+
receipt,
|
|
214
|
+
requestHeaders,
|
|
215
|
+
status: 'failed',
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
/** Adds a stored receipt to a response built for a retried delivery. */
|
|
219
|
+
export function withReceiptHeader(response, receipt) {
|
|
220
|
+
if (!receipt)
|
|
221
|
+
return response;
|
|
222
|
+
const headers = new Headers(response.headers);
|
|
223
|
+
headers.set('Payment-Receipt', receipt);
|
|
224
|
+
headers.set('Cache-Control', withPrivateCacheControl(headers.get('Cache-Control')));
|
|
225
|
+
return new Response(response.body, {
|
|
226
|
+
headers,
|
|
227
|
+
status: response.status,
|
|
228
|
+
statusText: response.statusText,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
function withPrivateCacheControl(value) {
|
|
232
|
+
if (!value)
|
|
233
|
+
return 'private';
|
|
234
|
+
const directives = value.split(',').map((directive) => directive.trim().toLowerCase());
|
|
235
|
+
if (directives.includes('private'))
|
|
236
|
+
return value;
|
|
237
|
+
return `${value}, private`;
|
|
238
|
+
}
|
|
239
|
+
function toRecord(response, body, expires) {
|
|
240
|
+
return {
|
|
241
|
+
body: Base64.fromBytes(body),
|
|
242
|
+
expires,
|
|
243
|
+
headers: [...response.headers.entries()],
|
|
244
|
+
responseStatus: response.status,
|
|
245
|
+
status: 'complete',
|
|
246
|
+
statusText: response.statusText,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function fromRecord(record) {
|
|
250
|
+
const nullBody = [101, 204, 205, 304].includes(record.responseStatus);
|
|
251
|
+
return new Response(nullBody ? null : Base64.toBytes(record.body), {
|
|
252
|
+
headers: record.headers,
|
|
253
|
+
status: record.responseStatus,
|
|
254
|
+
statusText: record.statusText,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
//# sourceMappingURL=Replay.js.map
|
package/dist/server/Mppx.d.ts
CHANGED
|
@@ -412,8 +412,10 @@ type ConfiguredHandler<transport extends Transport.AnyTransport = Transport.Http
|
|
|
412
412
|
scope?: string | undefined;
|
|
413
413
|
_canonicalRequest: Record<string, unknown>;
|
|
414
414
|
_stableBinding?: Method.StableBindingFn<Method.Method> | undefined;
|
|
415
|
-
/**
|
|
416
|
-
_paymentIdentity?:
|
|
415
|
+
/** The offer's payment-stage identity verifiers, if any. */
|
|
416
|
+
_paymentIdentity?: readonly Identity.Verifier[] | undefined;
|
|
417
|
+
/** Whether the route requires identity verifiers (request or payment stage). */
|
|
418
|
+
_identity?: boolean | undefined;
|
|
417
419
|
_transport: Transport.AnyTransport;
|
|
418
420
|
};
|
|
419
421
|
};
|