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