@octanejs/app-core 0.0.9 → 0.0.12

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.9",
3
+ "version": "0.0.12",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -75,14 +75,14 @@
75
75
  }
76
76
  },
77
77
  "dependencies": {
78
- "@ripple-ts/adapter": "^0.3.108",
78
+ "@ripple-ts/adapter": "^0.3.112",
79
79
  "esbuild": "^0.28.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "octane": "0.1.13"
82
+ "octane": "0.1.16"
83
83
  },
84
84
  "devDependencies": {
85
85
  "@types/node": "^24.13.3",
86
- "octane": "0.1.13"
86
+ "octane": "0.1.16"
87
87
  }
88
88
  }
package/src/constants.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
  export const DEFAULT_OUTDIR = 'dist';
3
3
  export const ENTRY_FILENAME = 'entry.js';
4
+ /** Default upper bound for an encoded server-function request body. */
5
+ export const DEFAULT_RPC_MAX_BODY_BYTES = 1_048_576;
4
6
  /** Context.state key a middleware sets to apply a per-request CSP nonce. */
5
7
  export const OCTANE_NONCE_STATE_KEY = 'octane.nonce';
package/src/index.js CHANGED
@@ -16,3 +16,4 @@ export {
16
16
  is_rpc_request,
17
17
  runMiddlewareChain,
18
18
  } from './middleware.js';
19
+ export { handleRpcRequest } from './server/rpc.js';
@@ -15,7 +15,34 @@
15
15
 
16
16
  import { normalizeRendererConfig } from 'octane/compiler/renderers';
17
17
 
18
- import { DEFAULT_OUTDIR } from './constants.js';
18
+ import { DEFAULT_OUTDIR, DEFAULT_RPC_MAX_BODY_BYTES } from './constants.js';
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {string}
23
+ */
24
+ function normalize_rpc_origin(value) {
25
+ if (typeof value !== 'string') {
26
+ throw new Error('[octane] server.rpc.allowedOrigins must contain only HTTP or HTTPS origins.');
27
+ }
28
+
29
+ try {
30
+ const origin = new URL(value);
31
+ if (
32
+ (origin.protocol !== 'http:' && origin.protocol !== 'https:') ||
33
+ origin.username !== '' ||
34
+ origin.password !== '' ||
35
+ origin.pathname !== '/' ||
36
+ origin.search !== '' ||
37
+ origin.hash !== ''
38
+ ) {
39
+ throw new Error('Not an HTTP or HTTPS origin');
40
+ }
41
+ return origin.origin;
42
+ } catch {
43
+ throw new Error('[octane] server.rpc.allowedOrigins must contain only HTTP or HTTPS origins.');
44
+ }
45
+ }
19
46
 
20
47
  /**
21
48
  * @param {unknown} route
@@ -176,6 +203,24 @@ export function resolveOctaneConfig(raw, options = {}) {
176
203
  throw new Error("[octane] server.render must be 'streaming' or 'buffered'.");
177
204
  }
178
205
 
206
+ const rawRpc = raw.server?.rpc;
207
+ if (
208
+ rawRpc !== undefined &&
209
+ (rawRpc === null || typeof rawRpc !== 'object' || Array.isArray(rawRpc))
210
+ ) {
211
+ throw new Error('[octane] server.rpc must be an object when provided.');
212
+ }
213
+ if (
214
+ rawRpc?.maxBodyBytes !== undefined &&
215
+ (!Number.isSafeInteger(rawRpc.maxBodyBytes) || rawRpc.maxBodyBytes <= 0)
216
+ ) {
217
+ throw new Error('[octane] server.rpc.maxBodyBytes must be a positive safe integer.');
218
+ }
219
+ if (rawRpc?.allowedOrigins !== undefined && !Array.isArray(rawRpc.allowedOrigins)) {
220
+ throw new Error('[octane] server.rpc.allowedOrigins must be an array.');
221
+ }
222
+ const allowedRpcOrigins = [...new Set((rawRpc?.allowedOrigins ?? []).map(normalize_rpc_origin))];
223
+
179
224
  // ------------------------------------------------------------------
180
225
  // Apply defaults
181
226
  // ------------------------------------------------------------------
@@ -201,6 +246,10 @@ export function resolveOctaneConfig(raw, options = {}) {
201
246
  server: {
202
247
  trustProxy: raw.server?.trustProxy ?? false,
203
248
  render: raw.server?.render ?? 'streaming',
249
+ rpc: {
250
+ allowedOrigins: allowedRpcOrigins,
251
+ maxBodyBytes: rawRpc?.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
252
+ },
204
253
  },
205
254
  };
206
255
  }
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { createRouter } from './router.js';
26
26
  import { createContext, runMiddlewareChain } from './middleware.js';
27
+ import { handleRpcRequest } from './rpc.js';
27
28
  import { handleServerRoute } from './server-route.js';
28
29
  import { composeHtmlStream } from './html-stream.js';
29
30
  import {
@@ -48,12 +49,7 @@ import {
48
49
  get_route_entry_export_name,
49
50
  get_route_entry_path,
50
51
  } from '../routes.js';
51
- import {
52
- patch_global_fetch,
53
- build_rpc_lookup,
54
- is_rpc_request,
55
- handle_rpc_request,
56
- } from '@ripple-ts/adapter/rpc';
52
+ import { patch_global_fetch, build_rpc_lookup, is_rpc_request } from '@ripple-ts/adapter/rpc';
57
53
 
58
54
  export { resolveOctaneConfig } from '../resolve-config.js';
59
55
 
@@ -127,6 +123,7 @@ export function createHandler(manifest, deps) {
127
123
  const router = createRouter(manifest.routes);
128
124
  const globalMiddlewares = manifest.middlewares ?? [];
129
125
  const trustProxy = manifest.trustProxy ?? false;
126
+ const rpcPolicy = manifest.rpc;
130
127
  const runtime = manifest.runtime;
131
128
  validateSsrTemplate(htmlTemplate);
132
129
  // Also pin the built-template contract up front. The marker is emitted by
@@ -158,17 +155,7 @@ export function createHandler(manifest, deps) {
158
155
  headers: { 'Content-Type': 'application/json' },
159
156
  });
160
157
  }
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
- };
171
- return handle_rpc_request(request, {
158
+ return handleRpcRequest(request, {
172
159
  resolveFunction(/** @type {string} */ hash) {
173
160
  const entry = rpcLookup.get(hash);
174
161
  if (!entry) return null;
@@ -176,8 +163,12 @@ export function createHandler(manifest, deps) {
176
163
  return typeof fn === 'function' ? fn : null;
177
164
  },
178
165
  executeServerFunction,
179
- asyncContext: requestAsyncContext,
166
+ asyncContext,
180
167
  trustProxy,
168
+ middlewares: globalMiddlewares,
169
+ allowedOrigins: rpcPolicy?.allowedOrigins,
170
+ maxBodyBytes: rpcPolicy?.maxBodyBytes,
171
+ platform,
181
172
  });
182
173
  }
183
174
 
@@ -0,0 +1,254 @@
1
+ // @ts-check
2
+ /**
3
+ * Bundler-neutral, security-owning server-function request boundary.
4
+ *
5
+ * The Ripple adapter owns the existing wire path, hash lookup, proxy-origin
6
+ * derivation, and request-scoped fetch primitives. Octane owns which requests
7
+ * may cross its server-function boundary, the bounded body reader, application
8
+ * authorization middleware, and production-safe error disclosure.
9
+ */
10
+
11
+ import { derive_origin } from '@ripple-ts/adapter/rpc';
12
+
13
+ import { DEFAULT_RPC_MAX_BODY_BYTES } from '../constants.js';
14
+ import { createContext, runMiddlewareChain } from './middleware.js';
15
+
16
+ const RPC_PATH_PREFIX = '/_$_ripple_rpc_$_/';
17
+
18
+ /**
19
+ * @param {number} status
20
+ * @param {string} message
21
+ * @param {HeadersInit} [headers]
22
+ * @returns {Response}
23
+ */
24
+ function rpcError(status, message, headers) {
25
+ return new Response(JSON.stringify({ error: message }), {
26
+ status,
27
+ headers: { 'Content-Type': 'application/json; charset=utf-8', ...headers },
28
+ });
29
+ }
30
+
31
+ /**
32
+ * @param {Request} request
33
+ * @param {boolean} trustProxy
34
+ * @param {readonly string[]} allowedOrigins
35
+ * @returns {string | null}
36
+ */
37
+ function allowedRequestOrigin(request, trustProxy, allowedOrigins) {
38
+ let requestOrigin;
39
+ try {
40
+ requestOrigin = new URL(derive_origin(request, trustProxy)).origin;
41
+ } catch {
42
+ return null;
43
+ }
44
+
45
+ const browserOrigin = request.headers.get('origin');
46
+ if (browserOrigin === null) {
47
+ return request.headers.get('sec-fetch-site')?.toLowerCase() === 'cross-site'
48
+ ? null
49
+ : requestOrigin;
50
+ }
51
+
52
+ let normalizedBrowserOrigin;
53
+ try {
54
+ normalizedBrowserOrigin = new URL(browserOrigin).origin;
55
+ } catch {
56
+ return null;
57
+ }
58
+ if (normalizedBrowserOrigin !== browserOrigin) return null;
59
+
60
+ return normalizedBrowserOrigin === requestOrigin ||
61
+ allowedOrigins.includes(normalizedBrowserOrigin)
62
+ ? requestOrigin
63
+ : null;
64
+ }
65
+
66
+ /**
67
+ * @param {Response} response
68
+ * @param {string | null} origin
69
+ * @returns {Response}
70
+ */
71
+ function withRpcCors(response, origin) {
72
+ if (origin === null) return response;
73
+
74
+ const headers = new Headers(response.headers);
75
+ headers.set('Access-Control-Allow-Origin', origin);
76
+ headers.append('Vary', 'Origin');
77
+ return new Response(response.body, {
78
+ status: response.status,
79
+ statusText: response.statusText,
80
+ headers,
81
+ });
82
+ }
83
+
84
+ /**
85
+ * @param {Request} request
86
+ * @param {number} maxBodyBytes
87
+ * @returns {Promise<{ body: string } | { error: Response }>}
88
+ */
89
+ async function readBoundedRpcBody(request, maxBodyBytes) {
90
+ const contentLength = request.headers.get('content-length');
91
+ if (contentLength !== null) {
92
+ if (!/^\d+$/.test(contentLength)) {
93
+ return { error: rpcError(400, 'Invalid RPC request') };
94
+ }
95
+ const declaredLength = Number(contentLength);
96
+ if (!Number.isSafeInteger(declaredLength) || declaredLength > maxBodyBytes) {
97
+ return { error: rpcError(413, 'RPC request exceeds the maximum body size') };
98
+ }
99
+ }
100
+
101
+ if (request.body === null || request.signal.aborted) {
102
+ return { error: rpcError(400, 'Invalid RPC request') };
103
+ }
104
+
105
+ const reader = request.body.getReader();
106
+ const decoder = new TextDecoder('utf-8', { fatal: true });
107
+ let bytesRead = 0;
108
+ let body = '';
109
+
110
+ try {
111
+ while (true) {
112
+ const { done, value } = await reader.read();
113
+ if (done) break;
114
+ bytesRead += value.byteLength;
115
+ if (bytesRead > maxBodyBytes) {
116
+ try {
117
+ await reader.cancel();
118
+ } catch {
119
+ // A hostile stream cannot downgrade an oversized request into another status.
120
+ }
121
+ return { error: rpcError(413, 'RPC request exceeds the maximum body size') };
122
+ }
123
+ body += decoder.decode(value, { stream: true });
124
+ }
125
+ body += decoder.decode();
126
+ } catch {
127
+ return { error: rpcError(400, 'Invalid RPC request') };
128
+ } finally {
129
+ reader.releaseLock();
130
+ }
131
+
132
+ try {
133
+ if (!Array.isArray(JSON.parse(body))) {
134
+ return { error: rpcError(400, 'Invalid server function arguments') };
135
+ }
136
+ } catch {
137
+ return { error: rpcError(400, 'Invalid server function arguments') };
138
+ }
139
+
140
+ return { body };
141
+ }
142
+
143
+ /**
144
+ * @param {unknown} error
145
+ * @returns {boolean}
146
+ */
147
+ function isInvalidRpcPayload(error) {
148
+ return (
149
+ typeof error === 'object' &&
150
+ error !== null &&
151
+ 'code' in error &&
152
+ error.code === 'OCTANE_INVALID_RPC_PAYLOAD'
153
+ );
154
+ }
155
+
156
+ /**
157
+ * Validate and execute an Octane server function behind application middleware.
158
+ *
159
+ * @param {Request} request
160
+ * @param {import('@octanejs/app-core').RpcRequestOptions} options
161
+ * @returns {Promise<Response>}
162
+ */
163
+ export async function handleRpcRequest(request, options) {
164
+ if (request.method === 'OPTIONS') {
165
+ const origin = allowedRequestOrigin(
166
+ request,
167
+ options.trustProxy ?? false,
168
+ options.allowedOrigins ?? [],
169
+ );
170
+ const browserOrigin = request.headers.get('origin');
171
+ if (origin === null || browserOrigin === null || browserOrigin === origin) {
172
+ return rpcError(403, 'Cross-origin RPC requests are not allowed');
173
+ }
174
+ if (request.headers.get('access-control-request-method') !== 'POST') {
175
+ return rpcError(405, 'RPC requests require POST', { Allow: 'POST' });
176
+ }
177
+
178
+ return new Response(null, {
179
+ status: 204,
180
+ headers: {
181
+ 'Access-Control-Allow-Origin': browserOrigin,
182
+ 'Access-Control-Allow-Methods': 'POST',
183
+ 'Access-Control-Allow-Headers':
184
+ request.headers.get('access-control-request-headers') ?? 'content-type',
185
+ Vary: 'Origin, Access-Control-Request-Headers',
186
+ },
187
+ });
188
+ }
189
+
190
+ if (request.method !== 'POST') {
191
+ return rpcError(405, 'RPC requests require POST', { Allow: 'POST' });
192
+ }
193
+
194
+ const contentType = request.headers.get('content-type');
195
+ if (contentType === null || !/^application\/json(?:\s*;|\s*$)/i.test(contentType)) {
196
+ return rpcError(415, 'RPC requests require application/json');
197
+ }
198
+
199
+ const hash = new URL(request.url).pathname.slice(RPC_PATH_PREFIX.length);
200
+ if (!/^[a-f0-9]{8}$/.test(hash)) {
201
+ return rpcError(400, 'Invalid RPC request');
202
+ }
203
+
204
+ const origin = allowedRequestOrigin(
205
+ request,
206
+ options.trustProxy ?? false,
207
+ options.allowedOrigins ?? [],
208
+ );
209
+ if (origin === null) {
210
+ return rpcError(403, 'Cross-origin RPC requests are not allowed');
211
+ }
212
+ const browserOrigin = request.headers.get('origin');
213
+ const corsOrigin = browserOrigin === origin ? null : browserOrigin;
214
+
215
+ const context = createContext(request, {}, options.platform);
216
+ const store =
217
+ options.platform === undefined ? { origin } : { origin, platform: options.platform };
218
+
219
+ try {
220
+ const response = await options.asyncContext.run(store, async () =>
221
+ runMiddlewareChain(
222
+ context,
223
+ options.middlewares ?? [],
224
+ [],
225
+ async () => {
226
+ const fn = await options.resolveFunction(hash);
227
+ if (fn === null) return rpcError(404, 'RPC function not found');
228
+ const payload = await readBoundedRpcBody(
229
+ request,
230
+ options.maxBodyBytes ?? DEFAULT_RPC_MAX_BODY_BYTES,
231
+ );
232
+ if ('error' in payload) return payload.error;
233
+ try {
234
+ const result = await options.executeServerFunction(fn, payload.body);
235
+ return new Response(result, {
236
+ status: 200,
237
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
238
+ });
239
+ } catch (error) {
240
+ if (isInvalidRpcPayload(error)) {
241
+ return rpcError(400, 'Invalid server function arguments');
242
+ }
243
+ throw error;
244
+ }
245
+ },
246
+ [],
247
+ ),
248
+ );
249
+ return withRpcCors(response, corsOrigin);
250
+ } catch (error) {
251
+ console.error('[octane] RPC request error:', error);
252
+ return withRpcCors(rpcError(500, 'Internal Server Error'), corsOrigin);
253
+ }
254
+ }
@@ -277,6 +277,7 @@ export const manifest = {
277
277
  layouts,
278
278
  middlewares: octaneConfig.middlewares,
279
279
  trustProxy: octaneConfig.server.trustProxy,
280
+ rpc: octaneConfig.server.rpc,
280
281
  render: octaneConfig.server.render,
281
282
  rootBoundary,
282
283
  rootBoundaryEntries,
@@ -387,6 +388,7 @@ export const handler = createHandler(
387
388
  layouts,
388
389
  middlewares: octaneConfig.middlewares,
389
390
  trustProxy: octaneConfig.server.trustProxy,
391
+ rpc: octaneConfig.server.rpc,
390
392
  render: octaneConfig.server.render,
391
393
  rootBoundary,
392
394
  rootBoundaryEntries,
package/types/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { RuntimePrimitives } from '@ripple-ts/adapter';
2
+ import type { AsyncContext } from '@ripple-ts/adapter/rpc';
2
3
 
3
4
  // ============================================================================
4
5
  // Shared app/config exports
@@ -138,6 +139,21 @@ export function handleServerRoute(
138
139
  ): Promise<Response>;
139
140
  export function is_rpc_request(pathname: string): boolean;
140
141
 
142
+ /** Security policy and execution dependencies for a server-function request. */
143
+ export interface RpcRequestOptions {
144
+ resolveFunction: (hash: string) => Function | null | Promise<Function | null>;
145
+ executeServerFunction: (fn: Function, body: string) => Promise<string>;
146
+ asyncContext: AsyncContext<{ origin?: string; platform?: unknown }>;
147
+ trustProxy?: boolean;
148
+ middlewares?: Middleware[];
149
+ allowedOrigins?: readonly string[];
150
+ maxBodyBytes?: number;
151
+ platform?: unknown;
152
+ }
153
+
154
+ /** Apply Octane's security policy and global middleware to a server function. */
155
+ export function handleRpcRequest(request: Request, options: RpcRequestOptions): Promise<Response>;
156
+
141
157
  // ============================================================================
142
158
  // Configuration
143
159
  // ============================================================================
@@ -339,6 +355,13 @@ export interface OctaneConfigOptions {
339
355
  * @default false
340
356
  */
341
357
  trustProxy?: boolean;
358
+ /** Security policy for compiler-generated `module server` requests. */
359
+ rpc?: {
360
+ /** Additional exact HTTP(S) origins permitted to call server functions. */
361
+ allowedOrigins?: string[];
362
+ /** Maximum encoded request size in bytes. @default 1048576 */
363
+ maxBodyBytes?: number;
364
+ };
342
365
  /**
343
366
  * Production SSR mode: 'streaming' (default) flushes the shell at
344
367
  * first await and streams suspense segments out-of-order (same engine
@@ -378,6 +401,12 @@ export interface ResolvedOctaneConfig {
378
401
  server: {
379
402
  /** @default false */
380
403
  trustProxy: boolean;
404
+ rpc: {
405
+ /** Additional normalized HTTP(S) origins. @default [] */
406
+ allowedOrigins: string[];
407
+ /** Maximum encoded request size in bytes. @default 1048576 */
408
+ maxBodyBytes: number;
409
+ };
381
410
  /** @default 'streaming' */
382
411
  render: 'streaming' | 'buffered';
383
412
  };
@@ -29,6 +29,8 @@ export interface ServerManifest {
29
29
  middlewares: Middleware[];
30
30
  /** Trust X-Forwarded-* headers when deriving origin for RPC fetch */
31
31
  trustProxy?: boolean;
32
+ /** Validated `module server` origin and body-size policy. */
33
+ rpc?: Partial<ResolvedOctaneConfig['server']['rpc']>;
32
34
  /** 'streaming' (default) renders via renderToReadableStream; 'buffered' awaits everything via prerender */
33
35
  render?: 'streaming' | 'buffered';
34
36
  /** Resolved server-compiled global boundary components. */