@octanejs/app-core 0.0.9 → 0.0.13
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 +4 -4
- package/src/constants.js +2 -0
- package/src/index.js +1 -0
- package/src/resolve-config.js +50 -1
- package/src/server/production.js +40 -21
- package/src/server/rpc.js +254 -0
- package/src/server/server-entry.js +2 -0
- package/types/index.d.ts +29 -0
- package/types/production.d.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@octanejs/app-core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
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.
|
|
78
|
+
"@ripple-ts/adapter": "^0.3.112",
|
|
79
79
|
"esbuild": "^0.28.1"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
|
-
"octane": "0.1.
|
|
82
|
+
"octane": "0.1.17"
|
|
83
83
|
},
|
|
84
84
|
"devDependencies": {
|
|
85
85
|
"@types/node": "^24.13.3",
|
|
86
|
-
"octane": "0.1.
|
|
86
|
+
"octane": "0.1.17"
|
|
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
package/src/resolve-config.js
CHANGED
|
@@ -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
|
}
|
package/src/server/production.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
166
|
+
asyncContext,
|
|
180
167
|
trustProxy,
|
|
168
|
+
middlewares: globalMiddlewares,
|
|
169
|
+
allowedOrigins: rpcPolicy?.allowedOrigins,
|
|
170
|
+
maxBodyBytes: rpcPolicy?.maxBodyBytes,
|
|
171
|
+
platform,
|
|
181
172
|
});
|
|
182
173
|
}
|
|
183
174
|
|
|
@@ -288,39 +279,67 @@ export function createHandler(manifest, deps) {
|
|
|
288
279
|
}
|
|
289
280
|
|
|
290
281
|
const headContent = [...preloadTags, dataScript].join('\n');
|
|
291
|
-
const
|
|
282
|
+
const noncedTemplate = applyHydrationNonce(htmlTemplate, nonce);
|
|
292
283
|
|
|
293
284
|
const status = route.status ?? 200;
|
|
294
285
|
const headers = { 'Content-Type': 'text/html; charset=utf-8' };
|
|
295
286
|
|
|
296
|
-
|
|
287
|
+
// `headChannel: 'separate'` below: this handler renders the ROUTE into the
|
|
288
|
+
// template's `<div id="root">`, not a document, so core's default fold has
|
|
289
|
+
// no `</head>` to target and would prepend hoisted `<title>`/`<meta>`/
|
|
290
|
+
// `<link>` into the body, where a title loses to the template's and a
|
|
291
|
+
// canonical or description is ignored. Splicing at `<!--ssr-head-->`
|
|
292
|
+
// instead is also what makes hydration's `document.head` adoption find the
|
|
293
|
+
// ownership markers rather than creating duplicates.
|
|
294
|
+
//
|
|
295
|
+
// The replacement is a FUNCTION, not a string: a string replacement makes
|
|
296
|
+
// `$&`, `` $` ``, `$'` and `$1` in the inserted text expand against the
|
|
297
|
+
// match, and this text now carries author-controlled metadata as well as
|
|
298
|
+
// the serialized route data.
|
|
299
|
+
/** @param {string} hoistedHead */
|
|
300
|
+
const splitAroundBody = (hoistedHead) =>
|
|
301
|
+
splitSsrTemplate(noncedTemplate.replace('<!--ssr-head-->', () => headContent + hoistedHead));
|
|
297
302
|
|
|
298
303
|
if (manifest.render === 'buffered') {
|
|
299
304
|
// Await-everything fallback (`prerender` from octane/static): no
|
|
300
305
|
// streaming, one document. The deduped scoped-style tags lead the body
|
|
301
306
|
// markup inside #root — the same position they hold in the streamed
|
|
302
307
|
// shell — so hydrateRoot's leading-style skip applies unchanged.
|
|
303
|
-
const {
|
|
308
|
+
const {
|
|
309
|
+
html: body,
|
|
310
|
+
css,
|
|
311
|
+
head,
|
|
312
|
+
} = await prerender(RootComponent, undefined, {
|
|
304
313
|
nonce: nonce ?? undefined,
|
|
314
|
+
headChannel: 'separate',
|
|
305
315
|
signal: context.request.signal,
|
|
306
316
|
onError(/** @type {unknown} */ error) {
|
|
307
317
|
console.error('[octane] SSR render error:', error);
|
|
308
318
|
},
|
|
309
319
|
});
|
|
320
|
+
const [prefix, suffix] = splitAroundBody(head ?? '');
|
|
310
321
|
return new Response(prefix + css + body + suffix, { status, headers });
|
|
311
322
|
}
|
|
312
323
|
|
|
313
324
|
// Streaming (default): shell flushes at first await, suspense segments
|
|
314
325
|
// stream out-of-order behind it — identical to dev.
|
|
326
|
+
let hoistedHead = '';
|
|
315
327
|
/** @type {ReadableStream<Uint8Array>} */
|
|
316
328
|
const renderStream = await renderToReadableStream(RootComponent, undefined, {
|
|
317
329
|
nonce: nonce ?? undefined,
|
|
330
|
+
headChannel: 'separate',
|
|
331
|
+
// Fires before the shell is written, so the metadata is in hand before
|
|
332
|
+
// the template prefix (which carries `<head>`) is composed below.
|
|
333
|
+
onHeadReady(/** @type {string} */ head) {
|
|
334
|
+
hoistedHead = head;
|
|
335
|
+
},
|
|
318
336
|
signal: context.request.signal,
|
|
319
337
|
onError(/** @type {unknown} */ error) {
|
|
320
338
|
console.error('[octane] SSR render error:', error);
|
|
321
339
|
},
|
|
322
340
|
});
|
|
323
341
|
|
|
342
|
+
const [prefix, suffix] = splitAroundBody(hoistedHead);
|
|
324
343
|
const body = composeHtmlStream(prefix, renderStream, suffix);
|
|
325
344
|
|
|
326
345
|
return new Response(body, { status, headers });
|
|
@@ -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
|
};
|
package/types/production.d.ts
CHANGED
|
@@ -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. */
|