@stdout-design/cli 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/dist/cache-BdS9VgtQ.mjs +22 -0
- package/dist/dev-DoEMJ8Qi.mjs +25 -0
- package/dist/dist-CZVUmY-d.mjs +590 -0
- package/dist/dist-Dm76-R-V.mjs +1187 -0
- package/dist/index.mjs +115 -0
- package/dist/init-CfgXE-J9.mjs +195 -0
- package/dist/lint-CacX6_Ob.mjs +7 -0
- package/dist/render-BhTeMXMX.mjs +120 -0
- package/dist/schema.mjs +2 -0
- package/dist/validation-CMTs74Vm-C3_d2dBv.mjs +14 -0
- package/package.json +83 -0
|
@@ -0,0 +1,1187 @@
|
|
|
1
|
+
import { n as isZodObject } from "./validation-CMTs74Vm-C3_d2dBv.mjs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fromJsx } from "takumi-js/helpers/jsx";
|
|
5
|
+
import { existsSync, unlinkSync } from "node:fs";
|
|
6
|
+
import { mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import pLimit from "p-limit";
|
|
9
|
+
import lockfile from "proper-lockfile";
|
|
10
|
+
import realWriteFileAtomic from "write-file-atomic";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { Kysely, sql } from "kysely";
|
|
13
|
+
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
|
14
|
+
import { Renderer } from "takumi-js/node";
|
|
15
|
+
import { createElement } from "react";
|
|
16
|
+
import { googleFonts } from "@takumi-rs/helpers";
|
|
17
|
+
import { pathToFileURL } from "node:url";
|
|
18
|
+
//#region ../core/dist/index.mjs
|
|
19
|
+
const templateEntrySchema = z.object({
|
|
20
|
+
componentPath: z.string().min(1, "componentPath is required"),
|
|
21
|
+
description: z.string().optional()
|
|
22
|
+
});
|
|
23
|
+
const presetSchema = z.object({
|
|
24
|
+
height: z.number().int().positive(),
|
|
25
|
+
id: z.string().min(1),
|
|
26
|
+
platform: z.string().min(1),
|
|
27
|
+
width: z.number().int().positive()
|
|
28
|
+
});
|
|
29
|
+
const studioConfigSchema = z.object({
|
|
30
|
+
defaultPreset: z.string().optional(),
|
|
31
|
+
locales: z.array(z.string()).optional(),
|
|
32
|
+
outDir: z.string().optional(),
|
|
33
|
+
presets: z.array(presetSchema),
|
|
34
|
+
templates: z.record(z.string(), templateEntrySchema)
|
|
35
|
+
});
|
|
36
|
+
const compileTemplate = async (element, options) => {
|
|
37
|
+
const result = await fromJsx(element, options);
|
|
38
|
+
return {
|
|
39
|
+
node: result.node,
|
|
40
|
+
stylesheets: result.stylesheets
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
const ErrorCode = {
|
|
44
|
+
API_ERROR: "API_ERROR",
|
|
45
|
+
CACHE_CLEANUP_FAILED: "CACHE_CLEANUP_FAILED",
|
|
46
|
+
CACHE_CORRUPT_ENTRY: "CACHE_CORRUPT_ENTRY",
|
|
47
|
+
CACHE_INIT_FAILED: "CACHE_INIT_FAILED",
|
|
48
|
+
CACHE_WRITE_FAILED: "CACHE_WRITE_FAILED",
|
|
49
|
+
CONFIG_INVALID: "CONFIG_INVALID",
|
|
50
|
+
CONFIG_NOT_FOUND: "CONFIG_NOT_FOUND",
|
|
51
|
+
FONT_REGISTRATION_FAILED: "FONT_REGISTRATION_FAILED",
|
|
52
|
+
INTERNAL_ERROR: "INTERNAL_ERROR",
|
|
53
|
+
LOCALE_LOAD_FAILED: "LOCALE_LOAD_FAILED",
|
|
54
|
+
PROP_VALIDATION_FAILED: "PROP_VALIDATION_FAILED",
|
|
55
|
+
RENDERER_NOT_INITIALIZED: "RENDERER_NOT_INITIALIZED",
|
|
56
|
+
RENDER_FAILED: "RENDER_FAILED",
|
|
57
|
+
SSE_ERROR: "SSE_ERROR",
|
|
58
|
+
TEMPLATE_INVALID_EXPORT: "TEMPLATE_INVALID_EXPORT",
|
|
59
|
+
TEMPLATE_LOAD_FAILED: "TEMPLATE_LOAD_FAILED",
|
|
60
|
+
TEMPLATE_NOT_FOUND: "TEMPLATE_NOT_FOUND",
|
|
61
|
+
UI_RENDER_ERROR: "UI_RENDER_ERROR"
|
|
62
|
+
};
|
|
63
|
+
var AppError = class extends Error {
|
|
64
|
+
code;
|
|
65
|
+
context;
|
|
66
|
+
constructor(code, message, context) {
|
|
67
|
+
super(message);
|
|
68
|
+
this.name = "AppError";
|
|
69
|
+
this.code = code;
|
|
70
|
+
this.context = context ?? {};
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const timestamp = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
74
|
+
const stringifyContext = (context) => {
|
|
75
|
+
if (!context || Object.keys(context).length === 0) return "";
|
|
76
|
+
return ` ${JSON.stringify(context)}`;
|
|
77
|
+
};
|
|
78
|
+
const logDebug = (message, context) => {
|
|
79
|
+
console.log(`[${timestamp()}] DEBUG ${message}${stringifyContext(context)}`);
|
|
80
|
+
};
|
|
81
|
+
const logWarn = (message, context) => {
|
|
82
|
+
console.warn(`[${timestamp()}] WARN ${message}${stringifyContext(context)}`);
|
|
83
|
+
};
|
|
84
|
+
const logError = (code, message, context) => {
|
|
85
|
+
console.error(`[${timestamp()}] ERROR [${code}] ${message}${stringifyContext(context)}`);
|
|
86
|
+
};
|
|
87
|
+
const createCompileCacheKey = (input) => createHash("sha256").update(`${input.templateId}\u0000${input.templateContentHash}\u0000${input.propsJSON}`).digest("hex").slice(0, 16);
|
|
88
|
+
const createPixelCacheKey = (input) => createHash("sha256").update(`${input.templateContentHash}\u0000${input.propsJSON}\u0000${input.width}x${input.height}\u0000${input.format ?? "png"}`).digest("hex").slice(0, 32);
|
|
89
|
+
/**
|
|
90
|
+
* CompileCache: in-memory compile-result cache.
|
|
91
|
+
*
|
|
92
|
+
* Process-local only, never persisted, never shared across processes.
|
|
93
|
+
* Cheap to recompute, so this uses a simple LRU eviction policy: the
|
|
94
|
+
* `get()` method promotes a hit to most-recently-used position before
|
|
95
|
+
* returning, and eviction removes the least-recently-used entry when
|
|
96
|
+
* capacity is exceeded.
|
|
97
|
+
*/
|
|
98
|
+
const DEFAULT_COMPILED_MAX = 100;
|
|
99
|
+
var CompileCache = class {
|
|
100
|
+
maxEntries;
|
|
101
|
+
cache = /* @__PURE__ */ new Map();
|
|
102
|
+
order = [];
|
|
103
|
+
contentHashIndex = /* @__PURE__ */ new Map();
|
|
104
|
+
constructor(maxEntries = DEFAULT_COMPILED_MAX) {
|
|
105
|
+
this.maxEntries = maxEntries;
|
|
106
|
+
}
|
|
107
|
+
get size() {
|
|
108
|
+
return this.cache.size;
|
|
109
|
+
}
|
|
110
|
+
get(key) {
|
|
111
|
+
if (this.cache.has(key)) {
|
|
112
|
+
const idx = this.order.indexOf(key);
|
|
113
|
+
if (idx !== -1) {
|
|
114
|
+
this.order.splice(idx, 1);
|
|
115
|
+
this.order.push(key);
|
|
116
|
+
}
|
|
117
|
+
return this.cache.get(key);
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
set(key, value) {
|
|
122
|
+
if (!this.cache.has(key)) this.order.push(key);
|
|
123
|
+
this.cache.set(key, value);
|
|
124
|
+
while (this.order.length > this.maxEntries) {
|
|
125
|
+
const oldest = this.order.shift();
|
|
126
|
+
if (oldest !== void 0) this.cache.delete(oldest);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Use this instead of `set` when the entry should be invalidatable by
|
|
131
|
+
* template content hash later (the normal case for template compile
|
|
132
|
+
* results).
|
|
133
|
+
*/
|
|
134
|
+
setWithContentHash(key, contentHash, value) {
|
|
135
|
+
this.set(key, value);
|
|
136
|
+
let set = this.contentHashIndex.get(contentHash);
|
|
137
|
+
if (!set) {
|
|
138
|
+
set = /* @__PURE__ */ new Set();
|
|
139
|
+
this.contentHashIndex.set(contentHash, set);
|
|
140
|
+
}
|
|
141
|
+
set.add(key);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Invalidate entries previously written via `setWithContentHash` for a
|
|
145
|
+
* given content hash. Uses the explicit index built at write time, not
|
|
146
|
+
* a hash-prefix guess against the cache key itself (the key is a
|
|
147
|
+
* one-way hash, it cannot be pattern-matched against its own input).
|
|
148
|
+
*/
|
|
149
|
+
invalidateByContentHash(contentHash) {
|
|
150
|
+
const keys = this.contentHashIndex.get(contentHash);
|
|
151
|
+
if (!keys) return;
|
|
152
|
+
for (const key of keys) {
|
|
153
|
+
this.cache.delete(key);
|
|
154
|
+
const idx = this.order.indexOf(key);
|
|
155
|
+
if (idx !== -1) this.order.splice(idx, 1);
|
|
156
|
+
}
|
|
157
|
+
this.contentHashIndex.delete(contentHash);
|
|
158
|
+
}
|
|
159
|
+
clear() {
|
|
160
|
+
this.cache.clear();
|
|
161
|
+
this.order.length = 0;
|
|
162
|
+
this.contentHashIndex.clear();
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Bounds how many concurrent operations (writes/reads through FsStore)
|
|
167
|
+
* run at once. Exists to cap the "N concurrent large-buffer renders spike
|
|
168
|
+
* RSS" risk at the layer that actually does the I/O, rather than trusting
|
|
169
|
+
* every future caller (CLI batch executor, dev-server, MCP) to remember
|
|
170
|
+
* to throttle itself.
|
|
171
|
+
*
|
|
172
|
+
* Deliberately a thin wrapper, not a reimplementation -- p-limit is the
|
|
173
|
+
* de facto standard promise concurrency limiter in the Node/Bun
|
|
174
|
+
* ecosystem, small and heavily exercised. No maxConcurrent (or a
|
|
175
|
+
* non-positive value) means unbounded, matching the cache's behavior
|
|
176
|
+
* before this existed.
|
|
177
|
+
*/
|
|
178
|
+
var ConcurrencyLimiter = class {
|
|
179
|
+
limit;
|
|
180
|
+
constructor(maxConcurrent) {
|
|
181
|
+
this.limit = maxConcurrent && maxConcurrent > 0 ? pLimit(maxConcurrent) : null;
|
|
182
|
+
}
|
|
183
|
+
run(fn) {
|
|
184
|
+
if (!this.limit) return fn();
|
|
185
|
+
return this.limit(fn);
|
|
186
|
+
}
|
|
187
|
+
get activeCount() {
|
|
188
|
+
return this.limit?.activeCount ?? 0;
|
|
189
|
+
}
|
|
190
|
+
get pendingCount() {
|
|
191
|
+
return this.limit?.pendingCount ?? 0;
|
|
192
|
+
}
|
|
193
|
+
get isBounded() {
|
|
194
|
+
return this.limit !== null;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const DEFAULT_DEBOUNCE_MS = 250;
|
|
198
|
+
/**
|
|
199
|
+
* Coordinates eviction across both time (debouncing) and processes
|
|
200
|
+
* (advisory locking), replacing two things from the old monolithic
|
|
201
|
+
* RenderCache:
|
|
202
|
+
*
|
|
203
|
+
* 1. Eviction ran synchronously inside every `setPixels` call, doing a
|
|
204
|
+
* full-table SUM and (once over budget) a full-table ORDER BY on
|
|
205
|
+
* every single write. Here, a write just calls `requestEviction()`,
|
|
206
|
+
* which schedules a debounced check -- the expensive scan runs at
|
|
207
|
+
* most once per debounce window, not once per write.
|
|
208
|
+
*
|
|
209
|
+
* 2. Multi-process safety was implicit ("WAL mode makes concurrent DB
|
|
210
|
+
* access safe"), which said nothing about the filesystem-level TOCTOU
|
|
211
|
+
* race in delete. `proper-lockfile` wraps the actual sweep in an
|
|
212
|
+
* advisory lock so only one process ever runs a sweep at a time --
|
|
213
|
+
* the delete race is eliminated by construction (only one actor ever
|
|
214
|
+
* deletes at once) rather than caught after the fact.
|
|
215
|
+
*/
|
|
216
|
+
var EvictionCoordinator = class {
|
|
217
|
+
lockTargetPath;
|
|
218
|
+
maxSizeBytes;
|
|
219
|
+
debounceMs;
|
|
220
|
+
getCurrentTotalBytes;
|
|
221
|
+
sweepFn;
|
|
222
|
+
debounceTimer = null;
|
|
223
|
+
constructor(options) {
|
|
224
|
+
this.lockTargetPath = options.lockTargetPath;
|
|
225
|
+
this.maxSizeBytes = options.maxSizeBytes;
|
|
226
|
+
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
227
|
+
this.getCurrentTotalBytes = options.getCurrentTotalBytes;
|
|
228
|
+
this.sweepFn = options.sweep;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Call after every write. Schedules (or no-ops if one is already
|
|
232
|
+
* scheduled) a debounced budget check -- callers never pay the full
|
|
233
|
+
* scan cost inline.
|
|
234
|
+
*/
|
|
235
|
+
requestEviction() {
|
|
236
|
+
if (this.debounceTimer) return;
|
|
237
|
+
this.debounceTimer = setTimeout(async () => {
|
|
238
|
+
this.debounceTimer = null;
|
|
239
|
+
try {
|
|
240
|
+
await this.runIfOverBudget();
|
|
241
|
+
} catch (error) {
|
|
242
|
+
logWarn("EvictionCoordinator: debounced sweep failed", { error: String(error) });
|
|
243
|
+
}
|
|
244
|
+
}, this.debounceMs);
|
|
245
|
+
this.debounceTimer.unref?.();
|
|
246
|
+
}
|
|
247
|
+
/** Checks the current total against budget and runs a partial sweep only if over. */
|
|
248
|
+
async runIfOverBudget() {
|
|
249
|
+
if (await this.getCurrentTotalBytes() <= this.maxSizeBytes) return { freedBytes: 0 };
|
|
250
|
+
return this.runSweep({ full: false });
|
|
251
|
+
}
|
|
252
|
+
/** Forces a full wipe (backs the public `clean()` API), still funneled through the same lock so it can't race another process's sweep. */
|
|
253
|
+
runFullSweep() {
|
|
254
|
+
return this.runSweep({ full: true });
|
|
255
|
+
}
|
|
256
|
+
async runSweep(options) {
|
|
257
|
+
let release = null;
|
|
258
|
+
try {
|
|
259
|
+
release = await lockfile.lock(this.lockTargetPath, {
|
|
260
|
+
retries: 0,
|
|
261
|
+
stale: 1e4
|
|
262
|
+
});
|
|
263
|
+
} catch (error) {
|
|
264
|
+
logDebug("EvictionCoordinator: lock held elsewhere, skipping this pass", { error: String(error) });
|
|
265
|
+
return { freedBytes: 0 };
|
|
266
|
+
}
|
|
267
|
+
try {
|
|
268
|
+
return await this.sweepFn(options);
|
|
269
|
+
} finally {
|
|
270
|
+
await release();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Cancels any pending debounced sweep. Call on shutdown so a timer doesn't fire after the owning cache has closed. */
|
|
274
|
+
cancelPending() {
|
|
275
|
+
if (this.debounceTimer) {
|
|
276
|
+
clearTimeout(this.debounceTimer);
|
|
277
|
+
this.debounceTimer = null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
const defaultFsPrimitives = {
|
|
282
|
+
existsSync,
|
|
283
|
+
readFile,
|
|
284
|
+
stat: (filePath) => stat(filePath),
|
|
285
|
+
unlink,
|
|
286
|
+
writeFileAtomic: (filePath, data) => realWriteFileAtomic(filePath, data)
|
|
287
|
+
};
|
|
288
|
+
/**
|
|
289
|
+
* Filesystem I/O for pixel cache entries.
|
|
290
|
+
*
|
|
291
|
+
* Replaces two things from the old monolithic RenderCache:
|
|
292
|
+
*
|
|
293
|
+
* 1. The hand-rolled tmp-file-then-rename with a copy+unlink fallback on
|
|
294
|
+
* cross-device rename (EXDEV). That fallback was NOT atomic -- a
|
|
295
|
+
* concurrent reader could observe a partially-copied file at the final
|
|
296
|
+
* path. `write-file-atomic` (maintained by the npm CLI team, who hit
|
|
297
|
+
* this exact problem with npm's own package cache) handles the
|
|
298
|
+
* cross-device case correctly.
|
|
299
|
+
*
|
|
300
|
+
* 2. The size-only integrity check on read (comparing `stat().size`
|
|
301
|
+
* against a DB column). A same-size bit-flip was silently served as
|
|
302
|
+
* valid. `Bun.hash()` gives us a cheap digest to verify content, not
|
|
303
|
+
* just length, on every read.
|
|
304
|
+
*
|
|
305
|
+
* This class knows nothing about SQLite or eviction policy -- it only
|
|
306
|
+
* knows how to put bytes on disk safely and get verified bytes back.
|
|
307
|
+
*/
|
|
308
|
+
var FsStore = class {
|
|
309
|
+
limiter;
|
|
310
|
+
fs;
|
|
311
|
+
constructor(limiter, fs = defaultFsPrimitives) {
|
|
312
|
+
this.limiter = limiter;
|
|
313
|
+
this.fs = fs;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Non-cryptographic content hash, used purely for corruption detection
|
|
317
|
+
* within a cache whose entries are always regenerable -- not for
|
|
318
|
+
* anything requiring cross-version stability or resistance to
|
|
319
|
+
* intentional tampering. Bun.hash()'s algorithm is not a documented,
|
|
320
|
+
* version-stable guarantee; if a future Bun upgrade changes it, the
|
|
321
|
+
* worst case is a one-time false "corrupted" verdict that costs a
|
|
322
|
+
* re-render, not data loss.
|
|
323
|
+
*/
|
|
324
|
+
computeHash(bytes) {
|
|
325
|
+
return Bun.hash(bytes).toString(16);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Writes `bytes` to `filePath` atomically: either the write is fully
|
|
329
|
+
* visible at `filePath` or `filePath` doesn't change at all. No caller
|
|
330
|
+
* ever observes a partial file at the final path, including across a
|
|
331
|
+
* cross-device fallback.
|
|
332
|
+
*/
|
|
333
|
+
async writeAtomic(filePath, bytes) {
|
|
334
|
+
const doWrite = () => this.fs.writeFileAtomic(filePath, bytes);
|
|
335
|
+
await (this.limiter ? this.limiter.run(doWrite) : doWrite());
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Reads `filePath` and verifies both size and content hash against the
|
|
339
|
+
* expected values before returning it. Returns `null` (and does NOT
|
|
340
|
+
* throw) on any mismatch or read failure -- callers are expected to
|
|
341
|
+
* treat `null` as "entry is gone/invalid, self-heal by forgetting it,"
|
|
342
|
+
* matching the old cache's missing-file behavior, now extended to
|
|
343
|
+
* cover content corruption too instead of only missing/truncated files.
|
|
344
|
+
*/
|
|
345
|
+
readVerified(filePath, expectedSizeBytes, expectedContentHash) {
|
|
346
|
+
const doRead = () => this.readVerifiedUnbounded(filePath, expectedSizeBytes, expectedContentHash);
|
|
347
|
+
return this.limiter ? this.limiter.run(doRead) : doRead();
|
|
348
|
+
}
|
|
349
|
+
async readVerifiedUnbounded(filePath, expectedSizeBytes, expectedContentHash) {
|
|
350
|
+
if (!this.fs.existsSync(filePath)) {
|
|
351
|
+
logDebug("FsStore: cached file missing on read", { filePath });
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
const fileStat = await this.fs.stat(filePath);
|
|
355
|
+
if (fileStat.size !== expectedSizeBytes) {
|
|
356
|
+
logWarn("FsStore: size mismatch on read, treating as corrupt", {
|
|
357
|
+
expected: expectedSizeBytes,
|
|
358
|
+
filePath,
|
|
359
|
+
onDisk: fileStat.size
|
|
360
|
+
});
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const bytes = await this.fs.readFile(filePath);
|
|
364
|
+
if (this.computeHash(bytes) !== expectedContentHash) {
|
|
365
|
+
logWarn("FsStore: content-hash mismatch on read, treating as corrupt", {
|
|
366
|
+
expected: expectedContentHash,
|
|
367
|
+
filePath
|
|
368
|
+
});
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
return bytes;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Removes a cache entry's file, if present. Returns the number of
|
|
375
|
+
* bytes actually freed. Tolerant of the file already being gone (a
|
|
376
|
+
* concurrent process may have removed it first) -- this is the TOCTOU
|
|
377
|
+
* gap from the old implementation, still possible here since checking
|
|
378
|
+
* existence and then deleting is inherently two steps, but now
|
|
379
|
+
* contained to one small, well-tested method instead of being
|
|
380
|
+
* duplicated across the eviction and clean code paths.
|
|
381
|
+
*/
|
|
382
|
+
async delete(filePath, knownSizeBytes) {
|
|
383
|
+
if (!this.fs.existsSync(filePath)) return 0;
|
|
384
|
+
let freed = knownSizeBytes ?? 0;
|
|
385
|
+
try {
|
|
386
|
+
if (knownSizeBytes === void 0) freed = (await this.fs.stat(filePath)).size;
|
|
387
|
+
await this.fs.unlink(filePath);
|
|
388
|
+
} catch (error) {
|
|
389
|
+
logWarn("FsStore: failed to delete cache entry file", {
|
|
390
|
+
error: String(error),
|
|
391
|
+
filePath
|
|
392
|
+
});
|
|
393
|
+
return freed;
|
|
394
|
+
}
|
|
395
|
+
return freed;
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
const BUSY_TIMEOUT_MS = 5e3;
|
|
399
|
+
const toRow = (r) => ({
|
|
400
|
+
contentHash: r.content_hash,
|
|
401
|
+
createdAt: r.created_at,
|
|
402
|
+
fileName: r.file_name,
|
|
403
|
+
hash: r.hash,
|
|
404
|
+
height: r.height,
|
|
405
|
+
lastAccessedAt: r.last_accessed_at,
|
|
406
|
+
sizeBytes: r.size_bytes,
|
|
407
|
+
width: r.width
|
|
408
|
+
});
|
|
409
|
+
/**
|
|
410
|
+
* SQLite-backed metadata store for pixel cache entries.
|
|
411
|
+
*
|
|
412
|
+
* Two things changed from the pre-split RenderCache:
|
|
413
|
+
*
|
|
414
|
+
* 1. Queries go through Kysely instead of hand-written SQL strings with
|
|
415
|
+
* positional parameter arrays (`db.run("...", [key, fileName, ...])`)
|
|
416
|
+
* -- a schema change that adds a column no longer risks a call site's
|
|
417
|
+
* array silently drifting out of sync; it's a compile error instead.
|
|
418
|
+
*
|
|
419
|
+
* 2. Total size/entry count are maintained by SQLite triggers into a
|
|
420
|
+
* single-row `cache_totals` table, updated in the same transaction as
|
|
421
|
+
* every insert/update/delete. Reading the current totals is an O(1)
|
|
422
|
+
* primary-key lookup instead of `SELECT SUM(size_bytes)` /
|
|
423
|
+
* `SELECT COUNT(*)` scanning the whole table on every write.
|
|
424
|
+
*/
|
|
425
|
+
var MetadataStore = class {
|
|
426
|
+
cacheDir;
|
|
427
|
+
dbPath;
|
|
428
|
+
rawDb;
|
|
429
|
+
db;
|
|
430
|
+
initialized = false;
|
|
431
|
+
constructor(cacheDir) {
|
|
432
|
+
this.cacheDir = cacheDir;
|
|
433
|
+
this.dbPath = cacheDir === ":memory:" ? ":memory:" : path.join(cacheDir, "cache.sqlite");
|
|
434
|
+
}
|
|
435
|
+
async init() {
|
|
436
|
+
if (this.initialized) return;
|
|
437
|
+
if (this.cacheDir !== ":memory:" && !existsSync(this.cacheDir)) await mkdir(this.cacheDir, { recursive: true });
|
|
438
|
+
const rawDb = new Database(this.dbPath, {
|
|
439
|
+
create: true,
|
|
440
|
+
readwrite: true
|
|
441
|
+
});
|
|
442
|
+
rawDb.run("PRAGMA journal_mode = WAL");
|
|
443
|
+
rawDb.run("PRAGMA synchronous = NORMAL");
|
|
444
|
+
rawDb.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
445
|
+
rawDb.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
446
|
+
let dbHealthy = true;
|
|
447
|
+
try {
|
|
448
|
+
const result = rawDb.query("PRAGMA integrity_check").get();
|
|
449
|
+
if (result?.integrity_check !== "ok") {
|
|
450
|
+
dbHealthy = false;
|
|
451
|
+
logWarn("SQLite integrity check failed, recreating cache database", { result: result?.integrity_check ?? "null" });
|
|
452
|
+
}
|
|
453
|
+
} catch {
|
|
454
|
+
dbHealthy = false;
|
|
455
|
+
logWarn("SQLite integrity check threw, recreating cache database");
|
|
456
|
+
}
|
|
457
|
+
if (dbHealthy) this.rawDb = rawDb;
|
|
458
|
+
else {
|
|
459
|
+
rawDb.close();
|
|
460
|
+
this.deleteDatabaseFiles();
|
|
461
|
+
const freshDb = new Database(this.dbPath, {
|
|
462
|
+
create: true,
|
|
463
|
+
readwrite: true
|
|
464
|
+
});
|
|
465
|
+
freshDb.run("PRAGMA journal_mode = WAL");
|
|
466
|
+
freshDb.run("PRAGMA synchronous = NORMAL");
|
|
467
|
+
freshDb.run(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
468
|
+
this.rawDb = freshDb;
|
|
469
|
+
}
|
|
470
|
+
this.db = new Kysely({ dialect: new BunSqliteDialect({ database: this.rawDb }) });
|
|
471
|
+
await this.createSchema();
|
|
472
|
+
this.initialized = true;
|
|
473
|
+
}
|
|
474
|
+
async createSchema() {
|
|
475
|
+
const db = this.requireDb();
|
|
476
|
+
await db.schema.createTable("pixel_cache_entries").ifNotExists().addColumn("hash", "text", (c) => c.primaryKey()).addColumn("file_name", "text", (c) => c.notNull()).addColumn("created_at", "integer", (c) => c.notNull()).addColumn("last_accessed_at", "integer", (c) => c.notNull()).addColumn("size_bytes", "integer", (c) => c.notNull()).addColumn("width", "integer", (c) => c.notNull()).addColumn("height", "integer", (c) => c.notNull()).addColumn("content_hash", "text", (c) => c.notNull()).execute();
|
|
477
|
+
await db.schema.createIndex("idx_last_accessed").ifNotExists().on("pixel_cache_entries").column("last_accessed_at").execute();
|
|
478
|
+
await db.schema.createTable("cache_totals").ifNotExists().addColumn("id", "integer", (c) => c.primaryKey()).addColumn("total_size_bytes", "integer", (c) => c.notNull().defaultTo(0)).addColumn("entry_count", "integer", (c) => c.notNull().defaultTo(0)).execute();
|
|
479
|
+
await sql`INSERT OR IGNORE INTO cache_totals (id, total_size_bytes, entry_count) VALUES (1, 0, 0)`.execute(db);
|
|
480
|
+
await sql`
|
|
481
|
+
CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_insert
|
|
482
|
+
AFTER INSERT ON pixel_cache_entries
|
|
483
|
+
BEGIN
|
|
484
|
+
UPDATE cache_totals
|
|
485
|
+
SET total_size_bytes = total_size_bytes + NEW.size_bytes,
|
|
486
|
+
entry_count = entry_count + 1
|
|
487
|
+
WHERE id = 1;
|
|
488
|
+
END;
|
|
489
|
+
`.execute(db);
|
|
490
|
+
await sql`
|
|
491
|
+
CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_delete
|
|
492
|
+
AFTER DELETE ON pixel_cache_entries
|
|
493
|
+
BEGIN
|
|
494
|
+
UPDATE cache_totals
|
|
495
|
+
SET total_size_bytes = total_size_bytes - OLD.size_bytes,
|
|
496
|
+
entry_count = entry_count - 1
|
|
497
|
+
WHERE id = 1;
|
|
498
|
+
END;
|
|
499
|
+
`.execute(db);
|
|
500
|
+
await sql`
|
|
501
|
+
CREATE TRIGGER IF NOT EXISTS trg_pixel_cache_update_size
|
|
502
|
+
AFTER UPDATE OF size_bytes ON pixel_cache_entries
|
|
503
|
+
WHEN OLD.size_bytes != NEW.size_bytes
|
|
504
|
+
BEGIN
|
|
505
|
+
UPDATE cache_totals
|
|
506
|
+
SET total_size_bytes = total_size_bytes - OLD.size_bytes + NEW.size_bytes
|
|
507
|
+
WHERE id = 1;
|
|
508
|
+
END;
|
|
509
|
+
`.execute(db);
|
|
510
|
+
}
|
|
511
|
+
close() {
|
|
512
|
+
if (this.rawDb) {
|
|
513
|
+
this.rawDb.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
514
|
+
this.rawDb.close();
|
|
515
|
+
}
|
|
516
|
+
this.initialized = false;
|
|
517
|
+
}
|
|
518
|
+
deleteDatabaseFiles() {
|
|
519
|
+
if (this.dbPath === ":memory:") return;
|
|
520
|
+
for (const suffix of [
|
|
521
|
+
"",
|
|
522
|
+
"-wal",
|
|
523
|
+
"-shm"
|
|
524
|
+
]) try {
|
|
525
|
+
unlinkSync(`${this.dbPath}${suffix}`);
|
|
526
|
+
} catch {}
|
|
527
|
+
}
|
|
528
|
+
/** Insert a new entry, or update it in place (same behavior as the old ON CONFLICT DO UPDATE) if the hash already exists -- e.g. a re-render at a different size for the same key. */
|
|
529
|
+
async upsertEntry(input) {
|
|
530
|
+
const db = this.requireDb();
|
|
531
|
+
const now = Date.now();
|
|
532
|
+
await db.insertInto("pixel_cache_entries").values({
|
|
533
|
+
content_hash: input.contentHash,
|
|
534
|
+
created_at: now,
|
|
535
|
+
file_name: input.fileName,
|
|
536
|
+
hash: input.hash,
|
|
537
|
+
height: input.height,
|
|
538
|
+
last_accessed_at: now,
|
|
539
|
+
size_bytes: input.sizeBytes,
|
|
540
|
+
width: input.width
|
|
541
|
+
}).onConflict((oc) => oc.column("hash").doUpdateSet({
|
|
542
|
+
content_hash: input.contentHash,
|
|
543
|
+
file_name: input.fileName,
|
|
544
|
+
height: input.height,
|
|
545
|
+
last_accessed_at: now,
|
|
546
|
+
size_bytes: input.sizeBytes,
|
|
547
|
+
width: input.width
|
|
548
|
+
})).execute();
|
|
549
|
+
}
|
|
550
|
+
async getEntry(hash) {
|
|
551
|
+
const row = await this.requireDb().selectFrom("pixel_cache_entries").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
552
|
+
return row ? toRow(row) : null;
|
|
553
|
+
}
|
|
554
|
+
async touchAccess(hash, timestamp = Date.now()) {
|
|
555
|
+
await this.requireDb().updateTable("pixel_cache_entries").set({ last_accessed_at: timestamp }).where("hash", "=", hash).execute();
|
|
556
|
+
}
|
|
557
|
+
async deleteEntry(hash) {
|
|
558
|
+
await this.requireDb().deleteFrom("pixel_cache_entries").where("hash", "=", hash).execute();
|
|
559
|
+
}
|
|
560
|
+
async deleteAll() {
|
|
561
|
+
await this.requireDb().deleteFrom("pixel_cache_entries").execute();
|
|
562
|
+
}
|
|
563
|
+
async listAllOrderedByLastAccessed() {
|
|
564
|
+
return (await this.requireDb().selectFrom("pixel_cache_entries").selectAll().orderBy("last_accessed_at", "asc").execute()).map(toRow);
|
|
565
|
+
}
|
|
566
|
+
/** O(1): reads the trigger-maintained single-row counter instead of scanning the table. */
|
|
567
|
+
async getTotals() {
|
|
568
|
+
const row = await this.requireDb().selectFrom("cache_totals").select(["total_size_bytes", "entry_count"]).where("id", "=", 1).executeTakeFirstOrThrow();
|
|
569
|
+
return {
|
|
570
|
+
entryCount: row.entry_count,
|
|
571
|
+
totalSizeBytes: row.total_size_bytes
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
requireDb() {
|
|
575
|
+
if (!this.db) throw new Error("MetadataStore.init() must be called before use.");
|
|
576
|
+
return this.db;
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
const DEFAULT_MAX_SIZE_MB = 500;
|
|
580
|
+
/**
|
|
581
|
+
* Content-addressed two-stage render cache -- coordinator.
|
|
582
|
+
*
|
|
583
|
+
* This class used to own SQLite calls, filesystem calls, atomicity
|
|
584
|
+
* handling, and eviction policy directly (~400 lines, five
|
|
585
|
+
* responsibilities). It now owns none of that: it constructs and wires
|
|
586
|
+
* together CompileCache, MetadataStore, FsStore, and EvictionCoordinator,
|
|
587
|
+
* and exposes the exact same public method signatures CLI, MCP, and
|
|
588
|
+
* dev-server already depend on. No downstream consumer needs to change
|
|
589
|
+
* anything as a result of this refactor.
|
|
590
|
+
*/
|
|
591
|
+
var RenderCache = class {
|
|
592
|
+
cacheDir;
|
|
593
|
+
maxSizeBytes;
|
|
594
|
+
compiled;
|
|
595
|
+
maxConcurrentWrites;
|
|
596
|
+
evictionDebounceMs;
|
|
597
|
+
metadata;
|
|
598
|
+
fsStore;
|
|
599
|
+
eviction;
|
|
600
|
+
initialized = false;
|
|
601
|
+
static createCompileCacheKey = createCompileCacheKey;
|
|
602
|
+
static createPixelCacheKey = createPixelCacheKey;
|
|
603
|
+
constructor(options) {
|
|
604
|
+
this.cacheDir = options?.cacheDir ?? ".studio-cache";
|
|
605
|
+
this.maxSizeBytes = (options?.maxSizeMB ?? DEFAULT_MAX_SIZE_MB) * 1024 * 1024;
|
|
606
|
+
this.compiled = new CompileCache(options?.compiledMaxEntries);
|
|
607
|
+
this.maxConcurrentWrites = options?.maxConcurrentWrites;
|
|
608
|
+
this.evictionDebounceMs = options?.evictionDebounceMs;
|
|
609
|
+
}
|
|
610
|
+
async init() {
|
|
611
|
+
if (this.initialized) return;
|
|
612
|
+
if (!existsSync(this.cacheDir)) await mkdir(this.cacheDir, { recursive: true });
|
|
613
|
+
this.metadata = new MetadataStore(this.cacheDir);
|
|
614
|
+
await this.metadata.init();
|
|
615
|
+
const limiter = new ConcurrencyLimiter(this.maxConcurrentWrites);
|
|
616
|
+
this.fsStore = new FsStore(limiter);
|
|
617
|
+
this.eviction = new EvictionCoordinator({
|
|
618
|
+
debounceMs: this.evictionDebounceMs,
|
|
619
|
+
getCurrentTotalBytes: async () => {
|
|
620
|
+
return (await this.requireMetadata().getTotals()).totalSizeBytes;
|
|
621
|
+
},
|
|
622
|
+
lockTargetPath: this.cacheDir,
|
|
623
|
+
maxSizeBytes: this.maxSizeBytes,
|
|
624
|
+
sweep: (options) => this.performSweep(options)
|
|
625
|
+
});
|
|
626
|
+
this.initialized = true;
|
|
627
|
+
}
|
|
628
|
+
close() {
|
|
629
|
+
this.eviction?.cancelPending();
|
|
630
|
+
this.metadata?.close();
|
|
631
|
+
this.initialized = false;
|
|
632
|
+
}
|
|
633
|
+
getCompiled(key) {
|
|
634
|
+
return this.compiled.get(key);
|
|
635
|
+
}
|
|
636
|
+
setCompiled(key, value) {
|
|
637
|
+
this.compiled.set(key, value);
|
|
638
|
+
}
|
|
639
|
+
setCompiledWithContentHash(key, contentHash, value) {
|
|
640
|
+
this.compiled.setWithContentHash(key, contentHash, value);
|
|
641
|
+
}
|
|
642
|
+
invalidateCompiledByContentHash(contentHash) {
|
|
643
|
+
this.compiled.invalidateByContentHash(contentHash);
|
|
644
|
+
}
|
|
645
|
+
clearCompiled() {
|
|
646
|
+
this.compiled.clear();
|
|
647
|
+
}
|
|
648
|
+
async getPixels(key) {
|
|
649
|
+
const metadata = this.requireMetadata();
|
|
650
|
+
const fsStore = this.requireFsStore();
|
|
651
|
+
let row;
|
|
652
|
+
try {
|
|
653
|
+
row = await metadata.getEntry(key);
|
|
654
|
+
} catch (error) {
|
|
655
|
+
logWarn("Pixel metadata read failed, attempting recovery", {
|
|
656
|
+
error: error instanceof Error ? error.message : String(error),
|
|
657
|
+
key
|
|
658
|
+
});
|
|
659
|
+
await this.recoverMetadata();
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
if (!row) return null;
|
|
663
|
+
const filePath = path.join(this.cacheDir, row.fileName);
|
|
664
|
+
const bytes = await fsStore.readVerified(filePath, row.sizeBytes, row.contentHash);
|
|
665
|
+
if (bytes === null) {
|
|
666
|
+
try {
|
|
667
|
+
await metadata.deleteEntry(key);
|
|
668
|
+
} catch {}
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
try {
|
|
672
|
+
await metadata.touchAccess(key);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
logWarn("Pixel touchAccess failed, attempting recovery", {
|
|
675
|
+
error: error instanceof Error ? error.message : String(error),
|
|
676
|
+
key
|
|
677
|
+
});
|
|
678
|
+
await this.recoverMetadata();
|
|
679
|
+
}
|
|
680
|
+
return bytes;
|
|
681
|
+
}
|
|
682
|
+
async setPixels(key, bytes, width, height, format = "png") {
|
|
683
|
+
const metadata = this.requireMetadata();
|
|
684
|
+
const fsStore = this.requireFsStore();
|
|
685
|
+
const fileName = `${key}.${format}`;
|
|
686
|
+
const filePath = path.join(this.cacheDir, fileName);
|
|
687
|
+
const contentHash = fsStore.computeHash(bytes);
|
|
688
|
+
await fsStore.writeAtomic(filePath, bytes);
|
|
689
|
+
await metadata.upsertEntry({
|
|
690
|
+
contentHash,
|
|
691
|
+
fileName,
|
|
692
|
+
hash: key,
|
|
693
|
+
height,
|
|
694
|
+
sizeBytes: bytes.length,
|
|
695
|
+
width
|
|
696
|
+
});
|
|
697
|
+
this.requireEviction().requestEviction();
|
|
698
|
+
return filePath;
|
|
699
|
+
}
|
|
700
|
+
async stats() {
|
|
701
|
+
const totals = await this.requireMetadata().getTotals();
|
|
702
|
+
return {
|
|
703
|
+
cacheDir: this.cacheDir,
|
|
704
|
+
compiled: { entries: this.compiled.size },
|
|
705
|
+
pixels: {
|
|
706
|
+
entries: totals.entryCount,
|
|
707
|
+
maxSizeBytes: this.maxSizeBytes,
|
|
708
|
+
sizeBytes: totals.totalSizeBytes
|
|
709
|
+
}
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
/** Full wipe -- unlike a budget-triggered sweep, always removes everything, still lock-coordinated. */
|
|
713
|
+
clean() {
|
|
714
|
+
this.compiled.clear();
|
|
715
|
+
return this.requireEviction().runFullSweep();
|
|
716
|
+
}
|
|
717
|
+
async performSweep(options) {
|
|
718
|
+
const metadata = this.requireMetadata();
|
|
719
|
+
const fsStore = this.requireFsStore();
|
|
720
|
+
if (options.full) {
|
|
721
|
+
const rows = await metadata.listAllOrderedByLastAccessed();
|
|
722
|
+
const freedAmounts = await Promise.all(rows.map((row) => fsStore.delete(path.join(this.cacheDir, row.fileName), row.sizeBytes)));
|
|
723
|
+
await metadata.deleteAll();
|
|
724
|
+
return { freedBytes: freedAmounts.reduce((sum, n) => sum + n, 0) };
|
|
725
|
+
}
|
|
726
|
+
const totals = await metadata.getTotals();
|
|
727
|
+
if (totals.totalSizeBytes <= this.maxSizeBytes) return { freedBytes: 0 };
|
|
728
|
+
const rows = await metadata.listAllOrderedByLastAccessed();
|
|
729
|
+
let remaining = totals.totalSizeBytes;
|
|
730
|
+
const rowsToEvict = [];
|
|
731
|
+
for (const row of rows) {
|
|
732
|
+
if (remaining <= this.maxSizeBytes) break;
|
|
733
|
+
rowsToEvict.push(row);
|
|
734
|
+
remaining -= row.sizeBytes;
|
|
735
|
+
}
|
|
736
|
+
return { freedBytes: (await Promise.all(rowsToEvict.map(async (row) => {
|
|
737
|
+
const freedForRow = await fsStore.delete(path.join(this.cacheDir, row.fileName), row.sizeBytes);
|
|
738
|
+
await metadata.deleteEntry(row.hash);
|
|
739
|
+
return freedForRow;
|
|
740
|
+
}))).reduce((sum, n) => sum + n, 0) };
|
|
741
|
+
}
|
|
742
|
+
requireMetadata() {
|
|
743
|
+
if (!this.metadata) throw new Error("RenderCache.init() must be called before use.");
|
|
744
|
+
return this.metadata;
|
|
745
|
+
}
|
|
746
|
+
async recoverMetadata() {
|
|
747
|
+
this.metadata?.close();
|
|
748
|
+
this.metadata = new MetadataStore(this.cacheDir);
|
|
749
|
+
await this.metadata.init();
|
|
750
|
+
}
|
|
751
|
+
requireFsStore() {
|
|
752
|
+
if (!this.fsStore) throw new Error("RenderCache.init() must be called before use.");
|
|
753
|
+
return this.fsStore;
|
|
754
|
+
}
|
|
755
|
+
requireEviction() {
|
|
756
|
+
if (!this.eviction) throw new Error("RenderCache.init() must be called before use.");
|
|
757
|
+
return this.eviction;
|
|
758
|
+
}
|
|
759
|
+
};
|
|
760
|
+
/**
|
|
761
|
+
* Type-only shim for `@takumi-rs/core` (as re-exported by `takumi-js/node`).
|
|
762
|
+
*
|
|
763
|
+
* Migrated for Takumi v2. The v1 shim existed to work around a broken
|
|
764
|
+
* `export.d.ts` re-export chain in @takumi-rs/core@1.8.6 that hid
|
|
765
|
+
* `render`/`measure` and several option types from consumers on
|
|
766
|
+
* `moduleResolution: "nodenext"`. This file re-declares the v2 surface we
|
|
767
|
+
* actually use, backed by the real runtime export.
|
|
768
|
+
*
|
|
769
|
+
* TODO(verify-on-upgrade): the v1 packaging bug this shim worked around
|
|
770
|
+
* has not been independently re-verified against the v2 release actually
|
|
771
|
+
* installed. Once upgraded, run the same isolated-probe check described
|
|
772
|
+
* in the v1 version of this file (import the types directly from
|
|
773
|
+
* "takumi-js/node" in a throwaway file and see if `render`/`measure`/
|
|
774
|
+
* `registerFont` resolve). If they do, delete this file and switch all
|
|
775
|
+
* importers back to `takumi-js/node` directly. Tracked against the
|
|
776
|
+
* upstream repo: https://github.com/kane50613/takumi
|
|
777
|
+
*
|
|
778
|
+
* v2 breaking changes reflected below (see /docs/upgrade/v2):
|
|
779
|
+
* - `new Renderer(options)` -> `new Renderer()`. The constructor takes no
|
|
780
|
+
* arguments; fonts and images are now per-render options instead of
|
|
781
|
+
* construction-time state.
|
|
782
|
+
* - `loadFont` / `loadFonts` / `loadFontSync` -> `registerFont`. Use this
|
|
783
|
+
* only to preload a font once and reuse it across many renders; most
|
|
784
|
+
* callers should just pass `fonts` on the render/measure call instead.
|
|
785
|
+
* - The persistent image store and `GlobalContext` are gone.
|
|
786
|
+
* `putPersistentImage` / `clearImageStore` no longer exist. Every image
|
|
787
|
+
* a render needs must be passed through `images`, keyed by `src`.
|
|
788
|
+
* - `fetchedResources` -> `images`. `images` also accepts a group form
|
|
789
|
+
* (`{ sources, fetch, fetchCache }`) that combines pre-fetched entries,
|
|
790
|
+
* a custom fetch implementation, and a shared byte cache.
|
|
791
|
+
* - `format` stays a string, but `quality` (jpeg / lossy webp) and
|
|
792
|
+
* `lossless` (webp) are now separate optional fields instead of one
|
|
793
|
+
* combined quality argument.
|
|
794
|
+
* - `fonts` entries may be bare URL strings, fetched and cached on demand,
|
|
795
|
+
* in addition to loaded font descriptors / raw bytes.
|
|
796
|
+
*/
|
|
797
|
+
const Renderer$1 = Renderer;
|
|
798
|
+
/**
|
|
799
|
+
* Renderer lifecycle -- v2.
|
|
800
|
+
*
|
|
801
|
+
* v1 kept a single Renderer instance and rebuilt it whenever
|
|
802
|
+
* font/persistent-image config changed, because fonts and images were
|
|
803
|
+
* construction-time state (`new Renderer({ fonts, persistentImages })`).
|
|
804
|
+
* That reconfigure-on-drift mechanism required every caller to pass the
|
|
805
|
+
* current config on every call so drift could be detected -- and in the
|
|
806
|
+
* shipped v1 code, `renderToPixels`/`measureTemplate` never actually did
|
|
807
|
+
* that (both called `getRenderer()` with no arguments), so config changes
|
|
808
|
+
* silently never took effect.
|
|
809
|
+
*
|
|
810
|
+
* v2 removes the problem at the source: `new Renderer()` takes no
|
|
811
|
+
* arguments at all. Fonts are now a per-render option
|
|
812
|
+
* (`render(node, { fonts })`), so there is nothing renderer-construction-
|
|
813
|
+
* time to drift out of sync with `studio.config.ts` in the first place.
|
|
814
|
+
* The singleton below exists purely to reuse the renderer's internal font
|
|
815
|
+
* cache/thread pool across calls, per Takumi's own performance guidance --
|
|
816
|
+
* it carries no configuration state to go stale.
|
|
817
|
+
*/
|
|
818
|
+
let activeRenderer = null;
|
|
819
|
+
const __getRendererForTesting = () => {
|
|
820
|
+
if (!activeRenderer) activeRenderer = new Renderer$1();
|
|
821
|
+
return activeRenderer;
|
|
822
|
+
};
|
|
823
|
+
/**
|
|
824
|
+
* Renders a compiled template to pixels.
|
|
825
|
+
*
|
|
826
|
+
* `width`/`height` are REQUIRED here, not optional pass-through fields.
|
|
827
|
+
* Takumi's `render()` returns only a Buffer -- it does not report back the
|
|
828
|
+
* dimensions it used, so auto-sized output isn't supported by this
|
|
829
|
+
* wrapper. Callers that need auto-sizing must call `measureTemplate` first
|
|
830
|
+
* and pass the resulting width/height through explicitly (see
|
|
831
|
+
* `renderAutoSized` below).
|
|
832
|
+
*
|
|
833
|
+
* Any `fonts`/`fontFamilies`/`images` the template needs must be supplied
|
|
834
|
+
* via `options` -- there is no construction-time renderer config to fall
|
|
835
|
+
* back on in v2.
|
|
836
|
+
*/
|
|
837
|
+
const renderToPixels = async (template, dimensions, options, signal) => {
|
|
838
|
+
const renderer = __getRendererForTesting();
|
|
839
|
+
logDebug("renderer.render start", {
|
|
840
|
+
fontFamilies: options?.fontFamilies,
|
|
841
|
+
fonts: options?.fonts?.filter((f) => typeof f === "object" && "name" in f).map((f) => ({
|
|
842
|
+
dataType: typeof f.data,
|
|
843
|
+
name: f.name
|
|
844
|
+
})),
|
|
845
|
+
fontsCount: options?.fonts?.length ?? 0,
|
|
846
|
+
lang: options?.lang
|
|
847
|
+
});
|
|
848
|
+
return {
|
|
849
|
+
bytes: await renderer.render(template.node, {
|
|
850
|
+
...options,
|
|
851
|
+
height: dimensions.height,
|
|
852
|
+
stylesheets: [...options?.stylesheets ?? [], ...template.stylesheets],
|
|
853
|
+
width: dimensions.width
|
|
854
|
+
}, signal),
|
|
855
|
+
format: options?.format ?? "png",
|
|
856
|
+
height: dimensions.height,
|
|
857
|
+
width: dimensions.width
|
|
858
|
+
};
|
|
859
|
+
};
|
|
860
|
+
const measureTemplate = (template, options, signal) => {
|
|
861
|
+
return __getRendererForTesting().measure(template.node, {
|
|
862
|
+
...options,
|
|
863
|
+
stylesheets: [...options?.stylesheets ?? [], ...template.stylesheets]
|
|
864
|
+
}, signal);
|
|
865
|
+
};
|
|
866
|
+
const mergeLocale = (props, localeData) => {
|
|
867
|
+
if (!localeData) return props;
|
|
868
|
+
const merged = { ...props };
|
|
869
|
+
for (const [key, value] of Object.entries(localeData)) if (Array.isArray(value) && Array.isArray(merged[key])) merged[key] = value;
|
|
870
|
+
else if (typeof value === "string" && typeof merged[key] === "string") merged[key] = value;
|
|
871
|
+
return merged;
|
|
872
|
+
};
|
|
873
|
+
/**
|
|
874
|
+
* Expands rows × locales × presets into a flat array of matrix cells.
|
|
875
|
+
* Locale data is merged into props during expansion so each cell carries
|
|
876
|
+
* fully resolved props.
|
|
877
|
+
*/
|
|
878
|
+
const expandMatrix = (input) => {
|
|
879
|
+
const { rows, locales, presets, baseProps = {} } = input;
|
|
880
|
+
if (rows.length === 0) return [];
|
|
881
|
+
const cells = [];
|
|
882
|
+
for (const [rowIndex, row] of rows.entries()) {
|
|
883
|
+
const rowKey = String(row.key ?? row.id ?? row.slug ?? String(rowIndex));
|
|
884
|
+
const rowProps = {
|
|
885
|
+
...baseProps,
|
|
886
|
+
...row
|
|
887
|
+
};
|
|
888
|
+
delete rowProps.key;
|
|
889
|
+
delete rowProps.id;
|
|
890
|
+
delete rowProps.slug;
|
|
891
|
+
for (const locale of locales) {
|
|
892
|
+
const localeProps = mergeLocale(rowProps, locale.data);
|
|
893
|
+
for (const preset of presets) cells.push({
|
|
894
|
+
locale: locale.id,
|
|
895
|
+
preset: {
|
|
896
|
+
height: preset.height,
|
|
897
|
+
id: preset.id,
|
|
898
|
+
width: preset.width
|
|
899
|
+
},
|
|
900
|
+
props: {
|
|
901
|
+
...localeProps,
|
|
902
|
+
locale: locale.id
|
|
903
|
+
},
|
|
904
|
+
rowIndex,
|
|
905
|
+
rowKey
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return cells;
|
|
910
|
+
};
|
|
911
|
+
const ensureDir = async (dir) => {
|
|
912
|
+
if (!existsSync(dir)) await mkdir(dir, { recursive: true });
|
|
913
|
+
};
|
|
914
|
+
const tryWriteFile = async (filePath, data) => {
|
|
915
|
+
try {
|
|
916
|
+
await writeFile(filePath, data);
|
|
917
|
+
} catch (error) {
|
|
918
|
+
logWarn("Failed to write output file", {
|
|
919
|
+
error: String(error),
|
|
920
|
+
filePath
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
const renderOne = async (input) => {
|
|
925
|
+
const start = performance.now();
|
|
926
|
+
const { compiledTemplate, contentHash, props, width, height, format = "png", locale, cache, outDir, filename, signal, renderOptions } = input;
|
|
927
|
+
const propsJSON = JSON.stringify(props);
|
|
928
|
+
const pixelKey = RenderCache.createPixelCacheKey({
|
|
929
|
+
format,
|
|
930
|
+
height,
|
|
931
|
+
propsJSON,
|
|
932
|
+
templateContentHash: contentHash,
|
|
933
|
+
width
|
|
934
|
+
});
|
|
935
|
+
const cached = await cache.getPixels(pixelKey);
|
|
936
|
+
if (cached) {
|
|
937
|
+
const outputPath = `${outDir}/${filename}`;
|
|
938
|
+
await ensureDir(outDir);
|
|
939
|
+
await tryWriteFile(outputPath, cached);
|
|
940
|
+
return {
|
|
941
|
+
cacheHit: true,
|
|
942
|
+
durationMs: performance.now() - start,
|
|
943
|
+
outputPath
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
const lang = locale?.startsWith("ar") ? "ar" : void 0;
|
|
947
|
+
logDebug("renderOne -> renderToPixels", {
|
|
948
|
+
fontFamilies: renderOptions?.fontFamilies,
|
|
949
|
+
fontNames: renderOptions?.fonts?.map((f) => typeof f === "object" && "name" in f ? f.name : "raw"),
|
|
950
|
+
fontsCount: renderOptions?.fonts?.length ?? 0,
|
|
951
|
+
lang,
|
|
952
|
+
locale
|
|
953
|
+
});
|
|
954
|
+
const output = await renderToPixels(compiledTemplate, {
|
|
955
|
+
height,
|
|
956
|
+
width
|
|
957
|
+
}, {
|
|
958
|
+
fontFamilies: renderOptions?.fontFamilies,
|
|
959
|
+
fonts: renderOptions?.fonts,
|
|
960
|
+
format,
|
|
961
|
+
lang
|
|
962
|
+
}, signal);
|
|
963
|
+
await cache.setPixels(pixelKey, output.bytes, width, height, format);
|
|
964
|
+
const outputPath = `${outDir}/${filename}`;
|
|
965
|
+
await ensureDir(outDir);
|
|
966
|
+
await tryWriteFile(outputPath, output.bytes);
|
|
967
|
+
return {
|
|
968
|
+
cacheHit: false,
|
|
969
|
+
durationMs: performance.now() - start,
|
|
970
|
+
outputPath
|
|
971
|
+
};
|
|
972
|
+
};
|
|
973
|
+
const generateOutputFilename = (options) => {
|
|
974
|
+
const { templateId, locale, presetId, rowKey, format = "png" } = options;
|
|
975
|
+
return `${templateId}.${locale}.${presetId}.${rowKey}.${format}`;
|
|
976
|
+
};
|
|
977
|
+
const writeManifest = async (filePath, manifest) => {
|
|
978
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
979
|
+
await writeFile(filePath, JSON.stringify(manifest, null, 2));
|
|
980
|
+
};
|
|
981
|
+
const loadConfig = async (rootDir) => {
|
|
982
|
+
const configPath = path.resolve(rootDir, "studio.config.ts");
|
|
983
|
+
try {
|
|
984
|
+
await readFile(configPath, "utf-8");
|
|
985
|
+
} catch {
|
|
986
|
+
throw new AppError(ErrorCode.CONFIG_NOT_FOUND, `No studio.config.ts found in ${rootDir}.`);
|
|
987
|
+
}
|
|
988
|
+
logDebug("Loading studio.config.ts", { path: configPath });
|
|
989
|
+
const mod = await import(pathToFileURL(configPath).href);
|
|
990
|
+
const raw = mod.default ?? mod;
|
|
991
|
+
const parsed = studioConfigSchema.safeParse(raw);
|
|
992
|
+
if (!parsed.success) {
|
|
993
|
+
const issues = parsed.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
994
|
+
throw new AppError(ErrorCode.CONFIG_INVALID, `studio.config.ts is invalid: ${issues}`);
|
|
995
|
+
}
|
|
996
|
+
return parsed.data;
|
|
997
|
+
};
|
|
998
|
+
const importTemplateForBatch = async (rootDir, componentPath, templateId) => {
|
|
999
|
+
const fullPath = path.resolve(rootDir, `${componentPath}.tsx`);
|
|
1000
|
+
let content;
|
|
1001
|
+
try {
|
|
1002
|
+
content = await readFile(fullPath, "utf-8");
|
|
1003
|
+
} catch {
|
|
1004
|
+
throw new AppError(ErrorCode.TEMPLATE_LOAD_FAILED, `Template file not found: ${fullPath}`, {
|
|
1005
|
+
componentPath,
|
|
1006
|
+
templateId
|
|
1007
|
+
});
|
|
1008
|
+
}
|
|
1009
|
+
const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
1010
|
+
logDebug("Loading template", {
|
|
1011
|
+
path: fullPath,
|
|
1012
|
+
templateId
|
|
1013
|
+
});
|
|
1014
|
+
const mod = await import(pathToFileURL(fullPath).href);
|
|
1015
|
+
if (typeof mod.default !== "function") throw new AppError(ErrorCode.TEMPLATE_INVALID_EXPORT, `Template "${templateId}" must have a default export that is a component function.`, { templateId });
|
|
1016
|
+
const { propsSchema } = mod;
|
|
1017
|
+
if (!isZodObject(propsSchema)) throw new AppError(ErrorCode.TEMPLATE_INVALID_EXPORT, `Template "${templateId}" must export a "propsSchema" that is a z.object({...}).`, { templateId });
|
|
1018
|
+
return {
|
|
1019
|
+
contentHash,
|
|
1020
|
+
module: mod
|
|
1021
|
+
};
|
|
1022
|
+
};
|
|
1023
|
+
const parseDataFile = async (filePath) => {
|
|
1024
|
+
const content = await readFile(filePath, "utf-8");
|
|
1025
|
+
if (filePath.endsWith(".json")) {
|
|
1026
|
+
const data = JSON.parse(content);
|
|
1027
|
+
if (Array.isArray(data)) return data;
|
|
1028
|
+
throw new AppError(ErrorCode.API_ERROR, "JSON data file must contain an array of objects.");
|
|
1029
|
+
}
|
|
1030
|
+
if (filePath.endsWith(".csv")) {
|
|
1031
|
+
const { parse } = await import("csv-parse/sync");
|
|
1032
|
+
return parse(content, {
|
|
1033
|
+
columns: true,
|
|
1034
|
+
skip_empty_lines: true,
|
|
1035
|
+
trim: true
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
throw new AppError(ErrorCode.API_ERROR, "Data file must be .csv or .json.");
|
|
1039
|
+
};
|
|
1040
|
+
const resolveLocales = async (rootDir, localeCodes, templateId) => {
|
|
1041
|
+
if (localeCodes.length === 0) return [{ id: "default" }];
|
|
1042
|
+
return await Promise.all(localeCodes.map(async (code) => {
|
|
1043
|
+
const localePath = path.resolve(rootDir, `locales/${code}.json`);
|
|
1044
|
+
try {
|
|
1045
|
+
const content = await readFile(localePath, "utf-8");
|
|
1046
|
+
return {
|
|
1047
|
+
data: JSON.parse(content)[templateId],
|
|
1048
|
+
id: code
|
|
1049
|
+
};
|
|
1050
|
+
} catch {
|
|
1051
|
+
return { id: code };
|
|
1052
|
+
}
|
|
1053
|
+
}));
|
|
1054
|
+
};
|
|
1055
|
+
const isFontDescriptor = (f) => typeof f === "object" && !(f instanceof Uint8Array);
|
|
1056
|
+
const resolveArabicFonts = async (_locales) => {
|
|
1057
|
+
let fonts = [];
|
|
1058
|
+
let fontFamilies = [];
|
|
1059
|
+
try {
|
|
1060
|
+
fonts = await googleFonts([{
|
|
1061
|
+
name: "Noto Sans Arabic",
|
|
1062
|
+
weight: [400, 700]
|
|
1063
|
+
}]);
|
|
1064
|
+
fontFamilies = fonts.filter(isFontDescriptor).map((f) => String(f.name ?? "")).filter(Boolean);
|
|
1065
|
+
logDebug("googleFonts resolved", {
|
|
1066
|
+
count: fonts.length,
|
|
1067
|
+
hasData: fonts.filter(isFontDescriptor).map((f) => typeof f.data),
|
|
1068
|
+
names: fonts.filter(isFontDescriptor).map((f) => f.name)
|
|
1069
|
+
});
|
|
1070
|
+
} catch (error) {
|
|
1071
|
+
logDebug("googleFonts failed, using direct URL fallback", { error: String(error) });
|
|
1072
|
+
fonts = [{
|
|
1073
|
+
data: /* @__PURE__ */ new Uint8Array(0),
|
|
1074
|
+
name: "Noto Sans Arabic",
|
|
1075
|
+
weight: 400
|
|
1076
|
+
}];
|
|
1077
|
+
fontFamilies = ["Noto Sans Arabic"];
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
fontFamilies,
|
|
1081
|
+
fonts
|
|
1082
|
+
};
|
|
1083
|
+
};
|
|
1084
|
+
const runBatch = async (input) => {
|
|
1085
|
+
const start = performance.now();
|
|
1086
|
+
const { baseProps = {}, dataFile, failFast = false, cacheDir: cacheDirOverride, locales: filterLocales, outDir: outDirOverride, presets: filterPresets, rootDir, rows: inlineRows, templateId } = input;
|
|
1087
|
+
const config = await loadConfig(rootDir);
|
|
1088
|
+
const entry = config.templates[templateId];
|
|
1089
|
+
if (!entry) throw new Error(`Template not found: ${templateId}`);
|
|
1090
|
+
const { contentHash, module } = await importTemplateForBatch(rootDir, entry.componentPath, templateId);
|
|
1091
|
+
const Component = module.default;
|
|
1092
|
+
const presets = (filterPresets ?? config.presets.map((p) => p.id)).map((id) => {
|
|
1093
|
+
const p = config.presets.find((preset) => preset.id === id);
|
|
1094
|
+
if (!p) throw new Error(`Preset not found: ${id}`);
|
|
1095
|
+
return {
|
|
1096
|
+
height: p.height,
|
|
1097
|
+
id: p.id,
|
|
1098
|
+
width: p.width
|
|
1099
|
+
};
|
|
1100
|
+
});
|
|
1101
|
+
const locales = await resolveLocales(rootDir, filterLocales ?? config.locales ?? [], templateId);
|
|
1102
|
+
const cells = expandMatrix({
|
|
1103
|
+
baseProps,
|
|
1104
|
+
locales,
|
|
1105
|
+
presets,
|
|
1106
|
+
rows: dataFile ? await parseDataFile(path.resolve(rootDir, dataFile)) : inlineRows ?? []
|
|
1107
|
+
});
|
|
1108
|
+
const outDir = path.resolve(rootDir, outDirOverride ?? config.outDir ?? "./out");
|
|
1109
|
+
const cache = new RenderCache({ cacheDir: cacheDirOverride ?? path.resolve(rootDir, ".studio-cache") });
|
|
1110
|
+
await cache.init();
|
|
1111
|
+
const arabicLocales = locales.filter((l) => l.id.startsWith("ar"));
|
|
1112
|
+
logDebug("Arabic locale detection", {
|
|
1113
|
+
arabicCount: arabicLocales.length,
|
|
1114
|
+
localeIds: locales.map((l) => l.id)
|
|
1115
|
+
});
|
|
1116
|
+
let fonts = [];
|
|
1117
|
+
let fontFamilies;
|
|
1118
|
+
if (arabicLocales.length > 0) {
|
|
1119
|
+
const { fonts: resolvedFonts, fontFamilies: resolvedFamilies } = await resolveArabicFonts(locales);
|
|
1120
|
+
fonts = resolvedFonts;
|
|
1121
|
+
fontFamilies = resolvedFamilies;
|
|
1122
|
+
}
|
|
1123
|
+
const succeeded = [];
|
|
1124
|
+
const failed = [];
|
|
1125
|
+
const renderOptions = fonts.length > 0 ? {
|
|
1126
|
+
fontFamilies,
|
|
1127
|
+
fonts
|
|
1128
|
+
} : void 0;
|
|
1129
|
+
logDebug("renderOptions before loop", {
|
|
1130
|
+
fontFamilies,
|
|
1131
|
+
fontsCount: fonts.length,
|
|
1132
|
+
hasOptions: renderOptions !== void 0
|
|
1133
|
+
});
|
|
1134
|
+
try {
|
|
1135
|
+
for (const cell of cells) try {
|
|
1136
|
+
const result = await renderOne({
|
|
1137
|
+
cache,
|
|
1138
|
+
compiledTemplate: await compileTemplate(createElement(Component, cell.props)),
|
|
1139
|
+
contentHash,
|
|
1140
|
+
filename: generateOutputFilename({
|
|
1141
|
+
locale: cell.locale,
|
|
1142
|
+
presetId: cell.preset.id,
|
|
1143
|
+
rowKey: cell.rowKey,
|
|
1144
|
+
templateId
|
|
1145
|
+
}),
|
|
1146
|
+
height: cell.preset.height,
|
|
1147
|
+
locale: cell.locale,
|
|
1148
|
+
outDir,
|
|
1149
|
+
props: cell.props,
|
|
1150
|
+
renderOptions,
|
|
1151
|
+
templateId,
|
|
1152
|
+
width: cell.preset.width
|
|
1153
|
+
});
|
|
1154
|
+
succeeded.push({
|
|
1155
|
+
cacheHit: result.cacheHit,
|
|
1156
|
+
locale: cell.locale,
|
|
1157
|
+
outputPath: result.outputPath,
|
|
1158
|
+
preset: cell.preset.id,
|
|
1159
|
+
rowIndex: cell.rowIndex
|
|
1160
|
+
});
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
failed.push({
|
|
1163
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1164
|
+
locale: cell.locale,
|
|
1165
|
+
preset: cell.preset.id,
|
|
1166
|
+
rowIndex: cell.rowIndex
|
|
1167
|
+
});
|
|
1168
|
+
if (failFast) throw error;
|
|
1169
|
+
}
|
|
1170
|
+
} finally {
|
|
1171
|
+
cache.close();
|
|
1172
|
+
}
|
|
1173
|
+
const manifest = {
|
|
1174
|
+
completedCount: cells.length,
|
|
1175
|
+
failed,
|
|
1176
|
+
status: failFast && failed.length > 0 ? "aborted" : "completed",
|
|
1177
|
+
succeeded,
|
|
1178
|
+
totalCount: cells.length
|
|
1179
|
+
};
|
|
1180
|
+
await writeManifest(`${outDir}/manifest.json`, manifest);
|
|
1181
|
+
return {
|
|
1182
|
+
elapsedMs: performance.now() - start,
|
|
1183
|
+
manifest
|
|
1184
|
+
};
|
|
1185
|
+
};
|
|
1186
|
+
//#endregion
|
|
1187
|
+
export { importTemplateForBatch as a, logWarn as c, runBatch as d, studioConfigSchema as f, compileTemplate as i, measureTemplate as l, ErrorCode as n, loadConfig as o, RenderCache as r, logError as s, AppError as t, renderToPixels as u };
|