Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .github/workflows/compiler.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: HTML compiler prototype
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
compiler:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 24
- uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.14
- uses: dtolnay/rust-toolchain@stable
with:
toolchain: 1.96.0
- run: npm ci
- run: bun run compiler:build
- run: bun run res:build
env:
RAYON_NUM_THREADS: 2
- run: bun test compiler/oxc.test.mjs compiler/plugins.test.mjs
- run: bun compiler/check.mjs
- name: Install and build demo assets
working-directory: demo
run: |
npm ci
bun run build:vite
- run: bun compiler/check-demo.mjs
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
node_modules
lib
dist
dist
compiler/.toolchain/
.resx/
compiler/oxc/target/
35 changes: 35 additions & 0 deletions bench/compiler.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import {measure} from './runner.mjs';
import {cpus} from 'node:os';
import {createHash} from 'node:crypto';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {fileURLToPath} from 'node:url';
const fixturePath = fileURLToPath(new URL('../test/CompilerFixtures.js', import.meta.url));
let code = readFileSync(fixturePath, 'utf8');
const optimized = process.argv.includes('--optimized');
if (optimized) {
const {createResxTransform} = await import('../compiler/oxc-transform.mjs');
const transform = createResxTransform();
try { code = (await transform.transform(code, fixturePath)).code; }
finally { transform.close(); }
}
const module = {exports: {}};
new Function('require', 'module', 'exports', code)(createRequire(fixturePath), module, module.exports);
const fixtures = module.exports;
import {make} from '../src/Handlers.js';
import {renderSync, render} from '../src/vendor/hyperons.js';

// Compile these ordinary ReScript JSX fixtures in each mode before comparing.
const expected = `<main id="catalog"><h1>Reading room</h1>${Array.from({length: 100}, (_, i) => `<article class="book"><h2>Book ${i}</h2><p>A &lt;writer&gt;</p><footer>Read &amp; enjoy</footer></article>`).join('')}</main>`;
const tree = fixtures.catalog(100);
const handler = make(async () => undefined);
const cases = [
{name: 'jsx/catalog100-render', run: () => renderSync(tree), expected},
{name: 'jsx/catalog100-create-render', run: () => renderSync(fixtures.catalog(100)), expected},
{name: 'jsx/catalog100-async-render', run: () => render(fixtures.catalog(100)), expected, async: true},
{name: 'jsx/catalog100-request', run: () => handler.handleRequest({request: new Request('http://localhost/catalog'), render: async () => fixtures.catalog(100)}).then(response => response.text()), expected: '<!DOCTYPE html>' + expected, async: true},
];
const results = [];
for (const benchmark of cases) results.push(await measure(benchmark));
console.log(JSON.stringify({optimized, runtime: Bun.version, cpu: cpus()[0]?.model,
fixtureHash: createHash('sha256').update(readFileSync(new URL('../test/CompilerFixtures.js', import.meta.url))).digest('hex'), results}, null, 2));
98 changes: 98 additions & 0 deletions compiler/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# ResX HTML compiler prototype

A standalone Rust/Oxc process optimizes the JavaScript produced by **unmodified stock ReScript**. A bundler plugin applies its edits in memory. There is no generated ReScript project, second ReScript compilation, source rewrite, or runtime compiler.

```text
ordinary app .res files
→ stock ReScript (type checking and JavaScript generation)
→ ResX Oxc transform in the server bundler
→ ordinary application JavaScript bundle
```

## Run the demo

Requires Rust 1.96, Node, and Bun 1.3.14. From the repository root:

```sh
npm ci
bun run compiler:build
cd demo
npm ci
bun run build:vite
bun run build:res:templates
bun run start:templates
```

Open `/catalog`. The entry point is `demo/.resx/optimized/app.mjs`; run it from the demo directory so existing asset paths work. `bun ../compiler/build.mjs --baseline` produces the same application bundle without the transform. `--entry src/YourApp.js` selects another server entry. Compilation happens once before bundling. The build report separates stock compilation and bundling time.

## What gets generated

For an eligible stock JSX factory call such as:

```js
H.Elements.jsx("div", {className: "card", children: title})
```

the transform emits the equivalent of:

```js
H.Elements.template(__resxWriter0, [title])
function __resxWriter0(output, context, values) {
H.Elements.templateStatic(output, '<div class="card">');
H.Elements.templateChild(output, context, values[0]);
H.Elements.templateStatic(output, '</div>');
}
```

Adjacent static regions, including nested native elements, become one string. Dynamic expressions are captured once in their original order. Dynamic children use the existing renderer, preserving escaping, context, async components, and error handling. Writers are private. Stock `.js` files stay untouched; the bundle contains the optimized code.

The current local optimization uses scope-resolved JavaScript bindings, not CMT analysis. It recognizes approved ResX imports, rejects binding mutation/escapes, and handles native tags with literal `className`, `id`, and `title`. Unsupported props and void tags keep ordinary rendering. This assumes the framework runtime ABI is not monkey-patched elsewhere in the application. No cross-module component inlining is attempted. Typed ReScript analysis could later supply additional proofs without changing this integration.

## Integration and performance

`oxc-transform.mjs` provides `transform(code, filename)` and a Vite/Rollup-style adapter. `oxc-bun.mjs` provides the build-time Bun plugin used by the demo. A persistent native process parses modules with Oxc and returns edits; MagicString applies them and generates source maps. Content-addressed caching includes the native binary, adapter version, source, filename, and approved runtime paths. Changes invalidate the affected module automatically.

Bun and Vite/Rollup production builds are tested, including successive builds with changed source. Full development-server watch/HMR integration is not yet verified. Native binary distribution and installation are not packaged yet. The compiler remains opt-in and is not included in the published npm package.

## Verification and measurement

```sh
bun test compiler/oxc.test.mjs compiler/plugins.test.mjs
bun compiler/check.mjs
bun compiler/check-demo.mjs
bun bench/compiler.mjs
bun bench/compiler.mjs --optimized
```

The framework suite runs against both baseline and transformed bundles. Compiler tests also generate 60 combinations of attributes and children and compare optimized/unoptimized HTML and evaluation order. Dedicated compiler tests inspect generated code and fallback behavior. The demo check compares HTTP status, HTML, and CSS. Benchmarks compile before timing, validate output before and after samples, and compare the same stock fixture with/without optimization. Run each mode in fresh processes, serially. Results belong in the PR description, not checked-in measurement snapshots.

## A normal server-bundler plugin

After stock ReScript generates JavaScript, the build integration is just:

```js
import {resxBunPlugin} from "../compiler/oxc-bun.mjs";

await Bun.build({
entrypoints: ["src/Demo.js"],
target: "bun",
outdir: "dist-server",
plugins: [resxBunPlugin()],
});
```

The plugin starts and closes its compiler worker automatically for each build. Reusing the plugin for another build is supported. Modules that are not transformed remain available to other loader plugins. The in-memory cache retains only the latest source version per file.

Add the transform to the **server** bundle pipeline. The existing `res-x-vite-plugin.mjs` primarily builds browser assets; adding an HTML transform to that assets-only build would not optimize the server entry point. `resxOxcPlugin()` is available for server builds using Vite/Rollup, with lazy initialization and cleanup on bundle/watcher closure or build errors.

## Keep the compiler local and small

The compiler recognizes known runtime calls, builds a sequence of static HTML and dynamic child slots, and emits private writers. Adjacent static text is combined as it is collected. Generated names use Oxc's decoded symbol/reference information, including names in nested scopes, and writer parameters avoid capturing the runtime import. Existing runtime helpers own dynamic rendering semantics.

Useful references for this design:

- [Solid's SSR template transform](https://github.com/ryansolid/dom-expressions/blob/main/packages/babel-plugin-jsx-dom-expressions/src/ssr/template.js) hoists static templates and uses scope-aware generated identifiers. It also deduplicates templates; that is a possible later size optimization, not implemented here.
- [Vue's SSR code generation](https://github.com/vuejs/core/blob/main/packages/compiler-ssr/src/ssrCodegenTransform.ts) combines adjacent string parts while building output and delegates interpolation to runtime helpers.
- [solid-jsx-oxc](https://github.com/frank-iii/solid-jsx-oxc) demonstrates native Rust/Oxc transforms packaged behind ordinary bundler plugins and N-API bindings. N-API is a packaging option to evaluate if subprocess overhead becomes material; this prototype keeps its standalone executable.

The useful next integration step is prebuilt native binaries and a standard server-build entry point. Local attribute coverage can grow with differential tests against the renderer. A separate typed-analysis stage or cross-module optimizer is not required by this design.
30 changes: 30 additions & 0 deletions compiler/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import {execFileSync} from 'node:child_process';
import {writeFileSync, mkdirSync} from 'node:fs';
import {dirname, join, resolve} from 'node:path';
import {createRequire} from 'node:module';
import {resxBunPlugin} from './oxc-bun.mjs';

if (!globalThis.Bun) throw new Error('Run this build adapter with Bun');
const root = process.cwd();
const baseline = process.argv.includes('--baseline');
const entryIndex = process.argv.indexOf('--entry');
const entry = resolve(root, entryIndex < 0 ? 'src/Demo.js' : process.argv[entryIndex + 1]);
const require = createRequire(join(root, 'package.json'));
const cli = join(dirname(require.resolve('rescript/package.json')), 'cli/rescript.js');
const env = {...process.env, RAYON_NUM_THREADS: process.env.RAYON_NUM_THREADS || '2'};
delete env.RESCRIPT_BSC_EXE;
const start = performance.now();
execFileSync('node', [cli], {cwd: root, env, stdio: 'inherit'});
const stockCompileMs = performance.now() - start;
const plugin = baseline ? null : resxBunPlugin({cacheDir: join(root, '.resx/cache/oxc')});
const bundleStart = performance.now();
const outdir = join(root, baseline ? '.resx/baseline' : '.resx/optimized');
const result = await Bun.build({entrypoints: [entry], outdir, target: 'bun', format: 'esm',
define: {'process.env.NODE_ENV': JSON.stringify('production')},
naming: 'app.mjs', sourcemap: 'external', plugins: plugin ? [plugin] : []});
if (!result.success) throw new AggregateError(result.logs, 'Application bundle failed');
const report = {mode: baseline ? 'baseline' : 'oxc', entry, outdir, stockCompileMs,
bundleMs: performance.now() - bundleStart, totalMs: performance.now() - start, ...plugin?.stats};
mkdirSync(join(root, '.resx'), {recursive: true});
writeFileSync(join(root, '.resx/build-report.json'), JSON.stringify(report, null, 2) + '\n');
console.log(JSON.stringify(report));
50 changes: 50 additions & 0 deletions compiler/check-demo.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import {execFileSync, spawn} from 'node:child_process';
import {fileURLToPath} from 'node:url';

const demo = fileURLToPath(new URL('../demo', import.meta.url));
async function capture(baseline) {
execFileSync(process.execPath, ['../compiler/build.mjs', ...(baseline ? ['--baseline'] : [])], {cwd: demo, stdio: 'inherit'});
const cwd = demo;
const entry = baseline ? '.resx/baseline/app.mjs' : '.resx/optimized/app.mjs';
const server = spawn('bun', ['run', entry], {cwd, env: {...process.env, PORT: '0', NODE_ENV: 'production'}, stdio: ['ignore', 'pipe', 'inherit']});
try {
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Demo did not start')), 15000);
let output = '';
const cleanup = () => clearTimeout(timer);
server.on('error', error => { cleanup(); reject(error); });
server.on('exit', code => { cleanup(); reject(new Error(`Demo exited: ${code}`)); });
server.stdout.on('data', chunk => {
output += chunk;
const match = output.match(/Listening! on localhost:(\d+)/);
if (match) { cleanup(); resolve(match[1]); }
});
});
const results = [];
for (const [path, status] of [['/catalog', 200], ['/start', 200], ['/not-a-route', 404]]) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {signal: AbortSignal.timeout(10000)});
assert.equal(response.status, status);
const html = await response.text();
assert(html.startsWith('<!DOCTYPE html>'));
if (path === '/catalog') {
assert(html.includes('The reading room'));
assert.equal((html.match(/<article /g) || []).length, 3);
}
results.push({path, status: response.status, contentType: response.headers.get('content-type'), html});
if (path === '/catalog') {
const stylesheet = html.match(/href="([^"]+\.css)"/)?.[1];
assert(stylesheet, 'catalog must include its stylesheet');
const asset = await fetch(`http://127.0.0.1:${port}${stylesheet}`, {signal: AbortSignal.timeout(10000)});
assert.equal(asset.status, 200);
results.push({path: stylesheet, css: await asset.text()});
}
}
return results;
} finally {
server.kill();
}
}
const baseline = await capture(true);
assert.deepEqual(await capture(false), baseline, 'demo HTTP output must match baseline exactly');
console.log('Demo catalog, start page, and 404 responses match byte for byte.');
17 changes: 17 additions & 0 deletions compiler/check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import assert from 'node:assert/strict';
import {execFileSync} from 'node:child_process';
import {readFileSync, readdirSync} from 'node:fs';
import {resolve} from 'node:path';
import {resxBunPlugin} from './oxc-bun.mjs';
execFileSync('bun', ['run', 'res:build'], {stdio: 'inherit', env: {...process.env, RAYON_NUM_THREADS: '2'}});
const original = readFileSync('test/CompilerFixtures.js', 'utf8');
const entrypoints = readdirSync('test').filter(name => /\.test\.(mjs|js)$/.test(name)).map(name => resolve('test', name));
for (const mode of ['baseline', 'optimized']) {
const plugin = resxBunPlugin();
const build = await Bun.build({entrypoints, outdir: `.resx/tests/${mode}`, naming: '[name].js', target: 'bun', format: 'esm', plugins: mode === 'optimized' ? [plugin] : []});
assert(build.success, String(build.logs));
execFileSync('bun', ['test', ...build.outputs.filter(out => out.path.endsWith('.js')).map(out => out.path)], {stdio: 'inherit', env: {...process.env, BENCH_ROOT: process.cwd()}});
if (mode === 'optimized') assert(plugin.stats.templates > 0);
}
assert.equal(readFileSync('test/CompilerFixtures.js', 'utf8'), original);
console.log('Baseline and transformed application bundles pass the same tests; stock output is untouched.');
23 changes: 23 additions & 0 deletions compiler/oxc-bun.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {readFile} from 'node:fs/promises';
import {createResxTransform} from './oxc-transform.mjs';

// A regular build plugin: the bundler owns each compiler worker's lifetime.
export function resxBunPlugin(options) {
let stats;
return {
name: 'resx-oxc',
get stats() { return stats; },
setup(build) {
const transform = createResxTransform(options);
stats = transform.stats;
build.onEnd(() => transform.close());
build.onLoad({filter: /\.[cm]?js$/}, async ({path}) => {
const code = await readFile(path, 'utf8');
const result = await transform.transform(code, path);
// Let later plugins and Bun's loader handle modules we don't change.
if (!result) return undefined;
return {contents: result.code + '\n//# sourceMappingURL=data:application/json;base64,' + Buffer.from(result.map).toString('base64'), loader: 'js'};
});
},
};
}
Loading
Loading