From cd07f0f8e265821e9f4b35eac6da8cb207f67f8f Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Tue, 18 Aug 2026 15:13:56 +0530 Subject: [PATCH] feat(compiler): add island marker codegen and props contract Emits the data-wrn-island placeholder, parses client:* strategies, and rejects non-serializable props at compile time via WRN-ISLAND-PROPS so the serialization boundary fails where it is cheapest to fix. Island names become URL path segments when the browser fetches the island bundle, so they are validated with core's existing isSafeIslandName rather than relying on escaping alone. This adds @wrnexus/core to the compiler's dependencies; core has no dependencies of its own, so no cycle is introduced. Co-Authored-By: Claude Opus 5 --- packages/compiler/package.json | 3 +- packages/compiler/src/island-codegen.ts | 82 +++++++++++++++++++ packages/compiler/test/island-codegen.test.ts | 73 +++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 packages/compiler/src/island-codegen.ts create mode 100644 packages/compiler/test/island-codegen.test.ts diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 3939f202..e32440a3 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -7,9 +7,10 @@ ".": "./src/index.ts" }, "dependencies": { + "@wrnexus/core": "workspace:*", "@wrnexus/csr": "workspace:*", - "@wrnexus/syntax": "workspace:*", "@wrnexus/store": "workspace:*", + "@wrnexus/syntax": "workspace:*", "@wrnexus/validation": "workspace:*" } } diff --git a/packages/compiler/src/island-codegen.ts b/packages/compiler/src/island-codegen.ts new file mode 100644 index 00000000..afdd4f3e --- /dev/null +++ b/packages/compiler/src/island-codegen.ts @@ -0,0 +1,82 @@ +import { escapeHtml, isSafeIslandName } from "@wrnexus/core"; + +export type IslandStrategy = "only" | "load" | "visible" | "idle"; + +export interface IslandDiagnostic { + code: "WRN-ISLAND-PROPS"; + message: string; + severity: "error"; +} + +const STRATEGIES: Record = { + "client:only": "only", + "client:load": "load", + "client:visible": "visible", + "client:idle": "idle", +}; + +export function parseIslandStrategy(directives: string[]): IslandStrategy { + for (const directive of directives) { + const match = STRATEGIES[directive]; + if (match) return match; + } + return "only"; +} + +function unsupportedProp(value: unknown): boolean { + const type = typeof value; + if (type === "function" || type === "symbol" || type === "bigint" || type === "undefined") { + return true; + } + if (value === null || type !== "object") return false; + if (Array.isArray(value)) return (value as unknown[]).some(unsupportedProp); + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) return true; + return Object.values(value as Record).some(unsupportedProp); +} + +export function serializeIslandProps( + componentName: string, + props: Record, +): { json: string } | { diagnostic: IslandDiagnostic } { + const offenders = Object.entries(props) + .filter(([, value]) => unsupportedProp(value)) + .map(([key]) => key); + + if (offenders.length > 0) { + return { + diagnostic: { + code: "WRN-ISLAND-PROPS", + severity: "error", + message: + `Island '${componentName}' received non-serializable prop(s): ${offenders.join(", ")}. ` + + `Island props cross a serialization boundary and must be JSON-safe ` + + `(no functions, symbols, bigints, undefined, or class instances).`, + }, + }; + } + + return { json: JSON.stringify(props) }; +} + +export function renderIslandMarker(input: { + name: string; + strategy: IslandStrategy; + propsJson: string; +}): string { + // The name becomes a path segment when the browser fetches + // /__wrnexus/island/.js, so reuse the framework's conservative charset + // rather than relying on escaping alone. + if (!isSafeIslandName(input.name)) { + throw new Error( + `Island name '${input.name}' is not a safe identifier. ` + + `Island names may only contain letters, digits, underscores, and hyphens.`, + ); + } + + return ( + `
` + ); +} diff --git a/packages/compiler/test/island-codegen.test.ts b/packages/compiler/test/island-codegen.test.ts new file mode 100644 index 00000000..8883d83a --- /dev/null +++ b/packages/compiler/test/island-codegen.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from "bun:test"; +import { + parseIslandStrategy, + renderIslandMarker, + serializeIslandProps, +} from "../src/island-codegen.ts"; + +test("defaults to the client-only strategy", () => { + expect(parseIslandStrategy([])).toBe("only"); + expect(parseIslandStrategy(["client:visible"])).toBe("visible"); + expect(parseIslandStrategy(["client:idle"])).toBe("idle"); + expect(parseIslandStrategy(["client:load"])).toBe("load"); +}); + +test("serializes JSON-safe props", () => { + const result = serializeIslandProps("Chart", { title: "Revenue", points: [1, 2] }); + expect(result).toEqual({ json: '{"title":"Revenue","points":[1,2]}' }); +}); + +test("rejects non-serializable props with WRN-ISLAND-PROPS", () => { + const result = serializeIslandProps("Chart", { onClick: () => {} }); + expect(result).toHaveProperty("diagnostic"); + const { diagnostic } = result as { diagnostic: { code: string; message: string } }; + expect(diagnostic.code).toBe("WRN-ISLAND-PROPS"); + expect(diagnostic.message).toContain("Chart"); + expect(diagnostic.message).toContain("onClick"); +}); + +test("rejects class instances and nested offenders", () => { + class Point { + constructor(public x = 1) {} + } + expect(serializeIslandProps("Chart", { origin: new Point() })).toHaveProperty("diagnostic"); + expect(serializeIslandProps("Chart", { nested: { deep: () => {} } })).toHaveProperty( + "diagnostic", + ); + expect(serializeIslandProps("Chart", { list: [1, () => {}] })).toHaveProperty("diagnostic"); +}); + +test("accepts null and nested plain data", () => { + const result = serializeIslandProps("Chart", { + empty: null, + nested: { rows: [{ id: 1 }], flag: false }, + }); + expect(result).toHaveProperty("json"); +}); + +test("rejects island names that are unsafe as URL path segments", () => { + // The name is fetched as /__wrnexus/island/.js, so traversal and + // separators must be refused rather than merely escaped. + expect(() => + renderIslandMarker({ name: "../secret", strategy: "only", propsJson: "{}" }), + ).toThrow(/not a safe identifier/); + expect(() => renderIslandMarker({ name: "a/b", strategy: "only", propsJson: "{}" })).toThrow( + /not a safe identifier/, + ); + expect(() => + renderIslandMarker({ name: "Chart", strategy: "only", propsJson: "{}" }), + ).not.toThrow(); +}); + +test("renders a marker with escaped props", () => { + const html = renderIslandMarker({ + name: "Chart", + strategy: "visible", + propsJson: '{"title":"a