@joycostudio/studio 0.0.0-stage → 0.1.0
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/README.md +271 -2
- package/dist/chunk-QKEAOM2U.js +13 -0
- package/dist/chunk-QKEAOM2U.js.map +1 -0
- package/dist/chunk-VYNEG5Z7.js +75 -0
- package/dist/chunk-VYNEG5Z7.js.map +1 -0
- package/dist/cli.js +687 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +63 -0
- package/dist/client.js +273 -0
- package/dist/client.js.map +1 -0
- package/dist/command-menu.css +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/protocol-BrHDtNkL.d.ts +140 -0
- package/dist/react.d.ts +184 -0
- package/dist/react.js +1527 -0
- package/dist/react.js.map +1 -0
- package/dist/runtime.d.ts +41 -0
- package/dist/runtime.js +546 -0
- package/dist/runtime.js.map +1 -0
- package/dist/styles.css +2 -0
- package/package.json +98 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import path4 from "path";
|
|
7
|
+
import { parseArgs } from "util";
|
|
8
|
+
|
|
9
|
+
// src/core/tool.ts
|
|
10
|
+
var DEFAULT_PORT = 5179;
|
|
11
|
+
|
|
12
|
+
// src/runtime/runtime.ts
|
|
13
|
+
import { watch } from "fs";
|
|
14
|
+
import path2 from "path";
|
|
15
|
+
|
|
16
|
+
// src/core/result.ts
|
|
17
|
+
var success = (data) => ({ ok: true, data });
|
|
18
|
+
var error = (code, message) => ({
|
|
19
|
+
ok: false,
|
|
20
|
+
error: code,
|
|
21
|
+
...message === void 0 ? {} : { message }
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// src/core/validate.ts
|
|
25
|
+
function validateConfig(tool2, candidate) {
|
|
26
|
+
const mismatch = shapeMismatch(tool2.initial, candidate, "config");
|
|
27
|
+
if (mismatch) return error("invalid_config", mismatch);
|
|
28
|
+
const config = structuredClone(candidate);
|
|
29
|
+
const invalid = invalidControl(tool2.controls(config));
|
|
30
|
+
return invalid ? error("invalid_config", invalid) : success(config);
|
|
31
|
+
}
|
|
32
|
+
function kind(value) {
|
|
33
|
+
if (value === null) return "null";
|
|
34
|
+
if (Array.isArray(value)) return "array";
|
|
35
|
+
return typeof value;
|
|
36
|
+
}
|
|
37
|
+
function shapeMismatch(reference, value, path5) {
|
|
38
|
+
const expected = kind(reference);
|
|
39
|
+
if (kind(value) !== expected) return `${path5}: expected ${expected}, got ${kind(value)}`;
|
|
40
|
+
if (expected === "number" && !Number.isFinite(value)) return `${path5}: expected a finite number`;
|
|
41
|
+
if (expected === "array") {
|
|
42
|
+
const [first] = reference;
|
|
43
|
+
if (first === void 0) return null;
|
|
44
|
+
for (const [index, item] of value.entries()) {
|
|
45
|
+
const nested = shapeMismatch(first, item, `${path5}[${index}]`);
|
|
46
|
+
if (nested) return nested;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (expected === "object") {
|
|
50
|
+
const ref = reference;
|
|
51
|
+
const obj = value;
|
|
52
|
+
for (const key of Object.keys(obj)) if (!(key in ref)) return `${path5}.${key}: unknown key`;
|
|
53
|
+
for (const key of Object.keys(ref)) {
|
|
54
|
+
if (!(key in obj)) return `${path5}.${key}: missing`;
|
|
55
|
+
const nested = shapeMismatch(ref[key], obj[key], `${path5}.${key}`);
|
|
56
|
+
if (nested) return nested;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
var isRgb = (value) => typeof value === "object" && value !== null && ["r", "g", "b"].every((channel) => Number.isFinite(value[channel]));
|
|
62
|
+
function invalidControl(schema, path5 = "") {
|
|
63
|
+
for (const [key, field] of Object.entries(schema)) {
|
|
64
|
+
if (field.kind === "button") continue;
|
|
65
|
+
const name = `${path5}${field.label ?? key}`;
|
|
66
|
+
if (field.kind === "folder") {
|
|
67
|
+
const nested = invalidControl(field.definition.schema(), `${name}/`);
|
|
68
|
+
if (nested) return nested;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const value = field.read();
|
|
72
|
+
if (field.kind === "slider") {
|
|
73
|
+
if (typeof value !== "number" || value < field.min || value > field.max)
|
|
74
|
+
return `${name}: expected a number in [${field.min}, ${field.max}], got ${JSON.stringify(value)}`;
|
|
75
|
+
} else if (field.kind === "bool") {
|
|
76
|
+
if (typeof value !== "boolean") return `${name}: expected a boolean`;
|
|
77
|
+
} else if (field.kind === "select") {
|
|
78
|
+
const options = Object.values(field.options);
|
|
79
|
+
if (!options.includes(value))
|
|
80
|
+
return `${name}: expected one of ${JSON.stringify(options)}, got ${JSON.stringify(value)}`;
|
|
81
|
+
} else if (field.kind === "color-string") {
|
|
82
|
+
if (typeof value !== "string") return `${name}: expected a color string`;
|
|
83
|
+
} else if (field.kind === "color") {
|
|
84
|
+
if (!isRgb(value)) return `${name}: expected an { r, g, b } color`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// src/runtime/files.ts
|
|
91
|
+
import { createHash, randomUUID } from "crypto";
|
|
92
|
+
import { mkdir, readFile, realpath, rename, rm, stat } from "fs/promises";
|
|
93
|
+
import { writeFile } from "fs/promises";
|
|
94
|
+
import path from "path";
|
|
95
|
+
var inside = (root, target) => {
|
|
96
|
+
const relative = path.relative(root, target);
|
|
97
|
+
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
98
|
+
};
|
|
99
|
+
var exists = (target) => stat(target).then(
|
|
100
|
+
() => true,
|
|
101
|
+
() => false
|
|
102
|
+
);
|
|
103
|
+
async function resolveToolFile(root, file) {
|
|
104
|
+
const fail = (reason) => new Error(`[studio] tool file "${file}" ${reason}`);
|
|
105
|
+
if (path.isAbsolute(file)) throw fail("must be relative to the project root");
|
|
106
|
+
if (path.extname(file) !== ".json") throw fail("must be a .json file");
|
|
107
|
+
const absolute = path.resolve(root, file);
|
|
108
|
+
if (!inside(root, absolute)) throw fail("escapes the project root");
|
|
109
|
+
let probe = absolute;
|
|
110
|
+
while (!await exists(probe)) probe = path.dirname(probe);
|
|
111
|
+
if (probe !== root && !inside(await realpath(root), await realpath(probe))) throw fail("escapes the project root");
|
|
112
|
+
return absolute;
|
|
113
|
+
}
|
|
114
|
+
var hashOf = (content) => createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
115
|
+
var serialize = (value) => `${JSON.stringify(value, null, 2)}
|
|
116
|
+
`;
|
|
117
|
+
async function readDisk(file) {
|
|
118
|
+
try {
|
|
119
|
+
const content = await readFile(file, "utf8");
|
|
120
|
+
return { content, hash: hashOf(content) };
|
|
121
|
+
} catch (cause) {
|
|
122
|
+
if (cause.code === "ENOENT") return null;
|
|
123
|
+
throw cause;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function writeAtomic(file, content) {
|
|
127
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
128
|
+
const temp = `${file}.${randomUUID()}.tmp`;
|
|
129
|
+
try {
|
|
130
|
+
await writeFile(temp, content, "utf8");
|
|
131
|
+
await rename(temp, file);
|
|
132
|
+
} catch (cause) {
|
|
133
|
+
await rm(temp, { force: true });
|
|
134
|
+
throw cause;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/runtime/runtime.ts
|
|
139
|
+
var equal = (a, b) => {
|
|
140
|
+
if (a === b) return true;
|
|
141
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
142
|
+
const keys = Object.keys(a);
|
|
143
|
+
return keys.length === Object.keys(b).length && keys.every((key) => equal(a[key], b[key]));
|
|
144
|
+
};
|
|
145
|
+
async function load(tool2, file) {
|
|
146
|
+
const disk = await readDisk(file);
|
|
147
|
+
if (!disk) return { value: structuredClone(tool2.initial), hash: null };
|
|
148
|
+
const parsed = validateConfig(tool2, JSON.parse(disk.content));
|
|
149
|
+
if (!parsed.ok)
|
|
150
|
+
throw new Error(`[studio] ${path2.basename(file)} is not a valid "${tool2.id}" config: ${parsed.message}`);
|
|
151
|
+
return { value: parsed.data, hash: disk.hash };
|
|
152
|
+
}
|
|
153
|
+
async function createRuntime({ root, tools }) {
|
|
154
|
+
root = path2.resolve(root);
|
|
155
|
+
const entries = /* @__PURE__ */ new Map();
|
|
156
|
+
const files = /* @__PURE__ */ new Set();
|
|
157
|
+
for (const tool2 of tools) {
|
|
158
|
+
if (entries.has(tool2.id)) throw new Error(`[studio] duplicate tool id "${tool2.id}"`);
|
|
159
|
+
const file = await resolveToolFile(root, tool2.file);
|
|
160
|
+
if (files.has(file)) throw new Error(`[studio] tool "${tool2.id}" shares its file with another tool`);
|
|
161
|
+
files.add(file);
|
|
162
|
+
const saved = await load(tool2, file);
|
|
163
|
+
entries.set(tool2.id, {
|
|
164
|
+
tool: tool2,
|
|
165
|
+
file,
|
|
166
|
+
queue: Promise.resolve(),
|
|
167
|
+
state: {
|
|
168
|
+
tool: tool2.id,
|
|
169
|
+
draft: { value: structuredClone(saved.value), revision: 0 },
|
|
170
|
+
saved,
|
|
171
|
+
dirty: false,
|
|
172
|
+
conflict: false
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
177
|
+
const commit = (entry, next, origin) => {
|
|
178
|
+
const state = { ...entry.state, ...next };
|
|
179
|
+
state.dirty = !equal(state.draft.value, state.saved.value);
|
|
180
|
+
if (!state.dirty) state.conflict = false;
|
|
181
|
+
entry.state = state;
|
|
182
|
+
for (const listener of listeners) listener(state, origin);
|
|
183
|
+
return state;
|
|
184
|
+
};
|
|
185
|
+
const serial = (entry, task) => {
|
|
186
|
+
const run = entry.queue.then(task);
|
|
187
|
+
entry.queue = run.catch(() => {
|
|
188
|
+
});
|
|
189
|
+
return run;
|
|
190
|
+
};
|
|
191
|
+
const handlers = {
|
|
192
|
+
"tools.list": async () => success(
|
|
193
|
+
[...entries.values()].map(({ tool: tool2 }) => ({
|
|
194
|
+
id: tool2.id,
|
|
195
|
+
title: tool2.title ?? tool2.id,
|
|
196
|
+
file: tool2.file,
|
|
197
|
+
icon: tool2.icon,
|
|
198
|
+
preview: tool2.preview
|
|
199
|
+
}))
|
|
200
|
+
),
|
|
201
|
+
"tool.get": async ({ tool: tool2 }) => withEntry(tool2, async (entry) => success(entry.state)),
|
|
202
|
+
"tool.update": async ({ tool: tool2, value, revision: revision2 }, origin) => withEntry(tool2, async (entry) => {
|
|
203
|
+
if (revision2 !== void 0 && revision2 !== entry.state.draft.revision)
|
|
204
|
+
return error("stale_revision", `draft is at revision ${entry.state.draft.revision}, not ${revision2}`);
|
|
205
|
+
const parsed = validateConfig(entry.tool, value);
|
|
206
|
+
if (!parsed.ok) return parsed;
|
|
207
|
+
const draft = { value: parsed.data, revision: entry.state.draft.revision + 1 };
|
|
208
|
+
return success(commit(entry, { draft }, origin));
|
|
209
|
+
}),
|
|
210
|
+
"tool.save": async ({ tool: tool2, revision: revision2, force = false }, origin) => withEntry(
|
|
211
|
+
tool2,
|
|
212
|
+
(entry) => serial(entry, async () => {
|
|
213
|
+
const { draft, saved } = entry.state;
|
|
214
|
+
if (revision2 !== draft.revision)
|
|
215
|
+
return error("stale_revision", `draft is at revision ${draft.revision}, not ${revision2}`);
|
|
216
|
+
const disk = await readDisk(entry.file);
|
|
217
|
+
if (!force && (entry.state.conflict || (disk?.hash ?? null) !== saved.hash)) {
|
|
218
|
+
commit(entry, { conflict: true });
|
|
219
|
+
return error("conflict", `${entry.tool.file} changed on disk; discard to reload or save with force`);
|
|
220
|
+
}
|
|
221
|
+
const content = serialize(draft.value);
|
|
222
|
+
await writeAtomic(entry.file, content);
|
|
223
|
+
return success(
|
|
224
|
+
commit(entry, { saved: { value: structuredClone(draft.value), hash: hashOf(content) } }, origin)
|
|
225
|
+
);
|
|
226
|
+
})
|
|
227
|
+
),
|
|
228
|
+
"tool.discard": async ({ tool: tool2, revision: revision2 }, origin) => withEntry(tool2, async (entry) => {
|
|
229
|
+
const { saved, draft } = entry.state;
|
|
230
|
+
if (revision2 !== void 0 && revision2 !== draft.revision)
|
|
231
|
+
return error("stale_revision", `draft is at revision ${draft.revision}, not ${revision2}`);
|
|
232
|
+
return success(
|
|
233
|
+
commit(entry, { draft: { value: structuredClone(saved.value), revision: draft.revision + 1 } }, origin)
|
|
234
|
+
);
|
|
235
|
+
})
|
|
236
|
+
};
|
|
237
|
+
async function withEntry(id, task) {
|
|
238
|
+
const entry = typeof id === "string" ? entries.get(id) : void 0;
|
|
239
|
+
return entry ? task(entry) : error("unknown_tool", `no tool "${String(id)}"`);
|
|
240
|
+
}
|
|
241
|
+
const reload = (entry) => serial(entry, async () => {
|
|
242
|
+
const disk = await readDisk(entry.file);
|
|
243
|
+
const { saved, draft, dirty } = entry.state;
|
|
244
|
+
if ((disk?.hash ?? null) === saved.hash) return;
|
|
245
|
+
if (!disk) return commit(entry, { saved: { ...saved, hash: null } });
|
|
246
|
+
let value;
|
|
247
|
+
try {
|
|
248
|
+
value = JSON.parse(disk.content);
|
|
249
|
+
} catch {
|
|
250
|
+
return console.warn(`[studio] ignoring unparsable ${entry.tool.file}`);
|
|
251
|
+
}
|
|
252
|
+
const parsed = validateConfig(entry.tool, value);
|
|
253
|
+
if (!parsed.ok) return console.warn(`[studio] ignoring invalid ${entry.tool.file}: ${parsed.message}`);
|
|
254
|
+
const next = { saved: { value: parsed.data, hash: disk.hash } };
|
|
255
|
+
if (dirty) next.conflict = true;
|
|
256
|
+
else next.draft = { value: structuredClone(parsed.data), revision: draft.revision + 1 };
|
|
257
|
+
commit(entry, next);
|
|
258
|
+
});
|
|
259
|
+
const timers = /* @__PURE__ */ new Map();
|
|
260
|
+
const watchers = [];
|
|
261
|
+
const byDirectory = Map.groupBy(entries.values(), (entry) => path2.dirname(entry.file));
|
|
262
|
+
for (const [directory, group] of byDirectory) {
|
|
263
|
+
try {
|
|
264
|
+
const watcher = watch(directory, (_, filename) => {
|
|
265
|
+
for (const entry of group) {
|
|
266
|
+
if (filename && filename !== path2.basename(entry.file)) continue;
|
|
267
|
+
clearTimeout(timers.get(entry));
|
|
268
|
+
timers.set(
|
|
269
|
+
entry,
|
|
270
|
+
setTimeout(() => void reload(entry).catch((cause) => console.error("[studio] reload failed", cause)), 50)
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
watchers.push(watcher);
|
|
275
|
+
} catch {
|
|
276
|
+
console.warn(`[studio] not watching ${path2.relative(root, directory)} (it does not exist yet)`);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
root,
|
|
281
|
+
run: (op, params, origin) => handlers[op](params, origin),
|
|
282
|
+
subscribe(listener) {
|
|
283
|
+
listeners.add(listener);
|
|
284
|
+
return () => listeners.delete(listener);
|
|
285
|
+
},
|
|
286
|
+
close() {
|
|
287
|
+
for (const watcher of watchers) watcher.close();
|
|
288
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
289
|
+
listeners.clear();
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/runtime/server.ts
|
|
295
|
+
import { createServer } from "http";
|
|
296
|
+
import { localhostHostValidation, toNodeHandler } from "@modelcontextprotocol/node";
|
|
297
|
+
import { createMcpHandler } from "@modelcontextprotocol/server";
|
|
298
|
+
import { WebSocketServer } from "ws";
|
|
299
|
+
|
|
300
|
+
// src/runtime/capture.ts
|
|
301
|
+
function previewSnapshot({ port, tool: tool2, revision: revision2 }) {
|
|
302
|
+
const instances = globalThis.__joycoStudio;
|
|
303
|
+
for (const [address, studio] of instances ?? []) {
|
|
304
|
+
const url = new URL(address);
|
|
305
|
+
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) || Number(url.port) !== port) continue;
|
|
306
|
+
const handle = studio.tools.find((handle2) => handle2.definition.id === tool2);
|
|
307
|
+
if (studio.connection.phase !== "connected" || !handle?.state || handle.comparing) continue;
|
|
308
|
+
if (handle.state.draft.revision < revision2) continue;
|
|
309
|
+
return { revision: handle.state.draft.revision, version: handle.version };
|
|
310
|
+
}
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
function createPreviewCapture(runtime, { appUrl, runtimePort, timeoutMs = 2e4 }) {
|
|
314
|
+
let browser;
|
|
315
|
+
let active = false;
|
|
316
|
+
let closed = false;
|
|
317
|
+
const capture = async ({
|
|
318
|
+
tool: tool2,
|
|
319
|
+
revision: revision2,
|
|
320
|
+
width = 1200,
|
|
321
|
+
height = 800
|
|
322
|
+
}) => {
|
|
323
|
+
if (closed) return error("capture_unavailable", "Studio is shutting down");
|
|
324
|
+
if (active) return error("capture_busy", "Another preview is being captured; retry when it finishes");
|
|
325
|
+
const state = await runtime.run("tool.get", { tool: tool2 });
|
|
326
|
+
if (!state.ok) return state;
|
|
327
|
+
revision2 ??= state.data.draft.revision;
|
|
328
|
+
if (revision2 !== state.data.draft.revision) return error("stale_revision", "Read the latest draft before capturing");
|
|
329
|
+
const tools = await runtime.run("tools.list", {});
|
|
330
|
+
if (!tools.ok) return tools;
|
|
331
|
+
const preview = tools.data.find((entry) => entry.id === tool2)?.preview;
|
|
332
|
+
if (!preview) return error("preview_missing", `Tool "${tool2}" has no preview route`);
|
|
333
|
+
if (!appUrl) return error("app_url_missing", "Set appUrl in studio.config.ts or pass studio dev --app-url <url>");
|
|
334
|
+
let url;
|
|
335
|
+
try {
|
|
336
|
+
const base = new URL(appUrl);
|
|
337
|
+
url = new URL(preview, base);
|
|
338
|
+
if (!["http:", "https:"].includes(base.protocol) || url.origin !== base.origin || url.username || url.password)
|
|
339
|
+
return error("invalid_preview_url", "The preview must be an HTTP(S) route on the configured app origin");
|
|
340
|
+
} catch {
|
|
341
|
+
return error("invalid_preview_url", "appUrl must be an absolute HTTP(S) URL and preview must be a valid route");
|
|
342
|
+
}
|
|
343
|
+
if (active) return error("capture_busy", "Another preview is being captured; retry when it finishes");
|
|
344
|
+
if (closed) return error("capture_unavailable", "Studio is shutting down");
|
|
345
|
+
active = true;
|
|
346
|
+
let context;
|
|
347
|
+
let timer;
|
|
348
|
+
let timedOut = false;
|
|
349
|
+
let phase = "starting the browser";
|
|
350
|
+
try {
|
|
351
|
+
browser ??= import("playwright").then(({ chromium }) => chromium.launch({ headless: true, timeout: 1e4 }));
|
|
352
|
+
let instance;
|
|
353
|
+
try {
|
|
354
|
+
instance = await browser;
|
|
355
|
+
} catch (cause) {
|
|
356
|
+
browser = void 0;
|
|
357
|
+
return error("capture_unavailable", `Run "studio install-browser" to install Chromium. ${String(cause)}`);
|
|
358
|
+
}
|
|
359
|
+
if (closed) return error("capture_unavailable", "Studio is shutting down");
|
|
360
|
+
if (!instance.isConnected()) {
|
|
361
|
+
browser = void 0;
|
|
362
|
+
return error("capture_unavailable", "The capture browser disconnected; retry to start it again");
|
|
363
|
+
}
|
|
364
|
+
context = await instance.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
|
|
365
|
+
timer = setTimeout(() => {
|
|
366
|
+
timedOut = true;
|
|
367
|
+
void context?.close().catch(() => {
|
|
368
|
+
});
|
|
369
|
+
}, timeoutMs);
|
|
370
|
+
const page = await context.newPage();
|
|
371
|
+
page.setDefaultTimeout(timeoutMs);
|
|
372
|
+
await page.route("**/*", (route) => {
|
|
373
|
+
const request = route.request();
|
|
374
|
+
return request.isNavigationRequest() && request.frame() === page.mainFrame() && new URL(request.url()).origin !== url.origin ? route.abort() : route.continue();
|
|
375
|
+
});
|
|
376
|
+
phase = "opening the preview";
|
|
377
|
+
const response = await page.goto(url.href, { waitUntil: "load" });
|
|
378
|
+
if (!response?.ok()) return error("preview_failed", `Preview returned HTTP ${response?.status() ?? "unknown"}`);
|
|
379
|
+
phase = "waiting for the preview to receive the draft";
|
|
380
|
+
const target = { port: runtimePort(), tool: tool2, revision: revision2 };
|
|
381
|
+
const synced = await page.waitForFunction(previewSnapshot, target);
|
|
382
|
+
const initial = await synced.jsonValue();
|
|
383
|
+
await synced.dispose();
|
|
384
|
+
if (initial?.revision !== revision2) return error("stale_revision", "The draft changed while opening the preview");
|
|
385
|
+
phase = "waiting for fonts, images, and animation frames";
|
|
386
|
+
await page.evaluate(async () => {
|
|
387
|
+
await document.fonts.ready;
|
|
388
|
+
await Promise.all(Array.from(document.images, (image) => image.decode().catch(() => {
|
|
389
|
+
})));
|
|
390
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
391
|
+
});
|
|
392
|
+
const before = await page.evaluate(previewSnapshot, target);
|
|
393
|
+
if (before?.revision !== revision2) return error("stale_revision", "The draft changed while settling the preview");
|
|
394
|
+
phase = "taking the screenshot";
|
|
395
|
+
const png = await page.screenshot({ type: "png", animations: "disabled", timeout: timeoutMs });
|
|
396
|
+
const after = await page.evaluate(previewSnapshot, target);
|
|
397
|
+
const latest = await runtime.run("tool.get", { tool: tool2 });
|
|
398
|
+
if (!latest.ok) return latest;
|
|
399
|
+
if (latest.data.draft.revision !== revision2 || after?.revision !== revision2 || after.version !== before.version)
|
|
400
|
+
return error("stale_revision", "The preview changed during capture; read the latest draft and retry");
|
|
401
|
+
return success({ tool: tool2, revision: revision2, url: page.url(), width, height, settling: "automatic", png });
|
|
402
|
+
} catch (cause) {
|
|
403
|
+
if (closed) return error("capture_unavailable", "Studio is shutting down");
|
|
404
|
+
return error(
|
|
405
|
+
timedOut || cause instanceof Error && cause.name === "TimeoutError" ? "preview_not_ready" : "capture_failed",
|
|
406
|
+
`${phase}: ${String(cause)}`
|
|
407
|
+
);
|
|
408
|
+
} finally {
|
|
409
|
+
clearTimeout(timer);
|
|
410
|
+
await context?.close().catch(() => {
|
|
411
|
+
});
|
|
412
|
+
active = false;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
return {
|
|
416
|
+
capture,
|
|
417
|
+
async close() {
|
|
418
|
+
closed = true;
|
|
419
|
+
await browser?.then((instance) => instance.close()).catch(() => {
|
|
420
|
+
});
|
|
421
|
+
browser = void 0;
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// src/runtime/mcp.ts
|
|
427
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
428
|
+
import { z } from "zod/v4";
|
|
429
|
+
var tool = z.string().min(1).describe("Registered Studio tool ID from studio_list_tools.");
|
|
430
|
+
var revision = z.number().int().nonnegative().describe("Draft revision returned by the latest read or update.");
|
|
431
|
+
function createMcpServer(runtime, previews) {
|
|
432
|
+
const server = new McpServer(
|
|
433
|
+
{ name: "joyco-studio", version: "0.0.0" },
|
|
434
|
+
{
|
|
435
|
+
instructions: "List Studio tools, read the selected tool, then update its complete draft value. Updates appear live in the playground. Saving is a separate operation. If a revision is stale, read again and reconcile your changes before retrying."
|
|
436
|
+
}
|
|
437
|
+
);
|
|
438
|
+
const run = async (op, params) => {
|
|
439
|
+
const result = await runtime.run(op, params);
|
|
440
|
+
return {
|
|
441
|
+
content: [{ type: "text", text: JSON.stringify(result) }],
|
|
442
|
+
structuredContent: result,
|
|
443
|
+
isError: !result.ok
|
|
444
|
+
};
|
|
445
|
+
};
|
|
446
|
+
server.registerTool(
|
|
447
|
+
"studio_list_tools",
|
|
448
|
+
{
|
|
449
|
+
description: "List registered Studio tools with their IDs, titles, config files, and preview routes.",
|
|
450
|
+
inputSchema: z.object({}),
|
|
451
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
|
|
452
|
+
},
|
|
453
|
+
() => run("tools.list", {})
|
|
454
|
+
);
|
|
455
|
+
server.registerTool(
|
|
456
|
+
"studio_get_tool",
|
|
457
|
+
{
|
|
458
|
+
description: "Read a tool\u2019s complete draft, revision, saved value, and dirty/conflict status before editing.",
|
|
459
|
+
inputSchema: z.object({ tool }),
|
|
460
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }
|
|
461
|
+
},
|
|
462
|
+
(params) => run("tool.get", params)
|
|
463
|
+
);
|
|
464
|
+
server.registerTool(
|
|
465
|
+
"studio_update_tool",
|
|
466
|
+
{
|
|
467
|
+
description: "Replace the complete draft config and update all connected previews. Read first and preserve fields you are not changing. The runtime validates types, slider bounds, and select options. Does not save to disk; rejects a stale revision.",
|
|
468
|
+
inputSchema: z.object({ tool, revision, value: z.record(z.string(), z.unknown()) }),
|
|
469
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
470
|
+
},
|
|
471
|
+
(params) => run("tool.update", params)
|
|
472
|
+
);
|
|
473
|
+
server.registerTool(
|
|
474
|
+
"studio_save_tool",
|
|
475
|
+
{
|
|
476
|
+
description: "Persist a draft to its registered JSON file. Pass the latest draft revision. Rejects stale revisions and changes made on disk; force explicitly overwrites a disk conflict.",
|
|
477
|
+
inputSchema: z.object({ tool, revision, force: z.boolean().optional() }),
|
|
478
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }
|
|
479
|
+
},
|
|
480
|
+
(params) => run("tool.save", params)
|
|
481
|
+
);
|
|
482
|
+
server.registerTool(
|
|
483
|
+
"studio_discard_tool",
|
|
484
|
+
{
|
|
485
|
+
description: "Discard unsaved edits and restore the runtime\u2019s latest saved value in every preview. Read first; rejects a stale revision.",
|
|
486
|
+
inputSchema: z.object({ tool, revision }),
|
|
487
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }
|
|
488
|
+
},
|
|
489
|
+
(params) => run("tool.discard", params)
|
|
490
|
+
);
|
|
491
|
+
server.registerTool(
|
|
492
|
+
"studio_capture_preview",
|
|
493
|
+
{
|
|
494
|
+
description: "Capture a registered preview as a PNG in a fresh browser page using the live draft. Requires appUrl and Chromium (studio install-browser). Defaults to the current revision and 1200\xD7800. Waits for draft sync, fonts, images, and two animation frames; automatic settling does not guarantee arbitrary async scenes are finished. Rejects a draft change during capture. Does not share your open tab\u2019s scroll, login, or interaction state.",
|
|
495
|
+
inputSchema: z.object({
|
|
496
|
+
tool,
|
|
497
|
+
revision: revision.optional(),
|
|
498
|
+
width: z.number().int().min(320).max(2560).optional(),
|
|
499
|
+
height: z.number().int().min(240).max(1600).optional()
|
|
500
|
+
}),
|
|
501
|
+
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true }
|
|
502
|
+
},
|
|
503
|
+
async (params) => {
|
|
504
|
+
const captured = await previews.capture(params);
|
|
505
|
+
if (!captured.ok)
|
|
506
|
+
return {
|
|
507
|
+
content: [{ type: "text", text: JSON.stringify(captured) }],
|
|
508
|
+
structuredContent: captured,
|
|
509
|
+
isError: true
|
|
510
|
+
};
|
|
511
|
+
const { png, ...metadata } = captured.data;
|
|
512
|
+
const result = success(metadata);
|
|
513
|
+
return {
|
|
514
|
+
content: [
|
|
515
|
+
{ type: "text", text: JSON.stringify(result) },
|
|
516
|
+
{ type: "image", mimeType: "image/png", data: png.toString("base64") }
|
|
517
|
+
],
|
|
518
|
+
structuredContent: result,
|
|
519
|
+
isError: false
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
);
|
|
523
|
+
return server;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// src/runtime/server.ts
|
|
527
|
+
var LOCAL_ORIGIN = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/;
|
|
528
|
+
var OPS = /* @__PURE__ */ new Set(["tools.list", "tool.get", "tool.update", "tool.save", "tool.discard"]);
|
|
529
|
+
function parse(raw) {
|
|
530
|
+
try {
|
|
531
|
+
const message = JSON.parse(raw);
|
|
532
|
+
if (typeof message?.id !== "number" || !OPS.has(message.op)) return null;
|
|
533
|
+
return { id: message.id, op: message.op, params: message.params ?? {} };
|
|
534
|
+
} catch {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
function serveBridge(runtime, { port, host = "127.0.0.1", origins, appUrl }) {
|
|
539
|
+
const allowed = (origin) => origin === void 0 || (origins ? origins.includes(origin) : LOCAL_ORIGIN.test(origin));
|
|
540
|
+
const onerror = (cause) => console.error("[studio] MCP request failed", cause);
|
|
541
|
+
const previews = createPreviewCapture(runtime, { appUrl, runtimePort: () => http.address().port });
|
|
542
|
+
const mcp = createMcpHandler(() => createMcpServer(runtime, previews), { onerror });
|
|
543
|
+
const handleMcp = toNodeHandler(mcp, { onerror });
|
|
544
|
+
const validateHost = localhostHostValidation();
|
|
545
|
+
const http = createServer((request, response) => {
|
|
546
|
+
if (request.url?.split("?")[0] !== "/mcp") {
|
|
547
|
+
response.writeHead(404).end();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (!validateHost(request, response)) return;
|
|
551
|
+
if (!allowed(request.headers.origin)) {
|
|
552
|
+
response.writeHead(403).end("Origin not allowed");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
void handleMcp(request, response);
|
|
556
|
+
});
|
|
557
|
+
const server = new WebSocketServer({
|
|
558
|
+
server: http,
|
|
559
|
+
verifyClient: ({ origin }) => allowed(origin)
|
|
560
|
+
});
|
|
561
|
+
const send = (socket, message) => {
|
|
562
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(message));
|
|
563
|
+
};
|
|
564
|
+
server.on("connection", (socket) => {
|
|
565
|
+
socket.on("message", async (raw) => {
|
|
566
|
+
const request = parse(raw.toString());
|
|
567
|
+
if (!request) return send(socket, { type: "response", id: -1, result: error("bad_request") });
|
|
568
|
+
const result = await runtime.run(request.op, request.params, socket).catch((cause) => error("internal", cause instanceof Error ? cause.message : String(cause)));
|
|
569
|
+
send(socket, { type: "response", id: request.id, result });
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
const unsubscribe = runtime.subscribe((state, origin) => {
|
|
573
|
+
for (const client of server.clients) if (client !== origin) send(client, { type: "state", state });
|
|
574
|
+
});
|
|
575
|
+
return new Promise((resolve, reject) => {
|
|
576
|
+
server.once("error", (cause) => {
|
|
577
|
+
unsubscribe();
|
|
578
|
+
server.close();
|
|
579
|
+
void mcp.close();
|
|
580
|
+
void previews.close();
|
|
581
|
+
reject(cause);
|
|
582
|
+
});
|
|
583
|
+
http.once(
|
|
584
|
+
"listening",
|
|
585
|
+
() => resolve({
|
|
586
|
+
port: http.address().port,
|
|
587
|
+
close: async () => {
|
|
588
|
+
unsubscribe();
|
|
589
|
+
await previews.close();
|
|
590
|
+
for (const client of server.clients) client.terminate();
|
|
591
|
+
await mcp.close();
|
|
592
|
+
await new Promise((done) => server.close(() => done()));
|
|
593
|
+
await new Promise((done) => {
|
|
594
|
+
http.close(() => done());
|
|
595
|
+
http.closeAllConnections();
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
})
|
|
599
|
+
);
|
|
600
|
+
http.listen(port, host);
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// src/runtime/load.ts
|
|
605
|
+
import { existsSync } from "fs";
|
|
606
|
+
import path3 from "path";
|
|
607
|
+
import { createJiti } from "jiti";
|
|
608
|
+
var CANDIDATES = ["studio.config.ts", "studio.config.mts", "studio.config.js", "studio.config.mjs"];
|
|
609
|
+
async function loadConfig(cwd, explicit) {
|
|
610
|
+
const file = explicit ? path3.resolve(cwd, explicit) : CANDIDATES.map((name) => path3.join(cwd, name)).find((candidate) => existsSync(candidate));
|
|
611
|
+
if (!file || !existsSync(file)) throw new Error(`[studio] no ${explicit ?? "studio.config.ts"} found in ${cwd}`);
|
|
612
|
+
const jiti = createJiti(import.meta.url, { moduleCache: false });
|
|
613
|
+
const config = await jiti.import(file, { default: true });
|
|
614
|
+
if (!Array.isArray(config?.tools))
|
|
615
|
+
throw new Error(`[studio] ${path3.basename(file)} must default-export defineConfig({ tools })`);
|
|
616
|
+
return { config, root: path3.dirname(file) };
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/cli.ts
|
|
620
|
+
var HELP = `Usage: studio dev [--config studio.config.ts] [--port ${DEFAULT_PORT}] [--app-url http://localhost:3000] [-- <command...>]
|
|
621
|
+
studio install-browser
|
|
622
|
+
|
|
623
|
+
Starts the Studio runtime with a WebSocket bridge and an HTTP MCP endpoint at /mcp.
|
|
624
|
+
Anything after "--" runs alongside it (e.g. "next dev")
|
|
625
|
+
and the runtime stops when that command exits.
|
|
626
|
+
Set appUrl in the config or pass --app-url to enable MCP preview capture.
|
|
627
|
+
Run "studio install-browser" once to install Chromium for captures.`;
|
|
628
|
+
async function main(argv) {
|
|
629
|
+
if (argv[0] === "install-browser") {
|
|
630
|
+
const require2 = createRequire(import.meta.url);
|
|
631
|
+
const cli = path4.join(
|
|
632
|
+
path4.dirname(require2.resolve("playwright/package.json")),
|
|
633
|
+
require2("playwright/package.json").bin.playwright
|
|
634
|
+
);
|
|
635
|
+
const child2 = spawn(process.execPath, [cli, "install", "chromium"], { stdio: "inherit" });
|
|
636
|
+
child2.once("error", (cause) => {
|
|
637
|
+
console.error(cause.message);
|
|
638
|
+
process.exit(1);
|
|
639
|
+
});
|
|
640
|
+
child2.once("exit", (code) => process.exit(code ?? 1));
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const split = argv.indexOf("--");
|
|
644
|
+
const own = split === -1 ? argv : argv.slice(0, split);
|
|
645
|
+
const command = split === -1 ? [] : argv.slice(split + 1);
|
|
646
|
+
const { positionals, values } = parseArgs({
|
|
647
|
+
args: own,
|
|
648
|
+
allowPositionals: true,
|
|
649
|
+
options: {
|
|
650
|
+
config: { type: "string" },
|
|
651
|
+
port: { type: "string" },
|
|
652
|
+
"app-url": { type: "string" },
|
|
653
|
+
help: { type: "boolean", short: "h" }
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
if (values.help || positionals[0] !== "dev") return console.log(HELP);
|
|
657
|
+
const { config, root } = await loadConfig(process.cwd(), values.config);
|
|
658
|
+
const runtime = await createRuntime({ root, tools: config.tools });
|
|
659
|
+
const bridge = await serveBridge(runtime, {
|
|
660
|
+
port: Number(values.port ?? config.port ?? DEFAULT_PORT),
|
|
661
|
+
origins: config.origins,
|
|
662
|
+
appUrl: values["app-url"] ?? config.appUrl
|
|
663
|
+
});
|
|
664
|
+
console.log(
|
|
665
|
+
`[studio] runtime ready on ws://127.0.0.1:${bridge.port} \u2014 ${config.tools.length} tool(s) in ${path4.relative(process.cwd(), root) || "."}`
|
|
666
|
+
);
|
|
667
|
+
console.log(`[studio] MCP ready on http://127.0.0.1:${bridge.port}/mcp`);
|
|
668
|
+
const shutdown = async (code = 0) => {
|
|
669
|
+
runtime.close();
|
|
670
|
+
await bridge.close();
|
|
671
|
+
process.exit(code);
|
|
672
|
+
};
|
|
673
|
+
const [bin, ...args] = command;
|
|
674
|
+
if (!bin) {
|
|
675
|
+
process.once("SIGINT", () => void shutdown());
|
|
676
|
+
process.once("SIGTERM", () => void shutdown());
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
const child = spawn(bin, args, { stdio: "inherit", env: { ...process.env, STUDIO_PORT: String(bridge.port) } });
|
|
680
|
+
child.once("exit", (code) => void shutdown(code ?? 0));
|
|
681
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => child.kill(signal));
|
|
682
|
+
}
|
|
683
|
+
main(process.argv.slice(2)).catch((cause) => {
|
|
684
|
+
console.error(cause instanceof Error ? cause.message : cause);
|
|
685
|
+
process.exit(1);
|
|
686
|
+
});
|
|
687
|
+
//# sourceMappingURL=cli.js.map
|