@aprovan/mcp-app-server 0.1.0-dev.c1ac1dc
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/.turbo/turbo-build.log +20 -0
- package/E2E_TESTING.md +198 -0
- package/LICENSE +373 -0
- package/README.md +134 -0
- package/dist/index.d.ts +108 -0
- package/dist/index.js +1592 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +1841 -0
- package/dist/server.js.map +1 -0
- package/package.json +52 -0
- package/src/__tests__/cache.test.ts +119 -0
- package/src/__tests__/cdn-plugin.test.ts +86 -0
- package/src/__tests__/compile.test.ts +93 -0
- package/src/__tests__/e2e-pipeline.test.ts +417 -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__/registry-backend.test.ts +256 -0
- package/src/__tests__/services.test.ts +153 -0
- package/src/__tests__/shim.test.ts +132 -0
- package/src/__tests__/widget-store.test.ts +183 -0
- package/src/compiler/cache.ts +68 -0
- package/src/compiler/cdn-plugin.ts +197 -0
- package/src/compiler/compile.ts +306 -0
- package/src/compiler/index.ts +3 -0
- package/src/hello-world.html +79 -0
- package/src/html.d.ts +4 -0
- package/src/index.ts +641 -0
- package/src/live-update.ts +149 -0
- package/src/reference-widgets/live-dashboard.ts +162 -0
- package/src/registry-backend.ts +304 -0
- package/src/server.ts +178 -0
- package/src/services.ts +251 -0
- package/src/shim.ts +247 -0
- package/src/widget-store/index.ts +10 -0
- package/src/widget-store/local-backend.ts +77 -0
- package/src/widget-store/store.ts +198 -0
- package/src/widget-store/types.ts +50 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +14 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { generateServiceShim, generateLiveUpdateShim } from "../shim.js";
|
|
3
|
+
|
|
4
|
+
describe("generateServiceShim", () => {
|
|
5
|
+
it("returns empty string when namespaces is empty", () => {
|
|
6
|
+
const result = generateServiceShim({ namespaces: [] });
|
|
7
|
+
expect(result).toBe("");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("generates shim code that imports App from esm.sh", () => {
|
|
11
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
12
|
+
expect(result).toContain("import { App } from");
|
|
13
|
+
expect(result).toContain("esm.sh/@modelcontextprotocol/ext-apps");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("uses default ext-apps version when not specified", () => {
|
|
17
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
18
|
+
expect(result).toContain("^1.7.3");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("uses custom ext-apps version when specified", () => {
|
|
22
|
+
const result = generateServiceShim({
|
|
23
|
+
namespaces: ["weather"],
|
|
24
|
+
extAppsVersion: "2.0.0",
|
|
25
|
+
});
|
|
26
|
+
expect(result).toContain("@modelcontextprotocol/ext-apps@2.0.0");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("creates App instance and calls connect", () => {
|
|
30
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
31
|
+
expect(result).toContain("new App(");
|
|
32
|
+
expect(result).toContain(".connect()");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("creates namespace proxies for each service", () => {
|
|
36
|
+
const result = generateServiceShim({
|
|
37
|
+
namespaces: ["weather", "stripe"],
|
|
38
|
+
});
|
|
39
|
+
expect(result).toContain('"weather"');
|
|
40
|
+
expect(result).toContain('"stripe"');
|
|
41
|
+
expect(result).toContain("__patchwork_createNamespaceProxy");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("sets namespace proxies on window", () => {
|
|
45
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
46
|
+
expect(result).toContain("window[__ns]");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("proxy uses __ separator for tool names", () => {
|
|
50
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
51
|
+
expect(result).toContain("namespace + '__' + prop");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("proxy calls callServerTool", () => {
|
|
55
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
56
|
+
expect(result).toContain("callServerTool");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("handles isError responses by throwing", () => {
|
|
60
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
61
|
+
expect(result).toContain("result.isError");
|
|
62
|
+
expect(result).toContain("throw new Error");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("parses JSON text content from results", () => {
|
|
66
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
67
|
+
expect(result).toContain("JSON.parse(textContent.text)");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("waits for app connection before making calls", () => {
|
|
71
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
72
|
+
expect(result).toContain("__patchwork_ready.then");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("handles connection failure gracefully", () => {
|
|
76
|
+
const result = generateServiceShim({ namespaces: ["weather"] });
|
|
77
|
+
expect(result).toContain(".catch");
|
|
78
|
+
expect(result).toContain("Failed to connect");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("generateLiveUpdateShim", () => {
|
|
83
|
+
it("imports App from esm.sh ext-apps", () => {
|
|
84
|
+
const shim = generateLiveUpdateShim();
|
|
85
|
+
expect(shim).toContain("import { App } from");
|
|
86
|
+
expect(shim).toContain("esm.sh/@modelcontextprotocol/ext-apps");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("uses default ext-apps version when not specified", () => {
|
|
90
|
+
const shim = generateLiveUpdateShim();
|
|
91
|
+
expect(shim).toContain("^1.7.3");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("uses custom ext-apps version when specified", () => {
|
|
95
|
+
const shim = generateLiveUpdateShim({ extAppsVersion: "2.0.0" });
|
|
96
|
+
expect(shim).toContain("@modelcontextprotocol/ext-apps@2.0.0");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("guards against double App initialisation", () => {
|
|
100
|
+
const shim = generateLiveUpdateShim();
|
|
101
|
+
expect(shim).toContain("if (!window.__patchwork_app)");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("exposes window.patchwork.subscribe", () => {
|
|
105
|
+
const shim = generateLiveUpdateShim();
|
|
106
|
+
expect(shim).toContain("subscribe:");
|
|
107
|
+
expect(shim).toContain("subscribe_stream");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("exposes window.patchwork.updateContext", () => {
|
|
111
|
+
const shim = generateLiveUpdateShim();
|
|
112
|
+
expect(shim).toContain("updateContext:");
|
|
113
|
+
expect(shim).toContain("ui/update-model-context");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("exposes window.patchwork.fireEvent", () => {
|
|
117
|
+
const shim = generateLiveUpdateShim();
|
|
118
|
+
expect(shim).toContain("fireEvent:");
|
|
119
|
+
expect(shim).toContain("callServerTool");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("registers a notifications/tools/list_changed handler for polling", () => {
|
|
123
|
+
const shim = generateLiveUpdateShim();
|
|
124
|
+
expect(shim).toContain("notifications/tools/list_changed");
|
|
125
|
+
expect(shim).toContain("poll_updates");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("poll_updates passes after_seq to avoid duplicates", () => {
|
|
129
|
+
const shim = generateLiveUpdateShim();
|
|
130
|
+
expect(shim).toContain("after_seq");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import { WidgetStore, resetWidgetStore } from "../widget-store/store.js";
|
|
3
|
+
import { MemoryBackend } from "./memory-backend.js";
|
|
4
|
+
import type { Manifest } from "@aprovan/patchwork-compiler";
|
|
5
|
+
|
|
6
|
+
const TEST_MANIFEST: Manifest = {
|
|
7
|
+
name: "test-widget",
|
|
8
|
+
version: "0.1.0",
|
|
9
|
+
platform: "browser",
|
|
10
|
+
image: "@aprovan/patchwork-image-shadcn",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function createStore(): WidgetStore {
|
|
14
|
+
resetWidgetStore();
|
|
15
|
+
return new WidgetStore({ backend: new MemoryBackend() });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("WidgetStore", () => {
|
|
19
|
+
let store: WidgetStore;
|
|
20
|
+
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
store = createStore();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("saveWidget", () => {
|
|
26
|
+
it("persists a widget and returns stored metadata", async () => {
|
|
27
|
+
const result = await store.saveWidget("abc123", "<html>test</html>", TEST_MANIFEST);
|
|
28
|
+
|
|
29
|
+
expect(result.path).toBe("widgets/test-widget/abc123/view.html");
|
|
30
|
+
expect(result.resourceUri).toBe("ui://widgets/test-widget/abc123/view.html");
|
|
31
|
+
expect(result.html).toBe("<html>test</html>");
|
|
32
|
+
expect(result.manifest.name).toBe("test-widget");
|
|
33
|
+
expect(result.createdAt).toBeTypeOf("number");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("stores manifest with entry for multi-file projects", async () => {
|
|
37
|
+
const result = await store.saveWidget(
|
|
38
|
+
"def456",
|
|
39
|
+
"<html>multi</html>",
|
|
40
|
+
{ ...TEST_MANIFEST, name: "multi-widget" },
|
|
41
|
+
"main.tsx",
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
expect(result.entry).toBe("main.tsx");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("stores manifest with services metadata", async () => {
|
|
48
|
+
const manifest: Manifest = {
|
|
49
|
+
...TEST_MANIFEST,
|
|
50
|
+
services: ["weather", "github"],
|
|
51
|
+
};
|
|
52
|
+
const result = await store.saveWidget("svc123", "<html>svc</html>", manifest);
|
|
53
|
+
|
|
54
|
+
expect(result.manifest.services).toEqual(["weather", "github"]);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("getWidget", () => {
|
|
59
|
+
it("retrieves a stored widget by name and hash", async () => {
|
|
60
|
+
await store.saveWidget("abc123", "<html>test</html>", TEST_MANIFEST);
|
|
61
|
+
const widget = await store.getWidget("test-widget", "abc123");
|
|
62
|
+
|
|
63
|
+
expect(widget).not.toBeNull();
|
|
64
|
+
expect(widget!.html).toBe("<html>test</html>");
|
|
65
|
+
expect(widget!.manifest.name).toBe("test-widget");
|
|
66
|
+
expect(widget!.resourceUri).toBe("ui://widgets/test-widget/abc123/view.html");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns null for non-existent widget", async () => {
|
|
70
|
+
const widget = await store.getWidget("nonexistent", "nothash");
|
|
71
|
+
expect(widget).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("retrieves entry point from manifest", async () => {
|
|
75
|
+
await store.saveWidget("ent123", "<html>entry</html>", TEST_MANIFEST, "app.tsx");
|
|
76
|
+
const widget = await store.getWidget("test-widget", "ent123");
|
|
77
|
+
|
|
78
|
+
expect(widget!.entry).toBe("app.tsx");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("listWidgets", () => {
|
|
83
|
+
it("returns empty list when no widgets stored", async () => {
|
|
84
|
+
const widgets = await store.listWidgets();
|
|
85
|
+
expect(widgets).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("lists all stored widgets with metadata", async () => {
|
|
89
|
+
await store.saveWidget("abc123", "<html>a</html>", TEST_MANIFEST);
|
|
90
|
+
await store.saveWidget(
|
|
91
|
+
"def456",
|
|
92
|
+
"<html>b</html>",
|
|
93
|
+
{ ...TEST_MANIFEST, name: "other-widget", description: "Another widget" },
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const widgets = await store.listWidgets();
|
|
97
|
+
expect(widgets).toHaveLength(2);
|
|
98
|
+
expect(widgets.map((w) => w.name)).toContain("test-widget");
|
|
99
|
+
expect(widgets.map((w) => w.name)).toContain("other-widget");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("includes services and entry in listing", async () => {
|
|
103
|
+
await store.saveWidget(
|
|
104
|
+
"svc123",
|
|
105
|
+
"<html>svc</html>",
|
|
106
|
+
{ ...TEST_MANIFEST, services: ["stripe"] },
|
|
107
|
+
"main.tsx",
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const widgets = await store.listWidgets();
|
|
111
|
+
const entry = widgets.find((w) => w.name === "test-widget");
|
|
112
|
+
expect(entry!.services).toEqual(["stripe"]);
|
|
113
|
+
expect(entry!.entry).toBe("main.tsx");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns widgets sorted by most recent first", async () => {
|
|
117
|
+
const store = createStore();
|
|
118
|
+
await store.saveWidget("old", "<html>old</html>", { ...TEST_MANIFEST, name: "old-widget" });
|
|
119
|
+
|
|
120
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
121
|
+
|
|
122
|
+
await store.saveWidget("new", "<html>new</html>", { ...TEST_MANIFEST, name: "new-widget" });
|
|
123
|
+
|
|
124
|
+
const widgets = await store.listWidgets();
|
|
125
|
+
expect(widgets[0]!.name).toBe("new-widget");
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("deleteWidget", () => {
|
|
130
|
+
it("deletes a stored widget", async () => {
|
|
131
|
+
await store.saveWidget("del123", "<html>del</html>", TEST_MANIFEST);
|
|
132
|
+
const deleted = await store.deleteWidget("test-widget", "del123");
|
|
133
|
+
expect(deleted).toBe(true);
|
|
134
|
+
|
|
135
|
+
const widget = await store.getWidget("test-widget", "del123");
|
|
136
|
+
expect(widget).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("returns false for non-existent widget", async () => {
|
|
140
|
+
const deleted = await store.deleteWidget("nonexistent", "nothash");
|
|
141
|
+
expect(deleted).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("hasWidget", () => {
|
|
146
|
+
it("returns true for stored widget", async () => {
|
|
147
|
+
await store.saveWidget("has123", "<html>has</html>", TEST_MANIFEST);
|
|
148
|
+
expect(await store.hasWidget("test-widget", "has123")).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("returns false for non-existent widget", async () => {
|
|
152
|
+
expect(await store.hasWidget("nonexistent", "nothash")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("resourceUriFor", () => {
|
|
157
|
+
it("generates correct resource URI", () => {
|
|
158
|
+
const uri = store.resourceUriFor("my-widget", "abc123");
|
|
159
|
+
expect(uri).toBe("ui://widgets/my-widget/abc123/view.html");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("loadAll", () => {
|
|
164
|
+
it("loads all stored widgets", async () => {
|
|
165
|
+
await store.saveWidget("abc123", "<html>a</html>", TEST_MANIFEST);
|
|
166
|
+
await store.saveWidget(
|
|
167
|
+
"def456",
|
|
168
|
+
"<html>b</html>",
|
|
169
|
+
{ ...TEST_MANIFEST, name: "other-widget" },
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
const widgets = await store.loadAll();
|
|
173
|
+
expect(widgets).toHaveLength(2);
|
|
174
|
+
expect(widgets.map((w) => w.manifest.name)).toContain("test-widget");
|
|
175
|
+
expect(widgets.map((w) => w.manifest.name)).toContain("other-widget");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("returns empty array when no widgets stored", async () => {
|
|
179
|
+
const widgets = await store.loadAll();
|
|
180
|
+
expect(widgets).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { Manifest, VirtualProject } from "@aprovan/patchwork-compiler";
|
|
3
|
+
|
|
4
|
+
export interface CachedWidget {
|
|
5
|
+
html: string;
|
|
6
|
+
manifest: Manifest;
|
|
7
|
+
resourceUri: string;
|
|
8
|
+
createdAt: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const MAX_CACHE_SIZE = 256;
|
|
12
|
+
|
|
13
|
+
const cache = new Map<string, CachedWidget>();
|
|
14
|
+
|
|
15
|
+
export function computeCacheKey(
|
|
16
|
+
source: string | VirtualProject,
|
|
17
|
+
manifest: Manifest,
|
|
18
|
+
): string {
|
|
19
|
+
const parts: string[] = [];
|
|
20
|
+
|
|
21
|
+
if (typeof source === "string") {
|
|
22
|
+
parts.push(source);
|
|
23
|
+
} else {
|
|
24
|
+
const sortedFiles = Array.from(source.files.entries()).sort(([a], [b]) =>
|
|
25
|
+
a.localeCompare(b),
|
|
26
|
+
);
|
|
27
|
+
for (const [path, file] of sortedFiles) {
|
|
28
|
+
parts.push(`${path}::${file.content}`);
|
|
29
|
+
}
|
|
30
|
+
parts.push(`entry:${source.entry}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
parts.push(JSON.stringify(manifest));
|
|
34
|
+
|
|
35
|
+
return createHash("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function get(hash: string): CachedWidget | undefined {
|
|
39
|
+
return cache.get(hash);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function set(hash: string, entry: CachedWidget): void {
|
|
43
|
+
if (cache.size >= MAX_CACHE_SIZE) {
|
|
44
|
+
const entries = Array.from(cache.entries());
|
|
45
|
+
entries.sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
46
|
+
const toRemove = entries.slice(0, Math.floor(MAX_CACHE_SIZE / 4));
|
|
47
|
+
for (const [key] of toRemove) {
|
|
48
|
+
cache.delete(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
cache.set(hash, entry);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function has(hash: string): boolean {
|
|
55
|
+
return cache.has(hash);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function clear(): void {
|
|
59
|
+
cache.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function size(): number {
|
|
63
|
+
return cache.size;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function allEntries(): Array<[string, CachedWidget]> {
|
|
67
|
+
return Array.from(cache.entries());
|
|
68
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import type { ImageConfig } from "@aprovan/patchwork-compiler";
|
|
2
|
+
import type { Plugin } from "vite";
|
|
3
|
+
|
|
4
|
+
const ESM_SH_BASE = "https://esm.sh";
|
|
5
|
+
|
|
6
|
+
const REACT_EXPORTS = [
|
|
7
|
+
"useState",
|
|
8
|
+
"useEffect",
|
|
9
|
+
"useCallback",
|
|
10
|
+
"useMemo",
|
|
11
|
+
"useRef",
|
|
12
|
+
"useContext",
|
|
13
|
+
"useReducer",
|
|
14
|
+
"useLayoutEffect",
|
|
15
|
+
"useId",
|
|
16
|
+
"createContext",
|
|
17
|
+
"createElement",
|
|
18
|
+
"cloneElement",
|
|
19
|
+
"createRef",
|
|
20
|
+
"forwardRef",
|
|
21
|
+
"lazy",
|
|
22
|
+
"memo",
|
|
23
|
+
"Fragment",
|
|
24
|
+
"Suspense",
|
|
25
|
+
"StrictMode",
|
|
26
|
+
"Component",
|
|
27
|
+
"PureComponent",
|
|
28
|
+
"Children",
|
|
29
|
+
"isValidElement",
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const REACT_DOM_EXPORTS = [
|
|
33
|
+
"createPortal",
|
|
34
|
+
"flushSync",
|
|
35
|
+
"render",
|
|
36
|
+
"hydrate",
|
|
37
|
+
"unmountComponentAtNode",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function toEsmShUrl(
|
|
41
|
+
packageName: string,
|
|
42
|
+
version?: string,
|
|
43
|
+
subpath?: string,
|
|
44
|
+
deps?: Record<string, string>,
|
|
45
|
+
): string {
|
|
46
|
+
let url: string = `${ESM_SH_BASE}/${packageName}`;
|
|
47
|
+
if (version) url += `@${version}`;
|
|
48
|
+
if (subpath) url += `/${subpath}`;
|
|
49
|
+
if (deps && Object.keys(deps).length > 0) {
|
|
50
|
+
const depsStr = Object.entries(deps)
|
|
51
|
+
.map(([name, ver]) => `${name}@${ver}`)
|
|
52
|
+
.join(",");
|
|
53
|
+
url += `?deps=${depsStr}`;
|
|
54
|
+
}
|
|
55
|
+
return url;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseImportPath(importPath: string): {
|
|
59
|
+
packageName: string;
|
|
60
|
+
subpath?: string;
|
|
61
|
+
} {
|
|
62
|
+
if (importPath.startsWith("@")) {
|
|
63
|
+
const parts = importPath.split("/");
|
|
64
|
+
if (parts.length >= 2) {
|
|
65
|
+
const packageName = `${parts[0]}/${parts[1]}`;
|
|
66
|
+
const subpath = parts.slice(2).join("/");
|
|
67
|
+
return { packageName, subpath: subpath || undefined };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const parts = importPath.split("/");
|
|
71
|
+
return { packageName: parts[0] ?? "", subpath: parts.slice(1).join("/") || undefined };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isBareImport(path: string): boolean {
|
|
75
|
+
return !(
|
|
76
|
+
path.startsWith(".") ||
|
|
77
|
+
path.startsWith("/") ||
|
|
78
|
+
path.startsWith("http://") ||
|
|
79
|
+
path.startsWith("https://")
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function matchAlias(
|
|
84
|
+
importPath: string,
|
|
85
|
+
aliases: Record<string, string>,
|
|
86
|
+
): string | null {
|
|
87
|
+
for (const [pattern, target] of Object.entries(aliases)) {
|
|
88
|
+
if (pattern.endsWith("/*")) {
|
|
89
|
+
const prefix = pattern.slice(0, -2);
|
|
90
|
+
if (importPath === prefix || importPath.startsWith(prefix + "/")) {
|
|
91
|
+
return target;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (importPath === pattern) {
|
|
95
|
+
return target;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface CdnPluginOptions {
|
|
102
|
+
imageConfig: ImageConfig;
|
|
103
|
+
packages?: Record<string, string>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function patchworkCdnPlugin(options: CdnPluginOptions): Plugin {
|
|
107
|
+
const { imageConfig } = options;
|
|
108
|
+
const globals = imageConfig.framework?.globals ?? {};
|
|
109
|
+
const deps = imageConfig.framework?.deps ?? {};
|
|
110
|
+
const aliases = imageConfig.aliases ?? {};
|
|
111
|
+
const packages = options.packages ?? {};
|
|
112
|
+
const globalsSet = new Set(Object.keys(globals));
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
name: "patchwork-cdn",
|
|
116
|
+
enforce: "pre",
|
|
117
|
+
|
|
118
|
+
resolveId(source) {
|
|
119
|
+
if (!isBareImport(source)) return null;
|
|
120
|
+
|
|
121
|
+
const aliasTarget = matchAlias(source, aliases);
|
|
122
|
+
if (aliasTarget) {
|
|
123
|
+
const { packageName, subpath } = parseImportPath(aliasTarget);
|
|
124
|
+
|
|
125
|
+
if (globalsSet.has(packageName)) {
|
|
126
|
+
return `${packageName}${subpath ? `/${subpath}` : ""}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const version = packages[packageName];
|
|
130
|
+
const url = toEsmShUrl(
|
|
131
|
+
packageName,
|
|
132
|
+
version,
|
|
133
|
+
subpath,
|
|
134
|
+
Object.keys(deps).length > 0 ? deps : undefined,
|
|
135
|
+
);
|
|
136
|
+
return { id: url, external: true };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const { packageName, subpath } = parseImportPath(source);
|
|
140
|
+
|
|
141
|
+
if (globalsSet.has(packageName)) {
|
|
142
|
+
return source;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const version = packages[packageName];
|
|
146
|
+
const url = toEsmShUrl(
|
|
147
|
+
packageName,
|
|
148
|
+
version,
|
|
149
|
+
subpath,
|
|
150
|
+
Object.keys(deps).length > 0 ? deps : undefined,
|
|
151
|
+
);
|
|
152
|
+
return { id: url, external: true };
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
load(id) {
|
|
156
|
+
const { packageName, subpath } = parseImportPath(id);
|
|
157
|
+
const globalName = globals[packageName];
|
|
158
|
+
if (!globalName) return null;
|
|
159
|
+
|
|
160
|
+
if (subpath) {
|
|
161
|
+
const url = toEsmShUrl(
|
|
162
|
+
packageName,
|
|
163
|
+
packages[packageName],
|
|
164
|
+
subpath,
|
|
165
|
+
Object.keys(deps).length > 0 ? deps : undefined,
|
|
166
|
+
);
|
|
167
|
+
return `export * from '${url}'; export { default } from '${url}';`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const commonExports =
|
|
171
|
+
packageName === "react"
|
|
172
|
+
? REACT_EXPORTS
|
|
173
|
+
: packageName === "react-dom"
|
|
174
|
+
? REACT_DOM_EXPORTS
|
|
175
|
+
: [];
|
|
176
|
+
|
|
177
|
+
const lines = [
|
|
178
|
+
`const mod = window.${globalName};`,
|
|
179
|
+
`export default mod;`,
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
if (commonExports.length > 0) {
|
|
183
|
+
lines.push(
|
|
184
|
+
`const { ${commonExports.join(", ")} } = mod;`,
|
|
185
|
+
`export { ${commonExports.join(", ")} };`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return lines.join("\n");
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function getPreloadScripts(imageConfig: ImageConfig): string[] {
|
|
195
|
+
const preload = imageConfig.framework?.preload ?? [];
|
|
196
|
+
return preload.map((url) => `<script src="${url}"></script>`);
|
|
197
|
+
}
|