@octanejs/rsbuild-plugin 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # `@octanejs/rsbuild-plugin`
2
+
3
+ Full Octane app integration for Rsbuild 2.x: Rspack source compilation,
4
+ routing, streaming dev SSR, hydration, `module server` RPC, production
5
+ client/server environments, preview, and deployment adapters.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pnpm add octane @octanejs/rsbuild-plugin
11
+ pnpm add -D @rsbuild/core
12
+ ```
13
+
14
+ ```ts
15
+ // rsbuild.config.ts
16
+ import { defineConfig } from '@rsbuild/core';
17
+ import { pluginOctane } from '@octanejs/rsbuild-plugin';
18
+
19
+ export default defineConfig({
20
+ plugins: [pluginOctane()],
21
+ });
22
+ ```
23
+
24
+ Without an `octane.config.ts` containing routes, the plugin only installs the
25
+ Octane compiler and preserves your own Rsbuild entries. Use the lower-level
26
+ `@octanejs/rspack-plugin` directly when you do not want Rsbuild.
27
+
28
+ ## Routing and SSR
29
+
30
+ ```ts
31
+ // octane.config.ts
32
+ import { defineConfig, RenderRoute, ServerRoute } from '@octanejs/rsbuild-plugin';
33
+
34
+ export default defineConfig({
35
+ router: {
36
+ routes: [
37
+ new RenderRoute({ path: '/', entry: '/src/Home.tsrx' }),
38
+ new ServerRoute({
39
+ path: '/api/health',
40
+ handler: () => Response.json({ ok: true }),
41
+ }),
42
+ ],
43
+ },
44
+ });
45
+ ```
46
+
47
+ `index.html` must contain `<!--ssr-head-->` in `<head>` and
48
+ `<!--ssr-body-->` inside `<div id="root">`. In app mode the plugin creates a
49
+ `web` hydration environment and a `node` SSR environment. Override their names
50
+ with `clientEnvironment` and `serverEnvironment` when composing a larger
51
+ Rsbuild setup.
52
+
53
+ ```sh
54
+ pnpm rsbuild dev
55
+ pnpm rsbuild build
56
+ pnpm octane-rsbuild-preview
57
+ ```
58
+
59
+ Production assets are written to `dist/client`; the self-contained ESM server,
60
+ SSR template, and route asset map are written to `dist/server`. Change the
61
+ shared root with `build.outDir` in `octane.config.ts`. The generated server
62
+ exports `handler` and `nodeHandler`, auto-boots under Node, and invokes a
63
+ configured adapter after both environments finish.
64
+
65
+ `build.target` applies to both application transforms and Rspack's generated
66
+ runtime. Use one ES level (`es2018`, `es2022`, and so on), `modules`, `false`, or
67
+ esbuild-style browser targets such as `['chrome100', 'firefox100']`. ES levels
68
+ and browser targets cannot be mixed in the same array.
69
+
70
+ Options are declarative and cache-stable:
71
+
72
+ - `hmr` controls browser component handoff;
73
+ - `parallelUse` controls the compiler's parallel `use()` transform;
74
+ - `exclude` skips path fragments in the plain `.ts`/`.js` hook-slot pass; and
75
+ - `clientEnvironment` / `serverEnvironment` rename the generated environments.
76
+
77
+ App mode currently serves from the root path and uses Rsbuild's default asset
78
+ prefix. Keep `server.base` at `/` and `output.assetPrefix` at `auto` or `/`; for
79
+ a subpath deployment, rewrite that prefix to the app root in the hosting proxy.
80
+ Changing routes or server-render settings in `octane.config.ts` triggers a full
81
+ browser reload within an already enabled app. Changing `build.target`,
82
+ `build.outDir`, `build.minify`, or adding the first route reshapes Rsbuild
83
+ environments and requires a dev-server restart.
84
+
85
+ The package follows the Rsbuild plugin model consumed by Rspeedy, but this
86
+ release targets Octane's DOM renderer. Lynx renderer selection is future work.
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@octanejs/rsbuild-plugin",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "description": "Rsbuild metaframework plugin for Octane (Rspack compiler, routing, SSR, hydration, and production builds)",
10
+ "author": {
11
+ "name": "Dominic Gannaway",
12
+ "email": "dg@domgan.com"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/octanejs/octane.git",
20
+ "directory": "packages/rsbuild-plugin-octane"
21
+ },
22
+ "files": [
23
+ "src",
24
+ "types",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "module": "src/index.js",
29
+ "main": "src/index.js",
30
+ "bin": {
31
+ "octane-rsbuild-preview": "src/bin/preview.js"
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "types": "./types/index.d.ts",
36
+ "import": "./src/index.js",
37
+ "default": "./src/index.js"
38
+ },
39
+ "./production": {
40
+ "types": "./types/production.d.ts",
41
+ "import": "./src/production.js",
42
+ "default": "./src/production.js"
43
+ },
44
+ "./node": {
45
+ "types": "./types/node.d.ts",
46
+ "import": "./src/node.js",
47
+ "default": "./src/node.js"
48
+ }
49
+ },
50
+ "dependencies": {
51
+ "@octanejs/app-core": "0.0.1",
52
+ "@octanejs/rspack-plugin": "0.1.0"
53
+ },
54
+ "peerDependencies": {
55
+ "@rsbuild/core": "^2.0.0",
56
+ "octane": "0.1.5"
57
+ },
58
+ "devDependencies": {
59
+ "@rsbuild/core": "^2.1.5",
60
+ "@rspack/core": "^2.1.3",
61
+ "@types/node": "^24.3.0",
62
+ "type-fest": "^5.6.0",
63
+ "vitest": "^4.1.9",
64
+ "octane": "0.1.5"
65
+ }
66
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ import { spawn } from 'node:child_process';
4
+ import path from 'node:path';
5
+
6
+ import { loadOctaneConfig, octaneConfigExists } from '@octanejs/app-core/config-loader';
7
+
8
+ const args = process.argv.slice(2);
9
+ let root = process.cwd();
10
+ let entryOverride;
11
+ for (let index = 0; index < args.length; index++) {
12
+ const arg = args[index];
13
+ if (arg === '--root') {
14
+ const value = args[++index];
15
+ if (!value) throw new Error('--root requires a directory.');
16
+ root = path.resolve(value);
17
+ } else if (arg === '--help' || arg === '-h') {
18
+ console.log('Usage: octane-rsbuild-preview [--root <dir>] [server-entry]');
19
+ process.exit(0);
20
+ } else if (arg.startsWith('-')) {
21
+ throw new Error(`Unknown option: ${arg}`);
22
+ } else if (!entryOverride) {
23
+ entryOverride = arg;
24
+ } else {
25
+ throw new Error(`Unexpected argument: ${arg}`);
26
+ }
27
+ }
28
+
29
+ const config = octaneConfigExists(root) ? await loadOctaneConfig(root) : null;
30
+ const entry = entryOverride
31
+ ? path.resolve(root, entryOverride)
32
+ : path.resolve(root, config?.build.outDir ?? 'dist', 'server', 'entry.js');
33
+ const child = spawn(process.execPath, [entry], {
34
+ cwd: root,
35
+ env: process.env,
36
+ stdio: 'inherit',
37
+ });
38
+
39
+ /** @type {NodeJS.Signals[]} */
40
+ const forwardedSignals = ['SIGINT', 'SIGTERM'];
41
+ /** @type {Map<NodeJS.Signals, () => void>} */
42
+ const signalHandlers = new Map();
43
+ for (const signal of forwardedSignals) {
44
+ const handler = () => child.kill(signal);
45
+ signalHandlers.set(signal, handler);
46
+ process.on(signal, handler);
47
+ }
48
+
49
+ function removeSignalHandlers() {
50
+ for (const [signal, handler] of signalHandlers) {
51
+ process.removeListener(signal, handler);
52
+ }
53
+ signalHandlers.clear();
54
+ }
55
+
56
+ child.on('error', (error) => {
57
+ removeSignalHandlers();
58
+ console.error(`[@octanejs/rsbuild-plugin] Unable to start ${entry}:`, error);
59
+ process.exitCode = 1;
60
+ });
61
+ child.on('exit', (code, signal) => {
62
+ // Remove our forwarding handlers before reproducing a signal exit. Without
63
+ // this, the preview process catches its own signal, forwards it to the
64
+ // already-exited child, and remains alive indefinitely.
65
+ removeSignalHandlers();
66
+ if (signal) process.kill(process.pid, signal);
67
+ else process.exitCode = code ?? 1;
68
+ });
package/src/build.js ADDED
@@ -0,0 +1,55 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Put the two Rsbuild environments into the layout consumed by the shared
7
+ * production entry, then hand the complete output to an optional adapter.
8
+ *
9
+ * @param {{
10
+ * root: string,
11
+ * config: import('@octanejs/app-core').ResolvedOctaneConfig,
12
+ * assetMapFilename?: string,
13
+ * log?: (message: string) => void,
14
+ * }} options
15
+ */
16
+ export async function finalizeOctaneRsbuildOutput(options) {
17
+ const root = path.resolve(options.root);
18
+ const outDir = options.config.build.outDir;
19
+ const clientDir = path.resolve(root, outDir, 'client');
20
+ const serverDir = path.resolve(root, outDir, 'server');
21
+ const assetMapFilename = options.assetMapFilename ?? 'octane-client-assets.json';
22
+ const log = options.log ?? (() => {});
23
+ const clientHtml = path.join(clientDir, 'index.html');
24
+ const serverHtml = path.join(serverDir, 'index.html');
25
+ const clientAssetMap = path.join(clientDir, assetMapFilename);
26
+ const serverAssetMap = path.join(serverDir, assetMapFilename);
27
+ const serverEntry = path.join(serverDir, 'entry.js');
28
+
29
+ if (!fs.existsSync(clientHtml)) {
30
+ throw new Error(`[@octanejs/rsbuild-plugin] Client HTML was not emitted at ${clientHtml}.`);
31
+ }
32
+ if (!fs.existsSync(clientAssetMap)) {
33
+ throw new Error(
34
+ `[@octanejs/rsbuild-plugin] Client asset metadata was not emitted at ${clientAssetMap}.`,
35
+ );
36
+ }
37
+ if (!fs.existsSync(serverEntry)) {
38
+ throw new Error(`[@octanejs/rsbuild-plugin] Server entry was not emitted at ${serverEntry}.`);
39
+ }
40
+
41
+ fs.mkdirSync(serverDir, { recursive: true });
42
+ fs.rmSync(serverHtml, { force: true });
43
+ fs.rmSync(serverAssetMap, { force: true });
44
+ fs.renameSync(clientHtml, serverHtml);
45
+ fs.renameSync(clientAssetMap, serverAssetMap);
46
+
47
+ log(`Server build complete: ${path.relative(root, serverEntry)}`);
48
+ log(`Start with: node ${path.relative(root, serverEntry)} (or octane-rsbuild-preview)`);
49
+
50
+ if (options.config.adapter?.adapt) {
51
+ const adapterName = options.config.adapter.name ?? 'adapter';
52
+ log(`Running ${adapterName} adapt()…`);
53
+ await options.config.adapter.adapt({ root, outDir, clientDir, serverDir, log });
54
+ }
55
+ }
@@ -0,0 +1,227 @@
1
+ // @ts-check
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ import { rspack } from '@rsbuild/core';
6
+
7
+ import { resolveProjectModule } from './project.js';
8
+
9
+ const PLUGIN_NAME = 'OctaneClientAssetsPlugin';
10
+
11
+ /** @param {string} file */
12
+ function canonicalResource(file) {
13
+ if (!file) return '';
14
+ const normalized = path.normalize(file);
15
+ try {
16
+ return path.normalize(fs.realpathSync(normalized));
17
+ } catch {
18
+ return normalized;
19
+ }
20
+ }
21
+
22
+ /** @param {unknown} request @param {string} root */
23
+ function canonicalRequestResource(request, root) {
24
+ if (typeof request !== 'string' || request.length === 0) return '';
25
+ // Rspack origins normally contain the concrete import specifier. Keep this
26
+ // tolerant of loader chains and resource queries so matching remains stable
27
+ // when another plugin decorates the generated import.
28
+ const resource = request.slice(request.lastIndexOf('!') + 1).split(/[?#]/, 1)[0];
29
+ return resource ? canonicalResource(resolveProjectModule(resource, root)) : '';
30
+ }
31
+
32
+ /** @param {unknown} value */
33
+ function iterable(value) {
34
+ return value && typeof value === 'object' && Symbol.iterator in value
35
+ ? /** @type {Iterable<any>} */ (value)
36
+ : [];
37
+ }
38
+
39
+ /**
40
+ * @param {any} module
41
+ * @param {(module: any) => void} visit
42
+ * @param {Set<any>} [seen]
43
+ */
44
+ function visitModule(module, visit, seen = new Set()) {
45
+ if (!module || seen.has(module)) return;
46
+ seen.add(module);
47
+ visit(module);
48
+ for (const child of iterable(module.modules)) visitModule(child, visit, seen);
49
+ if (module.rootModule) visitModule(module.rootModule, visit, seen);
50
+ }
51
+
52
+ /** @param {string} file */
53
+ function isJavaScript(file) {
54
+ return /\.m?js(?:\?.*)?$/.test(file) && !/\.hot-update\.js(?:\?|$)/.test(file);
55
+ }
56
+
57
+ /** @param {string} file */
58
+ function isCss(file) {
59
+ return /\.css(?:\?.*)?$/.test(file);
60
+ }
61
+
62
+ /** @param {any} chunk */
63
+ function javascriptFiles(chunk) {
64
+ return [...iterable(chunk?.files)].map(String).filter(isJavaScript).sort();
65
+ }
66
+
67
+ /** @param {Set<string>} files @param {any} chunk */
68
+ function collectCss(files, chunk) {
69
+ for (const file of iterable(chunk?.files)) {
70
+ const filename = String(file);
71
+ if (isCss(filename)) files.add(filename);
72
+ }
73
+ for (const file of iterable(chunk?.auxiliaryFiles)) {
74
+ const filename = String(file);
75
+ if (isCss(filename)) files.add(filename);
76
+ }
77
+ }
78
+
79
+ /** @param {any} compilation @param {string} message */
80
+ function addCompilationError(compilation, message) {
81
+ const error = new Error(`[octane] ${message}`);
82
+ if (compilation.errors && typeof compilation.errors.push === 'function') {
83
+ compilation.errors.push(error);
84
+ return;
85
+ }
86
+ throw error;
87
+ }
88
+
89
+ /**
90
+ * Emit the stable route-module → built JS/CSS map consumed by the production
91
+ * server. This uses Rspack's actual module/chunk graph rather than assuming a
92
+ * Vite-shaped manifest.
93
+ */
94
+ export class OctaneClientAssetsPlugin {
95
+ /**
96
+ * @param {{ root: string, entries: string[] | (() => string[]), filename?: string, clientEntry?: string }} options
97
+ */
98
+ constructor(options) {
99
+ this.root = path.resolve(options.root);
100
+ this.entries = options.entries;
101
+ this.filename = options.filename ?? 'octane-client-assets.json';
102
+ this.clientEntry = options.clientEntry
103
+ ? canonicalResource(resolveProjectModule(options.clientEntry, this.root))
104
+ : '';
105
+ }
106
+
107
+ /** @param {import('@rspack/core').Compiler} compiler */
108
+ apply(compiler) {
109
+ compiler.hooks.thisCompilation.tap(PLUGIN_NAME, (compilation) => {
110
+ compilation.hooks.processAssets.tap(
111
+ {
112
+ name: PLUGIN_NAME,
113
+ stage: rspack.Compilation.PROCESS_ASSETS_STAGE_REPORT,
114
+ },
115
+ () => {
116
+ const configuredEntries =
117
+ typeof this.entries === 'function' ? this.entries() : this.entries;
118
+ const entries = [...new Set(configuredEntries)];
119
+ const expected = new Map();
120
+ for (const id of entries) {
121
+ const resource = resolveProjectModule(id, this.root);
122
+ expected.set(path.normalize(resource), id);
123
+ expected.set(canonicalResource(resource), id);
124
+ }
125
+ /** @type {Map<string, Set<any>>} */
126
+ const moduleChunks = new Map(entries.map((id) => [id, new Set()]));
127
+ /** @type {Set<any>} */
128
+ const chunkGroups = new Set(iterable(compilation.chunkGroups));
129
+ const seenModules = new Set();
130
+
131
+ for (const topLevelModule of compilation.modules) {
132
+ visitModule(
133
+ topLevelModule,
134
+ (module) => {
135
+ const resource =
136
+ typeof module.resource === 'string' ? module.resource.split('?')[0] : '';
137
+ const id = expected.get(canonicalResource(resource));
138
+ if (!id) return;
139
+ for (const chunk of compilation.chunkGraph.getModuleChunksIterable(module)) {
140
+ moduleChunks.get(id)?.add(chunk);
141
+ for (const group of iterable(chunk.groupsIterable)) chunkGroups.add(group);
142
+ }
143
+ },
144
+ seenModules,
145
+ );
146
+ }
147
+
148
+ /** @type {Map<string, Set<any>>} */
149
+ const routeGroups = new Map(entries.map((id) => [id, new Set()]));
150
+ for (const group of chunkGroups) {
151
+ if (typeof group?.isInitial === 'function' && group.isInitial()) continue;
152
+ for (const origin of iterable(group?.origins)) {
153
+ const id = expected.get(canonicalRequestResource(origin?.request, this.root));
154
+ if (!id) continue;
155
+ const originResource =
156
+ typeof origin?.module?.resource === 'string'
157
+ ? canonicalResource(origin.module.resource.split('?')[0])
158
+ : '';
159
+ if (this.clientEntry && originResource && originResource !== this.clientEntry)
160
+ continue;
161
+ routeGroups.get(id)?.add(group);
162
+ }
163
+ }
164
+
165
+ /** @type {Record<string, { js: string, css: string[] }>} */
166
+ const assets = {};
167
+ for (const id of entries) {
168
+ const css = new Set();
169
+ for (const chunk of moduleChunks.get(id) ?? []) collectCss(css, chunk);
170
+
171
+ const groupJavaScript = new Set();
172
+ for (const group of routeGroups.get(id) ?? []) {
173
+ const chunks = [...iterable(group?.chunks)];
174
+ for (const chunk of chunks) collectCss(css, chunk);
175
+ // SplitChunks inserts shared chunks before the original async chunk,
176
+ // so scan from the end for the route script. Do not call
177
+ // getEntrypointChunk(): normal Rspack async groups are not entrypoints.
178
+ for (let index = chunks.length - 1; index >= 0; index--) {
179
+ const files = javascriptFiles(chunks[index]);
180
+ if (files.length === 0) continue;
181
+ if (files.length > 1) {
182
+ addCompilationError(
183
+ compilation,
184
+ `Route ${JSON.stringify(id)} emitted multiple JavaScript assets in one chunk: ${files.join(', ')}`,
185
+ );
186
+ } else {
187
+ groupJavaScript.add(files[0]);
188
+ }
189
+ break;
190
+ }
191
+ }
192
+
193
+ let js = '';
194
+ if (groupJavaScript.size === 1) {
195
+ js = /** @type {string} */ (groupJavaScript.values().next().value);
196
+ } else if (groupJavaScript.size > 1) {
197
+ addCompilationError(
198
+ compilation,
199
+ `Route ${JSON.stringify(id)} matched multiple async JavaScript chunks: ${[...groupJavaScript].sort().join(', ')}`,
200
+ );
201
+ } else {
202
+ // Tree-shaken/eager edge cases may not retain an async group. Preserve
203
+ // the old behavior only when the module graph leaves one unambiguous
204
+ // JavaScript file; never guess between filenames.
205
+ const directJavaScript = new Set();
206
+ for (const chunk of moduleChunks.get(id) ?? []) {
207
+ for (const file of javascriptFiles(chunk)) directJavaScript.add(file);
208
+ }
209
+ if (directJavaScript.size === 1) {
210
+ js = /** @type {string} */ (directJavaScript.values().next().value);
211
+ } else if (directJavaScript.size > 1) {
212
+ addCompilationError(
213
+ compilation,
214
+ `Route ${JSON.stringify(id)} has no matching async group and multiple JavaScript chunks: ${[...directJavaScript].sort().join(', ')}`,
215
+ );
216
+ }
217
+ }
218
+
219
+ assets[id] = { js, css: [...css].sort() };
220
+ }
221
+ const json = JSON.stringify(assets, null, 2) + '\n';
222
+ compilation.emitAsset(this.filename, new rspack.sources.RawSource(json));
223
+ },
224
+ );
225
+ });
226
+ }
227
+ }
@@ -0,0 +1,20 @@
1
+ // @ts-check
2
+ /**
3
+ * Config-safe facade used while bundling the production server entry.
4
+ *
5
+ * `octane.config.ts` imports the integration's bare package name, but the real
6
+ * entry also owns compiler/dev-server setup. Keep every app-core helper that is
7
+ * valid in declarative config while preventing the toolchain from entering the
8
+ * server bundle.
9
+ */
10
+
11
+ export * from '@octanejs/app-core';
12
+
13
+ /** @returns {never} */
14
+ export function pluginOctane() {
15
+ throw new Error(
16
+ '[@octanejs/rsbuild-plugin] pluginOctane() is an Rsbuild plugin and cannot run inside the production server bundle.',
17
+ );
18
+ }
19
+
20
+ export const octane = pluginOctane;
@@ -0,0 +1,117 @@
1
+ // @ts-check
2
+ import { createHandler } from '@octanejs/app-core/production';
3
+ import { createRouter, is_rpc_request } from '@octanejs/app-core';
4
+ import { nodeRequestToWebRequest, sendWebResponse } from '@octanejs/app-core/node';
5
+
6
+ import { isRsbuildOwnedUrl } from './html.js';
7
+
8
+ /**
9
+ * @typedef {{
10
+ * manifest: import('@octanejs/app-core/production').ServerManifest,
11
+ * rendererDeps: Omit<import('@octanejs/app-core/production').HandlerOptions, 'htmlTemplate'>,
12
+ * }} OctaneDevBundle
13
+ */
14
+
15
+ /**
16
+ * @param {import('@rspack/core').Stats} stats
17
+ * @returns {Set<string>}
18
+ */
19
+ function collectAssetPaths(stats) {
20
+ const json = stats.toJson({ all: false, assets: true });
21
+ const paths = new Set();
22
+ for (const asset of json.assets ?? []) {
23
+ if (!asset.name) continue;
24
+ paths.add(asset.name);
25
+ paths.add('/' + asset.name.replace(/^\/+/, ''));
26
+ }
27
+ return paths;
28
+ }
29
+
30
+ /**
31
+ * Create the early Connect middleware used by the Rsbuild Environment API.
32
+ * Asset/internal requests fall through to Rsbuild; matched app/RPC requests
33
+ * load the current server bundle and stream a Web Response back to Node.
34
+ *
35
+ * @param {{
36
+ * server: import('@rsbuild/core').RsbuildDevServer,
37
+ * clientEnvironment: string,
38
+ * serverEnvironment: string,
39
+ * clientEntry: string,
40
+ * serverEntry: string,
41
+ * publicRoots?: string[],
42
+ * logError?: (message: string, error: unknown) => void,
43
+ * }} options
44
+ * @returns {import('@rsbuild/core').RequestHandler}
45
+ */
46
+ export function createOctaneDevMiddleware(options) {
47
+ const clientApi = options.server.environments[options.clientEnvironment];
48
+ const serverApi = options.server.environments[options.serverEnvironment];
49
+ if (!clientApi || !serverApi) {
50
+ throw new Error(
51
+ `[@octanejs/rsbuild-plugin] Missing Rsbuild environments ${JSON.stringify(options.clientEnvironment)} and ${JSON.stringify(options.serverEnvironment)}.`,
52
+ );
53
+ }
54
+
55
+ let assetHash = '';
56
+ let assetPaths = new Set();
57
+ /** @type {WeakMap<object, { html: string, handler: (request: Request) => Promise<Response> }>} */
58
+ const handlerCache = new WeakMap();
59
+
60
+ return async function octaneDevMiddleware(request, response, next) {
61
+ try {
62
+ const host = request.headers.host ?? 'localhost';
63
+ const url = new URL(request.url ?? '/', `http://${host}`);
64
+ // Internal and public URLs do not depend on a successful client
65
+ // compilation. Yield them immediately so an initial compile error does
66
+ // not turn the error overlay, HMR transport, or favicon into an SSR 500.
67
+ if (isRsbuildOwnedUrl(url, new Set(), options.publicRoots)) {
68
+ next();
69
+ return;
70
+ }
71
+ const clientStats = await clientApi.getStats();
72
+ if (clientStats.hash !== assetHash) {
73
+ assetHash = clientStats.hash ?? '';
74
+ assetPaths = collectAssetPaths(clientStats);
75
+ }
76
+ if (isRsbuildOwnedUrl(url, assetPaths)) {
77
+ next();
78
+ return;
79
+ }
80
+
81
+ const bundle = await serverApi.loadBundle(options.serverEntry);
82
+ if (!bundle || typeof bundle !== 'object') {
83
+ throw new Error('The Octane server environment returned an invalid bundle.');
84
+ }
85
+ const typedBundle = /** @type {OctaneDevBundle} */ (bundle);
86
+ const method = request.method ?? 'GET';
87
+ const route = createRouter(typedBundle.manifest.routes).match(method, url.pathname);
88
+ if (!route && !is_rpc_request(url.pathname)) {
89
+ next();
90
+ return;
91
+ }
92
+
93
+ const html = await clientApi.getTransformedHtml(options.clientEntry);
94
+ let cached = handlerCache.get(typedBundle.manifest);
95
+ if (!cached || cached.html !== html) {
96
+ cached = {
97
+ html,
98
+ handler: createHandler(typedBundle.manifest, {
99
+ ...typedBundle.rendererDeps,
100
+ htmlTemplate: html,
101
+ }),
102
+ };
103
+ handlerCache.set(typedBundle.manifest, cached);
104
+ }
105
+
106
+ const webResponse = await cached.handler(nodeRequestToWebRequest(request));
107
+ await sendWebResponse(response, webResponse);
108
+ } catch (error) {
109
+ options.logError?.('Dev SSR request failed', error);
110
+ if (!response.headersSent) {
111
+ response.statusCode = 500;
112
+ response.setHeader('Content-Type', 'text/plain; charset=utf-8');
113
+ }
114
+ response.end('Internal Server Error');
115
+ }
116
+ };
117
+ }