@pulse-compute/runtime 0.0.0 → 1.0.0-beta.2

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.
@@ -0,0 +1,487 @@
1
+ 'use strict';
2
+
3
+ const { compileRoutePath, matchRoutePath, normalizeRoutePath } = require('./path.js');
4
+ const { createContext, createRequestView } = require('./context.js');
5
+ const { createJavascriptEffectExecution } = require('./effect-adapter.js');
6
+ const { reportingLevel } = require('./logging.js');
7
+ const {
8
+ PulseRuntimeContractError,
9
+ PulseUnhandledError,
10
+ fetchResponseToResponse,
11
+ isPulseFetchResponse,
12
+ isPulseResult,
13
+ markOpaqueResponse,
14
+ responseBodyClass,
15
+ responseHeaderPairs,
16
+ statusAllowsBody,
17
+ resultToResponse
18
+ } = require('./response.js');
19
+
20
+ const ROUTER_STATE = new WeakMap();
21
+ const TRANSFER_DATA = new WeakMap();
22
+ const NO_ERROR = Symbol('pulse.runtime.no-error');
23
+
24
+ function requireHandler(value, label) {
25
+ if (typeof value !== 'function') throw new TypeError(`Pulse Router ${label} requires a handler function.`);
26
+ return value;
27
+ }
28
+
29
+ function requireRouter(value) {
30
+ if (!ROUTER_STATE.has(value)) throw new TypeError('Pulse Router mount(path, router) requires a Router instance.');
31
+ return value;
32
+ }
33
+
34
+ function mergeParams(left, right) {
35
+ return Object.freeze({ ...(left || {}), ...(right || {}) });
36
+ }
37
+
38
+ function entry(kind, fields) {
39
+ return Object.freeze({ kind, ...fields });
40
+ }
41
+
42
+ class Router {
43
+ constructor() {
44
+ ROUTER_STATE.set(this, { entries: [] });
45
+ }
46
+
47
+ use(pathOrHandler, maybeHandler) {
48
+ const state = ROUTER_STATE.get(this);
49
+ if (typeof pathOrHandler === 'string') {
50
+ state.entries.push(entry('use', {
51
+ path: compileRoutePath(pathOrHandler, { scoped: true, allowWildcard: true }),
52
+ handler: requireHandler(maybeHandler, 'use(path, handler)')
53
+ }));
54
+ return this;
55
+ }
56
+ state.entries.push(entry('use', { path: null, handler: requireHandler(pathOrHandler, 'use(handler)') }));
57
+ return this;
58
+ }
59
+
60
+ get(path, handler) {
61
+ ROUTER_STATE.get(this).entries.push(entry('route', {
62
+ method: 'GET',
63
+ path: compileRoutePath(path, { allowWildcard: true }),
64
+ handler: requireHandler(handler, 'get(path, handler)')
65
+ }));
66
+ return this;
67
+ }
68
+
69
+ head(path, handler) {
70
+ ROUTER_STATE.get(this).entries.push(entry('route', {
71
+ method: 'HEAD',
72
+ path: compileRoutePath(path, { allowWildcard: true }),
73
+ handler: requireHandler(handler, 'head(path, handler)')
74
+ }));
75
+ return this;
76
+ }
77
+
78
+ post(path, handler) {
79
+ ROUTER_STATE.get(this).entries.push(entry('route', {
80
+ method: 'POST',
81
+ path: compileRoutePath(path, { allowWildcard: true }),
82
+ handler: requireHandler(handler, 'post(path, handler)')
83
+ }));
84
+ return this;
85
+ }
86
+
87
+ put(path, handler) {
88
+ ROUTER_STATE.get(this).entries.push(entry('route', {
89
+ method: 'PUT',
90
+ path: compileRoutePath(path, { allowWildcard: true }),
91
+ handler: requireHandler(handler, 'put(path, handler)')
92
+ }));
93
+ return this;
94
+ }
95
+
96
+ patch(path, handler) {
97
+ ROUTER_STATE.get(this).entries.push(entry('route', {
98
+ method: 'PATCH',
99
+ path: compileRoutePath(path, { allowWildcard: true }),
100
+ handler: requireHandler(handler, 'patch(path, handler)')
101
+ }));
102
+ return this;
103
+ }
104
+
105
+ delete(path, handler) {
106
+ ROUTER_STATE.get(this).entries.push(entry('route', {
107
+ method: 'DELETE',
108
+ path: compileRoutePath(path, { allowWildcard: true }),
109
+ handler: requireHandler(handler, 'delete(path, handler)')
110
+ }));
111
+ return this;
112
+ }
113
+
114
+ mount(path, router) {
115
+ ROUTER_STATE.get(this).entries.push(entry('mount', {
116
+ path: compileRoutePath(path, { scoped: true, allowWildcard: true }),
117
+ router: requireRouter(router)
118
+ }));
119
+ return this;
120
+ }
121
+
122
+ error(handler) {
123
+ ROUTER_STATE.get(this).entries.push(entry('error', { handler: requireHandler(handler, 'error(handler)') }));
124
+ return this;
125
+ }
126
+ }
127
+
128
+ function createTransferFactory() {
129
+ const token = Object.freeze({});
130
+ let transfer = null;
131
+ function next(error) {
132
+ if (transfer) {
133
+ throw new PulseRuntimeContractError('PULSE_RUNTIME_NEXT_MULTIPLE', 'Pulse next() may be invoked at most once by a managed handler.');
134
+ }
135
+ transfer = {};
136
+ Object.defineProperty(transfer, 'kind', { enumerable: false, value: 'pulse.router.transfer' });
137
+ TRANSFER_DATA.set(transfer, Object.freeze({ token, error: error === undefined ? NO_ERROR : error }));
138
+ Object.freeze(transfer);
139
+ return transfer;
140
+ }
141
+ return Object.freeze({
142
+ next,
143
+ token,
144
+ wasCalled: () => transfer !== null,
145
+ transfer: () => transfer
146
+ });
147
+ }
148
+
149
+ function transferData(value, token) {
150
+ const data = value && typeof value === 'object' ? TRANSFER_DATA.get(value) : undefined;
151
+ return data && data.token === token ? data : undefined;
152
+ }
153
+
154
+ function redactedError(frame, error) {
155
+ return frame && frame.effectExecution && typeof frame.effectExecution.redactError === 'function'
156
+ ? frame.effectExecution.redactError(error)
157
+ : error;
158
+ }
159
+
160
+ function containUnexpected(error, frame) {
161
+ const safe = redactedError(frame, error);
162
+ return safe instanceof PulseUnhandledError ? safe : new PulseUnhandledError(safe);
163
+ }
164
+
165
+ function contractError(code, message) {
166
+ return new PulseRuntimeContractError(code, message);
167
+ }
168
+
169
+ async function normalizeHandlerResponse(value, requestMethod) {
170
+ if (isPulseResult(value)) return resultToResponse(value, requestMethod);
171
+ if (isPulseFetchResponse(value)) return fetchResponseToResponse(value, requestMethod);
172
+ if (value instanceof Response) {
173
+ if (String(requestMethod).toUpperCase() !== 'HEAD' && statusAllowsBody(value.status)) return value;
174
+ if (value.body && !value.bodyUsed) {
175
+ try { await value.body.cancel(); } catch (_) { /* ownership cleanup is best effort */ }
176
+ }
177
+ const response = new Response(null, { status: value.status, statusText: value.statusText, headers: value.headers });
178
+ return responseBodyClass(value) === 'opaque'
179
+ ? markOpaqueResponse(response, responseHeaderPairs(value))
180
+ : response;
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function frameWith(frame, fields) {
186
+ return { ...frame, ...fields };
187
+ }
188
+
189
+ async function dispatchRouter(router, frame, startIndex, activeError) {
190
+ const entries = ROUTER_STATE.get(router).entries;
191
+ let index = startIndex;
192
+ let error = activeError;
193
+
194
+ while (index < entries.length) {
195
+ const current = entries[index];
196
+
197
+ if (error !== NO_ERROR) {
198
+ if (current.kind !== 'error') {
199
+ index += 1;
200
+ continue;
201
+ }
202
+ const result = await runErrorHandler(router, frame, index, error, current.handler);
203
+ if (result.kind === 'continue') {
204
+ index = result.index;
205
+ error = result.error;
206
+ continue;
207
+ }
208
+ return result;
209
+ }
210
+
211
+ if (current.kind === 'error') {
212
+ index += 1;
213
+ continue;
214
+ }
215
+
216
+ if (current.kind === 'mount') {
217
+ const match = matchRoutePath(current.path, frame.relativePath);
218
+ if (!match) {
219
+ index += 1;
220
+ continue;
221
+ }
222
+ const childFrame = frameWith(frame, {
223
+ relativePath: match.rest,
224
+ params: mergeParams(frame.params, match.params)
225
+ });
226
+ const childResult = await dispatchRouter(current.router, childFrame, 0, NO_ERROR);
227
+ if (childResult.kind === 'continue') {
228
+ if (childResult.error !== NO_ERROR) {
229
+ error = childResult.error;
230
+ frame = childResult.frame || childFrame;
231
+ }
232
+ index += 1;
233
+ continue;
234
+ }
235
+ return childResult;
236
+ }
237
+
238
+ if (current.kind === 'use') {
239
+ let handlerFrame = frame;
240
+ if (current.path) {
241
+ const match = matchRoutePath(current.path, frame.relativePath);
242
+ if (!match) {
243
+ index += 1;
244
+ continue;
245
+ }
246
+ handlerFrame = frameWith(frame, {
247
+ params: mergeParams(frame.params, match.params),
248
+ packageRelativePath: match.rest
249
+ });
250
+ }
251
+ const result = await runNormalHandler(router, handlerFrame, index, current.handler, false);
252
+ if (result.kind === 'continue') {
253
+ index = result.index;
254
+ error = result.error;
255
+ frame = result.frame || handlerFrame;
256
+ continue;
257
+ }
258
+ return result;
259
+ }
260
+
261
+ if (current.kind === 'route') {
262
+ if (frame.req.method !== current.method) {
263
+ index += 1;
264
+ continue;
265
+ }
266
+ const match = matchRoutePath(current.path, frame.relativePath);
267
+ if (!match) {
268
+ index += 1;
269
+ continue;
270
+ }
271
+ const handlerFrame = frameWith(frame, { params: mergeParams(frame.params, match.params) });
272
+ const result = await runNormalHandler(router, handlerFrame, index, current.handler, true);
273
+ if (result.kind === 'continue') {
274
+ index = result.index;
275
+ error = result.error;
276
+ frame = result.frame || handlerFrame;
277
+ continue;
278
+ }
279
+ return result;
280
+ }
281
+
282
+ index += 1;
283
+ }
284
+
285
+ return Object.freeze({ kind: 'continue', index, error, frame });
286
+ }
287
+
288
+ async function runNormalHandler(_router, frame, index, handler, routeContext) {
289
+ const transferFactory = createTransferFactory();
290
+ const ctx = createContext({ ...frame, routeContext });
291
+ let output;
292
+ let handlerError;
293
+ try {
294
+ output = await handler(ctx, transferFactory.next);
295
+ } catch (error) {
296
+ handlerError = error;
297
+ }
298
+ try {
299
+ await frame.effectExecution.assertIdle();
300
+ } catch (error) {
301
+ if (handlerError === undefined) handlerError = error;
302
+ }
303
+ if (handlerError !== undefined) {
304
+ return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
305
+ }
306
+
307
+ const transfer = transferData(output, transferFactory.token);
308
+ if (transferFactory.wasCalled()) {
309
+ if (!transfer || output !== transferFactory.transfer()) {
310
+ return Object.freeze({
311
+ kind: 'continue',
312
+ index: index + 1,
313
+ error: containUnexpected(contractError(
314
+ 'PULSE_RUNTIME_NEXT_NOT_TERMINAL',
315
+ 'Pulse next() is a terminal transfer and its exact value must be returned.'
316
+ ), frame),
317
+ frame
318
+ });
319
+ }
320
+ return Object.freeze({
321
+ kind: 'continue',
322
+ index: index + 1,
323
+ error: transfer.error === NO_ERROR ? NO_ERROR : redactedError(frame, transfer.error),
324
+ frame
325
+ });
326
+ }
327
+
328
+ const response = await normalizeHandlerResponse(output, frame.req.method);
329
+ if (response) return Object.freeze({ kind: 'response', response });
330
+ return Object.freeze({
331
+ kind: 'continue',
332
+ index: index + 1,
333
+ error: containUnexpected(contractError(
334
+ 'PULSE_RUNTIME_HANDLER_RESULT_REQUIRED',
335
+ 'Pulse handlers must return a response, next(), or next(error).'
336
+ ), frame),
337
+ frame
338
+ });
339
+ }
340
+
341
+ async function runErrorHandler(_router, frame, index, activeError, handler) {
342
+ const transferFactory = createTransferFactory();
343
+ const ctx = createContext({ ...frame, routeContext: false });
344
+ let output;
345
+ let handlerError;
346
+ try {
347
+ output = await handler(activeError, ctx, transferFactory.next);
348
+ } catch (error) {
349
+ handlerError = error;
350
+ }
351
+ try {
352
+ await frame.effectExecution.assertIdle();
353
+ } catch (error) {
354
+ if (handlerError === undefined) handlerError = error;
355
+ }
356
+ if (handlerError !== undefined) {
357
+ return Object.freeze({ kind: 'continue', index: index + 1, error: containUnexpected(handlerError, frame), frame });
358
+ }
359
+
360
+ const transfer = transferData(output, transferFactory.token);
361
+ if (transferFactory.wasCalled()) {
362
+ if (!transfer || output !== transferFactory.transfer()) {
363
+ return Object.freeze({
364
+ kind: 'continue',
365
+ index: index + 1,
366
+ error: containUnexpected(contractError(
367
+ 'PULSE_RUNTIME_NEXT_NOT_TERMINAL',
368
+ 'Pulse next() is a terminal transfer and its exact value must be returned.'
369
+ ), frame),
370
+ frame
371
+ });
372
+ }
373
+ return Object.freeze({
374
+ kind: 'continue',
375
+ index: index + 1,
376
+ error: transfer.error === NO_ERROR ? NO_ERROR : redactedError(frame, transfer.error),
377
+ frame
378
+ });
379
+ }
380
+
381
+ const response = await normalizeHandlerResponse(output, frame.req.method);
382
+ if (response) return Object.freeze({ kind: 'response', response });
383
+ return Object.freeze({
384
+ kind: 'continue',
385
+ index: index + 1,
386
+ error: containUnexpected(contractError(
387
+ 'PULSE_RUNTIME_HANDLER_RESULT_REQUIRED',
388
+ 'Pulse error handlers must return a response, next(), or next(error).'
389
+ ), frame),
390
+ frame
391
+ });
392
+ }
393
+
394
+ async function executeRouter(router, request, options = {}) {
395
+ requireRouter(router);
396
+ if (!(request instanceof Request)) throw new TypeError('Pulse JavaScript execution requires a Web Request.');
397
+ if (typeof options.onLogObservation === 'function') {
398
+ const level = reportingLevel(options.reporting);
399
+ const name = Object.freeze(['off', 'error', 'warn', 'info', 'debug'])[level];
400
+ try {
401
+ options.onLogObservation(Object.freeze({
402
+ version: 'pulse.log-event.v1',
403
+ type: 'logging-config',
404
+ reporting: Object.freeze({ name, level }),
405
+ target: options.target || 'javascript',
406
+ provider: options.provider || null
407
+ }));
408
+ } catch (_) {
409
+ // Logging evidence is best effort.
410
+ }
411
+ }
412
+ const executionSignal = options.signal || request.signal;
413
+ const effectExecution = createJavascriptEffectExecution({
414
+ effectAdapter: options.effectAdapter,
415
+ capabilities: options.capabilities,
416
+ application: options.application,
417
+ request,
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
+ };
456
+ try {
457
+ const result = await dispatchRouter(router, frame, 0, NO_ERROR);
458
+ if (result.kind === 'response') return result.response;
459
+ if (result.error !== NO_ERROR) {
460
+ return new Response('Internal Server Error', {
461
+ status: 500,
462
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
463
+ });
464
+ }
465
+ return new Response('Not Found', {
466
+ status: 404,
467
+ headers: { 'content-type': 'text/plain; charset=utf-8' }
468
+ });
469
+ } finally {
470
+ try {
471
+ await effectExecution.close();
472
+ } finally {
473
+ if (typeof options.onEffectSummary === 'function') options.onEffectSummary(effectExecution.summary());
474
+ }
475
+ }
476
+ }
477
+
478
+ function routerEntries(router) {
479
+ requireRouter(router);
480
+ return Object.freeze([...ROUTER_STATE.get(router).entries]);
481
+ }
482
+
483
+ module.exports = Object.freeze({
484
+ Router,
485
+ executeRouter,
486
+ routerEntries
487
+ });