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:
@@ -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 & 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
|
||||
// =========================================================================
|
||||
|
||||
Reference in New Issue
Block a user