@broxium/compiler 1.0.0 → 1.2.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/README.md ADDED
@@ -0,0 +1,251 @@
1
+ # @broxium/compiler
2
+
3
+ The Brodox component compiler. Takes a developer's component source files (TSX/JSX) and compiles them into two versioned ESM bundles — one for server-side rendering and one for browser hydration.
4
+
5
+ This is an internal package used by the Brodox platform. Developers building components do **not** use this directly; it is called automatically when a component version is approved in the Brodox dashboard.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install @broxium/compiler
11
+ ```
12
+
13
+ Node.js 20+ is required.
14
+
15
+ ---
16
+
17
+ ## What it does
18
+
19
+ When a component version is approved, `BrodoxCompiler.compile()` is called with the component's source files. It produces two output files in `LIVE_COMPONENTS_PATH`:
20
+
21
+ | Output file | Purpose | Format |
22
+ |---|---|---|
23
+ | `{slug}-v{version}.server.esm.js` | Loaded by the web engine via Node.js `import()` for SSR | Pure ESM, Node 20 target, readable (not minified) |
24
+ | `{slug}-v{version}.client.esm.js` | Served to the browser for island hydration | Pure ESM, ES2020 / Chrome 90 / Firefox 88 / Safari 14, minified |
25
+
26
+ Both bundles treat `react`, `react-dom`, `react/jsx-runtime`, and `@broxium/runtime` as **externals** — they are never bundled in. The web engine's import map and the browser's `@broxium/runtime` static file provide these at runtime.
27
+
28
+ ---
29
+
30
+ ## Usage
31
+
32
+ ```ts
33
+ import { BrodoxCompiler } from '@broxium/compiler'
34
+
35
+ const compiler = new BrodoxCompiler()
36
+
37
+ const result = await compiler.compile({
38
+ componentId: 42,
39
+ slug: 'hero-banner',
40
+ version: '1.0.4',
41
+ files: [
42
+ {
43
+ path: 'App.tsx',
44
+ content: `
45
+ import { BrodoxImage } from '@broxium/runtime'
46
+ export default function HeroBanner({ title, image }) {
47
+ return (
48
+ <section>
49
+ <h1>{title}</h1>
50
+ <BrodoxImage src={image} alt={title} width={1280} />
51
+ </section>
52
+ )
53
+ }
54
+ `,
55
+ },
56
+ ],
57
+ outputDir: '/brodox-developer-drive/web-components/live-bundles',
58
+ })
59
+
60
+ console.log(result.serverJsName) // "hero-banner-v1.0.4.server.esm.js"
61
+ console.log(result.clientJsName) // "hero-banner-v1.0.4.client.esm.js"
62
+ console.log(result.serverJsPath) // "/brodox-developer-drive/.../hero-banner-v1.0.4.server.esm.js"
63
+ console.log(result.compiledAt) // Date object
64
+ ```
65
+
66
+ ---
67
+
68
+ ## API
69
+
70
+ ### `new BrodoxCompiler()`
71
+
72
+ Creates a new compiler instance. The instance is stateless and safe to reuse across multiple calls — the Brodox platform creates a single instance per process.
73
+
74
+ ### `compiler.compile(input): Promise<CompileOutput>`
75
+
76
+ Compiles a component's source files into server and client ESM bundles.
77
+
78
+ #### `CompileInput`
79
+
80
+ ```ts
81
+ interface CompileInput {
82
+ componentId: number // DB row ID — not used in output naming, reserved for future use
83
+ slug: string // Component slug, e.g. "hero-banner"
84
+ version: string // Semver string, e.g. "1.0.4"
85
+ files: Array<{
86
+ path: string // Relative path within the component, e.g. "App.tsx" or "utils/format.ts"
87
+ content: string // Full source text
88
+ }>
89
+ outputDir: string // Absolute path where output files are written
90
+ }
91
+ ```
92
+
93
+ #### `CompileOutput`
94
+
95
+ ```ts
96
+ interface CompileOutput {
97
+ serverJsPath: string // Absolute path to the server bundle
98
+ clientJsPath: string // Absolute path to the client bundle
99
+ serverJsName: string // Filename only: "{slug}-v{version}.server.esm.js"
100
+ clientJsName: string // Filename only: "{slug}-v{version}.client.esm.js"
101
+ compiledAt: Date // Timestamp of compilation
102
+ }
103
+ ```
104
+
105
+ #### Entry point resolution
106
+
107
+ The compiler looks for the entry file by checking the `files` array in this priority order:
108
+
109
+ ```
110
+ App.tsx → App.jsx → App.ts → App.js →
111
+ index.tsx → index.jsx → index.ts → index.js
112
+ ```
113
+
114
+ If none of these are found, the compiler throws:
115
+
116
+ ```
117
+ Error: No entry file found in component files for {slug}
118
+ ```
119
+
120
+ #### Temporary directory
121
+
122
+ Source files are written to a temporary directory under `os.tmpdir()` before compilation. The directory is automatically cleaned up after compilation completes (or fails with an error that is re-thrown).
123
+
124
+ ---
125
+
126
+ ## `'use client'` and `'use server'` handling
127
+
128
+ The two esbuild builds use custom plugins to handle React-style directives:
129
+
130
+ ### Server bundle — `clientStubPlugin`
131
+
132
+ Any file whose first non-whitespace characters are `'use client'` or `"use client"` is replaced with a stub:
133
+
134
+ ```js
135
+ import React from 'react'
136
+ export default function ClientStub() { return null }
137
+ export function getServerData() { return {} }
138
+ ```
139
+
140
+ This means client-only sub-components silently render nothing on the server, which is correct — their interactive version will be rendered entirely by the browser.
141
+
142
+ ### Client bundle — `serverStubPlugin`
143
+
144
+ Any file whose first non-whitespace characters are `'use server'` or `"use server"` is replaced with:
145
+
146
+ ```js
147
+ export default function ServerStub() { return null }
148
+ ```
149
+
150
+ Server-only code never runs in the browser.
151
+
152
+ ### Entry file directive
153
+
154
+ The directive on the **entry file** (`App.tsx`) determines the `renderMode` stored in the Page Manifest:
155
+
156
+ | Entry file starts with | `renderMode` | Server renders | Client hydrates |
157
+ |---|---|---|---|
158
+ | `'use client'` | `client` | No | Yes |
159
+ | `'use server'` | `server` | Yes | No |
160
+ | _(nothing)_ | `both` | Yes | Yes |
161
+
162
+ ---
163
+
164
+ ## External dependencies
165
+
166
+ The following packages are always treated as external in both bundles:
167
+
168
+ | Package | Why external |
169
+ |---|---|
170
+ | `react` | Provided by the web engine's import map / browser global |
171
+ | `react-dom` | Same |
172
+ | `react/jsx-runtime` | Same |
173
+ | `react/jsx-dev-runtime` | Same |
174
+ | `@broxium/runtime` | Provided by `/static/broxium-runtime.js` on the web engine |
175
+
176
+ Any other import in the component source will be bundled in. This means third-party libraries like `date-fns`, `zod`, or `clsx` are embedded in the output files. Dependencies must be whitelisted in the Brodox platform's allowed-package list before they can be used in a component.
177
+
178
+ ---
179
+
180
+ ## Output file naming
181
+
182
+ Output files follow a deterministic naming convention:
183
+
184
+ ```
185
+ {slug}-v{version}.server.esm.js
186
+ {slug}-v{version}.client.esm.js
187
+ ```
188
+
189
+ Examples:
190
+
191
+ | Slug | Version | Server file | Client file |
192
+ |---|---|---|---|
193
+ | `hero-banner` | `1.0.4` | `hero-banner-v1.0.4.server.esm.js` | `hero-banner-v1.0.4.client.esm.js` |
194
+ | `product-grid` | `2.1.0` | `product-grid-v2.1.0.server.esm.js` | `product-grid-v2.1.0.client.esm.js` |
195
+
196
+ Because the version is embedded in the filename, approving a new version always produces new files. The old files remain on disk for rollback purposes and are never overwritten.
197
+
198
+ ---
199
+
200
+ ## Integration with BundleService
201
+
202
+ In the Brodox platform, `BrodoxCompiler` is called from `BundleService` in `sub-app-brodox-site-engine-app`. `BundleService` reads all source files from the component's `file_path` directory on disk and passes them to the compiler:
203
+
204
+ ```ts
205
+ // BundleService.ts (simplified)
206
+ import { BrodoxCompiler } from '@broxium/compiler'
207
+
208
+ const compiler = new BrodoxCompiler()
209
+
210
+ async function compileBothBundles(folderPath, slug, version) {
211
+ const files = await readComponentFiles(folderPath) // reads all .tsx/.jsx/.ts/.js/.css/.json
212
+ const result = await compiler.compile({
213
+ componentId: 0,
214
+ slug,
215
+ version,
216
+ files,
217
+ outputDir: process.env.LIVE_COMPONENTS_PATH,
218
+ })
219
+ return result
220
+ }
221
+ ```
222
+
223
+ ---
224
+
225
+ ## Error handling
226
+
227
+ The compiler throws on any of the following conditions:
228
+
229
+ | Condition | Error message |
230
+ |---|---|
231
+ | No entry file found | `No entry file found in component files for {slug}` |
232
+ | esbuild server build fails | esbuild error message (TypeScript error, import not found, etc.) |
233
+ | esbuild client build fails | esbuild error message |
234
+
235
+ Errors from the server build are thrown before the client build is attempted. The temporary directory is cleaned up even when an error is thrown.
236
+
237
+ When `BundleService` catches a compilation error, it blocks the component from being approved and returns the error message to the reviewer.
238
+
239
+ ---
240
+
241
+ ## Package info
242
+
243
+ | | |
244
+ |---|---|
245
+ | Package | `@broxium/compiler` |
246
+ | Version | `1.0.0` |
247
+ | Formats | ESM (`dist/index.mjs`), CJS (`dist/index.js`) |
248
+ | Types | `dist/index.d.ts` |
249
+ | Runtime dependency | `esbuild ^0.25` |
250
+ | Node.js requirement | 20+ |
251
+ | Side effects | Writes files to `outputDir`, creates/removes temp directory |
package/dist/index.d.mts CHANGED
@@ -7,6 +7,8 @@ interface CompileInput {
7
7
  content: string;
8
8
  }>;
9
9
  outputDir: string;
10
+ /** Extra node_modules directories esbuild uses to resolve React for the server bundle. */
11
+ nodePaths?: string[];
10
12
  }
11
13
  interface CompileOutput {
12
14
  serverJsPath: string;
package/dist/index.d.ts CHANGED
@@ -7,6 +7,8 @@ interface CompileInput {
7
7
  content: string;
8
8
  }>;
9
9
  outputDir: string;
10
+ /** Extra node_modules directories esbuild uses to resolve React for the server bundle. */
11
+ nodePaths?: string[];
10
12
  }
11
13
  interface CompileOutput {
12
14
  serverJsPath: string;
package/dist/index.js CHANGED
@@ -37,24 +37,37 @@ module.exports = __toCommonJS(index_exports);
37
37
  // src/compiler.ts
38
38
  var esbuild = __toESM(require("esbuild"));
39
39
  var import_promises3 = __toESM(require("fs/promises"));
40
- var import_node_path = __toESM(require("path"));
40
+ var import_node_path3 = __toESM(require("path"));
41
41
  var import_node_os = __toESM(require("os"));
42
42
  var import_node_crypto = require("crypto");
43
43
 
44
44
  // src/plugins/clientStubPlugin.ts
45
45
  var import_promises = __toESM(require("fs/promises"));
46
+ var import_node_path = __toESM(require("path"));
47
+ function isClientFile(content, filePath) {
48
+ const trimmed = content.trimStart();
49
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true;
50
+ return /\.client\.[jt]sx?$/.test(filePath);
51
+ }
52
+ function extractName(content, filePath) {
53
+ const m = content.match(/export\s+default\s+function\s+(\w+)/) ?? content.match(/(?:^|\n)function\s+(\w+)/) ?? content.match(/(?:^|\n)const\s+(\w+)\s*=/);
54
+ const base = import_node_path.default.basename(filePath, import_node_path.default.extname(filePath)).replace(/\.client$/, "");
55
+ return m?.[1] ?? base;
56
+ }
46
57
  function clientStubPlugin() {
47
58
  return {
48
59
  name: "brodox-client-stub",
49
60
  setup(build2) {
50
61
  build2.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async (args) => {
51
62
  const content = await import_promises.default.readFile(args.path, "utf8");
52
- const trimmed = content.trimStart();
53
- if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
63
+ if (isClientFile(content, args.path)) {
64
+ const name = extractName(content, args.path);
54
65
  return {
55
66
  contents: `
56
67
  import React from 'react'
57
- export default function ClientStub() { return null }
68
+ function ${name}() { return null }
69
+ ${name}.displayName = "${name}"
70
+ export default ${name}
58
71
  export function getServerData() { return {} }
59
72
  `,
60
73
  loader: "jsx"
@@ -85,6 +98,114 @@ function serverStubPlugin() {
85
98
  };
86
99
  }
87
100
 
101
+ // src/plugins/runtimeServerStubPlugin.ts
102
+ var import_node_path2 = __toESM(require("path"));
103
+ function runtimeServerStubPlugin(nodePaths = []) {
104
+ const resolveDir = nodePaths.length > 0 ? import_node_path2.default.dirname(nodePaths[0]) : process.cwd();
105
+ return {
106
+ name: "brodox-runtime-server-stub",
107
+ setup(build2) {
108
+ build2.onResolve({ filter: /^@broxium\/runtime$/ }, () => ({
109
+ path: "@broxium/runtime",
110
+ namespace: "brodox-runtime-server-stub"
111
+ }));
112
+ build2.onLoad({ filter: /.*/, namespace: "brodox-runtime-server-stub" }, () => ({
113
+ loader: "js",
114
+ resolveDir,
115
+ contents: `
116
+ import { createElement, Fragment, Children } from 'react';
117
+ var __islandSeq = 0;
118
+ function __nextIslandId() { return 'bi-' + (++__islandSeq) + '-' + Math.random().toString(36).slice(2,7); }
119
+
120
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
121
+ const maxW = width || 1920;
122
+ const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
123
+ if (!widths.length) widths.push(maxW);
124
+ const q = Math.min(100, Math.max(1, quality));
125
+ const enc = encodeURIComponent(src);
126
+ const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
127
+ const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
128
+ const imgStyle = fill
129
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
130
+ : (style || {});
131
+ return createElement('img', {
132
+ src: optimisedSrc, srcSet, sizes, alt: alt || '',
133
+ width: fill ? undefined : width, height: fill ? undefined : height,
134
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
135
+ className, style: imgStyle,
136
+ });
137
+ }
138
+
139
+ export function BrodoxLink({ href, children, className, style }) {
140
+ return createElement('a', { href, className, style }, children);
141
+ }
142
+
143
+ export function useRouter() {
144
+ return { pathname: '/', params: {}, navigate: function(){}, back: function(){}, forward: function(){}, prefetch: function(){} };
145
+ }
146
+
147
+ export function useParams() { return {}; }
148
+
149
+ export function BrodoxHead() { return null; }
150
+
151
+ export function BrodoxFont() { return null; }
152
+
153
+ export function BrodoxRouter({ children }) {
154
+ return createElement(Fragment, null, children);
155
+ }
156
+
157
+ /**
158
+ * <Client> \u2014 island boundary marker.
159
+ *
160
+ * During SSR emits an empty placeholder div plus a sibling
161
+ * <script type="application/json"> carrying the child's props.
162
+ * The IslandHydrator walks the live DOM and mounts the component from the
163
+ * parent bundle's __registry__ after page load.
164
+ */
165
+ export function Client({ children }) {
166
+ var id = __nextIslandId();
167
+ var child = Children.only(children);
168
+ var compType = child.type;
169
+ var name = typeof compType !== 'string' && compType
170
+ ? (compType.displayName || compType.name || 'Unknown')
171
+ : 'Unknown';
172
+ var props = child.props || {};
173
+ var safeProps = JSON.stringify(props)
174
+ .replace(/</g, '\\u003c')
175
+ .replace(/>/g, '\\u003e')
176
+ .replace(/&/g, '\\u0026');
177
+ return createElement(Fragment, null,
178
+ createElement('div', {
179
+ 'data-brodox-island': id,
180
+ 'data-hydration': 'load',
181
+ 'data-client-js': '',
182
+ 'data-component-slug': '',
183
+ 'data-version': '',
184
+ 'data-component': name,
185
+ }),
186
+ createElement('script', {
187
+ type: 'application/json',
188
+ 'data-brodox-props': id,
189
+ dangerouslySetInnerHTML: { __html: safeProps },
190
+ })
191
+ );
192
+ }
193
+
194
+ /**
195
+ * <Server> \u2014 semantic server-only wrapper. Transparent passthrough.
196
+ */
197
+ export function Server({ children }) {
198
+ return createElement(Fragment, null, children);
199
+ }
200
+
201
+ /** @deprecated Use <Client> instead. */
202
+ export var ClientRender = Client;
203
+ `
204
+ }));
205
+ }
206
+ };
207
+ }
208
+
88
209
  // src/compiler.ts
89
210
  var ENTRY_PRIORITY = [
90
211
  "App.tsx",
@@ -96,25 +217,53 @@ var ENTRY_PRIORITY = [
96
217
  "index.ts",
97
218
  "index.js"
98
219
  ];
99
- var external = [
220
+ function isClientFile2(content, filePath) {
221
+ const trimmed = content.trimStart();
222
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true;
223
+ return /\.client\.[jt]sx?$/.test(filePath);
224
+ }
225
+ function extractClientComponentName(content, filePath) {
226
+ const m = content.match(/export\s+default\s+function\s+(\w+)/) ?? content.match(/export\s+default\s+(\w+)\s*[;({]/) ?? content.match(/(?:^|\n)function\s+(\w+)/);
227
+ const base = import_node_path3.default.basename(filePath, import_node_path3.default.extname(filePath)).replace(/\.client$/, "");
228
+ return m?.[1] ?? base;
229
+ }
230
+ var CLIENT_EXTERNALS = [
100
231
  "react",
101
232
  "react-dom",
102
233
  "react/jsx-runtime",
103
234
  "react/jsx-dev-runtime",
104
235
  "@broxium/runtime"
105
236
  ];
237
+ function findReactNodeModules() {
238
+ const fsSync = require("fs");
239
+ try {
240
+ const reactPkg = require.resolve("react/package.json");
241
+ return [import_node_path3.default.dirname(import_node_path3.default.dirname(reactPkg))];
242
+ } catch {
243
+ }
244
+ let dir = process.cwd();
245
+ for (let i = 0; i < 10; i++) {
246
+ const nm = import_node_path3.default.join(dir, "node_modules");
247
+ const reactPkg = import_node_path3.default.join(nm, "react", "package.json");
248
+ if (fsSync.existsSync(reactPkg)) return [nm];
249
+ const parent = import_node_path3.default.dirname(dir);
250
+ if (parent === dir) break;
251
+ dir = parent;
252
+ }
253
+ return [];
254
+ }
106
255
  var BrodoxCompiler = class {
107
256
  async compile(input) {
108
- const tmpDir = import_node_path.default.join(import_node_os.default.tmpdir(), `brodox-compile-${input.slug}-${(0, import_node_crypto.randomUUID)()}`);
257
+ const tmpDir = import_node_path3.default.join(import_node_os.default.tmpdir(), `brodox-compile-${input.slug}-${(0, import_node_crypto.randomUUID)()}`);
109
258
  await import_promises3.default.mkdir(tmpDir, { recursive: true });
110
259
  for (const file of input.files) {
111
- const filePath = import_node_path.default.join(tmpDir, file.path);
112
- await import_promises3.default.mkdir(import_node_path.default.dirname(filePath), { recursive: true });
260
+ const filePath = import_node_path3.default.join(tmpDir, file.path);
261
+ await import_promises3.default.mkdir(import_node_path3.default.dirname(filePath), { recursive: true });
113
262
  await import_promises3.default.writeFile(filePath, file.content, "utf8");
114
263
  }
115
264
  let entryPoint = null;
116
265
  for (const candidate of ENTRY_PRIORITY) {
117
- const full = import_node_path.default.join(tmpDir, candidate);
266
+ const full = import_node_path3.default.join(tmpDir, candidate);
118
267
  try {
119
268
  await import_promises3.default.access(full);
120
269
  entryPoint = full;
@@ -128,34 +277,63 @@ var BrodoxCompiler = class {
128
277
  const safeName = `${input.slug}-v${input.version}`;
129
278
  const serverJsName = `${safeName}.server.esm.js`;
130
279
  const clientJsName = `${safeName}.client.esm.js`;
131
- const serverJsPath = import_node_path.default.join(input.outputDir, serverJsName);
132
- const clientJsPath = import_node_path.default.join(input.outputDir, clientJsName);
280
+ const serverJsPath = import_node_path3.default.join(input.outputDir, serverJsName);
281
+ const clientJsPath = import_node_path3.default.join(input.outputDir, clientJsName);
133
282
  await import_promises3.default.mkdir(input.outputDir, { recursive: true });
283
+ const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
134
284
  await esbuild.build({
135
285
  entryPoints: [entryPoint],
136
286
  bundle: true,
137
287
  format: "esm",
138
288
  platform: "node",
139
289
  target: "node20",
140
- external,
141
- plugins: [clientStubPlugin()],
290
+ jsx: "automatic",
291
+ nodePaths: serverNodePaths,
292
+ external: [],
293
+ // no externals — fully self-contained
294
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
142
295
  outfile: serverJsPath,
143
296
  minify: false,
144
297
  sourcemap: false,
145
298
  define: { "process.env.NODE_ENV": '"production"' }
146
299
  });
300
+ const clientComponents = [];
301
+ for (const file of input.files) {
302
+ if (isClientFile2(file.content, file.path)) {
303
+ const name = extractClientComponentName(file.content, file.path);
304
+ clientComponents.push({ name, filePath: file.path });
305
+ }
306
+ }
307
+ let clientEntryPoint = entryPoint;
308
+ if (clientComponents.length > 0) {
309
+ const entryRelative = import_node_path3.default.relative(tmpDir, entryPoint).replace(/\\/g, "/");
310
+ const importLines = clientComponents.map((c, i) => `import __reg${i}__ from './${c.filePath.replace(/\\/g, "/")}';`).join("\n");
311
+ const registryEntries = clientComponents.map((c, i) => ` '${c.name}': __reg${i}__`).join(",\n");
312
+ const registryWrapper = [
313
+ `export { default } from './${entryRelative}';`,
314
+ importLines,
315
+ `export const __registry__ = {
316
+ ${registryEntries}
317
+ };`
318
+ ].join("\n");
319
+ const registryEntryPath = import_node_path3.default.join(tmpDir, "__brodox_registry__.jsx");
320
+ await import_promises3.default.writeFile(registryEntryPath, registryWrapper, "utf8");
321
+ clientEntryPoint = registryEntryPath;
322
+ }
147
323
  await esbuild.build({
148
- entryPoints: [entryPoint],
324
+ entryPoints: [clientEntryPoint],
149
325
  bundle: true,
150
326
  format: "esm",
151
327
  platform: "browser",
152
328
  target: ["es2020", "chrome90", "firefox88", "safari14"],
153
- external,
329
+ jsx: "automatic",
330
+ external: CLIENT_EXTERNALS,
154
331
  plugins: [serverStubPlugin()],
155
332
  outfile: clientJsPath,
156
333
  minify: true,
157
334
  sourcemap: false,
158
- define: { "process.env.NODE_ENV": '"production"' }
335
+ define: { "process.env.NODE_ENV": '"production"' },
336
+ banner: { js: 'import React from "react";' }
159
337
  });
160
338
  await import_promises3.default.rm(tmpDir, { recursive: true, force: true });
161
339
  return {
package/dist/index.mjs CHANGED
@@ -1,24 +1,44 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
1
8
  // src/compiler.ts
2
9
  import * as esbuild from "esbuild";
3
10
  import fs3 from "fs/promises";
4
- import path from "path";
11
+ import path3 from "path";
5
12
  import os from "os";
6
13
  import { randomUUID } from "crypto";
7
14
 
8
15
  // src/plugins/clientStubPlugin.ts
9
16
  import fs from "fs/promises";
17
+ import path from "path";
18
+ function isClientFile(content, filePath) {
19
+ const trimmed = content.trimStart();
20
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true;
21
+ return /\.client\.[jt]sx?$/.test(filePath);
22
+ }
23
+ function extractName(content, filePath) {
24
+ const m = content.match(/export\s+default\s+function\s+(\w+)/) ?? content.match(/(?:^|\n)function\s+(\w+)/) ?? content.match(/(?:^|\n)const\s+(\w+)\s*=/);
25
+ const base = path.basename(filePath, path.extname(filePath)).replace(/\.client$/, "");
26
+ return m?.[1] ?? base;
27
+ }
10
28
  function clientStubPlugin() {
11
29
  return {
12
30
  name: "brodox-client-stub",
13
31
  setup(build2) {
14
32
  build2.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async (args) => {
15
33
  const content = await fs.readFile(args.path, "utf8");
16
- const trimmed = content.trimStart();
17
- if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
34
+ if (isClientFile(content, args.path)) {
35
+ const name = extractName(content, args.path);
18
36
  return {
19
37
  contents: `
20
38
  import React from 'react'
21
- export default function ClientStub() { return null }
39
+ function ${name}() { return null }
40
+ ${name}.displayName = "${name}"
41
+ export default ${name}
22
42
  export function getServerData() { return {} }
23
43
  `,
24
44
  loader: "jsx"
@@ -49,6 +69,114 @@ function serverStubPlugin() {
49
69
  };
50
70
  }
51
71
 
72
+ // src/plugins/runtimeServerStubPlugin.ts
73
+ import path2 from "path";
74
+ function runtimeServerStubPlugin(nodePaths = []) {
75
+ const resolveDir = nodePaths.length > 0 ? path2.dirname(nodePaths[0]) : process.cwd();
76
+ return {
77
+ name: "brodox-runtime-server-stub",
78
+ setup(build2) {
79
+ build2.onResolve({ filter: /^@broxium\/runtime$/ }, () => ({
80
+ path: "@broxium/runtime",
81
+ namespace: "brodox-runtime-server-stub"
82
+ }));
83
+ build2.onLoad({ filter: /.*/, namespace: "brodox-runtime-server-stub" }, () => ({
84
+ loader: "js",
85
+ resolveDir,
86
+ contents: `
87
+ import { createElement, Fragment, Children } from 'react';
88
+ var __islandSeq = 0;
89
+ function __nextIslandId() { return 'bi-' + (++__islandSeq) + '-' + Math.random().toString(36).slice(2,7); }
90
+
91
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
92
+ const maxW = width || 1920;
93
+ const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
94
+ if (!widths.length) widths.push(maxW);
95
+ const q = Math.min(100, Math.max(1, quality));
96
+ const enc = encodeURIComponent(src);
97
+ const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
98
+ const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
99
+ const imgStyle = fill
100
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
101
+ : (style || {});
102
+ return createElement('img', {
103
+ src: optimisedSrc, srcSet, sizes, alt: alt || '',
104
+ width: fill ? undefined : width, height: fill ? undefined : height,
105
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
106
+ className, style: imgStyle,
107
+ });
108
+ }
109
+
110
+ export function BrodoxLink({ href, children, className, style }) {
111
+ return createElement('a', { href, className, style }, children);
112
+ }
113
+
114
+ export function useRouter() {
115
+ return { pathname: '/', params: {}, navigate: function(){}, back: function(){}, forward: function(){}, prefetch: function(){} };
116
+ }
117
+
118
+ export function useParams() { return {}; }
119
+
120
+ export function BrodoxHead() { return null; }
121
+
122
+ export function BrodoxFont() { return null; }
123
+
124
+ export function BrodoxRouter({ children }) {
125
+ return createElement(Fragment, null, children);
126
+ }
127
+
128
+ /**
129
+ * <Client> \u2014 island boundary marker.
130
+ *
131
+ * During SSR emits an empty placeholder div plus a sibling
132
+ * <script type="application/json"> carrying the child's props.
133
+ * The IslandHydrator walks the live DOM and mounts the component from the
134
+ * parent bundle's __registry__ after page load.
135
+ */
136
+ export function Client({ children }) {
137
+ var id = __nextIslandId();
138
+ var child = Children.only(children);
139
+ var compType = child.type;
140
+ var name = typeof compType !== 'string' && compType
141
+ ? (compType.displayName || compType.name || 'Unknown')
142
+ : 'Unknown';
143
+ var props = child.props || {};
144
+ var safeProps = JSON.stringify(props)
145
+ .replace(/</g, '\\u003c')
146
+ .replace(/>/g, '\\u003e')
147
+ .replace(/&/g, '\\u0026');
148
+ return createElement(Fragment, null,
149
+ createElement('div', {
150
+ 'data-brodox-island': id,
151
+ 'data-hydration': 'load',
152
+ 'data-client-js': '',
153
+ 'data-component-slug': '',
154
+ 'data-version': '',
155
+ 'data-component': name,
156
+ }),
157
+ createElement('script', {
158
+ type: 'application/json',
159
+ 'data-brodox-props': id,
160
+ dangerouslySetInnerHTML: { __html: safeProps },
161
+ })
162
+ );
163
+ }
164
+
165
+ /**
166
+ * <Server> \u2014 semantic server-only wrapper. Transparent passthrough.
167
+ */
168
+ export function Server({ children }) {
169
+ return createElement(Fragment, null, children);
170
+ }
171
+
172
+ /** @deprecated Use <Client> instead. */
173
+ export var ClientRender = Client;
174
+ `
175
+ }));
176
+ }
177
+ };
178
+ }
179
+
52
180
  // src/compiler.ts
53
181
  var ENTRY_PRIORITY = [
54
182
  "App.tsx",
@@ -60,25 +188,53 @@ var ENTRY_PRIORITY = [
60
188
  "index.ts",
61
189
  "index.js"
62
190
  ];
63
- var external = [
191
+ function isClientFile2(content, filePath) {
192
+ const trimmed = content.trimStart();
193
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true;
194
+ return /\.client\.[jt]sx?$/.test(filePath);
195
+ }
196
+ function extractClientComponentName(content, filePath) {
197
+ const m = content.match(/export\s+default\s+function\s+(\w+)/) ?? content.match(/export\s+default\s+(\w+)\s*[;({]/) ?? content.match(/(?:^|\n)function\s+(\w+)/);
198
+ const base = path3.basename(filePath, path3.extname(filePath)).replace(/\.client$/, "");
199
+ return m?.[1] ?? base;
200
+ }
201
+ var CLIENT_EXTERNALS = [
64
202
  "react",
65
203
  "react-dom",
66
204
  "react/jsx-runtime",
67
205
  "react/jsx-dev-runtime",
68
206
  "@broxium/runtime"
69
207
  ];
208
+ function findReactNodeModules() {
209
+ const fsSync = __require("fs");
210
+ try {
211
+ const reactPkg = __require.resolve("react/package.json");
212
+ return [path3.dirname(path3.dirname(reactPkg))];
213
+ } catch {
214
+ }
215
+ let dir = process.cwd();
216
+ for (let i = 0; i < 10; i++) {
217
+ const nm = path3.join(dir, "node_modules");
218
+ const reactPkg = path3.join(nm, "react", "package.json");
219
+ if (fsSync.existsSync(reactPkg)) return [nm];
220
+ const parent = path3.dirname(dir);
221
+ if (parent === dir) break;
222
+ dir = parent;
223
+ }
224
+ return [];
225
+ }
70
226
  var BrodoxCompiler = class {
71
227
  async compile(input) {
72
- const tmpDir = path.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`);
228
+ const tmpDir = path3.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`);
73
229
  await fs3.mkdir(tmpDir, { recursive: true });
74
230
  for (const file of input.files) {
75
- const filePath = path.join(tmpDir, file.path);
76
- await fs3.mkdir(path.dirname(filePath), { recursive: true });
231
+ const filePath = path3.join(tmpDir, file.path);
232
+ await fs3.mkdir(path3.dirname(filePath), { recursive: true });
77
233
  await fs3.writeFile(filePath, file.content, "utf8");
78
234
  }
79
235
  let entryPoint = null;
80
236
  for (const candidate of ENTRY_PRIORITY) {
81
- const full = path.join(tmpDir, candidate);
237
+ const full = path3.join(tmpDir, candidate);
82
238
  try {
83
239
  await fs3.access(full);
84
240
  entryPoint = full;
@@ -92,34 +248,63 @@ var BrodoxCompiler = class {
92
248
  const safeName = `${input.slug}-v${input.version}`;
93
249
  const serverJsName = `${safeName}.server.esm.js`;
94
250
  const clientJsName = `${safeName}.client.esm.js`;
95
- const serverJsPath = path.join(input.outputDir, serverJsName);
96
- const clientJsPath = path.join(input.outputDir, clientJsName);
251
+ const serverJsPath = path3.join(input.outputDir, serverJsName);
252
+ const clientJsPath = path3.join(input.outputDir, clientJsName);
97
253
  await fs3.mkdir(input.outputDir, { recursive: true });
254
+ const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
98
255
  await esbuild.build({
99
256
  entryPoints: [entryPoint],
100
257
  bundle: true,
101
258
  format: "esm",
102
259
  platform: "node",
103
260
  target: "node20",
104
- external,
105
- plugins: [clientStubPlugin()],
261
+ jsx: "automatic",
262
+ nodePaths: serverNodePaths,
263
+ external: [],
264
+ // no externals — fully self-contained
265
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
106
266
  outfile: serverJsPath,
107
267
  minify: false,
108
268
  sourcemap: false,
109
269
  define: { "process.env.NODE_ENV": '"production"' }
110
270
  });
271
+ const clientComponents = [];
272
+ for (const file of input.files) {
273
+ if (isClientFile2(file.content, file.path)) {
274
+ const name = extractClientComponentName(file.content, file.path);
275
+ clientComponents.push({ name, filePath: file.path });
276
+ }
277
+ }
278
+ let clientEntryPoint = entryPoint;
279
+ if (clientComponents.length > 0) {
280
+ const entryRelative = path3.relative(tmpDir, entryPoint).replace(/\\/g, "/");
281
+ const importLines = clientComponents.map((c, i) => `import __reg${i}__ from './${c.filePath.replace(/\\/g, "/")}';`).join("\n");
282
+ const registryEntries = clientComponents.map((c, i) => ` '${c.name}': __reg${i}__`).join(",\n");
283
+ const registryWrapper = [
284
+ `export { default } from './${entryRelative}';`,
285
+ importLines,
286
+ `export const __registry__ = {
287
+ ${registryEntries}
288
+ };`
289
+ ].join("\n");
290
+ const registryEntryPath = path3.join(tmpDir, "__brodox_registry__.jsx");
291
+ await fs3.writeFile(registryEntryPath, registryWrapper, "utf8");
292
+ clientEntryPoint = registryEntryPath;
293
+ }
111
294
  await esbuild.build({
112
- entryPoints: [entryPoint],
295
+ entryPoints: [clientEntryPoint],
113
296
  bundle: true,
114
297
  format: "esm",
115
298
  platform: "browser",
116
299
  target: ["es2020", "chrome90", "firefox88", "safari14"],
117
- external,
300
+ jsx: "automatic",
301
+ external: CLIENT_EXTERNALS,
118
302
  plugins: [serverStubPlugin()],
119
303
  outfile: clientJsPath,
120
304
  minify: true,
121
305
  sourcemap: false,
122
- define: { "process.env.NODE_ENV": '"production"' }
306
+ define: { "process.env.NODE_ENV": '"production"' },
307
+ banner: { js: 'import React from "react";' }
123
308
  });
124
309
  await fs3.rm(tmpDir, { recursive: true, force: true });
125
310
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broxium/compiler",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Brodox component compiler — TSX to ESM server + client bundles",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
package/src/compiler.ts CHANGED
@@ -5,6 +5,7 @@ import os from 'node:os'
5
5
  import { randomUUID } from 'node:crypto'
6
6
  import { clientStubPlugin } from './plugins/clientStubPlugin'
7
7
  import { serverStubPlugin } from './plugins/serverStubPlugin'
8
+ import { runtimeServerStubPlugin } from './plugins/runtimeServerStubPlugin'
8
9
  import type { CompileInput, CompileOutput } from './types'
9
10
 
10
11
  const ENTRY_PRIORITY = [
@@ -12,7 +13,33 @@ const ENTRY_PRIORITY = [
12
13
  'index.tsx', 'index.jsx', 'index.ts', 'index.js',
13
14
  ]
14
15
 
15
- const external = [
16
+ /**
17
+ * Detect client component files by directive OR naming convention.
18
+ * *.client.(jsx|tsx) is the preferred convention — no 'use client' needed.
19
+ */
20
+ function isClientFile(content: string, filePath: string): boolean {
21
+ const trimmed = content.trimStart()
22
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true
23
+ return /\.client\.[jt]sx?$/.test(filePath)
24
+ }
25
+
26
+ /**
27
+ * Extract the default export function name from a client component file.
28
+ * Used to build the __registry__ export in the client bundle, keyed by the
29
+ * same name that clientStubPlugin sets as displayName in the server build.
30
+ * Strips .client from the basename fallback so "Navbar.client" → "Navbar".
31
+ */
32
+ function extractClientComponentName(content: string, filePath: string): string {
33
+ const m = content.match(/export\s+default\s+function\s+(\w+)/)
34
+ ?? content.match(/export\s+default\s+(\w+)\s*[;({]/)
35
+ ?? content.match(/(?:^|\n)function\s+(\w+)/)
36
+ const base = path.basename(filePath, path.extname(filePath)).replace(/\.client$/, '')
37
+ return m?.[1] ?? base
38
+ }
39
+
40
+ // ── Client build externals ─────────────────────────────────────────────────
41
+ // These are provided by the web-engine's import map in the browser.
42
+ const CLIENT_EXTERNALS = [
16
43
  'react',
17
44
  'react-dom',
18
45
  'react/jsx-runtime',
@@ -20,6 +47,36 @@ const external = [
20
47
  '@broxium/runtime',
21
48
  ]
22
49
 
50
+ // ── Server build: find React's node_modules directory ─────────────────────
51
+ // The server bundle must be self-contained (no bare module resolution from
52
+ // live-bundles/). We bundle React directly via esbuild's nodePaths so it
53
+ // can resolve 'react' during compilation. The bundled React is embedded
54
+ // in the output — no external resolution at runtime.
55
+ function findReactNodeModules(): string[] {
56
+ const fsSync = require('fs') as typeof import('fs')
57
+
58
+ // 1. Try require.resolve from the current module (works in most CJS contexts)
59
+ try {
60
+ const reactPkg = require.resolve('react/package.json')
61
+ return [path.dirname(path.dirname(reactPkg))]
62
+ } catch {}
63
+
64
+ // 2. Walk up the directory tree from process.cwd() looking for node_modules/react.
65
+ // When running inside brodox-core, react is installed there even if the
66
+ // current working directory is a sub-app that doesn't list react as a dep.
67
+ let dir = process.cwd()
68
+ for (let i = 0; i < 10; i++) {
69
+ const nm = path.join(dir, 'node_modules')
70
+ const reactPkg = path.join(nm, 'react', 'package.json')
71
+ if (fsSync.existsSync(reactPkg)) return [nm]
72
+ const parent = path.dirname(dir)
73
+ if (parent === dir) break
74
+ dir = parent
75
+ }
76
+
77
+ return []
78
+ }
79
+
23
80
  export class BrodoxCompiler {
24
81
  async compile(input: CompileInput): Promise<CompileOutput> {
25
82
  const tmpDir = path.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`)
@@ -52,32 +109,85 @@ export class BrodoxCompiler {
52
109
 
53
110
  await fs.mkdir(input.outputDir, { recursive: true })
54
111
 
112
+ // ── Server bundle ──────────────────────────────────────────────────────
113
+ // Self-contained: React is bundled in via nodePaths so the bundle loads
114
+ // from any directory without a node_modules beside it.
115
+ // nodePaths = caller-provided paths (from CompileInput) + auto-detected.
116
+ //
117
+ // @broxium/runtime → replaced by inline server stubs.
118
+ // 'use client' files → stubbed to null (render nothing server-side).
119
+ const serverNodePaths = [...(input.nodePaths ?? []), ...findReactNodeModules()]
120
+
55
121
  await esbuild.build({
56
122
  entryPoints: [entryPoint],
57
- bundle: true,
58
- format: 'esm',
123
+ bundle: true,
124
+ format: 'esm',
59
125
  platform: 'node',
60
- target: 'node20',
61
- external,
62
- plugins: [clientStubPlugin()],
63
- outfile: serverJsPath,
64
- minify: false,
126
+ target: 'node20',
127
+ jsx: 'automatic',
128
+ nodePaths: serverNodePaths,
129
+ external: [], // no externals — fully self-contained
130
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
131
+ outfile: serverJsPath,
132
+ minify: false,
65
133
  sourcemap: false,
66
- define: { 'process.env.NODE_ENV': '"production"' },
134
+ define: { 'process.env.NODE_ENV': '"production"' },
67
135
  })
68
136
 
137
+ // ── Client bundle ──────────────────────────────────────────────────────
138
+ // React and @broxium/runtime are external — provided by the web-engine's
139
+ // import map (/static/react.js, /static/broxium-runtime.js).
140
+ // 'use server' files are stubbed to null — they never run in the browser.
141
+ //
142
+ // If the component has any "use client" files, we generate a registry
143
+ // wrapper entry that re-exports `default` plus a `__registry__` map.
144
+ // The IslandHydrator uses __registry__ to mount sub-islands created by
145
+ // <ClientRender> inside server-only parent components.
146
+ //
147
+ // banner: always inject `import React from 'react'` so components that
148
+ // call React.createElement() directly (without JSX syntax) work correctly.
149
+ const clientComponents: Array<{ name: string; filePath: string }> = []
150
+ for (const file of input.files) {
151
+ if (isClientFile(file.content, file.path)) {
152
+ const name = extractClientComponentName(file.content, file.path)
153
+ clientComponents.push({ name, filePath: file.path })
154
+ }
155
+ }
156
+
157
+ let clientEntryPoint = entryPoint
158
+ if (clientComponents.length > 0) {
159
+ const entryRelative = path.relative(tmpDir, entryPoint).replace(/\\/g, '/')
160
+ const importLines = clientComponents
161
+ .map((c, i) => `import __reg${i}__ from './${c.filePath.replace(/\\/g, '/')}';`)
162
+ .join('\n')
163
+ const registryEntries = clientComponents
164
+ .map((c, i) => ` '${c.name}': __reg${i}__`)
165
+ .join(',\n')
166
+ const registryWrapper = [
167
+ `export { default } from './${entryRelative}';`,
168
+ importLines,
169
+ `export const __registry__ = {\n${registryEntries}\n};`,
170
+ ].join('\n')
171
+
172
+ const registryEntryPath = path.join(tmpDir, '__brodox_registry__.jsx')
173
+ await fs.writeFile(registryEntryPath, registryWrapper, 'utf8')
174
+ clientEntryPoint = registryEntryPath
175
+ }
176
+
69
177
  await esbuild.build({
70
- entryPoints: [entryPoint],
71
- bundle: true,
72
- format: 'esm',
178
+ entryPoints: [clientEntryPoint],
179
+ bundle: true,
180
+ format: 'esm',
73
181
  platform: 'browser',
74
- target: ['es2020', 'chrome90', 'firefox88', 'safari14'],
75
- external,
76
- plugins: [serverStubPlugin()],
77
- outfile: clientJsPath,
78
- minify: true,
182
+ target: ['es2020', 'chrome90', 'firefox88', 'safari14'],
183
+ jsx: 'automatic',
184
+ external: CLIENT_EXTERNALS,
185
+ plugins: [serverStubPlugin()],
186
+ outfile: clientJsPath,
187
+ minify: true,
79
188
  sourcemap: false,
80
- define: { 'process.env.NODE_ENV': '"production"' },
189
+ define: { 'process.env.NODE_ENV': '"production"' },
190
+ banner: { js: 'import React from "react";' },
81
191
  })
82
192
 
83
193
  await fs.rm(tmpDir, { recursive: true, force: true })
@@ -1,5 +1,29 @@
1
1
  import type { Plugin } from 'esbuild'
2
2
  import fs from 'node:fs/promises'
3
+ import path from 'node:path'
4
+
5
+ /**
6
+ * Detect client components by either:
7
+ * 1. 'use client' / "use client" directive at the top of the file (legacy)
8
+ * 2. *.client.(jsx|tsx|js|ts) filename convention (preferred — no directive needed)
9
+ *
10
+ * The naming convention lets developers avoid directives entirely and rely on
11
+ * <Client> / <Server> wrappers from @broxium/runtime for intent.
12
+ */
13
+ function isClientFile(content: string, filePath: string): boolean {
14
+ const trimmed = content.trimStart()
15
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true
16
+ return /\.client\.[jt]sx?$/.test(filePath)
17
+ }
18
+
19
+ function extractName(content: string, filePath: string): string {
20
+ const m = content.match(/export\s+default\s+function\s+(\w+)/)
21
+ ?? content.match(/(?:^|\n)function\s+(\w+)/)
22
+ ?? content.match(/(?:^|\n)const\s+(\w+)\s*=/)
23
+ // Strip .client from basename fallback so "Navbar.client" → "Navbar"
24
+ const base = path.basename(filePath, path.extname(filePath)).replace(/\.client$/, '')
25
+ return m?.[1] ?? base
26
+ }
3
27
 
4
28
  export function clientStubPlugin(): Plugin {
5
29
  return {
@@ -7,12 +31,14 @@ export function clientStubPlugin(): Plugin {
7
31
  setup(build) {
8
32
  build.onLoad({ filter: /\.(tsx?|jsx?)$/ }, async (args) => {
9
33
  const content = await fs.readFile(args.path, 'utf8')
10
- const trimmed = content.trimStart()
11
- if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) {
34
+ if (isClientFile(content, args.path)) {
35
+ const name = extractName(content, args.path)
12
36
  return {
13
37
  contents: `
14
38
  import React from 'react'
15
- export default function ClientStub() { return null }
39
+ function ${name}() { return null }
40
+ ${name}.displayName = "${name}"
41
+ export default ${name}
16
42
  export function getServerData() { return {} }
17
43
  `,
18
44
  loader: 'jsx',
@@ -0,0 +1,136 @@
1
+ import type { Plugin } from 'esbuild'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Replaces @broxium/runtime imports in the server bundle with server-safe
6
+ * inline implementations. This keeps the server bundle self-contained —
7
+ * no external module resolution needed from live-bundles/.
8
+ *
9
+ * BrodoxImage → <img> pointing to /api/image (WebP, srcset, lazy)
10
+ * BrodoxLink → <a href>
11
+ * Hooks → server-side no-ops / static defaults
12
+ * Client → island placeholder div + sibling props script
13
+ * Server → transparent passthrough (semantic marker only)
14
+ * ClientRender → alias for Client (deprecated)
15
+ *
16
+ * WHY nodePaths is required
17
+ * ─────────────────────────
18
+ * esbuild virtual modules (custom namespace) default to resolveDir="" which
19
+ * disables ALL bare-specifier resolution inside them. The stub content imports
20
+ * from 'react', so we derive a resolveDir from the caller-supplied nodePaths:
21
+ * the parent directory of the first node_modules entry that contains react is
22
+ * used as resolveDir so that esbuild can walk up and find node_modules/react.
23
+ */
24
+ export function runtimeServerStubPlugin(nodePaths: string[] = []): Plugin {
25
+ // Each nodePaths entry is a node_modules directory, e.g.
26
+ // /path/to/brodox-web-engine/node_modules
27
+ // resolveDir must be the PARENT so node module resolution walks into it:
28
+ // /path/to/brodox-web-engine
29
+ const resolveDir = nodePaths.length > 0
30
+ ? path.dirname(nodePaths[0])
31
+ : process.cwd()
32
+
33
+ return {
34
+ name: 'brodox-runtime-server-stub',
35
+ setup(build) {
36
+ build.onResolve({ filter: /^@broxium\/runtime$/ }, () => ({
37
+ path: '@broxium/runtime',
38
+ namespace: 'brodox-runtime-server-stub',
39
+ }))
40
+
41
+ build.onLoad({ filter: /.*/, namespace: 'brodox-runtime-server-stub' }, () => ({
42
+ loader: 'js',
43
+ resolveDir,
44
+ contents: `
45
+ import { createElement, Fragment, Children } from 'react';
46
+ var __islandSeq = 0;
47
+ function __nextIslandId() { return 'bi-' + (++__islandSeq) + '-' + Math.random().toString(36).slice(2,7); }
48
+
49
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
50
+ const maxW = width || 1920;
51
+ const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
52
+ if (!widths.length) widths.push(maxW);
53
+ const q = Math.min(100, Math.max(1, quality));
54
+ const enc = encodeURIComponent(src);
55
+ const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
56
+ const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
57
+ const imgStyle = fill
58
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
59
+ : (style || {});
60
+ return createElement('img', {
61
+ src: optimisedSrc, srcSet, sizes, alt: alt || '',
62
+ width: fill ? undefined : width, height: fill ? undefined : height,
63
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
64
+ className, style: imgStyle,
65
+ });
66
+ }
67
+
68
+ export function BrodoxLink({ href, children, className, style }) {
69
+ return createElement('a', { href, className, style }, children);
70
+ }
71
+
72
+ export function useRouter() {
73
+ return { pathname: '/', params: {}, navigate: function(){}, back: function(){}, forward: function(){}, prefetch: function(){} };
74
+ }
75
+
76
+ export function useParams() { return {}; }
77
+
78
+ export function BrodoxHead() { return null; }
79
+
80
+ export function BrodoxFont() { return null; }
81
+
82
+ export function BrodoxRouter({ children }) {
83
+ return createElement(Fragment, null, children);
84
+ }
85
+
86
+ /**
87
+ * <Client> — island boundary marker.
88
+ *
89
+ * During SSR emits an empty placeholder div plus a sibling
90
+ * <script type="application/json"> carrying the child's props.
91
+ * The IslandHydrator walks the live DOM and mounts the component from the
92
+ * parent bundle's __registry__ after page load.
93
+ */
94
+ export function Client({ children }) {
95
+ var id = __nextIslandId();
96
+ var child = Children.only(children);
97
+ var compType = child.type;
98
+ var name = typeof compType !== 'string' && compType
99
+ ? (compType.displayName || compType.name || 'Unknown')
100
+ : 'Unknown';
101
+ var props = child.props || {};
102
+ var safeProps = JSON.stringify(props)
103
+ .replace(/</g, '\\u003c')
104
+ .replace(/>/g, '\\u003e')
105
+ .replace(/&/g, '\\u0026');
106
+ return createElement(Fragment, null,
107
+ createElement('div', {
108
+ 'data-brodox-island': id,
109
+ 'data-hydration': 'load',
110
+ 'data-client-js': '',
111
+ 'data-component-slug': '',
112
+ 'data-version': '',
113
+ 'data-component': name,
114
+ }),
115
+ createElement('script', {
116
+ type: 'application/json',
117
+ 'data-brodox-props': id,
118
+ dangerouslySetInnerHTML: { __html: safeProps },
119
+ })
120
+ );
121
+ }
122
+
123
+ /**
124
+ * <Server> — semantic server-only wrapper. Transparent passthrough.
125
+ */
126
+ export function Server({ children }) {
127
+ return createElement(Fragment, null, children);
128
+ }
129
+
130
+ /** @deprecated Use <Client> instead. */
131
+ export var ClientRender = Client;
132
+ `,
133
+ }))
134
+ },
135
+ }
136
+ }
package/src/types.ts CHANGED
@@ -7,6 +7,8 @@ export interface CompileInput {
7
7
  content: string
8
8
  }>
9
9
  outputDir: string
10
+ /** Extra node_modules directories esbuild uses to resolve React for the server bundle. */
11
+ nodePaths?: string[]
10
12
  }
11
13
 
12
14
  export interface CompileOutput {