Step 17: streaming render — hyperscript enhancements, WASM builds, live server tests

Streaming chunked transfer with shell-first suspense and resolve scripts.
Hyperscript parser/compiler/runtime expanded for conformance. WASM static
assets added to OCaml host. Playwright streaming and page-level test suites.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 08:41:38 +00:00
parent 7aefe4da8f
commit 6e27442d57
29 changed files with 65959 additions and 628 deletions

View File

@@ -387,6 +387,178 @@ test.describe('Streaming sandbox', () => {
});
});
// =========================================================================
// Chunked transfer test — spins up a real HTTP server with chunked encoding,
// serves the actual page using the real WASM kernel + sx-platform.js +
// component defs. Verifies resolve scripts execute and fill suspense slots.
// =========================================================================
const http = require('http');
function buildStreamingPage() {
// Read component defs (same as server sends in <script type="text/sx">)
const compFiles = [
'shared/sx/templates/tw.sx', 'shared/sx/templates/tw-layout.sx',
'shared/sx/templates/tw-type.sx', 'shared/sx/templates/pages.sx',
'sx/sx/streaming-demo.sx',
];
const compDefs = compFiles.map(f => readFile(f)).join('\n');
// Shell body — suspense placeholders with script src tags (like real site)
const shellBody = `<!doctype html><html><head><meta charset="utf-8"></head><body>
<div id="sx-root">
<h1>Streaming &amp; Suspense Demo</h1>
<div data-suspense="stream-fast" id="sx-suspense-stream-fast" style="display:contents">
<div class="animate-pulse">Loading fast...</div>
</div>
<div data-suspense="stream-medium" id="sx-suspense-stream-medium" style="display:contents">
<div class="animate-pulse">Loading medium...</div>
</div>
<div data-suspense="stream-slow" id="sx-suspense-stream-slow" style="display:contents">
<div class="animate-pulse">Loading slow...</div>
</div>
</div>
<script type="text/sx">${compDefs.replace(/<\//g, '<\\/')}</script>
<script src="/wasm/sx_browser.bc.js"></script>
<script src="/wasm/sx-platform.js"></script>`;
// Bootstrap (same as _sx_streaming_bootstrap in sx_server.ml)
const bootstrap = `<script>window.__sxPending=[];window.__sxResolve=function(i,s){` +
`if(window.Sx&&Sx.resolveSuspense){Sx.resolveSuspense(i,s)}` +
`else{window.__sxPending.push({id:i,sx:s})}}</script>`;
// Resolve scripts (same as sx_streaming_resolve_script produces)
const resolves = [
{ id: 'stream-fast', sx: '(div "Fast source resolved")', delay: 500 },
{ id: 'stream-medium', sx: '(div "Medium source resolved")', delay: 1000 },
{ id: 'stream-slow', sx: '(div "Slow source resolved")', delay: 1500 },
];
const tail = '\n</body></html>';
return { shellBody, bootstrap, resolves, tail };
}
function startStreamingServer() {
const parts = buildStreamingPage();
const wasmSrc = fs.readFileSync(path.join(WASM_DIR, 'sx_browser.bc.js'), 'utf8');
const platformSrc = fs.readFileSync(path.join(PROJECT_ROOT, 'shared/static/wasm/sx-platform.js'), 'utf8');
const server = http.createServer((req, res) => {
// Serve static files from wasm directory (kernel, platform, .sxbc modules)
if (req.url.startsWith('/wasm/') || req.url.startsWith('/static/wasm/')) {
const relPath = req.url.replace('/static', '');
const filePath = path.join(WASM_DIR, relPath.replace('/wasm/', ''));
try {
const data = fs.readFileSync(filePath);
const ct = filePath.endsWith('.js') ? 'application/javascript'
: filePath.endsWith('.sx') ? 'text/plain' : 'application/octet-stream';
res.writeHead(200, { 'Content-Type': ct });
res.end(data);
return;
} catch(e) {
res.writeHead(404);
res.end('Not found: ' + filePath);
return;
}
}
// Streaming page — chunked transfer
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Transfer-Encoding': 'chunked',
});
// Chunk 1: shell body (suspense placeholders + script tags for kernel/platform)
res.write(parts.shellBody);
// Chunk 2: bootstrap
res.write(parts.bootstrap);
// Chunks 3-5: resolve scripts with staggered delays
let i = 0;
function sendNext() {
if (i >= parts.resolves.length) {
res.end(parts.tail);
return;
}
const r = parts.resolves[i++];
setTimeout(() => {
const script = `<script>window.__sxResolve&&window.__sxResolve(${JSON.stringify(r.id)},${JSON.stringify(r.sx)})</script>`;
res.write(script);
sendNext();
}, r.delay);
}
sendNext();
});
return new Promise(resolve => {
server.listen(0, () => resolve(server));
});
}
test.describe('Streaming chunked server', () => {
test.describe.configure({ timeout: 120000 });
let server;
let serverUrl;
test.beforeAll(async () => {
server = await startStreamingServer();
const port = server.address().port;
serverUrl = `http://localhost:${port}`;
});
test.afterAll(async () => {
if (server) server.close();
});
test('suspense slots resolve via chunked transfer', async ({ page }) => {
const consoleErrors = [];
page.on('console', msg => { if (msg.type() === 'error') consoleErrors.push(msg.text()); });
page.on('pageerror', e => consoleErrors.push('PAGE_ERROR: ' + e.message));
await page.goto(serverUrl, { waitUntil: 'commit', timeout: 60000 });
// Wait for WASM kernel + platform to boot
await page.waitForFunction('!!window.Sx && !!window.Sx.resolveSuspense', { timeout: 60000 });
// Shell should render with 3 suspense placeholders
await expect(page.locator('[data-suspense]')).toHaveCount(3);
// Debug: check state + console errors
const dbg = await page.evaluate(() => ({
pending: window.__sxPending,
fastText: document.querySelector('[data-suspense="stream-fast"]')?.textContent?.substring(0, 40),
}));
console.log('CHUNKED DEBUG:', JSON.stringify(dbg), 'errors:', consoleErrors.slice(0, 5));
// Wait for all resolves (500ms + 1000ms + 1500ms = 3s total, plus boot time)
await expect(page.locator('[data-suspense="stream-fast"]'))
.toContainText('Fast source resolved', { timeout: 15000 });
await expect(page.locator('[data-suspense="stream-medium"]'))
.toContainText('Medium source resolved', { timeout: 15000 });
await expect(page.locator('[data-suspense="stream-slow"]'))
.toContainText('Slow source resolved', { timeout: 15000 });
});
test('__sxResolve is defined after boot', async ({ page }) => {
await page.goto(serverUrl, { waitUntil: 'commit', timeout: 60000 });
// Wait for full boot (not just Sx.resolveSuspense, which is available eagerly)
await page.waitForFunction(
'!!document.documentElement.getAttribute("data-sx-ready")',
{ timeout: 60000 }
);
const state = await page.evaluate(() => ({
resolveType: typeof window.__sxResolve,
hasSx: typeof window.Sx,
sxKeys: window.Sx ? Object.keys(window.Sx).join(',') : 'no Sx',
sxResolveSuspense: typeof (window.Sx && Sx.resolveSuspense),
pending: window.__sxPending,
}));
expect(state.resolveType).toBe('function');
expect(state.sxResolveSuspense).toBe('function');
// Pending should be drained (null) after boot
expect(state.pending).toBeNull();
});
});
// =========================================================================
// Live server tests — verify the actual chunked response works end-to-end
// =========================================================================