@cardanvil/frame-kit 0.4.0 → 0.6.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 +45 -0
- package/dist/cli/main.js +16 -0
- package/dist/cli/sourceWatcher.d.ts +40 -0
- package/dist/cli/sourceWatcher.js +100 -0
- package/dist/cli/studio/boxSets.d.ts +33 -0
- package/dist/cli/studio/boxSets.js +87 -0
- package/dist/cli/studio/edit/applyEdits.d.ts +71 -0
- package/dist/cli/studio/edit/applyEdits.js +177 -0
- package/dist/cli/studio/edit/impact.d.ts +53 -0
- package/dist/cli/studio/edit/impact.js +87 -0
- package/dist/cli/studio/edit/resolveModule.d.ts +10 -0
- package/dist/cli/studio/edit/resolveModule.js +55 -0
- package/dist/cli/studio/edit/resolvePath.d.ts +53 -0
- package/dist/cli/studio/edit/resolvePath.js +606 -0
- package/dist/cli/studio/edit/sourceIndex.d.ts +29 -0
- package/dist/cli/studio/edit/sourceIndex.js +73 -0
- package/dist/cli/studio/edit/ts.d.ts +14 -0
- package/dist/cli/studio/edit/ts.js +34 -0
- package/dist/cli/studio/serve.d.ts +23 -0
- package/dist/cli/studio/serve.js +513 -0
- package/dist/cli/studio/session.d.ts +73 -0
- package/dist/cli/studio/session.js +154 -0
- package/dist/cli/watch.d.ts +3 -0
- package/dist/cli/watch.js +20 -89
- package/dist/layout/assetPlacement.d.ts +71 -0
- package/dist/layout/assetPlacement.js +104 -0
- package/dist/layout/decorations.d.ts +78 -0
- package/dist/layout/decorations.js +108 -0
- package/dist/layout/frameLayers.d.ts +136 -0
- package/dist/layout/frameLayers.js +291 -0
- package/dist/layout/index.d.ts +16 -0
- package/dist/layout/index.js +16 -0
- package/dist/layout/resolveFrameAsset.d.ts +31 -0
- package/dist/layout/resolveFrameAsset.js +58 -0
- package/dist/manifest/contract.d.ts +671 -1
- package/dist/manifest/contract.js +7 -1
- package/dist/schema/frame.d.ts +1692 -0
- package/dist/schema/frame.js +27 -0
- package/dist/schema/frameAssets.d.ts +3 -1
- package/dist/schema/frameAssets.js +23 -1
- package/dist/studio/assets/PlantinMTProRgIt-D0FB51kb.woff2 +0 -0
- package/dist/studio/assets/beleren-b-B3lFvodX.ttf +0 -0
- package/dist/studio/assets/beleren-bsc-yvL6mU7X.ttf +0 -0
- package/dist/studio/assets/index-CNzyV22G.js +76 -0
- package/dist/studio/index.html +12 -0
- package/package.json +12 -3
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Path in the one spelling everything else compares against.
|
|
5
|
+
*
|
|
6
|
+
* Windows hands the same file back with either separator and either
|
|
7
|
+
* drive-letter case depending on who produced the path — Vite's module graph,
|
|
8
|
+
* `path.resolve`, or an author's `frame.meta.json`. Every map key, root
|
|
9
|
+
* confinement check and self-write lookup goes through here.
|
|
10
|
+
*/
|
|
11
|
+
export function normalizeFile(file) {
|
|
12
|
+
const resolved = path.resolve(file).split(path.sep).join("/");
|
|
13
|
+
return /^[a-z]:\//i.test(resolved)
|
|
14
|
+
? resolved.slice(0, 1).toUpperCase() + resolved.slice(1)
|
|
15
|
+
: resolved;
|
|
16
|
+
}
|
|
17
|
+
/** Whether `file` is inside `root` — not merely prefixed by its name. */
|
|
18
|
+
export function isUnderRoot(file, root) {
|
|
19
|
+
const normalizedRoot = normalizeFile(root);
|
|
20
|
+
const normalized = normalizeFile(file);
|
|
21
|
+
return (normalized === normalizedRoot ||
|
|
22
|
+
normalized.startsWith(normalizedRoot.endsWith("/") ? normalizedRoot : `${normalizedRoot}/`));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Parses source files on demand and keeps them until they change on disk.
|
|
26
|
+
*
|
|
27
|
+
* Keyed on `(mtimeMs, size)` rather than a manual flush, so the studio's own
|
|
28
|
+
* writes invalidate themselves and an edit made in the author's editor is
|
|
29
|
+
* picked up without coordination.
|
|
30
|
+
*/
|
|
31
|
+
export function createSourceIndex(api) {
|
|
32
|
+
const cache = new Map();
|
|
33
|
+
function load(file) {
|
|
34
|
+
const key = normalizeFile(file);
|
|
35
|
+
let stat;
|
|
36
|
+
try {
|
|
37
|
+
stat = statSync(key);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
cache.delete(key);
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const hit = cache.get(key);
|
|
44
|
+
if (hit?.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
|
|
45
|
+
return hit;
|
|
46
|
+
}
|
|
47
|
+
let text;
|
|
48
|
+
try {
|
|
49
|
+
text = readFileSync(key, "utf8");
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
cache.delete(key);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const entry = {
|
|
56
|
+
mtimeMs: stat.mtimeMs,
|
|
57
|
+
size: stat.size,
|
|
58
|
+
text,
|
|
59
|
+
sourceFile: api.createSourceFile(key, text, api.ScriptTarget.Latest,
|
|
60
|
+
// The resolver walks upward from a node to find its declaration.
|
|
61
|
+
/* setParentNodes */ true, key.endsWith(".tsx") ? api.ScriptKind.TSX : api.ScriptKind.TS),
|
|
62
|
+
};
|
|
63
|
+
cache.set(key, entry);
|
|
64
|
+
return entry;
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
get: (file) => load(file)?.sourceFile,
|
|
68
|
+
text: (file) => load(file)?.text,
|
|
69
|
+
invalidate: (file) => {
|
|
70
|
+
cache.delete(normalizeFile(file));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type TypeScriptApi = typeof import("typescript");
|
|
2
|
+
/**
|
|
3
|
+
* The TypeScript parser, loaded on demand.
|
|
4
|
+
*
|
|
5
|
+
* An *optional* peer, imported dynamically for the same reason Vite is (see
|
|
6
|
+
* `loadFrame.ts`): frame-kit's schemas and manifest helpers must keep working
|
|
7
|
+
* under plain Node and in a browser, and nothing on those paths reaches here.
|
|
8
|
+
*
|
|
9
|
+
* The studio needs it because it edits a value by locating the exact text of
|
|
10
|
+
* the literal that produced it, which means parsing the author's source. Only
|
|
11
|
+
* the parser is used — no `Program`, no `CompilerHost`, no type checker — so
|
|
12
|
+
* this stays on the oldest and most stable corner of the API.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadTypeScript(): Promise<TypeScriptApi>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PackagingError } from "../../errors.js";
|
|
2
|
+
let cached;
|
|
3
|
+
/**
|
|
4
|
+
* The TypeScript parser, loaded on demand.
|
|
5
|
+
*
|
|
6
|
+
* An *optional* peer, imported dynamically for the same reason Vite is (see
|
|
7
|
+
* `loadFrame.ts`): frame-kit's schemas and manifest helpers must keep working
|
|
8
|
+
* under plain Node and in a browser, and nothing on those paths reaches here.
|
|
9
|
+
*
|
|
10
|
+
* The studio needs it because it edits a value by locating the exact text of
|
|
11
|
+
* the literal that produced it, which means parsing the author's source. Only
|
|
12
|
+
* the parser is used — no `Program`, no `CompilerHost`, no type checker — so
|
|
13
|
+
* this stays on the oldest and most stable corner of the API.
|
|
14
|
+
*/
|
|
15
|
+
export async function loadTypeScript() {
|
|
16
|
+
if (cached) {
|
|
17
|
+
return cached;
|
|
18
|
+
}
|
|
19
|
+
let mod;
|
|
20
|
+
try {
|
|
21
|
+
mod = await import("typescript");
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new PackagingError("TypeScript is not installed.\n\n" +
|
|
25
|
+
"The studio edits your source by finding the exact text of a value,\n" +
|
|
26
|
+
"which needs the TypeScript parser. Install it:\n\n" +
|
|
27
|
+
" pnpm add -D typescript\n");
|
|
28
|
+
}
|
|
29
|
+
// `typescript` is CommonJS: under Node's ESM interop the module object is
|
|
30
|
+
// the default export, though named exports are usually detected too.
|
|
31
|
+
const api = mod.default ?? mod;
|
|
32
|
+
cached = api;
|
|
33
|
+
return api;
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface StudioOptions {
|
|
2
|
+
readonly root: string;
|
|
3
|
+
readonly only?: readonly string[];
|
|
4
|
+
readonly port?: number;
|
|
5
|
+
readonly host?: string;
|
|
6
|
+
readonly writeEnabled?: boolean;
|
|
7
|
+
/** Directory holding the built SPA. Defaults to `dist/studio`. */
|
|
8
|
+
readonly uiDir?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Serve the studio UI from source with hot reloading instead of from
|
|
11
|
+
* `dist/studio`. Only possible inside the frames workspace, where that
|
|
12
|
+
* source exists — a published frame-kit ships the built SPA alone.
|
|
13
|
+
*/
|
|
14
|
+
readonly dev?: boolean;
|
|
15
|
+
/** Where the studio UI's source lives. Defaults to the workspace sibling. */
|
|
16
|
+
readonly uiSrcDir?: string;
|
|
17
|
+
readonly open?: boolean;
|
|
18
|
+
readonly onReady?: (url: string) => void;
|
|
19
|
+
}
|
|
20
|
+
/** Serves the studio until the process is interrupted. */
|
|
21
|
+
export declare function serveStudio(options: StudioOptions): Promise<void>;
|
|
22
|
+
/** `frame-kit studio` — parses its own flags, like `schema` does. */
|
|
23
|
+
export declare function runStudio(argv: string[]): Promise<number>;
|
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { createServer, } from "node:http";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { devUrlToFile } from "../devUrl.js";
|
|
8
|
+
import { relativePosix } from "../discover.js";
|
|
9
|
+
import { PackagingError } from "../errors.js";
|
|
10
|
+
import { startSourceWatcher } from "../sourceWatcher.js";
|
|
11
|
+
import { applyEdits } from "./edit/applyEdits.js";
|
|
12
|
+
import { createImpactCache } from "./edit/impact.js";
|
|
13
|
+
import { resolveFramePath } from "./edit/resolvePath.js";
|
|
14
|
+
import { isUnderRoot, normalizeFile } from "./edit/sourceIndex.js";
|
|
15
|
+
import { startSession } from "./session.js";
|
|
16
|
+
const DEFAULT_PORT = 4620;
|
|
17
|
+
const DEFAULT_HOST = "127.0.0.1";
|
|
18
|
+
/** How long a file we just wrote is still attributed to us. */
|
|
19
|
+
const SELF_WRITE_TTL_MS = 2000;
|
|
20
|
+
const HTML_TYPE = "text/html; charset=utf-8";
|
|
21
|
+
const MIME = {
|
|
22
|
+
".html": HTML_TYPE,
|
|
23
|
+
".js": "text/javascript; charset=utf-8",
|
|
24
|
+
".css": "text/css; charset=utf-8",
|
|
25
|
+
".json": "application/json",
|
|
26
|
+
".map": "application/json",
|
|
27
|
+
".png": "image/png",
|
|
28
|
+
".jpg": "image/jpeg",
|
|
29
|
+
".jpeg": "image/jpeg",
|
|
30
|
+
".webp": "image/webp",
|
|
31
|
+
".avif": "image/avif",
|
|
32
|
+
".gif": "image/gif",
|
|
33
|
+
".svg": "image/svg+xml",
|
|
34
|
+
".ttf": "font/ttf",
|
|
35
|
+
".otf": "font/otf",
|
|
36
|
+
".woff": "font/woff",
|
|
37
|
+
".woff2": "font/woff2",
|
|
38
|
+
".ico": "image/x-icon",
|
|
39
|
+
};
|
|
40
|
+
const mimeFor = (file) => MIME[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
|
41
|
+
function sendJson(res, status, body) {
|
|
42
|
+
res.writeHead(status, {
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
"cache-control": "no-store",
|
|
45
|
+
});
|
|
46
|
+
res.end(JSON.stringify(body));
|
|
47
|
+
}
|
|
48
|
+
async function readBody(req) {
|
|
49
|
+
const chunks = [];
|
|
50
|
+
for await (const chunk of req) {
|
|
51
|
+
chunks.push(chunk);
|
|
52
|
+
}
|
|
53
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
54
|
+
return text ? JSON.parse(text) : {};
|
|
55
|
+
}
|
|
56
|
+
/** Live-update channel. One-way, self-reconnecting, no dependency. */
|
|
57
|
+
class EventStream {
|
|
58
|
+
clients = new Set();
|
|
59
|
+
add(res) {
|
|
60
|
+
res.writeHead(200, {
|
|
61
|
+
"content-type": "text/event-stream",
|
|
62
|
+
"cache-control": "no-store",
|
|
63
|
+
connection: "keep-alive",
|
|
64
|
+
});
|
|
65
|
+
res.write("retry: 1000\n\n");
|
|
66
|
+
this.clients.add(res);
|
|
67
|
+
res.on("close", () => this.clients.delete(res));
|
|
68
|
+
}
|
|
69
|
+
send(event) {
|
|
70
|
+
const frame = `data: ${JSON.stringify(event)}\n\n`;
|
|
71
|
+
for (const client of this.clients) {
|
|
72
|
+
client.write(frame);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
ping() {
|
|
76
|
+
for (const client of this.clients) {
|
|
77
|
+
client.write(": ping\n\n");
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
close() {
|
|
81
|
+
for (const client of this.clients) {
|
|
82
|
+
client.end();
|
|
83
|
+
}
|
|
84
|
+
this.clients.clear();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Where the studio UI's source sits relative to the built CLI.
|
|
89
|
+
*
|
|
90
|
+
* `dist/cli/studio/serve.js` → up four is `packages/`, and the UI is a sibling
|
|
91
|
+
* package there. Only present in the frames workspace; a published frame-kit
|
|
92
|
+
* ships `dist/studio` and no source at all.
|
|
93
|
+
*/
|
|
94
|
+
function defaultUiSrcDir() {
|
|
95
|
+
return path.resolve(import.meta.dirname, "../../../../frame-studio");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The studio UI served from source, with hot reloading, on this same server.
|
|
99
|
+
*
|
|
100
|
+
* Vite runs in middleware mode and its HMR socket attaches to the http server
|
|
101
|
+
* already listening, so developing the UI needs one command and one port
|
|
102
|
+
* rather than a second dev server behind a proxy.
|
|
103
|
+
*/
|
|
104
|
+
async function startUiDevServer(srcDir, httpServer) {
|
|
105
|
+
if (!existsSync(path.join(srcDir, "index.html"))) {
|
|
106
|
+
throw new PackagingError(`--dev needs the studio UI's source, which is not at ${srcDir}.\n\n` +
|
|
107
|
+
"It ships only inside the frames workspace; an installed frame-kit\n" +
|
|
108
|
+
"carries the built UI instead. Drop --dev to serve that.\n");
|
|
109
|
+
}
|
|
110
|
+
let vite;
|
|
111
|
+
try {
|
|
112
|
+
vite = await import("vite");
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
throw new PackagingError("--dev needs Vite installed.\n");
|
|
116
|
+
}
|
|
117
|
+
const server = await vite.createServer({
|
|
118
|
+
root: srcDir,
|
|
119
|
+
appType: "spa",
|
|
120
|
+
server: { middlewareMode: true, hmr: { server: httpServer } },
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
middlewares: (req, res) => {
|
|
124
|
+
server.middlewares(req, res);
|
|
125
|
+
},
|
|
126
|
+
close: () => server.close(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** Opens a URL in the platform's default browser, best effort. */
|
|
130
|
+
function openBrowser(url) {
|
|
131
|
+
const [command, args] = process.platform === "win32"
|
|
132
|
+
? ["cmd", ["/c", "start", "", url]]
|
|
133
|
+
: process.platform === "darwin"
|
|
134
|
+
? ["open", [url]]
|
|
135
|
+
: ["xdg-open", [url]];
|
|
136
|
+
try {
|
|
137
|
+
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Not being able to open a browser is not a reason to fail to serve; the
|
|
141
|
+
// URL was already printed.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Serves the studio until the process is interrupted. */
|
|
145
|
+
export async function serveStudio(options) {
|
|
146
|
+
const root = path.resolve(options.root);
|
|
147
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
148
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
149
|
+
const writeEnabled = options.writeEnabled ?? true;
|
|
150
|
+
const uiDir = options.uiDir ?? path.resolve(import.meta.dirname, "../../studio");
|
|
151
|
+
const session = await startSession(root, options.only ?? []);
|
|
152
|
+
const events = new EventStream();
|
|
153
|
+
const selfWrites = new Map();
|
|
154
|
+
const impacts = createImpactCache();
|
|
155
|
+
/** Every box set in a frame, for the shared-literal scan. */
|
|
156
|
+
const boxSetsOf = (slug) => (session.get(slug)?.payload.slots ?? [])
|
|
157
|
+
.filter((slot) => slot.kind === "boxes")
|
|
158
|
+
.map((slot) => ({
|
|
159
|
+
variant: slot.variant,
|
|
160
|
+
layout: slot.layout,
|
|
161
|
+
key: slot.key,
|
|
162
|
+
path: slot.path,
|
|
163
|
+
}));
|
|
164
|
+
const keepAlive = setInterval(() => {
|
|
165
|
+
events.ping();
|
|
166
|
+
}, 25_000);
|
|
167
|
+
keepAlive.unref();
|
|
168
|
+
async function serveUi(res, pathname) {
|
|
169
|
+
const requested = pathname === "/" ? "/index.html" : pathname;
|
|
170
|
+
const file = path.join(uiDir, requested.replace(/^\/+/, ""));
|
|
171
|
+
const target = isUnderRoot(file, uiDir)
|
|
172
|
+
? file
|
|
173
|
+
: path.join(uiDir, "index.html");
|
|
174
|
+
try {
|
|
175
|
+
const body = await readFile(target);
|
|
176
|
+
res.writeHead(200, {
|
|
177
|
+
"content-type": mimeFor(target),
|
|
178
|
+
"cache-control": "no-store",
|
|
179
|
+
});
|
|
180
|
+
res.end(body);
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
// Unknown path: hand back the shell so the SPA can route it, unless the
|
|
184
|
+
// shell itself is missing, which means the UI was never built.
|
|
185
|
+
try {
|
|
186
|
+
const shell = await readFile(path.join(uiDir, "index.html"));
|
|
187
|
+
res.writeHead(200, {
|
|
188
|
+
"content-type": HTML_TYPE,
|
|
189
|
+
"cache-control": "no-store",
|
|
190
|
+
});
|
|
191
|
+
res.end(shell);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
res.writeHead(500, { "content-type": HTML_TYPE });
|
|
195
|
+
res.end("<h1>The studio UI is not built.</h1><p>Run <code>pnpm --filter @cardanvil/frame-studio build</code>.</p>");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function serveAsset(res, url) {
|
|
200
|
+
if (!url) {
|
|
201
|
+
sendJson(res, 400, { error: "missing asset url" });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const file = devUrlToFile(url, session.frameServer.root, session.frameServer.base);
|
|
205
|
+
if (!file) {
|
|
206
|
+
sendJson(res, 404, { error: `not an asset url: ${url}` });
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const info = await stat(file);
|
|
211
|
+
const etag = `"${info.size.toString(16)}-${info.mtimeMs.toString(16)}"`;
|
|
212
|
+
res.writeHead(200, {
|
|
213
|
+
"content-type": mimeFor(file),
|
|
214
|
+
"cache-control": "no-cache",
|
|
215
|
+
etag,
|
|
216
|
+
});
|
|
217
|
+
createReadStream(file).pipe(res);
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
sendJson(res, 404, {
|
|
221
|
+
error: `asset not found: ${relativePosix(root, file)}`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function handleSource(res, url) {
|
|
226
|
+
const slug = url.searchParams.get("frame") ?? "";
|
|
227
|
+
const raw = url.searchParams.get("path") ?? "";
|
|
228
|
+
const entry = session.get(slug);
|
|
229
|
+
if (!entry) {
|
|
230
|
+
sendJson(res, 404, { error: `no frame "${slug}"` });
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const framePath = raw.split(".").filter(Boolean);
|
|
234
|
+
const ctx = { api: session.api, index: session.index, root: session.root };
|
|
235
|
+
const resolution = resolveFramePath(ctx, entry.discovered.entryFile, entry.discovered.meta.export, framePath);
|
|
236
|
+
const shared = resolution.editable && url.searchParams.get("impact") === "1"
|
|
237
|
+
? impacts.get(entry.payload.revision, {
|
|
238
|
+
ctx,
|
|
239
|
+
entryFile: entry.discovered.entryFile,
|
|
240
|
+
exportName: entry.discovered.meta.export,
|
|
241
|
+
boxSets: boxSetsOf(slug),
|
|
242
|
+
target: framePath,
|
|
243
|
+
})
|
|
244
|
+
: [];
|
|
245
|
+
sendJson(res, 200, {
|
|
246
|
+
path: framePath,
|
|
247
|
+
...(resolution.editable
|
|
248
|
+
? {
|
|
249
|
+
editable: true,
|
|
250
|
+
file: relativePosix(root, resolution.file),
|
|
251
|
+
line: resolution.location.line,
|
|
252
|
+
column: resolution.location.column,
|
|
253
|
+
text: resolution.text,
|
|
254
|
+
sharedDefault: resolution.sharedDefault,
|
|
255
|
+
shared,
|
|
256
|
+
}
|
|
257
|
+
: {
|
|
258
|
+
editable: false,
|
|
259
|
+
reason: resolution.reason,
|
|
260
|
+
detail: resolution.detail,
|
|
261
|
+
...(resolution.source
|
|
262
|
+
? {
|
|
263
|
+
source: {
|
|
264
|
+
file: relativePosix(root, resolution.source.file),
|
|
265
|
+
line: resolution.source.line,
|
|
266
|
+
column: resolution.source.column,
|
|
267
|
+
},
|
|
268
|
+
}
|
|
269
|
+
: {}),
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
async function handleEdit(req, res) {
|
|
274
|
+
if (!writeEnabled) {
|
|
275
|
+
sendJson(res, 403, {
|
|
276
|
+
code: "read-only",
|
|
277
|
+
detail: "The studio was started with --no-write.",
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const body = (await readBody(req));
|
|
282
|
+
const entry = session.get(body.frame ?? "");
|
|
283
|
+
if (!entry) {
|
|
284
|
+
sendJson(res, 404, { error: `no frame "${body.frame ?? ""}"` });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// A literal several box sets read moves all of them. Say so and make the
|
|
288
|
+
// author confirm, rather than surprising them after the write.
|
|
289
|
+
if (body.confirmShared !== true) {
|
|
290
|
+
const ctx = {
|
|
291
|
+
api: session.api,
|
|
292
|
+
index: session.index,
|
|
293
|
+
root: session.root,
|
|
294
|
+
};
|
|
295
|
+
const boxSets = boxSetsOf(entry.payload.slug);
|
|
296
|
+
const shared = (body.edits ?? []).flatMap((edit) => impacts
|
|
297
|
+
.get(entry.payload.revision, {
|
|
298
|
+
ctx,
|
|
299
|
+
entryFile: entry.discovered.entryFile,
|
|
300
|
+
exportName: entry.discovered.meta.export,
|
|
301
|
+
boxSets,
|
|
302
|
+
target: edit.path,
|
|
303
|
+
})
|
|
304
|
+
.map((impact) => ({ ...impact, edited: edit.path })));
|
|
305
|
+
if (shared.length > 0) {
|
|
306
|
+
sendJson(res, 409, { code: "shared", shared });
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const result = await applyEdits({
|
|
311
|
+
ctx: { api: session.api, index: session.index, root: session.root },
|
|
312
|
+
entryFile: entry.discovered.entryFile,
|
|
313
|
+
exportName: entry.discovered.meta.export,
|
|
314
|
+
frame: entry.raw,
|
|
315
|
+
edits: body.edits ?? [],
|
|
316
|
+
...(body.expect
|
|
317
|
+
? {
|
|
318
|
+
expect: new Map(Object.entries(body.expect).map(([file, hash]) => [
|
|
319
|
+
normalizeFile(path.resolve(root, file)),
|
|
320
|
+
hash,
|
|
321
|
+
])),
|
|
322
|
+
}
|
|
323
|
+
: {}),
|
|
324
|
+
});
|
|
325
|
+
if (!result.ok) {
|
|
326
|
+
sendJson(res, 409, {
|
|
327
|
+
code: result.rejected[0]?.reason,
|
|
328
|
+
rejected: result.rejected,
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
for (const file of result.files.keys()) {
|
|
334
|
+
selfWrites.set(file, now);
|
|
335
|
+
}
|
|
336
|
+
await session.reload([entry.payload.slug]);
|
|
337
|
+
const updated = session.get(entry.payload.slug);
|
|
338
|
+
events.send({
|
|
339
|
+
type: "frame",
|
|
340
|
+
slug: entry.payload.slug,
|
|
341
|
+
revision: updated?.payload.revision ?? 0,
|
|
342
|
+
cause: "self",
|
|
343
|
+
});
|
|
344
|
+
sendJson(res, 200, {
|
|
345
|
+
applied: result.applied.map((edit) => ({
|
|
346
|
+
...edit,
|
|
347
|
+
file: relativePosix(root, edit.file),
|
|
348
|
+
})),
|
|
349
|
+
revision: updated?.payload.revision ?? 0,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* The UI in development: a Vite server in middleware mode, mounted on this
|
|
354
|
+
* same http server so one command and one port give you both the API and
|
|
355
|
+
* hot reloading. Its HMR socket rides the same server, so nothing else has
|
|
356
|
+
* to be running and no proxy sits in between.
|
|
357
|
+
*/
|
|
358
|
+
let uiDev;
|
|
359
|
+
const server = createServer((req, res) => {
|
|
360
|
+
void (async () => {
|
|
361
|
+
const url = new URL(req.url ?? "/", `http://${host}:${String(port)}`);
|
|
362
|
+
try {
|
|
363
|
+
switch (url.pathname) {
|
|
364
|
+
case "/api/studio":
|
|
365
|
+
sendJson(res, 200, {
|
|
366
|
+
root: relativePosix(path.dirname(root), root),
|
|
367
|
+
absoluteRoot: root,
|
|
368
|
+
writeEnabled,
|
|
369
|
+
});
|
|
370
|
+
return;
|
|
371
|
+
case "/api/frames":
|
|
372
|
+
sendJson(res, 200, { frames: session.list() });
|
|
373
|
+
return;
|
|
374
|
+
case "/api/frame": {
|
|
375
|
+
const entry = session.get(url.searchParams.get("frame") ?? "");
|
|
376
|
+
if (!entry) {
|
|
377
|
+
sendJson(res, 404, { error: "no such frame" });
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
sendJson(res, 200, entry.payload);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
case "/api/asset":
|
|
384
|
+
await serveAsset(res, url.searchParams.get("u"));
|
|
385
|
+
return;
|
|
386
|
+
case "/api/source":
|
|
387
|
+
handleSource(res, url);
|
|
388
|
+
return;
|
|
389
|
+
case "/api/edit":
|
|
390
|
+
await handleEdit(req, res);
|
|
391
|
+
return;
|
|
392
|
+
case "/api/events":
|
|
393
|
+
events.add(res);
|
|
394
|
+
return;
|
|
395
|
+
default:
|
|
396
|
+
if (uiDev) {
|
|
397
|
+
uiDev.middlewares(req, res);
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
await serveUi(res, url.pathname);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (cause) {
|
|
404
|
+
sendJson(res, 500, {
|
|
405
|
+
error: cause instanceof Error ? cause.message : String(cause),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
})();
|
|
409
|
+
});
|
|
410
|
+
const watcher = startSourceWatcher({
|
|
411
|
+
root,
|
|
412
|
+
dirs: session.watchDirs(),
|
|
413
|
+
onChange: async (changed) => {
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
const ours = changed.every((file) => {
|
|
416
|
+
const at = selfWrites.get(normalizeFile(file));
|
|
417
|
+
return at !== undefined && now - at < SELF_WRITE_TTL_MS;
|
|
418
|
+
});
|
|
419
|
+
if (ours) {
|
|
420
|
+
return; // already reloaded and broadcast by the edit that wrote it
|
|
421
|
+
}
|
|
422
|
+
const reloaded = await session.reload();
|
|
423
|
+
for (const slug of reloaded) {
|
|
424
|
+
const entry = session.get(slug);
|
|
425
|
+
events.send({
|
|
426
|
+
type: entry?.payload.stale === true ? "frame-error" : "frame",
|
|
427
|
+
slug,
|
|
428
|
+
revision: entry?.payload.revision ?? 0,
|
|
429
|
+
cause: "external",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
events.send({ type: "frames", frames: session.list() });
|
|
433
|
+
},
|
|
434
|
+
});
|
|
435
|
+
if (options.dev === true) {
|
|
436
|
+
// Created after the http server exists so HMR can attach to it.
|
|
437
|
+
uiDev = await startUiDevServer(options.uiSrcDir ?? defaultUiSrcDir(), server);
|
|
438
|
+
}
|
|
439
|
+
await new Promise((resolve, reject) => {
|
|
440
|
+
server.once("error", reject);
|
|
441
|
+
server.listen(port, host, resolve);
|
|
442
|
+
});
|
|
443
|
+
const url = `http://${host}:${String(port)}/`;
|
|
444
|
+
(options.onReady ??
|
|
445
|
+
((ready) => {
|
|
446
|
+
console.log(`studio → ${ready}`);
|
|
447
|
+
}))(url);
|
|
448
|
+
if (options.open === true) {
|
|
449
|
+
openBrowser(url);
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
await new Promise((resolve) => {
|
|
453
|
+
const stop = () => {
|
|
454
|
+
resolve();
|
|
455
|
+
};
|
|
456
|
+
process.once("SIGINT", stop);
|
|
457
|
+
process.once("SIGTERM", stop);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
clearInterval(keepAlive);
|
|
462
|
+
watcher.close();
|
|
463
|
+
events.close();
|
|
464
|
+
server.close();
|
|
465
|
+
await uiDev?.close();
|
|
466
|
+
await session.close();
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/** `frame-kit studio` — parses its own flags, like `schema` does. */
|
|
470
|
+
export async function runStudio(argv) {
|
|
471
|
+
let values;
|
|
472
|
+
try {
|
|
473
|
+
({ values } = parseArgs({
|
|
474
|
+
args: argv,
|
|
475
|
+
options: {
|
|
476
|
+
root: { type: "string" },
|
|
477
|
+
frame: { type: "string", multiple: true },
|
|
478
|
+
port: { type: "string" },
|
|
479
|
+
host: { type: "string" },
|
|
480
|
+
open: { type: "boolean" },
|
|
481
|
+
dev: { type: "boolean" },
|
|
482
|
+
// `parseArgs` has no `--no-` negation; the key is the literal name.
|
|
483
|
+
"no-write": { type: "boolean" },
|
|
484
|
+
},
|
|
485
|
+
strict: true,
|
|
486
|
+
}));
|
|
487
|
+
}
|
|
488
|
+
catch (cause) {
|
|
489
|
+
console.error(cause instanceof Error ? cause.message : String(cause));
|
|
490
|
+
return 2;
|
|
491
|
+
}
|
|
492
|
+
const port = values.port === undefined ? DEFAULT_PORT : Number(values.port);
|
|
493
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
494
|
+
console.error(`Invalid --port "${values.port ?? ""}".`);
|
|
495
|
+
return 2;
|
|
496
|
+
}
|
|
497
|
+
const host = values.host ?? DEFAULT_HOST;
|
|
498
|
+
const writeEnabled = values["no-write"] !== true;
|
|
499
|
+
if (writeEnabled && host !== DEFAULT_HOST && host !== "localhost") {
|
|
500
|
+
console.error(`Warning: serving on ${host} with editing enabled. The studio writes to\n` +
|
|
501
|
+
"your source files; anyone who can reach this port can change them.\n");
|
|
502
|
+
}
|
|
503
|
+
await serveStudio({
|
|
504
|
+
root: values.root ?? process.cwd(),
|
|
505
|
+
...(values.frame ? { only: values.frame } : {}),
|
|
506
|
+
port,
|
|
507
|
+
host,
|
|
508
|
+
writeEnabled,
|
|
509
|
+
open: values.open === true,
|
|
510
|
+
dev: values.dev === true,
|
|
511
|
+
});
|
|
512
|
+
return 0;
|
|
513
|
+
}
|