@aprovan/patchwork-mcp 0.1.0-dev.030eb3d

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.
Files changed (50) hide show
  1. package/LICENSE +373 -0
  2. package/README.md +15 -0
  3. package/dist/index.d.ts +128 -0
  4. package/dist/index.js +989 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/dist/runtime/assets/index-CSnUKUMa.js +1058 -0
  8. package/dist/runtime/index.html +38 -0
  9. package/dist/server.d.ts +6 -0
  10. package/dist/server.js +1400 -0
  11. package/dist/server.js.map +1 -0
  12. package/dist/shell/shell.js +67 -0
  13. package/docs/widget-preview.png +0 -0
  14. package/e2e/__snapshots__/.gitkeep +0 -0
  15. package/e2e/global-setup.ts +114 -0
  16. package/e2e/global-teardown.ts +15 -0
  17. package/e2e/visual-regression.test.ts +86 -0
  18. package/e2e/widget-smoke.test.ts +109 -0
  19. package/index.html +32 -0
  20. package/package.json +55 -0
  21. package/playwright.config.ts +43 -0
  22. package/src/__tests__/live-update.test.ts +158 -0
  23. package/src/__tests__/local-backend.test.ts +100 -0
  24. package/src/__tests__/memory-backend.ts +144 -0
  25. package/src/__tests__/services.test.ts +153 -0
  26. package/src/__tests__/shim.test.ts +60 -0
  27. package/src/__tests__/widget-store.test.ts +188 -0
  28. package/src/e2e-visual.ts +148 -0
  29. package/src/index.ts +608 -0
  30. package/src/live-update.ts +150 -0
  31. package/src/logger.ts +44 -0
  32. package/src/reference-widgets/live-dashboard.ts +162 -0
  33. package/src/registry-backend.ts +47 -0
  34. package/src/runtime/index.html +38 -0
  35. package/src/runtime/main.ts +164 -0
  36. package/src/scripts/export-artifacts.ts +51 -0
  37. package/src/server.ts +399 -0
  38. package/src/services.ts +307 -0
  39. package/src/shell/main.ts +172 -0
  40. package/src/shim.ts +106 -0
  41. package/src/tunnel.ts +123 -0
  42. package/src/widget-store/index.ts +10 -0
  43. package/src/widget-store/local-backend.ts +77 -0
  44. package/src/widget-store/store.ts +242 -0
  45. package/src/widget-store/types.ts +53 -0
  46. package/tsconfig.json +10 -0
  47. package/tsup.config.ts +14 -0
  48. package/vite.runtime.config.ts +28 -0
  49. package/vite.shell.config.ts +30 -0
  50. package/vitest.config.ts +7 -0
@@ -0,0 +1,150 @@
1
+ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { warn } from "./logger.js";
3
+
4
+ /**
5
+ * A single buffered event for a data stream.
6
+ */
7
+ export interface StreamEvent {
8
+ seq: number;
9
+ data: unknown;
10
+ timestamp: number;
11
+ }
12
+
13
+ const RING_BUFFER_MAX = 100;
14
+
15
+ /**
16
+ * Process-level ring buffer for each named stream.
17
+ * Shared across all sessions so any request can read from it.
18
+ */
19
+ const streamBuffers = new Map<string, StreamEvent[]>();
20
+ let globalSeq = 0;
21
+
22
+ function getOrCreateBuffer(stream: string): StreamEvent[] {
23
+ let buf = streamBuffers.get(stream);
24
+ if (!buf) {
25
+ buf = [];
26
+ streamBuffers.set(stream, buf);
27
+ }
28
+ return buf;
29
+ }
30
+
31
+ /**
32
+ * Retrieve buffered events for a stream with sequence numbers > afterSeq.
33
+ */
34
+ export function getEvents(stream: string, afterSeq: number): StreamEvent[] {
35
+ const buf = streamBuffers.get(stream);
36
+ if (!buf) return [];
37
+ return buf.filter((e) => e.seq > afterSeq);
38
+ }
39
+
40
+ /**
41
+ * Write an event into the ring buffer for a stream.
42
+ * Evicts the oldest entry when the buffer is full.
43
+ */
44
+ export function appendEvent(stream: string, data: unknown): StreamEvent {
45
+ const buf = getOrCreateBuffer(stream);
46
+ const event: StreamEvent = {
47
+ seq: ++globalSeq,
48
+ data,
49
+ timestamp: Date.now(),
50
+ };
51
+ buf.push(event);
52
+ if (buf.length > RING_BUFFER_MAX) {
53
+ buf.shift();
54
+ }
55
+ return event;
56
+ }
57
+
58
+ /**
59
+ * Per-session subscription tracking.
60
+ * A session is keyed by the MCP session ID; its McpServer is used to send
61
+ * `notifications/tools/list_changed` as a push signal when stream events arrive.
62
+ */
63
+ interface SessionEntry {
64
+ server: McpServer;
65
+ streams: Set<string>;
66
+ }
67
+
68
+ const sessions = new Map<string, SessionEntry>();
69
+
70
+ /**
71
+ * Register a live MCP session so it can receive push notifications.
72
+ */
73
+ export function registerSession(sessionId: string, server: McpServer): void {
74
+ if (!sessions.has(sessionId)) {
75
+ sessions.set(sessionId, { server, streams: new Set() });
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Unregister a session (e.g. on transport close or DELETE).
81
+ */
82
+ export function unregisterSession(sessionId: string): void {
83
+ sessions.delete(sessionId);
84
+ }
85
+
86
+ /**
87
+ * Subscribe a session to a named stream.
88
+ */
89
+ export function subscribeSession(sessionId: string, stream: string): void {
90
+ const entry = sessions.get(sessionId);
91
+ if (entry) {
92
+ entry.streams.add(stream);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Unsubscribe a session from a named stream.
98
+ */
99
+ export function unsubscribeSession(sessionId: string, stream: string): void {
100
+ const entry = sessions.get(sessionId);
101
+ if (entry) {
102
+ entry.streams.delete(stream);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Push a data update to all sessions that have subscribed to `stream`.
108
+ *
109
+ * The event is appended to the ring buffer. Each subscribed session's McpServer
110
+ * then sends a `notifications/tools/list_changed` notification to the host.
111
+ * The host forwards this to the widget, which uses it as a signal to call
112
+ * `poll_updates` and retrieve the buffered data.
113
+ *
114
+ * @returns The new sequence number assigned to this event.
115
+ */
116
+ export async function pushStreamUpdate(
117
+ stream: string,
118
+ data: unknown,
119
+ ): Promise<number> {
120
+ const event = appendEvent(stream, data);
121
+
122
+ const pushPromises: Promise<void>[] = [];
123
+ for (const [, entry] of sessions) {
124
+ if (entry.streams.has(stream)) {
125
+ const notifyPromise = entry.server.server
126
+ .notification({ method: "notifications/tools/list_changed" })
127
+ .catch((err: unknown) => {
128
+ warn("live-update", "Failed to notify session:", err);
129
+ });
130
+ pushPromises.push(notifyPromise);
131
+ }
132
+ }
133
+
134
+ await Promise.all(pushPromises);
135
+ return event.seq;
136
+ }
137
+
138
+ /**
139
+ * Return the current highest sequence number (useful for initial subscribe).
140
+ */
141
+ export function currentSeq(): number {
142
+ return globalSeq;
143
+ }
144
+
145
+ /** Clears all buffers and sessions — for testing only. */
146
+ export function _resetForTests(): void {
147
+ streamBuffers.clear();
148
+ sessions.clear();
149
+ globalSeq = 0;
150
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,44 @@
1
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
2
+
3
+ const CURRENT_LEVEL: LogLevel =
4
+ (process.env['LOG_LEVEL'] as LogLevel | undefined) ?? 'info';
5
+
6
+ const LEVEL_RANK: Record<LogLevel, number> = {
7
+ debug: 0,
8
+ info: 1,
9
+ warn: 2,
10
+ error: 3,
11
+ };
12
+
13
+ function shouldLog(level: LogLevel): boolean {
14
+ return LEVEL_RANK[level] >= LEVEL_RANK[CURRENT_LEVEL];
15
+ }
16
+
17
+ function formatMessage(tag: string, ...args: unknown[]): unknown[] {
18
+ return [`[${tag}]`, ...args];
19
+ }
20
+
21
+ // All logging goes to stderr to avoid interfering with MCP stdio protocol on stdout
22
+ export function debug(tag: string, ...args: unknown[]): void {
23
+ if (shouldLog('debug')) {
24
+ console.error(...formatMessage(tag, ...args));
25
+ }
26
+ }
27
+
28
+ export function log(tag: string, ...args: unknown[]): void {
29
+ if (shouldLog('info')) {
30
+ console.error(...formatMessage(tag, ...args));
31
+ }
32
+ }
33
+
34
+ export function warn(tag: string, ...args: unknown[]): void {
35
+ if (shouldLog('warn')) {
36
+ console.error(...formatMessage(tag, ...args));
37
+ }
38
+ }
39
+
40
+ export function error(tag: string, ...args: unknown[]): void {
41
+ if (shouldLog('error')) {
42
+ console.error(...formatMessage(tag, ...args));
43
+ }
44
+ }
@@ -0,0 +1,162 @@
1
+ import type { VirtualFile } from "@aprovan/patchwork-compiler";
2
+
3
+ const DASHBOARD_MAIN = `import { useState, useEffect } from 'react';
4
+ import { PriceCard } from './price-card';
5
+ import { StatusPanel } from './status-panel';
6
+ import { ActionBar } from './action-bar';
7
+
8
+ export default function LiveDashboard() {
9
+ const [prices, setPrices] = useState<Record<string, { price: number; change: number }>>({});
10
+ const [statuses, setStatuses] = useState<Record<string, string>>({});
11
+ const [lastUpdate, setLastUpdate] = useState<number>(Date.now());
12
+
13
+ useEffect(() => {
14
+ const unsub1 = window.patchwork.subscribe('price_feed', (data: any, seq: number) => {
15
+ setPrices(prev => ({ ...prev, ...data }));
16
+ setLastUpdate(Date.now());
17
+ });
18
+
19
+ const unsub2 = window.patchwork.subscribe('system_status', (data: any, seq: number) => {
20
+ setStatuses(prev => ({ ...prev, ...data }));
21
+ setLastUpdate(Date.now());
22
+ });
23
+
24
+ return () => { unsub1(); unsub2(); };
25
+ }, []);
26
+
27
+ const handleRefresh = () => {
28
+ window.patchwork.fireEvent('push_update', {
29
+ stream: 'price_feed',
30
+ data: { tick: true },
31
+ });
32
+ };
33
+
34
+ const handleContextUpdate = () => {
35
+ const summary = Object.entries(prices)
36
+ .map(([sym, p]) => \`\${sym}: $\${p.price.toFixed(2)} (\${p.change >= 0 ? '+' : ''}\${p.change.toFixed(2)}%)\`)
37
+ .join('; ');
38
+ window.patchwork.updateContext(\`Dashboard state: \${summary}\`);
39
+ };
40
+
41
+ const priceEntries = Object.entries(prices);
42
+ const statusEntries = Object.entries(statuses);
43
+
44
+ return (
45
+ <div className="p-6 max-w-2xl mx-auto bg-white rounded-xl shadow-lg space-y-6">
46
+ <div className="flex items-center justify-between border-b pb-3">
47
+ <h1 className="text-xl font-bold text-gray-900">Live Data Dashboard</h1>
48
+ <span className="text-xs text-gray-400">
49
+ Updated {new Date(lastUpdate).toLocaleTimeString()}
50
+ </span>
51
+ </div>
52
+
53
+ {priceEntries.length > 0 && (
54
+ <section>
55
+ <h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-2">
56
+ Price Feed
57
+ </h2>
58
+ <div className="grid grid-cols-2 gap-3">
59
+ {priceEntries.map(([symbol, p]) => (
60
+ <PriceCard key={symbol} symbol={symbol} price={p.price} change={p.change} />
61
+ ))}
62
+ </div>
63
+ </section>
64
+ )}
65
+
66
+ {statusEntries.length > 0 && (
67
+ <section>
68
+ <h2 className="text-sm font-semibold text-gray-600 uppercase tracking-wide mb-2">
69
+ System Status
70
+ </h2>
71
+ <StatusPanel statuses={statuses} />
72
+ </section>
73
+ )}
74
+
75
+ <ActionBar onRefresh={handleRefresh} onContextUpdate={handleContextUpdate} />
76
+
77
+ {priceEntries.length === 0 && statusEntries.length === 0 && (
78
+ <div className="text-center py-8 text-gray-400">
79
+ <p>Waiting for live data...</p>
80
+ <p className="text-xs mt-1">Use push_update or subscribe to streams to see data here.</p>
81
+ </div>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+ `;
87
+
88
+ const PRICE_CARD = `export function PriceCard({ symbol, price, change }: { symbol: string; price: number; change: number }) {
89
+ const isPositive = change >= 0;
90
+ return (
91
+ <div className="p-3 rounded-lg border bg-gray-50">
92
+ <div className="text-xs font-medium text-gray-500 uppercase">{symbol}</div>
93
+ <div className="text-lg font-bold text-gray-900">\${price.toFixed(2)}</div>
94
+ <div className={\`text-sm font-medium \${isPositive ? 'text-green-600' : 'text-red-600'}\`}>
95
+ {isPositive ? '+' : ''}{change.toFixed(2)}%
96
+ </div>
97
+ </div>
98
+ );
99
+ }
100
+ `;
101
+
102
+ const STATUS_PANEL = `export function StatusPanel({ statuses }: { statuses: Record<string, string> }) {
103
+ const colorMap: Record<string, string> = {
104
+ ok: 'bg-green-100 text-green-800',
105
+ healthy: 'bg-green-100 text-green-800',
106
+ warning: 'bg-yellow-100 text-yellow-800',
107
+ degraded: 'bg-yellow-100 text-yellow-800',
108
+ error: 'bg-red-100 text-red-800',
109
+ down: 'bg-red-100 text-red-800',
110
+ };
111
+
112
+ return (
113
+ <div className="space-y-2">
114
+ {Object.entries(statuses).map(([service, status]) => {
115
+ const color = colorMap[status.toLowerCase()] ?? 'bg-gray-100 text-gray-800';
116
+ return (
117
+ <div key={service} className="flex items-center justify-between p-2 rounded border bg-gray-50">
118
+ <span className="text-sm font-medium text-gray-700">{service}</span>
119
+ <span className={\`text-xs font-semibold px-2 py-0.5 rounded \${color}\`}>{status}</span>
120
+ </div>
121
+ );
122
+ })}
123
+ </div>
124
+ );
125
+ }
126
+ `;
127
+
128
+ const ACTION_BAR = `export function ActionBar({ onRefresh, onContextUpdate }: { onRefresh: () => void; onContextUpdate: () => void }) {
129
+ return (
130
+ <div className="flex gap-3 pt-2">
131
+ <button
132
+ onClick={onRefresh}
133
+ className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
134
+ >
135
+ Refresh Data
136
+ </button>
137
+ <button
138
+ onClick={onContextUpdate}
139
+ className="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 rounded-lg hover:bg-blue-100 transition-colors"
140
+ >
141
+ Send Context
142
+ </button>
143
+ </div>
144
+ );
145
+ }
146
+ `;
147
+
148
+ export const REFERENCE_WIDGET_FILES: VirtualFile[] = [
149
+ { path: "main.tsx", content: DASHBOARD_MAIN },
150
+ { path: "price-card.tsx", content: PRICE_CARD },
151
+ { path: "status-panel.tsx", content: STATUS_PANEL },
152
+ { path: "action-bar.tsx", content: ACTION_BAR },
153
+ ];
154
+
155
+ export const REFERENCE_WIDGET_MANIFEST = {
156
+ name: "live-dashboard",
157
+ version: "0.1.0",
158
+ platform: "browser" as const,
159
+ image: "@aprovan/patchwork-image-shadcn",
160
+ description: "Live data dashboard with price feed, system status, and context feedback",
161
+ services: ["weather"],
162
+ };
@@ -0,0 +1,47 @@
1
+ import {
2
+ PatchworkMcpClient,
3
+ type McpServerConfig,
4
+ type PatchworkTool,
5
+ } from "@aprovan/patchwork";
6
+ import type { ServiceBackend, ServiceToolInfo } from "./services.js";
7
+
8
+ export type RegistryBackendOptions = Omit<McpServerConfig, "mode">;
9
+
10
+ export interface RegistryBackend extends ServiceBackend {
11
+ getToolInfos(): ServiceToolInfo[];
12
+ readArtifact(uri: string): Promise<unknown>;
13
+ close(): Promise<void>;
14
+ }
15
+
16
+ function splitTool(tool: PatchworkTool): ServiceToolInfo {
17
+ const separator = tool.name.indexOf(".");
18
+ return {
19
+ name: tool.name,
20
+ namespace: separator < 0 ? tool.name : tool.name.slice(0, separator),
21
+ procedure: separator < 0 ? tool.name : tool.name.slice(separator + 1),
22
+ description: tool.description,
23
+ parameters:
24
+ tool.inputSchema && typeof tool.inputSchema === "object"
25
+ ? (tool.inputSchema as Record<string, unknown>)
26
+ : undefined,
27
+ };
28
+ }
29
+
30
+ export async function createRegistryBackend(
31
+ options: RegistryBackendOptions,
32
+ ): Promise<RegistryBackend> {
33
+ const client = new PatchworkMcpClient({ ...options, mode: "toolbox" });
34
+ await client.connect();
35
+ const tools = (await client.listTools()).map(splitTool);
36
+ return {
37
+ call(namespace, procedure, args) {
38
+ return client.callTool(
39
+ `${namespace}.${procedure}`,
40
+ (args[0] as Record<string, unknown> | undefined) ?? {},
41
+ );
42
+ },
43
+ getToolInfos: () => tools,
44
+ readArtifact: (uri) => client.readResource(uri),
45
+ close: () => client.close(),
46
+ };
47
+ }
@@ -0,0 +1,38 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Patchwork Widget Runtime</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+ html,
14
+ body {
15
+ width: 100%;
16
+ min-height: 100%;
17
+ font-family: system-ui, -apple-system, sans-serif;
18
+ }
19
+ #root {
20
+ width: 100%;
21
+ }
22
+ #pw-status {
23
+ padding: 12px 16px;
24
+ font-size: 13px;
25
+ color: #6b7280;
26
+ }
27
+ #pw-status[data-error] {
28
+ color: #dc2626;
29
+ white-space: pre-wrap;
30
+ }
31
+ </style>
32
+ </head>
33
+ <body>
34
+ <div id="pw-status">Loading runtime…</div>
35
+ <div id="root"></div>
36
+ <script type="module" src="./main.ts"></script>
37
+ </body>
38
+ </html>
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Patchwork widget runtime host.
3
+ *
4
+ * Runs inside the nested, CSP-free iframe embedded by the MCP App shell. It
5
+ * fetches a saved widget's raw source and compiles + mounts it in the browser
6
+ * using the shared `@aprovan/patchwork-compiler` runtime (esbuild-wasm) — the
7
+ * same path the chat app uses.
8
+ *
9
+ * The widget is selected via query params written by the shell:
10
+ * /runtime/?widget=<name>/<hash>&inputs=<urlencoded-json>
11
+ *
12
+ * Service / live-update / context calls made by the widget go through the
13
+ * bridge shim (window.patchwork.* and namespace.*), which forwards them to the
14
+ * parent shell over postMessage; the shell relays to the MCP host.
15
+ */
16
+ import {
17
+ createCompiler,
18
+ createProjectFromFiles,
19
+ type Compiler,
20
+ type Manifest,
21
+ type VirtualFile,
22
+ } from "@aprovan/patchwork-compiler";
23
+ import { generateBridgeShim } from "../shim.js";
24
+
25
+ const CDN_BASE = "https://esm.sh";
26
+
27
+ interface WidgetRef {
28
+ name: string;
29
+ hash: string;
30
+ }
31
+
32
+ interface WidgetFilesResponse {
33
+ files: VirtualFile[];
34
+ entry: string;
35
+ manifest: Manifest;
36
+ }
37
+
38
+ const statusEl = document.getElementById("pw-status");
39
+
40
+ function setStatus(message: string, isError = false): void {
41
+ if (!statusEl) return;
42
+ statusEl.textContent = message;
43
+ if (isError) statusEl.setAttribute("data-error", "true");
44
+ else statusEl.removeAttribute("data-error");
45
+ statusEl.style.display = message ? "block" : "none";
46
+ }
47
+
48
+ /**
49
+ * Execute the bridge shim in this window so `window.patchwork` and the service
50
+ * namespace proxies exist before the widget mounts. Imported as a blob module so
51
+ * execution completes before we continue.
52
+ */
53
+ async function runShim(code: string): Promise<void> {
54
+ if (!code.trim()) return;
55
+ const blob = new Blob([code], { type: "application/javascript" });
56
+ const url = URL.createObjectURL(blob);
57
+ try {
58
+ await import(/* @vite-ignore */ url);
59
+ } catch (err) {
60
+ console.warn("[patchwork-runtime] bridge shim failed to load:", err);
61
+ } finally {
62
+ URL.revokeObjectURL(url);
63
+ }
64
+ }
65
+
66
+ /** Report content height to the parent shell so it can size the iframe. */
67
+ function reportSize(): void {
68
+ const height = Math.ceil(document.documentElement.getBoundingClientRect().height);
69
+ window.parent.postMessage({ source: "patchwork", kind: "size", height }, "*");
70
+ }
71
+
72
+ let started = false;
73
+ const compilerCache = new Map<string, Promise<Compiler>>();
74
+
75
+ function getCompiler(image: string): Promise<Compiler> {
76
+ let cached = compilerCache.get(image);
77
+ if (!cached) {
78
+ cached = createCompiler({
79
+ image,
80
+ // Services are bridged to the MCP host via the shim, not an HTTP proxy.
81
+ proxyUrl: "",
82
+ cdnBaseUrl: CDN_BASE,
83
+ widgetCdnBaseUrl: CDN_BASE,
84
+ });
85
+ compilerCache.set(image, cached);
86
+ }
87
+ return cached;
88
+ }
89
+
90
+ async function render(widget: WidgetRef, inputs: Record<string, unknown>): Promise<void> {
91
+ if (started) return;
92
+ started = true;
93
+
94
+ try {
95
+ setStatus("Loading widget source…");
96
+ const res = await fetch(
97
+ `/widget/${encodeURIComponent(widget.name)}/${encodeURIComponent(widget.hash)}/files`,
98
+ );
99
+ if (!res.ok) throw new Error(`Failed to load widget files (${res.status})`);
100
+ const { files, entry, manifest } = (await res.json()) as WidgetFilesResponse;
101
+
102
+ const services = manifest.services ?? [];
103
+
104
+ // Wire window.patchwork + service namespaces (forwarded to the host shell)
105
+ // before mounting so the widget's calls resolve.
106
+ await runShim(generateBridgeShim({ namespaces: services }));
107
+
108
+ setStatus("Compiling widget…");
109
+ const compiler = await getCompiler(manifest.image);
110
+
111
+ const project = createProjectFromFiles(files, widget.name);
112
+ if (entry) project.entry = entry;
113
+
114
+ // Strip services from the manifest passed to the compiler so its built-in
115
+ // HTTP-proxy namespace globals don't clobber the bridge shim's proxies.
116
+ const compileManifest: Manifest = { ...manifest, services: undefined };
117
+ const compiled = await compiler.compile(project, compileManifest);
118
+
119
+ const root = document.getElementById("root");
120
+ if (!root) throw new Error("Missing #root element");
121
+
122
+ setStatus("");
123
+ await compiler.mount(compiled, { target: root, mode: "embedded", inputs });
124
+
125
+ // Report size now and on any subsequent layout change.
126
+ reportSize();
127
+ const ro = new ResizeObserver(() => reportSize());
128
+ ro.observe(document.body);
129
+ } catch (err) {
130
+ started = false;
131
+ console.error("[patchwork-runtime] render failed", err);
132
+ const message = err instanceof Error ? err.message : String(err);
133
+ setStatus(`Failed to render widget:\n${message}`, true);
134
+ window.parent.postMessage({ source: "patchwork", kind: "error", error: message }, "*");
135
+ }
136
+ }
137
+
138
+ // Render from query params: /runtime/?widget=<name>/<hash>&inputs=<json>
139
+ function renderFromQuery(): void {
140
+ const params = new URLSearchParams(window.location.search);
141
+ const widgetParam = params.get("widget");
142
+ if (!widgetParam) {
143
+ setStatus("No widget specified.", true);
144
+ return;
145
+ }
146
+ const [name, hash] = widgetParam.split("/");
147
+ if (!name || !hash) {
148
+ setStatus("Invalid widget reference.", true);
149
+ return;
150
+ }
151
+
152
+ let inputs: Record<string, unknown> = {};
153
+ const inputsParam = params.get("inputs");
154
+ if (inputsParam) {
155
+ try {
156
+ inputs = JSON.parse(inputsParam) as Record<string, unknown>;
157
+ } catch {
158
+ /* ignore malformed inputs */
159
+ }
160
+ }
161
+ void render({ name, hash }, inputs);
162
+ }
163
+
164
+ renderFromQuery();
@@ -0,0 +1,51 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join, resolve, dirname } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import {
5
+ REFERENCE_WIDGET_FILES,
6
+ REFERENCE_WIDGET_MANIFEST,
7
+ } from "../reference-widgets/live-dashboard.js";
8
+ import type { Manifest, VirtualFile } from "@aprovan/patchwork-compiler";
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const ARTIFACTS_DIR = resolve(__dirname, "../../../../.artifacts/widgets");
12
+
13
+ const WIDGETS: Array<{ manifest: Manifest; files: VirtualFile[] }> = [
14
+ { manifest: REFERENCE_WIDGET_MANIFEST, files: REFERENCE_WIDGET_FILES },
15
+ ];
16
+
17
+ /**
18
+ * Export the raw source of each reference widget to .artifacts/widgets/<name>/.
19
+ *
20
+ * Widgets are no longer compiled on the server — they are saved as raw source
21
+ * and compiled in the browser by the shared runtime. This script just dumps the
22
+ * raw files (plus manifest.json) for inspection.
23
+ */
24
+ async function main(): Promise<void> {
25
+ console.log("Exporting raw widget source artifacts...\n");
26
+
27
+ for (const { manifest, files } of WIDGETS) {
28
+ const widgetDir = join(ARTIFACTS_DIR, manifest.name);
29
+ await mkdir(widgetDir, { recursive: true });
30
+
31
+ for (const file of files) {
32
+ const outPath = join(widgetDir, file.path);
33
+ await mkdir(dirname(outPath), { recursive: true });
34
+ await writeFile(outPath, file.content, "utf-8");
35
+ }
36
+ await writeFile(
37
+ join(widgetDir, "manifest.json"),
38
+ JSON.stringify(manifest, null, 2),
39
+ "utf-8",
40
+ );
41
+
42
+ console.log(` ${manifest.name} → ${widgetDir} (${files.length} files)`);
43
+ }
44
+
45
+ console.log("\nDone. Render widgets via the runtime host to see them compiled.");
46
+ }
47
+
48
+ main().catch((err) => {
49
+ console.error("Export failed:", err);
50
+ process.exit(1);
51
+ });