@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/response.js
CHANGED
|
@@ -12,6 +12,7 @@ const {
|
|
|
12
12
|
|
|
13
13
|
const RESULT_DATA = new WeakMap();
|
|
14
14
|
const FETCH_RESPONSE_DATA = new WeakMap();
|
|
15
|
+
const FETCH_RESPONSE_CANCELLATIONS = new WeakMap();
|
|
15
16
|
const RESPONSE_HEADER_DATA = new WeakMap();
|
|
16
17
|
const RESPONSE_BODY_CLASS_DATA = new WeakMap();
|
|
17
18
|
const RESULT_BRAND = Symbol('pulse.runtime.result');
|
|
@@ -180,6 +181,24 @@ function claimFetchResponse(data, mode) {
|
|
|
180
181
|
data.ownershipMode = mode;
|
|
181
182
|
}
|
|
182
183
|
|
|
184
|
+
function retainFetchResponseForBudget(response, budget, onRelease) {
|
|
185
|
+
if (!(response instanceof Response) || !response.body) return;
|
|
186
|
+
const remove = budget?.onAbort(() => cancelResponseBody(response, budget.signal.reason));
|
|
187
|
+
let registrations = FETCH_RESPONSE_CANCELLATIONS.get(response);
|
|
188
|
+
if (!registrations) {
|
|
189
|
+
registrations = new Set();
|
|
190
|
+
FETCH_RESPONSE_CANCELLATIONS.set(response, registrations);
|
|
191
|
+
}
|
|
192
|
+
registrations.add(() => { remove?.(); onRelease?.(); });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function releaseFetchResponseCancellation(response) {
|
|
196
|
+
const registrations = FETCH_RESPONSE_CANCELLATIONS.get(response);
|
|
197
|
+
if (!registrations) return;
|
|
198
|
+
FETCH_RESPONSE_CANCELLATIONS.delete(response);
|
|
199
|
+
for (const remove of registrations) remove();
|
|
200
|
+
}
|
|
201
|
+
|
|
183
202
|
function readerForFetchData(data, options = {}) {
|
|
184
203
|
claimFetchResponse(data, 'structured-projection');
|
|
185
204
|
if (data.bodyReader) return data.bodyReader;
|
|
@@ -191,7 +210,10 @@ function readerForFetchData(data, options = {}) {
|
|
|
191
210
|
label: 'fetched response',
|
|
192
211
|
maxBytes: options.maxBodyBytes === undefined ? data.maxBodyBytes : options.maxBodyBytes,
|
|
193
212
|
forceStructured: true,
|
|
194
|
-
signal: options.signal || data.signal
|
|
213
|
+
signal: options.signal || data.signal,
|
|
214
|
+
onReadSettled: () => {
|
|
215
|
+
if (data.response.bodyUsed && !data.response.body?.locked) releaseFetchResponseCancellation(data.response);
|
|
216
|
+
}
|
|
195
217
|
});
|
|
196
218
|
return data.bodyReader;
|
|
197
219
|
}
|
|
@@ -355,9 +377,11 @@ function projectFetchResponse(value, projection, schemaId, options = {}) {
|
|
|
355
377
|
);
|
|
356
378
|
}
|
|
357
379
|
|
|
358
|
-
|
|
359
|
-
if (!(response instanceof Response) || !response.body || response.
|
|
360
|
-
|
|
380
|
+
function cancelResponseBody(response, reason) {
|
|
381
|
+
if (!(response instanceof Response) || !response.body || response.body.locked) return;
|
|
382
|
+
// A provider's cancellation callback may reject or never settle. Cleanup must
|
|
383
|
+
// observe rejection without delaying request completion or decoding the body.
|
|
384
|
+
try { void response.body.cancel(reason).catch(() => {}); } catch (_) { /* best effort */ }
|
|
361
385
|
}
|
|
362
386
|
|
|
363
387
|
async function fetchResponseToResponse(value, requestMethod = 'GET') {
|
|
@@ -371,7 +395,7 @@ async function fetchResponseToResponse(value, requestMethod = 'GET') {
|
|
|
371
395
|
if (bodyAllowed && !statusChanged) {
|
|
372
396
|
return rememberResponseMetadata(data.response, headers, value.bodyClass);
|
|
373
397
|
}
|
|
374
|
-
if (!bodyAllowed)
|
|
398
|
+
if (!bodyAllowed) cancelResponseBody(data.response);
|
|
375
399
|
return rememberResponseMetadata(
|
|
376
400
|
new Response(bodyAllowed ? data.response.body : null, {
|
|
377
401
|
status: value.status,
|
|
@@ -394,6 +418,7 @@ async function fetchResponseToResponse(value, requestMethod = 'GET') {
|
|
|
394
418
|
module.exports = Object.freeze({
|
|
395
419
|
PulseRuntimeContractError,
|
|
396
420
|
PulseUnhandledError,
|
|
421
|
+
cancelResponseBody,
|
|
397
422
|
createJsonResult,
|
|
398
423
|
createOpaqueFetchResponse,
|
|
399
424
|
createResponseResult,
|
|
@@ -403,6 +428,8 @@ module.exports = Object.freeze({
|
|
|
403
428
|
isPulseResult,
|
|
404
429
|
markOpaqueResponse,
|
|
405
430
|
normalizeFetchResponse,
|
|
431
|
+
retainFetchResponseForBudget,
|
|
432
|
+
releaseFetchResponseCancellation,
|
|
406
433
|
normalizeHeaders,
|
|
407
434
|
projectFetchResponse,
|
|
408
435
|
responseAllowsBody,
|
package/src/internal/router.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
+
const { createRequestBudget } = require('./request-budget.js');
|
|
2
3
|
|
|
3
4
|
const { compileRoutePath, matchRoutePath, normalizeRoutePath } = require('./path.js');
|
|
4
5
|
const { createContext, createRequestView } = require('./context.js');
|
|
5
6
|
const { createJavascriptEffectExecution } = require('./effect-adapter.js');
|
|
6
7
|
const { reportingLevel } = require('./logging.js');
|
|
8
|
+
const { isApplicationError } = require('./errors.js');
|
|
7
9
|
const {
|
|
8
10
|
PulseRuntimeContractError,
|
|
9
11
|
PulseUnhandledError,
|
|
12
|
+
cancelResponseBody,
|
|
10
13
|
fetchResponseToResponse,
|
|
11
14
|
isPulseFetchResponse,
|
|
12
15
|
isPulseResult,
|
|
@@ -31,6 +34,17 @@ function requireRouter(value) {
|
|
|
31
34
|
return value;
|
|
32
35
|
}
|
|
33
36
|
|
|
37
|
+
function mountEligibility(value) {
|
|
38
|
+
if (value === undefined) return undefined;
|
|
39
|
+
const invalid = () => { throw new TypeError('Pulse Router mount eligibility requires { state: "key", equals: "value" } with string data properties.'); };
|
|
40
|
+
if (!value || typeof value !== 'object') return invalid();
|
|
41
|
+
const properties = Object.getOwnPropertyDescriptors(value);
|
|
42
|
+
if (Reflect.ownKeys(properties).length !== 2 ||
|
|
43
|
+
!properties.state || !properties.equals ||
|
|
44
|
+
typeof properties.state.value !== 'string' || typeof properties.equals.value !== 'string') return invalid();
|
|
45
|
+
return Object.freeze({ state: properties.state.value, equals: properties.equals.value });
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
function mergeParams(left, right) {
|
|
35
49
|
return Object.freeze({ ...(left || {}), ...(right || {}) });
|
|
36
50
|
}
|
|
@@ -111,10 +125,11 @@ class Router {
|
|
|
111
125
|
return this;
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
mount(path, router) {
|
|
128
|
+
mount(path, router, eligibility) {
|
|
115
129
|
ROUTER_STATE.get(this).entries.push(entry('mount', {
|
|
116
130
|
path: compileRoutePath(path, { scoped: true, allowWildcard: true }),
|
|
117
|
-
router: requireRouter(router)
|
|
131
|
+
router: requireRouter(router),
|
|
132
|
+
eligibility: mountEligibility(eligibility)
|
|
118
133
|
}));
|
|
119
134
|
return this;
|
|
120
135
|
}
|
|
@@ -159,7 +174,7 @@ function redactedError(frame, error) {
|
|
|
159
174
|
|
|
160
175
|
function containUnexpected(error, frame) {
|
|
161
176
|
const safe = redactedError(frame, error);
|
|
162
|
-
return safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
|
|
177
|
+
return isApplicationError(error) || safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
|
|
163
178
|
}
|
|
164
179
|
|
|
165
180
|
function contractError(code, message) {
|
|
@@ -171,9 +186,7 @@ async function normalizeHandlerResponse(value, requestMethod) {
|
|
|
171
186
|
if (isPulseFetchResponse(value)) return fetchResponseToResponse(value, requestMethod);
|
|
172
187
|
if (value instanceof Response) {
|
|
173
188
|
if (String(requestMethod).toUpperCase() !== 'HEAD' && statusAllowsBody(value.status)) return value;
|
|
174
|
-
|
|
175
|
-
try { await value.body.cancel(); } catch (_) { /* ownership cleanup is best effort */ }
|
|
176
|
-
}
|
|
189
|
+
cancelResponseBody(value);
|
|
177
190
|
const response = new Response(null, { status: value.status, statusText: value.statusText, headers: value.headers });
|
|
178
191
|
return responseBodyClass(value) === 'opaque'
|
|
179
192
|
? markOpaqueResponse(response, responseHeaderPairs(value))
|
|
@@ -192,6 +205,7 @@ async function dispatchRouter(router, frame, startIndex, activeError) {
|
|
|
192
205
|
let error = activeError;
|
|
193
206
|
|
|
194
207
|
while (index < entries.length) {
|
|
208
|
+
frame.signal?.throwIfAborted();
|
|
195
209
|
const current = entries[index];
|
|
196
210
|
|
|
197
211
|
if (error !== NO_ERROR) {
|
|
@@ -215,7 +229,7 @@ async function dispatchRouter(router, frame, startIndex, activeError) {
|
|
|
215
229
|
|
|
216
230
|
if (current.kind === 'mount') {
|
|
217
231
|
const match = matchRoutePath(current.path, frame.relativePath);
|
|
218
|
-
if (!match) {
|
|
232
|
+
if (!match || (current.eligibility && frame.state.get(current.eligibility.state) !== current.eligibility.equals)) {
|
|
219
233
|
index += 1;
|
|
220
234
|
continue;
|
|
221
235
|
}
|
|
@@ -300,10 +314,13 @@ async function runNormalHandler(_router, frame, index, handler, routeContext) {
|
|
|
300
314
|
} catch (error) {
|
|
301
315
|
if (handlerError === undefined) handlerError = error;
|
|
302
316
|
}
|
|
317
|
+
frame.signal?.throwIfAborted();
|
|
318
|
+
if (handlerError !== undefined && frame.executionOptions.outputExecution?.started) throw handlerError;
|
|
303
319
|
if (handlerError !== undefined) {
|
|
304
320
|
return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
|
|
305
321
|
}
|
|
306
322
|
|
|
323
|
+
frame.executionOptions.outputExecution?.validateResult(output);
|
|
307
324
|
const transfer = transferData(output, transferFactory.token);
|
|
308
325
|
if (transferFactory.wasCalled()) {
|
|
309
326
|
if (!transfer || output !== transferFactory.transfer()) {
|
|
@@ -353,10 +370,13 @@ async function runErrorHandler(_router, frame, index, activeError, handler) {
|
|
|
353
370
|
} catch (error) {
|
|
354
371
|
if (handlerError === undefined) handlerError = error;
|
|
355
372
|
}
|
|
373
|
+
frame.signal?.throwIfAborted();
|
|
374
|
+
if (handlerError !== undefined && frame.executionOptions.outputExecution?.started) throw handlerError;
|
|
356
375
|
if (handlerError !== undefined) {
|
|
357
376
|
return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
|
|
358
377
|
}
|
|
359
378
|
|
|
379
|
+
frame.executionOptions.outputExecution?.validateResult(output);
|
|
360
380
|
const transfer = transferData(output, transferFactory.token);
|
|
361
381
|
if (transferFactory.wasCalled()) {
|
|
362
382
|
if (!transfer || output !== transferFactory.transfer()) {
|
|
@@ -409,53 +429,63 @@ async function executeRouter(router, request, options = {}) {
|
|
|
409
429
|
// Logging evidence is best effort.
|
|
410
430
|
}
|
|
411
431
|
}
|
|
412
|
-
const
|
|
413
|
-
const
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
executionKind: 'request',
|
|
419
|
-
signal: executionSignal,
|
|
420
|
-
maxEffects: options.maxEffects,
|
|
421
|
-
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
422
|
-
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
423
|
-
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
424
|
-
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|
|
425
|
-
maxKvKeyBytes: options.maxKvKeyBytes,
|
|
426
|
-
maxKvValueBytes: options.maxKvValueBytes,
|
|
427
|
-
maxKvValueDepth: options.maxKvValueDepth,
|
|
428
|
-
maxKvValueEntries: options.maxKvValueEntries,
|
|
429
|
-
schemaCodecs: options.schemaCodecs,
|
|
430
|
-
strict: options.strict,
|
|
431
|
-
target: options.target,
|
|
432
|
-
provider: options.provider,
|
|
433
|
-
redactionValues: options.redactionValues,
|
|
434
|
-
onEffectObservation: options.onEffectObservation
|
|
435
|
-
});
|
|
436
|
-
const req = createRequestView(request, options.requestHeaders, effectExecution, {
|
|
437
|
-
...options,
|
|
438
|
-
signal: executionSignal,
|
|
439
|
-
redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
|
|
440
|
-
});
|
|
441
|
-
const executionOptions = Object.freeze({
|
|
442
|
-
...options,
|
|
443
|
-
signal: executionSignal,
|
|
444
|
-
redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
|
|
445
|
-
});
|
|
446
|
-
const frame = {
|
|
447
|
-
request,
|
|
448
|
-
req,
|
|
449
|
-
relativePath: normalizeRoutePath(req.path),
|
|
450
|
-
params: Object.freeze({}),
|
|
451
|
-
state: new Map(),
|
|
452
|
-
effectExecution,
|
|
453
|
-
signal: executionSignal,
|
|
454
|
-
executionOptions
|
|
455
|
-
};
|
|
432
|
+
const ownsBudget = options.requestBudget === undefined;
|
|
433
|
+
const budget = createRequestBudget({ ...options, requestSignal: request.signal });
|
|
434
|
+
options = { ...options, requestBudget: budget, signal: budget.signal, deadlineMonotonicMs: budget.deadlineMonotonicMs ?? options.deadlineMonotonicMs, kvClock: budget.deadlineMonotonicMs === undefined ? options.kvClock : budget.clock };
|
|
435
|
+
const executionSignal = budget.signal;
|
|
436
|
+
let effectExecution;
|
|
437
|
+
let transferredResponse;
|
|
456
438
|
try {
|
|
457
|
-
|
|
458
|
-
|
|
439
|
+
budget.check();
|
|
440
|
+
effectExecution = createJavascriptEffectExecution({
|
|
441
|
+
outputExecution: options.outputExecution,
|
|
442
|
+
effectAdapter: options.effectAdapter,
|
|
443
|
+
capabilities: options.capabilities,
|
|
444
|
+
application: options.application,
|
|
445
|
+
request,
|
|
446
|
+
executionKind: 'request',
|
|
447
|
+
signal: executionSignal,
|
|
448
|
+
requestBudget: budget,
|
|
449
|
+
maxEffects: options.maxEffects,
|
|
450
|
+
kvClock: options.kvClock, deadlineMonotonicMs: options.deadlineMonotonicMs,
|
|
451
|
+
maxBindingNameBytes: options.maxBindingNameBytes,
|
|
452
|
+
maxBindingValueBytes: options.maxBindingValueBytes,
|
|
453
|
+
maxKvNamespaceBytes: options.maxKvNamespaceBytes,
|
|
454
|
+
maxKvKeyBytes: options.maxKvKeyBytes,
|
|
455
|
+
maxKvValueBytes: options.maxKvValueBytes,
|
|
456
|
+
maxKvValueDepth: options.maxKvValueDepth,
|
|
457
|
+
maxKvValueEntries: options.maxKvValueEntries,
|
|
458
|
+
schemaCodecs: options.schemaCodecs,
|
|
459
|
+
strict: options.strict,
|
|
460
|
+
target: options.target,
|
|
461
|
+
provider: options.provider,
|
|
462
|
+
redactionValues: options.redactionValues,
|
|
463
|
+
onEffectObservation: options.onEffectObservation
|
|
464
|
+
});
|
|
465
|
+
const req = createRequestView(request, options.requestHeaders, effectExecution, {
|
|
466
|
+
...options,
|
|
467
|
+
signal: executionSignal,
|
|
468
|
+
redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
|
|
469
|
+
});
|
|
470
|
+
const executionOptions = Object.freeze({
|
|
471
|
+
...options,
|
|
472
|
+
signal: executionSignal,
|
|
473
|
+
redactJsonTraceValue(value) { return effectExecution.redactValue(value); }
|
|
474
|
+
});
|
|
475
|
+
const frame = {
|
|
476
|
+
request,
|
|
477
|
+
req,
|
|
478
|
+
relativePath: normalizeRoutePath(req.path),
|
|
479
|
+
params: Object.freeze({}),
|
|
480
|
+
state: new Map(),
|
|
481
|
+
effectExecution,
|
|
482
|
+
signal: executionSignal,
|
|
483
|
+
executionOptions
|
|
484
|
+
};
|
|
485
|
+
budget.check();
|
|
486
|
+
const result = await budget.race(dispatchRouter(router, frame, 0, NO_ERROR));
|
|
487
|
+
executionSignal?.throwIfAborted();
|
|
488
|
+
if (result.kind === 'response') return (transferredResponse = result.response);
|
|
459
489
|
if (result.error !== NO_ERROR) {
|
|
460
490
|
return new Response('Internal Server Error', {
|
|
461
491
|
status: 500,
|
|
@@ -468,9 +498,10 @@ async function executeRouter(router, request, options = {}) {
|
|
|
468
498
|
});
|
|
469
499
|
} finally {
|
|
470
500
|
try {
|
|
471
|
-
await effectExecution
|
|
501
|
+
await effectExecution?.close(transferredResponse);
|
|
472
502
|
} finally {
|
|
473
|
-
if (
|
|
503
|
+
if (ownsBudget) budget.close();
|
|
504
|
+
if (effectExecution && typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
|
|
474
505
|
}
|
|
475
506
|
}
|
|
476
507
|
}
|
package/src/internal/schema.js
CHANGED
|
@@ -144,7 +144,17 @@ function utf8Bytes(value) {
|
|
|
144
144
|
|
|
145
145
|
function assertSchemaBodySize(options, text, schemaId, source) {
|
|
146
146
|
const configured = Number(schemaRegistry(options)?.maxBytes);
|
|
147
|
-
|
|
147
|
+
let maxBytes = Number.isSafeInteger(configured) && configured > 0 ? configured : 65_536;
|
|
148
|
+
const jsonLimits = schemaCodecs(options)?.schema?.(schemaId)?.jsonLimits;
|
|
149
|
+
if (jsonLimits) {
|
|
150
|
+
maxBytes = Math.min(maxBytes, jsonLimits.maxTextBytes);
|
|
151
|
+
// UTF-16 length is a lower bound on UTF-8 bytes. Reject large application
|
|
152
|
+
// strings before TextEncoder allocates their complete encoded form.
|
|
153
|
+
if (typeof text === 'string' && text.length > maxBytes) {
|
|
154
|
+
throw schemaRuntimeError('PULSE_BODY_TOO_LARGE', `Pulse ${source} schema body exceeds the ${maxBytes} byte limit.`,
|
|
155
|
+
{ schemaId: String(schemaId), source, bytesAtLeast: text.length, maxBytes });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
148
158
|
const bytes = utf8Bytes(text);
|
|
149
159
|
if (bytes <= maxBytes) return bytes;
|
|
150
160
|
throw schemaRuntimeError(
|
|
@@ -187,6 +197,7 @@ function emitSchemaTrace(options, event, value) {
|
|
|
187
197
|
|
|
188
198
|
function decodeSchemaText(schemaId, text, options = {}, context = {}) {
|
|
189
199
|
const source = String(context.source || 'json');
|
|
200
|
+
const applicationText = source === 'application-text';
|
|
190
201
|
const id = requireSchemaId(schemaId, options, source);
|
|
191
202
|
if (id === undefined) {
|
|
192
203
|
try {
|
|
@@ -197,24 +208,28 @@ function decodeSchemaText(schemaId, text, options = {}, context = {}) {
|
|
|
197
208
|
}
|
|
198
209
|
let contentType = normalizeContentType(headerValue(context.headers, 'content-type'));
|
|
199
210
|
const event = {
|
|
200
|
-
boundary: source === 'request' ? 'request' : 'fetch-response',
|
|
211
|
+
boundary: applicationText ? source : source === 'request' ? 'request' : 'fetch-response',
|
|
201
212
|
schemaId: id,
|
|
202
213
|
responseCaseId: null,
|
|
203
214
|
operationId: String(context.operationId || `${source}:${id}`),
|
|
204
215
|
status: context.status ?? null,
|
|
205
216
|
headers: context.headers || [],
|
|
206
|
-
bodyOwnership: source === 'request' ? 'request-snapshot' : 'fetched-response-snapshot',
|
|
217
|
+
bodyOwnership: applicationText ? 'application-owned' : source === 'request' ? 'request-snapshot' : 'fetched-response-snapshot',
|
|
207
218
|
effectId: context.effectId || null,
|
|
208
219
|
groupId: context.groupId || null
|
|
209
220
|
};
|
|
210
221
|
try {
|
|
211
|
-
|
|
222
|
+
if (applicationText && typeof text !== 'string') {
|
|
223
|
+
throw schemaRuntimeError('PULSE_SCHEMA_DECODE', 'Application JSON text must be a string.',
|
|
224
|
+
{ schemaId: id, source, expected: 'string', actualKind: typeof text });
|
|
225
|
+
}
|
|
226
|
+
if (!applicationText) contentType = assertSchemaContentType(options, context.headers, id, source);
|
|
212
227
|
assertSchemaBodySize(options, text, id, source);
|
|
213
228
|
const codecs = requireSchemaCodecs(options, id, source);
|
|
214
229
|
const value = codecs.decodeJsonText(id, String(text), source);
|
|
215
230
|
emitSchemaTrace(options, {
|
|
216
231
|
...event,
|
|
217
|
-
kind: source === 'request' ? 'json.decode.request' : 'json.decode.fetch',
|
|
232
|
+
kind: applicationText ? 'json.decode.text' : source === 'request' ? 'json.decode.request' : 'json.decode.fetch',
|
|
218
233
|
contentType: contentType || null
|
|
219
234
|
}, value);
|
|
220
235
|
return value;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { PulseRuntimeContractError } = require('./errors.js');
|
|
4
|
+
|
|
5
|
+
// UTC years 1970–9999; integer milliseconds remain exactly representable.
|
|
6
|
+
const WALL_TIME_MAX_MS = 253402300799999;
|
|
7
|
+
const unavailable = Object.freeze({ status: 'failed', reason: 'unavailable' });
|
|
8
|
+
const invalid = Object.freeze({ status: 'failed', reason: 'invalid-clock' });
|
|
9
|
+
|
|
10
|
+
function validMilliseconds(value) {
|
|
11
|
+
return Number.isSafeInteger(value) && value >= 0 && value <= WALL_TIME_MAX_MS;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The provider supplies the clock. Formatting never samples ambient time. */
|
|
15
|
+
function readWallTime(clock) {
|
|
16
|
+
if (typeof clock !== 'function') return unavailable;
|
|
17
|
+
let value;
|
|
18
|
+
try { value = clock(); } catch { return unavailable; }
|
|
19
|
+
if (!validMilliseconds(value)) return invalid;
|
|
20
|
+
return Object.freeze({ status: 'ok', unixEpochMs: value === 0 ? 0 : value, iso8601: new Date(value).toISOString() });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Validate and detach a trusted adapter result before guest/application use. */
|
|
24
|
+
function normalizeTimeResult(input) {
|
|
25
|
+
const fail = () => { throw new PulseRuntimeContractError('PULSE_TIME_RESULT_INVALID', 'Pulse time provider returned an invalid result.'); };
|
|
26
|
+
if (!input || typeof input !== 'object' || ![Object.prototype, null].includes(Object.getPrototypeOf(input))) return fail();
|
|
27
|
+
const properties = Object.getOwnPropertyDescriptors(input);
|
|
28
|
+
const keys = Reflect.ownKeys(properties);
|
|
29
|
+
if (keys.some(key => typeof key !== 'string' || !Object.hasOwn(properties[key], 'value'))) return fail();
|
|
30
|
+
const status = properties.status?.value;
|
|
31
|
+
if (status === 'failed' && keys.length === 2 && properties.reason
|
|
32
|
+
&& ['unavailable', 'invalid-clock'].includes(properties.reason.value)) {
|
|
33
|
+
return properties.reason.value === 'unavailable' ? unavailable : invalid;
|
|
34
|
+
}
|
|
35
|
+
const ms = properties.unixEpochMs?.value;
|
|
36
|
+
if (status !== 'ok' || keys.length !== 3 || !validMilliseconds(ms)
|
|
37
|
+
|| properties.iso8601?.value !== new Date(ms).toISOString()) return fail();
|
|
38
|
+
return Object.freeze({ status: 'ok', unixEpochMs: ms === 0 ? 0 : ms, iso8601: properties.iso8601.value });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = Object.freeze({ WALL_TIME_MAX_MS, readWallTime, normalizeTimeResult });
|