@octanejs/app-core 0.0.15 → 0.0.17
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 +3 -3
- package/src/index.js +2 -0
- package/src/resolve-config.js +4 -0
- package/src/server/production.js +34 -0
- package/src/server/request-context.js +72 -0
- package/src/server/rpc-registry.js +62 -0
- package/src/server/rpc.js +17 -1
- package/types/index.d.ts +72 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
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.
|
|
82
|
+
"octane": "0.1.21"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "0.1.
|
|
86
|
+
"octane": "0.1.21"
|
|
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';
|
package/src/resolve-config.js
CHANGED
|
@@ -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: {
|
package/src/server/production.js
CHANGED
|
@@ -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
|
|
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: {
|