release: WRNexusJS 0.3.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.2.79",
|
||||
"version": "0.3.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -177,3 +177,62 @@ function resolveSeoUrl(
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StreamRenderOptions extends Omit<RenderOptions, "body"> {
|
||||
body: string | Promise<string> | AsyncIterable<string>;
|
||||
}
|
||||
|
||||
function isAsyncIterable(value: unknown): value is AsyncIterable<string> {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Symbol.asyncIterator in value &&
|
||||
typeof (value as AsyncIterable<string>)[Symbol.asyncIterator] === "function"
|
||||
);
|
||||
}
|
||||
|
||||
async function* bodyChunks(body: StreamRenderOptions["body"]): AsyncIterable<string> {
|
||||
if (typeof body === "string") {
|
||||
yield body;
|
||||
return;
|
||||
}
|
||||
if (isAsyncIterable(body)) {
|
||||
yield* body;
|
||||
return;
|
||||
}
|
||||
yield await body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a complete document while preserving the exact head/body contract of
|
||||
* `renderDocument`. Async iterables can flush a shell, primary content, and
|
||||
* slower fragments without buffering the entire route.
|
||||
*/
|
||||
export function renderDocumentStream(opts: StreamRenderOptions): ReadableStream<Uint8Array> {
|
||||
const marker = "<!--__WRNEXUS_STREAM_BODY__-->";
|
||||
const document = renderDocument({ ...opts, body: marker });
|
||||
const [prefix, suffix] = document.split(marker);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
return new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(prefix ?? ""));
|
||||
for await (const chunk of bodyChunks(opts.body)) controller.enqueue(encoder.encode(chunk));
|
||||
controller.enqueue(encoder.encode(suffix ?? ""));
|
||||
controller.close();
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function streamDocumentResponse(
|
||||
opts: StreamRenderOptions,
|
||||
init: ResponseInit = {},
|
||||
): Response {
|
||||
const headers = new Headers(init.headers);
|
||||
if (!headers.has("content-type")) headers.set("content-type", "text/html; charset=utf-8");
|
||||
return new Response(renderDocumentStream(opts), { ...init, headers });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderDocument } from "../src/index.ts";
|
||||
import { renderDocument, renderDocumentStream } from "../src/index.ts";
|
||||
|
||||
test("escapes metadata and script URLs while preserving trusted rendered body", () => {
|
||||
const html = renderDocument({
|
||||
@@ -35,3 +35,14 @@ test("always emits a document language and preserves an explicit language", () =
|
||||
expect(explicit).toContain('<html data-theme="dark" lang="fr">');
|
||||
expect(explicit).not.toContain('lang="en"');
|
||||
});
|
||||
|
||||
test("streams async body chunks inside the document shell", async () => {
|
||||
async function* body() {
|
||||
yield "<h1>Shell</h1>";
|
||||
yield "<p>Later</p>";
|
||||
}
|
||||
const html = await new Response(
|
||||
renderDocumentStream({ meta: { title: "Stream" }, body: body() }),
|
||||
).text();
|
||||
expect(html).toContain('<div id="app"><h1>Shell</h1><p>Later</p></div>');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user