@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 +21 -0
- package/README.md +86 -0
- package/package.json +66 -0
- package/src/bin/preview.js +68 -0
- package/src/build.js +55 -0
- package/src/client-assets-plugin.js +227 -0
- package/src/config-entry.js +20 -0
- package/src/dev-server.js +117 -0
- package/src/html.js +79 -0
- package/src/index.js +592 -0
- package/src/node.js +1 -0
- package/src/production.js +1 -0
- package/src/project.js +279 -0
- package/types/index.d.ts +28 -0
- package/types/node.d.ts +1 -0
- package/types/production.d.ts +1 -0
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
|
+
}
|