@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 +251 -0
- package/dist/index.d.mts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +192 -16
- package/dist/index.mjs +199 -16
- package/package.json +1 -1
- package/src/compiler.ts +128 -18
- package/src/plugins/clientStubPlugin.ts +29 -3
- package/src/plugins/runtimeServerStubPlugin.ts +134 -0
- package/src/types.ts +2 -0
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
package/dist/index.d.ts
CHANGED
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
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
112
|
-
await import_promises3.default.mkdir(
|
|
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 =
|
|
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 =
|
|
132
|
-
const clientJsPath =
|
|
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
|
-
|
|
141
|
-
|
|
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: [
|
|
322
|
+
entryPoints: [clientEntryPoint],
|
|
149
323
|
bundle: true,
|
|
150
324
|
format: "esm",
|
|
151
325
|
platform: "browser",
|
|
152
326
|
target: ["es2020", "chrome90", "firefox88", "safari14"],
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
76
|
-
await fs3.mkdir(
|
|
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 =
|
|
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 =
|
|
96
|
-
const clientJsPath =
|
|
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
|
-
|
|
105
|
-
|
|
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: [
|
|
293
|
+
entryPoints: [clientEntryPoint],
|
|
113
294
|
bundle: true,
|
|
114
295
|
format: "esm",
|
|
115
296
|
platform: "browser",
|
|
116
297
|
target: ["es2020", "chrome90", "firefox88", "safari14"],
|
|
117
|
-
|
|
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
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
|
-
|
|
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:
|
|
58
|
-
format:
|
|
123
|
+
bundle: true,
|
|
124
|
+
format: 'esm',
|
|
59
125
|
platform: 'node',
|
|
60
|
-
target:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
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: [
|
|
71
|
-
bundle:
|
|
72
|
-
format:
|
|
178
|
+
entryPoints: [clientEntryPoint],
|
|
179
|
+
bundle: true,
|
|
180
|
+
format: 'esm',
|
|
73
181
|
platform: 'browser',
|
|
74
|
-
target:
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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:
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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