@octanejs/app-core 0.0.15 → 0.0.16

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.15",
3
+ "version": "0.0.16",
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.19"
82
+ "octane": "0.1.20"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.1.19"
86
+ "octane": "0.1.20"
87
87
  }
88
88
  }
package/src/index.js CHANGED
@@ -17,3 +17,5 @@ export {
17
17
  runMiddlewareChain,
18
18
  } from './middleware.js';
19
19
  export { handleRpcRequest } from './server/rpc.js';
20
+ export { getRequestContext, tryGetRequestContext } from './server/request-context.js';
21
+ export { createRpcRegistry } from './server/rpc-registry.js';
@@ -174,6 +174,9 @@ export function resolveOctaneConfig(raw, options = {}) {
174
174
  ) {
175
175
  throw new Error('[octane] compiler must be an object when provided.');
176
176
  }
177
+ if (raw.compiler?.strong !== undefined && typeof raw.compiler.strong !== 'boolean') {
178
+ throw new Error('[octane] compiler.strong must be a boolean when provided.');
179
+ }
177
180
 
178
181
  if (raw.router?.routes !== undefined && !Array.isArray(raw.router.routes)) {
179
182
  throw new Error('[octane] router.routes must be an array.');
@@ -232,6 +235,7 @@ export function resolveOctaneConfig(raw, options = {}) {
232
235
  },
233
236
  adapter: raw.adapter,
234
237
  compiler: {
238
+ strong: raw.compiler?.strong ?? false,
235
239
  renderers: normalizeRendererConfig(raw.compiler?.renderers),
236
240
  },
237
241
  router: {
@@ -25,6 +25,7 @@
25
25
  import { createRouter } from './router.js';
26
26
  import { createContext, runMiddlewareChain } from './middleware.js';
27
27
  import { handleRpcRequest } from './rpc.js';
28
+ import { rpcIdCollision } from './rpc-registry.js';
28
29
  import { handleServerRoute } from './server-route.js';
29
30
  import { composeHtmlStream } from './html-stream.js';
30
31
  import {
@@ -95,6 +96,34 @@ function getFetchCoordinator(runtime) {
95
96
  return coordinator;
96
97
  }
97
98
 
99
+ /**
100
+ * Name every server function id, built once per handler.
101
+ *
102
+ * `build_rpc_lookup` keeps only the namespace object and export name, but a
103
+ * middleware policy needs the declaring module before the function is resolved.
104
+ *
105
+ * @param {Record<string, Record<string, Function>>} rpcModules
106
+ * @param {(value: string) => string} hashFn
107
+ * @returns {Map<string, { module: string, export: string }>}
108
+ */
109
+ function buildRpcDescriptors(rpcModules, hashFn) {
110
+ /** @type {Map<string, { module: string, export: string }>} */
111
+ const descriptors = new Map();
112
+ for (const [entryPath, serverObj] of Object.entries(rpcModules)) {
113
+ for (const funcName of Object.keys(serverObj)) {
114
+ const id = hashFn(entryPath + '#' + funcName);
115
+ // Each manifest entry is listed once, so an id already taken here is a
116
+ // genuine collision. `build_rpc_lookup` would resolve it by overwriting.
117
+ const existing = descriptors.get(id);
118
+ if (existing !== undefined) {
119
+ throw rpcIdCollision(id, existing, { module: entryPath, export: funcName });
120
+ }
121
+ descriptors.set(id, { module: entryPath, export: funcName });
122
+ }
123
+ }
124
+ return descriptors;
125
+ }
126
+
98
127
  /**
99
128
  * @typedef {import('@octanejs/app-core').RenderRoute} RenderRoute
100
129
  * @typedef {import('@octanejs/app-core').Middleware} Middleware
@@ -134,6 +163,8 @@ export function createHandler(manifest, deps) {
134
163
  // (compiler hash → server function).
135
164
  const rpcLookup =
136
165
  manifest.rpcModules && runtime ? build_rpc_lookup(manifest.rpcModules, runtime.hash) : null;
166
+ const rpcDescriptors =
167
+ manifest.rpcModules && runtime ? buildRpcDescriptors(manifest.rpcModules, runtime.hash) : null;
137
168
 
138
169
  // Request-scoped async context + same-origin fetch short-circuit: fetch()
139
170
  // during SSR that resolves to this origin routes through the handler
@@ -162,6 +193,9 @@ export function createHandler(manifest, deps) {
162
193
  const fn = entry.serverObj[entry.funcName];
163
194
  return typeof fn === 'function' ? fn : null;
164
195
  },
196
+ describeFunction(/** @type {string} */ hash) {
197
+ return rpcDescriptors?.get(hash) ?? null;
198
+ },
165
199
  executeServerFunction,
166
200
  asyncContext,
167
201
  trustProxy,
@@ -0,0 +1,72 @@
1
+ // @ts-check
2
+ /**
3
+ * Request-scoped context lookup for `module server` functions.
4
+ *
5
+ * A server function is loaded through the SSR module graph in dev and through
6
+ * the server manifest in production, so the `@octanejs/app-core` instance it
7
+ * imports is not necessarily the one that handled the request. The active async
8
+ * context is therefore published on a `Symbol.for` global, the same technique
9
+ * the fetch coordinator uses, so every evaluated copy resolves the one live
10
+ * store.
11
+ *
12
+ * The holder tracks one boundary, which is what a process has: dev serves RPC
13
+ * through the plugin's single async context and production through the single
14
+ * fetch coordinator. A process running two boundaries concurrently would see the
15
+ * later registration win, and a server function under the earlier one reports no
16
+ * request rather than the wrong one.
17
+ */
18
+
19
+ /**
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
23
+ */
24
+
25
+ const REQUEST_CONTEXT_KEY = Symbol.for('octane.app-core.request-context');
26
+
27
+ const globals =
28
+ /** @type {typeof globalThis & { [REQUEST_CONTEXT_KEY]?: RequestContextHolder }} */ (globalThis);
29
+
30
+ /**
31
+ * Publish the async context a request boundary runs its handler inside. Called
32
+ * per request from `handleRpcRequest` rather than once at construction so any
33
+ * embedder of that boundary is covered without a second registration step; the
34
+ * identity check keeps the steady state a read.
35
+ *
36
+ * @param {RequestAsyncContext} asyncContext
37
+ * @returns {void}
38
+ */
39
+ export function setRequestContextSource(asyncContext) {
40
+ const current = globals[REQUEST_CONTEXT_KEY];
41
+ if (current === undefined) {
42
+ globals[REQUEST_CONTEXT_KEY] = { asyncContext };
43
+ } else if (current.asyncContext !== asyncContext) {
44
+ current.asyncContext = asyncContext;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * The `Context` for the in-flight request, or `null` when there is none.
50
+ *
51
+ * @returns {Context | null}
52
+ */
53
+ export function tryGetRequestContext() {
54
+ return globals[REQUEST_CONTEXT_KEY]?.asyncContext.getStore()?.context ?? null;
55
+ }
56
+
57
+ /**
58
+ * The `Context` for the in-flight request.
59
+ *
60
+ * @returns {Context}
61
+ */
62
+ export function getRequestContext() {
63
+ const context = tryGetRequestContext();
64
+ if (context === null) {
65
+ throw new Error(
66
+ '[octane] getRequestContext() was called outside of a request. It is available inside ' +
67
+ '`module server` functions and the middleware chain that runs them. Use ' +
68
+ 'tryGetRequestContext() for code that must also run outside a request.',
69
+ );
70
+ }
71
+ return context;
72
+ }
@@ -0,0 +1,62 @@
1
+ // @ts-check
2
+ /**
3
+ * Collision guard for `module server` function ids.
4
+ *
5
+ * An id is a truncated SHA-256 of `"<module>#<export>"`, so two different
6
+ * exports can produce the same one. Both registration paths are a plain Map
7
+ * set, which resolves a collision by overwriting: one function becomes
8
+ * unreachable and its calls execute the other one instead, silently and with
9
+ * whatever authorization that other function carries. Fail the boot instead.
10
+ */
11
+
12
+ /**
13
+ * @typedef {{ module: string, export: string }} RpcDeclaration
14
+ */
15
+
16
+ /**
17
+ * @param {string} id
18
+ * @param {RpcDeclaration} first
19
+ * @param {RpcDeclaration} second
20
+ * @returns {Error}
21
+ */
22
+ export function rpcIdCollision(id, first, second) {
23
+ return new Error(
24
+ `[octane] Two server functions share the id "${id}": \`${first.export}\` in ${first.module} ` +
25
+ `and \`${second.export}\` in ${second.module}. An id is a truncated hash of the module ` +
26
+ 'path and export name, so renaming either export resolves it.',
27
+ );
28
+ }
29
+
30
+ /**
31
+ * `globalThis.rpc_modules`, rejecting a second declaration under an id already
32
+ * taken by a different one. Re-registering the same export is a no-op, which
33
+ * dev module reloads depend on.
34
+ */
35
+ class RpcRegistry extends Map {
36
+ /**
37
+ * @param {string} id
38
+ * @param {[modulePath: string, exportName: string]} declaration
39
+ * @returns {this}
40
+ */
41
+ set(id, declaration) {
42
+ const existing = this.get(id);
43
+ if (
44
+ existing !== undefined &&
45
+ (existing[0] !== declaration[0] || existing[1] !== declaration[1])
46
+ ) {
47
+ throw rpcIdCollision(
48
+ id,
49
+ { module: existing[0], export: existing[1] },
50
+ { module: declaration[0], export: declaration[1] },
51
+ );
52
+ }
53
+ return super.set(id, declaration);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @returns {Map<string, [modulePath: string, exportName: string]>}
59
+ */
60
+ export function createRpcRegistry() {
61
+ return new RpcRegistry();
62
+ }
package/src/server/rpc.js CHANGED
@@ -12,6 +12,7 @@ import { derive_origin } from '@ripple-ts/adapter/rpc';
12
12
 
13
13
  import { DEFAULT_RPC_MAX_BODY_BYTES } from '../constants.js';
14
14
  import { createContext, runMiddlewareChain } from './middleware.js';
15
+ import { setRequestContextSource } from './request-context.js';
15
16
 
16
17
  const RPC_PATH_PREFIX = '/_$_ripple_rpc_$_/';
17
18
 
@@ -213,8 +214,23 @@ export async function handleRpcRequest(request, options) {
213
214
  const corsOrigin = browserOrigin === origin ? null : browserOrigin;
214
215
 
215
216
  const context = createContext(request, {}, options.platform);
217
+ // Name the target before middleware so a policy can authorize per function.
218
+ // The id alone is a compiler artifact, so a policy written against it would
219
+ // break on any rename.
220
+ const described = options.describeFunction?.(hash);
221
+ context.rpc = {
222
+ id: hash,
223
+ module: described?.module ?? null,
224
+ export: described?.export ?? null,
225
+ };
226
+ // `context` rides the request store so a server function can read what the
227
+ // middleware chain established (auth, tenant) instead of trusting arguments
228
+ // the browser sent. The body is already consumed by the time it runs.
216
229
  const store =
217
- options.platform === undefined ? { origin } : { origin, platform: options.platform };
230
+ options.platform === undefined
231
+ ? { origin, context }
232
+ : { origin, platform: options.platform, context };
233
+ setRequestContextSource(options.asyncContext);
218
234
 
219
235
  try {
220
236
  const response = await options.asyncContext.run(store, async () =>
package/types/index.d.ts CHANGED
@@ -111,6 +111,34 @@ export interface Context {
111
111
  state: Map<string, unknown>;
112
112
  /** Request-scoped bindings supplied by the active platform integration. */
113
113
  platform?: unknown;
114
+ /**
115
+ * The `module server` export this request targets, present only on an RPC
116
+ * request. Set before the middleware chain runs, so a policy can authorize
117
+ * per function instead of per endpoint.
118
+ */
119
+ rpc?: RpcTarget;
120
+ }
121
+
122
+ /**
123
+ * Identifies the `module server` export an RPC request targets.
124
+ *
125
+ * `module` and `export` are `null` when the integration supplies no
126
+ * {@link RpcRequestOptions.describeFunction}. A policy that matches on them then
127
+ * matches nothing and allows the request, so an integration that hand-rolls the
128
+ * RPC boundary must supply it before writing per-function authorization. Both
129
+ * first-party integrations (the Vite plugin and the production handler) do.
130
+ */
131
+ export interface RpcTarget {
132
+ /**
133
+ * Compiler-assigned function id, taken from the request path. Stable only for
134
+ * a given build: it is a hash of the declaring module and export name, so it
135
+ * changes on rename. Authorize on `module`/`export`, not on this.
136
+ */
137
+ id: string;
138
+ /** Module that declared the export, or `null` when the integration cannot name it. */
139
+ module: string | null;
140
+ /** Exported function name, or `null` when the integration cannot name it. */
141
+ export: string | null;
114
142
  }
115
143
 
116
144
  export type NextFunction = () => Promise<Response>;
@@ -142,8 +170,15 @@ export function is_rpc_request(pathname: string): boolean;
142
170
  /** Security policy and execution dependencies for a server-function request. */
143
171
  export interface RpcRequestOptions {
144
172
  resolveFunction: (hash: string) => Function | null | Promise<Function | null>;
173
+ /**
174
+ * Name the export a function id refers to, without loading its module. Called
175
+ * once per RPC request, before middleware, to populate {@link Context.rpc}.
176
+ * Synchronous by contract: both first-party integrations already hold the
177
+ * mapping, and the middleware chain must not wait on it.
178
+ */
179
+ describeFunction?: (hash: string) => { module: string; export: string } | null;
145
180
  executeServerFunction: (fn: Function, body: string) => Promise<string>;
146
- asyncContext: AsyncContext<{ origin?: string; platform?: unknown }>;
181
+ asyncContext: AsyncContext<{ origin?: string; platform?: unknown; context?: Context }>;
147
182
  trustProxy?: boolean;
148
183
  middlewares?: Middleware[];
149
184
  allowedOrigins?: readonly string[];
@@ -154,6 +189,38 @@ export interface RpcRequestOptions {
154
189
  /** Apply Octane's security policy and global middleware to a server function. */
155
190
  export function handleRpcRequest(request: Request, options: RpcRequestOptions): Promise<Response>;
156
191
 
192
+ /**
193
+ * The `Context` for the in-flight request.
194
+ *
195
+ * Available inside a `module server` function and inside the middleware chain
196
+ * that runs it, so a server function can read the identity its middleware
197
+ * established rather than trusting an argument the browser supplied. It is the
198
+ * same `Context` instance the middleware saw, including `state` mutations.
199
+ *
200
+ * `context.request.body` is already consumed on the RPC path (the boundary
201
+ * reads it under the configured size limit before dispatching), so
202
+ * `bodyUsed` is `true`; headers, cookies, and `url` are unaffected.
203
+ *
204
+ * Throws outside a request. Use {@link tryGetRequestContext} in code that must
205
+ * also run outside one.
206
+ */
207
+ export function getRequestContext(): Context;
208
+
209
+ /** {@link getRequestContext}, returning `null` outside a request instead of throwing. */
210
+ export function tryGetRequestContext(): Context | null;
211
+
212
+ /**
213
+ * The `globalThis.rpc_modules` registry compiled `module server` declarations
214
+ * register into, guarding against an id collision.
215
+ *
216
+ * An id is a truncated hash of the module path and export name, so two exports
217
+ * can produce the same one. A plain Map would resolve that by overwriting, which
218
+ * makes one function unreachable and routes its calls to the other. This throws
219
+ * instead. Re-registering the same export is a no-op, which module reloads rely
220
+ * on.
221
+ */
222
+ export function createRpcRegistry(): Map<string, [modulePath: string, exportName: string]>;
223
+
157
224
  // ============================================================================
158
225
  // Configuration
159
226
  // ============================================================================
@@ -326,6 +393,8 @@ export interface OctaneConfigOptions {
326
393
  adapter?: OctaneAdapter;
327
394
  /** @experimental Compiler-owned configuration shared by all bundler integrations. */
328
395
  compiler?: {
396
+ /** Reject unsafe state updates and ref writes in application-owned modules. @default false */
397
+ strong?: boolean;
329
398
  renderers?: ExperimentalRendererConfigOptions;
330
399
  };
331
400
  router?: {
@@ -385,6 +454,8 @@ export interface ResolvedOctaneConfig {
385
454
  };
386
455
  adapter?: OctaneAdapter;
387
456
  compiler: {
457
+ /** @default false */
458
+ strong: boolean;
388
459
  renderers: ExperimentalResolvedRendererConfig;
389
460
  };
390
461
  router: {