@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/src/project.js
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { get_route_entry_path } from '@octanejs/app-core/routes';
|
|
7
|
+
import { compile } from 'octane/compiler';
|
|
8
|
+
|
|
9
|
+
const OCTANE_COMPONENT_EXTENSIONS = new Set(['.tsrx', '.tsx']);
|
|
10
|
+
const IGNORED_DIRECTORIES = new Set([
|
|
11
|
+
'.git',
|
|
12
|
+
'.octane',
|
|
13
|
+
'.vercel',
|
|
14
|
+
'__fixtures__',
|
|
15
|
+
'__tests__',
|
|
16
|
+
'build',
|
|
17
|
+
'coverage',
|
|
18
|
+
'dist',
|
|
19
|
+
'examples',
|
|
20
|
+
'fixtures',
|
|
21
|
+
'node_modules',
|
|
22
|
+
'playground',
|
|
23
|
+
'test',
|
|
24
|
+
'tests',
|
|
25
|
+
]);
|
|
26
|
+
const SERVER_MODULE_CANDIDATE = /\bmodule\s+server\s*\{/;
|
|
27
|
+
const COMPILED_SERVER_NAMESPACE = /(?:^|\n)\s*export\s+const\s+_\$_server_\$_\s*=/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Replace comments and string/template contents with whitespace while keeping
|
|
31
|
+
* line breaks and token boundaries intact. This is only a cheap candidate
|
|
32
|
+
* filter: the Octane compiler remains the syntax authority below.
|
|
33
|
+
*
|
|
34
|
+
* Template expressions are intentionally masked with the surrounding
|
|
35
|
+
* template. A `module server` declaration cannot be top-level while it is
|
|
36
|
+
* nested in an expression, and treating template text as source caused false
|
|
37
|
+
* RPC imports in the old regexp-only scan.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} source
|
|
40
|
+
*/
|
|
41
|
+
function maskNonCode(source) {
|
|
42
|
+
const output = source.split('');
|
|
43
|
+
let state = 'code';
|
|
44
|
+
|
|
45
|
+
for (let index = 0; index < source.length; index++) {
|
|
46
|
+
const character = source[index];
|
|
47
|
+
const next = source[index + 1];
|
|
48
|
+
|
|
49
|
+
if (state === 'code') {
|
|
50
|
+
if (character === '/' && next === '/') {
|
|
51
|
+
output[index] = output[index + 1] = ' ';
|
|
52
|
+
index++;
|
|
53
|
+
state = 'line-comment';
|
|
54
|
+
} else if (character === '/' && next === '*') {
|
|
55
|
+
output[index] = output[index + 1] = ' ';
|
|
56
|
+
index++;
|
|
57
|
+
state = 'block-comment';
|
|
58
|
+
} else if (character === "'" || character === '"' || character === '`') {
|
|
59
|
+
output[index] = ' ';
|
|
60
|
+
state = character;
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (state === 'line-comment') {
|
|
66
|
+
if (character === '\n' || character === '\r') state = 'code';
|
|
67
|
+
else output[index] = ' ';
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (state === 'block-comment') {
|
|
72
|
+
if (character === '*' && next === '/') {
|
|
73
|
+
output[index] = output[index + 1] = ' ';
|
|
74
|
+
index++;
|
|
75
|
+
state = 'code';
|
|
76
|
+
} else if (character !== '\n' && character !== '\r') {
|
|
77
|
+
output[index] = ' ';
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (character === '\\') {
|
|
83
|
+
output[index] = ' ';
|
|
84
|
+
if (index + 1 < source.length) {
|
|
85
|
+
index++;
|
|
86
|
+
if (source[index] !== '\n' && source[index] !== '\r') output[index] = ' ';
|
|
87
|
+
}
|
|
88
|
+
} else if (character === state) {
|
|
89
|
+
output[index] = ' ';
|
|
90
|
+
state = 'code';
|
|
91
|
+
} else if (character !== '\n' && character !== '\r') {
|
|
92
|
+
output[index] = ' ';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return output.join('');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Confirm a candidate through the same parser and server transform used by
|
|
101
|
+
* the Rspack loader. Inspecting the compiler-owned namespace declaration keeps
|
|
102
|
+
* comments, JSX text, regexps, and string/template content out of the static
|
|
103
|
+
* RPC graph without maintaining a second TSRX parser here.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} source
|
|
106
|
+
* @param {string} id
|
|
107
|
+
*/
|
|
108
|
+
function ownsServerModule(source, id) {
|
|
109
|
+
if (!SERVER_MODULE_CANDIDATE.test(maskNonCode(source))) return false;
|
|
110
|
+
const compiled = compile(source, id, { mode: 'server' });
|
|
111
|
+
return COMPILED_SERVER_NAMESPACE.test(maskNonCode(compiled.code));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Convert a stable, project-root module ID used in route/config data into a
|
|
116
|
+
* concrete import specifier for Rspack. IDs remain stable in serialized
|
|
117
|
+
* hydration/RPC data; only generated source sees the filesystem path.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} id
|
|
120
|
+
* @param {string} root
|
|
121
|
+
*/
|
|
122
|
+
export function resolveProjectModule(id, root) {
|
|
123
|
+
const projectRoot = path.resolve(root);
|
|
124
|
+
if (path.isAbsolute(id)) {
|
|
125
|
+
const normalized = path.resolve(id);
|
|
126
|
+
if (normalized === projectRoot || normalized.startsWith(projectRoot + path.sep)) {
|
|
127
|
+
return normalized;
|
|
128
|
+
}
|
|
129
|
+
// Canonical IDs for linked/raw Octane dependencies remain absolute. A
|
|
130
|
+
// project-root ID such as `/src/Page.tsrx` normally does not exist as an
|
|
131
|
+
// absolute filesystem path and falls through to the root-relative case.
|
|
132
|
+
if (fs.existsSync(normalized)) return normalized;
|
|
133
|
+
// Octane config paths use project-root syntax (`/src/Page.tsrx`).
|
|
134
|
+
return path.join(projectRoot, id.replace(/^[/\\]+/, ''));
|
|
135
|
+
}
|
|
136
|
+
return path.resolve(projectRoot, id);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** @param {string} file @param {string} root */
|
|
140
|
+
export function toProjectModuleId(file, root) {
|
|
141
|
+
const relative = path.relative(path.resolve(root), path.resolve(file));
|
|
142
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
|
|
143
|
+
let externalFile = path.resolve(file);
|
|
144
|
+
try {
|
|
145
|
+
// Rspack resolves linked packages through their real path before invoking
|
|
146
|
+
// loaders. Use the same path in RPC hashes (notably `/private/var` on
|
|
147
|
+
// macOS) so generated manifest keys and compiler hashes cannot diverge.
|
|
148
|
+
externalFile = fs.realpathSync.native(externalFile);
|
|
149
|
+
} catch {
|
|
150
|
+
// Preserve a stable absolute ID for a missing path; the build will report
|
|
151
|
+
// the missing import with its normal resolver diagnostic.
|
|
152
|
+
}
|
|
153
|
+
return externalFile.split(path.sep).join('/');
|
|
154
|
+
}
|
|
155
|
+
return '/' + relative.split(path.sep).join('/');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Every module the server may serialize into `#__octane_data`.
|
|
160
|
+
*
|
|
161
|
+
* @param {import('@octanejs/app-core').ResolvedOctaneConfig} config
|
|
162
|
+
*/
|
|
163
|
+
export function collectClientEntries(config) {
|
|
164
|
+
const entries = config.router.routes
|
|
165
|
+
.filter((route) => route.type === 'render')
|
|
166
|
+
.flatMap((route) => [get_route_entry_path(route.entry), route.layout]);
|
|
167
|
+
if (config.router.preHydrate) entries.push(config.router.preHydrate);
|
|
168
|
+
entries.push(
|
|
169
|
+
get_route_entry_path(config.rootBoundary.pending),
|
|
170
|
+
get_route_entry_path(config.rootBoundary.catch),
|
|
171
|
+
);
|
|
172
|
+
return [...new Set(entries.filter((entry) => typeof entry === 'string'))];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Discover project-owned and raw-dependency TSRX/TSX modules containing an
|
|
177
|
+
* actual top-level `module server` block. This is an inclusion pre-pass so the
|
|
178
|
+
* generated server graph can statically import every RPC owner before Rspack
|
|
179
|
+
* compiles it; the compiler validates candidates rather than a second parser.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} root
|
|
182
|
+
* @param {string[]} [sourceRoots]
|
|
183
|
+
* @returns {{ ids: string[], files: string[], directories: string[] }}
|
|
184
|
+
*/
|
|
185
|
+
export function discoverServerModules(root, sourceRoots = [root]) {
|
|
186
|
+
const projectRoot = path.resolve(root);
|
|
187
|
+
const files = new Set();
|
|
188
|
+
/** @type {string[]} */
|
|
189
|
+
const directories = [];
|
|
190
|
+
/** @type {string[]} */
|
|
191
|
+
const pending = [...new Set(sourceRoots.map((sourceRoot) => path.resolve(sourceRoot)))];
|
|
192
|
+
const visitedDirectories = new Set();
|
|
193
|
+
|
|
194
|
+
while (pending.length > 0) {
|
|
195
|
+
const directory = /** @type {string} */ (pending.pop());
|
|
196
|
+
if (visitedDirectories.has(directory)) continue;
|
|
197
|
+
visitedDirectories.add(directory);
|
|
198
|
+
directories.push(directory);
|
|
199
|
+
/** @type {fs.Dirent[]} */
|
|
200
|
+
let entries;
|
|
201
|
+
try {
|
|
202
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
for (const entry of entries) {
|
|
207
|
+
if (entry.isDirectory()) {
|
|
208
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) pending.push(path.join(directory, entry.name));
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (!entry.isFile() || !OCTANE_COMPONENT_EXTENSIONS.has(path.extname(entry.name))) continue;
|
|
212
|
+
const file = path.join(directory, entry.name);
|
|
213
|
+
let source;
|
|
214
|
+
try {
|
|
215
|
+
source = fs.readFileSync(file, 'utf8');
|
|
216
|
+
} catch {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const id = toProjectModuleId(file, projectRoot);
|
|
220
|
+
if (ownsServerModule(source, id)) files.add(file);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const sortedFiles = [...files].sort();
|
|
225
|
+
return {
|
|
226
|
+
ids: sortedFiles.map((file) => toProjectModuleId(file, projectRoot)),
|
|
227
|
+
files: sortedFiles,
|
|
228
|
+
directories,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the source directories of installed packages the neutral compiler
|
|
234
|
+
* identified as raw Octane dependencies. Published packages normally expose a
|
|
235
|
+
* `src` directory; limiting linked workspace packages to that directory avoids
|
|
236
|
+
* accidentally pulling their tests/examples into the server RPC manifest.
|
|
237
|
+
*
|
|
238
|
+
* @param {string} root
|
|
239
|
+
* @param {string[]} packageNames
|
|
240
|
+
* @returns {string[]}
|
|
241
|
+
*/
|
|
242
|
+
export function resolveOctaneSourceRoots(root, packageNames) {
|
|
243
|
+
const projectRoot = path.resolve(root);
|
|
244
|
+
const projectRequire = createRequire(path.join(projectRoot, 'package.json'));
|
|
245
|
+
const roots = [projectRoot];
|
|
246
|
+
|
|
247
|
+
for (const packageName of packageNames) {
|
|
248
|
+
if (packageName === 'octane' || packageName === '@octanejs/app-core') continue;
|
|
249
|
+
let entry;
|
|
250
|
+
try {
|
|
251
|
+
entry = projectRequire.resolve(packageName);
|
|
252
|
+
} catch {
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
let directory = path.dirname(entry);
|
|
256
|
+
let packageRoot = null;
|
|
257
|
+
for (;;) {
|
|
258
|
+
const manifest = path.join(directory, 'package.json');
|
|
259
|
+
if (fs.existsSync(manifest)) {
|
|
260
|
+
try {
|
|
261
|
+
if (JSON.parse(fs.readFileSync(manifest, 'utf8')).name === packageName) {
|
|
262
|
+
packageRoot = directory;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// Keep walking; the resolver already found a usable package entry.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const parent = path.dirname(directory);
|
|
270
|
+
if (parent === directory) break;
|
|
271
|
+
directory = parent;
|
|
272
|
+
}
|
|
273
|
+
if (!packageRoot) continue;
|
|
274
|
+
const sourceDirectory = path.join(packageRoot, 'src');
|
|
275
|
+
roots.push(fs.existsSync(sourceDirectory) ? sourceDirectory : packageRoot);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return [...new Set(roots)];
|
|
279
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { RsbuildPlugin } from '@rsbuild/core';
|
|
2
|
+
|
|
3
|
+
export * from '@octanejs/app-core';
|
|
4
|
+
export {
|
|
5
|
+
getOctaneConfigPath,
|
|
6
|
+
loadOctaneConfig,
|
|
7
|
+
loadOctaneConfigWithMetadata,
|
|
8
|
+
octaneConfigExists,
|
|
9
|
+
} from '@octanejs/app-core/config-loader';
|
|
10
|
+
|
|
11
|
+
export interface OctaneRsbuildPluginOptions {
|
|
12
|
+
/** Override component HMR in the browser environment. */
|
|
13
|
+
hmr?: boolean;
|
|
14
|
+
/** Disable the compiler's parallel `use()` transform. */
|
|
15
|
+
parallelUse?: boolean;
|
|
16
|
+
/** Ad-hoc path fragments skipped by the plain TypeScript/JavaScript hook-slot pass. */
|
|
17
|
+
exclude?: string[];
|
|
18
|
+
/** Rsbuild environment name used for the browser bundle. @default 'web' */
|
|
19
|
+
clientEnvironment?: string;
|
|
20
|
+
/** Rsbuild environment name used for the Node SSR bundle. @default 'node' */
|
|
21
|
+
serverEnvironment?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Full Octane metaframework integration for Rsbuild 2.x. */
|
|
25
|
+
export function pluginOctane(options?: OctaneRsbuildPluginOptions): RsbuildPlugin;
|
|
26
|
+
|
|
27
|
+
/** Alias matching the Vite integration's factory name. */
|
|
28
|
+
export const octane: typeof pluginOctane;
|
package/types/node.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@octanejs/app-core/node';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from '@octanejs/app-core/production';
|