@pulse-compute/runtime 0.0.0 → 1.0.0-beta.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.
@@ -0,0 +1,415 @@
1
+ 'use strict';
2
+
3
+ const { createStructuredBodyReader, DEFAULT_STRUCTURED_BODY_BYTES, classifyContentType } = require('./body.js');
4
+ const { PulseRuntimeContractError, PulseUnhandledError } = require('./errors.js');
5
+ const {
6
+ decodeSchemaText,
7
+ encodeSchemaValue,
8
+ requireSchemaId,
9
+ resolveResponseDescriptor,
10
+ strictSchemaPolicy
11
+ } = require('./schema.js');
12
+
13
+ const RESULT_DATA = new WeakMap();
14
+ const FETCH_RESPONSE_DATA = new WeakMap();
15
+ const RESPONSE_HEADER_DATA = new WeakMap();
16
+ const RESPONSE_BODY_CLASS_DATA = new WeakMap();
17
+ const RESULT_BRAND = Symbol('pulse.runtime.result');
18
+ const FETCH_RESPONSE_BRAND = Symbol('pulse.runtime.fetch-response');
19
+
20
+ function normalizeHeaders(input) {
21
+ if (!input) return Object.freeze([]);
22
+ const pairs = [];
23
+ if (Array.isArray(input)) {
24
+ for (const pair of input) {
25
+ if (!Array.isArray(pair) || pair.length !== 2) throw new TypeError('Pulse headers must be [name, value] pairs.');
26
+ pairs.push(Object.freeze([String(pair[0]), String(pair[1])]));
27
+ }
28
+ } else if (input instanceof Headers) {
29
+ for (const [name, value] of input.entries()) pairs.push(Object.freeze([name, value]));
30
+ if (typeof input.getSetCookie === 'function') {
31
+ const cookies = input.getSetCookie();
32
+ if (cookies.length > 1) {
33
+ for (let index = pairs.length - 1; index >= 0; index -= 1) {
34
+ if (pairs[index][0].toLowerCase() === 'set-cookie') pairs.splice(index, 1);
35
+ }
36
+ for (const cookie of cookies) pairs.push(Object.freeze(['set-cookie', cookie]));
37
+ }
38
+ }
39
+ } else if (typeof input === 'object') {
40
+ for (const [name, value] of Object.entries(input)) pairs.push(Object.freeze([name, String(value)]));
41
+ } else {
42
+ throw new TypeError('Pulse headers must be a record, Headers, or [name, value] pairs.');
43
+ }
44
+ return Object.freeze(pairs);
45
+ }
46
+
47
+ function hasHeader(headers, name) {
48
+ const lower = String(name).toLowerCase();
49
+ return headers.some(([header]) => header.toLowerCase() === lower);
50
+ }
51
+
52
+ function appendDefaultHeader(headers, name, value) {
53
+ if (hasHeader(headers, name)) return headers;
54
+ return Object.freeze([...headers, Object.freeze([name, value])]);
55
+ }
56
+
57
+ function defineResult(publicFields, data) {
58
+ const result = { ...publicFields };
59
+ Object.defineProperty(result, RESULT_BRAND, { enumerable: false, value: true });
60
+ RESULT_DATA.set(result, Object.freeze(data));
61
+ return Object.freeze(result);
62
+ }
63
+
64
+ function createTextResult(value, options = {}) {
65
+ const status = normalizeStatus(options.status, 200);
66
+ const headers = appendDefaultHeader(normalizeHeaders(options.headers), 'content-type', 'text/plain; charset=utf-8');
67
+ return defineResult({ status, kind: 'text', bodyClass: 'structured', headers }, { body: String(value) });
68
+ }
69
+
70
+ function createJsonResult(value, descriptor, executionOptions = {}) {
71
+ const resolved = resolveResponseDescriptor(descriptor, executionOptions);
72
+ const status = normalizeStatus(resolved.status, 200);
73
+ const headers = appendDefaultHeader(normalizeHeaders(resolved.headers), 'content-type', 'application/json; charset=utf-8');
74
+ const encoded = encodeSchemaValue(resolved.schemaId, value, executionOptions, {
75
+ source: 'application-response',
76
+ responseCaseId: resolved.responseCaseId,
77
+ status,
78
+ headers,
79
+ contentType: headers.find(([name]) => name.toLowerCase() === 'content-type')?.[1],
80
+ operationId: `application-response:${resolved.responseCaseId || resolved.schemaId || 'generic'}`
81
+ });
82
+ return defineResult(
83
+ { status, kind: 'json', bodyClass: 'structured', headers },
84
+ {
85
+ body: encoded.text,
86
+ schema: resolved.schemaId,
87
+ responseCase: resolved.responseCaseId,
88
+ value: encoded.value
89
+ }
90
+ );
91
+ }
92
+
93
+ function createResponseResult(input = {}) {
94
+ const status = normalizeStatus(input.status, 200);
95
+ const headers = normalizeHeaders(input.headers);
96
+ const body = input.body == null ? '' : String(input.body);
97
+ return defineResult({ status, kind: 'response', bodyClass: 'structured', headers }, { body });
98
+ }
99
+
100
+ function normalizeStatus(value, fallback) {
101
+ const status = value === undefined ? fallback : Number(value);
102
+ if (!Number.isInteger(status) || status < 100 || status > 599) {
103
+ throw new TypeError(`Pulse response status must be an integer between 100 and 599; received ${String(value)}.`);
104
+ }
105
+ return status;
106
+ }
107
+
108
+ function isPulseResult(value) {
109
+ return Boolean(value && typeof value === 'object' && RESULT_DATA.has(value));
110
+ }
111
+
112
+ function rememberResponseMetadata(response, headers, bodyClass = 'structured') {
113
+ if (response instanceof Response) {
114
+ RESPONSE_HEADER_DATA.set(response, Object.freeze([...headers]));
115
+ RESPONSE_BODY_CLASS_DATA.set(response, bodyClass === 'opaque' ? 'opaque' : 'structured');
116
+ }
117
+ return response;
118
+ }
119
+
120
+ function markOpaqueResponse(response, headers = response && response.headers) {
121
+ if (!(response instanceof Response)) {
122
+ throw new TypeError('Pulse provider hosts can only mark Web Responses as opaque.');
123
+ }
124
+ return rememberResponseMetadata(response, normalizeHeaders(headers), 'opaque');
125
+ }
126
+
127
+ function statusAllowsBody(status) {
128
+ const value = Number(status);
129
+ return value !== 204 && value !== 205 && value !== 304 && !(value >= 100 && value < 200);
130
+ }
131
+
132
+ function responseAllowsBody(responseOrStatus, requestMethod = 'GET') {
133
+ const status = responseOrStatus instanceof Response ? responseOrStatus.status : responseOrStatus;
134
+ return String(requestMethod).toUpperCase() !== 'HEAD' && statusAllowsBody(status);
135
+ }
136
+
137
+ function responseIsStructured(response, requestMethod = 'GET') {
138
+ if (!(response instanceof Response)) return false;
139
+ if (!responseAllowsBody(response, requestMethod)) return true;
140
+ return classifyContentType(response.headers.get('content-type')) !== 'opaque';
141
+ }
142
+
143
+ function resultToResponse(result, requestMethod = 'GET') {
144
+ const data = RESULT_DATA.get(result);
145
+ if (!data) throw new TypeError('Expected a Pulse result.');
146
+ const headers = new Headers();
147
+ for (const [name, value] of result.headers) headers.append(name, value);
148
+ const body = responseAllowsBody(result.status, requestMethod) ? data.body : null;
149
+ return rememberResponseMetadata(new Response(body, { status: result.status, headers }), result.headers, result.bodyClass);
150
+ }
151
+
152
+ function responseHeaderPairs(response) {
153
+ const remembered = response instanceof Response ? RESPONSE_HEADER_DATA.get(response) : undefined;
154
+ return remembered || normalizeHeaders(response.headers);
155
+ }
156
+
157
+ function responseBodyClass(response) {
158
+ return response instanceof Response ? RESPONSE_BODY_CLASS_DATA.get(response) || 'structured' : 'structured';
159
+ }
160
+
161
+ function defineFetchResponse(publicFields, data) {
162
+ const value = { ...publicFields };
163
+ Object.defineProperty(value, FETCH_RESPONSE_BRAND, { enumerable: false, value: true });
164
+ FETCH_RESPONSE_DATA.set(value, data);
165
+ return Object.freeze(value);
166
+ }
167
+
168
+ function responseOwnershipConflict(current, requested) {
169
+ return new PulseRuntimeContractError(
170
+ 'PULSE_FETCH_RESPONSE_OWNERSHIP_CONFLICT',
171
+ `Pulse fetch response ownership is already ${current}; it cannot also be used as ${requested}.`,
172
+ { detail: Object.freeze({ current, requested }) }
173
+ );
174
+ }
175
+
176
+ function claimFetchResponse(data, mode) {
177
+ if (data.ownershipMode && data.ownershipMode !== mode) {
178
+ throw responseOwnershipConflict(data.ownershipMode, mode);
179
+ }
180
+ data.ownershipMode = mode;
181
+ }
182
+
183
+ function readerForFetchData(data, options = {}) {
184
+ claimFetchResponse(data, 'structured-projection');
185
+ if (data.bodyReader) return data.bodyReader;
186
+ if (!(data.response instanceof Response)) return undefined;
187
+ // Projection owns the provider response body. Reading the response directly
188
+ // avoids teeing an unread pass-through branch, which could defeat the body
189
+ // bound by buffering the whole upstream stream outside the snapshot.
190
+ data.bodyReader = createStructuredBodyReader(data.response, {
191
+ label: 'fetched response',
192
+ maxBytes: options.maxBodyBytes === undefined ? data.maxBodyBytes : options.maxBodyBytes,
193
+ forceStructured: true,
194
+ signal: options.signal || data.signal
195
+ });
196
+ return data.bodyReader;
197
+ }
198
+
199
+ function opaqueInspectionError() {
200
+ return new PulseRuntimeContractError(
201
+ 'PULSE_OPAQUE_BODY_INSPECTION',
202
+ 'Opaque Pulse bodies are host-owned pass-through values and cannot be inspected.'
203
+ );
204
+ }
205
+
206
+ function opaqueBodyHandle(response, supplied) {
207
+ if (supplied !== undefined) return supplied;
208
+ if (!(response instanceof Response)) return undefined;
209
+ return Object.freeze({
210
+ kind: 'web-response-body',
211
+ ownership: 'host-owned',
212
+ passThroughOnly: true
213
+ });
214
+ }
215
+
216
+ function headerAccessor(headers) {
217
+ return function header(name) {
218
+ const lower = String(name).toLowerCase();
219
+ return headers.find(([headerName]) => headerName.toLowerCase() === lower)?.[1];
220
+ };
221
+ }
222
+
223
+ /**
224
+ * Explicit host helper for a response already materialized as a structured value.
225
+ * Ordinary ctx.fetch roots are opaque; their text/json projections use one
226
+ * bounded request-owned snapshot through projectFetchResponse().
227
+ */
228
+ function wrapStructuredResponse(response, options = {}) {
229
+ if (!(response instanceof Response)) throw new TypeError('Pulse fetch adapters must return a Web Response or Pulse fetch response.');
230
+ const headers = responseHeaderPairs(response);
231
+ const data = {
232
+ response,
233
+ bodyClass: 'structured',
234
+ bodyReader: undefined,
235
+ maxBodyBytes: options.maxBodyBytes === undefined ? DEFAULT_STRUCTURED_BODY_BYTES : options.maxBodyBytes,
236
+ signal: options.signal,
237
+ ownershipMode: undefined,
238
+ schemaProjectionCache: new Map()
239
+ };
240
+ return defineFetchResponse({
241
+ status: response.status,
242
+ ok: response.ok,
243
+ kind: 'response',
244
+ bodyClass: 'structured',
245
+ headers,
246
+ header: headerAccessor(headers),
247
+ text() { return readerForFetchData(data).text(); },
248
+ json(schemaId) {
249
+ if (schemaId === undefined && !strictSchemaPolicy(options)) return readerForFetchData(data).json();
250
+ const key = schemaId === undefined ? '<missing>' : String(schemaId);
251
+ if (!data.schemaProjectionCache.has(key)) {
252
+ data.schemaProjectionCache.set(key, readerForFetchData(data).text().then((text) => decodeSchemaText(
253
+ schemaId,
254
+ text,
255
+ options,
256
+ {
257
+ source: 'fetch-response',
258
+ headers,
259
+ status: response.status,
260
+ operationId: `fetch-response:${options.effectId || key}`,
261
+ effectId: options.effectId
262
+ }
263
+ )));
264
+ }
265
+ return data.schemaProjectionCache.get(key);
266
+ }
267
+ }, data);
268
+ }
269
+
270
+ function createOpaqueFetchResponse({ status, headers, bodyHandle, response, maxBodyBytes, signal } = {}) {
271
+ const actualStatus = status === undefined
272
+ ? (response instanceof Response ? response.status : 200)
273
+ : status;
274
+ const normalizedStatus = normalizeStatus(actualStatus, 200);
275
+ const normalizedHeaders = normalizeHeaders(headers || response?.headers);
276
+ const handle = opaqueBodyHandle(response, bodyHandle);
277
+ const data = {
278
+ response,
279
+ bodyClass: 'opaque',
280
+ bodyHandle: handle,
281
+ bodyReader: undefined,
282
+ maxBodyBytes: maxBodyBytes === undefined ? DEFAULT_STRUCTURED_BODY_BYTES : maxBodyBytes,
283
+ signal,
284
+ statusOverride: status !== undefined,
285
+ ownershipMode: undefined,
286
+ schemaProjectionCache: new Map()
287
+ };
288
+ return defineFetchResponse({
289
+ status: normalizedStatus,
290
+ ok: normalizedStatus >= 200 && normalizedStatus < 300,
291
+ kind: 'stream',
292
+ bodyClass: 'opaque',
293
+ headers: normalizedHeaders,
294
+ header: headerAccessor(normalizedHeaders),
295
+ bodyHandle: handle,
296
+ text() { throw opaqueInspectionError(); },
297
+ json() { throw opaqueInspectionError(); }
298
+ }, data);
299
+ }
300
+
301
+ function isPulseFetchResponse(value) {
302
+ return Boolean(value && typeof value === 'object' && FETCH_RESPONSE_DATA.has(value));
303
+ }
304
+
305
+ function normalizeFetchResponse(value, options = {}) {
306
+ if (isPulseFetchResponse(value)) return value;
307
+ if (!(value instanceof Response)) {
308
+ throw new TypeError('Pulse fetch adapters must resolve to a Web Response or Pulse fetch response.');
309
+ }
310
+ // The root fetch result is always host-owned. Returning or awaiting it does not
311
+ // implicitly materialize bytes merely because a content type looks textual.
312
+ // Explicit operation projections (`ctx.fetch(...).text/json`) retain bounded
313
+ // access to the underlying Web Response through FETCH_RESPONSE_DATA below.
314
+ return createOpaqueFetchResponse({
315
+ response: value,
316
+ maxBodyBytes: options.maxBodyBytes,
317
+ signal: options.signal
318
+ });
319
+ }
320
+
321
+ function projectFetchResponse(value, projection, schemaId, options = {}) {
322
+ const data = FETCH_RESPONSE_DATA.get(value);
323
+ if (!data) throw new TypeError('Expected a Pulse fetch response.');
324
+ // A direct/awaited fetch root exposes only pass-through methods, but the
325
+ // operation-owned projection path may claim and consume one bounded snapshot.
326
+ // A provider body handle with no Web Response remains truly opaque.
327
+ if (!(data.response instanceof Response) && data.bodyClass !== 'structured') throw opaqueInspectionError();
328
+ const reader = readerForFetchData(data, options);
329
+ if (!reader) throw opaqueInspectionError();
330
+ if (projection === 'text') return reader.text();
331
+ if (projection === 'json') {
332
+ if (schemaId === undefined && !strictSchemaPolicy(options)) return reader.json();
333
+ requireSchemaId(schemaId, options, 'fetch-response');
334
+ const key = schemaId === undefined ? '<missing>' : String(schemaId);
335
+ if (!data.schemaProjectionCache) data.schemaProjectionCache = new Map();
336
+ if (!data.schemaProjectionCache.has(key)) {
337
+ data.schemaProjectionCache.set(key, reader.text().then((text) => decodeSchemaText(
338
+ schemaId,
339
+ text,
340
+ options,
341
+ {
342
+ source: 'fetch-response',
343
+ headers: value.headers,
344
+ status: value.status,
345
+ operationId: `fetch-response:${options.effectId || key}`,
346
+ effectId: options.effectId
347
+ }
348
+ )));
349
+ }
350
+ return data.schemaProjectionCache.get(key);
351
+ }
352
+ throw new PulseRuntimeContractError(
353
+ 'PULSE_RUNTIME_FETCH_PROJECTION_INVALID',
354
+ `Pulse fetch projection ${JSON.stringify(String(projection))} is unsupported.`
355
+ );
356
+ }
357
+
358
+ async function cancelResponseBody(response) {
359
+ if (!(response instanceof Response) || !response.body || response.bodyUsed) return;
360
+ try { await response.body.cancel(); } catch (_) { /* ownership cleanup is best effort */ }
361
+ }
362
+
363
+ async function fetchResponseToResponse(value, requestMethod = 'GET') {
364
+ const data = FETCH_RESPONSE_DATA.get(value);
365
+ if (!data) throw new TypeError('Expected a Pulse fetch response.');
366
+ if (data.response instanceof Response) {
367
+ claimFetchResponse(data, 'host-pass-through');
368
+ const headers = value.headers || responseHeaderPairs(data.response);
369
+ const bodyAllowed = responseAllowsBody(value.status, requestMethod);
370
+ const statusChanged = data.statusOverride && value.status !== data.response.status;
371
+ if (bodyAllowed && !statusChanged) {
372
+ return rememberResponseMetadata(data.response, headers, value.bodyClass);
373
+ }
374
+ if (!bodyAllowed) await cancelResponseBody(data.response);
375
+ return rememberResponseMetadata(
376
+ new Response(bodyAllowed ? data.response.body : null, {
377
+ status: value.status,
378
+ ...(statusChanged ? {} : { statusText: data.response.statusText }),
379
+ headers: data.response.headers
380
+ }),
381
+ headers,
382
+ value.bodyClass
383
+ );
384
+ }
385
+ if (data.bodyClass === 'opaque') {
386
+ throw new PulseRuntimeContractError(
387
+ 'PULSE_RUNTIME_OPAQUE_RESPONSE_ADAPTER_REQUIRED',
388
+ 'The current JavaScript host cannot serialize this opaque response without a provider adapter.'
389
+ );
390
+ }
391
+ throw new PulseRuntimeContractError('PULSE_RUNTIME_FETCH_RESPONSE_INVALID', 'Pulse fetch response data is incomplete.');
392
+ }
393
+
394
+ module.exports = Object.freeze({
395
+ PulseRuntimeContractError,
396
+ PulseUnhandledError,
397
+ createJsonResult,
398
+ createOpaqueFetchResponse,
399
+ createResponseResult,
400
+ createTextResult,
401
+ fetchResponseToResponse,
402
+ isPulseFetchResponse,
403
+ isPulseResult,
404
+ markOpaqueResponse,
405
+ normalizeFetchResponse,
406
+ normalizeHeaders,
407
+ projectFetchResponse,
408
+ responseAllowsBody,
409
+ responseBodyClass,
410
+ responseHeaderPairs,
411
+ responseIsStructured,
412
+ resultToResponse,
413
+ statusAllowsBody,
414
+ wrapStructuredResponse
415
+ });