@pulse-compute/runtime 1.0.0-beta.4 → 1.0.0-beta.6
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/README.md +2 -2
- package/docs/API.md +52 -16
- package/docs/guides/routing.md +71 -1
- package/docs/preview-scope.md +17 -8
- package/package.json +2 -2
- package/src/host.d.ts +75 -0
- package/src/host.js +5 -0
- package/src/index.d.ts +25 -2
- package/src/internal/body.js +11 -2
- package/src/internal/context.js +66 -7
- package/src/internal/effect-adapter.js +57 -9
- package/src/internal/errors.js +29 -1
- package/src/internal/fetch.js +3 -2
- package/src/internal/incoming-body.js +68 -0
- package/src/internal/package-runtime.js +16 -1
- package/src/internal/redaction.js +14 -3
- package/src/internal/request-budget.js +103 -0
- package/src/internal/response.js +32 -5
- package/src/internal/router.js +86 -55
- package/src/internal/schema.js +20 -5
- package/src/internal/time.js +41 -0
package/src/internal/context.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { createStructuredBodyReader, normalizeBodyLimit } = require('./body.js');
|
|
4
4
|
const { normalizeFetchRequest } = require('./fetch.js');
|
|
5
|
+
const { isIncomingBody } = require('./incoming-body.js');
|
|
6
|
+
const { PulseRuntimeContractError } = require('./errors.js');
|
|
5
7
|
const { decodeSchemaText, encodeSchemaValue, requireExplicitSchemaId, strictSchemaPolicy } = require('./schema.js');
|
|
6
8
|
const {
|
|
7
9
|
cloneKvValue,
|
|
@@ -20,6 +22,10 @@ const {
|
|
|
20
22
|
projectFetchResponse
|
|
21
23
|
} = require('./response.js');
|
|
22
24
|
|
|
25
|
+
// Router entries create new contexts but share one effect execution. Keep fetch
|
|
26
|
+
// numbering with that execution, without retaining completed requests.
|
|
27
|
+
const fetchSequences = new WeakMap();
|
|
28
|
+
|
|
23
29
|
function headersToPairs(headers) {
|
|
24
30
|
const pairs = [];
|
|
25
31
|
for (const [name, value] of headers.entries()) pairs.push(Object.freeze([name, value]));
|
|
@@ -35,6 +41,7 @@ function normalizeRequestHeaderPairs(headers, fallback) {
|
|
|
35
41
|
}
|
|
36
42
|
|
|
37
43
|
function createRequestView(request, requestHeaders, effectExecution, options = {}) {
|
|
44
|
+
options.incomingBody?.bindInvalidation(error => effectExecution.invalidateAdmission(error));
|
|
38
45
|
const url = new URL(request.url);
|
|
39
46
|
const headers = normalizeRequestHeaderPairs(requestHeaders, request.headers);
|
|
40
47
|
const body = createStructuredBodyReader(request, {
|
|
@@ -52,16 +59,28 @@ function createRequestView(request, requestHeaders, effectExecution, options = {
|
|
|
52
59
|
url: request.url,
|
|
53
60
|
path: url.pathname || '/',
|
|
54
61
|
headers,
|
|
62
|
+
body(...args) {
|
|
63
|
+
if (args.length) throw new TypeError('ctx.req.body takes no arguments.');
|
|
64
|
+
if (!options.incomingBody) throw new PulseRuntimeContractError(
|
|
65
|
+
'PULSE_REQUEST_FORWARDING_UNAVAILABLE', 'Incoming body forwarding requires the configured Node JavaScript provider.');
|
|
66
|
+
return options.incomingBody.marker();
|
|
67
|
+
},
|
|
68
|
+
readTextChunk(...args) {
|
|
69
|
+
if (args.length) throw new TypeError('ctx.req.readTextChunk takes no arguments.');
|
|
70
|
+
return effectExecution.dispatch({ kind: 'output.readTextChunk', providerKind: 'output', operation: 'readTextChunk', capability: 'request.body.transform', parallelEligible: false });
|
|
71
|
+
},
|
|
55
72
|
header(name) {
|
|
56
73
|
const lower = String(name).toLowerCase();
|
|
57
74
|
const match = headers.find(([header]) => header.toLowerCase() === lower);
|
|
58
75
|
return match ? match[1] : undefined;
|
|
59
76
|
},
|
|
60
77
|
text() {
|
|
78
|
+
options.incomingBody?.structured();
|
|
61
79
|
if (!textEffect) textEffect = effectExecution.local('request.body.text', () => body.text());
|
|
62
80
|
return textEffect;
|
|
63
81
|
},
|
|
64
82
|
json(schemaId) {
|
|
83
|
+
options.incomingBody?.structured();
|
|
65
84
|
if (schemaId === undefined && !strictSchemaPolicy(options)) {
|
|
66
85
|
if (!jsonEffect) jsonEffect = effectExecution.local('request.body.json', () => body.json());
|
|
67
86
|
return jsonEffect;
|
|
@@ -145,7 +164,6 @@ function createContext(frame) {
|
|
|
145
164
|
...(frame.executionOptions || {}),
|
|
146
165
|
signal: frame.signal
|
|
147
166
|
});
|
|
148
|
-
let fetchSequence = 0;
|
|
149
167
|
const log = createPulseLogger({
|
|
150
168
|
reporting: options.reporting,
|
|
151
169
|
redact(message) { return effects.redactValue(message); },
|
|
@@ -161,17 +179,32 @@ function createContext(frame) {
|
|
|
161
179
|
const id = requireExplicitSchemaId(schemaId, options, 'application-value');
|
|
162
180
|
return encodeSchemaValue(id, value, options, { source: 'application-value' }).text;
|
|
163
181
|
},
|
|
182
|
+
decodeJson(text, schemaId) {
|
|
183
|
+
const id = requireExplicitSchemaId(schemaId, options, 'application-text');
|
|
184
|
+
return decodeSchemaText(id, text, options, { source: 'application-text' });
|
|
185
|
+
},
|
|
164
186
|
...(eventContext ? {} : { req }),
|
|
165
187
|
state,
|
|
166
188
|
log,
|
|
167
189
|
fetch(url, init) {
|
|
168
|
-
fetchSequence
|
|
190
|
+
const fetchSequence = (fetchSequences.get(effects) || 0) + 1;
|
|
191
|
+
fetchSequences.set(effects, fetchSequence);
|
|
169
192
|
const effectId = `fetch-${fetchSequence}`;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
193
|
+
let fetchRequest;
|
|
194
|
+
try {
|
|
195
|
+
fetchRequest = normalizeFetchRequest(url, init, {
|
|
196
|
+
...options,
|
|
197
|
+
operationId: `fetch-request:${effectId}`,
|
|
198
|
+
effectId
|
|
199
|
+
});
|
|
200
|
+
if (fetchRequest.init.bodyMode === 'incoming-request-v1') {
|
|
201
|
+
if (!options.incomingBody) throw new PulseRuntimeContractError('PULSE_REQUEST_BODY_OWNERSHIP', 'Incoming body belongs to another execution.');
|
|
202
|
+
options.incomingBody.claim(fetchRequest.init.body, fetchRequest.init);
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (isIncomingBody(init?.body)) effects.invalidateAdmission(error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
175
208
|
const fetchEffect = effects.dispatch({
|
|
176
209
|
id: effectId,
|
|
177
210
|
kind: 'fetch',
|
|
@@ -183,6 +216,12 @@ function createContext(frame) {
|
|
|
183
216
|
});
|
|
184
217
|
return createFetchOperation(effects, fetchEffect, fetchRequest, options, effectId);
|
|
185
218
|
},
|
|
219
|
+
time: Object.freeze({
|
|
220
|
+
now(...args) {
|
|
221
|
+
if (args.length) throw new TypeError('Pulse time.now takes no arguments.');
|
|
222
|
+
return effects.dispatch({ kind: 'time.now', providerKind: 'time', operation: 'now', capability: 'time.wall-clock' });
|
|
223
|
+
}
|
|
224
|
+
}),
|
|
186
225
|
parallel(record) { return effects.parallel(record); },
|
|
187
226
|
config: Object.freeze({
|
|
188
227
|
get(name) {
|
|
@@ -256,6 +295,26 @@ function createContext(frame) {
|
|
|
256
295
|
if (eventContext) {
|
|
257
296
|
ctx.event = frame.event;
|
|
258
297
|
} else {
|
|
298
|
+
const output = () => {
|
|
299
|
+
if (!options.outputExecution) throw new PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires configured Node HTTP execution.');
|
|
300
|
+
return options.outputExecution;
|
|
301
|
+
};
|
|
302
|
+
ctx.output = Object.freeze({
|
|
303
|
+
start(...args) {
|
|
304
|
+
if (args.length > 1) throw new TypeError('output.start accepts one options object.');
|
|
305
|
+
output().assertEffect();
|
|
306
|
+
return effects.dispatch({ kind: 'output.start', providerKind: 'output', operation: 'start', capability: 'response.output', parallelEligible: false, argument0: args[0] });
|
|
307
|
+
},
|
|
308
|
+
write(...args) {
|
|
309
|
+
if (args.length !== 1) throw new TypeError('output.write requires one text chunk.');
|
|
310
|
+
output().assertEffect();
|
|
311
|
+
return effects.dispatch({ kind: 'output.write', providerKind: 'output', operation: 'write', capability: 'response.output', parallelEligible: false, argument0: args[0] });
|
|
312
|
+
},
|
|
313
|
+
close(...args) {
|
|
314
|
+
if (args.length) throw new TypeError('output.close accepts no arguments.');
|
|
315
|
+
return output().close(init => createTextResult('', init));
|
|
316
|
+
}
|
|
317
|
+
});
|
|
259
318
|
ctx.json = (value, descriptor) => createJsonResult(value, descriptor, options);
|
|
260
319
|
ctx.text = (value, responseOptions) => createTextResult(value, responseOptions);
|
|
261
320
|
ctx.response = (input) => createResponseResult(input);
|
|
@@ -9,11 +9,13 @@ const {
|
|
|
9
9
|
normalizeKvNamespace,
|
|
10
10
|
normalizeKvPutResult
|
|
11
11
|
} = require('./bindings.js');
|
|
12
|
+
const { normalizeTimeResult, readWallTime } = require('./time.js');
|
|
12
13
|
const conditionalKv = require('./conditional-kv.js');
|
|
13
14
|
const { fetchRequestInitForHost } = require('./fetch.js');
|
|
14
15
|
const { createRedactionState } = require('./redaction.js');
|
|
15
16
|
const { validateSchemaValue } = require('./schema.js');
|
|
16
17
|
const { EVENT_EMIT_CODES } = require('./event-emission.js');
|
|
18
|
+
const { cancelResponseBody, retainFetchResponseForBudget, releaseFetchResponseCancellation } = require('./response.js');
|
|
17
19
|
|
|
18
20
|
const JAVASCRIPT_EFFECT_PROTOCOL_VERSION = 'pulse.javascript-effect.v1';
|
|
19
21
|
const JAVASCRIPT_EFFECT_ADAPTER_VERSION = 'pulse.javascript-effect-adapter.v1';
|
|
@@ -244,6 +246,7 @@ function createCapabilityEffectAdapter(capabilities = {}) {
|
|
|
244
246
|
if (typeof value.fetch !== 'function') return unavailableCapability(effect);
|
|
245
247
|
return value.fetch(effect.url, fetchRequestInitForHost(effect.init, execution.signal), execution);
|
|
246
248
|
}
|
|
249
|
+
if (effect.kind === 'time.now') return typeof value.time === 'function' ? value.time(execution) : readWallTime();
|
|
247
250
|
if (effect.kind === 'config.get') {
|
|
248
251
|
if (typeof value.config !== 'function') return unavailableCapability(effect);
|
|
249
252
|
return value.config(effect.name, execution);
|
|
@@ -305,6 +308,7 @@ function normalizeEffectDescriptor(input, limits) {
|
|
|
305
308
|
}
|
|
306
309
|
|
|
307
310
|
function normalizeEffectResult(descriptor, value, limits) {
|
|
311
|
+
if (descriptor.kind === 'time.now') return normalizeTimeResult(value);
|
|
308
312
|
if (descriptor.kind === 'config.get' || descriptor.kind === 'secret.get') {
|
|
309
313
|
return normalizeBindingValue(descriptor.kind, descriptor.name, value, limits);
|
|
310
314
|
}
|
|
@@ -490,6 +494,8 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
490
494
|
event: options.event,
|
|
491
495
|
application: options.application,
|
|
492
496
|
signal: lifecycleController.signal,
|
|
497
|
+
deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
498
|
+
requestBudget: options.requestBudget,
|
|
493
499
|
registerRedactionValue(value) {
|
|
494
500
|
if (typeof value === 'string' && value.length > 0) redaction.add(value);
|
|
495
501
|
},
|
|
@@ -505,6 +511,7 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
505
511
|
const counters = new Map();
|
|
506
512
|
const dispatchedIds = new Set();
|
|
507
513
|
const pendingEffects = new Map();
|
|
514
|
+
const ownedResponses = new Set();
|
|
508
515
|
const claimedParallelRootIds = new Set();
|
|
509
516
|
const observations = [];
|
|
510
517
|
const resolutionOrder = [];
|
|
@@ -541,6 +548,7 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
541
548
|
'Pulse effect execution is already closed.'
|
|
542
549
|
);
|
|
543
550
|
}
|
|
551
|
+
options.requestBudget?.check();
|
|
544
552
|
}
|
|
545
553
|
|
|
546
554
|
function trackEffect(effect, data) {
|
|
@@ -565,6 +573,7 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
565
573
|
limitMessage
|
|
566
574
|
);
|
|
567
575
|
}
|
|
576
|
+
options.outputExecution?.assertEffect();
|
|
568
577
|
const normalizedInput = normalizeEffectDescriptor(input, limits);
|
|
569
578
|
const kind = String(normalizedInput.kind || 'effect');
|
|
570
579
|
const descriptor = Object.freeze({
|
|
@@ -597,20 +606,36 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
597
606
|
const operationSignal = createEffectSignal(lifecycleController.signal, descriptor);
|
|
598
607
|
const operationExecution = Object.freeze({ ...externalExecution, signal: operationSignal.signal });
|
|
599
608
|
const raw = raceWithSignal(Promise.resolve().then(() => {
|
|
609
|
+
options.requestBudget?.check();
|
|
600
610
|
if (operationSignal.signal.aborted) throw abortedEffectError(operationSignal.signal.reason);
|
|
611
|
+
options.outputExecution?.assertEffect();
|
|
612
|
+
if (kind.startsWith('output.')) {
|
|
613
|
+
if (!options.outputExecution) throw new PulseRuntimeContractError('PULSE_OUTPUT_UNAVAILABLE', 'Generated output requires a Node HTTP writer.');
|
|
614
|
+
return options.outputExecution.dispatch(descriptor);
|
|
615
|
+
}
|
|
601
616
|
if (conditionalKv.isConditionalKv(kind)) return conditionalKv.executeConditionalKv(descriptor,
|
|
602
617
|
adapter.prepareConditionalKv || ((_admitted, kvExecution) => () => adapter.dispatch(descriptor, kvExecution)),
|
|
603
618
|
{ ...operationExecution, onKvObservation: observe }, limits);
|
|
604
|
-
return adapter.dispatch(descriptor, operationExecution)
|
|
619
|
+
return Promise.resolve(adapter.dispatch(descriptor, operationExecution)).then(value => {
|
|
620
|
+
if (value instanceof Response && value.body) {
|
|
621
|
+
if (closed || operationSignal.signal.aborted) cancelResponseBody(value, operationSignal.signal.reason);
|
|
622
|
+
else {
|
|
623
|
+
ownedResponses.add(value);
|
|
624
|
+
retainFetchResponseForBudget(value, options.requestBudget, () => ownedResponses.delete(value));
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return value;
|
|
628
|
+
});
|
|
605
629
|
}), operationSignal.signal).then(
|
|
606
630
|
(value) => {
|
|
631
|
+
options.requestBudget?.check();
|
|
607
632
|
const normalized = normalizeEffectResult(descriptor, value, limits);
|
|
608
633
|
if (descriptor.kind === 'secret.get' && typeof normalized === 'string') redaction.add(normalized);
|
|
609
634
|
return normalized;
|
|
610
635
|
},
|
|
611
636
|
(error) => { throw redaction.redactError(error); }
|
|
612
637
|
).finally(operationSignal.dispose);
|
|
613
|
-
const projected = (typeof projector === 'function' ? raw.then(projector) : raw).catch((error) => {
|
|
638
|
+
const projected = (typeof projector === 'function' ? raw.then(value => { options.requestBudget?.check(); return projector(value); }).then(value => { options.requestBudget?.check(); return value; }) : raw).catch((error) => {
|
|
614
639
|
throw redaction.redactError(error);
|
|
615
640
|
});
|
|
616
641
|
const effectData = {
|
|
@@ -672,9 +697,10 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
672
697
|
const publicDescriptor = publicEffectDescriptor(descriptor, redaction);
|
|
673
698
|
observe({ type: 'effect-dispatched', effect: publicDescriptor });
|
|
674
699
|
const promise = Promise.resolve().then(() => {
|
|
700
|
+
options.requestBudget?.check();
|
|
675
701
|
if (lifecycleController.signal.aborted) throw abortedEffectError(lifecycleController.signal.reason);
|
|
676
702
|
return producer(externalExecution);
|
|
677
|
-
}).catch((error) => { throw redaction.redactError(error); });
|
|
703
|
+
}).then(value => { options.requestBudget?.check(); return value; }).catch((error) => { throw redaction.redactError(error); });
|
|
678
704
|
const effectData = {
|
|
679
705
|
execution,
|
|
680
706
|
id,
|
|
@@ -711,7 +737,7 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
711
737
|
: 'Pulse effect projections require a request-owned Pulse effect.'
|
|
712
738
|
);
|
|
713
739
|
}
|
|
714
|
-
const projected = raceWithSignal(parent.then(projector), lifecycleController.signal).catch((error) => {
|
|
740
|
+
const projected = raceWithSignal(parent.then(value => { options.requestBudget?.check(); return projector(value); }).then(value => { options.requestBudget?.check(); return value; }), lifecycleController.signal).catch((error) => {
|
|
715
741
|
throw redaction.redactError(error);
|
|
716
742
|
});
|
|
717
743
|
const effectData = {
|
|
@@ -723,7 +749,12 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
723
749
|
|
|
724
750
|
parallel(record) {
|
|
725
751
|
assertOpen();
|
|
726
|
-
|
|
752
|
+
let entries;
|
|
753
|
+
try { entries = validateParallelRecord(record, execution, claimedParallelRootIds); }
|
|
754
|
+
catch (error) {
|
|
755
|
+
if (record && Object.values(Object.getOwnPropertyDescriptors(record)).some(field => dataForEffect(field.value)?.kind?.startsWith('output.'))) execution.invalidateAdmission(error);
|
|
756
|
+
throw error;
|
|
757
|
+
}
|
|
727
758
|
for (const entry of entries) claimedParallelRootIds.add(entry.data.rootId);
|
|
728
759
|
parallelCount += 1;
|
|
729
760
|
const id = `parallel-${parallelCount}`;
|
|
@@ -790,6 +821,11 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
790
821
|
);
|
|
791
822
|
return parallelEffect;
|
|
792
823
|
},
|
|
824
|
+
invalidateAdmission(error) {
|
|
825
|
+
// A synchronous body-claim conflict must fence every already queued
|
|
826
|
+
// group member before its provider dispatch microtask can run.
|
|
827
|
+
if (!lifecycleController.signal.aborted) lifecycleController.abort(error);
|
|
828
|
+
},
|
|
793
829
|
|
|
794
830
|
async assertIdle() {
|
|
795
831
|
if (pendingEffects.size === 0) return;
|
|
@@ -840,7 +876,7 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
840
876
|
});
|
|
841
877
|
},
|
|
842
878
|
|
|
843
|
-
async close() {
|
|
879
|
+
async close(transferredResponse) {
|
|
844
880
|
if (closed) return;
|
|
845
881
|
closed = true;
|
|
846
882
|
if (pendingEffects.size > 0 && !lifecycleController.signal.aborted) {
|
|
@@ -853,9 +889,21 @@ function createJavascriptEffectExecution(options = {}) {
|
|
|
853
889
|
}
|
|
854
890
|
if (pendingEffects.size > 0) await Promise.allSettled(Array.from(pendingEffects.keys()));
|
|
855
891
|
if (removeSourceAbortListener) removeSourceAbortListener();
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
892
|
+
try {
|
|
893
|
+
if (adapter.dispose) await adapter.dispose(externalExecution);
|
|
894
|
+
} catch (error) {
|
|
895
|
+
transferredResponse = undefined;
|
|
896
|
+
throw redaction.redactError(error);
|
|
897
|
+
} finally {
|
|
898
|
+
// Only the final response escapes this execution. Intermediate router
|
|
899
|
+
// responses and successful siblings of a failed group remain owned.
|
|
900
|
+
for (const response of ownedResponses) {
|
|
901
|
+
if (response.body !== transferredResponse?.body) {
|
|
902
|
+
cancelResponseBody(response, lifecycleController.signal.reason);
|
|
903
|
+
releaseFetchResponseCancellation(response);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
ownedResponses.clear();
|
|
859
907
|
}
|
|
860
908
|
}
|
|
861
909
|
};
|
package/src/internal/errors.js
CHANGED
|
@@ -24,4 +24,32 @@ class PulseUnhandledError extends Error {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
// Data failures admitted to the Router error lane. Configuration, provider
|
|
28
|
+
// protocol failures and VM traps are deliberately outside this catalog.
|
|
29
|
+
const APPLICATION_ERROR_CODES = new Set([
|
|
30
|
+
'PULSE_SCHEMA_DECODE', 'PULSE_SCHEMA_ENCODE',
|
|
31
|
+
'PULSE_SCHEMA_JSON_MALFORMED', 'PULSE_SCHEMA_CONTENT_TYPE',
|
|
32
|
+
'PULSE_BODY_TOO_LARGE', 'PULSE_REQUEST_BODY_INVALID_UTF8',
|
|
33
|
+
'PULSE_JWT_TOKEN_REQUIRED', 'PULSE_JWT_BEARER_INVALID',
|
|
34
|
+
'PULSE_JWT_MALFORMED', 'PULSE_JWT_LIMIT_EXCEEDED',
|
|
35
|
+
'PULSE_JWT_ALGORITHM_NOT_ALLOWED', 'PULSE_JWT_KEY_INVALID',
|
|
36
|
+
'PULSE_JWT_SIGNATURE_INVALID', 'PULSE_JWT_CLAIMS_INVALID',
|
|
37
|
+
'PULSE_JWT_CLAIMS_SCHEMA_INVALID', 'PULSE_JWT_CLOCK_INVALID'
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function isApplicationError(error) {
|
|
41
|
+
if (!(error instanceof Error)) return false;
|
|
42
|
+
const code = Object.getOwnPropertyDescriptor(error, 'code')?.value;
|
|
43
|
+
if (!APPLICATION_ERROR_CODES.has(code)) return false;
|
|
44
|
+
const seen = new Set();
|
|
45
|
+
for (let cause = error; cause instanceof Error; cause = Object.getOwnPropertyDescriptor(cause, 'cause')?.value) {
|
|
46
|
+
if (seen.has(cause)) return false;
|
|
47
|
+
seen.add(cause);
|
|
48
|
+
if (cause instanceof WebAssembly.RuntimeError
|
|
49
|
+
|| Object.getOwnPropertyDescriptor(cause, 'code')?.value === 'PULSE_CANONICAL_NATIVE_AS_ABORT'
|
|
50
|
+
|| Object.getOwnPropertyDescriptor(cause, 'name')?.value === 'AbortError') return false;
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = Object.freeze({ PulseRuntimeContractError, PulseUnhandledError, isApplicationError });
|
package/src/internal/fetch.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const { PulseRuntimeContractError } = require('./errors.js');
|
|
4
4
|
const { encodeSchemaValue, requireExplicitSchemaId } = require('./schema.js');
|
|
5
|
+
const { isIncomingBody } = require('./incoming-body.js');
|
|
5
6
|
|
|
6
7
|
const SUPPORTED_FETCH_METHODS = Object.freeze(new Set(['GET', 'HEAD', 'POST']));
|
|
7
8
|
const SUPPORTED_FETCH_INIT_FIELDS = Object.freeze(new Set(['method', 'headers', 'body', 'json', 'schema', 'timeoutMs']));
|
|
@@ -153,11 +154,11 @@ function normalizeFetchInit(input, options = {}) {
|
|
|
153
154
|
let body;
|
|
154
155
|
let bodyMode = 'none';
|
|
155
156
|
if (hasBody) {
|
|
156
|
-
if (typeof value.body !== 'string') {
|
|
157
|
+
if (typeof value.body !== 'string' && !isIncomingBody(value.body)) {
|
|
157
158
|
throw fetchContractError('PULSE_FETCH_BODY_INVALID', 'Pulse fetch body must be a string.', { type: typeof value.body });
|
|
158
159
|
}
|
|
159
160
|
body = value.body;
|
|
160
|
-
bodyMode = 'text';
|
|
161
|
+
bodyMode = isIncomingBody(value.body) ? 'incoming-request-v1' : 'text';
|
|
161
162
|
} else if (hasJson) {
|
|
162
163
|
if (!hasHeader(headers, 'content-type')) {
|
|
163
164
|
headers.push(Object.freeze(['content-type', 'application/json; charset=utf-8']));
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PulseRuntimeContractError } = require('./errors.js');
|
|
4
|
+
const owners = new WeakMap();
|
|
5
|
+
function conflict() {
|
|
6
|
+
return new PulseRuntimeContractError('PULSE_REQUEST_BODY_OWNERSHIP', 'The incoming body has already been claimed by this request.');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Host integration only. Markers carry no stream, bytes or serializable identity.
|
|
10
|
+
function createIncomingBodyOwnership(transport) {
|
|
11
|
+
let state = 'available', invalidation, failure, disposed = false;
|
|
12
|
+
const marker = Object.freeze(Object.create(null));
|
|
13
|
+
const owner = Object.freeze({
|
|
14
|
+
marker() { return marker; },
|
|
15
|
+
bindInvalidation(callback) { invalidation = callback; },
|
|
16
|
+
fail(error) {
|
|
17
|
+
failure ||= error;
|
|
18
|
+
state = 'failed';
|
|
19
|
+
invalidation?.(error);
|
|
20
|
+
return error;
|
|
21
|
+
},
|
|
22
|
+
structured() {
|
|
23
|
+
if (transport.readTextChunk) throw owner.fail(conflict());
|
|
24
|
+
if (state !== 'available' && state !== 'structured') throw owner.fail(conflict());
|
|
25
|
+
state = 'structured';
|
|
26
|
+
},
|
|
27
|
+
claim(value, init) {
|
|
28
|
+
if (owners.get(value) !== owner || state !== 'available') throw owner.fail(conflict());
|
|
29
|
+
transport.validate(init);
|
|
30
|
+
state = 'reserved';
|
|
31
|
+
},
|
|
32
|
+
async readTextChunk(budget) {
|
|
33
|
+
if (!transport.readTextChunk || !['available', 'reading'].includes(state)) throw owner.fail(conflict());
|
|
34
|
+
state = 'reading';
|
|
35
|
+
try { return await transport.readTextChunk(budget); } catch (error) { throw owner.fail(error); }
|
|
36
|
+
},
|
|
37
|
+
async forward(url, init, execution) {
|
|
38
|
+
if (state !== 'reserved') throw failure || conflict();
|
|
39
|
+
state = 'forwarding';
|
|
40
|
+
try {
|
|
41
|
+
const response = await transport.forward(url, init, execution);
|
|
42
|
+
state = 'closed';
|
|
43
|
+
return response;
|
|
44
|
+
} catch (error) { throw owner.fail(error); }
|
|
45
|
+
},
|
|
46
|
+
async close() {
|
|
47
|
+
if (disposed) return;
|
|
48
|
+
disposed = true;
|
|
49
|
+
if (state !== 'closed' && state !== 'failed') state = 'cancelled';
|
|
50
|
+
// Provider cleanup is best effort, must observe rejection, and never
|
|
51
|
+
// delay an execution or grant application authority.
|
|
52
|
+
try { Promise.resolve(transport.cancel(failure)).catch(() => {}); } catch (_) {}
|
|
53
|
+
},
|
|
54
|
+
get failure() { return failure; },
|
|
55
|
+
get responseSignal() { return transport.responseSignal; }
|
|
56
|
+
});
|
|
57
|
+
owners.set(marker, owner);
|
|
58
|
+
return owner;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isIncomingBody(value) { return value !== null && typeof value === 'object' && owners.has(value); }
|
|
62
|
+
function forwardIncomingBody(value, url, init, execution) {
|
|
63
|
+
const owner = owners.get(value);
|
|
64
|
+
if (!owner) throw conflict();
|
|
65
|
+
return owner.forward(url, init, execution);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { createIncomingBodyOwnership, isIncomingBody, forwardIncomingBody };
|
|
@@ -10,14 +10,23 @@ const {
|
|
|
10
10
|
const PACKAGE_RUNTIME_BRIDGE_VERSION = 'pulse.package-runtime-bridge.v1';
|
|
11
11
|
const PACKAGE_SCHEMA_CODEC_BRIDGE_VERSION = 'pulse.first-party-embedded-schema-codec-bridge.v1';
|
|
12
12
|
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
13
|
+
// Fixed first-party text operations alone admit 2 MiB plus worst-case JSON
|
|
14
|
+
// escaping and 64 KiB metadata. Other package operations retain their bounds.
|
|
15
|
+
const TEXT_MAX_PAYLOAD_BYTES = 6 * 2097152 + 65536;
|
|
13
16
|
const DEFAULT_MAX_PAYLOAD_DEPTH = 32;
|
|
14
17
|
const DEFAULT_MAX_PAYLOAD_ENTRIES = 4096;
|
|
15
18
|
|
|
16
19
|
const PACKAGE_CONTEXTS = new WeakMap();
|
|
17
20
|
|
|
18
21
|
const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
|
|
22
|
+
'@pulse-compute/crypto': Object.freeze({
|
|
23
|
+
contractId: 'pulse.crypto', providerKind: 'crypto', operations: Object.freeze({
|
|
24
|
+
digestText: Object.freeze({ kind: 'crypto.digestText', capability: 'crypto.digestText', result: 'text-digest-result' })
|
|
25
|
+
})
|
|
26
|
+
}),
|
|
19
27
|
'@pulse-compute/s3': Object.freeze({
|
|
20
28
|
contractId: 'pulse.s3', providerKind: 's3', operations: Object.freeze({
|
|
29
|
+
getBody: Object.freeze({ kind: 's3.getBody', capability: 's3.getBody', result: 'opaque-response' }),
|
|
21
30
|
head: Object.freeze({ kind: 's3.head', capability: 's3.head', result: 's3-head-result' }),
|
|
22
31
|
getText: Object.freeze({ kind: 's3.getText', capability: 's3.getText', result: 's3-get-text-result' }),
|
|
23
32
|
putText: Object.freeze({ kind: 's3.putText', capability: 's3.putText', result: 's3-put-text-result' })
|
|
@@ -49,6 +58,7 @@ const TRUSTED_PACKAGE_EFFECT_CATALOG = Object.freeze({
|
|
|
49
58
|
contractId: 'pulse.jwt',
|
|
50
59
|
providerKind: 'jwt',
|
|
51
60
|
operations: Object.freeze({
|
|
61
|
+
sign: Object.freeze({ kind: 'jwt.sign', capability: 'jwt.sign', result: 'string' }),
|
|
52
62
|
verify: Object.freeze({
|
|
53
63
|
kind: 'jwt.verify',
|
|
54
64
|
capability: 'jwt.verify',
|
|
@@ -586,7 +596,12 @@ function createPackageRuntime(input) {
|
|
|
586
596
|
operation,
|
|
587
597
|
capability: declared.capability,
|
|
588
598
|
result: declared.result,
|
|
589
|
-
payload: clonePackageEffectPayload(payload
|
|
599
|
+
payload: clonePackageEffectPayload(payload, {
|
|
600
|
+
// The sign payload adds one envelope level around its 32-level claims.
|
|
601
|
+
maxDepth: declared.kind === 'jwt.sign' ? DEFAULT_MAX_PAYLOAD_DEPTH + 1 : DEFAULT_MAX_PAYLOAD_DEPTH,
|
|
602
|
+
maxBytes: declared.kind === 'crypto.digestText' || declared.kind === 's3.putText'
|
|
603
|
+
? TEXT_MAX_PAYLOAD_BYTES : DEFAULT_MAX_PAYLOAD_BYTES
|
|
604
|
+
})
|
|
590
605
|
}, projector);
|
|
591
606
|
}
|
|
592
607
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { PulseRuntimeContractError, PulseUnhandledError } = require('./errors.js');
|
|
3
|
+
const { PulseRuntimeContractError, PulseUnhandledError, isApplicationError } = require('./errors.js');
|
|
4
4
|
|
|
5
5
|
const REDACTED_VALUE = '<redacted>';
|
|
6
6
|
const CIRCULAR_VALUE = '<circular>';
|
|
@@ -121,9 +121,12 @@ function createRedactionState(initialValues = []) {
|
|
|
121
121
|
: redactValue(descriptor.value, key, seen, depth + 1);
|
|
122
122
|
Object.defineProperty(output, outputKey, {
|
|
123
123
|
enumerable: true,
|
|
124
|
-
|
|
124
|
+
// Different private keys can redact to the same spelling. Their value
|
|
125
|
+
// association is lost, so collapse the collision to a redacted marker.
|
|
126
|
+
// Properties remain replaceable only until the completed record freezes.
|
|
127
|
+
configurable: true,
|
|
125
128
|
writable: false,
|
|
126
|
-
value: outputValue
|
|
129
|
+
value: Object.prototype.hasOwnProperty.call(output, outputKey) ? REDACTED_VALUE : outputValue
|
|
127
130
|
});
|
|
128
131
|
}
|
|
129
132
|
return Object.freeze(output);
|
|
@@ -142,6 +145,10 @@ function createRedactionState(initialValues = []) {
|
|
|
142
145
|
const rawMessage = ownDataValue(error, 'message');
|
|
143
146
|
const rawName = ownDataValue(error, 'name');
|
|
144
147
|
const rawCode = ownDataValue(error, 'code');
|
|
148
|
+
// Admitted discriminators are fixed protocol vocabulary, not private text.
|
|
149
|
+
// Short KV keys/array indexes must not change Router recovery decisions.
|
|
150
|
+
const stableCode = isApplicationError(error) ? rawCode
|
|
151
|
+
: error instanceof PulseUnhandledError ? 'PULSE_RUNTIME_UNHANDLED_ERROR' : undefined;
|
|
145
152
|
const message = redactString(typeof rawMessage === 'string' ? rawMessage : 'Pulse runtime failure.');
|
|
146
153
|
const causeDescriptor = Object.getOwnPropertyDescriptor(error, 'cause');
|
|
147
154
|
const cause = causeDescriptor && Object.prototype.hasOwnProperty.call(causeDescriptor, 'value')
|
|
@@ -167,6 +174,10 @@ function createRedactionState(initialValues = []) {
|
|
|
167
174
|
}
|
|
168
175
|
|
|
169
176
|
const reserved = new Set(['name', 'message', 'stack', 'cause', 'detail']);
|
|
177
|
+
if (stableCode !== undefined) {
|
|
178
|
+
reserved.add('code');
|
|
179
|
+
Object.defineProperty(safe, 'code', { enumerable: true, configurable: true, writable: false, value: stableCode });
|
|
180
|
+
}
|
|
170
181
|
for (const key of Reflect.ownKeys(error).slice(0, MAX_REDACTION_ENTRIES)) {
|
|
171
182
|
if (typeof key !== 'string' || reserved.has(key)) continue;
|
|
172
183
|
const descriptor = Object.getOwnPropertyDescriptor(error, key);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PulseRuntimeContractError } = require('./errors.js');
|
|
4
|
+
const budgets = new WeakSet();
|
|
5
|
+
|
|
6
|
+
function normalizeRequestDuration(value) {
|
|
7
|
+
if (value === undefined) return undefined;
|
|
8
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > 30000) {
|
|
9
|
+
throw new PulseRuntimeContractError('PULSE_REQUEST_DURATION_INVALID', 'Request maxDurationMs must be an integer from 1 through 30000.');
|
|
10
|
+
}
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Host-only authority. One budget is passed through adapters; nested calls must
|
|
15
|
+
// never restart it. Wall time and inbound headers are deliberately not inputs.
|
|
16
|
+
function createRequestBudget(options = {}) {
|
|
17
|
+
if (options.requestBudget !== undefined) {
|
|
18
|
+
if (!budgets.has(options.requestBudget)) throw new TypeError('Invalid host request budget.');
|
|
19
|
+
return options.requestBudget;
|
|
20
|
+
}
|
|
21
|
+
const duration = normalizeRequestDuration(options.maxDurationMs);
|
|
22
|
+
const clock = options.requestClock || {
|
|
23
|
+
now: () => performance.now(),
|
|
24
|
+
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
|
25
|
+
clearTimeout: handle => clearTimeout(handle)
|
|
26
|
+
};
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
let timer, closed = false, last = -Infinity;
|
|
29
|
+
const read = () => {
|
|
30
|
+
let value;
|
|
31
|
+
try { value = clock.now(); } catch (_) { value = NaN; }
|
|
32
|
+
if (!Number.isFinite(value) || value < last) {
|
|
33
|
+
controller.abort(new PulseRuntimeContractError('PULSE_REQUEST_CLOCK_INVALID', 'Request monotonic clock failed or regressed.'));
|
|
34
|
+
controller.signal.throwIfAborted();
|
|
35
|
+
}
|
|
36
|
+
last = value;
|
|
37
|
+
return value;
|
|
38
|
+
};
|
|
39
|
+
const deadline = duration === undefined ? undefined : read() + duration;
|
|
40
|
+
const expire = () => controller.abort(new PulseRuntimeContractError('PULSE_REQUEST_DEADLINE_EXCEEDED', 'Pulse request exceeded its total execution deadline.'));
|
|
41
|
+
const signals = [...new Set([options.signal, options.requestSignal].filter(Boolean))];
|
|
42
|
+
const listeners = new Set(signals.map(signal => {
|
|
43
|
+
const abort = () => controller.abort(signal.reason);
|
|
44
|
+
if (signal.aborted) abort();
|
|
45
|
+
else signal.addEventListener('abort', abort, { once: true });
|
|
46
|
+
return () => signal.removeEventListener('abort', abort);
|
|
47
|
+
}));
|
|
48
|
+
const budget = Object.freeze({
|
|
49
|
+
signal: controller.signal,
|
|
50
|
+
deadlineMonotonicMs: deadline,
|
|
51
|
+
clock,
|
|
52
|
+
check() {
|
|
53
|
+
if (closed) throw new PulseRuntimeContractError('PULSE_REQUEST_EXECUTION_CLOSED', 'Pulse request execution is closed.');
|
|
54
|
+
if (!controller.signal.aborted && deadline !== undefined && read() >= deadline) expire();
|
|
55
|
+
controller.signal.throwIfAborted();
|
|
56
|
+
},
|
|
57
|
+
onAbort(callback) {
|
|
58
|
+
if (controller.signal.aborted || closed) { callback(); return () => {}; }
|
|
59
|
+
const remove = () => {
|
|
60
|
+
controller.signal.removeEventListener('abort', callback);
|
|
61
|
+
listeners.delete(remove);
|
|
62
|
+
};
|
|
63
|
+
listeners.add(remove);
|
|
64
|
+
controller.signal.addEventListener('abort', callback, { once: true });
|
|
65
|
+
return remove;
|
|
66
|
+
},
|
|
67
|
+
remainingMs() { budget.check(); return deadline === undefined ? Infinity : Math.max(0, deadline - last); },
|
|
68
|
+
race(value) {
|
|
69
|
+
// Always observe the pending work, including an already-expired request.
|
|
70
|
+
const pending = Promise.resolve(value);
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const abort = () => { cleanup(); reject(controller.signal.reason); };
|
|
73
|
+
controller.signal.addEventListener('abort', abort, { once: true });
|
|
74
|
+
const cleanup = () => controller.signal.removeEventListener('abort', abort);
|
|
75
|
+
pending.then(result => {
|
|
76
|
+
cleanup();
|
|
77
|
+
try { budget.check(); resolve(result); } catch (error) { reject(error); }
|
|
78
|
+
}, error => { cleanup(); reject(error); });
|
|
79
|
+
try { budget.check(); } catch (error) { cleanup(); reject(error); }
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
close() {
|
|
83
|
+
if (closed) return;
|
|
84
|
+
closed = true;
|
|
85
|
+
if (timer !== undefined) clock.clearTimeout(timer);
|
|
86
|
+
for (const remove of listeners) remove();
|
|
87
|
+
listeners.clear();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
budgets.add(budget);
|
|
91
|
+
if (deadline !== undefined && !controller.signal.aborted) {
|
|
92
|
+
const schedule = () => {
|
|
93
|
+
try {
|
|
94
|
+
budget.check();
|
|
95
|
+
timer = clock.setTimeout(schedule, budget.remainingMs());
|
|
96
|
+
} catch (_) { /* check has already revoked execution authority */ }
|
|
97
|
+
};
|
|
98
|
+
timer = clock.setTimeout(schedule, duration);
|
|
99
|
+
}
|
|
100
|
+
return budget;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { createRequestBudget, normalizeRequestDuration };
|