@octanejs/vite-plugin 0.1.1 → 0.1.4
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/CHANGELOG.md +234 -0
- package/package.json +8 -3
- package/src/bin/preview.js +20 -5
- package/src/config-entry.js +31 -0
- package/src/constants.js +1 -0
- package/src/index.js +362 -73
- package/src/load-config.js +6 -126
- package/src/project-codegen.js +75 -15
- package/src/resolve-config.js +171 -0
- package/src/routes.js +7 -1
- package/src/server/component-wrappers.js +1 -0
- package/src/server/middleware.js +1 -0
- package/src/server/node-http.js +188 -0
- package/src/server/production.js +276 -16
- package/src/server/render-route.js +86 -38
- package/src/server/router.js +1 -0
- package/src/server/server-route.js +1 -0
- package/src/server/virtual-entry.js +176 -7
- package/tests/_fixtures/app/index.html +11 -0
- package/tests/_fixtures/app/octane.config.ts +14 -0
- package/tests/_fixtures/app/package.json +7 -0
- package/tests/_fixtures/app/src/Layout.tsrx +6 -0
- package/tests/_fixtures/app/src/Page.tsrx +17 -0
- package/tests/_fixtures/app/vite.config.ts +12 -0
- package/tests/handler.test.ts +134 -0
- package/tests/plugin.test.ts +155 -0
- package/tests/production.test.ts +157 -0
- package/tsconfig.typecheck.json +2 -2
- package/types/index.d.ts +102 -13
- package/types/node.d.ts +31 -0
- package/types/production.d.ts +34 -22
package/src/server/production.js
CHANGED
|
@@ -1,26 +1,286 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
/**
|
|
2
3
|
* Production fetch-handler factory + config re-exports.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* bundle calls in production
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* re-exported
|
|
9
|
-
*
|
|
5
|
+
* `createHandler(manifest, deps)` is the runtime entry the generated server
|
|
6
|
+
* bundle (dist/server/entry.js) calls in production. It is designed to be
|
|
7
|
+
* BUNDLED: platform-agnostic (no Node imports — platform capabilities come via
|
|
8
|
+
* `manifest.runtime`), and free of vite / octane-compiler imports (which is why
|
|
9
|
+
* `resolveOctaneConfig` is re-exported from resolve-config.js, not
|
|
10
|
+
* load-config.js).
|
|
11
|
+
*
|
|
12
|
+
* The render path mirrors the DEV middleware's `handleRenderRoute`
|
|
13
|
+
* (server/render-route.js) byte-for-byte in everything hydration can see —
|
|
14
|
+
* the same `renderToReadableStream` engine, the same `#__octane_data` payload
|
|
15
|
+
* (same keys, same order), and the same template-prefix → render-stream →
|
|
16
|
+
* template-suffix assembly — so `hydrateRoot()` adopts a production response
|
|
17
|
+
* exactly as it adopts a dev one. Deliberate differences: the template is the
|
|
18
|
+
* BUILT dist/client/index.html (hashed hydrate script already in place, so
|
|
19
|
+
* nothing is injected per-request), per-route `<link rel=stylesheet/modulepreload>`
|
|
20
|
+
* tags from the client manifest join the head, and render errors produce a
|
|
21
|
+
* plain 500 (no dev stack page). Keep the two files in sync when the shape
|
|
22
|
+
* changes.
|
|
10
23
|
*/
|
|
11
24
|
|
|
12
|
-
|
|
25
|
+
import { createRouter } from './router.js';
|
|
26
|
+
import { createContext, runMiddlewareChain } from './middleware.js';
|
|
27
|
+
import { handleServerRoute } from './server-route.js';
|
|
28
|
+
import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js';
|
|
29
|
+
import {
|
|
30
|
+
get_component_export,
|
|
31
|
+
get_route_entry_export_name,
|
|
32
|
+
get_route_entry_path,
|
|
33
|
+
} from '../routes.js';
|
|
34
|
+
import {
|
|
35
|
+
patch_global_fetch,
|
|
36
|
+
build_rpc_lookup,
|
|
37
|
+
is_rpc_request,
|
|
38
|
+
handle_rpc_request,
|
|
39
|
+
} from '@ripple-ts/adapter/rpc';
|
|
40
|
+
|
|
41
|
+
export { resolveOctaneConfig } from '../resolve-config.js';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @typedef {import('@octanejs/vite-plugin').RenderRoute} RenderRoute
|
|
45
|
+
* @typedef {import('@octanejs/vite-plugin').Middleware} Middleware
|
|
46
|
+
* @typedef {import('@octanejs/vite-plugin').Context} Context
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
@import { ServerManifest, HandlerOptions, ClientAssetEntry } from '../../types/production.d.ts'
|
|
50
|
+
*/
|
|
13
51
|
|
|
14
52
|
/**
|
|
15
|
-
* Create the production
|
|
53
|
+
* Create the production request handler from a manifest.
|
|
54
|
+
*
|
|
55
|
+
* The returned function is a standard Web `fetch`-style handler:
|
|
56
|
+
* `(request: Request) => Promise<Response>` — the generated server entry boots
|
|
57
|
+
* it behind the adapter's `serve()` (or the built-in Node server), and
|
|
58
|
+
* serverless wrappers import it directly.
|
|
16
59
|
*
|
|
17
|
-
* @param {
|
|
18
|
-
* @param {
|
|
19
|
-
* @returns {
|
|
60
|
+
* @param {ServerManifest} manifest
|
|
61
|
+
* @param {HandlerOptions} deps
|
|
62
|
+
* @returns {(request: Request) => Promise<Response>}
|
|
63
|
+
*/
|
|
64
|
+
export function createHandler(manifest, deps) {
|
|
65
|
+
const { renderToReadableStream, prerender, htmlTemplate, executeServerFunction } = deps;
|
|
66
|
+
const router = createRouter(manifest.routes);
|
|
67
|
+
const globalMiddlewares = manifest.middlewares ?? [];
|
|
68
|
+
const trustProxy = manifest.trustProxy ?? false;
|
|
69
|
+
const runtime = manifest.runtime;
|
|
70
|
+
|
|
71
|
+
// RPC lookup for `module server` functions (hash → server function). Empty
|
|
72
|
+
// today — the octane compiler does not emit `module server` modules yet —
|
|
73
|
+
// but the wiring matches the dev middleware so it lights up when it does.
|
|
74
|
+
const rpcLookup =
|
|
75
|
+
manifest.rpcModules && runtime ? build_rpc_lookup(manifest.rpcModules, runtime.hash) : null;
|
|
76
|
+
|
|
77
|
+
// Request-scoped async context + same-origin fetch short-circuit: fetch()
|
|
78
|
+
// during SSR that resolves to this origin routes through the handler
|
|
79
|
+
// in-process instead of a network round-trip.
|
|
80
|
+
const asyncContext = runtime?.createAsyncContext();
|
|
81
|
+
const fetchHandle = asyncContext ? patch_global_fetch(asyncContext) : null;
|
|
82
|
+
|
|
83
|
+
const handler = async function handler(/** @type {Request} */ request) {
|
|
84
|
+
const url = new URL(request.url);
|
|
85
|
+
const method = request.method;
|
|
86
|
+
|
|
87
|
+
if (is_rpc_request(url.pathname)) {
|
|
88
|
+
if (!rpcLookup || !asyncContext) {
|
|
89
|
+
return new Response(JSON.stringify({ error: 'RPC is not configured' }), {
|
|
90
|
+
status: 404,
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return handle_rpc_request(request, {
|
|
95
|
+
resolveFunction(/** @type {string} */ hash) {
|
|
96
|
+
const entry = rpcLookup.get(hash);
|
|
97
|
+
if (!entry) return null;
|
|
98
|
+
const fn = entry.serverObj[entry.funcName];
|
|
99
|
+
return typeof fn === 'function' ? fn : null;
|
|
100
|
+
},
|
|
101
|
+
executeServerFunction,
|
|
102
|
+
asyncContext,
|
|
103
|
+
trustProxy,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const match = router.match(method, url.pathname);
|
|
108
|
+
if (!match) {
|
|
109
|
+
// Static assets never reach here (the static layer — the built-in Node
|
|
110
|
+
// server, or the platform's file serving — runs first); an app with a
|
|
111
|
+
// catch-all RenderRoute matches everything else, so this is only hit
|
|
112
|
+
// when no catch-all exists.
|
|
113
|
+
return new Response('Not Found', { status: 404 });
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const context = createContext(request, match.params);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
if (match.route.type === 'render') {
|
|
120
|
+
return await runMiddlewareChain(
|
|
121
|
+
context,
|
|
122
|
+
globalMiddlewares,
|
|
123
|
+
match.route.before || [],
|
|
124
|
+
async () => renderRoute(/** @type {RenderRoute} */ (match.route), context),
|
|
125
|
+
[],
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return await handleServerRoute(match.route, context, globalMiddlewares);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
console.error('[@octanejs/vite-plugin] Request error:', error);
|
|
131
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
fetchHandle?.set_handler(handler);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render a RenderRoute — the production twin of dev's `handleRenderRoute`.
|
|
139
|
+
*
|
|
140
|
+
* @param {RenderRoute} route
|
|
141
|
+
* @param {Context} context
|
|
142
|
+
* @returns {Promise<Response>}
|
|
143
|
+
*/
|
|
144
|
+
async function renderRoute(route, context) {
|
|
145
|
+
const entryPath = get_route_entry_path(route.entry);
|
|
146
|
+
const exportName = get_route_entry_export_name(route.entry);
|
|
147
|
+
const PageComponent = entryPath
|
|
148
|
+
? get_component_export(manifest.components[entryPath] ?? {}, exportName)
|
|
149
|
+
: null;
|
|
150
|
+
if (!PageComponent) {
|
|
151
|
+
throw new Error(`Component not found for route ${route.path}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Identical props to dev: `{ params, url }`, url origin-free so the client
|
|
155
|
+
// re-renders the exact string.
|
|
156
|
+
const requestUrl = context.url.pathname + context.url.search;
|
|
157
|
+
const pageProps = { params: context.params, url: requestUrl };
|
|
158
|
+
|
|
159
|
+
let RootComponent;
|
|
160
|
+
if (route.layout) {
|
|
161
|
+
const LayoutComponent = get_component_export(manifest.layouts[route.layout] ?? {}, undefined);
|
|
162
|
+
if (!LayoutComponent) {
|
|
163
|
+
throw new Error(`No layout component found for ${route.layout}`);
|
|
164
|
+
}
|
|
165
|
+
RootComponent = createLayoutWrapper(
|
|
166
|
+
/** @type {any} */ (LayoutComponent),
|
|
167
|
+
/** @type {any} */ (PageComponent),
|
|
168
|
+
pageProps,
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
RootComponent = createPropsWrapper(/** @type {any} */ (PageComponent), pageProps);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The hydration payload — SAME keys, SAME order as dev render-route.js, so
|
|
175
|
+
// the data script is byte-identical between dev and production.
|
|
176
|
+
const routeData = JSON.stringify({
|
|
177
|
+
entry: entryPath,
|
|
178
|
+
exportName: exportName ?? null,
|
|
179
|
+
layout: route.layout ?? null,
|
|
180
|
+
routeIndex: getRenderRouteIndex(manifest.routes, route),
|
|
181
|
+
params: context.params,
|
|
182
|
+
url: requestUrl,
|
|
183
|
+
preHydrate: manifest.preHydrate ?? null,
|
|
184
|
+
});
|
|
185
|
+
const dataScript = `<script id="__octane_data" type="application/json">${escapeScript(routeData)}</script>`;
|
|
186
|
+
|
|
187
|
+
// Per-route asset hints from the client manifest: stylesheet links so
|
|
188
|
+
// page CSS applies before hydration, modulepreload so the page chunk
|
|
189
|
+
// downloads in parallel with the hydrate entry (which the template's own
|
|
190
|
+
// script tag already references).
|
|
191
|
+
/** @type {string[]} */
|
|
192
|
+
const preloadTags = [];
|
|
193
|
+
const entryAssets = entryPath ? manifest.clientAssets?.[entryPath] : undefined;
|
|
194
|
+
if (entryAssets) {
|
|
195
|
+
for (const cssFile of entryAssets.css) {
|
|
196
|
+
preloadTags.push(`<link rel="stylesheet" href="/${cssFile}">`);
|
|
197
|
+
}
|
|
198
|
+
if (entryAssets.js) {
|
|
199
|
+
preloadTags.push(`<link rel="modulepreload" href="/${entryAssets.js}">`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const headContent = [...preloadTags, dataScript].join('\n');
|
|
204
|
+
const html = htmlTemplate.replace('<!--ssr-head-->', headContent);
|
|
205
|
+
|
|
206
|
+
const status = route.status ?? 200;
|
|
207
|
+
const headers = { 'Content-Type': 'text/html; charset=utf-8' };
|
|
208
|
+
|
|
209
|
+
const splitAt = html.indexOf('<!--ssr-body-->');
|
|
210
|
+
if (splitAt === -1) {
|
|
211
|
+
return new Response(html, { status, headers });
|
|
212
|
+
}
|
|
213
|
+
const prefix = html.slice(0, splitAt);
|
|
214
|
+
const suffix = html.slice(splitAt + '<!--ssr-body-->'.length);
|
|
215
|
+
|
|
216
|
+
if (manifest.render === 'buffered') {
|
|
217
|
+
// Await-everything fallback (`prerender` from octane/static): no
|
|
218
|
+
// streaming, one document. The deduped scoped-style tags lead the body
|
|
219
|
+
// markup inside #root — the same position they hold in the streamed
|
|
220
|
+
// shell — so hydrateRoot's leading-style skip applies unchanged.
|
|
221
|
+
const { html: body, css } = await prerender(RootComponent, undefined, {
|
|
222
|
+
onError(/** @type {unknown} */ error) {
|
|
223
|
+
console.error('[octane] SSR render error:', error);
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
return new Response(prefix + css + body + suffix, { status, headers });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Streaming (default): shell flushes at first await, suspense segments
|
|
230
|
+
// stream out-of-order behind it — identical to dev.
|
|
231
|
+
/** @type {ReadableStream<Uint8Array>} */
|
|
232
|
+
const renderStream = await renderToReadableStream(RootComponent, undefined, {
|
|
233
|
+
onError(/** @type {unknown} */ error) {
|
|
234
|
+
console.error('[octane] SSR render error:', error);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const encoder = new TextEncoder();
|
|
239
|
+
const body = new ReadableStream({
|
|
240
|
+
async start(controller) {
|
|
241
|
+
controller.enqueue(encoder.encode(prefix));
|
|
242
|
+
const reader = renderStream.getReader();
|
|
243
|
+
try {
|
|
244
|
+
while (true) {
|
|
245
|
+
const { done, value } = await reader.read();
|
|
246
|
+
if (done) break;
|
|
247
|
+
controller.enqueue(value);
|
|
248
|
+
}
|
|
249
|
+
controller.enqueue(encoder.encode(suffix));
|
|
250
|
+
controller.close();
|
|
251
|
+
} catch (error) {
|
|
252
|
+
controller.error(error);
|
|
253
|
+
} finally {
|
|
254
|
+
reader.releaseLock();
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
cancel(reason) {
|
|
258
|
+
return renderStream.cancel(reason);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
return new Response(body, { status, headers });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return handler;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* @param {import('@octanejs/vite-plugin').Route[]} routes
|
|
270
|
+
* @param {RenderRoute} route
|
|
271
|
+
* @returns {number | undefined}
|
|
272
|
+
*/
|
|
273
|
+
function getRenderRouteIndex(routes, route) {
|
|
274
|
+
const renderRoutes = routes.filter((r) => r.type === 'render');
|
|
275
|
+
const index = renderRoutes.indexOf(route);
|
|
276
|
+
return index === -1 ? undefined : index;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Escape script content to prevent XSS in the inline JSON data block.
|
|
281
|
+
* @param {string} str
|
|
282
|
+
* @returns {string}
|
|
20
283
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
'[@octanejs/vite-plugin] Production build (createHandler) is Phase 2 — not yet implemented. ' +
|
|
24
|
-
'Dev SSR works via `vite dev`.',
|
|
25
|
-
);
|
|
284
|
+
function escapeScript(str) {
|
|
285
|
+
return str.replace(/</g, '\\u003c').replace(/>/g, '\\u003e');
|
|
26
286
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @ts-check
|
|
1
2
|
import { readFile } from 'node:fs/promises';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { createLayoutWrapper, createPropsWrapper } from './component-wrappers.js';
|
|
@@ -15,19 +16,27 @@ import {
|
|
|
15
16
|
*/
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
|
-
*
|
|
19
|
-
* `<style data-octane="hash">…</style>` string (NOT a Set<string> needing a
|
|
20
|
-
* `get_css_for_hashes` lookup like Ripple). So CSS handling here is identity.
|
|
19
|
+
* Handle SSR rendering for a RenderRoute (dev) — STREAMING.
|
|
21
20
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* The render uses octane's `renderToReadableStream` (octane/server): the shell
|
|
22
|
+
* (with `@pending` fallbacks for anything still suspended) flushes as soon as
|
|
23
|
+
* it is ready, and each suspense boundary streams later as a hidden segment +
|
|
24
|
+
* an inline `$OCTRC` swap script, so a slow `use(thenable)` no longer blocks
|
|
25
|
+
* TTFB the way the old buffered `prerender` did. Consequences of streaming:
|
|
26
|
+
*
|
|
27
|
+
* - Scoped CSS rides the STREAM (the shell emits its deduped
|
|
28
|
+
* `<style data-octane>` tags ahead of the body markup, per-wave styles ride
|
|
29
|
+
* their segment chunk), so nothing is spliced into `<!--ssr-head-->`
|
|
30
|
+
* anymore — only the hydration data script goes there. `hydrateRoot()`
|
|
31
|
+
* skips the shell's leading style tags when adopting.
|
|
32
|
+
* - A render error BEFORE the shell completes still produces the dev 500
|
|
33
|
+
* page (`renderToReadableStream` rejects on shell errors). An error AFTER
|
|
34
|
+
* the shell (a rejected `use(thenable)` with no `@catch`) can't change the
|
|
35
|
+
* status — the stream ends with `$OCTRX` markers and hydration
|
|
36
|
+
* client-renders the affected boundaries.
|
|
26
37
|
*/
|
|
27
38
|
|
|
28
39
|
/**
|
|
29
|
-
* Handle SSR rendering for a RenderRoute (dev).
|
|
30
|
-
*
|
|
31
40
|
* @param {RenderRoute} route
|
|
32
41
|
* @param {Context} context
|
|
33
42
|
* @param {ViteDevServer} vite
|
|
@@ -43,10 +52,10 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
|
|
|
43
52
|
/** @type {any} */ (globalThis).rpc_modules = new Map();
|
|
44
53
|
}
|
|
45
54
|
|
|
46
|
-
// Load the octane
|
|
47
|
-
// (no ssrComponent injection — the root must NOT be
|
|
48
|
-
//
|
|
49
|
-
const {
|
|
55
|
+
// Load the octane streaming renderer. The wrappers call components
|
|
56
|
+
// directly (no ssrComponent injection — the root must NOT be
|
|
57
|
+
// marker-wrapped).
|
|
58
|
+
const { renderToReadableStream } = await vite.ssrLoadModule('octane/server');
|
|
50
59
|
|
|
51
60
|
// Load the page component (compiled in server mode by octane()).
|
|
52
61
|
const entryPath = get_route_entry_path(route.entry);
|
|
@@ -60,9 +69,14 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
|
|
|
60
69
|
throw new Error(`No component found for route ${route.path}`);
|
|
61
70
|
}
|
|
62
71
|
|
|
63
|
-
// Build the component tree (with optional layout).
|
|
72
|
+
// Build the component tree (with optional layout). Every RenderRoute
|
|
73
|
+
// component receives the request `url` (pathname + search — origin-free so
|
|
74
|
+
// the identical string re-renders on the client) alongside its `params`,
|
|
75
|
+
// so an app-level router can match without baking the URL into per-route
|
|
76
|
+
// entry exports.
|
|
64
77
|
let RootComponent;
|
|
65
|
-
const
|
|
78
|
+
const requestUrl = context.url.pathname + context.url.search;
|
|
79
|
+
const pageProps = { params: context.params, url: requestUrl };
|
|
66
80
|
|
|
67
81
|
if (route.layout) {
|
|
68
82
|
const layoutModule = await vite.ssrLoadModule(route.layout);
|
|
@@ -81,35 +95,24 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
|
|
|
81
95
|
RootComponent = createPropsWrapper(/** @type {any} */ (PageComponent), pageProps);
|
|
82
96
|
}
|
|
83
97
|
|
|
84
|
-
// Render to HTML. octane render() is async; head is '' in Phase 1
|
|
85
|
-
// (no document-head API yet). `body` already contains any inline
|
|
86
|
-
// <script data-octane-suspense> seed.
|
|
87
|
-
/** @type {RenderResult} */
|
|
88
|
-
const { head, body, css } = await render(RootComponent);
|
|
89
|
-
|
|
90
|
-
// CSS is already a ready <style> string (or '') — identity, no re-wrapping.
|
|
91
|
-
const cssContent = css || '';
|
|
92
|
-
|
|
93
98
|
// Build head content with hydration data. The client entry is CONFIG-FREE
|
|
94
99
|
// (importing octane.config.ts into the browser would drag the plugin + the
|
|
95
100
|
// server adapter — and their `node:fs` imports — into the client graph and
|
|
96
101
|
// break at module-eval). So everything the client needs to pick + import
|
|
97
102
|
// the page/layout is serialized HERE: entry path, export name, layout path,
|
|
98
|
-
//
|
|
103
|
+
// params, the request url, and the optional preHydrate module the client
|
|
104
|
+
// entry awaits before hydrateRoot. routeIndex stays for debugging /
|
|
105
|
+
// Phase-2 static maps.
|
|
99
106
|
const routeData = JSON.stringify({
|
|
100
107
|
entry: entryPath,
|
|
101
108
|
exportName: get_route_entry_export_name(route.entry) ?? null,
|
|
102
109
|
layout: route.layout ?? null,
|
|
103
110
|
routeIndex: getRenderRouteIndex(octaneConfig, route),
|
|
104
111
|
params: context.params,
|
|
112
|
+
url: requestUrl,
|
|
113
|
+
preHydrate: octaneConfig?.router.preHydrate ?? null,
|
|
105
114
|
});
|
|
106
|
-
const headContent =
|
|
107
|
-
head,
|
|
108
|
-
cssContent,
|
|
109
|
-
`<script id="__octane_data" type="application/json">${escapeScript(routeData)}</script>`,
|
|
110
|
-
]
|
|
111
|
-
.filter(Boolean)
|
|
112
|
-
.join('\n');
|
|
115
|
+
const headContent = `<script id="__octane_data" type="application/json">${escapeScript(routeData)}</script>`;
|
|
113
116
|
|
|
114
117
|
// Load and process index.html template.
|
|
115
118
|
const templatePath = join(vite.config.root, 'index.html');
|
|
@@ -118,19 +121,64 @@ export async function handleRenderRoute(route, context, vite, octaneConfig) {
|
|
|
118
121
|
// Apply Vite's HTML transforms (HMR client, module resolution, etc.).
|
|
119
122
|
template = await vite.transformIndexHtml(context.url.pathname, template);
|
|
120
123
|
|
|
121
|
-
|
|
122
|
-
let html = template.replace('<!--ssr-head-->', headContent).replace('<!--ssr-body-->', body);
|
|
124
|
+
let html = template.replace('<!--ssr-head-->', headContent);
|
|
123
125
|
|
|
124
126
|
// Inject the hydration entry before </body>.
|
|
125
127
|
const hydrationScript = `<script type="module" src="/@id/virtual:octane-hydrate"></script>`;
|
|
126
128
|
html = html.replace('</body>', `${hydrationScript}\n</body>`);
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
// Start the render. This await resolves at SHELL-ready (so a synchronous
|
|
131
|
+
// render error still falls into the catch below and produces the dev 500
|
|
132
|
+
// page); segments keep flushing through the returned stream afterwards.
|
|
133
|
+
/** @type {ReadableStream<Uint8Array>} */
|
|
134
|
+
const renderStream = await renderToReadableStream(RootComponent, undefined, {
|
|
135
|
+
onError(/** @type {unknown} */ error) {
|
|
136
|
+
if (error instanceof Error) vite.ssrFixStacktrace(error);
|
|
137
|
+
console.error('[octane] SSR render error:', error);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const status = route.status ?? 200;
|
|
142
|
+
const headers = { 'Content-Type': 'text/html; charset=utf-8' };
|
|
143
|
+
|
|
144
|
+
// No body placeholder → nothing to stream into; cancel the render and
|
|
145
|
+
// serve the transformed template (matches the old buffered behavior).
|
|
146
|
+
const splitAt = html.indexOf('<!--ssr-body-->');
|
|
147
|
+
if (splitAt === -1) {
|
|
148
|
+
await renderStream.cancel();
|
|
149
|
+
return new Response(html, { status, headers });
|
|
150
|
+
}
|
|
151
|
+
const prefix = html.slice(0, splitAt);
|
|
152
|
+
const suffix = html.slice(splitAt + '<!--ssr-body-->'.length);
|
|
153
|
+
|
|
154
|
+
// Template prefix → render stream (shell, then out-of-order segments) →
|
|
155
|
+
// template suffix. The hydration <script> is in the SUFFIX, so by the time
|
|
156
|
+
// the browser requests the entry every segment is already in the DOM.
|
|
157
|
+
const encoder = new TextEncoder();
|
|
158
|
+
const body = new ReadableStream({
|
|
159
|
+
async start(controller) {
|
|
160
|
+
controller.enqueue(encoder.encode(prefix));
|
|
161
|
+
const reader = renderStream.getReader();
|
|
162
|
+
try {
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read();
|
|
165
|
+
if (done) break;
|
|
166
|
+
controller.enqueue(value);
|
|
167
|
+
}
|
|
168
|
+
controller.enqueue(encoder.encode(suffix));
|
|
169
|
+
controller.close();
|
|
170
|
+
} catch (error) {
|
|
171
|
+
controller.error(error);
|
|
172
|
+
} finally {
|
|
173
|
+
reader.releaseLock();
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
cancel(reason) {
|
|
177
|
+
return renderStream.cancel(reason);
|
|
132
178
|
},
|
|
133
179
|
});
|
|
180
|
+
|
|
181
|
+
return new Response(body, { status, headers });
|
|
134
182
|
} catch (error) {
|
|
135
183
|
console.error('[octane] SSR render error:', error);
|
|
136
184
|
|
package/src/server/router.js
CHANGED