@octanejs/app-core 0.0.7 → 0.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octanejs/app-core",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -79,10 +79,10 @@
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "0.1.11"
82
+ "octane": "0.1.12"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.3.0",
86
- "octane": "0.1.11"
86
+ "octane": "0.1.12"
87
87
  }
88
88
  }
package/src/codegen.js CHANGED
@@ -14,6 +14,7 @@ export const SERVER_ONLY_ADAPTER_IDS = new Set([
14
14
  '@ripple-ts/adapter-bun',
15
15
  '@ripple-ts/adapter-vercel',
16
16
  '@octanejs/adapter-vercel',
17
+ '@octanejs/adapter-cloudflare',
17
18
  ]);
18
19
 
19
20
  /** @type {Map<string, string>} */
@@ -39,6 +40,9 @@ export function webResponseToNodeResponse() {
39
40
  export function vercel() {
40
41
  throw new Error('[octane] Deploy adapters cannot run in the browser.');
41
42
  }
43
+ export function cloudflare() {
44
+ throw new Error('[octane] Deploy adapters cannot run in the browser.');
45
+ }
42
46
  export function adapt() {
43
47
  throw new Error('[octane] Deploy adapters cannot run in the browser.');
44
48
  }
@@ -123,6 +123,22 @@ export function resolveOctaneConfig(raw, options = {}) {
123
123
  if (raw.adapter.serve !== undefined && typeof raw.adapter.serve !== 'function') {
124
124
  throw new Error('[octane] adapter.serve must be a function.');
125
125
  }
126
+ if (
127
+ raw.adapter.serverTarget !== undefined &&
128
+ raw.adapter.serverTarget !== 'node' &&
129
+ raw.adapter.serverTarget !== 'webworker'
130
+ ) {
131
+ throw new Error("[octane] adapter.serverTarget must be 'node' or 'webworker'.");
132
+ }
133
+ if (
134
+ raw.adapter.serverTarget === 'webworker' &&
135
+ (typeof raw.adapter.runtime?.hash !== 'function' ||
136
+ typeof raw.adapter.runtime?.createAsyncContext !== 'function')
137
+ ) {
138
+ throw new Error(
139
+ '[octane] A webworker adapter must provide runtime.hash and runtime.createAsyncContext functions.',
140
+ );
141
+ }
126
142
  }
127
143
 
128
144
  if (
@@ -59,15 +59,20 @@ export function compose(middlewares) {
59
59
  * Create a context object for the request
60
60
  * @param {Request} request
61
61
  * @param {Record<string, string>} params
62
+ * @param {unknown} [platform]
62
63
  * @returns {Context}
63
64
  */
64
- export function createContext(request, params) {
65
- return {
65
+ export function createContext(request, params, platform) {
66
+ const context = /** @type {Context} */ ({
66
67
  request,
67
68
  params,
68
69
  url: new URL(request.url),
69
70
  state: new Map(),
70
- };
71
+ });
72
+ // Preserve the existing context shape for Node and other integrations that
73
+ // do not supply request-scoped platform bindings.
74
+ if (platform !== undefined) context.platform = platform;
75
+ return context;
71
76
  }
72
77
 
73
78
  /**
@@ -68,8 +68,8 @@ const FETCH_COORDINATOR_KEY = Symbol.for('octane.app-core.fetch-coordinator');
68
68
 
69
69
  /**
70
70
  * @typedef {Object} FetchCoordinator
71
- * @property {import('@ripple-ts/adapter/rpc').AsyncContext} asyncContext
72
- * @property {((request: Request) => Promise<Response>) | null} handler
71
+ * @property {import('@ripple-ts/adapter/rpc').AsyncContext<{ origin?: string, platform?: unknown }>} asyncContext
72
+ * @property {((request: Request, platform?: unknown) => Promise<Response>) | null} handler
73
73
  */
74
74
 
75
75
  /**
@@ -94,7 +94,7 @@ function getFetchCoordinator(runtime) {
94
94
  if (!shared.handler) {
95
95
  return Promise.resolve(new Response('Octane handler is not ready', { status: 503 }));
96
96
  }
97
- return shared.handler(request);
97
+ return shared.handler(request, shared.asyncContext.getStore()?.platform);
98
98
  });
99
99
  return coordinator;
100
100
  }
@@ -112,13 +112,15 @@ function getFetchCoordinator(runtime) {
112
112
  * Create the production request handler from a manifest.
113
113
  *
114
114
  * The returned function is a standard Web `fetch`-style handler:
115
- * `(request: Request) => Promise<Response>` — the generated server entry boots
116
- * it behind the adapter's `serve()` (or the built-in Node server), and
117
- * serverless wrappers import it directly.
115
+ * `(request: Request, platform?: unknown) => Promise<Response>` — the generated
116
+ * server entry boots it behind the adapter's `serve()` (or the built-in Node
117
+ * server), and serverless wrappers import it directly. Integrations can expose
118
+ * request-scoped platform bindings to middleware and routes via the optional
119
+ * second argument.
118
120
  *
119
121
  * @param {ServerManifest} manifest
120
122
  * @param {HandlerOptions} deps
121
- * @returns {(request: Request) => Promise<Response>}
123
+ * @returns {(request: Request, platform?: unknown) => Promise<Response>}
122
124
  */
123
125
  export function createHandler(manifest, deps) {
124
126
  const { renderToReadableStream, prerender, htmlTemplate, executeServerFunction } = deps;
@@ -142,7 +144,10 @@ export function createHandler(manifest, deps) {
142
144
  const fetchCoordinator = getFetchCoordinator(runtime);
143
145
  const asyncContext = fetchCoordinator?.asyncContext;
144
146
 
145
- const handler = async function handler(/** @type {Request} */ request) {
147
+ const handler = async function handler(
148
+ /** @type {Request} */ request,
149
+ /** @type {unknown} */ platform = undefined,
150
+ ) {
146
151
  const url = new URL(request.url);
147
152
  const method = request.method;
148
153
 
@@ -153,6 +158,16 @@ export function createHandler(manifest, deps) {
153
158
  headers: { 'Content-Type': 'application/json' },
154
159
  });
155
160
  }
161
+ /** @type {import('@ripple-ts/adapter/rpc').AsyncContext<{ origin?: string, platform?: unknown }>} */
162
+ const requestAsyncContext =
163
+ platform === undefined
164
+ ? asyncContext
165
+ : {
166
+ run(store, fn) {
167
+ return asyncContext.run({ ...store, platform }, fn);
168
+ },
169
+ getStore: () => asyncContext.getStore(),
170
+ };
156
171
  return handle_rpc_request(request, {
157
172
  resolveFunction(/** @type {string} */ hash) {
158
173
  const entry = rpcLookup.get(hash);
@@ -161,7 +176,7 @@ export function createHandler(manifest, deps) {
161
176
  return typeof fn === 'function' ? fn : null;
162
177
  },
163
178
  executeServerFunction,
164
- asyncContext,
179
+ asyncContext: requestAsyncContext,
165
180
  trustProxy,
166
181
  });
167
182
  }
@@ -175,7 +190,7 @@ export function createHandler(manifest, deps) {
175
190
  return new Response('Not Found', { status: 404 });
176
191
  }
177
192
 
178
- const context = createContext(request, match.params);
193
+ const context = createContext(request, match.params, platform);
179
194
 
180
195
  try {
181
196
  if (match.route.type === 'render') {
@@ -3,20 +3,29 @@
3
3
  * Production server-entry generator.
4
4
  *
5
5
  * `generateServerEntry` emits the module an integration uses as its server
6
- * bundle input. The generated module statically
7
- * imports every RenderRoute entry/layout module (compiled in server mode by
8
- * the active bundler integration) plus
9
- * octane.config.ts itself, wires them into `createHandler`, and:
6
+ * bundle input. The generated module statically imports every RenderRoute
7
+ * entry/layout module (compiled in server mode by the active bundler
8
+ * integration) plus octane.config.ts itself. Its mode selects one of three
9
+ * deployment surfaces:
10
10
  *
11
- * - exports `handler` the Web fetch handler `(Request) => Promise<Response>`
12
- * - exports `nodeHandler` a Node `(req, res)` wrapper for serverless
13
- * platforms (e.g. a Vercel Node function does
14
- * `export { nodeHandler as default } from '../dist/server/entry.js'`)
15
- * - auto-boots when run directly (`node dist/server/entry.js`): the
16
- * adapter's `serve()` when configured, else the built-in Node server
17
- * (static dist/client assets + the handler).
11
+ * - `handler` exports the Web fetch handler and a Node `(req, res)` wrapper
12
+ * for serverless platforms, then auto-boots when run directly.
13
+ * - `manifest` exports the template-free manifest and renderer dependencies
14
+ * consumed by integrations that inject the current HTML themselves.
15
+ * - `webworker` exports those manifest values plus a
16
+ * `createWebWorkerHandler({ htmlTemplate, clientAssets? })` factory, without
17
+ * template filesystem access, a Node HTTP bridge, or automatic boot.
18
18
  *
19
- * It is unused in dev, where the active integration loads modules directly.
19
+ * The Node handler's wrapper supports platforms such as Vercel, where a
20
+ * function can do:
21
+ *
22
+ * - `export { nodeHandler as default } from '../dist/server/entry.js'`
23
+ *
24
+ * Its direct-execution path uses the adapter's `serve()` when configured,
25
+ * otherwise the built-in Node server (static dist/client assets + the handler).
26
+ *
27
+ * Dev integrations use the manifest shape while loading request modules
28
+ * directly; the deployment handler shapes are production-only.
20
29
  */
21
30
 
22
31
  /** @import { Route, RootBoundaryOptions } from '@octanejs/app-core' */
@@ -35,7 +44,7 @@ import { get_route_entry_export_name, get_route_entry_path } from '../routes.js'
35
44
  * @property {Record<string, string>} [moduleImports] - Stable module ID → bundler import specifier
36
45
  * @property {((id: string) => string)} [resolveImport] - Fallback module-specifier mapper
37
46
  * @property {string} [configImportPath] - Bundler import specifier for octane.config.ts
38
- * @property {'handler' | 'manifest'} [mode] - Emit a bootable handler or a template-free manifest module
47
+ * @property {'handler' | 'manifest' | 'webworker'} [mode] - Emit a bootable handler, template-free manifest, or Web Worker factory module
39
48
  * @property {string} [serverRuntimeModuleId] - Renderer server runtime module ID
40
49
  * @property {string} [staticRuntimeModuleId] - Renderer static runtime module ID
41
50
  * @property {string} [productionModuleId] - App-core production runtime module ID
@@ -160,24 +169,57 @@ export function generateServerEntry(options) {
160
169
  })
161
170
  .join('\n');
162
171
 
163
- if (mode === 'manifest') {
164
- const assetFileImports = clientAssetMapFile
172
+ if (mode === 'manifest' || mode === 'webworker') {
173
+ const isWebWorker = mode === 'webworker';
174
+ const readsAssetFile = !isWebWorker && Boolean(clientAssetMapFile);
175
+ const assetFileImports = readsAssetFile
165
176
  ? `import { readFileSync } from 'node:fs';\nimport { fileURLToPath } from 'node:url';\nimport { dirname, join } from 'node:path';\n`
166
177
  : '';
167
- const assetDirectory = clientAssetMapFile
178
+ const platformImports = isWebWorker
179
+ ? ''
180
+ : `import { createHash } from 'node:crypto';\nimport { AsyncLocalStorage } from 'node:async_hooks';\n`;
181
+ const handlerImport = isWebWorker
182
+ ? `import { createHandler } from ${JSON.stringify(productionModuleId)};\n`
183
+ : '';
184
+ const assetDirectory = readsAssetFile
168
185
  ? `const __dirname = dirname(fileURLToPath(import.meta.url));\n`
169
186
  : '';
170
- const clientAssets = clientAssetMapFile
187
+ const clientAssets = readsAssetFile
171
188
  ? `JSON.parse(readFileSync(join(__dirname, ${JSON.stringify(clientAssetMapFile)}), 'utf-8'))`
172
189
  : JSON.stringify(clientAssetMap, null, '\t');
190
+ const runtime = isWebWorker
191
+ ? `const runtime = octaneConfig.adapter?.runtime;
192
+ if (!runtime) {
193
+ throw new Error(
194
+ "[octane] adapter.serverTarget 'webworker' requires adapter.runtime platform primitives.",
195
+ );
196
+ }`
197
+ : `const runtime = octaneConfig.adapter?.runtime ?? {
198
+ hash: (str) => createHash('sha256').update(str).digest('hex').slice(0, 8),
199
+ createAsyncContext: () => {
200
+ const als = new AsyncLocalStorage();
201
+ return { run: (store, fn) => als.run(store, fn), getStore: () => als.getStore() };
202
+ },
203
+ };`;
204
+ const workerFactory = isWebWorker
205
+ ? `
206
+ export function createWebWorkerHandler({ htmlTemplate, clientAssets = manifest.clientAssets }) {
207
+ return createHandler(
208
+ { ...manifest, clientAssets },
209
+ { ...rendererDeps, htmlTemplate },
210
+ );
211
+ }
212
+ `
213
+ : '';
214
+ const entryDescription = isWebWorker
215
+ ? 'the Web Worker server entry'
216
+ : 'the template-free server manifest entry';
173
217
 
174
218
  return `\
175
- // Auto-generated by ${generatedBy} — the template-free server manifest entry.
219
+ // Auto-generated by ${generatedBy} — ${entryDescription}.
176
220
  // Do not edit; regenerated by the active app integration.
177
221
 
178
- ${assetFileImports}import { createHash } from 'node:crypto';
179
- import { AsyncLocalStorage } from 'node:async_hooks';
180
- import {
222
+ ${assetFileImports}${platformImports}import {
181
223
  renderToReadableStream,
182
224
  executeServerFunction,
183
225
  Suspense,
@@ -186,19 +228,13 @@ import {
186
228
  } from ${JSON.stringify(serverRuntimeModuleId)};
187
229
  import { prerender } from ${JSON.stringify(staticRuntimeModuleId)};
188
230
  import { resolveOctaneConfig } from ${JSON.stringify(configModuleId)};
189
- import _rawOctaneConfig from ${JSON.stringify(resolvedConfigImport)};
231
+ ${handlerImport}import _rawOctaneConfig from ${JSON.stringify(resolvedConfigImport)};
190
232
 
191
233
  ${import_lines.join('\n')}
192
234
 
193
235
  export const octaneConfig = resolveOctaneConfig(_rawOctaneConfig);
194
236
 
195
- const runtime = octaneConfig.adapter?.runtime ?? {
196
- hash: (str) => createHash('sha256').update(str).digest('hex').slice(0, 8),
197
- createAsyncContext: () => {
198
- const als = new AsyncLocalStorage();
199
- return { run: (store, fn) => als.run(store, fn), getStore: () => als.getStore() };
200
- },
201
- };
237
+ ${runtime}
202
238
 
203
239
  const components = {
204
240
  ${component_entries}
@@ -258,6 +294,7 @@ export const rendererDeps = {
258
294
  ErrorBoundary,
259
295
  createElement,
260
296
  };
297
+ ${workerFactory}
261
298
  `;
262
299
  }
263
300
 
@@ -47,7 +47,8 @@ export interface ServerEntryOptions {
47
47
  moduleImports?: Record<string, string>;
48
48
  resolveImport?: (id: string) => string;
49
49
  configImportPath?: string;
50
- mode?: 'handler' | 'manifest';
50
+ /** Server module shape emitted for the active adapter target. @default 'handler' */
51
+ mode?: 'handler' | 'manifest' | 'webworker';
51
52
  serverRuntimeModuleId?: string;
52
53
  staticRuntimeModuleId?: string;
53
54
  productionModuleId?: string;
@@ -56,7 +57,7 @@ export interface ServerEntryOptions {
56
57
  generatedBy?: string;
57
58
  }
58
59
 
59
- /** Generate a production fetch-handler + optional Node auto-boot entry. */
60
+ /** Generate a production server entry in the requested module shape. */
60
61
  export function generateServerEntry(options: ServerEntryOptions): string;
61
62
  /** Generate a template-free bundle exporting `manifest` and `rendererDeps`. */
62
63
  export function generateServerManifestEntry(options: ServerEntryOptions): string;
package/types/index.d.ts CHANGED
@@ -108,6 +108,8 @@ export interface Context {
108
108
  * renderer inline scripts, hydration data, and the hydrate module script.
109
109
  */
110
110
  state: Map<string, unknown>;
111
+ /** Request-scoped bindings supplied by the active platform integration. */
112
+ platform?: unknown;
111
113
  }
112
114
 
113
115
  export type NextFunction = () => Promise<Response>;
@@ -117,7 +119,11 @@ export type RouteHandler = (context: Context) => Response | Promise<Response>;
117
119
  export function compose(
118
120
  middlewares: Middleware[],
119
121
  ): (context: Context, finalHandler: () => Promise<Response>) => Promise<Response>;
120
- export function createContext(request: Request, params: Record<string, string>): Context;
122
+ export function createContext(
123
+ request: Request,
124
+ params: Record<string, string>,
125
+ platform?: unknown,
126
+ ): Context;
121
127
  export function runMiddlewareChain(
122
128
  context: Context,
123
129
  globalMiddlewares: Middleware[],
@@ -402,11 +408,14 @@ export interface AdaptContext {
402
408
  * a deployment target (e.g. @octanejs/adapter-vercel emits `.vercel/output`).
403
409
  * - `serve(handler, opts)` — replaces the generated server entry's built-in
404
410
  * Node boot when running `node dist/server/entry.js` / `octane-preview`.
411
+ * - `serverTarget` — selects the integration's Node or Web Worker server build.
405
412
  * - `runtime` — platform primitives (hashing, async context) replacing the
406
- * entry's Node defaults; needed on non-Node runtimes.
413
+ * entry's Node defaults; required for `serverTarget: 'webworker'`.
407
414
  */
408
415
  export interface OctaneAdapter {
409
416
  name?: string;
417
+ /** Server bundle runtime selected by the active app integration. @default 'node' */
418
+ serverTarget?: 'node' | 'webworker';
410
419
  adapt?: (ctx: AdaptContext) => void | Promise<void>;
411
420
  serve?: AdapterServeFunction;
412
421
  runtime?: RuntimePrimitives;
@@ -83,7 +83,7 @@ export interface HandlerOptions {
83
83
  export function createHandler(
84
84
  manifest: ServerManifest,
85
85
  options: HandlerOptions,
86
- ): (request: Request) => Promise<Response>;
86
+ ): (request: Request, platform?: unknown) => Promise<Response>;
87
87
 
88
88
  export function createPropsWrapper(Page: Component, pageProps: Record<string, unknown>): Component;
89
89
  export function createLayoutWrapper(