@flow-like/widget-bundler 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/csp.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { WidgetCapabilities } from "@flow-like/widget-sdk";
2
+ import { escapeHtmlAttr, insertAtHeadStart } from "./html";
3
+
4
+ /** Local-only schemes every widget document may fetch and play (`LOCAL_SOURCES`). */
5
+ export const LOCAL_CSP_SOURCES = ["data:", "blob:"] as const;
6
+
7
+ /**
8
+ * Build the pack-time widget-document CSP: `default-src 'none'` baseline,
9
+ * inline script/style allowed (self-contained documents), chunk loads
10
+ * restricted to the bundle asset origins, local `data:`/`blob:` bytes for
11
+ * `connect-src` and `media-src`, and browser features from the contract
12
+ * `capabilities`. Contract `csp` sources are never included: hosts add them
13
+ * to the header they serve, and only after the viewer approves.
14
+ */
15
+ export function buildCsp(
16
+ servingPrefix: string | null,
17
+ capabilities: WidgetCapabilities = {},
18
+ ): string {
19
+ // The bundle hash is a hash of the finished archive, so pack cannot embed a
20
+ // hash-specific serving URL without making the archive self-referential.
21
+ // Allow only the origins used by the supported web and Tauri asset servers;
22
+ // callers may additionally narrow/extend this with a deployment prefix.
23
+ if (
24
+ servingPrefix &&
25
+ (/[\s;"'<>]/.test(servingPrefix) ||
26
+ !/^(?:https?:\/\/|flow-widget:)/.test(servingPrefix))
27
+ ) {
28
+ throw new Error("Invalid widget CSP source");
29
+ }
30
+ const assetSources = [
31
+ "'self'",
32
+ "flow-widget:",
33
+ "http://flow-widget.localhost",
34
+ ...(servingPrefix ? [servingPrefix] : []),
35
+ ].join(" ");
36
+ const local = LOCAL_CSP_SOURCES.join(" ");
37
+ const localAnd = (enabled: boolean | undefined) =>
38
+ enabled ? `${local} ${assetSources}` : local;
39
+ return [
40
+ "default-src 'none'",
41
+ `script-src 'unsafe-inline' ${capabilities.wasm ? "'wasm-unsafe-eval' " : ""}${assetSources}`,
42
+ `style-src 'unsafe-inline' ${assetSources}`,
43
+ `img-src ${local} ${assetSources}`,
44
+ `font-src data: ${assetSources}`,
45
+ `connect-src ${localAnd(capabilities.workers)}`,
46
+ `worker-src ${capabilities.workers ? `blob: ${assetSources}` : "'none'"}`,
47
+ `media-src ${localAnd(capabilities.media)}`,
48
+ ].join("; ");
49
+ }
50
+
51
+ export function injectCspMeta(html: string, csp: string): string {
52
+ const meta = `<meta http-equiv="Content-Security-Policy" content="${escapeHtmlAttr(csp)}" />`;
53
+ return insertAtHeadStart(html, meta);
54
+ }
@@ -0,0 +1,103 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ type JsonValue,
5
+ type WidgetContract,
6
+ canonicalizeContract,
7
+ } from "../contract-types";
8
+ import { type ExtractResult, extractContract } from "../extract";
9
+ import { type PropsFormField, derivePropsFormModel } from "./form-model";
10
+
11
+ export interface ContractEndpointPayload {
12
+ contract: WidgetContract;
13
+ fixtures: Record<string, JsonValue>;
14
+ formModel: PropsFormField[];
15
+ warnings: string[];
16
+ }
17
+
18
+ /** Pure part of the contract endpoint: extraction result → response body. */
19
+ export function buildContractPayload(
20
+ extracted: ExtractResult,
21
+ ): ContractEndpointPayload {
22
+ const contract = canonicalizeContract(extracted.contract);
23
+ return {
24
+ contract,
25
+ fixtures: extracted.config.fixtures ?? {},
26
+ formModel: derivePropsFormModel(contract),
27
+ warnings: extracted.warnings,
28
+ };
29
+ }
30
+
31
+ export interface ContractGroupRef {
32
+ name: string;
33
+ dir: string;
34
+ }
35
+
36
+ const WIDGET_ID_RE = /^[a-z0-9-]+$/;
37
+
38
+ /**
39
+ * Resolve `GET /api/contract/<group>/<id>` to the widget's config path.
40
+ * Returns `null` for unknown groups or ids that are not plain widget ids
41
+ * (which also guards the endpoint against path traversal).
42
+ */
43
+ export function resolveWidgetConfigPath(
44
+ groups: readonly ContractGroupRef[],
45
+ groupName: string,
46
+ widgetId: string,
47
+ ): string | null {
48
+ if (!WIDGET_ID_RE.test(widgetId)) return null;
49
+ const group = groups.find((candidate) => candidate.name === groupName);
50
+ if (!group) return null;
51
+ return join(group.dir, "src", "widgets", widgetId, "widget.config.ts");
52
+ }
53
+
54
+ /**
55
+ * Contract extraction cached by `widget.config.ts` mtime: each request
56
+ * re-checks the file so config edits are picked up, without re-running the
57
+ * TypeScript program when nothing changed. (Edits to imported sibling type
58
+ * files require touching the config file — same limitation as the Vite
59
+ * plugin's dev cache.)
60
+ */
61
+ export class ContractCache {
62
+ private readonly cache = new Map<
63
+ string,
64
+ { mtimeMs: number; result: ExtractResult }
65
+ >();
66
+
67
+ get(configPath: string): ExtractResult {
68
+ const mtimeMs = statSync(configPath).mtimeMs;
69
+ const cached = this.cache.get(configPath);
70
+ if (cached && cached.mtimeMs === mtimeMs) return cached.result;
71
+ const result = extractContract(configPath);
72
+ this.cache.set(configPath, { mtimeMs, result });
73
+ return result;
74
+ }
75
+ }
76
+
77
+ export interface ContractEndpointResponse {
78
+ status: number;
79
+ body: ContractEndpointPayload | { error: string };
80
+ }
81
+
82
+ export function handleContractRequest(
83
+ cache: ContractCache,
84
+ groups: readonly ContractGroupRef[],
85
+ groupName: string,
86
+ widgetId: string,
87
+ ): ContractEndpointResponse {
88
+ const configPath = resolveWidgetConfigPath(groups, groupName, widgetId);
89
+ if (configPath === null || !existsSync(configPath)) {
90
+ return {
91
+ status: 404,
92
+ body: { error: `Unknown widget '${groupName}/${widgetId}'` },
93
+ };
94
+ }
95
+ try {
96
+ return { status: 200, body: buildContractPayload(cache.get(configPath)) };
97
+ } catch (e) {
98
+ return {
99
+ status: 500,
100
+ body: { error: e instanceof Error ? e.message : String(e) },
101
+ };
102
+ }
103
+ }
@@ -0,0 +1,63 @@
1
+ import type {
2
+ ContractInput,
3
+ JsonObject,
4
+ JsonValue,
5
+ WidgetContract,
6
+ } from "../contract-types";
7
+
8
+ export type PropsFormControl =
9
+ | { kind: "text" }
10
+ | { kind: "number"; integer: boolean; min?: number; max?: number }
11
+ | { kind: "checkbox" }
12
+ | { kind: "select"; choices: string[] }
13
+ | { kind: "json"; schema?: JsonObject };
14
+
15
+ export interface PropsFormField {
16
+ key: string;
17
+ label: string;
18
+ description?: string;
19
+ optional: boolean;
20
+ default?: JsonValue;
21
+ control: PropsFormControl;
22
+ }
23
+
24
+ function controlFor(input: ContractInput): PropsFormControl {
25
+ switch (input.type) {
26
+ case "string":
27
+ return { kind: "text" };
28
+ case "number":
29
+ case "integer":
30
+ return {
31
+ kind: "number",
32
+ integer: input.type === "integer",
33
+ ...(input.min !== undefined && { min: input.min }),
34
+ ...(input.max !== undefined && { max: input.max }),
35
+ };
36
+ case "boolean":
37
+ return { kind: "checkbox" };
38
+ case "enum":
39
+ return { kind: "select", choices: input.choices ?? [] };
40
+ case "json":
41
+ return {
42
+ kind: "json",
43
+ ...(input.schema !== undefined && { schema: input.schema }),
44
+ };
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Map a widget contract to the harness props panel's form field descriptors
50
+ * (one control per contract input, in contract order).
51
+ */
52
+ export function derivePropsFormModel(
53
+ contract: WidgetContract,
54
+ ): PropsFormField[] {
55
+ return Object.entries(contract.inputs).map(([key, input]) => ({
56
+ key,
57
+ label: key,
58
+ ...(input.description !== undefined && { description: input.description }),
59
+ optional: input.optional === true,
60
+ ...(input.default !== undefined && { default: input.default }),
61
+ control: controlFor(input),
62
+ }));
63
+ }