@stdout-design/cli 0.1.1 → 0.1.2
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/dist/index.mjs +16 -99
- package/dist/schema.mjs +1 -1
- package/package.json +15 -22
- package/dist/cache-BdS9VgtQ.mjs +0 -22
- package/dist/dev-DoEMJ8Qi.mjs +0 -25
- package/dist/dist-CZVUmY-d.mjs +0 -590
- package/dist/dist-Dm76-R-V.mjs +0 -1187
- package/dist/init-CfgXE-J9.mjs +0 -195
- package/dist/lint-CacX6_Ob.mjs +0 -7
- package/dist/render-BhTeMXMX.mjs +0 -120
- package/dist/validation-CMTs74Vm-C3_d2dBv.mjs +0 -14
package/dist/dist-CZVUmY-d.mjs
DELETED
|
@@ -1,590 +0,0 @@
|
|
|
1
|
-
import { n as isZodObject, r as zodToJsonSchemaShape } from "./validation-CMTs74Vm-C3_d2dBv.mjs";
|
|
2
|
-
import { c as logWarn, f as studioConfigSchema, i as compileTemplate, l as measureTemplate, n as ErrorCode, r as RenderCache, s as logError, u as renderToPixels } from "./dist-Dm76-R-V.mjs";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import path from "node:path";
|
|
5
|
-
import { readFile } from "node:fs/promises";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { createElement } from "react";
|
|
8
|
-
import { pathToFileURL } from "node:url";
|
|
9
|
-
import { Hono } from "hono";
|
|
10
|
-
import { cors } from "hono/cors";
|
|
11
|
-
import { logger } from "hono/logger";
|
|
12
|
-
import { streamSSE } from "hono/streaming";
|
|
13
|
-
import { watch } from "chokidar";
|
|
14
|
-
import react from "@vitejs/plugin-react";
|
|
15
|
-
import { createServer } from "vite";
|
|
16
|
-
import { ViteNodeRunner } from "vite-node/client";
|
|
17
|
-
import { ViteNodeServer } from "vite-node/server";
|
|
18
|
-
import { installSourcemapsSupport } from "vite-node/source-map";
|
|
19
|
-
//#region ../dev-server/dist/index.mjs
|
|
20
|
-
const errorHandler = (err, c) => {
|
|
21
|
-
logError(ErrorCode.INTERNAL_ERROR, "Unhandled server error", {
|
|
22
|
-
error: err.message,
|
|
23
|
-
method: c.req.method,
|
|
24
|
-
path: c.req.path,
|
|
25
|
-
stack: err.stack
|
|
26
|
-
});
|
|
27
|
-
return c.json({ error: "Internal server error" }, 500);
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
|
-
* Watches studio.config.ts and the templates/ directory, triggering
|
|
31
|
-
* TemplateLoader reloads on change and notifying listeners (the dev-server
|
|
32
|
-
* SSE endpoint) of what changed.
|
|
33
|
-
*/
|
|
34
|
-
var FileWatcher = class FileWatcher {
|
|
35
|
-
rootDir;
|
|
36
|
-
templateLoader;
|
|
37
|
-
listeners = /* @__PURE__ */ new Set();
|
|
38
|
-
debounceTimers = /* @__PURE__ */ new Map();
|
|
39
|
-
debounceMs;
|
|
40
|
-
watcher = null;
|
|
41
|
-
constructor(rootDir, templateLoader, options) {
|
|
42
|
-
this.rootDir = rootDir;
|
|
43
|
-
this.templateLoader = templateLoader;
|
|
44
|
-
this.debounceMs = options?.debounceMs ?? 200;
|
|
45
|
-
}
|
|
46
|
-
start() {
|
|
47
|
-
if (this.watcher) return;
|
|
48
|
-
const watchPaths = [`${this.rootDir}/templates`, `${this.rootDir}/studio.config.ts`];
|
|
49
|
-
this.watcher = watch(watchPaths, {
|
|
50
|
-
awaitWriteFinish: {
|
|
51
|
-
pollInterval: 50,
|
|
52
|
-
stabilityThreshold: 100
|
|
53
|
-
},
|
|
54
|
-
ignoreInitial: true
|
|
55
|
-
});
|
|
56
|
-
this.watcher.on("change", (filePath) => this.scheduleChange(filePath));
|
|
57
|
-
this.watcher.on("unlink", (filePath) => this.scheduleChange(filePath));
|
|
58
|
-
this.watcher.on("add", (filePath) => this.scheduleChange(filePath));
|
|
59
|
-
this.watcher.on("error", (error) => {
|
|
60
|
-
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
61
|
-
this.emit({
|
|
62
|
-
error: normalizedError,
|
|
63
|
-
type: "template-error"
|
|
64
|
-
});
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
async stop() {
|
|
68
|
-
for (const timer of this.debounceTimers.values()) clearTimeout(timer);
|
|
69
|
-
this.debounceTimers.clear();
|
|
70
|
-
await this.watcher?.close();
|
|
71
|
-
this.watcher = null;
|
|
72
|
-
}
|
|
73
|
-
onEvent(listener) {
|
|
74
|
-
this.listeners.add(listener);
|
|
75
|
-
return () => this.listeners.delete(listener);
|
|
76
|
-
}
|
|
77
|
-
/** Per-path debounce: a change to file B never cancels a pending reload for file A. */
|
|
78
|
-
scheduleChange(filePath) {
|
|
79
|
-
const existing = this.debounceTimers.get(filePath);
|
|
80
|
-
if (existing) clearTimeout(existing);
|
|
81
|
-
const timer = setTimeout(() => {
|
|
82
|
-
this.debounceTimers.delete(filePath);
|
|
83
|
-
this.handleChange(filePath);
|
|
84
|
-
}, this.debounceMs);
|
|
85
|
-
this.debounceTimers.set(filePath, timer);
|
|
86
|
-
}
|
|
87
|
-
async handleChange(filePath) {
|
|
88
|
-
try {
|
|
89
|
-
if (filePath.endsWith("studio.config.ts")) {
|
|
90
|
-
await this.templateLoader.reloadAll();
|
|
91
|
-
this.emit({ type: "full" });
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
const templateId = FileWatcher.extractTemplateId(filePath);
|
|
95
|
-
if (!templateId) {
|
|
96
|
-
await this.templateLoader.reloadAll();
|
|
97
|
-
this.emit({ type: "full" });
|
|
98
|
-
return;
|
|
99
|
-
}
|
|
100
|
-
const info = await this.templateLoader.reloadTemplate(templateId);
|
|
101
|
-
if (info?.status === "error") {
|
|
102
|
-
this.emit({
|
|
103
|
-
error: new Error(info.errorMessage ?? "Unknown template error"),
|
|
104
|
-
templateId,
|
|
105
|
-
type: "template-error"
|
|
106
|
-
});
|
|
107
|
-
return;
|
|
108
|
-
}
|
|
109
|
-
this.emit({
|
|
110
|
-
templateId,
|
|
111
|
-
type: "template"
|
|
112
|
-
});
|
|
113
|
-
} catch (error) {
|
|
114
|
-
const normalizedError = error instanceof Error ? error : new Error(String(error));
|
|
115
|
-
this.emit({
|
|
116
|
-
error: normalizedError,
|
|
117
|
-
type: "template-error"
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Extracts a template id from a changed file path, supporting nested
|
|
123
|
-
* directories under templates/ (e.g. templates/social/bento.tsx ->
|
|
124
|
-
* "social/bento"). Returns null for files that don't look like a
|
|
125
|
-
* template's own .tsx entry point.
|
|
126
|
-
*/
|
|
127
|
-
static extractTemplateId(filePath) {
|
|
128
|
-
return filePath.replaceAll("\\", "/").match(/templates\/(?<templateId>.+)\.tsx$/u)?.groups?.templateId ?? null;
|
|
129
|
-
}
|
|
130
|
-
emit(event) {
|
|
131
|
-
for (const listener of this.listeners) try {
|
|
132
|
-
listener(event);
|
|
133
|
-
} catch (error) {
|
|
134
|
-
logError(ErrorCode.INTERNAL_ERROR, "Listener threw during file-watch event dispatch", {
|
|
135
|
-
error: String(error),
|
|
136
|
-
eventType: event.type
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
};
|
|
141
|
-
var TemplateConfigError = class extends Error {
|
|
142
|
-
issues;
|
|
143
|
-
constructor(message, issues) {
|
|
144
|
-
super(message);
|
|
145
|
-
this.name = "TemplateConfigError";
|
|
146
|
-
this.issues = issues;
|
|
147
|
-
}
|
|
148
|
-
};
|
|
149
|
-
function validateTemplateModule(mod, templateId) {
|
|
150
|
-
const candidate = mod;
|
|
151
|
-
if (!candidate || typeof candidate.default !== "function") throw new Error(`Template "${templateId}" must have a default export that is a component function.`);
|
|
152
|
-
if (!isZodObject(candidate.propsSchema)) throw new Error(`Template "${templateId}" must export a "propsSchema" that is a z.object({...}) describing its props.`);
|
|
153
|
-
return candidate;
|
|
154
|
-
}
|
|
155
|
-
function summarizeZodIssues(issues) {
|
|
156
|
-
return issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
157
|
-
}
|
|
158
|
-
const { resolve } = path;
|
|
159
|
-
var TemplateLoader = class TemplateLoader {
|
|
160
|
-
rootDir;
|
|
161
|
-
configPath;
|
|
162
|
-
viteServer = null;
|
|
163
|
-
runner = null;
|
|
164
|
-
config = null;
|
|
165
|
-
templates = /* @__PURE__ */ new Map();
|
|
166
|
-
started = false;
|
|
167
|
-
constructor(rootDir) {
|
|
168
|
-
this.rootDir = rootDir;
|
|
169
|
-
this.configPath = resolve(rootDir, "studio.config.ts");
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Boots the Vite SSR pipeline. Must be called once before load/reload
|
|
173
|
-
* methods are used. Cheap to call once per `studio dev` session; not
|
|
174
|
-
* meant to be re-created per reload (that defeats the point of the
|
|
175
|
-
* module cache).
|
|
176
|
-
*/
|
|
177
|
-
async start() {
|
|
178
|
-
if (this.started) return;
|
|
179
|
-
this.viteServer = await createServer({
|
|
180
|
-
mode: "development",
|
|
181
|
-
optimizeDeps: { noDiscovery: true },
|
|
182
|
-
plugins: [react()],
|
|
183
|
-
root: this.rootDir,
|
|
184
|
-
server: {
|
|
185
|
-
hmr: false,
|
|
186
|
-
middlewareMode: true
|
|
187
|
-
},
|
|
188
|
-
ssr: { optimizeDeps: { include: ["zod"] } }
|
|
189
|
-
});
|
|
190
|
-
const nodeServer = new ViteNodeServer(this.viteServer);
|
|
191
|
-
installSourcemapsSupport({ getSourceMap: (source) => nodeServer.getSourceMap(source) });
|
|
192
|
-
this.runner = new ViteNodeRunner({
|
|
193
|
-
fetchModule: (id) => nodeServer.fetchModule(id),
|
|
194
|
-
resolveId: (id, importer) => nodeServer.resolveId(id, importer),
|
|
195
|
-
root: this.viteServer.config.root
|
|
196
|
-
});
|
|
197
|
-
this.started = true;
|
|
198
|
-
}
|
|
199
|
-
async stop() {
|
|
200
|
-
await this.viteServer?.close();
|
|
201
|
-
this.started = false;
|
|
202
|
-
this.viteServer = null;
|
|
203
|
-
this.runner = null;
|
|
204
|
-
}
|
|
205
|
-
async loadConfig() {
|
|
206
|
-
this.assertStarted();
|
|
207
|
-
const raw = await this.importFresh(this.configPath);
|
|
208
|
-
const candidate = raw.default ?? raw;
|
|
209
|
-
const parsed = studioConfigSchema.safeParse(candidate);
|
|
210
|
-
if (!parsed.success) throw new TemplateConfigError(`studio.config.ts is invalid: ${summarizeZodIssues(parsed.error.issues)}`, parsed.error.issues);
|
|
211
|
-
this.config = parsed.data;
|
|
212
|
-
return this.config;
|
|
213
|
-
}
|
|
214
|
-
async getConfig() {
|
|
215
|
-
if (!this.config) return this.loadConfig();
|
|
216
|
-
return this.config;
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Loads the config and registers + loads every template declared in it.
|
|
220
|
-
* A failure in one template does not prevent the others from loading.
|
|
221
|
-
*/
|
|
222
|
-
async loadAll() {
|
|
223
|
-
const config = await this.loadConfig();
|
|
224
|
-
this.templates.clear();
|
|
225
|
-
for (const [id, entry] of Object.entries(config.templates)) {
|
|
226
|
-
const modulePath = resolve(this.rootDir, `${entry.componentPath}.tsx`);
|
|
227
|
-
this.templates.set(id, {
|
|
228
|
-
contentHash: "",
|
|
229
|
-
description: entry.description ?? "",
|
|
230
|
-
id,
|
|
231
|
-
modulePath,
|
|
232
|
-
state: null
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
await Promise.all([...this.templates.keys()].map((id) => this.loadOne(id)));
|
|
236
|
-
return this.getAllTemplateInfo();
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* (Re)loads a single template by id. Used by the file watcher when one
|
|
240
|
-
* template file changes -- does not touch any other template's state.
|
|
241
|
-
*/
|
|
242
|
-
async reloadTemplate(id) {
|
|
243
|
-
const entry = this.templates.get(id);
|
|
244
|
-
if (!entry) return null;
|
|
245
|
-
await this.loadOne(id);
|
|
246
|
-
return TemplateLoader.toTemplateInfo(entry);
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Full reload: config + every template. Used when studio.config.ts
|
|
250
|
-
* itself changes, since template registration may have changed.
|
|
251
|
-
*/
|
|
252
|
-
async reloadAll() {
|
|
253
|
-
this.invalidateModule(this.configPath);
|
|
254
|
-
return this.loadAll();
|
|
255
|
-
}
|
|
256
|
-
getTemplateModule(id) {
|
|
257
|
-
const entry = this.templates.get(id);
|
|
258
|
-
if (!entry || !entry.state || entry.state.status === "error") return null;
|
|
259
|
-
return entry.state.module;
|
|
260
|
-
}
|
|
261
|
-
getTemplateInfo(id) {
|
|
262
|
-
const entry = this.templates.get(id);
|
|
263
|
-
return entry ? TemplateLoader.toTemplateInfo(entry) : null;
|
|
264
|
-
}
|
|
265
|
-
getAllTemplateInfo() {
|
|
266
|
-
return [...this.templates.values()].map((entry) => TemplateLoader.toTemplateInfo(entry));
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* Loads any file (not just templates) through the same vite-node SSR
|
|
270
|
-
* pipeline used for templates -- e.g. locale JSON files. Reuses the
|
|
271
|
-
* already-running Vite server rather than spinning up a second one, and
|
|
272
|
-
* gets the same leak-free cache invalidation as template loading.
|
|
273
|
-
*
|
|
274
|
-
* Returns the module's default export if present, otherwise the module
|
|
275
|
-
* namespace itself (matches how `import x.json` typically shapes up).
|
|
276
|
-
*/
|
|
277
|
-
async loadDataModule(filePath) {
|
|
278
|
-
const raw = await this.importFresh(filePath);
|
|
279
|
-
return raw.default ?? raw;
|
|
280
|
-
}
|
|
281
|
-
/** Forces a fresh read on the next loadDataModule/template load for this path. */
|
|
282
|
-
invalidateDataModule(filePath) {
|
|
283
|
-
this.invalidateModule(filePath);
|
|
284
|
-
}
|
|
285
|
-
async loadOne(id) {
|
|
286
|
-
const entry = this.templates.get(id);
|
|
287
|
-
if (!entry) return;
|
|
288
|
-
try {
|
|
289
|
-
const content = await readFile(entry.modulePath, "utf-8");
|
|
290
|
-
entry.contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
291
|
-
this.invalidateModule(entry.modulePath);
|
|
292
|
-
entry.state = {
|
|
293
|
-
module: validateTemplateModule(await this.importFresh(entry.modulePath), id),
|
|
294
|
-
status: "ok"
|
|
295
|
-
};
|
|
296
|
-
} catch (error) {
|
|
297
|
-
console.error(`[template-loader] Failed to load template "${id}":`, error);
|
|
298
|
-
entry.state = {
|
|
299
|
-
error: error instanceof Error ? error : new Error(String(error)),
|
|
300
|
-
status: "error"
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
importFresh(modulePath) {
|
|
305
|
-
if (!this.runner) throw new Error("TemplateLoader.start() must be called before loading modules.");
|
|
306
|
-
return this.runner.executeFile(modulePath);
|
|
307
|
-
}
|
|
308
|
-
invalidateModule(modulePath) {
|
|
309
|
-
if (!this.runner) return;
|
|
310
|
-
const id = pathToFileURL(modulePath).pathname;
|
|
311
|
-
this.runner.moduleCache.deleteByModuleId(id);
|
|
312
|
-
this.runner.moduleCache.delete(modulePath);
|
|
313
|
-
}
|
|
314
|
-
static toTemplateInfo(entry) {
|
|
315
|
-
if (!entry.state) return {
|
|
316
|
-
contentHash: entry.contentHash,
|
|
317
|
-
description: entry.description,
|
|
318
|
-
errorMessage: "Not loaded yet",
|
|
319
|
-
id: entry.id,
|
|
320
|
-
propsSchema: {},
|
|
321
|
-
status: "error"
|
|
322
|
-
};
|
|
323
|
-
if (entry.state.status === "error") return {
|
|
324
|
-
contentHash: entry.contentHash,
|
|
325
|
-
description: entry.description,
|
|
326
|
-
errorMessage: entry.state.error.message,
|
|
327
|
-
id: entry.id,
|
|
328
|
-
propsSchema: {},
|
|
329
|
-
status: "error"
|
|
330
|
-
};
|
|
331
|
-
return {
|
|
332
|
-
contentHash: entry.contentHash,
|
|
333
|
-
description: entry.description,
|
|
334
|
-
id: entry.id,
|
|
335
|
-
propsSchema: zodToJsonSchemaShape(entry.state.module.propsSchema),
|
|
336
|
-
status: "ok"
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
assertStarted() {
|
|
340
|
-
if (!this.started || !this.runner) throw new Error("TemplateLoader.start() must be called before use.");
|
|
341
|
-
}
|
|
342
|
-
};
|
|
343
|
-
const renderRequestSchema = z.object({
|
|
344
|
-
locale: z.string().optional(),
|
|
345
|
-
preset: z.string().optional(),
|
|
346
|
-
props: z.unknown(),
|
|
347
|
-
templateId: z.string().min(1)
|
|
348
|
-
});
|
|
349
|
-
const measureRequestSchema = z.object({
|
|
350
|
-
props: z.unknown(),
|
|
351
|
-
templateId: z.string().min(1)
|
|
352
|
-
});
|
|
353
|
-
/**
|
|
354
|
-
* Merges locale translations into already-validated props. Loads the
|
|
355
|
-
* locale JSON through TemplateLoader's vite-node pipeline (the same
|
|
356
|
-
* leak-free, explicitly-invalidated module cache used for templates)
|
|
357
|
-
* rather than a raw `import(...)?t=Date.now()`, which would reproduce the
|
|
358
|
-
* exact unbounded module-registry leak that was fixed for template
|
|
359
|
-
* loading.
|
|
360
|
-
*
|
|
361
|
-
* Unlike the original, failures are NOT swallowed: a missing or malformed
|
|
362
|
-
* locale file is a real error the caller should see, not a silent
|
|
363
|
-
* fallback to default-language props (silently shipping the wrong
|
|
364
|
-
* language is a worse outcome than a clear 400).
|
|
365
|
-
*/
|
|
366
|
-
const applyLocale = async (templateLoader, rootDir, locale, props) => {
|
|
367
|
-
const localePath = `${rootDir}/locales/${locale}.json`;
|
|
368
|
-
const translations = await templateLoader.loadDataModule(localePath);
|
|
369
|
-
const merged = {
|
|
370
|
-
...props,
|
|
371
|
-
locale
|
|
372
|
-
};
|
|
373
|
-
for (const [key, value] of Object.entries(translations)) if (Array.isArray(value) && Array.isArray(merged[key])) merged[key] = value;
|
|
374
|
-
else if (typeof value === "string" && typeof merged[key] === "string") merged[key] = value;
|
|
375
|
-
return merged;
|
|
376
|
-
};
|
|
377
|
-
const createDevServer = async (options) => {
|
|
378
|
-
const { rootDir, port, cacheDir, webUiDist: _webUiDist } = options;
|
|
379
|
-
const templateLoader = new TemplateLoader(rootDir);
|
|
380
|
-
const fileWatcher = new FileWatcher(rootDir, templateLoader);
|
|
381
|
-
const renderCache = new RenderCache({ cacheDir: cacheDir ?? path.join(rootDir, ".studio-cache") });
|
|
382
|
-
await renderCache.init();
|
|
383
|
-
await templateLoader.start();
|
|
384
|
-
await templateLoader.loadAll();
|
|
385
|
-
fileWatcher.start();
|
|
386
|
-
const app = new Hono();
|
|
387
|
-
app.onError(errorHandler);
|
|
388
|
-
app.use("*", cors({
|
|
389
|
-
credentials: true,
|
|
390
|
-
origin: ["http://localhost:5173", `http://localhost:${port}`]
|
|
391
|
-
}));
|
|
392
|
-
app.use("*", logger());
|
|
393
|
-
app.get("/events", (c) => streamSSE(c, async (s) => {
|
|
394
|
-
const unsubscribe = fileWatcher.onEvent((event) => {
|
|
395
|
-
s.writeSSE({
|
|
396
|
-
data: JSON.stringify(event),
|
|
397
|
-
event: "reload"
|
|
398
|
-
});
|
|
399
|
-
});
|
|
400
|
-
s.onAbort(unsubscribe);
|
|
401
|
-
await s.writeSSE({
|
|
402
|
-
data: "{}",
|
|
403
|
-
event: "connected"
|
|
404
|
-
});
|
|
405
|
-
while (!c.req.raw.signal.aborted) {
|
|
406
|
-
await s.write(": keepalive\n\n");
|
|
407
|
-
await s.sleep(15e3);
|
|
408
|
-
}
|
|
409
|
-
}));
|
|
410
|
-
app.get("/templates", (c) => c.json(templateLoader.getAllTemplateInfo()));
|
|
411
|
-
app.get("/presets", async (c) => {
|
|
412
|
-
const config = await templateLoader.getConfig();
|
|
413
|
-
return c.json(config.presets);
|
|
414
|
-
});
|
|
415
|
-
app.get("/locales", async (c) => {
|
|
416
|
-
const config = await templateLoader.getConfig();
|
|
417
|
-
return c.json(config.locales ?? []);
|
|
418
|
-
});
|
|
419
|
-
app.get("/config", async (c) => {
|
|
420
|
-
const config = await templateLoader.getConfig();
|
|
421
|
-
return c.json({
|
|
422
|
-
defaultPreset: config.defaultPreset,
|
|
423
|
-
locales: config.locales,
|
|
424
|
-
outDir: config.outDir,
|
|
425
|
-
presets: config.presets
|
|
426
|
-
});
|
|
427
|
-
});
|
|
428
|
-
app.post("/render", async (c) => {
|
|
429
|
-
const parsedBody = renderRequestSchema.safeParse(await c.req.json());
|
|
430
|
-
if (!parsedBody.success) {
|
|
431
|
-
logWarn("Invalid render request body", { issues: parsedBody.error.issues });
|
|
432
|
-
return c.json({
|
|
433
|
-
error: "Invalid request body",
|
|
434
|
-
issues: parsedBody.error.issues
|
|
435
|
-
}, 400);
|
|
436
|
-
}
|
|
437
|
-
const { templateId, props, preset: presetId, locale } = parsedBody.data;
|
|
438
|
-
const templateInfo = templateLoader.getTemplateInfo(templateId);
|
|
439
|
-
if (!templateInfo) {
|
|
440
|
-
logWarn("Render template not found", { templateId });
|
|
441
|
-
return c.json({ error: `Template "${templateId}" not found` }, 404);
|
|
442
|
-
}
|
|
443
|
-
if (templateInfo.status === "error") {
|
|
444
|
-
logWarn("Render template load error", {
|
|
445
|
-
errorMessage: templateInfo.errorMessage,
|
|
446
|
-
templateId
|
|
447
|
-
});
|
|
448
|
-
return c.json({ error: `Template "${templateId}" failed to load: ${templateInfo.errorMessage}` }, 422);
|
|
449
|
-
}
|
|
450
|
-
const templateModule = templateLoader.getTemplateModule(templateId);
|
|
451
|
-
if (!templateModule) return c.json({ error: `Template "${templateId}" module unavailable` }, 500);
|
|
452
|
-
const config = await templateLoader.getConfig();
|
|
453
|
-
const preset = presetId ? config.presets.find((p) => p.id === presetId) : config.presets.find((p) => p.id === config.defaultPreset);
|
|
454
|
-
if (!preset) {
|
|
455
|
-
logWarn("Render preset not found", { presetId: presetId ?? config.defaultPreset });
|
|
456
|
-
return c.json({ error: `Preset "${presetId ?? config.defaultPreset}" not found` }, 404);
|
|
457
|
-
}
|
|
458
|
-
const parsed = templateModule.propsSchema.safeParse(props);
|
|
459
|
-
if (!parsed.success) {
|
|
460
|
-
logWarn("Invalid render props", {
|
|
461
|
-
issues: parsed.error.issues,
|
|
462
|
-
templateId
|
|
463
|
-
});
|
|
464
|
-
return c.json({
|
|
465
|
-
error: "Invalid props",
|
|
466
|
-
issues: parsed.error.issues
|
|
467
|
-
}, 400);
|
|
468
|
-
}
|
|
469
|
-
const validatedProps = parsed.data;
|
|
470
|
-
let resolvedProps;
|
|
471
|
-
if (locale) try {
|
|
472
|
-
resolvedProps = await applyLocale(templateLoader, rootDir, locale, validatedProps);
|
|
473
|
-
} catch (error) {
|
|
474
|
-
const message = error instanceof Error ? error.message : "Failed to load locale";
|
|
475
|
-
logWarn("Render locale load failed", {
|
|
476
|
-
locale,
|
|
477
|
-
message
|
|
478
|
-
});
|
|
479
|
-
return c.json({ error: `Locale "${locale}": ${message}` }, 400);
|
|
480
|
-
}
|
|
481
|
-
else resolvedProps = validatedProps;
|
|
482
|
-
const propsJSON = JSON.stringify(resolvedProps);
|
|
483
|
-
const pixelKey = RenderCache.createPixelCacheKey({
|
|
484
|
-
height: preset.height,
|
|
485
|
-
propsJSON,
|
|
486
|
-
templateContentHash: templateInfo.contentHash,
|
|
487
|
-
width: preset.width
|
|
488
|
-
});
|
|
489
|
-
const cached = await renderCache.getPixels(pixelKey);
|
|
490
|
-
if (cached) return new Response(new Uint8Array(cached), { headers: {
|
|
491
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
492
|
-
"Content-Type": "image/png",
|
|
493
|
-
"X-Cache": "hit"
|
|
494
|
-
} });
|
|
495
|
-
const compileKey = RenderCache.createCompileCacheKey({
|
|
496
|
-
propsJSON,
|
|
497
|
-
templateContentHash: templateInfo.contentHash,
|
|
498
|
-
templateId
|
|
499
|
-
});
|
|
500
|
-
let compiled = renderCache.getCompiled(compileKey);
|
|
501
|
-
if (!compiled) {
|
|
502
|
-
const Component = templateModule.default;
|
|
503
|
-
compiled = await compileTemplate(createElement(Component, resolvedProps));
|
|
504
|
-
renderCache.setCompiledWithContentHash(compileKey, templateInfo.contentHash, compiled);
|
|
505
|
-
}
|
|
506
|
-
const output = await renderToPixels(compiled, {
|
|
507
|
-
height: preset.height,
|
|
508
|
-
width: preset.width
|
|
509
|
-
});
|
|
510
|
-
await renderCache.setPixels(pixelKey, output.bytes, preset.width, preset.height);
|
|
511
|
-
return new Response(new Uint8Array(output.bytes), { headers: {
|
|
512
|
-
"Cache-Control": "public, max-age=31536000, immutable",
|
|
513
|
-
"Content-Type": "image/png",
|
|
514
|
-
"X-Cache": "miss"
|
|
515
|
-
} });
|
|
516
|
-
});
|
|
517
|
-
app.post("/measure", async (c) => {
|
|
518
|
-
const parsedBody = measureRequestSchema.safeParse(await c.req.json());
|
|
519
|
-
if (!parsedBody.success) {
|
|
520
|
-
logWarn("Invalid measure request body", { issues: parsedBody.error.issues });
|
|
521
|
-
return c.json({
|
|
522
|
-
error: "Invalid request body",
|
|
523
|
-
issues: parsedBody.error.issues
|
|
524
|
-
}, 400);
|
|
525
|
-
}
|
|
526
|
-
const { templateId, props } = parsedBody.data;
|
|
527
|
-
const templateInfo = templateLoader.getTemplateInfo(templateId);
|
|
528
|
-
if (!templateInfo) {
|
|
529
|
-
logWarn("Measure template not found", { templateId });
|
|
530
|
-
return c.json({ error: `Template "${templateId}" not found` }, 404);
|
|
531
|
-
}
|
|
532
|
-
if (templateInfo.status === "error") {
|
|
533
|
-
logWarn("Measure template load error", {
|
|
534
|
-
errorMessage: templateInfo.errorMessage,
|
|
535
|
-
templateId
|
|
536
|
-
});
|
|
537
|
-
return c.json({ error: `Template "${templateId}" failed to load: ${templateInfo.errorMessage}` }, 422);
|
|
538
|
-
}
|
|
539
|
-
const templateModule = templateLoader.getTemplateModule(templateId);
|
|
540
|
-
if (!templateModule) return c.json({ error: `Template "${templateId}" module unavailable` }, 500);
|
|
541
|
-
const parsed = templateModule.propsSchema.safeParse(props);
|
|
542
|
-
if (!parsed.success) {
|
|
543
|
-
logWarn("Invalid measure props", {
|
|
544
|
-
issues: parsed.error.issues,
|
|
545
|
-
templateId
|
|
546
|
-
});
|
|
547
|
-
return c.json({
|
|
548
|
-
error: "Invalid props",
|
|
549
|
-
issues: parsed.error.issues
|
|
550
|
-
}, 400);
|
|
551
|
-
}
|
|
552
|
-
const validatedProps = parsed.data;
|
|
553
|
-
const propsJSON = JSON.stringify(validatedProps);
|
|
554
|
-
const compileKey = RenderCache.createCompileCacheKey({
|
|
555
|
-
propsJSON,
|
|
556
|
-
templateContentHash: templateInfo.contentHash,
|
|
557
|
-
templateId
|
|
558
|
-
});
|
|
559
|
-
let compiled = renderCache.getCompiled(compileKey);
|
|
560
|
-
if (!compiled) {
|
|
561
|
-
const Component = templateModule.default;
|
|
562
|
-
compiled = await compileTemplate(createElement(Component, validatedProps));
|
|
563
|
-
renderCache.setCompiledWithContentHash(compileKey, templateInfo.contentHash, compiled);
|
|
564
|
-
}
|
|
565
|
-
const measured = await measureTemplate(compiled);
|
|
566
|
-
return c.json({
|
|
567
|
-
height: measured.height,
|
|
568
|
-
width: measured.width
|
|
569
|
-
});
|
|
570
|
-
});
|
|
571
|
-
app.get("/cache/stats", (c) => c.json(renderCache.stats()));
|
|
572
|
-
app.post("/cache/clean", async (c) => {
|
|
573
|
-
const result = await renderCache.clean();
|
|
574
|
-
return c.json(result);
|
|
575
|
-
});
|
|
576
|
-
const close = async () => {
|
|
577
|
-
await fileWatcher.stop();
|
|
578
|
-
await templateLoader.stop();
|
|
579
|
-
renderCache.close();
|
|
580
|
-
};
|
|
581
|
-
return {
|
|
582
|
-
app,
|
|
583
|
-
close,
|
|
584
|
-
fileWatcher,
|
|
585
|
-
port,
|
|
586
|
-
templateLoader
|
|
587
|
-
};
|
|
588
|
-
};
|
|
589
|
-
//#endregion
|
|
590
|
-
export { createDevServer };
|