@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/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
+ }
@@ -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;
@@ -0,0 +1 @@
1
+ export * from '@octanejs/app-core/node';
@@ -0,0 +1 @@
1
+ export * from '@octanejs/app-core/production';