@octanejs/app-core 0.0.50 → 0.0.51

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.
@@ -25,6 +25,9 @@
25
25
  import { createRouter } from './router.js';
26
26
  import { createContext, runMiddlewareChain } from './middleware.js';
27
27
  import { handleRpcRequest } from './rpc.js';
28
+ import { setRequestContextSource } from './request-context.js';
29
+ import { createServerCallHost } from './server-calls.js';
30
+ import { runServerRequest } from './signal-owners.js';
28
31
  import { rpcIdCollision } from './rpc-registry.js';
29
32
  import { handleServerRoute } from './server-route.js';
30
33
  import { composeHtmlStream } from './html-stream.js';
@@ -32,6 +35,7 @@ import {
32
35
  applyHydrationNonce,
33
36
  getContextNonce,
34
37
  nonceAttribute,
38
+ prepareStreamingHydrationTemplate,
35
39
  splitSsrTemplate,
36
40
  validateSsrTemplate,
37
41
  } from './html-template.js';
@@ -69,7 +73,7 @@ const FETCH_COORDINATOR_KEY = Symbol.for('octane.app-core.fetch-coordinator');
69
73
 
70
74
  /**
71
75
  * @typedef {Object} FetchCoordinator
72
- * @property {import('@ripple-ts/adapter/rpc').AsyncContext<{ origin?: string, platform?: unknown }>} asyncContext
76
+ * @property {import('@octanejs/app-core').RpcRequestOptions['asyncContext']} asyncContext
73
77
  * @property {((request: Request, platform?: unknown) => Promise<Response>) | null} handler
74
78
  */
75
79
 
@@ -138,9 +142,16 @@ function buildRpcDescriptors(rpcModules, hashFn) {
138
142
  * the body-marker contract.
139
143
  *
140
144
  * @param {string} html
145
+ * @param {boolean} [earlyHydration]
141
146
  * @returns {(headContent: string) => string[]}
142
147
  */
143
- function prepareSsrTemplate(html) {
148
+ function prepareSsrTemplate(html, earlyHydration = false) {
149
+ let afterShell = '';
150
+ if (earlyHydration) {
151
+ const prepared = prepareStreamingHydrationTemplate(html);
152
+ html = prepared.html;
153
+ afterShell = prepared.afterShell;
154
+ }
144
155
  const [prefix, suffix] = splitSsrTemplate(html);
145
156
  const prefixHeadAt = prefix.indexOf(HEAD_MARKER);
146
157
  const headInPrefix = prefixHeadAt !== -1;
@@ -153,9 +164,9 @@ function prepareSsrTemplate(html) {
153
164
  const nextPrefix = headInPrefix ? beforeHead + headContent + afterHead : prefix;
154
165
  const nextSuffix = headInPrefix ? suffix : beforeHead + headContent + afterHead;
155
166
  if (headContent.includes(BODY_MARKER) || BODY_CLOSE_TAG.test(headContent)) {
156
- return splitSsrTemplate(nextPrefix + BODY_MARKER + nextSuffix);
167
+ return [...splitSsrTemplate(nextPrefix + BODY_MARKER + nextSuffix), afterShell];
157
168
  }
158
- return [nextPrefix, nextSuffix];
169
+ return [nextPrefix, nextSuffix, afterShell];
159
170
  };
160
171
  }
161
172
 
@@ -197,6 +208,83 @@ function prepareRenderRoutes(routes) {
197
208
  return prepared;
198
209
  }
199
210
 
211
+ /**
212
+ * Freeze the client-build lookup behind the renderer's narrow per-boundary
213
+ * resolver. Request renders can then join immutable asset identity with their
214
+ * own encoded captures without consulting global mutable state.
215
+ *
216
+ * @param {ServerManifest['independentHydration']} manifest
217
+ * @returns {import('octane/server').RenderOptions['independentHydration'] | undefined}
218
+ */
219
+ function prepareIndependentHydration(manifest) {
220
+ if (manifest == null) return undefined;
221
+ if (
222
+ manifest.version !== 1 ||
223
+ typeof manifest.buildId !== 'string' ||
224
+ manifest.buildId.length === 0 ||
225
+ manifest.widgets === null ||
226
+ typeof manifest.widgets !== 'object' ||
227
+ Array.isArray(manifest.widgets)
228
+ ) {
229
+ throw new TypeError('Invalid independent Hydrate build manifest.');
230
+ }
231
+ return Object.freeze({
232
+ buildId: manifest.buildId,
233
+ resolve(boundaryId) {
234
+ const entry = manifest.widgets[boundaryId];
235
+ if (
236
+ entry === null ||
237
+ typeof entry !== 'object' ||
238
+ entry.version !== 1 ||
239
+ entry.boundaryId !== boundaryId ||
240
+ typeof entry.moduleId !== 'string' ||
241
+ !Array.isArray(entry.styles) ||
242
+ !entry.styles.every((style) => typeof style === 'string') ||
243
+ entry.parentDependencies !== false
244
+ ) {
245
+ return undefined;
246
+ }
247
+ return { moduleId: entry.moduleId, styles: entry.styles };
248
+ },
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Snapshot completed build authority once per handler, never from request data.
254
+ * Legacy custom handlers without a client build retain their existing path.
255
+ * @param {ServerManifest} manifest
256
+ * @returns {ServerManifest['clientBuild'] | undefined}
257
+ */
258
+ function prepareClientBuild(manifest) {
259
+ const build = manifest.clientBuild;
260
+ if (build == null) return undefined;
261
+ if (
262
+ build.version !== 1 ||
263
+ typeof build.buildId !== 'string' ||
264
+ build.buildId.length === 0 ||
265
+ (build.mode !== 'production' && build.mode !== 'development') ||
266
+ typeof build.capabilities?.independentHydration !== 'boolean'
267
+ ) {
268
+ throw new TypeError('Invalid completed client build metadata.');
269
+ }
270
+ if (
271
+ (manifest.independentHydration != null &&
272
+ manifest.independentHydration.buildId !== build.buildId) ||
273
+ (build.capabilities.independentHydration && manifest.independentHydration == null) ||
274
+ (!build.capabilities.independentHydration &&
275
+ manifest.independentHydration != null &&
276
+ Object.keys(manifest.independentHydration.widgets).length !== 0)
277
+ ) {
278
+ throw new Error('Independent Hydrate metadata does not match the completed client build.');
279
+ }
280
+ return Object.freeze({
281
+ version: 1,
282
+ buildId: build.buildId,
283
+ mode: build.mode,
284
+ capabilities: Object.freeze({ independentHydration: build.capabilities.independentHydration }),
285
+ });
286
+ }
287
+
200
288
  /**
201
289
  * @param {ServerManifest} manifest
202
290
  * @param {RenderRoute} route
@@ -255,12 +343,17 @@ export function createHandler(manifest, deps) {
255
343
  const trustProxy = manifest.trustProxy ?? false;
256
344
  const rpcPolicy = manifest.rpc;
257
345
  const runtime = manifest.runtime;
346
+ const independentHydration = prepareIndependentHydration(manifest.independentHydration);
347
+ const clientBuild = prepareClientBuild(manifest);
258
348
  validateSsrTemplate(htmlTemplate);
259
349
  // Also pin the built-template contract up front. The marker is emitted by
260
350
  // the integration's HTML transform and survives source hashing. Prepare the
261
351
  // normalized no-nonce template once: this is the common request path, and its
262
352
  // static fragments are identical for every request handled by this manifest.
263
- const splitHydrationTemplate = prepareSsrTemplate(applyHydrationNonce(htmlTemplate, null));
353
+ const normalizedTemplate = applyHydrationNonce(htmlTemplate, null);
354
+ const splitHydrationTemplate = prepareSsrTemplate(normalizedTemplate);
355
+ /** @type {ReturnType<typeof prepareSsrTemplate> | undefined} */
356
+ let splitEarlyHydrationTemplate;
264
357
 
265
358
  // RPC lookup for statically imported `module server` functions
266
359
  // (compiler hash → server function).
@@ -300,6 +393,10 @@ export function createHandler(manifest, deps) {
300
393
  return rpcDescriptors?.get(hash) ?? null;
301
394
  },
302
395
  executeServerFunction,
396
+ streamServerFunction: deps.streamServerFunction,
397
+ batchServerFunctions: deps.batchServerFunctions,
398
+ signalOwners: deps.signalOwners,
399
+ resultLimits: rpcPolicy?.resultLimits,
303
400
  asyncContext,
304
401
  trustProxy,
305
402
  middlewares: globalMiddlewares,
@@ -319,22 +416,44 @@ export function createHandler(manifest, deps) {
319
416
  }
320
417
 
321
418
  const context = createContext(request, match.params, platform);
322
-
323
- try {
324
- if (match.route.type === 'render') {
325
- return await runMiddlewareChain(
326
- context,
327
- globalMiddlewares,
328
- match.route.before || [],
329
- async () => renderRoute(/** @type {RenderRoute} */ (match.route), context),
330
- [],
331
- );
419
+ if (clientBuild != null) Object.defineProperty(context, 'clientBuild', { value: clientBuild });
420
+ if (manifest.clientAssets != null)
421
+ Object.defineProperty(context, 'clientAssets', { value: manifest.clientAssets });
422
+ const run = async () => {
423
+ try {
424
+ if (match.route.type === 'render') {
425
+ return await runMiddlewareChain(
426
+ context,
427
+ globalMiddlewares,
428
+ match.route.before || [],
429
+ async () => renderRoute(/** @type {RenderRoute} */ (match.route), context),
430
+ [],
431
+ );
432
+ }
433
+ return await handleServerRoute(match.route, context, globalMiddlewares);
434
+ } catch (error) {
435
+ console.error('[octane] Request error:', error);
436
+ return new Response('Internal Server Error', { status: 500 });
332
437
  }
333
- return await handleServerRoute(match.route, context, globalMiddlewares);
334
- } catch (error) {
335
- console.error('[octane] Request error:', error);
336
- return new Response('Internal Server Error', { status: 500 });
337
- }
438
+ };
439
+ if (!asyncContext) return run();
440
+ setRequestContextSource(asyncContext);
441
+ const origin = url.origin;
442
+ return runServerRequest(
443
+ asyncContext,
444
+ {
445
+ origin,
446
+ platform,
447
+ context,
448
+ serverCallHost: createServerCallHost(context, {
449
+ asyncContext,
450
+ middlewares: globalMiddlewares,
451
+ origin,
452
+ }),
453
+ },
454
+ deps.signalOwners,
455
+ run,
456
+ );
338
457
  };
339
458
 
340
459
  if (fetchCoordinator) fetchCoordinator.handler = handler;
@@ -388,6 +507,13 @@ export function createHandler(manifest, deps) {
388
507
 
389
508
  // The hydration payload — SAME keys, SAME order as dev render-route.js, so
390
509
  // the data script is byte-identical between dev and production.
510
+ const streamedSignals =
511
+ clientBuild == null
512
+ ? undefined
513
+ : {
514
+ buildId: clientBuild.buildId,
515
+ documentId: crypto.randomUUID(),
516
+ };
391
517
  const routeData = JSON.stringify({
392
518
  entry: entryPath,
393
519
  exportName: exportName ?? null,
@@ -397,6 +523,8 @@ export function createHandler(manifest, deps) {
397
523
  url: requestUrl,
398
524
  preHydrate: manifest.preHydrate ?? null,
399
525
  rootBoundary: manifest.rootBoundaryEntries ?? { pending: null, catch: null },
526
+ clientBuild,
527
+ streamedSignals,
400
528
  });
401
529
  const dataScript = `<script id="__octane_data" type="application/json"${nonceAttribute(nonce)}>${escapeScript(routeData)}</script>`;
402
530
 
@@ -427,12 +555,14 @@ export function createHandler(manifest, deps) {
427
555
  // `$&`, `` $` ``, `$'` and `$1` in the inserted text expand against the
428
556
  // match, and this text now carries author-controlled metadata as well as
429
557
  // the serialized route data.
430
- /** @param {string} hoistedHead */
431
- const splitAroundBody = (hoistedHead) => {
558
+ /** @param {string} hoistedHead @param {boolean} [earlyHydration] */
559
+ const splitAroundBody = (hoistedHead, earlyHydration = false) => {
432
560
  const completeHead = headContent + hoistedHead;
433
- return noncedTemplate === null
434
- ? splitHydrationTemplate(completeHead)
435
- : splitSsrTemplate(noncedTemplate.replace(HEAD_MARKER, () => completeHead));
561
+ if (noncedTemplate !== null)
562
+ return prepareSsrTemplate(noncedTemplate, earlyHydration)(completeHead);
563
+ if (!earlyHydration) return splitHydrationTemplate(completeHead);
564
+ splitEarlyHydrationTemplate ??= prepareSsrTemplate(normalizedTemplate, true);
565
+ return splitEarlyHydrationTemplate(completeHead);
436
566
  };
437
567
 
438
568
  if (manifest.render === 'buffered') {
@@ -447,6 +577,8 @@ export function createHandler(manifest, deps) {
447
577
  } = await prerender(RootComponent, undefined, {
448
578
  nonce: nonce ?? undefined,
449
579
  headChannel: 'separate',
580
+ ...(streamedSignals === undefined ? {} : { streamedSignals }),
581
+ ...(independentHydration === undefined ? {} : { independentHydration }),
450
582
  signal: context.request.signal,
451
583
  onError(/** @type {unknown} */ error) {
452
584
  console.error('[octane] SSR render error:', error);
@@ -459,10 +591,16 @@ export function createHandler(manifest, deps) {
459
591
  // Streaming (default): shell flushes at first await, suspense segments
460
592
  // stream out-of-order behind it — identical to dev.
461
593
  let hoistedHead = '';
594
+ let earlyHydration = clientBuild?.capabilities.independentHydration === true;
462
595
  /** @type {ReadableStream<Uint8Array>} */
463
596
  const renderStream = await renderToReadableStream(RootComponent, undefined, {
597
+ onEarlyHydrationReady() {
598
+ earlyHydration = true;
599
+ },
464
600
  nonce: nonce ?? undefined,
465
601
  headChannel: 'separate',
602
+ ...(streamedSignals === undefined ? {} : { streamedSignals }),
603
+ ...(independentHydration === undefined ? {} : { independentHydration }),
466
604
  // Fires before the shell is written, so the metadata is in hand before
467
605
  // the template prefix (which carries `<head>`) is composed below.
468
606
  onHeadReady(/** @type {string} */ head) {
@@ -474,8 +612,8 @@ export function createHandler(manifest, deps) {
474
612
  },
475
613
  });
476
614
 
477
- const [prefix, suffix] = splitAroundBody(hoistedHead);
478
- const body = composeHtmlStream(prefix, renderStream, suffix);
615
+ const [prefix, suffix, afterShell] = splitAroundBody(hoistedHead, earlyHydration);
616
+ const body = composeHtmlStream(prefix, renderStream, suffix, afterShell);
479
617
 
480
618
  return new Response(body, { status, headers });
481
619
  }
@@ -18,14 +18,17 @@
18
18
 
19
19
  /**
20
20
  * @typedef {import('@octanejs/app-core').Context} Context
21
- * @typedef {import('@ripple-ts/adapter/rpc').AsyncContext<{ origin?: string, platform?: unknown, context?: Context }>} RequestAsyncContext
22
- * @typedef {{ asyncContext: RequestAsyncContext }} RequestContextHolder
21
+ * @typedef {import('@octanejs/app-core').RpcRequestOptions['asyncContext']} RequestAsyncContext
22
+ * @typedef {{ asyncContext: RequestAsyncContext, serverCallSource: import('octane/server').ServerCallContextSource }} RequestContextHolder
23
23
  */
24
24
 
25
25
  const REQUEST_CONTEXT_KEY = Symbol.for('octane.app-core.request-context');
26
+ const SERVER_CALL_CONTEXT_KEY = Symbol.for('octane.server.call-context');
26
27
 
27
28
  const globals =
28
- /** @type {typeof globalThis & { [REQUEST_CONTEXT_KEY]?: RequestContextHolder }} */ (globalThis);
29
+ /** @type {typeof globalThis & { [REQUEST_CONTEXT_KEY]?: RequestContextHolder, [SERVER_CALL_CONTEXT_KEY]?: import('octane/server').ServerCallContextSource }} */ (
30
+ globalThis
31
+ );
29
32
 
30
33
  /**
31
34
  * Publish the async context a request boundary runs its handler inside. Called
@@ -37,12 +40,21 @@ const globals =
37
40
  * @returns {void}
38
41
  */
39
42
  export function setRequestContextSource(asyncContext) {
40
- const current = globals[REQUEST_CONTEXT_KEY];
43
+ let current = globals[REQUEST_CONTEXT_KEY];
41
44
  if (current === undefined) {
42
- globals[REQUEST_CONTEXT_KEY] = { asyncContext };
45
+ current = {
46
+ asyncContext,
47
+ serverCallSource: {
48
+ getStore: () => globals[REQUEST_CONTEXT_KEY]?.asyncContext.getStore()?.serverCallHost,
49
+ },
50
+ };
51
+ globals[REQUEST_CONTEXT_KEY] = current;
43
52
  } else if (current.asyncContext !== asyncContext) {
44
53
  current.asyncContext = asyncContext;
45
54
  }
55
+ if (globals[SERVER_CALL_CONTEXT_KEY] !== current.serverCallSource) {
56
+ globals[SERVER_CALL_CONTEXT_KEY] = current.serverCallSource;
57
+ }
46
58
  }
47
59
 
48
60
  /**
package/src/server/rpc.js CHANGED
@@ -13,6 +13,8 @@ import { derive_origin } from '@ripple-ts/adapter/rpc';
13
13
  import { DEFAULT_RPC_MAX_BODY_BYTES } from '../constants.js';
14
14
  import { createContext, runMiddlewareChain } from './middleware.js';
15
15
  import { setRequestContextSource } from './request-context.js';
16
+ import { createServerCallHost, serverCallContext } from './server-calls.js';
17
+ import { runServerRequest } from './signal-owners.js';
16
18
 
17
19
  const RPC_PATH_PREFIX = '/_$_ripple_rpc_$_/';
18
20
 
@@ -25,7 +27,11 @@ const RPC_PATH_PREFIX = '/_$_ripple_rpc_$_/';
25
27
  function rpcError(status, message, headers) {
26
28
  return new Response(JSON.stringify({ error: message }), {
27
29
  status,
28
- headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers },
30
+ headers: {
31
+ 'Content-Type': 'application/json; charset=utf-8',
32
+ 'Octane-RPC-Outcome': 'rejected',
33
+ ...headers,
34
+ },
29
35
  });
30
36
  }
31
37
 
@@ -74,6 +80,7 @@ function withRpcCors(response, origin) {
74
80
 
75
81
  const headers = new Headers(response.headers);
76
82
  headers.set('Access-Control-Allow-Origin', origin);
83
+ headers.set('Access-Control-Expose-Headers', 'Octane-RPC-Outcome');
77
84
  headers.append('Vary', 'Origin');
78
85
  return new Response(response.body, {
79
86
  status: response.status,
@@ -212,6 +219,53 @@ export async function handleRpcRequest(request, options) {
212
219
  }
213
220
  const browserOrigin = request.headers.get('origin');
214
221
  const corsOrigin = browserOrigin === origin ? null : browserOrigin;
222
+ if (request.headers.get('accept') === 'application/x-octane-rpc-batch+ndjson') {
223
+ if (options.batchServerFunctions === undefined || options.streamServerFunction === undefined) {
224
+ return withRpcCors(rpcError(406, 'Server-call batching is not configured'), corsOrigin);
225
+ }
226
+ const payload = await readBoundedRpcBody(
227
+ request,
228
+ options.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
229
+ );
230
+ if ('error' in payload) return withRpcCors(payload.error, corsOrigin);
231
+ try {
232
+ const stream = await options.batchServerFunctions(
233
+ payload.body,
234
+ (member, signal) => {
235
+ const url = new URL(request.url);
236
+ url.pathname = RPC_PATH_PREFIX + member.hash;
237
+ const headers = new Headers(request.headers);
238
+ headers.set('accept', 'application/x-octane-rpc+ndjson');
239
+ headers.delete('content-length');
240
+ // Re-enter the complete boundary for every member, with isolated
241
+ // authorization state. The batch envelope grants no credentials.
242
+ return handleRpcRequest(
243
+ new Request(url, {
244
+ method: 'POST',
245
+ headers,
246
+ body: member.body,
247
+ signal,
248
+ }),
249
+ options,
250
+ );
251
+ },
252
+ { ...options.resultLimits, signal: request.signal },
253
+ );
254
+ return withRpcCors(
255
+ new Response(stream, {
256
+ headers: {
257
+ 'Content-Type': 'application/x-octane-rpc-batch+ndjson',
258
+ 'Cache-Control': 'no-store',
259
+ },
260
+ }),
261
+ corsOrigin,
262
+ );
263
+ } catch (error) {
264
+ if (isInvalidRpcPayload(error))
265
+ return withRpcCors(rpcError(400, 'Invalid server batch arguments'), corsOrigin);
266
+ throw error;
267
+ }
268
+ }
215
269
 
216
270
  const context = createContext(request, {}, options.platform);
217
271
  // Name the target before middleware so a policy can authorize per function.
@@ -230,41 +284,123 @@ export async function handleRpcRequest(request, options) {
230
284
  options.platform === undefined
231
285
  ? { origin, context }
232
286
  : { origin, platform: options.platform, context };
287
+ Object.assign(store, {
288
+ serverCallHost: createServerCallHost(context, {
289
+ asyncContext: options.asyncContext,
290
+ middlewares: options.middlewares,
291
+ origin,
292
+ }),
293
+ });
233
294
  setRequestContextSource(options.asyncContext);
295
+ let invoked = false;
234
296
 
235
297
  try {
236
- const response = await options.asyncContext.run(store, async () =>
237
- runMiddlewareChain(
238
- context,
239
- options.middlewares ?? [],
240
- [],
241
- async () => {
242
- const fn = await options.resolveFunction(hash);
243
- if (fn === null) return rpcError(404, 'RPC function not found');
244
- const payload = await readBoundedRpcBody(
245
- request,
246
- options.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
247
- );
248
- if ('error' in payload) return payload.error;
249
- try {
250
- const result = await options.executeServerFunction(fn, payload.body);
251
- return new Response(result, {
252
- status: 200,
253
- headers: { 'Content-Type': 'application/json; charset=utf-8' },
254
- });
255
- } catch (error) {
256
- if (isInvalidRpcPayload(error)) {
257
- return rpcError(400, 'Invalid server function arguments');
298
+ const response = await runServerRequest(
299
+ options.asyncContext,
300
+ store,
301
+ options.signalOwners,
302
+ async () =>
303
+ runMiddlewareChain(
304
+ context,
305
+ options.middlewares ?? [],
306
+ [],
307
+ async () => {
308
+ const fn = await options.resolveFunction(hash);
309
+ if (fn === null) return rpcError(404, 'RPC function not found');
310
+ const payload = await readBoundedRpcBody(
311
+ request,
312
+ options.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
313
+ );
314
+ if ('error' in payload) return payload.error;
315
+ try {
316
+ if (request.headers.get('accept') === 'application/x-octane-rpc+ndjson') {
317
+ if (options.streamServerFunction === undefined) {
318
+ return rpcError(406, 'Streamed RPC results are not configured');
319
+ }
320
+ invoked = true;
321
+ const stream = await options.streamServerFunction(
322
+ fn,
323
+ payload.body,
324
+ serverCallContext(context),
325
+ options.resultLimits,
326
+ );
327
+ const reader = stream.getReader();
328
+ return new Response(
329
+ new ReadableStream(
330
+ {
331
+ pull(controller) {
332
+ return options.asyncContext.run(store, async () => {
333
+ try {
334
+ const item = await reader.read();
335
+ if (item.done) {
336
+ reader.releaseLock();
337
+ controller.close();
338
+ } else controller.enqueue(item.value);
339
+ } catch (error) {
340
+ controller.error(error);
341
+ }
342
+ });
343
+ },
344
+ cancel(reason) {
345
+ return options.asyncContext.run(store, async () => {
346
+ try {
347
+ await reader.cancel(reason);
348
+ } finally {
349
+ reader.releaseLock();
350
+ }
351
+ });
352
+ },
353
+ },
354
+ { highWaterMark: 0 },
355
+ ),
356
+ {
357
+ headers: {
358
+ 'Content-Type': 'application/x-octane-rpc+ndjson',
359
+ 'Cache-Control': 'no-store',
360
+ },
361
+ },
362
+ );
363
+ }
364
+ invoked = true;
365
+ const result = await options.executeServerFunction(
366
+ fn,
367
+ payload.body,
368
+ serverCallContext(context),
369
+ );
370
+ return new Response(result, {
371
+ status: 200,
372
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
373
+ });
374
+ } catch (error) {
375
+ if (isInvalidRpcPayload(error)) {
376
+ return rpcError(400, 'Invalid server function arguments');
377
+ }
378
+ throw error;
258
379
  }
259
- throw error;
260
- }
261
- },
262
- [],
263
- ),
380
+ },
381
+ [],
382
+ ),
264
383
  );
384
+ if (!response.ok && !invoked) {
385
+ const headers = new Headers(response.headers);
386
+ headers.set('Octane-RPC-Outcome', 'rejected');
387
+ return withRpcCors(
388
+ new Response(response.body, {
389
+ status: response.status,
390
+ statusText: response.statusText,
391
+ headers,
392
+ }),
393
+ corsOrigin,
394
+ );
395
+ }
265
396
  return withRpcCors(response, corsOrigin);
266
397
  } catch (error) {
267
398
  console.error('[octane] RPC request error:', error);
268
- return withRpcCors(rpcError(500, 'Internal Server Error'), corsOrigin);
399
+ return withRpcCors(
400
+ rpcError(500, 'Internal Server Error', {
401
+ 'Octane-RPC-Outcome': invoked ? 'uncertain' : 'rejected',
402
+ }),
403
+ corsOrigin,
404
+ );
269
405
  }
270
406
  }