release: WRNexusJS 0.8.0
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/ssr",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -23,6 +23,46 @@ export interface ScriptAsset {
|
||||
|
||||
export type RenderScript = string | ScriptAsset;
|
||||
|
||||
export interface PartialPrerenderResult {
|
||||
shell: string;
|
||||
regions: Array<{ id: string; html: string }>;
|
||||
}
|
||||
|
||||
/** Extract compiler-emitted dynamic regions into a cacheable static shell. */
|
||||
export function partialPrerender(html: string, startIndex = 0): PartialPrerenderResult {
|
||||
const regions: PartialPrerenderResult["regions"] = [];
|
||||
const shell = html.replace(
|
||||
/<wrn-dynamic-region\b[^>]*>([\s\S]*?)<\/wrn-dynamic-region>/gi,
|
||||
(_whole, content: string) => {
|
||||
const id = `wrn-region-${startIndex + regions.length}`;
|
||||
regions.push({ id, html: content });
|
||||
return `<template data-wrn-dynamic-placeholder="${id}"></template>`;
|
||||
},
|
||||
);
|
||||
return { shell, regions };
|
||||
}
|
||||
|
||||
/** Stream the static shell first, followed by inert region templates for client insertion. */
|
||||
export function streamPartialDocument(
|
||||
result: PartialPrerenderResult,
|
||||
nonce?: string,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(result.shell));
|
||||
for (const region of result.regions) {
|
||||
controller.enqueue(
|
||||
encoder.encode(
|
||||
`<template data-wrn-dynamic-content="${region.id}">${region.html}</template><script${nonce ? ` nonce="${escapeHtml(nonce)}"` : ""}>(function(){var p=document.querySelector('template[data-wrn-dynamic-placeholder="${region.id}"]');var c=document.querySelector('template[data-wrn-dynamic-content="${region.id}"]');if(p&&c){p.replaceWith(c.content);c.remove()}})()</script>`,
|
||||
),
|
||||
);
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
/** Page metadata for the document head. */
|
||||
meta: PageMeta;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { serializeForHtml } from "@wrnexus/security";
|
||||
import { escapeHtml } from "@wrnexus/core";
|
||||
import { createRequestStoreContainer } from "@wrnexus/store/server";
|
||||
import type { StoreContainer } from "@wrnexus/store";
|
||||
|
||||
@@ -21,5 +22,5 @@ export async function disposeRequestStores(request: object): Promise<void> {
|
||||
|
||||
export function renderStoreHydration(container: StoreContainer, nonce?: string): string {
|
||||
const json = serializeForHtml(container.serialize());
|
||||
return `<script type="application/json" data-wrnexus-store-hydration${nonce ? ` nonce="${nonce}"` : ""}>${json}</script>`;
|
||||
return `<script type="application/json" data-wrnexus-store-hydration${nonce ? ` nonce="${escapeHtml(nonce)}"` : ""}>${json}</script>`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { partialPrerender, streamPartialDocument } from "../src/index.ts";
|
||||
|
||||
test("partial prerender extracts and streams dynamic regions after the shell", async () => {
|
||||
const result = partialPrerender(
|
||||
'<header>Static</header><wrn-dynamic-region data-wrn-dynamic="true"><b>User</b></wrn-dynamic-region><footer>Static</footer>',
|
||||
);
|
||||
expect(result.shell).toContain("data-wrn-dynamic-placeholder");
|
||||
expect(result.regions).toEqual([{ id: "wrn-region-0", html: "<b>User</b>" }]);
|
||||
const output = await new Response(streamPartialDocument(result)).text();
|
||||
expect(output.indexOf("<header>Static</header>")).toBeLessThan(output.indexOf("<b>User</b>"));
|
||||
});
|
||||
@@ -1,5 +1,30 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { renderDocument, renderDocumentStream } from "../src/index.ts";
|
||||
import { renderDocument, renderDocumentStream, renderStoreHydration } from "../src/index.ts";
|
||||
import type { StoreContainer } from "@wrnexus/store";
|
||||
|
||||
test("store hydration is HTML-safe, bounded, redacted, and JSON-compatible", () => {
|
||||
const container = {
|
||||
serialize: () => ({
|
||||
ProfileStore: {
|
||||
display: "</script><script>alert(1)</script>",
|
||||
accessToken: "never-render-this",
|
||||
},
|
||||
}),
|
||||
} as unknown as StoreContainer;
|
||||
const html = renderStoreHydration(container, 'safe" onload="bad');
|
||||
expect(html).not.toContain("</script><script>");
|
||||
expect(html).not.toContain("never-render-this");
|
||||
expect(html).toContain('nonce="safe" onload="bad"');
|
||||
const payload = html.match(/>(.*)<\/script>$/)?.[1];
|
||||
expect(JSON.parse(payload!)).toEqual({
|
||||
ProfileStore: { display: "</script><script>alert(1)</script>", accessToken: "[REDACTED]" },
|
||||
});
|
||||
|
||||
const oversized = {
|
||||
serialize: () => ({ value: "x".repeat(300_000) }),
|
||||
} as unknown as StoreContainer;
|
||||
expect(() => renderStoreHydration(oversized)).toThrow("exceeds 262144 bytes");
|
||||
});
|
||||
|
||||
test("escapes metadata and script URLs while preserving trusted rendered body", () => {
|
||||
const html = renderDocument({
|
||||
|
||||
Reference in New Issue
Block a user