diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index ca2608a8..f414d425 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -2079,11 +2079,30 @@ let http_render_page_streaming env path _headers fd page_name = in let t1 = Unix.gettimeofday () in - (* Phase 2: Send chunked header + shell HTML *) + (* Phase 2: Send chunked header + shell HTML. + Strip closing from shell — resolve scripts must go INSIDE + the body, otherwise the browser's HTML parser ignores them. *) + let shell_body, shell_tail = + (* Find last and split there *) + let s = shell_html in + let body_close = "" in + let rec find_last i found = + if i < 0 then found + else if i + String.length body_close <= String.length s + && String.sub s i (String.length body_close) = body_close + then find_last (i - 1) i + else find_last (i - 1) found + in + let pos = find_last (String.length s - String.length body_close) (-1) in + if pos >= 0 then + (String.sub s 0 pos, String.sub s pos (String.length s - pos)) + else + (s, "") + in let header = http_chunked_header () in let header_bytes = Bytes.of_string header in (try ignore (Unix.write fd header_bytes 0 (Bytes.length header_bytes)) with _ -> ()); - write_chunk fd shell_html; + write_chunk fd shell_body; (* Bootstrap resolve script — must come after shell so suspense elements exist *) write_chunk fd _sx_streaming_bootstrap; let t2 = Unix.gettimeofday () in @@ -2170,7 +2189,8 @@ let http_render_page_streaming env path _headers fd page_name = end else Printf.eprintf "[sx-stream] %s shell=%.3fs (no :data/:content)\n%!" path (t1 -. t0); - (* Phase 4: End chunked response *) + (* Phase 4: Send closing tags + end chunked response *) + if shell_tail <> "" then write_chunk fd shell_tail; end_chunked fd (* ====================================================================== *) diff --git a/tests/playwright/streaming.spec.js b/tests/playwright/streaming.spec.js index e84fde61..6d58dcf4 100644 --- a/tests/playwright/streaming.spec.js +++ b/tests/playwright/streaming.spec.js @@ -386,3 +386,46 @@ test.describe('Streaming sandbox', () => { await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('~5s'); }); }); + +// ========================================================================= +// Live server tests — verify the actual chunked response works end-to-end +// ========================================================================= +const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; + +test.describe('Streaming live server', () => { + test.describe.configure({ timeout: 30000 }); + + test('streaming page resolves suspense slots from server', async ({ page }) => { + // Load the streaming page from the actual server + await page.goto(BASE_URL + '/sx/(geography.(isomorphism.streaming))', { + waitUntil: 'networkidle', + timeout: 20000, + }); + + // Should have 3 suspense slots + await expect(page.locator('[data-suspense]')).toHaveCount(3); + + // All 3 slots should have resolved content (not empty, not just skeletons) + await expect(page.locator('[data-suspense="stream-fast"]')).toContainText('Fast source', { timeout: 15000 }); + await expect(page.locator('[data-suspense="stream-medium"]')).toContainText('Medium source', { timeout: 15000 }); + await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('Slow source', { timeout: 15000 }); + }); + + test('no chunked encoding errors in console', async ({ page }) => { + const errors = []; + page.on('pageerror', e => errors.push(e.message)); + page.on('console', msg => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + await page.goto(BASE_URL + '/sx/(geography.(isomorphism.streaming))', { + waitUntil: 'networkidle', + timeout: 20000, + }); + + // Filter for chunked encoding errors specifically + const chunkErrors = errors.filter(e => + e.includes('CHUNKED') || e.includes('INCOMPLETE') || e.includes('ERR_EMPTY')); + expect(chunkErrors).toEqual([]); + }); +});