Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ node_modules/
dist/
.rsc_cache/
nul
_test_*.mjs
*.log
test-prerender*.mjs
test-resume.mjs
115 changes: 111 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,120 @@

[![CI](https://github.com/renderffx/ppr-scratch/actions/workflows/ci.yml/badge.svg)](https://github.com/renderffx/ppr-scratch/actions/workflows/ci.yml)

Next.js-style Partial Prerendering engine using React 19 Canary.
Next.js-style Partial Prerendering engine using React 19 Canary (`prerenderToNodeStream` / `resumeToPipeableStream`).

```
## Quick Start

```bash
npm install
npm run build
npm start
# http://localhost:3000
```

## Commands

| Command | Description |
|---------|-------------|
| `npm run build` | Full pipeline: prereqs → clean → bundle → RSC flight → prerender |
| `npm start` | Start Express server on port 3000 |
| `npm run test` | Artifact contract tests |
| `npm run test:unit` | Unit tests (phase, dynamic-apis, flight-cache) |
| `npm run test:e2e` | End-to-end prerender→resume pipeline test |
| `npm run test:all` | All tests |
| `npm run dev:loop` | Build + all tests |
| `npm run clean` | Remove `dist/` |
| `npm run clean:cache` | Remove `.rsc_cache/` |
| `npm run prewarm` | Prewarm RSC cache entries |

## API

| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | Serves static prerendered shell HTML |
| GET | `/rsc-payload` | Raw RSC Flight v1 binary payload |
| GET | `/cache/:name` | Cached RSC entry by component name |
| POST | `/resume` | Resume postponed boundaries (JSON) |
| POST | `/resume/stream` | Resume boundaries as chunked HTML |
| GET | `/api/status` | Build status and artifact health |
| GET | `/api/manifest` | Raw build manifest |

Environment: `PORT` (default 3000), `PPR_RESUME_TIMEOUT` (default 10000ms).

## Architecture

### Build Pipeline

```
src/App.js ──esbuild──▶ dist/App.bundle.js
build-rsc.js │
└── react-server-dom-webpack ──▶ dist/rsc-payload.bin

build.js
├── setPhase('prerender')
├── prewarm-cache → .rsc_cache/*.bin
├── prerenderToNodeStream(App)
│ └── dynamic components throw NEVER promise
│ └── Suspense boundaries → postponed state
│ └── onShellReady fires → abort(request) via patch
│ └── resolves with {postponed, prelude}
├── prelude → dist/shell.html (<!--$?--> markers)
├── postponed JSON → dist/postponed.json
├── RSC payload embedded in shell </body>
└── manifest → dist/manifest.json
```

### Request Pipeline

```
GET / → shell.html (instant, no JS required)
POST /resume → resumeToPipeableStream(App, postponed)
→ pipes resumed content + $RC() swap scripts
POST /resume/stream → same, chunked HTML transfer
```

## React Patch

React's `prerenderToNodeStream` waits for all content (blocks on never-resolving promises). The `postinstall` script patches `onShellReady` from `void 0` to a function that calls `abort(request)`, which marks pending Suspense boundaries as real postponed state.

Targets: `node_modules/react-dom/cjs/react-dom-server.node.{production,development}.js`

Run manually: `node scripts/patch-react-dom.mjs`

## Project Structure

```
├── src/
│ ├── App.js React app with static + dynamic boundaries
│ ├── phase.js Prerender/request phase management
│ ├── dynamic-apis.js Cookies, headers, searchParams (suspend during prerender)
│ ├── ErrorBoundary.js Catch errors per boundary
│ ├── flight-cache.js RSC Flight cache (memory + disk, TTL 5min)
│ └── cache-registry.js Component definitions for cache prewarming
├── scripts/
│ ├── patch-react-dom.mjs React abort-on-shellReady patch
│ └── check-prereqs.mjs Node/npm/patch validation
├── tests/
│ ├── ppr.test.js Artifact contract tests
│ ├── unit.test.js Unit tests (phase, dynamic-apis, flight-cache)
│ └── ppr-e2e.test.js Prerender→resume end-to-end
├── server.js Express server
├── build.js PPR build orchestrator
├── build-rsc.js RSC Flight payload builder
└── prewarm-cache.mjs Cache prewarming
```

## Output Artifacts

| File | Contents |
|------|----------|
| `dist/shell.html` | Static HTML with `<!--$?-->` markers + embedded RSC payload |
| `dist/postponed.json` | React postponed state (resumableState, replayNodes) |
| `dist/rsc-payload.bin` | RSC Flight v1 binary protocol payload |
| `dist/manifest.json` | Build metadata (boundaries, cache entries, timestamps) |
| `.rsc_cache/*.bin` | Per-component RSC Flight cache entries |

---
## License

**Author:** [@infinterenders](https://x.com/infinterenders) · [renderffx](https://github.com/renderffx)
ISC &mdash; &copy; 2026 renderffx
21 changes: 14 additions & 7 deletions scripts/patch-react-dom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ const FILES = [
resolve(__dirname, '../node_modules/react-dom/cjs/react-dom-server.node.development.js'),
];

// Pattern: onShellReady is void 0 (needs patching)
const SEARCH = 'resolve(readable);\n },\n void 0,\n void 0,\n reject';
const REPLACE = 'resolve(readable);\n },\n function () {\n abort(request);\n },\n void 0,\n reject';
const RE = /(resolve\(readable\);\s*\n\s*\},)\s*\n\s*void\s+0\s*,\s*\n(\s*void\s+0\s*,\s*\n\s*reject)/;
const REPLACE = '$1\n function () {\n abort(request);\n },\n$2';

let patched = 0;

Expand All @@ -23,16 +22,24 @@ for (const filePath of FILES) {
continue;
}

if (content.includes(REPLACE)) continue;
if (content.includes('abort(request)')) {
console.log(` [OK] already patched: ${filePath}`);
continue;
}

if (!content.includes(SEARCH)) continue;
if (!RE.test(content)) {
console.warn(` [WARN] pattern not found in: ${filePath}`);
continue;
}

content = content.replace(SEARCH, REPLACE);
content = content.replace(RE, REPLACE);
writeFileSync(filePath, content, 'utf-8');
patched++;
console.log(` patched: ${filePath}`);
console.log(` [PATCH] applied: ${filePath}`);
}

if (patched > 0) {
console.log(`\nReact DOM patch complete: ${patched} file(s) patched`);
} else {
console.log('\nNo files needed patching.');
}
70 changes: 51 additions & 19 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ import { getCachedBuffer } from './src/flight-cache.js';

const app = express();
const PORT = process.env.PORT || 3000;
const RESUME_TIMEOUT_MS = parseInt(process.env.PPR_RESUME_TIMEOUT || '10000', 10);
const RESUME_TIMEOUT_MS = Math.max(1000, parseInt(process.env.PPR_RESUME_TIMEOUT || '10000', 10));

app.use(express.json());
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
app.use(express.static('./public', { index: false }));

const CACHED_NAMES = ['CookieBasedGreeting', 'HeaderBasedContent', 'AsyncDataWidget', 'AuthBasedSection'];
const VALID_CACHE_NAMES = new Set(CACHED_NAMES);

function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (m) {
return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#x27;' })[m];
});
}

function withTimeout(promise, ms, label) {
return Promise.race([
Expand Down Expand Up @@ -51,24 +59,41 @@ app.get('/rsc-payload', (req, res) => {

app.get('/cache/:name', async (req, res) => {
const { name } = req.params;
const buf = await getCachedBuffer(name, {});
if (!buf) {
return res.status(404).json({ error: `No cache entry for ${name}` });
if (!VALID_CACHE_NAMES.has(name)) {
return res.status(400).json({ error: `Invalid cache name. Valid: ${CACHED_NAMES.join(', ')}` });
}
try {
const buf = await getCachedBuffer(name, {});
if (!buf) {
return res.status(404).json({ error: `No cache entry for ${name}` });
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Cache-Entry', name);
res.setHeader('Content-Length', buf.length);
res.send(buf);
} catch (err) {
res.status(500).json({ error: `Cache read failed: ${err.message}` });
}
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('X-Cache-Entry', name);
res.setHeader('Content-Length', buf.length);
res.send(buf);
});

function readPostponed(path) {
const raw = readFileSync(path, 'utf-8');
const parsed = JSON.parse(raw);
if (!parsed.resumableState || !Array.isArray(parsed.replayNodes)) {
throw new Error('Invalid postponed state: missing resumableState or replayNodes');
}
return parsed;
}

app.post('/resume', async (req, res) => {
const postponedPath = './dist/postponed.json';
if (!existsSync(postponedPath)) {
return res.status(400).json({ error: 'No postponed state available. Run build first.' });
}

try {
const hasCachedContent = CACHED_NAMES.some(n => getCachedBuffer(n, {}));
const cacheChecks = await Promise.all(CACHED_NAMES.map(n => getCachedBuffer(n, {})));
const hasCachedContent = cacheChecks.some(Boolean);

if (hasCachedContent) {
const resumedHtml = [];
Expand All @@ -77,9 +102,9 @@ app.post('/resume', async (req, res) => {
if (buf) {
resumedHtml.push(
`<div class="ppr-cached-boundary" data-component="${name}">` +
`<h3>${name} <span class="badge">served from RSC cache</span></h3>` +
`<h3>${escapeHtml(name)} <span class="badge">served from RSC cache</span></h3>` +
`<p class="meta">Cached RSC Flight payload: ${buf.length} bytes</p>` +
`<pre class="cache-key">Cache key: ${name}:{}</pre>` +
`<pre class="cache-key">Cache key: ${escapeHtml(name)}:{}</pre>` +
`</div>`
);
}
Expand All @@ -93,7 +118,7 @@ app.post('/resume', async (req, res) => {
});
}

const postponed = JSON.parse(readFileSync(postponedPath, 'utf-8'));
const postponed = readPostponed(postponedPath);
const chunks = [];

const html = await withTimeout(new Promise((resolve, reject) => {
Expand All @@ -112,9 +137,6 @@ app.post('/resume', async (req, res) => {
onShellError(err) {
reject(err);
},
onAllReady() {
// All content has been resumed — ensure flush
},
});
}), RESUME_TIMEOUT_MS, 'Resume');

Expand Down Expand Up @@ -157,7 +179,7 @@ app.post('/resume/stream', async (req, res) => {
if (buf) {
res.write(
`<div class="streamed-boundary" data-component="${name}">` +
`<h3>${name} (Streamed from RSC cache)</h3>` +
`<h3>${escapeHtml(name)} (Streamed from RSC cache)</h3>` +
`<p>RSC payload: ${buf.length} bytes</p>` +
`<p class="meta">Served at: ${new Date().toISOString()}</p>` +
`</div>`
Expand All @@ -166,7 +188,7 @@ app.post('/resume/stream', async (req, res) => {
}
}
} else {
const postponed = JSON.parse(readFileSync(postponedPath, 'utf-8'));
const postponed = readPostponed(postponedPath);
const streamable = resumeToPipeableStream(createElement(App), postponed, {
onShellReady() {
streamable.pipe(res);
Expand All @@ -181,7 +203,7 @@ app.post('/resume/stream', async (req, res) => {
res.write('<!--PPR_RESUME_STREAM_END-->');
res.end();
} catch (err) {
res.status(500).json({ error: err.message });
try { res.status(500).json({ error: err.message }); } catch {}
}
});

Expand Down Expand Up @@ -222,6 +244,16 @@ app.get('/api/manifest', (req, res) => {
res.json(JSON.parse(readFileSync('./dist/manifest.json', 'utf-8')));
});

app.use((req, res) => {
res.status(404).json({ error: `Not found: ${req.method} ${req.path}` });
});

app.use((err, req, res, _next) => {
console.error(`[ERROR] ${req.method} ${req.path}:`, err);
if (res.headersSent) return;
res.status(500).json({ error: 'Internal server error' });
});

app.listen(PORT, () => {
console.log(`PPR server running on http://localhost:${PORT}`);
console.log(` GET / - Static shell`);
Expand Down
56 changes: 0 additions & 56 deletions test-prerender.mjs

This file was deleted.

Loading
Loading