@hyperframes/studio-server 0.8.82 → 0.8.84

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.
Files changed (36) hide show
  1. package/dist/{chunk-G6IIFKXY.js → chunk-2YEGCDJW.js} +39 -20
  2. package/dist/chunk-2YEGCDJW.js.map +1 -0
  3. package/dist/{chunk-TJQ76IEA.js → chunk-I3DRHG52.js} +23 -71
  4. package/dist/chunk-I3DRHG52.js.map +1 -0
  5. package/dist/chunk-IG4DRZI2.js +1571 -0
  6. package/dist/chunk-IG4DRZI2.js.map +1 -0
  7. package/dist/chunk-PMXUKOC3.js +22 -0
  8. package/dist/chunk-PMXUKOC3.js.map +1 -0
  9. package/dist/chunk-TTWLTYSQ.js +79 -0
  10. package/dist/chunk-TTWLTYSQ.js.map +1 -0
  11. package/dist/chunk-WUHW3MWG.js +100 -0
  12. package/dist/chunk-WUHW3MWG.js.map +1 -0
  13. package/dist/{chunk-FTDTO3QP.js → chunk-YUPEBATT.js} +3 -3
  14. package/dist/{chunk-FTDTO3QP.js.map → chunk-YUPEBATT.js.map} +1 -1
  15. package/dist/helpers/hfIdPersist.d.ts +23 -0
  16. package/dist/helpers/hfIdPersist.js +13 -0
  17. package/dist/helpers/hfIdPersist.js.map +1 -0
  18. package/dist/helpers/mediaCodecMap.js +2 -1
  19. package/dist/helpers/mediaProxyPreview.d.ts +2 -1
  20. package/dist/helpers/mediaProxyPreview.js +4 -3
  21. package/dist/helpers/proxyTranscoder.d.ts +5 -2
  22. package/dist/helpers/proxyTranscoder.js +7 -2
  23. package/dist/helpers/safePath.d.ts +22 -0
  24. package/dist/helpers/safePath.js +27 -0
  25. package/dist/helpers/safePath.js.map +1 -0
  26. package/dist/history/index.d.ts +49 -0
  27. package/dist/history/index.js +29 -0
  28. package/dist/history/index.js.map +1 -0
  29. package/dist/index.d.ts +12 -54
  30. package/dist/index.js +357 -1915
  31. package/dist/index.js.map +1 -1
  32. package/dist/{mediaProxyPreview-BMHd9G3N.d.ts → mediaProxyPreview-CLu9FdIr.d.ts} +2 -139
  33. package/dist/projectHistory-Cm8Mdmmv.d.ts +139 -0
  34. package/package.json +15 -3
  35. package/dist/chunk-G6IIFKXY.js.map +0 -1
  36. package/dist/chunk-TJQ76IEA.js.map +0 -1
package/dist/index.js CHANGED
@@ -14,6 +14,39 @@ import {
14
14
  unwrapElementsFromHtml,
15
15
  wrapElementsInHtml
16
16
  } from "./chunk-W6TFBTP2.js";
17
+ import {
18
+ isCompositionSource,
19
+ stampProjectHfIds
20
+ } from "./chunk-WUHW3MWG.js";
21
+ import {
22
+ DEFAULT_HISTORY_ROOT,
23
+ DELETED_VERSION,
24
+ HistoryBusyError,
25
+ HistoryClosedError,
26
+ HistoryIdError,
27
+ MAX_WINDOW_IDLE_MS,
28
+ START,
29
+ STUDIO_SIGNATURE_MANIFEST_PATHS,
30
+ UNDO_MODES,
31
+ affectsProjectSignature,
32
+ clearFileWriteReceipt,
33
+ consumeFileWriteReceipt,
34
+ createProjectSignature,
35
+ createWriteToken,
36
+ fileContentVersion,
37
+ historyCache,
38
+ identifyFileWrite,
39
+ listProjectHistories,
40
+ openProjectHistory,
41
+ pruneGoneProjectHistories,
42
+ recordFileWriteReceipt,
43
+ resolveProjectAndSignature,
44
+ resolveProjectSignature,
45
+ settledFileTag
46
+ } from "./chunk-IG4DRZI2.js";
47
+ import {
48
+ replaceFileAtomically
49
+ } from "./chunk-PMXUKOC3.js";
17
50
  import {
18
51
  getElementScreenshotClip
19
52
  } from "./chunk-W2SBTCO2.js";
@@ -22,28 +55,37 @@ import {
22
55
  isAutoProxyEnabled,
23
56
  proxyEtagSalt,
24
57
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-FTDTO3QP.js";
58
+ } from "./chunk-YUPEBATT.js";
26
59
  import {
60
+ PROXY_PENDING_RETRY_AFTER_SECONDS,
27
61
  ProxyCapacityError,
28
62
  ProxyTranscodeError,
29
- resolveProxy
30
- } from "./chunk-G6IIFKXY.js";
63
+ ProxyWaitTimeoutError,
64
+ proxyActivityMark,
65
+ resolveProxy,
66
+ waitForProxy
67
+ } from "./chunk-2YEGCDJW.js";
31
68
  import {
32
69
  PROXY_VARIANT_CONFIG,
33
70
  decideMediaProxyEligibility,
34
- isInHiddenOrVendorDir,
35
71
  isProxyVariantRequest,
36
- isSafePath,
37
- pinWithinProject,
38
72
  probeAssetCodec,
39
73
  probeMediaMetadata,
74
+ recordProxyRequest,
75
+ resolveProxyVariantRequest
76
+ } from "./chunk-I3DRHG52.js";
77
+ import {
78
+ folderGone,
79
+ isInHiddenOrVendorDir,
80
+ isProjectRootMissing,
81
+ isSafePath,
82
+ mkdirWithinProject,
83
+ pinWithinProject,
40
84
  realFilePath,
41
85
  realpath,
42
- recordProxyRequest,
43
- resolveProxyVariantRequest,
44
86
  resolveWithinProject,
45
87
  walkDir
46
- } from "./chunk-TJQ76IEA.js";
88
+ } from "./chunk-TTWLTYSQ.js";
47
89
  import {
48
90
  STUDIO_MANUAL_EDITS_PATH,
49
91
  createStudioManualEditsRenderBodyScript,
@@ -59,308 +101,13 @@ import { Hono } from "hono";
59
101
 
60
102
  // src/routes/projects.ts
61
103
  import { readFile } from "fs/promises";
62
- import { join as join2 } from "path";
63
-
64
- // src/helpers/hfIdPersist.ts
65
- import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
66
- import { createHash } from "crypto";
67
- import {
68
- closeSync,
69
- constants,
70
- fstatSync,
71
- mkdirSync,
72
- openSync,
73
- readFileSync,
74
- writeFileSync
75
- } from "fs";
76
- import { dirname, join } from "path";
77
-
78
- // src/helpers/atomicFile.ts
79
- import * as fs from "fs";
80
- import { randomUUID } from "crypto";
81
- function replaceFileAtomically(filePath, content, mode, operations = fs) {
82
- const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
83
- try {
84
- operations.writeFileSync(tempPath, content, { encoding: "utf-8", mode });
85
- operations.chmodSync(tempPath, mode);
86
- operations.renameSync(tempPath, filePath);
87
- } catch (error) {
88
- try {
89
- operations.unlinkSync(tempPath);
90
- } catch {
91
- }
92
- throw error;
93
- }
94
- }
95
-
96
- // src/helpers/hfIdPersist.ts
97
- var isCompositionSource = (html) => /data-composition-id\s*=/.test(html);
98
- var STAMP_RECORD = join(".hyperframes", "hf-ids-stamped.json");
99
- var contentHash = (text2) => createHash("sha1").update(text2).digest("base64url");
100
- function stampProjectHfIds(projectDir) {
101
- const recordPath = join(projectDir, STAMP_RECORD);
102
- let last = {};
103
- try {
104
- last = JSON.parse(readFileSync(recordPath, "utf-8"));
105
- } catch {
106
- }
107
- const next = {};
108
- for (const file of walkDir(projectDir)) {
109
- if (!file.endsWith(".html") || isInHiddenOrVendorDir(file)) continue;
110
- const absPath = join(projectDir, file);
111
- let html;
112
- try {
113
- html = readFileSync(absPath, "utf-8");
114
- } catch {
115
- continue;
116
- }
117
- let hash = contentHash(html);
118
- if (last[file] !== hash && isCompositionSource(html)) {
119
- const stamped = stampFileHfIds(absPath);
120
- if (stamped === null) continue;
121
- hash = contentHash(stamped);
122
- }
123
- next[file] = hash;
124
- }
125
- try {
126
- mkdirSync(dirname(recordPath), { recursive: true });
127
- writeFileSync(recordPath, JSON.stringify(next));
128
- } catch {
129
- }
130
- }
131
- function openNoFollow(filePath, flags) {
132
- const noFollow = constants.O_NOFOLLOW ?? 0;
133
- try {
134
- return openSync(filePath, flags | noFollow);
135
- } catch {
136
- return null;
137
- }
138
- }
139
- function isUnchanged(filePath, expected) {
140
- const fd = openNoFollow(filePath, constants.O_RDONLY);
141
- if (fd === null) return false;
142
- try {
143
- return readFileSync(fd, "utf-8") === expected;
144
- } finally {
145
- closeSync(fd);
146
- }
147
- }
148
- function stampFileHfIds(filePath) {
149
- let fd = openNoFollow(filePath, constants.O_RDWR);
150
- let writable = true;
151
- if (fd === null) {
152
- fd = openNoFollow(filePath, constants.O_RDONLY);
153
- writable = false;
154
- }
155
- if (fd === null) return null;
156
- try {
157
- if (!fstatSync(fd).isFile()) return null;
158
- const html = readFileSync(fd, "utf-8");
159
- const normalized = ensureHfIds(html);
160
- const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
161
- const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
162
- if (writable && idsAfter > idsBefore) {
163
- const mode = fstatSync(fd).mode;
164
- closeSync(fd);
165
- fd = null;
166
- if (isUnchanged(filePath, html)) replaceFileAtomically(filePath, normalized, mode);
167
- }
168
- return normalized;
169
- } catch (err) {
170
- console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
171
- return null;
172
- } finally {
173
- if (fd !== null) closeSync(fd);
174
- }
175
- }
176
-
177
- // src/helpers/projectSignature.ts
178
- import { createHash as createHash2 } from "crypto";
179
- import { lstatSync, readFileSync as readFileSync2, readdirSync } from "fs";
180
- import { extname, isAbsolute, relative, resolve, sep } from "path";
181
- var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
182
- ".cjs",
183
- ".css",
184
- ".html",
185
- ".js",
186
- ".json",
187
- ".jsx",
188
- ".mjs",
189
- ".svg",
190
- ".ts",
191
- ".tsx"
192
- ]);
193
- var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
194
- ".cache",
195
- ".git",
196
- ".hyperframes",
197
- ".next",
198
- ".thumbnails",
199
- ".transcode-cache",
200
- ".vite",
201
- ".waveform-cache",
202
- "build",
203
- "coverage",
204
- "dist",
205
- "node_modules",
206
- "outputs",
207
- "renders"
208
- ]);
209
- var MAX_SIGNATURE_TEXT_BYTES = 2e6;
210
- var STUDIO_SIGNATURE_MANIFEST_PATHS = [
211
- ".hyperframes/studio-manual-edits.json",
212
- ".hyperframes/studio-motion.json"
213
- ];
214
- function affectsProjectSignature(projectDir, changedPath) {
215
- const relativePath = relative(resolve(projectDir), resolve(changedPath));
216
- if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
217
- return false;
218
- }
219
- const segments = relativePath.split(sep);
220
- if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
221
- return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
222
- }
223
- var projectSignatureCache = /* @__PURE__ */ new Map();
224
- function isPathWithin(parentDir, childPath) {
225
- const childRelativePath = relative(parentDir, childPath);
226
- return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
227
- }
228
- function isTextContentEligible(file, size) {
229
- return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
230
- }
231
- function collectProjectSignatureFiles(projectDir, dir, files) {
232
- let entries;
233
- try {
234
- entries = readdirSync(dir).sort();
235
- } catch {
236
- return;
237
- }
238
- for (const entry of entries) {
239
- if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
240
- const file = resolve(dir, entry);
241
- if (!isPathWithin(projectDir, file)) continue;
242
- let stat3;
243
- try {
244
- stat3 = lstatSync(file);
245
- } catch {
246
- continue;
247
- }
248
- if (stat3.isSymbolicLink()) continue;
249
- if (stat3.isDirectory()) {
250
- collectProjectSignatureFiles(projectDir, file, files);
251
- } else if (stat3.isFile()) {
252
- files.push({
253
- file,
254
- mtimeMs: stat3.mtimeMs,
255
- ctimeMs: stat3.ctimeMs,
256
- size: stat3.size,
257
- textContentEligible: isTextContentEligible(file, stat3.size)
258
- });
259
- }
260
- }
261
- }
262
- function collectProjectSignatureManifestFiles(projectDir, files) {
263
- const seen = new Set(files.map((entry) => entry.file));
264
- for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
265
- const file = resolve(projectDir, manifestPath);
266
- if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
267
- let stat3;
268
- try {
269
- stat3 = lstatSync(file);
270
- } catch {
271
- continue;
272
- }
273
- if (stat3.isSymbolicLink() || !stat3.isFile()) continue;
274
- files.push({
275
- file,
276
- mtimeMs: stat3.mtimeMs,
277
- ctimeMs: stat3.ctimeMs,
278
- size: stat3.size,
279
- textContentEligible: isTextContentEligible(file, stat3.size)
280
- });
281
- seen.add(file);
282
- }
283
- }
284
- function createProjectFingerprint(projectDir, files) {
285
- const hash = createHash2("sha256");
286
- for (const entry of files) {
287
- hash.update(relative(projectDir, entry.file));
288
- hash.update("\0");
289
- hash.update(String(entry.size));
290
- hash.update("\0");
291
- hash.update(String(entry.mtimeMs));
292
- hash.update("\0");
293
- hash.update(String(entry.ctimeMs));
294
- hash.update("\0");
295
- hash.update(entry.textContentEligible ? "text" : "binary");
296
- hash.update("\0");
297
- }
298
- return hash.digest("hex").slice(0, 24);
299
- }
300
- function collectProjectFiles(normalizedProjectDir) {
301
- const collected = [];
302
- collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
303
- collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
304
- return collected;
305
- }
306
- function listProjectFiles(projectDir) {
307
- const normalizedProjectDir = resolve(projectDir);
308
- return collectProjectFiles(normalizedProjectDir).map((entry) => ({
309
- path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
310
- size: entry.size,
311
- mtimeMs: entry.mtimeMs,
312
- ctimeMs: entry.ctimeMs
313
- }));
314
- }
315
- function resolveProjectSignature(adapter, projectDir) {
316
- return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
317
- }
318
- async function resolveProjectAndSignature(adapter, projectId) {
319
- const project = await adapter.resolveProject(projectId);
320
- if (!project) return null;
321
- return { project, signature: resolveProjectSignature(adapter, project.dir) };
322
- }
323
- function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
324
- const normalizedProjectDir = resolve(projectDir);
325
- const collected = collectProjectFiles(normalizedProjectDir);
326
- const files = collected.filter(
327
- (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
328
- );
329
- files.sort((a, b) => a.file.localeCompare(b.file));
330
- const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
331
- const cacheKey = excluding.size ? `${normalizedProjectDir}\0${createHash2("sha256").update([...excluding].sort().join("\0")).digest("hex")}` : normalizedProjectDir;
332
- const cached = projectSignatureCache.get(cacheKey);
333
- if (cached?.fingerprint === fingerprint) return cached.signature;
334
- const hash = createHash2("sha256");
335
- for (const entry of files) {
336
- const relativePath = relative(normalizedProjectDir, entry.file);
337
- hash.update(relativePath);
338
- hash.update("\0");
339
- hash.update(String(entry.size));
340
- hash.update("\0");
341
- if (entry.textContentEligible) {
342
- try {
343
- hash.update(readFileSync2(entry.file));
344
- } catch {
345
- hash.update(String(entry.mtimeMs));
346
- }
347
- } else {
348
- hash.update(String(entry.mtimeMs));
349
- }
350
- hash.update("\0");
351
- }
352
- const signature = hash.digest("hex").slice(0, 24);
353
- projectSignatureCache.set(cacheKey, { fingerprint, signature });
354
- return signature;
355
- }
356
-
357
- // src/routes/projects.ts
104
+ import { join } from "path";
358
105
  async function filterCompositionFiles(projectDir, files) {
359
106
  const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
360
107
  const checks = await Promise.all(
361
108
  htmlFiles.map(async (f) => {
362
109
  try {
363
- const content = await readFile(join2(projectDir, f), "utf-8");
110
+ const content = await readFile(join(projectDir, f), "utf-8");
364
111
  return isCompositionSource(content);
365
112
  } catch {
366
113
  return false;
@@ -400,23 +147,22 @@ function registerProjectRoutes(api, adapter) {
400
147
  // src/routes/files.ts
401
148
  import { bodyLimit } from "hono/body-limit";
402
149
  import {
403
- closeSync as closeSync2,
150
+ closeSync,
404
151
  existsSync as existsSync3,
405
- lstatSync as lstatSync3,
406
- openSync as openSync2,
407
- readFileSync as readFileSync6,
152
+ lstatSync as lstatSync2,
153
+ openSync,
154
+ readFileSync as readFileSync4,
408
155
  readlinkSync,
409
- writeFileSync as writeFileSync5,
156
+ writeFileSync as writeFileSync4,
410
157
  writeSync,
411
- mkdirSync as mkdirSync4,
412
158
  unlinkSync as unlinkSync2,
413
159
  rmSync as rmSync3,
414
160
  statSync as statSync3,
415
- fstatSync as fstatSync2,
161
+ fstatSync,
416
162
  renameSync as renameSync2,
417
- readdirSync as readdirSync3
163
+ readdirSync as readdirSync2
418
164
  } from "fs";
419
- import { resolve as resolve3, dirname as dirname4, join as join6 } from "path";
165
+ import { resolve as resolve2, dirname as dirname3, join as join5 } from "path";
420
166
 
421
167
  // src/helpers/mime.ts
422
168
  var MIME_TYPES = {
@@ -468,15 +214,15 @@ function isAudioFile(name) {
468
214
  import { spawn } from "child_process";
469
215
  import {
470
216
  existsSync,
471
- writeFileSync as writeFileSync2,
472
- mkdirSync as mkdirSync2,
473
- lstatSync as lstatSync2,
217
+ writeFileSync,
218
+ mkdirSync,
219
+ lstatSync,
474
220
  mkdtempSync,
475
221
  renameSync,
476
222
  rmSync,
477
223
  statSync
478
224
  } from "fs";
479
- import { dirname as dirname2, join as join3 } from "path";
225
+ import { dirname, join as join2 } from "path";
480
226
  import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
481
227
  var SAMPLE_RATE = 4e3;
482
228
  var PEAK_COUNT = 4e3;
@@ -543,32 +289,32 @@ function decodeAudioPeaks(audioPath) {
543
289
  });
544
290
  }
545
291
  async function generateWaveformCache(projectDir, assetPath) {
546
- const audioPath = join3(projectDir, assetPath);
292
+ const audioPath = join2(projectDir, assetPath);
547
293
  if (!existsSync(audioPath)) return;
548
294
  const stats = statSync(audioPath);
549
- const cacheDir = join3(projectDir, ".waveform-cache");
550
- const cachePath = join3(cacheDir, buildWaveformCacheKey(assetPath, stats));
295
+ const cacheDir = join2(projectDir, ".waveform-cache");
296
+ const cachePath = join2(cacheDir, buildWaveformCacheKey(assetPath, stats));
551
297
  if (isWaveformCacheDirectory(cacheDir) && existsSync(cachePath)) return;
552
298
  const peaks = await decodeAudioPeaks(audioPath);
553
299
  writeWaveformCache(cachePath, peaks);
554
300
  }
555
301
  function isWaveformCacheDirectory(cacheDir) {
556
- return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
302
+ return lstatSync(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
557
303
  }
558
304
  function writeWaveformCache(cachePath, peaks) {
559
- const cacheDir = dirname2(cachePath);
305
+ const cacheDir = dirname(cachePath);
560
306
  try {
561
- mkdirSync2(cacheDir, { mode: 448 });
307
+ mkdirSync(cacheDir, { mode: 448 });
562
308
  } catch (error) {
563
309
  if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
564
310
  }
565
311
  if (!isWaveformCacheDirectory(cacheDir)) {
566
312
  throw new Error("Waveform cache must be a directory, not a symlink");
567
313
  }
568
- const stagingDir = mkdtempSync(join3(cacheDir, ".waveform-"));
314
+ const stagingDir = mkdtempSync(join2(cacheDir, ".waveform-"));
569
315
  try {
570
- const stagingPath = join3(stagingDir, "peaks.json");
571
- writeFileSync2(stagingPath, JSON.stringify(peaks), { flag: "wx" });
316
+ const stagingPath = join2(stagingDir, "peaks.json");
317
+ writeFileSync(stagingPath, JSON.stringify(peaks), { flag: "wx" });
572
318
  renameSync(stagingPath, cachePath);
573
319
  } finally {
574
320
  try {
@@ -580,12 +326,12 @@ function writeWaveformCache(cachePath, peaks) {
580
326
 
581
327
  // src/helpers/mediaValidation.ts
582
328
  import { spawnSync } from "child_process";
583
- import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
329
+ import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
584
330
  import { tmpdir } from "os";
585
- import { basename, join as join4 } from "path";
331
+ import { basename, join as join3 } from "path";
586
332
  import { FFPROBE_PATH_ENV, findFfBinary as findFfBinary2 } from "@hyperframes/parsers/ff-binaries";
587
333
  var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
588
- var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
334
+ var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac|flac)$/i;
589
335
  var defaultRunner = spawnSync;
590
336
  var FFPROBE_MISSING = "not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
591
337
  function validateUploadedMedia(filePath, runner = defaultRunner) {
@@ -633,10 +379,10 @@ function checkStreams(stdout, isVideo, isAudio) {
633
379
  }
634
380
  }
635
381
  function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
636
- const tempDir = mkdtempSync2(join4(tmpdir(), "hyperframes-upload-"));
637
- const tempPath = join4(tempDir, basename(fileName));
382
+ const tempDir = mkdtempSync2(join3(tmpdir(), "hyperframes-upload-"));
383
+ const tempPath = join3(tempDir, basename(fileName));
638
384
  try {
639
- writeFileSync3(tempPath, buffer);
385
+ writeFileSync2(tempPath, buffer);
640
386
  return validateUploadedMedia(tempPath, runner);
641
387
  } finally {
642
388
  rmSync2(tempDir, { recursive: true, force: true });
@@ -644,9 +390,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
644
390
  }
645
391
 
646
392
  // src/helpers/backupJournal.ts
647
- import { mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
393
+ import { readdirSync, readFileSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
648
394
  import { Buffer as Buffer2 } from "buffer";
649
- import { join as join5, relative as relative2 } from "path";
395
+ import { join as join4, relative } from "path";
650
396
  var DEFAULT_KEEP_PER_FILE = 10;
651
397
  function backupKeyForPath(path) {
652
398
  return Buffer2.from(path, "utf-8").toString("base64url");
@@ -656,23 +402,24 @@ function timestampPrefix() {
656
402
  }
657
403
  function backupPathForResponse(projectDir, backupPath) {
658
404
  if (!backupPath) return null;
659
- const rel = relative2(projectDir, backupPath);
405
+ const rel = relative(projectDir, backupPath);
660
406
  if (!rel || rel.startsWith("..")) return null;
661
407
  return rel.split("\\").join("/");
662
408
  }
663
409
  function snapshotBeforeWrite(projectDir, absPath, options = {}) {
664
410
  if (!isSafePath(projectDir, absPath)) return { backupPath: null };
665
411
  try {
666
- const content = readFileSync3(absPath);
667
- const relativePath = relative2(projectDir, absPath);
668
- const backupDir = join5(projectDir, ".hyperframes", "backup");
669
- mkdirSync3(backupDir, { recursive: true });
412
+ const content = readFileSync(absPath);
413
+ const relativePath = relative(projectDir, absPath);
414
+ const backupDir = join4(projectDir, ".hyperframes", "backup");
415
+ mkdirWithinProject(projectDir, backupDir);
670
416
  const backupKey = backupKeyForPath(relativePath);
671
417
  const backupPath = nextBackupPath(backupDir, backupKey);
672
- writeFileSync4(backupPath, content);
418
+ writeFileSync3(backupPath, content);
673
419
  pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
674
420
  return { backupPath };
675
421
  } catch (error) {
422
+ if (isProjectRootMissing(error)) throw error;
676
423
  if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "EISDIR")) {
677
424
  return { backupPath: null };
678
425
  }
@@ -681,18 +428,18 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
681
428
  }
682
429
  function nextBackupPath(backupDir, backupKey) {
683
430
  const base = `${timestampPrefix()}-${backupKey}`;
684
- let candidate = join5(backupDir, base);
431
+ let candidate = join4(backupDir, base);
685
432
  let counter = 2;
686
433
  while (true) {
687
434
  try {
688
- readFileSync3(candidate);
435
+ readFileSync(candidate);
689
436
  } catch (error) {
690
437
  if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
691
438
  return candidate;
692
439
  }
693
440
  throw error;
694
441
  }
695
- candidate = join5(backupDir, `${base}-${counter}`);
442
+ candidate = join4(backupDir, `${base}-${counter}`);
696
443
  counter += 1;
697
444
  }
698
445
  }
@@ -700,7 +447,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
700
447
  const keep = Math.max(1, Math.floor(keepPerFile));
701
448
  const suffix = `-${backupKey}`;
702
449
  const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
703
- const matches = readdirSync2(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join5(backupDir, name)).sort((a, b) => {
450
+ const matches = readdirSync(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join4(backupDir, name)).sort((a, b) => {
704
451
  return b.localeCompare(a);
705
452
  });
706
453
  for (const file of matches.slice(keep)) {
@@ -711,96 +458,37 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
711
458
  }
712
459
  }
713
460
 
714
- // src/helpers/fileVersion.ts
715
- import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
716
- var RECEIPT_TTL_MS = 1e4;
717
- var receipts = /* @__PURE__ */ new Map();
718
- function fileContentVersion(content) {
719
- return hashVersion(createHash3("sha256").update(content).digest("hex"));
720
- }
721
- var DELETED_VERSION = '"deleted"';
722
- function hashVersion(hex) {
723
- return `"sha256:${hex}"`;
724
- }
725
- function hashOfVersion(version) {
726
- return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
727
- }
728
- function settledFileTag(stat3, now = Date.now()) {
729
- if (now - stat3.ctimeMs < 3e3) return null;
730
- return [stat3.ino, stat3.ctimeMs, stat3.size].map((n) => n.toString(36)).join("-");
731
- }
732
- function createWriteToken(requestToken) {
733
- const token = requestToken?.trim();
734
- return token && token.length <= 200 ? token : randomUUID2();
735
- }
736
- function recordFileWriteReceipt(filePath, receipt) {
737
- const absPath = realFilePath(filePath);
738
- const now = Date.now();
739
- for (const [path, list] of receipts) {
740
- const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
741
- if (live.length > 0) receipts.set(path, live);
742
- else receipts.delete(path);
743
- }
744
- receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
745
- }
746
- function clearFileWriteReceipt(filePath, version, writeToken) {
747
- const absPath = realFilePath(filePath);
748
- const current = (receipts.get(absPath) ?? []).filter(
749
- (entry) => entry.version !== version || entry.writeToken !== writeToken
750
- );
751
- if (current.length > 0) receipts.set(absPath, current);
752
- else receipts.delete(absPath);
753
- }
754
- function identifyFileWrite(filePath, expectedVersion) {
755
- const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
756
- if (!receipt) return null;
757
- const { path, version, writeToken } = receipt;
758
- return { path, version, writeToken };
759
- }
760
- function bytesOverwrittenBy(filePath, version) {
761
- return newestReceipt(realFilePath(filePath), version)?.overwrote;
762
- }
763
- function forgetOverwrittenBytes(filePath, versions) {
764
- for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
765
- if (versions.has(receipt.version)) delete receipt.overwrote;
766
- }
767
- function newestReceipt(absPath, expectedVersion) {
768
- const now = Date.now();
769
- const current = (receipts.get(absPath) ?? []).filter(
770
- (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
771
- );
772
- if (current.length > 0) receipts.set(absPath, current);
773
- else receipts.delete(absPath);
774
- let receipt;
775
- for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
776
- if (current[i]?.version === expectedVersion) receipt = current[i];
777
- }
778
- return receipt;
461
+ // src/helpers/projectDirMissing.ts
462
+ var PROJECT_DIR_MISSING = { error: "not found", why: "project_dir_missing" };
463
+ var projectDirMissing = (c) => c.json(PROJECT_DIR_MISSING, 404);
464
+ function replaceWithProjectDirMissing(c, hostHeaders) {
465
+ hostHeaders.delete("content-type");
466
+ c.res = void 0;
467
+ c.res = Response.json(PROJECT_DIR_MISSING, { status: 404, headers: hostHeaders });
779
468
  }
780
- var consumeFileWriteReceipt = identifyFileWrite;
781
469
 
782
470
  // src/helpers/applyFileMutations.ts
783
- import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
784
- function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
471
+ import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
472
+ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
785
473
  const prepared = mutations.map((mutation) => ({
786
474
  ...mutation,
787
- before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
475
+ before: mutation.before ?? readFileSync2(mutation.absPath, "utf-8")
788
476
  }));
789
477
  const results = [];
790
478
  const attempted = [];
791
479
  for (const mutation of prepared)
792
- assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
480
+ assertExpectedVersion(mutation, readFileSync2(mutation.absPath, "utf-8"));
793
481
  try {
794
482
  for (const mutation of prepared) {
795
- results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted));
483
+ results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted));
796
484
  }
797
485
  return results;
798
486
  } catch (error) {
799
487
  const rollbackErrors = [];
800
488
  for (const mutation of attempted.reverse()) {
801
489
  try {
802
- if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
803
- writeFile2(mutation.absPath, mutation.before, "utf-8");
490
+ if (!mutation.written || readFileSync2(mutation.absPath, "utf-8") === mutation.after)
491
+ writeFile(mutation.absPath, mutation.before, "utf-8");
804
492
  clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
805
493
  } catch (rollbackError) {
806
494
  rollbackErrors.push(rollbackError);
@@ -815,8 +503,8 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (p
815
503
  throw error;
816
504
  }
817
505
  }
818
- function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted) {
819
- const current = readFileSync4(mutation.absPath, "utf-8");
506
+ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted) {
507
+ const current = readFileSync2(mutation.absPath, "utf-8");
820
508
  assertExpectedVersion(mutation, current);
821
509
  if (mutation.after === mutation.before) {
822
510
  return {
@@ -830,13 +518,13 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attemp
830
518
  }
831
519
  const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
832
520
  if (backup.error) throw new Error(`backup failed: ${backup.error}`);
833
- assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
521
+ assertExpectedVersion(mutation, readFileSync2(mutation.absPath, "utf-8"));
834
522
  const before = current;
835
523
  const version = fileContentVersion(mutation.after);
836
524
  const writeToken = createWriteToken(requestToken);
837
525
  const attempt = { ...mutation, before, version, writeToken, written: false };
838
526
  attempted.push(attempt);
839
- writeFile2(mutation.absPath, mutation.after, "utf-8");
527
+ writeFile(mutation.absPath, mutation.after, "utf-8");
840
528
  attempt.written = true;
841
529
  recordFileWriteReceipt(mutation.absPath, {
842
530
  path: mutation.sourceFile,
@@ -898,13 +586,14 @@ import {
898
586
  syncPositionHoldsBeforeKeyframes
899
587
  } from "@hyperframes/parsers/gsap-writer-acorn";
900
588
  import { parseHTML as parseHTML2 } from "linkedom";
901
- import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
589
+ import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
902
590
 
903
591
  // src/helpers/compositionInsertion.ts
904
- import { existsSync as existsSync2, readFileSync as readFileSync5 } from "fs";
905
- import { randomUUID as randomUUID3 } from "crypto";
906
- import { dirname as dirname3, relative as relative3, resolve as resolve2, sep as sep2 } from "path";
592
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
593
+ import { randomUUID } from "crypto";
594
+ import { dirname as dirname2, relative as relative2, resolve, sep } from "path";
907
595
  import { parseHTML } from "linkedom";
596
+ import { ProjectRootMissingError } from "@hyperframes/core";
908
597
  var CompositionInsertionError = class extends Error {
909
598
  constructor(message, status) {
910
599
  super(message);
@@ -934,6 +623,7 @@ function positiveAttribute(root, ...names) {
934
623
  }
935
624
  function canonicalProjectPath(projectDir, candidate) {
936
625
  if (!candidate) {
626
+ if (folderGone(projectDir)) throw new ProjectRootMissingError(projectDir);
937
627
  throw new CompositionInsertionError("Composition source escapes the project", 400);
938
628
  }
939
629
  if (!existsSync2(candidate)) {
@@ -958,7 +648,7 @@ function canonicalDependency(projectDir, ownerAbs, sourcePath) {
958
648
  validateSourcePath(sourcePath);
959
649
  return canonicalProjectPath(
960
650
  projectDir,
961
- resolveWithinProject(projectDir, relative3(projectDir, resolve2(dirname3(ownerAbs), sourcePath)))
651
+ resolveWithinProject(projectDir, relative2(projectDir, resolve(dirname2(ownerAbs), sourcePath)))
962
652
  );
963
653
  }
964
654
  function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
@@ -973,7 +663,7 @@ function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
973
663
  }
974
664
  if (visited.has(file)) return;
975
665
  visiting.add(file);
976
- const source = readFileSync5(file, "utf-8");
666
+ const source = readFileSync3(file, "utf-8");
977
667
  const { document } = compositionRoot(source);
978
668
  for (const host of descendants(document, "[data-composition-src]")) {
979
669
  const dependency = host.getAttribute("data-composition-src");
@@ -1031,13 +721,13 @@ function uniqueHostId(root, base) {
1031
721
  return `${base}_${suffix}`;
1032
722
  }
1033
723
  function relativeSourcePath(targetAbs, sourceAbs) {
1034
- return relative3(dirname3(targetAbs), sourceAbs).split(sep2).join("/");
724
+ return relative2(dirname2(targetAbs), sourceAbs).split(sep).join("/");
1035
725
  }
1036
726
  function insertCompositionIntoSource(input) {
1037
727
  const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
1038
728
  const sourceAbs = canonicalProjectFile(input.projectDir, input.sourcePath);
1039
729
  validateDependencyGraph(input.projectDir, targetAbs, sourceAbs);
1040
- const source = readFileSync5(sourceAbs, "utf-8");
730
+ const source = readFileSync3(sourceAbs, "utf-8");
1041
731
  const sourceComposition = compositionRoot(source).root;
1042
732
  const duration = positiveAttribute(
1043
733
  sourceComposition,
@@ -1066,7 +756,7 @@ function insertCompositionIntoSource(input) {
1066
756
  const host = document.createElement("div");
1067
757
  host.id = hostId;
1068
758
  host.className = "clip";
1069
- host.setAttribute("data-hf-id", `hf-${randomUUID3()}`);
759
+ host.setAttribute("data-hf-id", `hf-${randomUUID()}`);
1070
760
  host.setAttribute("data-composition-id", hostId);
1071
761
  host.setAttribute("data-composition-src", relativeSourcePath(targetAbs, sourceAbs));
1072
762
  host.setAttribute("data-start", String(Math.round(input.start * 100) / 100));
@@ -1163,15 +853,15 @@ async function loadGsapParser() {
1163
853
  return import("@hyperframes/parsers/gsap-parser-recast");
1164
854
  }
1165
855
  function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1166
- if (!isSafePath(projectDir, dirname4(lexicalPath))) return false;
856
+ if (!isSafePath(projectDir, dirname3(lexicalPath))) return false;
1167
857
  let stats;
1168
858
  try {
1169
- stats = lstatSync3(lexicalPath);
859
+ stats = lstatSync2(lexicalPath);
1170
860
  } catch {
1171
861
  return false;
1172
862
  }
1173
863
  if (!stats.isSymbolicLink()) return false;
1174
- const target = resolve3(dirname4(lexicalPath), readlinkSync(lexicalPath));
864
+ const target = resolve2(dirname3(lexicalPath), readlinkSync(lexicalPath));
1175
865
  if (!isSafePath(projectDir, target)) return false;
1176
866
  try {
1177
867
  statSync3(lexicalPath);
@@ -1186,10 +876,8 @@ async function resolveProjectPath(c, adapter, route, opts) {
1186
876
  if (!project) {
1187
877
  return { error: c.json({ error: "not found" }, 404) };
1188
878
  }
1189
- if (!existsSync3(project.dir)) {
1190
- return {
1191
- error: c.json({ error: "not found", why: "project_dir_missing" }, 404)
1192
- };
879
+ if (folderGone(project.dir)) {
880
+ return { error: projectDirMissing(c) };
1193
881
  }
1194
882
  const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
1195
883
  if (filePath.includes("\0")) {
@@ -1197,7 +885,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
1197
885
  }
1198
886
  const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject)(project.dir, filePath);
1199
887
  if (!absPath) {
1200
- if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
888
+ if (isDanglingSymlinkInProject(project.dir, resolve2(project.dir, filePath))) {
1201
889
  return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
1202
890
  }
1203
891
  return { error: c.json({ error: "forbidden", why: "outside_project" }, 403) };
@@ -1261,10 +949,10 @@ function foldElementPatches(originalContent, patches) {
1261
949
  return { content, matched };
1262
950
  }
1263
951
  var PATCH_CONFLICT_ATTEMPTS = 3;
1264
- function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
952
+ function commitElementPatchBatches(projectDir, batches, writeFile = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
1265
953
  for (let attempt = 1; ; attempt += 1) {
1266
954
  try {
1267
- return foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken);
955
+ return foldAndCommitElementPatchBatches(projectDir, batches, writeFile, requestToken);
1268
956
  } catch (error) {
1269
957
  if (!(error instanceof FileChangedError)) throw error;
1270
958
  if (attempt === PATCH_CONFLICT_ATTEMPTS)
@@ -1272,7 +960,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, cont
1272
960
  }
1273
961
  }
1274
962
  }
1275
- function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken) {
963
+ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile, requestToken) {
1276
964
  const resolvedPaths = /* @__PURE__ */ new Set();
1277
965
  const prepared = [];
1278
966
  for (const batch of batches) {
@@ -1282,7 +970,7 @@ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, reque
1282
970
  resolvedPaths.add(absPath);
1283
971
  let before;
1284
972
  try {
1285
- before = readFileSync6(absPath, "utf-8");
973
+ before = readFileSync4(absPath, "utf-8");
1286
974
  } catch {
1287
975
  return { error: "not-found", sourceFile: batch.sourceFile };
1288
976
  }
@@ -1317,7 +1005,7 @@ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, reque
1317
1005
  after
1318
1006
  })),
1319
1007
  requestToken,
1320
- writeFile2
1008
+ writeFile
1321
1009
  );
1322
1010
  const files = applied.map((file, index) => ({
1323
1011
  sourceFile: file.sourceFile,
@@ -1338,7 +1026,7 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
1338
1026
  );
1339
1027
  }
1340
1028
  function writeFileWithReceipt(c, filePath, absPath, html) {
1341
- const overwrote = readFileSync6(absPath);
1029
+ const overwrote = readFileSync4(absPath);
1342
1030
  replaceFileAtomically(absPath, html, statSync3(absPath).mode);
1343
1031
  const version = fileContentVersion(html);
1344
1032
  const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
@@ -1348,7 +1036,7 @@ function writeFileWithReceipt(c, filePath, absPath, html) {
1348
1036
  function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
1349
1037
  const backup = snapshotBeforeWrite(projectDir, absPath);
1350
1038
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
1351
- if (readFileSync6(absPath, "utf-8") !== original) {
1039
+ if (readFileSync4(absPath, "utf-8") !== original) {
1352
1040
  return c.json({ error: "file changed", conflict: true, path: filePath }, 409);
1353
1041
  }
1354
1042
  const { version } = writeFileWithReceipt(c, filePath, absPath, html);
@@ -1393,9 +1081,8 @@ async function parseMutationBody(c) {
1393
1081
  }
1394
1082
  return { target: body.target, body };
1395
1083
  }
1396
- function ensureDir(filePath) {
1397
- const dir = dirname4(filePath);
1398
- if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
1084
+ function ensureDir(projectDir, filePath) {
1085
+ mkdirWithinProject(projectDir, dirname3(filePath));
1399
1086
  }
1400
1087
  function generateCopyPath(projectDir, originalPath) {
1401
1088
  const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
@@ -1404,7 +1091,7 @@ function generateCopyPath(projectDir, originalPath) {
1404
1091
  const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
1405
1092
  let num = copyMatch ? copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2 : 1;
1406
1093
  let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
1407
- while (existsSync3(resolve3(projectDir, candidate))) {
1094
+ while (existsSync3(resolve2(projectDir, candidate))) {
1408
1095
  num++;
1409
1096
  candidate = `${cleanBase} (copy ${num})${ext}`;
1410
1097
  }
@@ -1412,8 +1099,8 @@ function generateCopyPath(projectDir, originalPath) {
1412
1099
  }
1413
1100
  function walkFiles(dir, filter) {
1414
1101
  const results = [];
1415
- for (const entry of readdirSync3(dir, { withFileTypes: true })) {
1416
- const full = join6(dir, entry.name);
1102
+ for (const entry of readdirSync2(dir, { withFileTypes: true })) {
1103
+ const full = join5(dir, entry.name);
1417
1104
  if (entry.isDirectory()) {
1418
1105
  if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders" || entry.name === ".transcode-cache")
1419
1106
  continue;
@@ -1432,7 +1119,7 @@ function updateReferences(projectDir, oldPath, newPath) {
1432
1119
  let updatedCount = 0;
1433
1120
  for (const file of textFiles) {
1434
1121
  if (!isSafePath(projectDir, file)) continue;
1435
- const content = readFileSync6(file, "utf-8");
1122
+ const content = readFileSync4(file, "utf-8");
1436
1123
  if (!content.includes(oldPath)) continue;
1437
1124
  const updated = content.split(oldPath).join(newPath);
1438
1125
  if (updated !== content) {
@@ -1443,7 +1130,7 @@ function updateReferences(projectDir, oldPath, newPath) {
1443
1130
  return updatedCount;
1444
1131
  }
1445
1132
  function extractGsapScriptBlock(html) {
1446
- const { document } = parseHTML2(ensureHfIds2(html));
1133
+ const { document } = parseHTML2(ensureHfIds(html));
1447
1134
  const scripts = [
1448
1135
  ...document.querySelectorAll("script:not([src])"),
1449
1136
  ...Array.from(document.querySelectorAll("template")).flatMap(
@@ -1706,7 +1393,7 @@ function containsRawGsapExpression(value) {
1706
1393
  return Object.values(value).some(containsRawGsapExpression);
1707
1394
  }
1708
1395
  async function prepareGsapMutationScript(c, res, firstMutation) {
1709
- const beforeHtml = readFileSync6(res.absPath, "utf-8");
1396
+ const beforeHtml = readFileSync4(res.absPath, "utf-8");
1710
1397
  let html = beforeHtml;
1711
1398
  let block = extractGsapScriptBlock(html);
1712
1399
  if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
@@ -1773,7 +1460,7 @@ async function applyGsapMutations(c, res, mutations) {
1773
1460
  const changed = block.scriptText !== initialScript;
1774
1461
  const newHtml = changed ? block.replaceScript(block.scriptText) : html;
1775
1462
  let backupPath = null;
1776
- if (readFileSync6(res.absPath, "utf-8") !== beforeHtml) {
1463
+ if (readFileSync4(res.absPath, "utf-8") !== beforeHtml) {
1777
1464
  return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
1778
1465
  }
1779
1466
  if (changed) {
@@ -2527,7 +2214,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2527
2214
  skipped.push(name);
2528
2215
  continue;
2529
2216
  }
2530
- const destPath = resolve3(targetDir, name);
2217
+ const destPath = resolve2(targetDir, name);
2531
2218
  if (!isSafePath(projectDir, destPath)) continue;
2532
2219
  let finalPath = destPath;
2533
2220
  let finalName = name;
@@ -2538,13 +2225,13 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2538
2225
  let n = 1;
2539
2226
  if (existsSync3(finalPath)) {
2540
2227
  n = 2;
2541
- while (n < MAX_COPY_INDEX && existsSync3(resolve3(targetDir, `${base} (${n})${ext}`))) n++;
2228
+ while (n < MAX_COPY_INDEX && existsSync3(resolve2(targetDir, `${base} (${n})${ext}`))) n++;
2542
2229
  if (n >= MAX_COPY_INDEX) {
2543
2230
  skipped.push(name);
2544
2231
  continue;
2545
2232
  }
2546
2233
  finalName = `${base} (${n})${ext}`;
2547
- finalPath = resolve3(targetDir, finalName);
2234
+ finalPath = resolve2(targetDir, finalName);
2548
2235
  }
2549
2236
  if (!isSafePath(projectDir, finalPath)) continue;
2550
2237
  const buffer = Buffer.from(await value.arrayBuffer());
@@ -2556,11 +2243,11 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2556
2243
  let written = false;
2557
2244
  while (n < MAX_COPY_INDEX && isSafePath(projectDir, finalPath)) {
2558
2245
  try {
2559
- const fd = openSync2(finalPath, "wx");
2246
+ const fd = openSync(finalPath, "wx");
2560
2247
  try {
2561
- writeFileSync5(fd, buffer);
2248
+ writeFileSync4(fd, buffer);
2562
2249
  } finally {
2563
- closeSync2(fd);
2250
+ closeSync(fd);
2564
2251
  }
2565
2252
  written = true;
2566
2253
  break;
@@ -2570,14 +2257,14 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
2570
2257
  }
2571
2258
  n++;
2572
2259
  finalName = `${base} (${n})${ext}`;
2573
- finalPath = resolve3(targetDir, finalName);
2260
+ finalPath = resolve2(targetDir, finalName);
2574
2261
  }
2575
2262
  }
2576
2263
  if (!written) {
2577
2264
  if (n >= MAX_COPY_INDEX) skipped.push(name);
2578
2265
  continue;
2579
2266
  }
2580
- const relativePath = subDir ? join6(subDir, finalName) : finalName;
2267
+ const relativePath = subDir ? join5(subDir, finalName) : finalName;
2581
2268
  uploaded.push(relativePath);
2582
2269
  if (validation.unchecked) unchecked.push({ name: finalName, reason: validation.unchecked });
2583
2270
  if (isAudioFile(finalName)) {
@@ -2593,7 +2280,7 @@ function registerFileRoutes(api, adapter) {
2593
2280
  if ("error" in res) return res.error;
2594
2281
  let fd;
2595
2282
  try {
2596
- fd = openSync2(res.absPath, "r");
2283
+ fd = openSync(res.absPath, "r");
2597
2284
  } catch {
2598
2285
  if (c.req.query("optional") === "1") {
2599
2286
  return c.json({ filename: res.filePath, content: "", missing: true });
@@ -2601,10 +2288,10 @@ function registerFileRoutes(api, adapter) {
2601
2288
  return c.json({ error: "not found" }, 404);
2602
2289
  }
2603
2290
  try {
2604
- if (!fstatSync2(fd).isFile()) {
2291
+ if (!fstatSync(fd).isFile()) {
2605
2292
  return c.json({ error: "not found", why: "not_a_file" }, 404);
2606
2293
  }
2607
- const content = readFileSync6(fd);
2294
+ const content = readFileSync4(fd);
2608
2295
  const version = fileContentVersion(content);
2609
2296
  c.header("ETag", version);
2610
2297
  return c.json({
@@ -2614,7 +2301,7 @@ function registerFileRoutes(api, adapter) {
2614
2301
  missing: false
2615
2302
  });
2616
2303
  } finally {
2617
- closeSync2(fd);
2304
+ closeSync(fd);
2618
2305
  }
2619
2306
  });
2620
2307
  api.put("/projects/:id/files/*", async (c) => {
@@ -2626,7 +2313,7 @@ function registerFileRoutes(api, adapter) {
2626
2313
  if (expectedVersion === null && !createOnly) {
2627
2314
  let currentContent = null;
2628
2315
  try {
2629
- currentContent = readFileSync6(res.absPath);
2316
+ currentContent = readFileSync4(res.absPath);
2630
2317
  } catch (error) {
2631
2318
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2632
2319
  throw error;
@@ -2645,15 +2332,15 @@ function registerFileRoutes(api, adapter) {
2645
2332
  let backup = { backupPath: null };
2646
2333
  let overwrote;
2647
2334
  if (createOnly) {
2648
- ensureDir(res.absPath);
2335
+ ensureDir(res.project.dir, res.absPath);
2649
2336
  let fd;
2650
2337
  try {
2651
- fd = openSync2(res.absPath, "wx");
2338
+ fd = openSync(res.absPath, "wx");
2652
2339
  } catch (error) {
2653
2340
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
2654
2341
  throw error;
2655
2342
  }
2656
- const currentContent = readFileSync6(res.absPath);
2343
+ const currentContent = readFileSync4(res.absPath);
2657
2344
  return c.json(
2658
2345
  {
2659
2346
  error: "file conflict",
@@ -2667,12 +2354,12 @@ function registerFileRoutes(api, adapter) {
2667
2354
  try {
2668
2355
  writeSync(fd, body, 0, body.length, 0);
2669
2356
  } finally {
2670
- closeSync2(fd);
2357
+ closeSync(fd);
2671
2358
  }
2672
2359
  } else {
2673
2360
  let fd;
2674
2361
  try {
2675
- fd = openSync2(res.absPath, "r+");
2362
+ fd = openSync(res.absPath, "r+");
2676
2363
  } catch (error) {
2677
2364
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2678
2365
  throw error;
@@ -2688,7 +2375,7 @@ function registerFileRoutes(api, adapter) {
2688
2375
  );
2689
2376
  }
2690
2377
  try {
2691
- const currentContent = readFileSync6(fd);
2378
+ const currentContent = readFileSync4(fd);
2692
2379
  overwrote = currentContent;
2693
2380
  const currentVersion = fileContentVersion(currentContent);
2694
2381
  if (expectedVersion !== currentVersion) {
@@ -2704,12 +2391,12 @@ function registerFileRoutes(api, adapter) {
2704
2391
  }
2705
2392
  backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2706
2393
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2707
- const mode = fstatSync2(fd).mode;
2708
- closeSync2(fd);
2394
+ const mode = fstatSync(fd).mode;
2395
+ closeSync(fd);
2709
2396
  fd = null;
2710
2397
  replaceFileAtomically(res.absPath, body, mode);
2711
2398
  } finally {
2712
- if (fd !== null) closeSync2(fd);
2399
+ if (fd !== null) closeSync(fd);
2713
2400
  }
2714
2401
  }
2715
2402
  const version = fileContentVersion(body);
@@ -2727,10 +2414,10 @@ function registerFileRoutes(api, adapter) {
2727
2414
  api.post("/projects/:id/files/*", async (c) => {
2728
2415
  const res = await resolveProjectFile(c, adapter);
2729
2416
  if ("error" in res) return res.error;
2730
- ensureDir(res.absPath);
2417
+ ensureDir(res.project.dir, res.absPath);
2731
2418
  const body = Buffer.from(await c.req.arrayBuffer());
2732
2419
  try {
2733
- writeFileSync5(res.absPath, body, { flag: "wx" });
2420
+ writeFileSync4(res.absPath, body, { flag: "wx" });
2734
2421
  } catch (error) {
2735
2422
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
2736
2423
  throw error;
@@ -2742,10 +2429,10 @@ function registerFileRoutes(api, adapter) {
2742
2429
  api.delete("/projects/:id/files/*", async (c) => {
2743
2430
  const res = await resolveProjectFile(c, adapter, { mustExist: true });
2744
2431
  if ("error" in res) return res.error;
2745
- const stat3 = statSync3(res.absPath);
2432
+ const stat2 = statSync3(res.absPath);
2746
2433
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2747
2434
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2748
- if (stat3.isDirectory()) {
2435
+ if (stat2.isDirectory()) {
2749
2436
  rmSync3(res.absPath, { recursive: true });
2750
2437
  } else {
2751
2438
  unlinkSync2(res.absPath);
@@ -2764,7 +2451,7 @@ function registerFileRoutes(api, adapter) {
2764
2451
  }
2765
2452
  let before;
2766
2453
  try {
2767
- before = readFileSync6(ctx.absPath, "utf-8");
2454
+ before = readFileSync4(ctx.absPath, "utf-8");
2768
2455
  } catch (error) {
2769
2456
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2770
2457
  throw error;
@@ -2793,7 +2480,7 @@ function registerFileRoutes(api, adapter) {
2793
2480
  }
2794
2481
  const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2795
2482
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2796
- const current = readFileSync6(ctx.absPath, "utf-8");
2483
+ const current = readFileSync4(ctx.absPath, "utf-8");
2797
2484
  if (current !== before) {
2798
2485
  const currentVersion2 = fileContentVersion(current);
2799
2486
  return c.json({ error: "file conflict", currentVersion: currentVersion2, currentContent: current }, 409);
@@ -2826,7 +2513,7 @@ function registerFileRoutes(api, adapter) {
2826
2513
  }
2827
2514
  const parsed = await parseMutationBody(c);
2828
2515
  if ("error" in parsed) return parsed.error;
2829
- const originalContent = readFileSync6(ctx.absPath, "utf-8");
2516
+ const originalContent = readFileSync4(ctx.absPath, "utf-8");
2830
2517
  return writeIfChanged(
2831
2518
  c,
2832
2519
  ctx.project.dir,
@@ -2847,7 +2534,7 @@ function registerFileRoutes(api, adapter) {
2847
2534
  if (!Array.isArray(targets) || targets.length === 0) {
2848
2535
  return c.json({ error: "targets required" }, 400);
2849
2536
  }
2850
- const originalContent = readFileSync6(ctx.absPath, "utf-8");
2537
+ const originalContent = readFileSync4(ctx.absPath, "utf-8");
2851
2538
  let next = originalContent;
2852
2539
  for (const target of targets) {
2853
2540
  next = removeElementFromHtml(next, target);
@@ -2880,7 +2567,7 @@ function registerFileRoutes(api, adapter) {
2880
2567
  seen.add(absPath);
2881
2568
  let before;
2882
2569
  try {
2883
- before = readFileSync6(absPath, "utf-8");
2570
+ before = readFileSync4(absPath, "utf-8");
2884
2571
  } catch {
2885
2572
  return c.json({ error: `not found: ${file.path}` }, 404);
2886
2573
  }
@@ -2907,7 +2594,7 @@ function registerFileRoutes(api, adapter) {
2907
2594
  prepared.push(folded);
2908
2595
  }
2909
2596
  for (const file of prepared) {
2910
- const current = readFileSync6(file.absPath, "utf-8");
2597
+ const current = readFileSync4(file.absPath, "utf-8");
2911
2598
  if (current !== file.before) {
2912
2599
  return c.json(
2913
2600
  {
@@ -2950,7 +2637,7 @@ function registerFileRoutes(api, adapter) {
2950
2637
  const conflicts = [];
2951
2638
  for (const file of written.reverse()) {
2952
2639
  try {
2953
- const current = readFileSync6(file.absPath, "utf-8");
2640
+ const current = readFileSync4(file.absPath, "utf-8");
2954
2641
  if (current !== file.after) {
2955
2642
  conflicts.push(file.path);
2956
2643
  continue;
@@ -2998,7 +2685,7 @@ function registerFileRoutes(api, adapter) {
2998
2685
  const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
2999
2686
  let originalContent;
3000
2687
  try {
3001
- originalContent = readFileSync6(ctx.absPath, "utf-8");
2688
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
3002
2689
  } catch {
3003
2690
  return c.json({ error: "not found" }, 404);
3004
2691
  }
@@ -3056,7 +2743,7 @@ function registerFileRoutes(api, adapter) {
3056
2743
  for (let attempt = 1; ; attempt += 1) {
3057
2744
  let originalContent;
3058
2745
  try {
3059
- originalContent = readFileSync6(ctx.absPath, "utf-8");
2746
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
3060
2747
  } catch {
3061
2748
  return c.json({ error: "not found" }, 404);
3062
2749
  }
@@ -3162,7 +2849,7 @@ function registerFileRoutes(api, adapter) {
3162
2849
  }
3163
2850
  let originalContent;
3164
2851
  try {
3165
- originalContent = readFileSync6(ctx.absPath, "utf-8");
2852
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
3166
2853
  } catch {
3167
2854
  return c.json({ error: "not found" }, 404);
3168
2855
  }
@@ -3218,7 +2905,7 @@ function registerFileRoutes(api, adapter) {
3218
2905
  ).map((entry) => ({ target: entry.target, track: entry.track }));
3219
2906
  let originalContent;
3220
2907
  try {
3221
- originalContent = readFileSync6(ctx.absPath, "utf-8");
2908
+ originalContent = readFileSync4(ctx.absPath, "utf-8");
3222
2909
  } catch {
3223
2910
  return c.json({ error: "not found" }, 404);
3224
2911
  }
@@ -3247,7 +2934,7 @@ function registerFileRoutes(api, adapter) {
3247
2934
  if ("error" in parsed) return parsed.error;
3248
2935
  let content;
3249
2936
  try {
3250
- content = readFileSync6(ctx.absPath, "utf-8");
2937
+ content = readFileSync4(ctx.absPath, "utf-8");
3251
2938
  } catch {
3252
2939
  return c.json({ exists: false });
3253
2940
  }
@@ -3268,7 +2955,7 @@ function registerFileRoutes(api, adapter) {
3268
2955
  if (existsSync3(newAbs)) {
3269
2956
  return c.json({ error: "already exists" }, 409);
3270
2957
  }
3271
- ensureDir(newAbs);
2958
+ ensureDir(res.project.dir, newAbs);
3272
2959
  renameSync2(res.absPath, newAbs);
3273
2960
  const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
3274
2961
  return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
@@ -3289,9 +2976,9 @@ function registerFileRoutes(api, adapter) {
3289
2976
  if (!destAbs) {
3290
2977
  return c.json({ error: "forbidden" }, 403);
3291
2978
  }
3292
- ensureDir(destAbs);
2979
+ ensureDir(project.dir, destAbs);
3293
2980
  try {
3294
- writeFileSync5(destAbs, readFileSync6(srcAbs), { flag: "wx" });
2981
+ writeFileSync4(destAbs, readFileSync4(srcAbs), { flag: "wx" });
3295
2982
  } catch (error) {
3296
2983
  if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
3297
2984
  throw error;
@@ -3310,12 +2997,14 @@ function registerFileRoutes(api, adapter) {
3310
2997
  async (c) => {
3311
2998
  const project = await adapter.resolveProject(c.req.param("id"));
3312
2999
  if (!project) return c.json({ error: "not found" }, 404);
3000
+ if (folderGone(project.dir)) return projectDirMissing(c);
3313
3001
  const subDir = c.req.query("dir") ?? "";
3314
3002
  const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
3315
3003
  if (!targetDir) return c.json({ error: "forbidden" }, 403);
3316
- if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
3317
3004
  const formData = await c.req.formData();
3005
+ mkdirWithinProject(project.dir, targetDir);
3318
3006
  const result = await processUploadedFiles(formData, targetDir, project.dir);
3007
+ if (folderGone(project.dir)) return projectDirMissing(c);
3319
3008
  return c.json(
3320
3009
  {
3321
3010
  ok: true,
@@ -3333,7 +3022,7 @@ function registerFileRoutes(api, adapter) {
3333
3022
  mustExist: true
3334
3023
  });
3335
3024
  if ("error" in res) return res.error;
3336
- const html = readFileSync6(res.absPath, "utf-8");
3025
+ const html = readFileSync4(res.absPath, "utf-8");
3337
3026
  const block = extractGsapScriptBlock(html);
3338
3027
  if (!block) {
3339
3028
  return c.json({
@@ -3389,7 +3078,7 @@ function registerFileRoutes(api, adapter) {
3389
3078
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
3390
3079
  return c.json({ error: "expected and restore contents required" }, 400);
3391
3080
  }
3392
- const current = readFileSync6(res.absPath, "utf-8");
3081
+ const current = readFileSync4(res.absPath, "utf-8");
3393
3082
  if (current !== body.expected) {
3394
3083
  return c.json({ ok: true, restored: false, conflict: true });
3395
3084
  }
@@ -3399,10 +3088,10 @@ function registerFileRoutes(api, adapter) {
3399
3088
  }
3400
3089
 
3401
3090
  // src/routes/preview.ts
3402
- import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
3091
+ import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
3403
3092
  import { Readable } from "stream";
3404
- import { join as join9, resolve as resolve5 } from "path";
3405
- import { createHash as createHash4 } from "crypto";
3093
+ import { join as join8, resolve as resolve4 } from "path";
3094
+ import { createHash } from "crypto";
3406
3095
  import {
3407
3096
  addScenePartsManifest,
3408
3097
  injectScriptsIntoHtml,
@@ -3414,8 +3103,8 @@ import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document"
3414
3103
  import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3415
3104
 
3416
3105
  // src/helpers/subComposition.ts
3417
- import { existsSync as existsSync4, readFileSync as readFileSync7 } from "fs";
3418
- import { join as join7 } from "path";
3106
+ import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
3107
+ import { join as join6 } from "path";
3419
3108
  import { parseHTML as parseHTML3 } from "linkedom";
3420
3109
  import {
3421
3110
  rewriteAssetPaths,
@@ -3425,7 +3114,7 @@ import {
3425
3114
  import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
3426
3115
  import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
3427
3116
  function rewriteRelativePaths(root, compPath, projectDir) {
3428
- const assetExists = (path) => existsSync4(join7(projectDir, path));
3117
+ const assetExists = (path) => existsSync4(join6(projectDir, path));
3429
3118
  rewriteAssetPaths(
3430
3119
  root.querySelectorAll("[src], [href]"),
3431
3120
  compPath,
@@ -3522,9 +3211,9 @@ function tagRootCompositionFile(bodyHtml, compPath) {
3522
3211
  return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
3523
3212
  }
3524
3213
  function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
3525
- const compFile = join7(projectDir, compPath);
3214
+ const compFile = join6(projectDir, compPath);
3526
3215
  if (!existsSync4(compFile)) return null;
3527
- const rawComp = rawOverride ?? readFileSync7(compFile, "utf-8");
3216
+ const rawComp = rawOverride ?? readFileSync5(compFile, "utf-8");
3528
3217
  let compHeadContent = "";
3529
3218
  let rewrittenContent;
3530
3219
  let htmlAttrs = "";
@@ -3554,10 +3243,10 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
3554
3243
  }
3555
3244
  rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
3556
3245
  rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
3557
- const indexPath = join7(projectDir, "index.html");
3246
+ const indexPath = join6(projectDir, "index.html");
3558
3247
  let headContent = "";
3559
3248
  if (existsSync4(indexPath)) {
3560
- const indexHtml = readFileSync7(indexPath, "utf-8");
3249
+ const indexHtml = readFileSync5(indexPath, "utf-8");
3561
3250
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
3562
3251
  headContent = headMatch?.[1] ?? "";
3563
3252
  }
@@ -3594,18 +3283,18 @@ function hasBaseElement(html) {
3594
3283
  }
3595
3284
 
3596
3285
  // src/routes/preview.ts
3597
- import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3286
+ import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
3598
3287
 
3599
3288
  // src/helpers/previewReads.ts
3600
3289
  import { readlinkSync as readlinkSync2 } from "fs";
3601
- import { dirname as dirname5, join as join8, parse, relative as relative4, resolve as resolve4 } from "path";
3290
+ import { dirname as dirname4, join as join7, parse, relative as relative3, resolve as resolve3 } from "path";
3602
3291
  var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
3603
3292
  var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
3604
- var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
3293
+ var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve3(path).toLowerCase() : (path) => resolve3(path);
3605
3294
  var readsByProject = /* @__PURE__ */ new Map();
3606
3295
  var builtProjects = /* @__PURE__ */ new Set();
3607
- var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
3608
- var inRealProject = (projectDir, path) => join8(realFilePath(resolve4(projectDir)), relative4(resolve4(projectDir), resolve4(projectDir, path)));
3296
+ var projectKey = (projectDir) => pathKey(realFilePath(resolve3(projectDir)));
3297
+ var inRealProject = (projectDir, path) => join7(realFilePath(resolve3(projectDir)), relative3(resolve3(projectDir), resolve3(projectDir, path)));
3609
3298
  var readLink = (path) => {
3610
3299
  try {
3611
3300
  return readlinkSync2(path);
@@ -3621,10 +3310,10 @@ function linksOnTheWay(path) {
3621
3310
  const part = rest.shift();
3622
3311
  if (!part || part === ".") continue;
3623
3312
  if (part === "..") {
3624
- at = dirname5(at);
3313
+ at = dirname4(at);
3625
3314
  continue;
3626
3315
  }
3627
- const next = join8(at, part);
3316
+ const next = join7(at, part);
3628
3317
  const target = hops < 40 ? readLink(next) : null;
3629
3318
  if (target === null) {
3630
3319
  at = next;
@@ -3645,7 +3334,7 @@ function recordPreviewRead(projectDir, filePath) {
3645
3334
  if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
3646
3335
  const read = inRealProject(projectDir, filePath);
3647
3336
  for (const found of [read, ...linksOnTheWay(read)]) {
3648
- for (let path = pathKey(found); !reads.has(path); path = dirname5(path)) reads.add(path);
3337
+ for (let path = pathKey(found); !reads.has(path); path = dirname4(path)) reads.add(path);
3649
3338
  }
3650
3339
  }
3651
3340
  function recordPreviewReferences(projectDir, html) {
@@ -3742,10 +3431,10 @@ function injectProjectSignature(html, signature) {
3742
3431
  ${html}`;
3743
3432
  }
3744
3433
  function readStudioMotionManifestContent(projectDir) {
3745
- const manifestPath = join9(projectDir, STUDIO_MOTION_PATH);
3434
+ const manifestPath = join8(projectDir, STUDIO_MOTION_PATH);
3746
3435
  if (!existsSync5(manifestPath)) return "";
3747
3436
  try {
3748
- return readFileSync8(manifestPath, "utf-8");
3437
+ return readFileSync6(manifestPath, "utf-8");
3749
3438
  } catch {
3750
3439
  return "";
3751
3440
  }
@@ -3856,7 +3545,7 @@ function parsePreviewVariablesParam(raw) {
3856
3545
  }
3857
3546
  function variablesEtagSalt(raw) {
3858
3547
  if (!raw) return "";
3859
- return `:vars:${createHash4("sha1").update(raw).digest("hex").slice(0, 12)}`;
3548
+ return `:vars:${createHash("sha1").update(raw).digest("hex").slice(0, 12)}`;
3860
3549
  }
3861
3550
  function previewVariablesFromRequest(rawVariables) {
3862
3551
  const parse2 = parsePreviewVariablesParam(rawVariables);
@@ -3890,19 +3579,19 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
3890
3579
  }
3891
3580
  }
3892
3581
  function resolveProjectMainHtml(projectDir, projectId) {
3893
- const indexPath = join9(projectDir, "index.html");
3894
- const blockHtmlPath = join9(projectDir, `${projectId}.html`);
3582
+ const indexPath = join8(projectDir, "index.html");
3583
+ const blockHtmlPath = join8(projectDir, `${projectId}.html`);
3895
3584
  recordPreviewRead(projectDir, indexPath);
3896
3585
  recordPreviewRead(projectDir, blockHtmlPath);
3897
3586
  if (existsSync5(indexPath)) {
3898
3587
  return {
3899
- html: readFileSync8(indexPath, "utf-8"),
3588
+ html: readFileSync6(indexPath, "utf-8"),
3900
3589
  compositionPath: "index.html"
3901
3590
  };
3902
3591
  }
3903
3592
  if (existsSync5(blockHtmlPath)) {
3904
3593
  return {
3905
- html: readFileSync8(blockHtmlPath, "utf-8"),
3594
+ html: readFileSync6(blockHtmlPath, "utf-8"),
3906
3595
  compositionPath: `${projectId}.html`
3907
3596
  };
3908
3597
  }
@@ -3929,7 +3618,7 @@ function registerPreviewRoutes(api, adapter) {
3929
3618
  const previewBuilds = /* @__PURE__ */ new Map();
3930
3619
  async function buildPreview(project, previewVariables, builtKey, capture) {
3931
3620
  const diskMain = resolveProjectMainHtml(project.dir, project.id);
3932
- const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
3621
+ const normalizedDisk = diskMain ? ensureHfIds2(diskMain.html) : null;
3933
3622
  try {
3934
3623
  let bundled = await adapter.bundle(project.dir, {
3935
3624
  stampHfIds: true,
@@ -3954,7 +3643,7 @@ ${runtimeTag}`;
3954
3643
  bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
3955
3644
  }
3956
3645
  bundled = injectStudioPreviewAugmentations(
3957
- ensureHfIds3(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
3646
+ ensureHfIds2(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
3958
3647
  adapter,
3959
3648
  project.dir,
3960
3649
  mainCompositionPath,
@@ -3975,7 +3664,7 @@ ${runtimeTag}`;
3975
3664
  } catch {
3976
3665
  const fallback = resolveProjectMainHtml(project.dir, project.id);
3977
3666
  if (fallback) {
3978
- const fallbackHtml = ensureHfIds3(fallback.html);
3667
+ const fallbackHtml = ensureHfIds2(fallback.html);
3979
3668
  let fallbackAugmented = injectStudioPreviewAugmentations(
3980
3669
  await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
3981
3670
  adapter,
@@ -4035,7 +3724,7 @@ ${etag}`;
4035
3724
  function pinSubCompHfIds(compFile, compPath) {
4036
3725
  if (!/\.html?$/i.test(compPath)) return void 0;
4037
3726
  try {
4038
- return ensureHfIds3(readFileSync8(compFile, "utf-8"));
3727
+ return ensureHfIds2(readFileSync6(compFile, "utf-8"));
4039
3728
  } catch {
4040
3729
  return null;
4041
3730
  }
@@ -4054,7 +3743,7 @@ ${etag}`;
4054
3743
  if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
4055
3744
  return c.text("not found", 404);
4056
3745
  }
4057
- const compPathHash = createHash4("sha1").update(compPath).digest("hex");
3746
+ const compPathHash = createHash("sha1").update(compPath).digest("hex");
4058
3747
  const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
4059
3748
  const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
4060
3749
  const ifNoneMatch = c.req.header("If-None-Match");
@@ -4076,7 +3765,7 @@ ${etag}`;
4076
3765
  );
4077
3766
  if (!html) return c.text("not found", 404);
4078
3767
  recordPreviewReferences(project.dir, html);
4079
- html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
3768
+ html = ensureHfIds2(await transformPreviewHtml(html, adapter, project, compPath));
4080
3769
  html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture);
4081
3770
  if (previewVariables) html = injectPreviewVariables(html, previewVariables);
4082
3771
  html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
@@ -4086,14 +3775,14 @@ ${etag}`;
4086
3775
  const project = await adapter.resolveProject(c.req.param("id"));
4087
3776
  if (!project) return c.json({ error: "not found" }, 404);
4088
3777
  const subPath = requestSubPath(c.req.url, "projects/:id/preview");
4089
- const candidate = resolve5(project.dir, subPath);
3778
+ const candidate = resolve4(project.dir, subPath);
4090
3779
  const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
4091
3780
  if (!file) {
4092
3781
  return c.text("not found", 404);
4093
3782
  }
4094
3783
  recordPreviewRead(project.dir, file);
4095
- const stat3 = existsSync5(file) ? statSync4(file) : null;
4096
- if (!stat3?.isFile()) {
3784
+ const stat2 = existsSync5(file) ? statSync4(file) : null;
3785
+ if (!stat2?.isFile()) {
4097
3786
  return c.text("not found", 404);
4098
3787
  }
4099
3788
  const contentType = getMimeType(subPath);
@@ -4115,7 +3804,7 @@ ${etag}`;
4115
3804
  return c.text("media proxy variant does not match asset", 422);
4116
3805
  }
4117
3806
  }
4118
- const tag = settledFileTag(stat3);
3807
+ const tag = settledFileTag(stat2);
4119
3808
  const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
4120
3809
  const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
4121
3810
  if (!isText && etag) {
@@ -4127,19 +3816,26 @@ ${etag}`;
4127
3816
  let servedPath = file;
4128
3817
  let servedContentType = contentType;
4129
3818
  if (proxyVariant !== void 0) {
4130
- recordProxyRequest();
4131
3819
  try {
4132
- servedPath = await resolveProxy(project.dir, file, proxyVariant);
3820
+ servedPath = await waitForProxy(resolveProxy(project.dir, file, proxyVariant), 0);
4133
3821
  } catch (err) {
3822
+ if (err instanceof ProxyWaitTimeoutError) {
3823
+ return c.text("media proxy is being made", 202, {
3824
+ "Retry-After": String(PROXY_PENDING_RETRY_AFTER_SECONDS),
3825
+ "Cache-Control": "no-store"
3826
+ });
3827
+ }
4134
3828
  if (err instanceof ProxyCapacityError) {
4135
3829
  return c.text(err.message, 503, { "Retry-After": "5" });
4136
3830
  }
3831
+ if (isProjectRootMissing(err)) throw err;
4137
3832
  const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
4138
3833
  return c.text(message, 502);
4139
3834
  }
3835
+ recordProxyRequest();
4140
3836
  servedContentType = PROXY_VARIANT_CONFIG[proxyVariant].contentType;
4141
3837
  }
4142
- const textBuffer = isText ? Buffer.from(readFileSync8(file, "utf-8"), "utf-8") : null;
3838
+ const textBuffer = isText ? Buffer.from(readFileSync6(file, "utf-8"), "utf-8") : null;
4143
3839
  const totalSize = textBuffer ? textBuffer.length : statSync4(servedPath).size;
4144
3840
  const bodyFor = (start, end) => textBuffer ? new Uint8Array(textBuffer.subarray(start, end + 1)) : (
4145
3841
  // Node's web stream type and the DOM one do not overlap for tsc on
@@ -4183,8 +3879,8 @@ ${etag}`;
4183
3879
  }
4184
3880
 
4185
3881
  // src/routes/lint.ts
4186
- import { readFileSync as readFileSync9 } from "fs";
4187
- import { join as join10 } from "path";
3882
+ import { readFileSync as readFileSync7 } from "fs";
3883
+ import { join as join9 } from "path";
4188
3884
  async function collectProjectFindings(adapter, projectDir) {
4189
3885
  const findings = [];
4190
3886
  const coveredFiles = /* @__PURE__ */ new Set();
@@ -4202,7 +3898,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
4202
3898
  const findings = [];
4203
3899
  for (const file of htmlFiles) {
4204
3900
  if (coveredFiles.has(file)) continue;
4205
- const content = readFileSync9(join10(projectDir, file), "utf-8");
3901
+ const content = readFileSync7(join9(projectDir, file), "utf-8");
4206
3902
  const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
4207
3903
  for (const finding of result?.findings ?? []) {
4208
3904
  findings.push({ ...finding, file });
@@ -4235,8 +3931,8 @@ function registerLintRoutes(api, adapter) {
4235
3931
 
4236
3932
  // src/routes/render.ts
4237
3933
  import { streamSSE } from "hono/streaming";
4238
- import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync4, statSync as statSync5 } from "fs";
4239
- import { join as join11 } from "path";
3934
+ import { existsSync as existsSync6, readFileSync as readFileSync8, unlinkSync as unlinkSync3, readdirSync as readdirSync3, statSync as statSync5 } from "fs";
3935
+ import { join as join10 } from "path";
4240
3936
  import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
4241
3937
  import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
4242
3938
  var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
@@ -4288,6 +3984,7 @@ function registerRenderRoutes(api, adapter) {
4288
3984
  let composition;
4289
3985
  if (typeof body.composition === "string" && body.composition.length > 0) {
4290
3986
  if (!resolveWithinProject(project.dir, body.composition)) {
3987
+ if (folderGone(project.dir)) return projectDirMissing(c);
4291
3988
  return c.json({ error: "composition path must be within the project directory" }, 400);
4292
3989
  }
4293
3990
  composition = body.composition;
@@ -4302,9 +3999,9 @@ function registerRenderRoutes(api, adapter) {
4302
3999
  const now = /* @__PURE__ */ new Date();
4303
4000
  const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
4304
4001
  const rendersDir = adapter.rendersDir(project);
4305
- if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
4002
+ mkdirWithinProject(project.dir, rendersDir);
4306
4003
  const ext = FORMAT_EXT[format] ?? ".mp4";
4307
- const outputPath = join11(rendersDir, `${jobId}${ext}`);
4004
+ const outputPath = join10(rendersDir, `${jobId}${ext}`);
4308
4005
  const jobState = adapter.startRender({
4309
4006
  project,
4310
4007
  outputPath,
@@ -4373,7 +4070,7 @@ function registerRenderRoutes(api, adapter) {
4373
4070
  }
4374
4071
  const contentType = renderContentType(job.outputPath);
4375
4072
  const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
4376
- const content = readFileSync10(job.outputPath);
4073
+ const content = readFileSync8(job.outputPath);
4377
4074
  return new Response(content, {
4378
4075
  headers: {
4379
4076
  "Content-Type": contentType,
@@ -4391,7 +4088,7 @@ function registerRenderRoutes(api, adapter) {
4391
4088
  }
4392
4089
  const contentType = renderContentType(job.outputPath);
4393
4090
  const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
4394
- const content = readFileSync10(job.outputPath);
4091
+ const content = readFileSync8(job.outputPath);
4395
4092
  return new Response(content, {
4396
4093
  headers: {
4397
4094
  "Content-Type": contentType,
@@ -4405,7 +4102,7 @@ function registerRenderRoutes(api, adapter) {
4405
4102
  if (state.id === jobId && state.outputPath) {
4406
4103
  const dir = state.outputPath.replace(/\/[^/]+$/, "");
4407
4104
  for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
4408
- const fp = join11(dir, `${jobId}${ext}`);
4105
+ const fp = join10(dir, `${jobId}${ext}`);
4409
4106
  if (existsSync6(fp)) unlinkSync3(fp);
4410
4107
  }
4411
4108
  break;
@@ -4424,7 +4121,7 @@ function registerRenderRoutes(api, adapter) {
4424
4121
  if (!fp) return c.json({ error: "forbidden" }, 403);
4425
4122
  if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
4426
4123
  const contentType = renderContentType(fp);
4427
- const content = readFileSync10(fp);
4124
+ const content = readFileSync8(fp);
4428
4125
  return new Response(content, {
4429
4126
  headers: {
4430
4127
  "Content-Type": contentType,
@@ -4439,16 +4136,16 @@ function registerRenderRoutes(api, adapter) {
4439
4136
  if (!project) return c.json({ error: "not found" }, 404);
4440
4137
  const rendersDir = adapter.rendersDir(project);
4441
4138
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4442
- const files = readdirSync4(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4443
- const fp = join11(rendersDir, f);
4444
- const stat3 = statSync5(fp);
4139
+ const files = readdirSync3(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4140
+ const fp = join10(rendersDir, f);
4141
+ const stat2 = statSync5(fp);
4445
4142
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4446
- const metaPath = join11(rendersDir, `${rid}.meta.json`);
4143
+ const metaPath = join10(rendersDir, `${rid}.meta.json`);
4447
4144
  let status = "complete";
4448
4145
  let durationMs;
4449
4146
  if (existsSync6(metaPath)) {
4450
4147
  try {
4451
- const meta = JSON.parse(readFileSync10(metaPath, "utf-8"));
4148
+ const meta = JSON.parse(readFileSync8(metaPath, "utf-8"));
4452
4149
  if (meta.status === "failed" && !existsSync6(fp)) status = "failed";
4453
4150
  if (meta.durationMs) durationMs = meta.durationMs;
4454
4151
  } catch {
@@ -4457,8 +4154,8 @@ function registerRenderRoutes(api, adapter) {
4457
4154
  return {
4458
4155
  id: rid,
4459
4156
  filename: f,
4460
- size: stat3.size,
4461
- createdAt: stat3.mtimeMs,
4157
+ size: stat2.size,
4158
+ createdAt: stat2.mtimeMs,
4462
4159
  status,
4463
4160
  durationMs
4464
4161
  };
@@ -4469,7 +4166,7 @@ function registerRenderRoutes(api, adapter) {
4469
4166
  id: file.id,
4470
4167
  status: file.status,
4471
4168
  progress: 100,
4472
- outputPath: join11(rendersDir, file.filename),
4169
+ outputPath: join10(rendersDir, file.filename),
4473
4170
  createdAt: file.createdAt
4474
4171
  });
4475
4172
  }
@@ -4481,7 +4178,7 @@ function registerRenderRoutes(api, adapter) {
4481
4178
  // src/routes/imageThumbnail.ts
4482
4179
  import { HTTPException } from "hono/http-exception";
4483
4180
  import { open, stat } from "fs/promises";
4484
- import { createHash as createHash5 } from "crypto";
4181
+ import { createHash as createHash2 } from "crypto";
4485
4182
  import sharp from "sharp";
4486
4183
 
4487
4184
  // src/routes/thumbnailGenerationCoordinator.ts
@@ -4501,10 +4198,10 @@ var ThumbnailGenerationCoordinator = class {
4501
4198
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4502
4199
  let entry = this.entries.get(key);
4503
4200
  if (!entry) {
4504
- let resolve9;
4201
+ let resolve6;
4505
4202
  let reject;
4506
4203
  const promise = new Promise((resolvePromise, rejectPromise) => {
4507
- resolve9 = resolvePromise;
4204
+ resolve6 = resolvePromise;
4508
4205
  reject = rejectPromise;
4509
4206
  });
4510
4207
  entry = {
@@ -4514,7 +4211,7 @@ var ThumbnailGenerationCoordinator = class {
4514
4211
  state: "queued",
4515
4212
  work,
4516
4213
  promise,
4517
- resolve: resolve9,
4214
+ resolve: resolve6,
4518
4215
  reject
4519
4216
  };
4520
4217
  this.entries.set(key, entry);
@@ -4528,7 +4225,7 @@ var ThumbnailGenerationCoordinator = class {
4528
4225
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4529
4226
  }
4530
4227
  lease(entry, signal) {
4531
- return new Promise((resolve9, reject) => {
4228
+ return new Promise((resolve6, reject) => {
4532
4229
  let released = false;
4533
4230
  const release = () => {
4534
4231
  if (released) return;
@@ -4552,7 +4249,7 @@ var ThumbnailGenerationCoordinator = class {
4552
4249
  entry.promise.then(
4553
4250
  (value) => {
4554
4251
  release();
4555
- resolve9(value);
4252
+ resolve6(value);
4556
4253
  },
4557
4254
  (reason) => {
4558
4255
  release();
@@ -4617,14 +4314,14 @@ async function generateThumbnail(path, signal) {
4617
4314
  async function resolveSource(adapter, id, url) {
4618
4315
  const project = await adapter.resolveProject(id);
4619
4316
  if (!project) throw new HTTPException(404);
4620
- let relative6;
4317
+ let relative4;
4621
4318
  try {
4622
- relative6 = requestSubPath(url, "projects/:id/image-thumbnail");
4319
+ relative4 = requestSubPath(url, "projects/:id/image-thumbnail");
4623
4320
  } catch {
4624
4321
  throw new HTTPException(400);
4625
4322
  }
4626
- if (!/\.jpe?g$/i.test(relative6)) throw new HTTPException(415);
4627
- const path = pinWithinProject(project.dir, relative6);
4323
+ if (!/\.jpe?g$/i.test(relative4)) throw new HTTPException(415);
4324
+ const path = pinWithinProject(project.dir, relative4);
4628
4325
  if (!path) throw new HTTPException(404);
4629
4326
  const info = await stat(path).catch(() => {
4630
4327
  throw new HTTPException(404);
@@ -4652,7 +4349,7 @@ function registerImageThumbnailRoutes(api, adapter) {
4652
4349
  api.get("/projects/:id/image-thumbnail/*", async (c) => {
4653
4350
  try {
4654
4351
  const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url);
4655
- const key = createHash5("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
4352
+ const key = createHash2("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
4656
4353
  const etag = `"${key}"`;
4657
4354
  c.header("ETag", etag);
4658
4355
  c.header("Cache-Control", "private, no-cache");
@@ -4680,23 +4377,22 @@ function registerImageThumbnailRoutes(api, adapter) {
4680
4377
 
4681
4378
  // src/routes/thumbnail.ts
4682
4379
  import {
4683
- closeSync as closeSync3,
4684
- fstatSync as fstatSync3,
4685
- openSync as openSync3,
4686
- mkdirSync as mkdirSync6,
4687
- readFileSync as readFileSync12,
4688
- readdirSync as readdirSync5,
4380
+ closeSync as closeSync2,
4381
+ fstatSync as fstatSync2,
4382
+ openSync as openSync2,
4383
+ readFileSync as readFileSync10,
4384
+ readdirSync as readdirSync4,
4689
4385
  renameSync as renameSync3,
4690
4386
  rmSync as rmSync4,
4691
4387
  statSync as statSync6,
4692
4388
  unlinkSync as unlinkSync4,
4693
- writeFileSync as writeFileSync6
4389
+ writeFileSync as writeFileSync5
4694
4390
  } from "fs";
4695
- import { join as join12 } from "path";
4696
- import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
4391
+ import { join as join11 } from "path";
4392
+ import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
4697
4393
 
4698
4394
  // src/helpers/compositionInputs.ts
4699
- import { readFileSync as readFileSync11 } from "fs";
4395
+ import { readFileSync as readFileSync9 } from "fs";
4700
4396
  var ROOT_COMPOSITION = "index.html";
4701
4397
  var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
4702
4398
  function projectReader(projectDir) {
@@ -4706,7 +4402,7 @@ function projectReader(projectDir) {
4706
4402
  const file = resolveWithinProject(projectDir, compPath);
4707
4403
  let source = null;
4708
4404
  try {
4709
- source = file ? readFileSync11(file, "utf-8") : null;
4405
+ source = file ? readFileSync9(file, "utf-8") : null;
4710
4406
  } catch {
4711
4407
  }
4712
4408
  sources.set(compPath, source);
@@ -4762,14 +4458,14 @@ var prunedCacheDirs = /* @__PURE__ */ new Set();
4762
4458
  function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4763
4459
  let entries;
4764
4460
  try {
4765
- entries = readdirSync5(cacheDir, { withFileTypes: true });
4461
+ entries = readdirSync4(cacheDir, { withFileTypes: true });
4766
4462
  } catch (err) {
4767
4463
  if (err.code === "ENOENT") return;
4768
4464
  throw err;
4769
4465
  }
4770
4466
  const files = entries.flatMap((entry) => {
4771
4467
  if (!entry.isFile()) return [];
4772
- const path = join12(cacheDir, entry.name);
4468
+ const path = join11(cacheDir, entry.name);
4773
4469
  try {
4774
4470
  const stats = statSync6(path);
4775
4471
  return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
@@ -4797,9 +4493,9 @@ function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
4797
4493
  }
4798
4494
  }
4799
4495
  function writeThumbnailAtomically(path, buffer) {
4800
- const temporaryPath = `${path}.${process.pid}.${randomUUID4()}.tmp`;
4496
+ const temporaryPath = `${path}.${process.pid}.${randomUUID2()}.tmp`;
4801
4497
  try {
4802
- writeFileSync6(temporaryPath, buffer, { flag: "wx" });
4498
+ writeFileSync5(temporaryPath, buffer, { flag: "wx" });
4803
4499
  renameSync3(temporaryPath, path);
4804
4500
  } finally {
4805
4501
  rmSync4(temporaryPath, { force: true });
@@ -4808,7 +4504,7 @@ function writeThumbnailAtomically(path, buffer) {
4808
4504
  function readFileOnce(file) {
4809
4505
  let fd;
4810
4506
  try {
4811
- fd = openSync3(file, "r");
4507
+ fd = openSync2(file, "r");
4812
4508
  } catch (err) {
4813
4509
  const code = err.code;
4814
4510
  if (code === "ENOENT") return "missing";
@@ -4816,17 +4512,17 @@ function readFileOnce(file) {
4816
4512
  throw err;
4817
4513
  }
4818
4514
  try {
4819
- const stat3 = fstatSync3(fd);
4820
- if (!stat3.isFile()) return "not-a-file";
4821
- return { data: readFileSync12(fd), mtimeMs: stat3.mtimeMs };
4515
+ const stat2 = fstatSync2(fd);
4516
+ if (!stat2.isFile()) return "not-a-file";
4517
+ return { data: readFileSync10(fd), mtimeMs: stat2.mtimeMs };
4822
4518
  } finally {
4823
- closeSync3(fd);
4519
+ closeSync2(fd);
4824
4520
  }
4825
4521
  }
4826
4522
  function manifestKey(file) {
4827
4523
  const manifest = readFileOnce(file);
4828
4524
  if (typeof manifest === "string") return { key: "", mtimeMs: -Infinity };
4829
- const hash = createHash6("sha1").update(manifest.data).digest("hex").slice(0, 16);
4525
+ const hash = createHash3("sha1").update(manifest.data).digest("hex").slice(0, 16);
4830
4526
  return { key: `_${hash}`, mtimeMs: Math.round(manifest.mtimeMs) };
4831
4527
  }
4832
4528
  function registerThumbnailRoutes(api, adapter) {
@@ -4863,7 +4559,7 @@ function registerThumbnailRoutes(api, adapter) {
4863
4559
  let sourceKey = "";
4864
4560
  if (source !== "missing") {
4865
4561
  const html = source.data.toString("utf-8");
4866
- sourceKey = `_${createHash6("sha1").update(html).digest("hex").slice(0, 16)}`;
4562
+ sourceKey = `_${createHash3("sha1").update(html).digest("hex").slice(0, 16)}`;
4867
4563
  sourceMtime = Math.round(source.mtimeMs);
4868
4564
  if (!vpWidth) {
4869
4565
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4872,21 +4568,21 @@ function registerThumbnailRoutes(api, adapter) {
4872
4568
  if (hMatch?.[1]) compH = parseInt(hMatch[1]);
4873
4569
  }
4874
4570
  }
4875
- const manualEdits = manifestKey(join12(project.dir, STUDIO_MANUAL_EDITS_PATH));
4876
- const motion = manifestKey(join12(project.dir, STUDIO_MOTION_PATH));
4571
+ const manualEdits = manifestKey(join11(project.dir, STUDIO_MANUAL_EDITS_PATH));
4572
+ const motion = manifestKey(join11(project.dir, STUDIO_MOTION_PATH));
4877
4573
  sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
4878
4574
  const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4879
4575
  const previewPath = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4880
4576
  const previewUrl = `${previewPath}?${PREVIEW_CAPTURE_PARAM}=1`;
4881
- const cacheDir = join12(project.dir, ".thumbnails");
4577
+ const cacheDir = join11(project.dir, ".thumbnails");
4882
4578
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4883
4579
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
4884
- const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4580
+ const inputSignatureKey = `_${createHash3("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
4885
4581
  const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
4886
4582
  const outputWidth = Math.max(1, Math.round(compW * outputScale));
4887
4583
  const outputHeight = Math.max(1, Math.round(compH * outputScale));
4888
4584
  const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
4889
- const cachePath = join12(cacheDir, cacheKey);
4585
+ const cachePath = join11(cacheDir, cacheKey);
4890
4586
  if (!prunedCacheDirs.has(cacheDir)) {
4891
4587
  prunedCacheDirs.add(cacheDir);
4892
4588
  pruneThumbnailCache(
@@ -4900,12 +4596,14 @@ function registerThumbnailRoutes(api, adapter) {
4900
4596
  headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
4901
4597
  });
4902
4598
  }
4903
- if (url.searchParams.get("cached") === "1") return c.body(null, 404);
4599
+ if (url.searchParams.get("cached") === "1")
4600
+ return c.body(null, 204, { "Cache-Control": "no-cache" });
4904
4601
  try {
4905
4602
  const buffer = await thumbnailGenerationCoordinator.acquire(
4906
4603
  cachePath,
4907
4604
  c.req.raw.signal,
4908
4605
  async (signal) => {
4606
+ const previewCopiesAtStart = proxyActivityMark(project.dir);
4909
4607
  const generated = await adapter.generateThumbnail({
4910
4608
  project,
4911
4609
  compPath,
@@ -4921,12 +4619,13 @@ function registerThumbnailRoutes(api, adapter) {
4921
4619
  signal
4922
4620
  });
4923
4621
  if (!generated) return null;
4622
+ const previewCopiesAtEnd = proxyActivityMark(project.dir);
4924
4623
  const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
4925
4624
  const inputsUnchanged = (signature) => compositionInputSignature(project.dir, compPath, signature) === inputSignature;
4926
- if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4625
+ if (previewCopiesAtStart === null || previewCopiesAtEnd !== previewCopiesAtStart || afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
4927
4626
  return generated;
4928
4627
  }
4929
- mkdirSync6(cacheDir, { recursive: true });
4628
+ mkdirWithinProject(project.dir, cacheDir);
4930
4629
  writeThumbnailAtomically(cachePath, generated);
4931
4630
  return generated;
4932
4631
  }
@@ -4945,6 +4644,7 @@ function registerThumbnailRoutes(api, adapter) {
4945
4644
  if (err instanceof DOMException && err.name === "AbortError") {
4946
4645
  return new Response(null, { status: 499 });
4947
4646
  }
4647
+ if (isProjectRootMissing(err)) throw err;
4948
4648
  const msg = err instanceof Error ? err.message : String(err);
4949
4649
  return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
4950
4650
  }
@@ -4952,25 +4652,25 @@ function registerThumbnailRoutes(api, adapter) {
4952
4652
  }
4953
4653
 
4954
4654
  // src/routes/waveform.ts
4955
- import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
4956
- import { join as join13, resolve as resolve6 } from "path";
4655
+ import { existsSync as existsSync7, readFileSync as readFileSync11, statSync as statSync7 } from "fs";
4656
+ import { join as join12, resolve as resolve5 } from "path";
4957
4657
  import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4958
4658
  function registerWaveformRoutes(api, adapter) {
4959
4659
  api.get("/projects/:id/waveform/*", async (c) => {
4960
4660
  const project = await adapter.resolveProject(c.req.param("id"));
4961
4661
  if (!project) return c.json({ error: "not found" }, 404);
4962
4662
  const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
4963
- const audioPath = resolve6(project.dir, assetPath);
4663
+ const audioPath = resolve5(project.dir, assetPath);
4964
4664
  if (!isWithinProjectRoot2(project.dir, audioPath)) {
4965
4665
  return c.json({ error: "file not found" }, 404);
4966
4666
  }
4967
4667
  const stats = statSync7(audioPath, { throwIfNoEntry: false });
4968
4668
  if (!stats) return c.json({ error: "file not found" }, 404);
4969
- const cacheDir = join13(project.dir, ".waveform-cache");
4970
- const cachePath = join13(cacheDir, buildWaveformCacheKey(assetPath, stats));
4669
+ const cacheDir = join12(project.dir, ".waveform-cache");
4670
+ const cachePath = join12(cacheDir, buildWaveformCacheKey(assetPath, stats));
4971
4671
  try {
4972
4672
  if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
4973
- const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
4673
+ const peaks2 = JSON.parse(readFileSync11(cachePath, "utf-8"));
4974
4674
  return c.json({ peaks: peaks2 });
4975
4675
  }
4976
4676
  } catch {
@@ -4990,7 +4690,7 @@ function registerWaveformRoutes(api, adapter) {
4990
4690
  }
4991
4691
 
4992
4692
  // src/routes/fonts.ts
4993
- import { closeSync as closeSync4, constants as constants2, fstatSync as fstatSync4, openSync as openSync4, readSync } from "fs";
4693
+ import { closeSync as closeSync3, constants, fstatSync as fstatSync3, openSync as openSync3, readSync } from "fs";
4994
4694
  import {
4995
4695
  collectFontFileEntries,
4996
4696
  fontDirectories,
@@ -5097,17 +4797,17 @@ function registerFontRoutes(api) {
5097
4797
  if (!located) return c.json({ error: "font not found" }, 404);
5098
4798
  let fd;
5099
4799
  try {
5100
- fd = openSync4(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4800
+ fd = openSync3(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
5101
4801
  } catch {
5102
4802
  return c.json({ error: "font file not accessible" }, 404);
5103
4803
  }
5104
4804
  try {
5105
- const stat3 = fstatSync4(fd);
5106
- if (stat3.size > SYSTEM_FONT_SIZE_LIMIT) {
4805
+ const stat2 = fstatSync3(fd);
4806
+ if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
5107
4807
  return c.json({ error: "font file too large" }, 413);
5108
4808
  }
5109
- const buffer = Buffer.alloc(stat3.size);
5110
- readSync(fd, buffer, 0, stat3.size, 0);
4809
+ const buffer = Buffer.alloc(stat2.size);
4810
+ readSync(fd, buffer, 0, stat2.size, 0);
5111
4811
  const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
5112
4812
  const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
5113
4813
  return new Response(buffer, {
@@ -5119,7 +4819,7 @@ function registerFontRoutes(api) {
5119
4819
  } catch {
5120
4820
  return c.json({ error: "failed to read font file" }, 500);
5121
4821
  } finally {
5122
- closeSync4(fd);
4822
+ closeSync3(fd);
5123
4823
  }
5124
4824
  });
5125
4825
  }
@@ -5139,6 +4839,7 @@ function registerRegistryRoutes(api, adapter) {
5139
4839
  }
5140
4840
  const project = await adapter.resolveProject(c.req.param("id"));
5141
4841
  if (!project) return c.json({ error: "Project not found" }, 404);
4842
+ if (folderGone(project.dir)) return projectDirMissing(c);
5142
4843
  const body = await c.req.json().catch(() => null);
5143
4844
  if (!body?.blockName) {
5144
4845
  return c.json({ error: "blockName is required" }, 400);
@@ -5147,6 +4848,7 @@ function registerRegistryRoutes(api, adapter) {
5147
4848
  const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
5148
4849
  return c.json(result);
5149
4850
  } catch (err) {
4851
+ if (isProjectRootMissing(err)) return projectDirMissing(c);
5150
4852
  const message = err instanceof Error ? err.message : "Install failed";
5151
4853
  return c.json({ error: message }, 500);
5152
4854
  }
@@ -5253,8 +4955,8 @@ function registerSelectionRoutes(api, adapter) {
5253
4955
 
5254
4956
  // src/routes/media.ts
5255
4957
  import { streamSSE as streamSSE2 } from "hono/streaming";
5256
- import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
5257
- import { basename as basename2, dirname as dirname6, extname as extname2, join as join14, posix } from "path";
4958
+ import { existsSync as existsSync8 } from "fs";
4959
+ import { basename as basename2, dirname as dirname5, extname, join as join13, posix } from "path";
5258
4960
  var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
5259
4961
  ".mp4",
5260
4962
  ".mov",
@@ -5272,10 +4974,10 @@ var VIDEO_OUTPUT_EXTENSIONS = /* @__PURE__ */ new Set([".webm", ".mov"]);
5272
4974
  var QUALITIES = /* @__PURE__ */ new Set(["fast", "balanced", "best"]);
5273
4975
  var DEVICES = /* @__PURE__ */ new Set(["auto", "cpu", "coreml", "cuda"]);
5274
4976
  function isVideoPath(path) {
5275
- return VIDEO_EXTENSIONS.has(extname2(path).toLowerCase());
4977
+ return VIDEO_EXTENSIONS.has(extname(path).toLowerCase());
5276
4978
  }
5277
4979
  function isImagePath(path) {
5278
- return IMAGE_EXTENSIONS.has(extname2(path).toLowerCase());
4980
+ return IMAGE_EXTENSIONS.has(extname(path).toLowerCase());
5279
4981
  }
5280
4982
  function normalizeProjectAssetPath(path) {
5281
4983
  return path.trim().replace(/^[.]\//, "").replace(/[?#].*$/, "");
@@ -5284,14 +4986,14 @@ function containsNullByte(path) {
5284
4986
  return path.includes("\0");
5285
4987
  }
5286
4988
  function slugFileBase(path) {
5287
- const name = basename2(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
4989
+ const name = basename2(path, extname(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
5288
4990
  return name || "media";
5289
4991
  }
5290
4992
  function uniqueAssetPath(projectDir, assetPath, taken) {
5291
- const ext = extname2(assetPath);
4993
+ const ext = extname(assetPath);
5292
4994
  const withoutExt = assetPath.slice(0, -ext.length);
5293
4995
  let candidate = assetPath;
5294
- for (let index = 2; taken.has(candidate) || existsSync8(join14(projectDir, candidate)); index++) {
4996
+ for (let index = 2; taken.has(candidate) || existsSync8(join13(projectDir, candidate)); index++) {
5295
4997
  candidate = `${withoutExt}-${index}${ext}`;
5296
4998
  }
5297
4999
  return candidate;
@@ -5391,10 +5093,10 @@ function registerMediaRoutes(api, adapter, options = {}) {
5391
5093
  const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput, taken) : defaultOutputPath(project.dir, inputAssetPath, taken);
5392
5094
  const outputPath = pinWithinProject(project.dir, outputAssetPath);
5393
5095
  if (!outputPath) return c.json({ error: "forbidden" }, 403);
5394
- if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
5096
+ if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname(outputAssetPath).toLowerCase())) {
5395
5097
  return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
5396
5098
  }
5397
- if (inputIsImage && extname2(outputAssetPath).toLowerCase() !== ".png") {
5099
+ if (inputIsImage && extname(outputAssetPath).toLowerCase() !== ".png") {
5398
5100
  return c.json({ error: "image background removal output must be .png" }, 400);
5399
5101
  }
5400
5102
  let backgroundOutputAssetPath;
@@ -5410,8 +5112,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
5410
5112
  return c.json({ error: "forbidden" }, 403);
5411
5113
  }
5412
5114
  }
5413
- mkdirSync7(dirname6(outputPath), { recursive: true });
5414
- if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
5115
+ mkdirWithinProject(project.dir, dirname5(outputPath));
5116
+ if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname5(backgroundOutputPath));
5415
5117
  const jobId = makeJobId(project.id, mediaJobs);
5416
5118
  const state = adapter.startBackgroundRemoval({
5417
5119
  project,
@@ -5468,14 +5170,14 @@ function registerMediaRoutes(api, adapter, options = {}) {
5468
5170
  }
5469
5171
 
5470
5172
  // src/routes/globalAssets.ts
5471
- import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
5173
+ import { existsSync as existsSync9, readFileSync as readFileSync12 } from "fs";
5472
5174
  import { homedir } from "os";
5473
- import { join as join15 } from "path";
5175
+ import { join as join14 } from "path";
5474
5176
  function readGlobalAssets(home = homedir()) {
5475
- const manifestPath = join15(home, ".media", "manifest.jsonl");
5177
+ const manifestPath = join14(home, ".media", "manifest.jsonl");
5476
5178
  if (!existsSync9(manifestPath)) return [];
5477
5179
  const out = [];
5478
- for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
5180
+ for (const line of readFileSync12(manifestPath, "utf8").split("\n")) {
5479
5181
  if (!line.trim()) continue;
5480
5182
  try {
5481
5183
  const rec = JSON.parse(line);
@@ -5492,1259 +5194,6 @@ function registerGlobalAssetRoutes(api) {
5492
5194
  api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
5493
5195
  }
5494
5196
 
5495
- // src/history/projectHistory.ts
5496
- import { randomUUID as randomUUID9 } from "crypto";
5497
- import { readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
5498
- import {
5499
- existsSync as existsSync11,
5500
- lstatSync as lstatSync4,
5501
- statSync as statSync10,
5502
- mkdirSync as mkdirSync11,
5503
- readdirSync as readdirSync7,
5504
- readFileSync as readFileSync19,
5505
- realpathSync,
5506
- rmSync as rmSync6
5507
- } from "fs";
5508
- import { homedir as homedir2 } from "os";
5509
- import { dirname as dirname10, isAbsolute as isAbsolute2, join as join20, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
5510
-
5511
- // src/history/blobStore.ts
5512
- import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
5513
- import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
5514
- import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
5515
- import { dirname as dirname7, join as join16 } from "path";
5516
- var BLOB_HASH = /^[0-9a-f]{64}$/;
5517
- async function hashFile(path) {
5518
- const hash = createHash7("sha256");
5519
- for await (const chunk of createReadStream2(path)) hash.update(chunk);
5520
- return hash.digest("hex");
5521
- }
5522
- async function cloneOrCopy(from, to, beforeReplace) {
5523
- await mkdir(dirname7(to), { recursive: true });
5524
- const temp = `${to}.${randomUUID5()}.tmp`;
5525
- try {
5526
- await copyFile(from, temp, constants3.COPYFILE_FICLONE);
5527
- beforeReplace?.();
5528
- renameSync4(temp, to);
5529
- } catch (error) {
5530
- await rm(temp, { force: true });
5531
- throw error;
5532
- }
5533
- }
5534
- async function openBlobStore(dir) {
5535
- const sizes = /* @__PURE__ */ new Map();
5536
- await mkdir(dir, { recursive: true });
5537
- for (const shard of await readdir(dir))
5538
- for (const hash of await readdir(join16(dir, shard)).catch(() => []))
5539
- sizes.set(hash, (await stat2(join16(dir, shard, hash))).size);
5540
- const pathOf = (hash) => {
5541
- if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
5542
- return join16(dir, hash.slice(0, 2), hash);
5543
- };
5544
- let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5545
- return {
5546
- async put(absPath) {
5547
- const temp = join16(dir, `incoming-${randomUUID5()}`);
5548
- await mkdir(dir, { recursive: true });
5549
- await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
5550
- const hash = await hashFile(temp);
5551
- if (sizes.has(hash)) {
5552
- await rm(temp, { force: true });
5553
- return hash;
5554
- }
5555
- await mkdir(dirname7(pathOf(hash)), { recursive: true });
5556
- const size = (await stat2(temp)).size;
5557
- await rename(temp, pathOf(hash));
5558
- sizes.set(hash, size);
5559
- total += size;
5560
- return hash;
5561
- },
5562
- has: (hash) => sizes.has(hash),
5563
- read: async (hash) => readFile2(pathOf(hash)),
5564
- writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
5565
- bytes: () => total,
5566
- size: (hash) => sizes.get(hash) ?? 0,
5567
- async prune(keep) {
5568
- for (const [hash, size] of sizes) {
5569
- if (keep.has(hash)) continue;
5570
- await rm(pathOf(hash), { force: true });
5571
- sizes.delete(hash);
5572
- total -= size;
5573
- }
5574
- }
5575
- };
5576
- }
5577
-
5578
- // src/history/pruneHistories.ts
5579
- import { randomUUID as randomUUID8 } from "crypto";
5580
- import { readdirSync as readdirSync6, readFileSync as readFileSync17, renameSync as renameSync5, statSync as statSync9, writeFileSync as writeFileSync9 } from "fs";
5581
- import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
5582
- import { tmpdir as tmpdir2 } from "os";
5583
- import { dirname as dirname8, join as join19 } from "path";
5584
-
5585
- // src/history/historyId.ts
5586
- import { randomUUID as randomUUID6 } from "crypto";
5587
- import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
5588
- import { basename as basename3, join as join17, resolve as resolve7 } from "path";
5589
- var ID_PATH = join17(".hyperframes", "history-id");
5590
- var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5591
- var isHistoryId = (name) => ID_SHAPE.test(name);
5592
- function readId(projectDir) {
5593
- try {
5594
- const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
5595
- return ID_SHAPE.test(id) ? id : null;
5596
- } catch {
5597
- return null;
5598
- }
5599
- }
5600
- var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
5601
- function isRecordedFolder(historyDir, folder) {
5602
- const was = readRecord(historyDir);
5603
- if (typeof was?.ino === "number")
5604
- return sameFolder({ ino: was.ino, birthtimeMs: was.born ?? NaN }, folder);
5605
- const unreadableSo0878KeptTheId = typeof was?.dir !== "string";
5606
- if (unreadableSo0878KeptTheId) return true;
5607
- return !isCopyOf0878Folder(was.dir, basename3(historyDir), folder);
5608
- }
5609
- function readRecord(historyDir) {
5610
- try {
5611
- return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
5612
- } catch {
5613
- return null;
5614
- }
5615
- }
5616
- function isCopyOf0878Folder(recordedDir, id, folder) {
5617
- let there;
5618
- try {
5619
- there = statSync8(recordedDir, { throwIfNoEntry: false });
5620
- } catch {
5621
- return false;
5622
- }
5623
- return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
5624
- }
5625
- function projectHistoryId(projectDir, historyRoot) {
5626
- const dir = resolve7(projectDir);
5627
- const folder = statSync8(dir);
5628
- let id = readId(dir);
5629
- if (!id && existsSync10(join17(dir, ID_PATH)))
5630
- throw new HistoryIdError(
5631
- `${join17(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
5632
- );
5633
- if (!id || existsSync10(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
5634
- id = randomUUID6();
5635
- mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
5636
- writeFileSync7(join17(dir, ID_PATH), `${id}
5637
- `);
5638
- }
5639
- recordProject(join17(historyRoot, id), dir, folder);
5640
- return id;
5641
- }
5642
- var HistoryIdError = class extends Error {
5643
- constructor(message) {
5644
- super(message);
5645
- this.name = "HistoryIdError";
5646
- }
5647
- };
5648
- function recordProject(historyDir, dir, folder) {
5649
- const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
5650
- mkdirSync8(historyDir, { recursive: true });
5651
- replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
5652
- }
5653
-
5654
- // src/history/ownerLock.ts
5655
- import { randomUUID as randomUUID7 } from "crypto";
5656
- import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5657
- import { join as join18 } from "path";
5658
- var HistoryBusyError = class extends Error {
5659
- constructor(pid) {
5660
- super(`This project's history is open in another process (pid ${pid}).`);
5661
- this.pid = pid;
5662
- this.name = "HistoryBusyError";
5663
- }
5664
- pid;
5665
- };
5666
- function alive(pid) {
5667
- if (!Number.isInteger(pid) || pid <= 0) return false;
5668
- try {
5669
- process.kill(pid, 0);
5670
- return true;
5671
- } catch (error) {
5672
- return error.code === "EPERM";
5673
- }
5674
- }
5675
- function ownerOf(file) {
5676
- try {
5677
- const text2 = readFileSync16(file, "utf-8");
5678
- return /^\d+$/.test(text2) ? Number(text2) : Number.NaN;
5679
- } catch (error) {
5680
- if (error.code === "ENOENT") return null;
5681
- throw error;
5682
- }
5683
- }
5684
- function claim(file) {
5685
- const draft = `${file}-${randomUUID7()}.tmp`;
5686
- writeFileSync8(draft, String(process.pid));
5687
- try {
5688
- linkSync(draft, file);
5689
- return true;
5690
- } catch (error) {
5691
- if (error.code !== "EEXIST") throw error;
5692
- return false;
5693
- } finally {
5694
- rmSync5(draft, { force: true });
5695
- }
5696
- }
5697
- function releaseOwn(file) {
5698
- if (ownerOf(file) === process.pid) rmSync5(file, { force: true });
5699
- }
5700
- function evictDeadOwner(file) {
5701
- const evictor = `${file}.evict`;
5702
- if (!claim(evictor)) {
5703
- const holder = ownerOf(evictor);
5704
- if (holder !== null && !alive(holder)) rmSync5(evictor, { force: true });
5705
- return false;
5706
- }
5707
- try {
5708
- const pid = ownerOf(file);
5709
- if (pid !== null && !alive(pid)) rmSync5(file, { force: true });
5710
- return true;
5711
- } finally {
5712
- releaseOwn(evictor);
5713
- }
5714
- }
5715
- async function takeHistoryOwnership(home, waitMs) {
5716
- const file = join18(home, "owner.pid");
5717
- const deadline = Date.now() + waitMs;
5718
- mkdirSync9(home, { recursive: true });
5719
- for (; ; ) {
5720
- if (claim(file)) {
5721
- let held = true;
5722
- return () => {
5723
- if (held) releaseOwn(file);
5724
- held = false;
5725
- };
5726
- }
5727
- const pid = ownerOf(file);
5728
- if (pid === null) continue;
5729
- if (!alive(pid) && evictDeadOwner(file)) continue;
5730
- if (Date.now() >= deadline) throw new HistoryBusyError(pid);
5731
- await new Promise((settle) => setTimeout(settle, 50));
5732
- }
5733
- }
5734
-
5735
- // src/history/pruneHistories.ts
5736
- var DAY_MS = 24 * 60 * 60 * 1e3;
5737
- var KEEP_GONE_PROJECT_HISTORY_MS = 14 * DAY_MS;
5738
- var PRUNED_PREFIX = ".pruned-";
5739
- var LAST_PRUNE_FILE = ".last-prune";
5740
- async function pruneGoneProjectHistories(historyRoot, options = {}) {
5741
- return (await pruneWithin(historyRoot, options)).pruned;
5742
- }
5743
- async function pruneWithin(historyRoot, {
5744
- now = Date.now(),
5745
- dryRun = false,
5746
- tempDir = tmpdir2(),
5747
- onError = (error) => console.warn("[history] could not prune a history:", error),
5748
- budgetMs = Infinity,
5749
- measure = true
5750
- }) {
5751
- const started = performance.now();
5752
- let names;
5753
- try {
5754
- names = readdirSync6(historyRoot);
5755
- } catch (error) {
5756
- if (error.code === "ENOENT") return { pruned: [], finished: true };
5757
- throw error;
5758
- }
5759
- const pruned = [];
5760
- for (const name of names) {
5761
- if (performance.now() - started >= budgetMs) return { pruned, finished: false };
5762
- try {
5763
- const gone = await pruneEntry(historyRoot, name, { now, dryRun, tempDir, measure });
5764
- if (gone) pruned.push(gone);
5765
- } catch (error) {
5766
- onError(error);
5767
- }
5768
- }
5769
- return { pruned, finished: true };
5770
- }
5771
- async function pruneEntry(historyRoot, name, run) {
5772
- if (name.startsWith(PRUNED_PREFIX)) {
5773
- if (!run.dryRun) await rm2(join19(historyRoot, name), { recursive: true, force: true });
5774
- return null;
5775
- }
5776
- return isHistoryId(name) ? pruneOne(historyRoot, name, run) : null;
5777
- }
5778
- async function pruneOne(historyRoot, id, run) {
5779
- const home = join19(historyRoot, id);
5780
- if (abandonedProject(home, id, run.now, run.tempDir) === null) return null;
5781
- const release = await takeUnlessBusy(home);
5782
- if (!release) return null;
5783
- let taken;
5784
- try {
5785
- taken = recheckAndMoveAway(historyRoot, id, run);
5786
- } catch (error) {
5787
- release();
5788
- throw error;
5789
- }
5790
- if (!taken?.trash) release();
5791
- if (!taken) return null;
5792
- const bytes = await clearAway(taken.trash, home, run.measure);
5793
- return { id, projectDir: taken.projectDir, bytes };
5794
- }
5795
- async function clearAway(trash, home, measure) {
5796
- const bytes = measure ? await bytesIn(trash ?? home) : 0;
5797
- if (trash) await rm2(trash, { recursive: true, force: true });
5798
- return bytes;
5799
- }
5800
- async function takeUnlessBusy(home) {
5801
- try {
5802
- return await takeHistoryOwnership(home, 0);
5803
- } catch (error) {
5804
- if (error instanceof HistoryBusyError) return null;
5805
- throw error;
5806
- }
5807
- }
5808
- function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
5809
- const home = join19(historyRoot, id);
5810
- const projectDir = abandonedProject(home, id, now, tempDir);
5811
- if (projectDir === null) return null;
5812
- if (dryRun) return { projectDir, trash: null };
5813
- const trash = join19(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID8()}`);
5814
- renameSync5(home, trash);
5815
- return { projectDir, trash };
5816
- }
5817
- function abandonedProject(home, id, now, tempDir) {
5818
- const record = readRecord(home);
5819
- const dir = record?.dir;
5820
- if (typeof dir !== "string") return null;
5821
- if (now - lastUsed(home) < KEEP_GONE_PROJECT_HISTORY_MS && !isSafePath(tempDir, dir)) return null;
5822
- return projectGone(dir, id, record?.dev, tempDir) ? dir : null;
5823
- }
5824
- function projectGone(dir, id, dev, tempDir) {
5825
- if (!statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false }))
5826
- return diskStillHere(dir, dev, tempDir);
5827
- const there = readId(dir);
5828
- return there !== null && there !== id;
5829
- }
5830
- function diskStillHere(dir, dev, tempDir) {
5831
- let at = dir;
5832
- let stat3 = statSync9(at, { throwIfNoEntry: false });
5833
- while (!stat3) {
5834
- if (dirname8(at) === at) return false;
5835
- at = dirname8(at);
5836
- stat3 = statSync9(at, { throwIfNoEntry: false });
5837
- }
5838
- if (typeof dev === "number") return stat3.dev === dev;
5839
- return at === dir || isSafePath(tempDir, at);
5840
- }
5841
- function lastUsed(home) {
5842
- const mtime = (file) => statSync9(join19(home, file), { throwIfNoEntry: false })?.mtimeMs ?? 0;
5843
- return Math.max(mtime("project.json"), mtime("log.jsonl"));
5844
- }
5845
- async function bytesIn(dir) {
5846
- let total = 0;
5847
- for (const entry of await readdir2(dir, { withFileTypes: true })) {
5848
- const path = join19(dir, entry.name);
5849
- total += entry.isDirectory() ? await bytesIn(path) : (await lstat(path)).size;
5850
- }
5851
- return total;
5852
- }
5853
- function prunedWithinADay(stamp, now) {
5854
- try {
5855
- const last = Number(readFileSync17(stamp, "utf-8"));
5856
- return last <= now && now - last < DAY_MS;
5857
- } catch {
5858
- return false;
5859
- }
5860
- }
5861
- var pruning = /* @__PURE__ */ new Set();
5862
- function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs } = {}) {
5863
- const stamp = join19(historyRoot, LAST_PRUNE_FILE);
5864
- if (pruning.has(historyRoot) || prunedWithinADay(stamp, now)) return;
5865
- pruning.add(historyRoot);
5866
- setImmediate(() => {
5867
- pruneWithin(historyRoot, { now, onError, budgetMs, measure: false }).then(({ finished }) => finished && writeFileSync9(stamp, String(now))).catch((error) => onError?.(error)).finally(() => pruning.delete(historyRoot));
5868
- });
5869
- }
5870
-
5871
- // src/history/historyLog.ts
5872
- import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync18, writeSync as writeSync2 } from "fs";
5873
- import { dirname as dirname9 } from "path";
5874
- var START = "start";
5875
- function parseRecord(line) {
5876
- try {
5877
- return JSON.parse(line);
5878
- } catch {
5879
- return null;
5880
- }
5881
- }
5882
- function readLog(file, onUnreadable) {
5883
- let text2;
5884
- try {
5885
- text2 = readFileSync18(file, "utf-8");
5886
- } catch {
5887
- return null;
5888
- }
5889
- const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5890
- const lines = text2.split("\n");
5891
- lines.forEach((line, index) => {
5892
- const record = line ? parseRecord(line) : null;
5893
- if (record) applyRecord(log, record);
5894
- else if (line && index < lines.length - 1) onUnreadable(index + 1);
5895
- });
5896
- return log;
5897
- }
5898
- function applyRecord(log, record) {
5899
- if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
5900
- else if (record.type === "entry") log.entries.push(record.entry);
5901
- else if (record.pinned) log.pins.add(record.id);
5902
- else log.pins.delete(record.id);
5903
- }
5904
- function saveRecord(file, log, record) {
5905
- let fd;
5906
- try {
5907
- fd = openSync5(file, constants4.O_WRONLY | constants4.O_APPEND);
5908
- } catch (error) {
5909
- if (error.code !== "ENOENT") throw error;
5910
- return writeLog(file, log);
5911
- }
5912
- try {
5913
- writeSync2(fd, `${JSON.stringify(record)}
5914
- `);
5915
- } finally {
5916
- closeSync5(fd);
5917
- }
5918
- }
5919
- function writeLog(file, log) {
5920
- const records = [
5921
- { type: "baseline", files: Object.fromEntries(log.baseline) },
5922
- ...log.entries.map((entry) => ({ type: "entry", entry })),
5923
- ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5924
- ];
5925
- mkdirSync10(dirname9(file), { recursive: true });
5926
- replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5927
- `).join(""), 420);
5928
- }
5929
- function applyEntry(manifest, entry, side) {
5930
- for (const file of entry.files) {
5931
- const hash = file[side];
5932
- if (hash === null) manifest.delete(file.path);
5933
- else manifest.set(file.path, hash);
5934
- }
5935
- }
5936
- function manifestAt(log, point) {
5937
- return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
5938
- }
5939
- function manifestAround(log, id, side) {
5940
- const manifest = new Map(log.baseline);
5941
- for (const entry of log.entries) {
5942
- applyEntry(manifest, entry, entry.id === id ? side : "after");
5943
- if (entry.id === id) return manifest;
5944
- }
5945
- return null;
5946
- }
5947
- function undoneIds(entries) {
5948
- const undone = /* @__PURE__ */ new Set();
5949
- for (let i = entries.length - 1; i >= 0; i -= 1) {
5950
- const entry = entries[i];
5951
- if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
5952
- }
5953
- return undone;
5954
- }
5955
- function stepTarget(entries, direction, mine) {
5956
- const undone = undoneIds(entries);
5957
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
5958
- const owns = ownerOf2(entries, byId, mine);
5959
- const newestFirst = [...entries].reverse();
5960
- if (direction === "back")
5961
- return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
5962
- for (const entry of newestFirst) {
5963
- if (!entry.undoes) {
5964
- if (owns(entry)) return void 0;
5965
- continue;
5966
- }
5967
- const target = byId.get(entry.undoes);
5968
- if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
5969
- }
5970
- return void 0;
5971
- }
5972
- function ownerOf2(entries, byId, mine) {
5973
- const redoneBy = /* @__PURE__ */ new Map();
5974
- for (const entry of entries) {
5975
- const target = entry.undoes ? byId.get(entry.undoes) : void 0;
5976
- if (target?.undoes) redoneBy.set(target.undoes, entry);
5977
- }
5978
- return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
5979
- }
5980
- function foldOldest(log) {
5981
- const oldest = log.entries[0];
5982
- if (!oldest || log.pins.has(oldest.id)) return false;
5983
- applyEntry(log.baseline, oldest, "after");
5984
- log.entries.shift();
5985
- return true;
5986
- }
5987
- function referencedHashes(log, current) {
5988
- const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
5989
- for (const entry of log.entries)
5990
- for (const file of entry.files) {
5991
- if (file.before) keep.add(file.before);
5992
- if (file.after) keep.add(file.after);
5993
- }
5994
- return keep;
5995
- }
5996
-
5997
- // src/history/projectHistory.ts
5998
- var DEFAULT_HISTORY_ROOT = join20(homedir2(), ".cache", "hyperframes", "history");
5999
- var MAX_WINDOW_IDLE_MS = 10 * 6e4;
6000
- var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
6001
- var OUTSIDE = { kind: "outside", name: "Outside" };
6002
- var OUTSIDE_LABEL = "Changed outside the app";
6003
- var RACY_MS = 2e3;
6004
- var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
6005
- var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
6006
- function splitAt(change, at, kept) {
6007
- if (at === change.after) return [change, null];
6008
- if (!at || at === change.before || !kept) return [null, change];
6009
- return [
6010
- { ...change, after: at },
6011
- { ...change, before: at }
6012
- ];
6013
- }
6014
- var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
6015
- async function removeEmptyFolders(dir) {
6016
- let entries;
6017
- try {
6018
- entries = await readdir3(dir, { withFileTypes: true });
6019
- } catch (error) {
6020
- if (isMissingFolder(error)) return;
6021
- throw error;
6022
- }
6023
- for (const entry of entries)
6024
- if (entry.isDirectory()) await removeEmptyFolders(join20(dir, entry.name));
6025
- await rmdir(dir);
6026
- }
6027
- function fileLeftIn(dir, at, deleted) {
6028
- for (const entry of readdirSync7(dir, { withFileTypes: true })) {
6029
- const path = `${at}/${entry.name}`;
6030
- const left = entry.isDirectory() ? fileLeftIn(join20(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
6031
- if (left) return left;
6032
- }
6033
- return void 0;
6034
- }
6035
- function ignoresCase(dir) {
6036
- const self = statSync10(join20(dir, ID_PATH), { throwIfNoEntry: false });
6037
- const other = statSync10(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
6038
- return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
6039
- }
6040
- var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
6041
- var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
6042
- function addChange(group, path, before, after) {
6043
- const earlier = group.changes.get(path);
6044
- const from = earlier ? earlier.before : before;
6045
- if (from === after) group.changes.delete(path);
6046
- else group.changes.set(path, { path, before: from, after });
6047
- }
6048
- var Engine = class {
6049
- constructor(options, projectId, blobs) {
6050
- this.options = options;
6051
- this.projectId = projectId;
6052
- this.blobs = blobs;
6053
- this.dir = resolve8(options.projectDir);
6054
- this.home = join20(options.historyRoot, projectId);
6055
- this.foldsCase = ignoresCase(this.dir);
6056
- const folder = statSync10(this.dir, { throwIfNoEntry: false });
6057
- if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
6058
- throw this.replaced();
6059
- this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
6060
- }
6061
- options;
6062
- projectId;
6063
- blobs;
6064
- dir;
6065
- foldsCase;
6066
- folder;
6067
- home;
6068
- log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
6069
- tracked = /* @__PURE__ */ new Map();
6070
- windows = [];
6071
- outside = null;
6072
- /** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
6073
- claimed = null;
6074
- writeToken;
6075
- quietTimer;
6076
- maxTimer;
6077
- notedTimer = null;
6078
- listeners = /* @__PURE__ */ new Set();
6079
- tail = Promise.resolve();
6080
- closed = false;
6081
- closing;
6082
- /** A path as the project's disk compares names: folded where `A` and `a` are one name. */
6083
- nameKey(path) {
6084
- return this.foldsCase ? path.toLowerCase() : path;
6085
- }
6086
- get logFile() {
6087
- return join20(this.home, "log.jsonl");
6088
- }
6089
- now() {
6090
- return (this.options.now ?? Date.now)();
6091
- }
6092
- /** One operation at a time, in order: sweeps, windows and undos never interleave. */
6093
- queue(task) {
6094
- if (this.closed)
6095
- return Promise.reject(new HistoryClosedError("This project's history is closed."));
6096
- const run = this.tail.then(task);
6097
- this.tail = run.catch(() => void 0);
6098
- return run;
6099
- }
6100
- /** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
6101
- whereFolder() {
6102
- const now = statSync10(this.dir, { throwIfNoEntry: false });
6103
- if (!now) return "gone";
6104
- const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
6105
- return same && readId(this.dir) === this.projectId ? "here" : "replaced";
6106
- }
6107
- assertWritable() {
6108
- const folder = this.whereFolder();
6109
- if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
6110
- if (folder === "replaced") throw this.replaced();
6111
- }
6112
- persistLog(record) {
6113
- if (record) saveRecord(this.logFile, this.log, record);
6114
- else writeLog(this.logFile, this.log);
6115
- if (!existsSync11(join20(this.home, "project.json")))
6116
- recordProject(this.home, this.dir, this.folder);
6117
- }
6118
- replaced() {
6119
- return new HistoryClosedError(`${this.dir} is now another project.`);
6120
- }
6121
- assertOpen() {
6122
- if (this.closed) throw new HistoryClosedError("This project's history is closed.");
6123
- if (this.whereFolder() === "replaced") throw this.replaced();
6124
- }
6125
- async start() {
6126
- const log = readLog(
6127
- this.logFile,
6128
- (line) => this.options.onError?.(
6129
- new Error(`History log line ${line} could not be read and was skipped.`)
6130
- )
6131
- );
6132
- if (!log) return this.firstOpen();
6133
- this.log = log;
6134
- const cache = this.readStatCache();
6135
- const last = this.log.entries.at(-1)?.id ?? START;
6136
- for (const [path, hash] of manifestAt(this.log, last) ?? []) {
6137
- const cached = cache.get(path);
6138
- this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
6139
- }
6140
- this.reopenClosedWindow();
6141
- await this.settleAll();
6142
- }
6143
- reopenClosedWindow() {
6144
- const closed = this.options.closedWindow;
6145
- if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
6146
- const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
6147
- this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: /* @__PURE__ */ new Map() });
6148
- }
6149
- async firstOpen() {
6150
- const sweptAt = Date.now();
6151
- for (const file of listProjectFiles(this.dir)) {
6152
- const hash = await this.storeIfPresent(file.path);
6153
- if (this.whereFolder() !== "here") throw this.replaced();
6154
- if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
6155
- }
6156
- this.log.baseline = this.manifest();
6157
- this.persistLog();
6158
- this.saveStatCache();
6159
- }
6160
- manifest() {
6161
- return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
6162
- }
6163
- readStatCache() {
6164
- try {
6165
- const files = JSON.parse(readFileSync19(join20(this.home, "stat.json"), "utf-8"));
6166
- return new Map(Object.entries(files));
6167
- } catch {
6168
- return /* @__PURE__ */ new Map();
6169
- }
6170
- }
6171
- saveStatCache() {
6172
- const files = JSON.stringify(Object.fromEntries(this.tracked));
6173
- mkdirSync11(this.home, { recursive: true });
6174
- replaceFileAtomically(join20(this.home, "stat.json"), files, 420);
6175
- }
6176
- /**
6177
- * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
6178
- * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
6179
- */
6180
- async sweep() {
6181
- if (this.whereFolder() !== "here") return;
6182
- const sweptAt = Date.now();
6183
- const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
6184
- const seen = listProjectFiles(this.dir);
6185
- const present = new Set(seen.map((file) => file.path));
6186
- const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
6187
- const standing = this.standingAt(path);
6188
- return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
6189
- });
6190
- const appearedAt = (file) => Math.max(
6191
- changedAt(file),
6192
- ...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
6193
- );
6194
- const events = [
6195
- ...removed,
6196
- ...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
6197
- ].sort((a, b) => a.at - b.at);
6198
- let changed = false;
6199
- for (const { at, path, file } of events) {
6200
- if (file) {
6201
- changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
6202
- continue;
6203
- }
6204
- const known = this.tracked.get(path);
6205
- this.tracked.delete(path);
6206
- await this.record(path, known.hash, null, at);
6207
- changed = true;
6208
- }
6209
- if (changed) this.saveStatCache();
6210
- }
6211
- /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
6212
- async storeIfPresent(path) {
6213
- try {
6214
- return await this.blobs.put(join20(this.dir, path));
6215
- } catch (error) {
6216
- if (error.code === "ENOENT") return null;
6217
- throw error;
6218
- }
6219
- }
6220
- async observe(path, stat3, at) {
6221
- const known = this.tracked.get(path) ?? { hash: null, stat: null };
6222
- if (stat3 && known.stat === stat3) return false;
6223
- const hash = await this.storeIfPresent(path);
6224
- if (hash === null || this.whereFolder() !== "here") return false;
6225
- this.tracked.set(path, { hash, stat: stat3 });
6226
- if (known.hash !== hash) await this.record(path, known.hash, hash, at);
6227
- return known.hash !== hash || known.stat !== stat3;
6228
- }
6229
- async record(path, before, after, at) {
6230
- const endedBeforeThisWrite = this.windows.filter((open2) => !takesWrite(open2, at));
6231
- if (endedBeforeThisWrite.length) await this.commitOutside();
6232
- for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
6233
- const window = this.windows.at(-1);
6234
- if (window) this.touch(window, at);
6235
- addChange(window ?? this.outsideGroup(), path, before, after);
6236
- }
6237
- /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
6238
- async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
6239
- await this.sweep();
6240
- const taken = await this.takeClaimed(who, paths, overwrote);
6241
- if (!taken.length) return this.claimNothing(coalesceKey);
6242
- const held = this.heldFor(coalesceKey, taken);
6243
- if (!held) await this.commitClaim();
6244
- await this.commitPending();
6245
- const group = held ?? this.newGroup(who, label);
6246
- for (const change of taken) addChange(group, change.path, change.before, change.after);
6247
- if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
6248
- const entry = await this.commit(group);
6249
- return entry && { id: entry.id };
6250
- }
6251
- /** A claim that took nothing still ends a held claim under another key. */
6252
- async claimNothing(coalesceKey) {
6253
- if (coalesceKey !== this.claimed?.key) await this.commitClaim();
6254
- return null;
6255
- }
6256
- /** Commits every other writer's pending changes, oldest first; open windows stay open. */
6257
- async commitPending() {
6258
- await this.commitOutside();
6259
- for (const window of [...this.windows])
6260
- if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
6261
- }
6262
- pendingElsewhere() {
6263
- return Boolean(this.outside?.changes.size) || this.windows.some((open2) => open2.changes.size > 0);
6264
- }
6265
- /** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
6266
- heldFor(key, taken) {
6267
- const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
6268
- const after = (path, before) => {
6269
- const own = group?.changes.get(path);
6270
- return own ? own.after : before;
6271
- };
6272
- const takenPaths = new Set(taken.map((change) => change.path));
6273
- const untouched = (change) => takenPaths.has(change.path) || (this.tracked.get(change.path)?.hash ?? null) === change.after;
6274
- return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
6275
- }
6276
- logPath(path) {
6277
- return relative5(this.dir, resolve8(this.dir, path)).split(sep3).join("/");
6278
- }
6279
- /** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
6280
- async takeClaimed(who, paths, overwrote) {
6281
- const wanted = new Set(paths.map((path) => this.logPath(path)));
6282
- const at = new Map(
6283
- Object.entries(overwrote).map(([path, version]) => [
6284
- this.logPath(path),
6285
- hashOfVersion(version)
6286
- ])
6287
- );
6288
- const others = this.windows.filter((open2) => !sameWho(open2.who, who));
6289
- const groups = this.outside ? [this.outside, ...others] : others;
6290
- const hits = groups.flatMap(
6291
- (group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
6292
- );
6293
- const cuts = [];
6294
- const walked = hits.map(() => /* @__PURE__ */ new Set());
6295
- for (const [i, { change }] of hits.entries()) {
6296
- const told = at.get(change.path);
6297
- cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
6298
- }
6299
- hits.forEach(
6300
- ({ change }, i) => forgetOverwrittenBytes(
6301
- join20(this.dir, change.path),
6302
- new Set([...walked[i]].map(hashVersion))
6303
- )
6304
- );
6305
- return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
6306
- }
6307
- /** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
6308
- async overwrittenBy(change, told, seen) {
6309
- const absPath = join20(this.dir, change.path);
6310
- let bytes;
6311
- for (let hash = change.after; hash; ) {
6312
- if (seen.has(hash)) return void 0;
6313
- seen.add(hash);
6314
- const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
6315
- if (replaced === void 0) break;
6316
- bytes = replaced;
6317
- hash = hashOfVersion(fileContentVersion(replaced));
6318
- if (hash === change.before || hash === told) return hash;
6319
- }
6320
- if (bytes === void 0) return void 0;
6321
- const staged = join20(this.home, `overwrote-${randomUUID9()}`);
6322
- await writeFile(staged, bytes);
6323
- try {
6324
- return await this.blobs.put(staged);
6325
- } finally {
6326
- await rm3(staged, { force: true });
6327
- }
6328
- }
6329
- cutOut(group, change, cut) {
6330
- const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
6331
- group.changes.delete(change.path);
6332
- if (kept) group.changes.set(kept.path, kept);
6333
- return claimed;
6334
- }
6335
- /** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
6336
- * so the writer's next write to a cut file would otherwise span the claimer's edit. */
6337
- async commitCut(window, paths) {
6338
- const part = { ...this.newGroup(window.who, window.label), startedAt: window.startedAt };
6339
- for (const path of paths) {
6340
- part.changes.set(path, window.changes.get(path));
6341
- window.changes.delete(path);
6342
- }
6343
- (window.parts ??= /* @__PURE__ */ new Set()).add(part.id);
6344
- window.entry = await this.commit(part);
6345
- }
6346
- holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
6347
- clearTimeout(this.claimed?.timer);
6348
- const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
6349
- timer?.unref?.();
6350
- this.claimed = { group, key, timer };
6351
- return group.changes.size ? { id: group.id } : null;
6352
- }
6353
- async commitClaim() {
6354
- const held = this.claimed;
6355
- this.claimed = null;
6356
- if (!held) return;
6357
- clearTimeout(held.timer);
6358
- await this.commit(held.group);
6359
- }
6360
- outsideGroup() {
6361
- const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
6362
- const closeIn = (ms) => {
6363
- const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
6364
- timer.unref?.();
6365
- return timer;
6366
- };
6367
- if (!this.outside) {
6368
- this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
6369
- this.maxTimer = closeIn(maxGroupMs);
6370
- }
6371
- clearTimeout(this.quietTimer);
6372
- this.quietTimer = closeIn(quietMs);
6373
- return this.outside;
6374
- }
6375
- newGroup(who, label) {
6376
- return { id: randomUUID9(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
6377
- }
6378
- async commitOutside() {
6379
- if (this.outside?.changes.size) await this.commitClaim();
6380
- clearTimeout(this.quietTimer);
6381
- clearTimeout(this.maxTimer);
6382
- const group = this.outside;
6383
- this.outside = null;
6384
- if (group) await this.commit(group);
6385
- }
6386
- async commit(group, extra = {}) {
6387
- if (!group.changes.size) return null;
6388
- const pending = this.pendingEntry(group);
6389
- const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
6390
- const entry = { ...pending, endedAt, ...extra };
6391
- this.log.entries.push(entry);
6392
- try {
6393
- this.persistLog({ type: "entry", entry });
6394
- } catch (error) {
6395
- this.log.entries.pop();
6396
- throw error;
6397
- }
6398
- for (const listener of this.listeners) listener(entry);
6399
- await this.keepWithinBudget();
6400
- return entry;
6401
- }
6402
- /** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
6403
- historyBytes() {
6404
- let current = 0;
6405
- for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
6406
- current += this.blobs.size(hash);
6407
- return this.blobs.bytes() - current;
6408
- }
6409
- async keepWithinBudget() {
6410
- const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
6411
- let folded = false;
6412
- while (this.historyBytes() > budget && foldOldest(this.log)) {
6413
- folded = true;
6414
- await this.blobs.prune(
6415
- /* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
6416
- );
6417
- }
6418
- if (folded) this.persistLog();
6419
- }
6420
- /** Hashes only pending changes point at yet, such as a claim's stored cut. */
6421
- pendingHashes() {
6422
- const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
6423
- ...group?.changes.values() ?? []
6424
- ]);
6425
- return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
6426
- }
6427
- /** Before an operation or a window: every write is filed and every pending change committed, in write order. */
6428
- async settle() {
6429
- await this.sweep();
6430
- await this.commitClaim();
6431
- await this.commitPending();
6432
- }
6433
- /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
6434
- noteChange(path) {
6435
- if (this.notedTimer || !affectsProjectSignature(this.dir, resolve8(this.dir, path))) return;
6436
- this.notedTimer = setTimeout(() => {
6437
- this.notedTimer = null;
6438
- this.background(() => this.sweep());
6439
- }, 20);
6440
- this.notedTimer.unref?.();
6441
- }
6442
- background(task) {
6443
- this.queue(task).catch((error) => {
6444
- if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
6445
- });
6446
- }
6447
- beginWindow(who, label, idleMs) {
6448
- return this.queue(async () => {
6449
- await this.settle();
6450
- const window = { ...this.newGroup(who, label), idleMs };
6451
- this.windows.push(window);
6452
- this.touch(window, Date.now());
6453
- const close = () => this.queue(() => this.sweepAndEnd(window));
6454
- return { id: window.id, startedAt: window.startedAt, close };
6455
- });
6456
- }
6457
- /** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
6458
- touch(window, at) {
6459
- window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
6460
- clearTimeout(window.idleTimer);
6461
- if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
6462
- window.idleTimer = setTimeout(
6463
- () => this.background(() => this.sweepAndEnd(window)),
6464
- window.idleMs
6465
- );
6466
- window.idleTimer.unref?.();
6467
- }
6468
- async sweepAndEnd(window) {
6469
- await this.sweep();
6470
- return this.endWindow(window);
6471
- }
6472
- /** Commits an open window once; ending it again returns the entry it became. */
6473
- async endWindow(window) {
6474
- if (!this.windows.includes(window)) return window.entry ?? null;
6475
- clearTimeout(window.idleTimer);
6476
- await this.commitClaim();
6477
- this.windows = this.windows.filter((open2) => open2 !== window);
6478
- window.entry = await this.commit(window) ?? window.entry ?? null;
6479
- return window.entry;
6480
- }
6481
- /** Every pending write, open window and outside group, committed: for flush and close. */
6482
- async settleAll() {
6483
- await this.sweep();
6484
- await this.commitClaim();
6485
- for (const window of [...this.windows]) await this.endWindow(window);
6486
- await this.commitOutside();
6487
- }
6488
- /** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
6489
- standingAt(path) {
6490
- const parts = path.split("/");
6491
- for (let depth = 1; depth <= parts.length; depth += 1) {
6492
- const at = parts.slice(0, depth).join("/");
6493
- const stat3 = lstatSync4(join20(this.dir, at), { throwIfNoEntry: false });
6494
- if (!stat3) return void 0;
6495
- if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
6496
- }
6497
- return void 0;
6498
- }
6499
- /** What a write of `path` would have to delete: a file or link at or above it, or a file in a folder at it. */
6500
- inTheWay(path, deleted) {
6501
- const standing = this.standingAt(path);
6502
- if (!standing) return void 0;
6503
- if (standing.stat.isSymbolicLink()) return standing.at;
6504
- if (standing.at === path && !standing.stat.isDirectory()) return void 0;
6505
- const real = realpathSync.native(join20(this.dir, standing.at));
6506
- const at = relative5(realpathSync.native(this.dir), real).split(sep3).join("/");
6507
- if (standing.at !== path) return deleted.has(at) ? void 0 : at;
6508
- return fileLeftIn(real, at, deleted);
6509
- }
6510
- /** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
6511
- async writeAs(who, label, target, extra) {
6512
- this.assertWritable();
6513
- const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
6514
- const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
6515
- const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
6516
- if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
6517
- const group = this.newGroup(who, label);
6518
- this.windows.push(group);
6519
- try {
6520
- for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
6521
- await this.sweep();
6522
- } finally {
6523
- this.windows = this.windows.filter((open2) => open2 !== group);
6524
- }
6525
- return this.commit(group, extra);
6526
- }
6527
- /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
6528
- async writeProjectFile(path, hash) {
6529
- this.assertWritable();
6530
- const absPath = join20(this.dir, path);
6531
- const { writeToken } = this;
6532
- if (writeToken) {
6533
- const version = hash === null ? DELETED_VERSION : hashVersion(hash);
6534
- recordFileWriteReceipt(absPath, { path, version, writeToken });
6535
- }
6536
- if (hash === null) {
6537
- this.assertWritable();
6538
- rmSync6(absPath, { force: true });
6539
- } else {
6540
- await removeEmptyFolders(absPath);
6541
- await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
6542
- }
6543
- }
6544
- entry(id) {
6545
- const entry = this.log.entries.find((candidate) => candidate.id === id);
6546
- if (!entry) throw new Error("That change is no longer kept in this project's history.");
6547
- return entry;
6548
- }
6549
- undoLabel(entry) {
6550
- if (!entry.undoes) return `Undid: ${entry.label}`;
6551
- const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
6552
- return `Redid: ${original?.label ?? entry.label}`;
6553
- }
6554
- async undoNow(id, who, mode) {
6555
- const entry = this.entry(id);
6556
- const changed = this.movedOn(entry);
6557
- if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
6558
- if (mode === "back-to-before") {
6559
- const index = this.log.entries.indexOf(entry);
6560
- const point = index > 0 ? this.log.entries[index - 1].id : START;
6561
- return {
6562
- ok: true,
6563
- entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
6564
- };
6565
- }
6566
- const target = new Map(
6567
- entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
6568
- );
6569
- return {
6570
- ok: true,
6571
- entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
6572
- };
6573
- }
6574
- movedOn(entry) {
6575
- return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
6576
- }
6577
- next(direction, who) {
6578
- const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
6579
- const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
6580
- (group) => group?.changes.size && mine(group.who)
6581
- );
6582
- if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
6583
- const everyone = this.options.undoScope === "everyone";
6584
- const ofOpenTurn = (entry) => this.windows.some((open2) => open2.parts?.has(entry.id));
6585
- return stepTarget(
6586
- this.log.entries,
6587
- direction,
6588
- (entry) => mine(entry.who) || everyone && !ofOpenTurn(entry)
6589
- );
6590
- }
6591
- /** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
6592
- pendingEntry(group) {
6593
- const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
6594
- const { id, who, label, startedAt, lastWriteAt } = group;
6595
- return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
6596
- }
6597
- conflict(entry, changed) {
6598
- const paths = new Set(changed.map((file) => file.path));
6599
- const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
6600
- const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
6601
- return { files: [...paths], newer: newer.map((later) => later.id) };
6602
- }
6603
- async restoreNow(point, who, label) {
6604
- const files = manifestAt(this.log, point);
6605
- if (!files) throw new Error("That point is no longer kept in this project's history.");
6606
- const target = new Map(
6607
- [...this.tracked.keys()].map((path) => [path, null])
6608
- );
6609
- for (const [path, hash] of files) target.set(path, hash);
6610
- return this.writeAs(who, label, target, { restoredTo: point });
6611
- }
6612
- /** Settles, then runs `task` with its writes labelled `writeToken`. */
6613
- operation(writeToken, task) {
6614
- return this.queue(async () => {
6615
- await this.settle();
6616
- this.writeToken = writeToken;
6617
- try {
6618
- return await task();
6619
- } finally {
6620
- this.writeToken = void 0;
6621
- }
6622
- });
6623
- }
6624
- pointLabel(point) {
6625
- return point === START ? "the start" : this.entry(point).label;
6626
- }
6627
- api() {
6628
- return {
6629
- projectId: this.projectId,
6630
- beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
6631
- claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
6632
- noteChange: (path) => {
6633
- if (!this.closed) this.noteChange(path);
6634
- },
6635
- list: () => {
6636
- this.assertOpen();
6637
- const undone = undoneIds(this.log.entries);
6638
- return this.log.entries.map((entry) => ({
6639
- ...entry,
6640
- pinned: this.log.pins.has(entry.id),
6641
- undone: undone.has(entry.id)
6642
- }));
6643
- },
6644
- step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
6645
- const target = this.next(direction, who);
6646
- return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
6647
- }),
6648
- undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
6649
- restore: (point, who, { writeToken } = {}) => this.operation(
6650
- writeToken,
6651
- () => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
6652
- ),
6653
- peek: (point) => {
6654
- this.assertOpen();
6655
- const files = manifestAt(this.log, point);
6656
- return files && Object.fromEntries(files);
6657
- },
6658
- checkout: (entryId, side, emptyDir) => this.queue(async () => {
6659
- this.entry(entryId);
6660
- if (!existsSync11(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
6661
- const dest = resolve8(emptyDir);
6662
- if (isWithin(this.dir, dest))
6663
- throw new Error(`Checkout writes outside the project only: ${dest}`);
6664
- if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
6665
- throw new Error(`Checkout writes into an empty folder only: ${dest}`);
6666
- const files = manifestAround(this.log, entryId, side);
6667
- try {
6668
- for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
6669
- } catch (error) {
6670
- for (const name of await readdir3(dest).catch(() => []))
6671
- await rm3(join20(dest, name), { recursive: true, force: true });
6672
- throw error;
6673
- }
6674
- }),
6675
- next: (direction, who) => {
6676
- this.assertOpen();
6677
- return this.next(direction, who);
6678
- },
6679
- readBlob: async (hash) => {
6680
- this.assertOpen();
6681
- return this.blobs.read(hash);
6682
- },
6683
- pin: (id, pinned) => {
6684
- this.assertOpen();
6685
- this.entry(id);
6686
- if (pinned) this.log.pins.add(id);
6687
- else this.log.pins.delete(id);
6688
- this.persistLog({ type: "pin", id, pinned });
6689
- },
6690
- onEntry: (listener) => {
6691
- this.listeners.add(listener);
6692
- return () => this.listeners.delete(listener);
6693
- },
6694
- flush: () => this.queue(() => this.settleAll()),
6695
- replacedAtPath: () => this.whereFolder() === "replaced",
6696
- close: () => this.closing ??= (async () => {
6697
- if (this.notedTimer) clearTimeout(this.notedTimer);
6698
- const settled = this.queue(() => this.settleAll());
6699
- this.closed = true;
6700
- await settled;
6701
- })()
6702
- };
6703
- }
6704
- };
6705
- function isWithin(dir, path) {
6706
- let probe = resolve8(path);
6707
- while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
6708
- const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
6709
- return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute2(rel);
6710
- }
6711
- function missingIsEmpty(error) {
6712
- if (error.code === "ENOENT") return [];
6713
- throw error;
6714
- }
6715
- var HistoryClosedError = class extends Error {
6716
- constructor(message) {
6717
- super(message);
6718
- this.name = "HistoryClosedError";
6719
- }
6720
- };
6721
- async function openProjectHistory(options) {
6722
- if (!existsSync11(options.projectDir))
6723
- throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
6724
- const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6725
- const release = await takeHistoryOwnership(
6726
- join20(options.historyRoot, projectId),
6727
- options.ownerWaitMs ?? 5e3
6728
- );
6729
- try {
6730
- const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
6731
- const engine = new Engine(options, projectId, blobs);
6732
- await engine.queue(() => engine.start());
6733
- pruneGoneProjectHistoriesDaily(options.historyRoot, engine.now(), {
6734
- onError: options.onError,
6735
- budgetMs: options.pruneGoneProjectsBudgetMs
6736
- });
6737
- const api = engine.api();
6738
- return {
6739
- ...api,
6740
- close: () => api.close().finally(release)
6741
- };
6742
- } catch (error) {
6743
- release();
6744
- throw error;
6745
- }
6746
- }
6747
-
6748
5197
  // src/routes/history.ts
6749
5198
  var YOU = { kind: "person", name: "You" };
6750
5199
  async function historyOf(adapter, c) {
@@ -6884,6 +5333,21 @@ function registerHistoryRoutes(api, adapter) {
6884
5333
  // src/createStudioApi.ts
6885
5334
  function createStudioApi(adapter) {
6886
5335
  const api = new Hono();
5336
+ api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
5337
+ const hostHeaders = new Headers(c.res.headers);
5338
+ await next();
5339
+ if (isProjectRootMissing(c.error)) replaceWithProjectDirMissing(c, hostHeaders);
5340
+ });
5341
+ api.use("/projects/:id/*", async function answerProjectDirMissingForVanishedFolder(c, next) {
5342
+ const hostHeaders = new Headers(c.res.headers);
5343
+ const dirBeforeRoute = await Promise.resolve().then(() => adapter.resolveProject(c.req.param("id"))).then(
5344
+ (project) => project?.dir,
5345
+ () => void 0
5346
+ );
5347
+ await next();
5348
+ if (c.res.status >= 403 && dirBeforeRoute && folderGone(dirBeforeRoute))
5349
+ replaceWithProjectDirMissing(c, hostHeaders);
5350
+ });
6887
5351
  registerProjectRoutes(api, adapter);
6888
5352
  registerFileRoutes(api, adapter);
6889
5353
  registerPreviewRoutes(api, adapter);
@@ -6901,33 +5365,9 @@ function createStudioApi(adapter) {
6901
5365
  return api;
6902
5366
  }
6903
5367
 
6904
- // src/history/historyCache.ts
6905
- function historyCache(open2) {
6906
- const histories = /* @__PURE__ */ new Map();
6907
- const get = async (projectDir) => {
6908
- let cached = histories.get(projectDir);
6909
- if (!cached) histories.set(projectDir, cached = open2(projectDir));
6910
- const opened = await cached;
6911
- if (!opened?.replacedAtPath()) return opened;
6912
- const reopen = () => open2(projectDir);
6913
- if (histories.get(projectDir) === cached)
6914
- histories.set(projectDir, opened.close().then(reopen, reopen));
6915
- return get(projectDir);
6916
- };
6917
- return {
6918
- get,
6919
- /** The history if one was opened, without opening it. */
6920
- peek: (projectDir) => histories.get(projectDir),
6921
- forget: (projectDir) => histories.delete(projectDir),
6922
- closeAll: () => Promise.all(
6923
- [...histories.values()].map((opened) => opened.then((history) => history?.close()))
6924
- )
6925
- };
6926
- }
6927
-
6928
5368
  // src/helpers/duplicateElement.ts
6929
5369
  import { sameInstant } from "@hyperframes/core/clip-facts";
6930
- import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
5370
+ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
6931
5371
  function duplicateElementInHtml(source, target, newId, at) {
6932
5372
  const { document, wrappedFragment } = parseSourceDocument(source);
6933
5373
  const element = findTargetElement(document, target);
@@ -6947,7 +5387,7 @@ function duplicateElementInHtml(source, target, newId, at) {
6947
5387
  clone.setAttribute("data-start", String(at));
6948
5388
  element.parentElement.insertBefore(clone, element.nextSibling);
6949
5389
  const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
6950
- return { html: ensureHfIds4(html), matched: true, newId: uniqueId };
5390
+ return { html: ensureHfIds3(html), matched: true, newId: uniqueId };
6951
5391
  }
6952
5392
  function nextUniqueId(document, newId) {
6953
5393
  let uniqueId = newId;
@@ -6979,27 +5419,28 @@ function numericAttribute(element, name) {
6979
5419
  }
6980
5420
 
6981
5421
  // src/helpers/previewDocumentStore.ts
6982
- import { createHash as createHash8 } from "crypto";
6983
- import { mkdirSync as mkdirSync12, readFileSync as readFileSync20, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
6984
- import { join as join21 } from "path";
6985
- function createPreviewDocumentStore(dir, salt) {
6986
- const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
5422
+ import { createHash as createHash4 } from "crypto";
5423
+ import { readFileSync as readFileSync13, readdirSync as readdirSync5, rmSync as rmSync5 } from "fs";
5424
+ import { join as join15 } from "path";
5425
+ function createPreviewDocumentStore(projectDir, salt) {
5426
+ const dir = join15(projectDir, ".hyperframes", "preview");
5427
+ const fileFor = (key) => join15(dir, `${createHash4("sha256").update(`${salt}
6987
5428
  ${key}`).digest("hex").slice(0, 32)}.html`);
6988
5429
  return {
6989
5430
  read(key) {
6990
5431
  try {
6991
- return readFileSync20(fileFor(key), "utf-8");
5432
+ return readFileSync13(fileFor(key), "utf-8");
6992
5433
  } catch {
6993
5434
  return null;
6994
5435
  }
6995
5436
  },
6996
5437
  write(key, html) {
6997
5438
  try {
6998
- mkdirSync12(dir, { recursive: true });
5439
+ mkdirWithinProject(projectDir, dir);
6999
5440
  const file = fileFor(key);
7000
5441
  replaceFileAtomically(file, html, 420);
7001
- for (const name of readdirSync8(dir)) {
7002
- if (join21(dir, name) !== file) rmSync7(join21(dir, name), { force: true });
5442
+ for (const name of readdirSync5(dir)) {
5443
+ if (join15(dir, name) !== file) rmSync5(join15(dir, name), { force: true });
7003
5444
  }
7004
5445
  } catch {
7005
5446
  }
@@ -7109,6 +5550,7 @@ export {
7109
5550
  historyCache,
7110
5551
  identifyFileWrite,
7111
5552
  isSafePath,
5553
+ listProjectHistories,
7112
5554
  openProjectHistory,
7113
5555
  parseSourceDocument,
7114
5556
  patchElementInHtml,