@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.
- package/LICENSE +373 -0
- package/README.md +15 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +989 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/dist/runtime/assets/index-CSnUKUMa.js +1058 -0
- package/dist/runtime/index.html +38 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +1400 -0
- package/dist/server.js.map +1 -0
- package/dist/shell/shell.js +67 -0
- package/docs/widget-preview.png +0 -0
- package/e2e/__snapshots__/.gitkeep +0 -0
- package/e2e/global-setup.ts +114 -0
- package/e2e/global-teardown.ts +15 -0
- package/e2e/visual-regression.test.ts +86 -0
- package/e2e/widget-smoke.test.ts +109 -0
- package/index.html +32 -0
- package/package.json +55 -0
- package/playwright.config.ts +43 -0
- package/src/__tests__/live-update.test.ts +158 -0
- package/src/__tests__/local-backend.test.ts +100 -0
- package/src/__tests__/memory-backend.ts +144 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +60 -0
- package/src/__tests__/widget-store.test.ts +188 -0
- package/src/e2e-visual.ts +148 -0
- package/src/index.ts +608 -0
- package/src/live-update.ts +150 -0
- package/src/logger.ts +44 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +47 -0
- package/src/runtime/index.html +38 -0
- package/src/runtime/main.ts +164 -0
- package/src/scripts/export-artifacts.ts +51 -0
- package/src/server.ts +399 -0
- package/src/services.ts +307 -0
- package/src/shell/main.ts +172 -0
- package/src/shim.ts +106 -0
- package/src/tunnel.ts +123 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +242 -0
- package/src/widget-store/types.ts +53 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
- package/vite.runtime.config.ts +28 -0
- package/vite.shell.config.ts +30 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patchwork MCP App "shell".
|
|
3
|
+
*
|
|
4
|
+
* This is the document Claude loads as the MCP App resource. Per the MCP Apps
|
|
5
|
+
* protocol the resource document itself must be the app that connects to the
|
|
6
|
+
* host (`App.connect()` → `window.parent`); it runs under a strict CSP with no
|
|
7
|
+
* `unsafe-eval`, so it cannot run the esbuild-wasm compiler.
|
|
8
|
+
*
|
|
9
|
+
* So the shell stays light: it runs the ext-apps {@link App} (handshake + sizing
|
|
10
|
+
* + host bridge) and embeds the Patchwork runtime in a nested, CSP-free iframe
|
|
11
|
+
* served from the widget host. The runtime compiles the widget in the browser
|
|
12
|
+
* and mounts it; the widget's `window.patchwork.*` / service-namespace calls are
|
|
13
|
+
* forwarded up here over postMessage and relayed to the host:
|
|
14
|
+
*
|
|
15
|
+
* widget (runtime iframe) ──postMessage──▶ shell (App) ──ext-apps──▶ host
|
|
16
|
+
*/
|
|
17
|
+
import { App } from "@modelcontextprotocol/ext-apps";
|
|
18
|
+
|
|
19
|
+
interface ShellConfig {
|
|
20
|
+
/** Base URL of the runtime host page, e.g. https://host/runtime/ */
|
|
21
|
+
runtime: string;
|
|
22
|
+
/** "<name>/<hash>" of the saved widget to render. */
|
|
23
|
+
widget: string;
|
|
24
|
+
/** Startup props passed to the widget. */
|
|
25
|
+
inputs: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Minimal MCP tool-result shape (avoids depending on SDK types in the bundle).
|
|
29
|
+
interface ToolResult {
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
content?: Array<{ type: string; text?: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readConfig(): ShellConfig {
|
|
35
|
+
const el = document.currentScript as HTMLScriptElement | null;
|
|
36
|
+
const raw = el?.dataset["config"] ?? "";
|
|
37
|
+
try {
|
|
38
|
+
const json = decodeURIComponent(escape(atob(raw)));
|
|
39
|
+
return JSON.parse(json) as ShellConfig;
|
|
40
|
+
} catch {
|
|
41
|
+
return { runtime: "", widget: "", inputs: {} };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseResult(result: ToolResult): unknown {
|
|
46
|
+
const textBlock = result?.content?.find((c) => c.type === "text");
|
|
47
|
+
if (textBlock?.text !== undefined) {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(textBlock.text);
|
|
50
|
+
} catch {
|
|
51
|
+
return textBlock.text;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function boot(): void {
|
|
58
|
+
const config = readConfig();
|
|
59
|
+
|
|
60
|
+
const app = new App({ name: "patchwork-widget", version: "0.1.0" });
|
|
61
|
+
|
|
62
|
+
// Nested, CSP-free runtime iframe (compiles + mounts the widget in-browser).
|
|
63
|
+
const iframe = document.createElement("iframe");
|
|
64
|
+
iframe.style.cssText = "width:100%;border:none;display:block;";
|
|
65
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin");
|
|
66
|
+
const query = new URLSearchParams({
|
|
67
|
+
widget: config.widget,
|
|
68
|
+
inputs: JSON.stringify(config.inputs ?? {}),
|
|
69
|
+
});
|
|
70
|
+
iframe.src = `${config.runtime}?${query.toString()}`;
|
|
71
|
+
(document.getElementById("pw-root") ?? document.body).appendChild(iframe);
|
|
72
|
+
|
|
73
|
+
const post = (msg: Record<string, unknown>): void => {
|
|
74
|
+
iframe.contentWindow?.postMessage({ source: "patchwork-host", ...msg }, "*");
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Per-stream cursor for live updates.
|
|
78
|
+
const streams = new Map<string, number>();
|
|
79
|
+
|
|
80
|
+
const pollStream = async (stream: string): Promise<void> => {
|
|
81
|
+
const afterSeq = streams.get(stream) ?? 0;
|
|
82
|
+
try {
|
|
83
|
+
const res = (await app.callServerTool({
|
|
84
|
+
name: "poll_updates",
|
|
85
|
+
arguments: { stream, after_seq: afterSeq },
|
|
86
|
+
})) as ToolResult;
|
|
87
|
+
const parsed = parseResult(res) as { events?: Array<{ seq: number; data: unknown }> };
|
|
88
|
+
if (!parsed?.events?.length) return;
|
|
89
|
+
for (const ev of parsed.events) {
|
|
90
|
+
if (ev.seq > (streams.get(stream) ?? 0)) streams.set(stream, ev.seq);
|
|
91
|
+
post({ kind: "stream-event", stream, data: ev.data, seq: ev.seq });
|
|
92
|
+
}
|
|
93
|
+
} catch (err) {
|
|
94
|
+
console.warn("[patchwork-shell] poll_updates failed:", err);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const callTool = async (id: string, name: string, args: unknown): Promise<void> => {
|
|
99
|
+
try {
|
|
100
|
+
const res = (await app.callServerTool({
|
|
101
|
+
name,
|
|
102
|
+
arguments: (args as Record<string, unknown>) ?? {},
|
|
103
|
+
})) as ToolResult;
|
|
104
|
+
if (res.isError) {
|
|
105
|
+
const text = res.content?.find((c) => c.type === "text")?.text;
|
|
106
|
+
post({ kind: "result", id, ok: false, error: text ?? `Tool call failed: ${name}` });
|
|
107
|
+
} else {
|
|
108
|
+
post({ kind: "result", id, ok: true, value: parseResult(res) });
|
|
109
|
+
}
|
|
110
|
+
} catch (err) {
|
|
111
|
+
post({ kind: "result", id, ok: false, error: err instanceof Error ? err.message : String(err) });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
window.addEventListener("message", (event: MessageEvent) => {
|
|
116
|
+
if (event.source !== iframe.contentWindow) return;
|
|
117
|
+
const m = event.data as Record<string, unknown> | undefined;
|
|
118
|
+
if (!m || m["source"] !== "patchwork") return;
|
|
119
|
+
|
|
120
|
+
switch (m["kind"]) {
|
|
121
|
+
case "size": {
|
|
122
|
+
const h = m["height"];
|
|
123
|
+
if (typeof h === "number" && h > 0) iframe.style.height = `${h}px`;
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
case "service":
|
|
127
|
+
void callTool(m["id"] as string, `${m["namespace"]}__${m["procedure"]}`, m["args"]);
|
|
128
|
+
break;
|
|
129
|
+
case "fire":
|
|
130
|
+
void callTool(m["id"] as string, m["toolName"] as string, m["args"]);
|
|
131
|
+
break;
|
|
132
|
+
case "subscribe": {
|
|
133
|
+
const stream = m["stream"] as string;
|
|
134
|
+
if (streams.has(stream)) break;
|
|
135
|
+
streams.set(stream, 0);
|
|
136
|
+
void app
|
|
137
|
+
.callServerTool({ name: "subscribe_stream", arguments: { stream } })
|
|
138
|
+
.then((res) => {
|
|
139
|
+
const parsed = parseResult(res as ToolResult) as { seq?: number };
|
|
140
|
+
if (typeof parsed?.seq === "number") streams.set(stream, parsed.seq);
|
|
141
|
+
})
|
|
142
|
+
.catch((err) => console.warn("[patchwork-shell] subscribe_stream failed:", err));
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
case "context": {
|
|
146
|
+
const content = m["content"];
|
|
147
|
+
const params =
|
|
148
|
+
typeof content === "string"
|
|
149
|
+
? { content: [{ type: "text", text: content }] }
|
|
150
|
+
: Array.isArray(content)
|
|
151
|
+
? { content }
|
|
152
|
+
: { structuredContent: content as Record<string, unknown> };
|
|
153
|
+
void app.updateModelContext(params).catch(() => {});
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// When the server signals new data (notifications/tools/list_changed), poll
|
|
160
|
+
// every subscribed stream and forward fresh events down to the widget.
|
|
161
|
+
app.fallbackNotificationHandler = async (notification: { method: string }) => {
|
|
162
|
+
if (notification.method === "notifications/tools/list_changed") {
|
|
163
|
+
for (const stream of streams.keys()) await pollStream(stream);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
app.connect().catch((err) => {
|
|
168
|
+
console.error("[patchwork-shell] failed to connect to host:", err);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
boot();
|
package/src/shim.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export interface BridgeShimOptions {
|
|
2
|
+
/** Service namespaces the widget calls (e.g. ["weather", "stripe"]). */
|
|
3
|
+
namespaces: string[];
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generate the runtime-side bridge shim.
|
|
8
|
+
*
|
|
9
|
+
* This runs inside the CSP-free runtime iframe (the nested frame that compiles
|
|
10
|
+
* and mounts the widget). It exposes `window.patchwork` and a proxy per service
|
|
11
|
+
* namespace, but instead of talking to the MCP host directly — the runtime
|
|
12
|
+
* iframe is one level removed from the host and can't — it forwards every call
|
|
13
|
+
* to the parent shell over `postMessage`. The shell holds the ext-apps `App`
|
|
14
|
+
* and relays to the host:
|
|
15
|
+
*
|
|
16
|
+
* widget → window.patchwork.* / namespace.* → postMessage(parent) → shell → host
|
|
17
|
+
*
|
|
18
|
+
* Messages sent to the parent: `{ source: 'patchwork', kind, ... }`.
|
|
19
|
+
* Messages received from the parent: `{ source: 'patchwork-host', kind, ... }`.
|
|
20
|
+
*/
|
|
21
|
+
export function generateBridgeShim(options: BridgeShimOptions): string {
|
|
22
|
+
const namespacesJson = JSON.stringify(options.namespaces ?? []);
|
|
23
|
+
|
|
24
|
+
return `
|
|
25
|
+
(function () {
|
|
26
|
+
if (window.patchwork) return; // guard against double-injection
|
|
27
|
+
|
|
28
|
+
var __pending = {};
|
|
29
|
+
var __seq = 0;
|
|
30
|
+
var __streams = {};
|
|
31
|
+
|
|
32
|
+
function __post(msg) {
|
|
33
|
+
msg.source = 'patchwork';
|
|
34
|
+
window.parent.postMessage(msg, '*');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function __request(payload) {
|
|
38
|
+
return new Promise(function (resolve, reject) {
|
|
39
|
+
var id = 'r' + (++__seq);
|
|
40
|
+
__pending[id] = { resolve: resolve, reject: reject };
|
|
41
|
+
payload.id = id;
|
|
42
|
+
__post(payload);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
window.addEventListener('message', function (e) {
|
|
47
|
+
var m = e.data;
|
|
48
|
+
if (!m || m.source !== 'patchwork-host') return;
|
|
49
|
+
if (m.kind === 'result' && m.id && __pending[m.id]) {
|
|
50
|
+
var p = __pending[m.id];
|
|
51
|
+
delete __pending[m.id];
|
|
52
|
+
if (m.ok) p.resolve(m.value);
|
|
53
|
+
else p.reject(new Error(m.error || 'Service call failed'));
|
|
54
|
+
} else if (m.kind === 'stream-event') {
|
|
55
|
+
var cbs = __streams[m.stream];
|
|
56
|
+
if (cbs) {
|
|
57
|
+
for (var i = 0; i < cbs.length; i++) {
|
|
58
|
+
try { cbs[i](m.data, m.seq, m.stream); }
|
|
59
|
+
catch (err) { console.error('[patchwork] subscribe callback error:', err); }
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
window.patchwork = {
|
|
66
|
+
/** Subscribe to a named server data stream. Returns an unsubscribe fn. */
|
|
67
|
+
subscribe: function (stream, cb) {
|
|
68
|
+
if (!__streams[stream]) {
|
|
69
|
+
__streams[stream] = [];
|
|
70
|
+
__post({ kind: 'subscribe', stream: stream });
|
|
71
|
+
}
|
|
72
|
+
__streams[stream].push(cb);
|
|
73
|
+
return function () {
|
|
74
|
+
var a = __streams[stream];
|
|
75
|
+
if (!a) return;
|
|
76
|
+
var i = a.indexOf(cb);
|
|
77
|
+
if (i !== -1) a.splice(i, 1);
|
|
78
|
+
};
|
|
79
|
+
},
|
|
80
|
+
/** Push widget state into the conversation context. */
|
|
81
|
+
updateContext: function (content) {
|
|
82
|
+
__post({ kind: 'context', content: content });
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
},
|
|
85
|
+
/** Fire a client event as an MCP tool call and await its result. */
|
|
86
|
+
fireEvent: function (toolName, args) {
|
|
87
|
+
return __request({ kind: 'fire', toolName: toolName, args: args || {} });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
var __namespaces = ${namespacesJson};
|
|
92
|
+
for (var __i = 0; __i < __namespaces.length; __i++) {
|
|
93
|
+
(function (ns) {
|
|
94
|
+
window[ns] = new Proxy({}, {
|
|
95
|
+
get: function (target, prop) {
|
|
96
|
+
if (typeof prop !== 'string') return undefined;
|
|
97
|
+
return function (args) {
|
|
98
|
+
return __request({ kind: 'service', namespace: ns, procedure: prop, args: args || {} });
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
})(__namespaces[__i]);
|
|
103
|
+
}
|
|
104
|
+
})();
|
|
105
|
+
`;
|
|
106
|
+
}
|
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { log, error } from "./logger.js";
|
|
3
|
+
|
|
4
|
+
let tunnelProcess: ChildProcess | null = null;
|
|
5
|
+
let tunnelUrl: string | null = null;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Poll the tunnel's public `/health` endpoint until it returns 200.
|
|
9
|
+
*
|
|
10
|
+
* cloudflared prints the `*.trycloudflare.com` URL as soon as the process
|
|
11
|
+
* registers, but the edge route is frequently not live yet — requests in that
|
|
12
|
+
* window return Cloudflare error 1033 ("unable to resolve"). Publishing the URL
|
|
13
|
+
* before it's actually reachable bakes a dead host into rendered widgets, so we
|
|
14
|
+
* verify reachability before adopting it.
|
|
15
|
+
*/
|
|
16
|
+
async function waitForTunnelLive(url: string, timeoutMs = 30000): Promise<boolean> {
|
|
17
|
+
const deadline = Date.now() + timeoutMs;
|
|
18
|
+
while (Date.now() < deadline) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
|
21
|
+
if (res.ok) return true;
|
|
22
|
+
} catch {
|
|
23
|
+
// not reachable yet — keep polling
|
|
24
|
+
}
|
|
25
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Start a cloudflare tunnel to expose a local port publicly.
|
|
32
|
+
* Returns the public URL once the tunnel is established AND verified reachable.
|
|
33
|
+
*/
|
|
34
|
+
export async function startTunnel(port: number): Promise<string> {
|
|
35
|
+
if (tunnelUrl) return tunnelUrl;
|
|
36
|
+
|
|
37
|
+
const detectedUrl = await new Promise<string>((resolve, reject) => {
|
|
38
|
+
const args = ["tunnel", "--url", `http://localhost:${port}`];
|
|
39
|
+
|
|
40
|
+
log("tunnel", `Starting cloudflared tunnel for port ${port}...`);
|
|
41
|
+
|
|
42
|
+
tunnelProcess = spawn("cloudflared", args, {
|
|
43
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
let resolved = false;
|
|
47
|
+
const timeout = setTimeout(() => {
|
|
48
|
+
if (!resolved) {
|
|
49
|
+
resolved = true;
|
|
50
|
+
reject(new Error("Tunnel startup timed out after 30s"));
|
|
51
|
+
}
|
|
52
|
+
}, 30000);
|
|
53
|
+
|
|
54
|
+
const handleOutput = (data: Buffer) => {
|
|
55
|
+
const text = data.toString();
|
|
56
|
+
|
|
57
|
+
// Look for the tunnel URL in the output
|
|
58
|
+
// cloudflared outputs: "https://xxx.trycloudflare.com"
|
|
59
|
+
const urlMatch = text.match(/https:\/\/[^\s]+\.trycloudflare\.com\/?/);
|
|
60
|
+
if (urlMatch && !resolved) {
|
|
61
|
+
resolved = true;
|
|
62
|
+
clearTimeout(timeout);
|
|
63
|
+
resolve(urlMatch[0].replace(/\/$/, ""));
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
tunnelProcess.stdout?.on("data", handleOutput);
|
|
68
|
+
tunnelProcess.stderr?.on("data", handleOutput);
|
|
69
|
+
|
|
70
|
+
tunnelProcess.on("error", (err) => {
|
|
71
|
+
if (!resolved) {
|
|
72
|
+
resolved = true;
|
|
73
|
+
clearTimeout(timeout);
|
|
74
|
+
error("tunnel", "Failed to start cloudflared:", err);
|
|
75
|
+
reject(err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
tunnelProcess.on("exit", (code) => {
|
|
80
|
+
if (!resolved) {
|
|
81
|
+
resolved = true;
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
reject(new Error(`cloudflared exited with code ${code}`));
|
|
84
|
+
}
|
|
85
|
+
tunnelProcess = null;
|
|
86
|
+
tunnelUrl = null;
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// Verify-then-publish: don't adopt the hostname until the edge route is live,
|
|
91
|
+
// otherwise rendered widgets reference a host that returns Cloudflare 1033.
|
|
92
|
+
log("tunnel", `Tunnel hostname detected (${detectedUrl}); verifying reachability...`);
|
|
93
|
+
const live = await waitForTunnelLive(detectedUrl);
|
|
94
|
+
if (!live) {
|
|
95
|
+
error(
|
|
96
|
+
"tunnel",
|
|
97
|
+
`Tunnel ${detectedUrl} did not become reachable within 30s — publishing anyway, but widgets may fail to load until the edge route propagates.`,
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
log("tunnel", `Tunnel verified reachable: ${detectedUrl}`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
tunnelUrl = detectedUrl;
|
|
104
|
+
return tunnelUrl;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Stop the cloudflare tunnel if running.
|
|
109
|
+
*/
|
|
110
|
+
export function stopTunnel(): void {
|
|
111
|
+
if (tunnelProcess) {
|
|
112
|
+
tunnelProcess.kill();
|
|
113
|
+
tunnelProcess = null;
|
|
114
|
+
tunnelUrl = null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get the current tunnel URL if available.
|
|
120
|
+
*/
|
|
121
|
+
export function getTunnelUrl(): string | null {
|
|
122
|
+
return tunnelUrl;
|
|
123
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { WidgetStore, getWidgetStore, resetWidgetStore } from "./store.js";
|
|
2
|
+
export { LocalFileBackend } from "./local-backend.js";
|
|
3
|
+
export type {
|
|
4
|
+
FSProvider,
|
|
5
|
+
FileStats,
|
|
6
|
+
DirEntry,
|
|
7
|
+
StoredWidget,
|
|
8
|
+
StoredWidgetInfo,
|
|
9
|
+
WidgetStoreOptions,
|
|
10
|
+
} from "./types.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile, writeFile, unlink, stat, mkdir, readdir, rm, access } from "node:fs/promises";
|
|
2
|
+
import { join, dirname, normalize } from "node:path";
|
|
3
|
+
import type { DirEntry, FileStats, FSProvider } from "./types.js";
|
|
4
|
+
|
|
5
|
+
function createDirEntry(name: string, isDir: boolean): DirEntry {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
isFile: () => !isDir,
|
|
9
|
+
isDirectory: () => isDir,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createFileStats(size: number, mtime: Date, isDir: boolean): FileStats {
|
|
14
|
+
return {
|
|
15
|
+
size,
|
|
16
|
+
mtime,
|
|
17
|
+
isFile: () => !isDir,
|
|
18
|
+
isDirectory: () => isDir,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class LocalFileBackend implements FSProvider {
|
|
23
|
+
constructor(private basePath: string) {}
|
|
24
|
+
|
|
25
|
+
private resolve(path: string): string {
|
|
26
|
+
return join(this.basePath, normalize(path));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async readFile(path: string): Promise<string> {
|
|
30
|
+
return readFile(this.resolve(path), "utf-8");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async writeFile(path: string, content: string): Promise<void> {
|
|
34
|
+
const fullPath = this.resolve(path);
|
|
35
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
36
|
+
await writeFile(fullPath, content, "utf-8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async unlink(path: string): Promise<void> {
|
|
40
|
+
await unlink(this.resolve(path));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async stat(path: string): Promise<FileStats> {
|
|
44
|
+
const fullPath = this.resolve(path);
|
|
45
|
+
const s = await stat(fullPath);
|
|
46
|
+
return createFileStats(s.size, s.mtime, s.isDirectory());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
50
|
+
await mkdir(this.resolve(path), options);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async readdir(path: string): Promise<DirEntry[]> {
|
|
54
|
+
const fullPath = this.resolve(path);
|
|
55
|
+
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
56
|
+
return entries.map((e) =>
|
|
57
|
+
createDirEntry(e.name, e.isDirectory()),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async rmdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
62
|
+
if (options?.recursive) {
|
|
63
|
+
await rm(this.resolve(path), { recursive: true, force: true });
|
|
64
|
+
} else {
|
|
65
|
+
await rm(this.resolve(path), { force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async exists(path: string): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
await access(this.resolve(path));
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|