@broxium/compiler 1.0.0 → 1.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/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,112 @@ 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, useId, Children } from 'react';
117
+
118
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
119
+ const maxW = width || 1920;
120
+ const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
121
+ if (!widths.length) widths.push(maxW);
122
+ const q = Math.min(100, Math.max(1, quality));
123
+ const enc = encodeURIComponent(src);
124
+ const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
125
+ const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
126
+ const imgStyle = fill
127
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
128
+ : (style || {});
129
+ return createElement('img', {
130
+ src: optimisedSrc, srcSet, sizes, alt: alt || '',
131
+ width: fill ? undefined : width, height: fill ? undefined : height,
132
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
133
+ className, style: imgStyle,
134
+ });
135
+ }
136
+
137
+ export function BrodoxLink({ href, children, className, style }) {
138
+ return createElement('a', { href, className, style }, children);
139
+ }
140
+
141
+ export function useRouter() {
142
+ return { pathname: '/', params: {}, navigate: function(){}, back: function(){}, forward: function(){}, prefetch: function(){} };
143
+ }
144
+
145
+ export function useParams() { return {}; }
146
+
147
+ export function BrodoxHead() { return null; }
148
+
149
+ export function BrodoxFont() { return null; }
150
+
151
+ export function BrodoxRouter({ children }) {
152
+ return createElement(Fragment, null, children);
153
+ }
154
+
155
+ /**
156
+ * <Client> \u2014 island boundary marker.
157
+ *
158
+ * During SSR emits an empty placeholder div plus a sibling
159
+ * <script type="application/json"> carrying the child's props.
160
+ * The IslandHydrator walks the live DOM and mounts the component from the
161
+ * parent bundle's __registry__ after page load.
162
+ */
163
+ export function Client({ children }) {
164
+ var id = useId();
165
+ var child = Children.only(children);
166
+ var compType = child.type;
167
+ var name = typeof compType !== 'string' && compType
168
+ ? (compType.displayName || compType.name || 'Unknown')
169
+ : 'Unknown';
170
+ var props = child.props || {};
171
+ var safeProps = JSON.stringify(props)
172
+ .replace(/</g, '\\u003c')
173
+ .replace(/>/g, '\\u003e')
174
+ .replace(/&/g, '\\u0026');
175
+ return createElement(Fragment, null,
176
+ createElement('div', {
177
+ 'data-brodox-island': id,
178
+ 'data-hydration': 'load',
179
+ 'data-client-js': '',
180
+ 'data-component-slug': '',
181
+ 'data-version': '',
182
+ 'data-component': name,
183
+ }),
184
+ createElement('script', {
185
+ type: 'application/json',
186
+ 'data-brodox-props': id,
187
+ dangerouslySetInnerHTML: { __html: safeProps },
188
+ })
189
+ );
190
+ }
191
+
192
+ /**
193
+ * <Server> \u2014 semantic server-only wrapper. Transparent passthrough.
194
+ */
195
+ export function Server({ children }) {
196
+ return createElement(Fragment, null, children);
197
+ }
198
+
199
+ /** @deprecated Use <Client> instead. */
200
+ export var ClientRender = Client;
201
+ `
202
+ }));
203
+ }
204
+ };
205
+ }
206
+
88
207
  // src/compiler.ts
89
208
  var ENTRY_PRIORITY = [
90
209
  "App.tsx",
@@ -96,25 +215,53 @@ var ENTRY_PRIORITY = [
96
215
  "index.ts",
97
216
  "index.js"
98
217
  ];
99
- var external = [
218
+ function isClientFile2(content, filePath) {
219
+ const trimmed = content.trimStart();
220
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true;
221
+ return /\.client\.[jt]sx?$/.test(filePath);
222
+ }
223
+ function extractClientComponentName(content, filePath) {
224
+ 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+)/);
225
+ const base = import_node_path3.default.basename(filePath, import_node_path3.default.extname(filePath)).replace(/\.client$/, "");
226
+ return m?.[1] ?? base;
227
+ }
228
+ var CLIENT_EXTERNALS = [
100
229
  "react",
101
230
  "react-dom",
102
231
  "react/jsx-runtime",
103
232
  "react/jsx-dev-runtime",
104
233
  "@broxium/runtime"
105
234
  ];
235
+ function findReactNodeModules() {
236
+ const fsSync = require("fs");
237
+ try {
238
+ const reactPkg = require.resolve("react/package.json");
239
+ return [import_node_path3.default.dirname(import_node_path3.default.dirname(reactPkg))];
240
+ } catch {
241
+ }
242
+ let dir = process.cwd();
243
+ for (let i = 0; i < 10; i++) {
244
+ const nm = import_node_path3.default.join(dir, "node_modules");
245
+ const reactPkg = import_node_path3.default.join(nm, "react", "package.json");
246
+ if (fsSync.existsSync(reactPkg)) return [nm];
247
+ const parent = import_node_path3.default.dirname(dir);
248
+ if (parent === dir) break;
249
+ dir = parent;
250
+ }
251
+ return [];
252
+ }
106
253
  var BrodoxCompiler = class {
107
254
  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)()}`);
255
+ const tmpDir = import_node_path3.default.join(import_node_os.default.tmpdir(), `brodox-compile-${input.slug}-${(0, import_node_crypto.randomUUID)()}`);
109
256
  await import_promises3.default.mkdir(tmpDir, { recursive: true });
110
257
  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 });
258
+ const filePath = import_node_path3.default.join(tmpDir, file.path);
259
+ await import_promises3.default.mkdir(import_node_path3.default.dirname(filePath), { recursive: true });
113
260
  await import_promises3.default.writeFile(filePath, file.content, "utf8");
114
261
  }
115
262
  let entryPoint = null;
116
263
  for (const candidate of ENTRY_PRIORITY) {
117
- const full = import_node_path.default.join(tmpDir, candidate);
264
+ const full = import_node_path3.default.join(tmpDir, candidate);
118
265
  try {
119
266
  await import_promises3.default.access(full);
120
267
  entryPoint = full;
@@ -128,34 +275,63 @@ var BrodoxCompiler = class {
128
275
  const safeName = `${input.slug}-v${input.version}`;
129
276
  const serverJsName = `${safeName}.server.esm.js`;
130
277
  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);
278
+ const serverJsPath = import_node_path3.default.join(input.outputDir, serverJsName);
279
+ const clientJsPath = import_node_path3.default.join(input.outputDir, clientJsName);
133
280
  await import_promises3.default.mkdir(input.outputDir, { recursive: true });
281
+ const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
134
282
  await esbuild.build({
135
283
  entryPoints: [entryPoint],
136
284
  bundle: true,
137
285
  format: "esm",
138
286
  platform: "node",
139
287
  target: "node20",
140
- external,
141
- plugins: [clientStubPlugin()],
288
+ jsx: "automatic",
289
+ nodePaths: serverNodePaths,
290
+ external: [],
291
+ // no externals — fully self-contained
292
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
142
293
  outfile: serverJsPath,
143
294
  minify: false,
144
295
  sourcemap: false,
145
296
  define: { "process.env.NODE_ENV": '"production"' }
146
297
  });
298
+ const clientComponents = [];
299
+ for (const file of input.files) {
300
+ if (isClientFile2(file.content, file.path)) {
301
+ const name = extractClientComponentName(file.content, file.path);
302
+ clientComponents.push({ name, filePath: file.path });
303
+ }
304
+ }
305
+ let clientEntryPoint = entryPoint;
306
+ if (clientComponents.length > 0) {
307
+ const entryRelative = import_node_path3.default.relative(tmpDir, entryPoint).replace(/\\/g, "/");
308
+ const importLines = clientComponents.map((c, i) => `import __reg${i}__ from './${c.filePath.replace(/\\/g, "/")}';`).join("\n");
309
+ const registryEntries = clientComponents.map((c, i) => ` '${c.name}': __reg${i}__`).join(",\n");
310
+ const registryWrapper = [
311
+ `export { default } from './${entryRelative}';`,
312
+ importLines,
313
+ `export const __registry__ = {
314
+ ${registryEntries}
315
+ };`
316
+ ].join("\n");
317
+ const registryEntryPath = import_node_path3.default.join(tmpDir, "__brodox_registry__.jsx");
318
+ await import_promises3.default.writeFile(registryEntryPath, registryWrapper, "utf8");
319
+ clientEntryPoint = registryEntryPath;
320
+ }
147
321
  await esbuild.build({
148
- entryPoints: [entryPoint],
322
+ entryPoints: [clientEntryPoint],
149
323
  bundle: true,
150
324
  format: "esm",
151
325
  platform: "browser",
152
326
  target: ["es2020", "chrome90", "firefox88", "safari14"],
153
- external,
327
+ jsx: "automatic",
328
+ external: CLIENT_EXTERNALS,
154
329
  plugins: [serverStubPlugin()],
155
330
  outfile: clientJsPath,
156
331
  minify: true,
157
332
  sourcemap: false,
158
- define: { "process.env.NODE_ENV": '"production"' }
333
+ define: { "process.env.NODE_ENV": '"production"' },
334
+ banner: { js: 'import React from "react";' }
159
335
  });
160
336
  await import_promises3.default.rm(tmpDir, { recursive: true, force: true });
161
337
  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,112 @@ 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, useId, Children } from 'react';
88
+
89
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
90
+ const maxW = width || 1920;
91
+ const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
92
+ if (!widths.length) widths.push(maxW);
93
+ const q = Math.min(100, Math.max(1, quality));
94
+ const enc = encodeURIComponent(src);
95
+ const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
96
+ const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
97
+ const imgStyle = fill
98
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
99
+ : (style || {});
100
+ return createElement('img', {
101
+ src: optimisedSrc, srcSet, sizes, alt: alt || '',
102
+ width: fill ? undefined : width, height: fill ? undefined : height,
103
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
104
+ className, style: imgStyle,
105
+ });
106
+ }
107
+
108
+ export function BrodoxLink({ href, children, className, style }) {
109
+ return createElement('a', { href, className, style }, children);
110
+ }
111
+
112
+ export function useRouter() {
113
+ return { pathname: '/', params: {}, navigate: function(){}, back: function(){}, forward: function(){}, prefetch: function(){} };
114
+ }
115
+
116
+ export function useParams() { return {}; }
117
+
118
+ export function BrodoxHead() { return null; }
119
+
120
+ export function BrodoxFont() { return null; }
121
+
122
+ export function BrodoxRouter({ children }) {
123
+ return createElement(Fragment, null, children);
124
+ }
125
+
126
+ /**
127
+ * <Client> \u2014 island boundary marker.
128
+ *
129
+ * During SSR emits an empty placeholder div plus a sibling
130
+ * <script type="application/json"> carrying the child's props.
131
+ * The IslandHydrator walks the live DOM and mounts the component from the
132
+ * parent bundle's __registry__ after page load.
133
+ */
134
+ export function Client({ children }) {
135
+ var id = useId();
136
+ var child = Children.only(children);
137
+ var compType = child.type;
138
+ var name = typeof compType !== 'string' && compType
139
+ ? (compType.displayName || compType.name || 'Unknown')
140
+ : 'Unknown';
141
+ var props = child.props || {};
142
+ var safeProps = JSON.stringify(props)
143
+ .replace(/</g, '\\u003c')
144
+ .replace(/>/g, '\\u003e')
145
+ .replace(/&/g, '\\u0026');
146
+ return createElement(Fragment, null,
147
+ createElement('div', {
148
+ 'data-brodox-island': id,
149
+ 'data-hydration': 'load',
150
+ 'data-client-js': '',
151
+ 'data-component-slug': '',
152
+ 'data-version': '',
153
+ 'data-component': name,
154
+ }),
155
+ createElement('script', {
156
+ type: 'application/json',
157
+ 'data-brodox-props': id,
158
+ dangerouslySetInnerHTML: { __html: safeProps },
159
+ })
160
+ );
161
+ }
162
+
163
+ /**
164
+ * <Server> \u2014 semantic server-only wrapper. Transparent passthrough.
165
+ */
166
+ export function Server({ children }) {
167
+ return createElement(Fragment, null, children);
168
+ }
169
+
170
+ /** @deprecated Use <Client> instead. */
171
+ export var ClientRender = Client;
172
+ `
173
+ }));
174
+ }
175
+ };
176
+ }
177
+
52
178
  // src/compiler.ts
53
179
  var ENTRY_PRIORITY = [
54
180
  "App.tsx",
@@ -60,25 +186,53 @@ var ENTRY_PRIORITY = [
60
186
  "index.ts",
61
187
  "index.js"
62
188
  ];
63
- var external = [
189
+ function isClientFile2(content, filePath) {
190
+ const trimmed = content.trimStart();
191
+ if (trimmed.startsWith("'use client'") || trimmed.startsWith('"use client"')) return true;
192
+ return /\.client\.[jt]sx?$/.test(filePath);
193
+ }
194
+ function extractClientComponentName(content, filePath) {
195
+ 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+)/);
196
+ const base = path3.basename(filePath, path3.extname(filePath)).replace(/\.client$/, "");
197
+ return m?.[1] ?? base;
198
+ }
199
+ var CLIENT_EXTERNALS = [
64
200
  "react",
65
201
  "react-dom",
66
202
  "react/jsx-runtime",
67
203
  "react/jsx-dev-runtime",
68
204
  "@broxium/runtime"
69
205
  ];
206
+ function findReactNodeModules() {
207
+ const fsSync = __require("fs");
208
+ try {
209
+ const reactPkg = __require.resolve("react/package.json");
210
+ return [path3.dirname(path3.dirname(reactPkg))];
211
+ } catch {
212
+ }
213
+ let dir = process.cwd();
214
+ for (let i = 0; i < 10; i++) {
215
+ const nm = path3.join(dir, "node_modules");
216
+ const reactPkg = path3.join(nm, "react", "package.json");
217
+ if (fsSync.existsSync(reactPkg)) return [nm];
218
+ const parent = path3.dirname(dir);
219
+ if (parent === dir) break;
220
+ dir = parent;
221
+ }
222
+ return [];
223
+ }
70
224
  var BrodoxCompiler = class {
71
225
  async compile(input) {
72
- const tmpDir = path.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`);
226
+ const tmpDir = path3.join(os.tmpdir(), `brodox-compile-${input.slug}-${randomUUID()}`);
73
227
  await fs3.mkdir(tmpDir, { recursive: true });
74
228
  for (const file of input.files) {
75
- const filePath = path.join(tmpDir, file.path);
76
- await fs3.mkdir(path.dirname(filePath), { recursive: true });
229
+ const filePath = path3.join(tmpDir, file.path);
230
+ await fs3.mkdir(path3.dirname(filePath), { recursive: true });
77
231
  await fs3.writeFile(filePath, file.content, "utf8");
78
232
  }
79
233
  let entryPoint = null;
80
234
  for (const candidate of ENTRY_PRIORITY) {
81
- const full = path.join(tmpDir, candidate);
235
+ const full = path3.join(tmpDir, candidate);
82
236
  try {
83
237
  await fs3.access(full);
84
238
  entryPoint = full;
@@ -92,34 +246,63 @@ var BrodoxCompiler = class {
92
246
  const safeName = `${input.slug}-v${input.version}`;
93
247
  const serverJsName = `${safeName}.server.esm.js`;
94
248
  const clientJsName = `${safeName}.client.esm.js`;
95
- const serverJsPath = path.join(input.outputDir, serverJsName);
96
- const clientJsPath = path.join(input.outputDir, clientJsName);
249
+ const serverJsPath = path3.join(input.outputDir, serverJsName);
250
+ const clientJsPath = path3.join(input.outputDir, clientJsName);
97
251
  await fs3.mkdir(input.outputDir, { recursive: true });
252
+ const serverNodePaths = [...input.nodePaths ?? [], ...findReactNodeModules()];
98
253
  await esbuild.build({
99
254
  entryPoints: [entryPoint],
100
255
  bundle: true,
101
256
  format: "esm",
102
257
  platform: "node",
103
258
  target: "node20",
104
- external,
105
- plugins: [clientStubPlugin()],
259
+ jsx: "automatic",
260
+ nodePaths: serverNodePaths,
261
+ external: [],
262
+ // no externals — fully self-contained
263
+ plugins: [clientStubPlugin(), runtimeServerStubPlugin(serverNodePaths)],
106
264
  outfile: serverJsPath,
107
265
  minify: false,
108
266
  sourcemap: false,
109
267
  define: { "process.env.NODE_ENV": '"production"' }
110
268
  });
269
+ const clientComponents = [];
270
+ for (const file of input.files) {
271
+ if (isClientFile2(file.content, file.path)) {
272
+ const name = extractClientComponentName(file.content, file.path);
273
+ clientComponents.push({ name, filePath: file.path });
274
+ }
275
+ }
276
+ let clientEntryPoint = entryPoint;
277
+ if (clientComponents.length > 0) {
278
+ const entryRelative = path3.relative(tmpDir, entryPoint).replace(/\\/g, "/");
279
+ const importLines = clientComponents.map((c, i) => `import __reg${i}__ from './${c.filePath.replace(/\\/g, "/")}';`).join("\n");
280
+ const registryEntries = clientComponents.map((c, i) => ` '${c.name}': __reg${i}__`).join(",\n");
281
+ const registryWrapper = [
282
+ `export { default } from './${entryRelative}';`,
283
+ importLines,
284
+ `export const __registry__ = {
285
+ ${registryEntries}
286
+ };`
287
+ ].join("\n");
288
+ const registryEntryPath = path3.join(tmpDir, "__brodox_registry__.jsx");
289
+ await fs3.writeFile(registryEntryPath, registryWrapper, "utf8");
290
+ clientEntryPoint = registryEntryPath;
291
+ }
111
292
  await esbuild.build({
112
- entryPoints: [entryPoint],
293
+ entryPoints: [clientEntryPoint],
113
294
  bundle: true,
114
295
  format: "esm",
115
296
  platform: "browser",
116
297
  target: ["es2020", "chrome90", "firefox88", "safari14"],
117
- external,
298
+ jsx: "automatic",
299
+ external: CLIENT_EXTERNALS,
118
300
  plugins: [serverStubPlugin()],
119
301
  outfile: clientJsPath,
120
302
  minify: true,
121
303
  sourcemap: false,
122
- define: { "process.env.NODE_ENV": '"production"' }
304
+ define: { "process.env.NODE_ENV": '"production"' },
305
+ banner: { js: 'import React from "react";' }
123
306
  });
124
307
  await fs3.rm(tmpDir, { recursive: true, force: true });
125
308
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@broxium/compiler",
3
- "version": "1.0.0",
3
+ "version": "1.1.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,134 @@
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, useId, Children } from 'react';
46
+
47
+ export function BrodoxImage({ src, alt, width, height, fill, className, style, priority, quality = 75, sizes }) {
48
+ const maxW = width || 1920;
49
+ const widths = [320, 640, 768, 1024, 1280, 1920].filter(w => w <= maxW);
50
+ if (!widths.length) widths.push(maxW);
51
+ const q = Math.min(100, Math.max(1, quality));
52
+ const enc = encodeURIComponent(src);
53
+ const optimisedSrc = '/api/image?src=' + enc + '&w=' + maxW + '&q=' + q + '&fmt=webp';
54
+ const srcSet = widths.map(w => '/api/image?src=' + enc + '&w=' + w + '&q=' + q + '&fmt=webp ' + w + 'w').join(', ');
55
+ const imgStyle = fill
56
+ ? Object.assign({ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover' }, style || {})
57
+ : (style || {});
58
+ return createElement('img', {
59
+ src: optimisedSrc, srcSet, sizes, alt: alt || '',
60
+ width: fill ? undefined : width, height: fill ? undefined : height,
61
+ loading: priority ? 'eager' : 'lazy', decoding: 'async',
62
+ className, style: imgStyle,
63
+ });
64
+ }
65
+
66
+ export function BrodoxLink({ href, children, className, style }) {
67
+ return createElement('a', { href, className, style }, children);
68
+ }
69
+
70
+ export function useRouter() {
71
+ return { pathname: '/', params: {}, navigate: function(){}, back: function(){}, forward: function(){}, prefetch: function(){} };
72
+ }
73
+
74
+ export function useParams() { return {}; }
75
+
76
+ export function BrodoxHead() { return null; }
77
+
78
+ export function BrodoxFont() { return null; }
79
+
80
+ export function BrodoxRouter({ children }) {
81
+ return createElement(Fragment, null, children);
82
+ }
83
+
84
+ /**
85
+ * <Client> — island boundary marker.
86
+ *
87
+ * During SSR emits an empty placeholder div plus a sibling
88
+ * <script type="application/json"> carrying the child's props.
89
+ * The IslandHydrator walks the live DOM and mounts the component from the
90
+ * parent bundle's __registry__ after page load.
91
+ */
92
+ export function Client({ children }) {
93
+ var id = useId();
94
+ var child = Children.only(children);
95
+ var compType = child.type;
96
+ var name = typeof compType !== 'string' && compType
97
+ ? (compType.displayName || compType.name || 'Unknown')
98
+ : 'Unknown';
99
+ var props = child.props || {};
100
+ var safeProps = JSON.stringify(props)
101
+ .replace(/</g, '\\u003c')
102
+ .replace(/>/g, '\\u003e')
103
+ .replace(/&/g, '\\u0026');
104
+ return createElement(Fragment, null,
105
+ createElement('div', {
106
+ 'data-brodox-island': id,
107
+ 'data-hydration': 'load',
108
+ 'data-client-js': '',
109
+ 'data-component-slug': '',
110
+ 'data-version': '',
111
+ 'data-component': name,
112
+ }),
113
+ createElement('script', {
114
+ type: 'application/json',
115
+ 'data-brodox-props': id,
116
+ dangerouslySetInnerHTML: { __html: safeProps },
117
+ })
118
+ );
119
+ }
120
+
121
+ /**
122
+ * <Server> — semantic server-only wrapper. Transparent passthrough.
123
+ */
124
+ export function Server({ children }) {
125
+ return createElement(Fragment, null, children);
126
+ }
127
+
128
+ /** @deprecated Use <Client> instead. */
129
+ export var ClientRender = Client;
130
+ `,
131
+ }))
132
+ },
133
+ }
134
+ }
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 {