Fix streaming: resolve scripts inside </body>, live server tests
The shell HTML included closing </body></html> tags. Resolve script chunks arrived AFTER the document end — browser ignored them (ERR_INCOMPLETE_CHUNKED_ENCODING). Now strips </body></html> from shell, sends resolve scripts inside the body, closes document last. Added live server Playwright tests that hit the actual streaming endpoint and verify suspense slots resolve with content. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2079,11 +2079,30 @@ let http_render_page_streaming env path _headers fd page_name =
|
|||||||
in
|
in
|
||||||
let t1 = Unix.gettimeofday () in
|
let t1 = Unix.gettimeofday () in
|
||||||
|
|
||||||
(* Phase 2: Send chunked header + shell HTML *)
|
(* Phase 2: Send chunked header + shell HTML.
|
||||||
|
Strip closing </body></html> from shell — resolve scripts must go INSIDE
|
||||||
|
the body, otherwise the browser's HTML parser ignores them. *)
|
||||||
|
let shell_body, shell_tail =
|
||||||
|
(* Find last </body> and split there *)
|
||||||
|
let s = shell_html in
|
||||||
|
let body_close = "</body>" 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 = http_chunked_header () in
|
||||||
let header_bytes = Bytes.of_string header in
|
let header_bytes = Bytes.of_string header in
|
||||||
(try ignore (Unix.write fd header_bytes 0 (Bytes.length header_bytes)) with _ -> ());
|
(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 *)
|
(* Bootstrap resolve script — must come after shell so suspense elements exist *)
|
||||||
write_chunk fd _sx_streaming_bootstrap;
|
write_chunk fd _sx_streaming_bootstrap;
|
||||||
let t2 = Unix.gettimeofday () in
|
let t2 = Unix.gettimeofday () in
|
||||||
@@ -2170,7 +2189,8 @@ let http_render_page_streaming env path _headers fd page_name =
|
|||||||
end else
|
end else
|
||||||
Printf.eprintf "[sx-stream] %s shell=%.3fs (no :data/:content)\n%!" path (t1 -. t0);
|
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
|
end_chunked fd
|
||||||
|
|
||||||
(* ====================================================================== *)
|
(* ====================================================================== *)
|
||||||
|
|||||||
@@ -386,3 +386,46 @@ test.describe('Streaming sandbox', () => {
|
|||||||
await expect(page.locator('[data-suspense="stream-slow"]')).toContainText('~5s');
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user