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