@hyperframes/studio-server 0.8.72 → 0.8.74

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -151,22 +151,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
151
151
  if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
152
152
  const file = resolve(dir, entry);
153
153
  if (!isPathWithin(projectDir, file)) continue;
154
- let stat;
154
+ let stat2;
155
155
  try {
156
- stat = lstatSync(file);
156
+ stat2 = lstatSync(file);
157
157
  } catch {
158
158
  continue;
159
159
  }
160
- if (stat.isSymbolicLink()) continue;
161
- if (stat.isDirectory()) {
160
+ if (stat2.isSymbolicLink()) continue;
161
+ if (stat2.isDirectory()) {
162
162
  collectProjectSignatureFiles(projectDir, file, files);
163
- } else if (stat.isFile()) {
163
+ } else if (stat2.isFile()) {
164
164
  files.push({
165
165
  file,
166
- mtimeMs: stat.mtimeMs,
167
- ctimeMs: stat.ctimeMs,
168
- size: stat.size,
169
- textContentEligible: isTextContentEligible(file, stat.size)
166
+ mtimeMs: stat2.mtimeMs,
167
+ ctimeMs: stat2.ctimeMs,
168
+ size: stat2.size,
169
+ textContentEligible: isTextContentEligible(file, stat2.size)
170
170
  });
171
171
  }
172
172
  }
@@ -176,19 +176,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
176
176
  for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
177
177
  const file = resolve(projectDir, manifestPath);
178
178
  if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
179
- let stat;
179
+ let stat2;
180
180
  try {
181
- stat = lstatSync(file);
181
+ stat2 = lstatSync(file);
182
182
  } catch {
183
183
  continue;
184
184
  }
185
- if (stat.isSymbolicLink() || !stat.isFile()) continue;
185
+ if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
186
186
  files.push({
187
187
  file,
188
- mtimeMs: stat.mtimeMs,
189
- ctimeMs: stat.ctimeMs,
190
- size: stat.size,
191
- textContentEligible: isTextContentEligible(file, stat.size)
188
+ mtimeMs: stat2.mtimeMs,
189
+ ctimeMs: stat2.ctimeMs,
190
+ size: stat2.size,
191
+ textContentEligible: isTextContentEligible(file, stat2.size)
192
192
  });
193
193
  seen.add(file);
194
194
  }
@@ -209,6 +209,21 @@ function createProjectFingerprint(projectDir, files) {
209
209
  }
210
210
  return hash.digest("hex").slice(0, 24);
211
211
  }
212
+ function collectProjectFiles(normalizedProjectDir) {
213
+ const collected = [];
214
+ collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
215
+ collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
216
+ return collected;
217
+ }
218
+ function listProjectFiles(projectDir) {
219
+ const normalizedProjectDir = resolve(projectDir);
220
+ return collectProjectFiles(normalizedProjectDir).map((entry) => ({
221
+ path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
222
+ size: entry.size,
223
+ mtimeMs: entry.mtimeMs,
224
+ ctimeMs: entry.ctimeMs
225
+ }));
226
+ }
212
227
  function resolveProjectSignature(adapter, projectDir) {
213
228
  return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
214
229
  }
@@ -219,9 +234,7 @@ async function resolveProjectAndSignature(adapter, projectId) {
219
234
  }
220
235
  function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
221
236
  const normalizedProjectDir = resolve(projectDir);
222
- const collected = [];
223
- collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
224
- collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
237
+ const collected = collectProjectFiles(normalizedProjectDir);
225
238
  const files = collected.filter(
226
239
  (entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
227
240
  );
@@ -622,9 +635,9 @@ var receipts = /* @__PURE__ */ new Map();
622
635
  function fileContentVersion(content) {
623
636
  return `"sha256:${createHash2("sha256").update(content).digest("hex")}"`;
624
637
  }
625
- function settledFileTag(stat, now = Date.now()) {
626
- if (now - stat.ctimeMs < 3e3) return null;
627
- return [stat.ino, stat.ctimeMs, stat.size].map((n) => n.toString(36)).join("-");
638
+ function settledFileTag(stat2, now = Date.now()) {
639
+ if (now - stat2.ctimeMs < 3e3) return null;
640
+ return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
628
641
  }
629
642
  function createWriteToken(requestToken) {
630
643
  const token = requestToken?.trim();
@@ -1018,6 +1031,14 @@ function resolveGsapWriter(env) {
1018
1031
  throw new Error(`Invalid ${GSAP_WRITER_MIGRATION.flag}=${configured}; expected recast or acorn`);
1019
1032
  }
1020
1033
 
1034
+ // src/helpers/requestSubPath.ts
1035
+ function requestSubPath(url, route) {
1036
+ const routeSegments = route.split("/");
1037
+ const segments = new URL(url).pathname.split("/");
1038
+ const start = segments.indexOf(routeSegments[0] ?? "");
1039
+ return decodeURIComponent(segments.slice(start + routeSegments.length).join("/"));
1040
+ }
1041
+
1021
1042
  // src/routes/files.ts
1022
1043
  async function loadGsapParser() {
1023
1044
  return import("@hyperframes/parsers/gsap-parser-recast");
@@ -1040,7 +1061,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1040
1061
  return true;
1041
1062
  }
1042
1063
  }
1043
- async function resolveProjectPath(c, adapter, pathPrefix, opts) {
1064
+ async function resolveProjectPath(c, adapter, route, opts) {
1044
1065
  const id = c.req.param("id");
1045
1066
  const project = await adapter.resolveProject(id);
1046
1067
  if (!project) {
@@ -1051,7 +1072,7 @@ async function resolveProjectPath(c, adapter, pathPrefix, opts) {
1051
1072
  error: c.json({ error: "not found", why: "project_dir_missing" }, 404)
1052
1073
  };
1053
1074
  }
1054
- const filePath = decodeURIComponent(c.req.path.replace(pathPrefix(project.id), ""));
1075
+ const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
1055
1076
  if (filePath.includes("\0")) {
1056
1077
  return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
1057
1078
  }
@@ -1068,10 +1089,10 @@ async function resolveProjectPath(c, adapter, pathPrefix, opts) {
1068
1089
  return { project, filePath, absPath };
1069
1090
  }
1070
1091
  function resolveProjectFile(c, adapter, opts) {
1071
- return resolveProjectPath(c, adapter, (id) => `/projects/${id}/files/`, opts);
1092
+ return resolveProjectPath(c, adapter, "files", opts);
1072
1093
  }
1073
1094
  function resolveFileMutationContext(c, adapter, operation) {
1074
- return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
1095
+ return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
1075
1096
  }
1076
1097
  function isOptionalInteger(value) {
1077
1098
  return value === void 0 || Number.isInteger(value);
@@ -2582,10 +2603,10 @@ function registerFileRoutes(api, adapter) {
2582
2603
  api.delete("/projects/:id/files/*", async (c) => {
2583
2604
  const res = await resolveProjectFile(c, adapter, { mustExist: true });
2584
2605
  if ("error" in res) return res.error;
2585
- const stat = statSync3(res.absPath);
2606
+ const stat2 = statSync3(res.absPath);
2586
2607
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2587
2608
  if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
2588
- if (stat.isDirectory()) {
2609
+ if (stat2.isDirectory()) {
2589
2610
  rmSync3(res.absPath, { recursive: true });
2590
2611
  } else {
2591
2612
  unlinkSync2(res.absPath);
@@ -3142,7 +3163,7 @@ function registerFileRoutes(api, adapter) {
3142
3163
  }
3143
3164
  );
3144
3165
  api.get("/projects/:id/gsap-animations/*", async (c) => {
3145
- const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-animations/`, {
3166
+ const res = await resolveProjectPath(c, adapter, "gsap-animations", {
3146
3167
  mustExist: true
3147
3168
  });
3148
3169
  if ("error" in res) return res.error;
@@ -3165,7 +3186,7 @@ function registerFileRoutes(api, adapter) {
3165
3186
  return c.json({ atomicOwnershipPairs: true });
3166
3187
  });
3167
3188
  api.post("/projects/:id/gsap-mutations/*", async (c) => {
3168
- const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
3189
+ const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
3169
3190
  mustExist: true
3170
3191
  });
3171
3192
  if ("error" in res) return res.error;
@@ -3176,12 +3197,7 @@ function registerFileRoutes(api, adapter) {
3176
3197
  return applyGsapMutations(c, res, [body]);
3177
3198
  });
3178
3199
  api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
3179
- const res = await resolveProjectPath(
3180
- c,
3181
- adapter,
3182
- (id) => `/projects/${id}/gsap-mutations-batch/`,
3183
- { mustExist: true }
3184
- );
3200
+ const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
3185
3201
  if ("error" in res) return res.error;
3186
3202
  const body = await c.req.json().catch(() => null);
3187
3203
  if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
@@ -3194,12 +3210,7 @@ function registerFileRoutes(api, adapter) {
3194
3210
  return applyGsapMutations(c, res, body.mutations);
3195
3211
  });
3196
3212
  api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
3197
- const res = await resolveProjectPath(
3198
- c,
3199
- adapter,
3200
- (id) => `/projects/${id}/gsap-mutation-rollback/`,
3201
- { mustExist: true }
3202
- );
3213
+ const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
3203
3214
  if ("error" in res) return res.error;
3204
3215
  const body = await c.req.json().catch(() => null);
3205
3216
  if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
@@ -3375,7 +3386,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
3375
3386
  const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
3376
3387
  headContent = headMatch?.[1] ?? "";
3377
3388
  }
3378
- if (baseHref && !headContent.includes("<base")) {
3389
+ if (baseHref && !hasBaseElement(headContent)) {
3379
3390
  headContent = `<base href="${baseHref}">
3380
3391
  ${headContent}`;
3381
3392
  }
@@ -3403,6 +3414,9 @@ ${rewrittenContent}
3403
3414
  </body>
3404
3415
  </html>`;
3405
3416
  }
3417
+ function hasBaseElement(html) {
3418
+ return parseHTML3(html).document.querySelector("base") !== null;
3419
+ }
3406
3420
 
3407
3421
  // src/routes/preview.ts
3408
3422
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
@@ -3709,7 +3723,7 @@ function registerPreviewRoutes(api, adapter) {
3709
3723
  ${runtimeTag}`;
3710
3724
  }
3711
3725
  const baseHref = `/api/projects/${project.id}/preview/`;
3712
- if (!bundled.includes("<base")) {
3726
+ if (!hasBaseElement(bundled)) {
3713
3727
  bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
3714
3728
  }
3715
3729
  bundled = injectStudioPreviewAugmentations(
@@ -3766,9 +3780,7 @@ ${runtimeTag}`;
3766
3780
  const vars = previewVariablesFromRequest(c.req.query("variables"));
3767
3781
  if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
3768
3782
  const previewVariables = vars.values;
3769
- const compPath = decodeURIComponent(
3770
- c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? ""
3771
- );
3783
+ const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
3772
3784
  const compFile = resolveWithinProject(project.dir, compPath);
3773
3785
  if (!compFile || !existsSync5(compFile) || !statSync5(compFile).isFile()) {
3774
3786
  return c.text("not found", 404);
@@ -3802,16 +3814,14 @@ ${runtimeTag}`;
3802
3814
  api.get("/projects/:id/preview/*", async (c) => {
3803
3815
  const project = await adapter.resolveProject(c.req.param("id"));
3804
3816
  if (!project) return c.json({ error: "not found" }, 404);
3805
- const subPath = decodeURIComponent(
3806
- c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? ""
3807
- );
3817
+ const subPath = requestSubPath(c.req.url, "projects/:id/preview");
3808
3818
  const candidate = resolve4(project.dir, subPath);
3809
3819
  const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
3810
3820
  if (!file) {
3811
3821
  return c.text("not found", 404);
3812
3822
  }
3813
- const stat = existsSync5(file) ? statSync5(file) : null;
3814
- if (!stat?.isFile()) {
3823
+ const stat2 = existsSync5(file) ? statSync5(file) : null;
3824
+ if (!stat2?.isFile()) {
3815
3825
  return c.text("not found", 404);
3816
3826
  }
3817
3827
  const contentType = getMimeType(subPath);
@@ -3833,7 +3843,7 @@ ${runtimeTag}`;
3833
3843
  return c.text("media proxy variant does not match asset", 422);
3834
3844
  }
3835
3845
  }
3836
- const tag = settledFileTag(stat);
3846
+ const tag = settledFileTag(stat2);
3837
3847
  const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
3838
3848
  const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
3839
3849
  if (!isText && etag) {
@@ -4159,7 +4169,7 @@ function registerRenderRoutes(api, adapter) {
4159
4169
  if (!existsSync6(rendersDir)) return c.json({ renders: [] });
4160
4170
  const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
4161
4171
  const fp = join10(rendersDir, f);
4162
- const stat = statSync6(fp);
4172
+ const stat2 = statSync6(fp);
4163
4173
  const rid = f.replace(/\.(mp4|webm|mov)$/, "");
4164
4174
  const metaPath = join10(rendersDir, `${rid}.meta.json`);
4165
4175
  let status = "complete";
@@ -4175,8 +4185,8 @@ function registerRenderRoutes(api, adapter) {
4175
4185
  return {
4176
4186
  id: rid,
4177
4187
  filename: f,
4178
- size: stat.size,
4179
- createdAt: stat.mtimeMs,
4188
+ size: stat2.size,
4189
+ createdAt: stat2.mtimeMs,
4180
4190
  status,
4181
4191
  durationMs
4182
4192
  };
@@ -4198,7 +4208,10 @@ function registerRenderRoutes(api, adapter) {
4198
4208
 
4199
4209
  // src/routes/thumbnail.ts
4200
4210
  import {
4211
+ closeSync as closeSync3,
4201
4212
  existsSync as existsSync7,
4213
+ fstatSync as fstatSync3,
4214
+ openSync as openSync3,
4202
4215
  mkdirSync as mkdirSync5,
4203
4216
  readFileSync as readFileSync12,
4204
4217
  readdirSync as readdirSync6,
@@ -4285,10 +4298,10 @@ var ThumbnailGenerationCoordinator = class {
4285
4298
  if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
4286
4299
  let entry = this.entries.get(key);
4287
4300
  if (!entry) {
4288
- let resolve5;
4301
+ let resolve8;
4289
4302
  let reject;
4290
4303
  const promise = new Promise((resolvePromise, rejectPromise) => {
4291
- resolve5 = resolvePromise;
4304
+ resolve8 = resolvePromise;
4292
4305
  reject = rejectPromise;
4293
4306
  });
4294
4307
  entry = {
@@ -4298,7 +4311,7 @@ var ThumbnailGenerationCoordinator = class {
4298
4311
  state: "queued",
4299
4312
  work,
4300
4313
  promise,
4301
- resolve: resolve5,
4314
+ resolve: resolve8,
4302
4315
  reject
4303
4316
  };
4304
4317
  this.entries.set(key, entry);
@@ -4312,7 +4325,7 @@ var ThumbnailGenerationCoordinator = class {
4312
4325
  return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
4313
4326
  }
4314
4327
  lease(entry, signal) {
4315
- return new Promise((resolve5, reject) => {
4328
+ return new Promise((resolve8, reject) => {
4316
4329
  let released = false;
4317
4330
  const release = () => {
4318
4331
  if (released) return;
@@ -4336,7 +4349,7 @@ var ThumbnailGenerationCoordinator = class {
4336
4349
  entry.promise.then(
4337
4350
  (value) => {
4338
4351
  release();
4339
- resolve5(value);
4352
+ resolve8(value);
4340
4353
  },
4341
4354
  (reason) => {
4342
4355
  release();
@@ -4418,6 +4431,22 @@ function writeThumbnailAtomically(path, buffer) {
4418
4431
  rmSync4(temporaryPath, { force: true });
4419
4432
  }
4420
4433
  }
4434
+ function readCompositionSource(file) {
4435
+ let fd;
4436
+ try {
4437
+ fd = openSync3(file, "r");
4438
+ } catch (err) {
4439
+ if (err.code === "ENOENT") return "missing";
4440
+ throw err;
4441
+ }
4442
+ try {
4443
+ const stat2 = fstatSync3(fd);
4444
+ if (!stat2.isFile()) return "not-a-file";
4445
+ return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat2.mtimeMs };
4446
+ } finally {
4447
+ closeSync3(fd);
4448
+ }
4449
+ }
4421
4450
  function registerThumbnailRoutes(api, adapter) {
4422
4451
  api.get("/projects/:id/thumbnail/*", async (c) => {
4423
4452
  if (!adapter.generateThumbnail) {
@@ -4426,10 +4455,11 @@ function registerThumbnailRoutes(api, adapter) {
4426
4455
  const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
4427
4456
  if (!resolved) return c.json({ error: "not found" }, 404);
4428
4457
  const { project, signature: projectSignature } = resolved;
4429
- let compPath = decodeURIComponent(
4430
- c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? ""
4431
- );
4458
+ let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
4432
4459
  if (compPath && !compPath.includes(".")) compPath += ".html";
4460
+ const htmlFile = resolveWithinProject(project.dir, compPath);
4461
+ const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
4462
+ if (source === "not-a-file") return c.json({ error: "not found" }, 404);
4433
4463
  const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
4434
4464
  const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
4435
4465
  const rawSeekTime = url.searchParams.get("t");
@@ -4449,11 +4479,10 @@ function registerThumbnailRoutes(api, adapter) {
4449
4479
  let compH = vpHeight || 1080;
4450
4480
  let sourceMtime = 0;
4451
4481
  let sourceKey = "";
4452
- const htmlFile = join11(project.dir, compPath);
4453
- if (existsSync7(htmlFile)) {
4454
- const html = readFileSync12(htmlFile, "utf-8");
4482
+ if (source !== "missing") {
4483
+ const { html } = source;
4455
4484
  sourceKey = `_${createHash4("sha1").update(html).digest("hex").slice(0, 16)}`;
4456
- sourceMtime = Math.round(statSync7(htmlFile).mtimeMs);
4485
+ sourceMtime = Math.round(source.mtimeMs);
4457
4486
  if (!vpWidth) {
4458
4487
  const wMatch = html.match(/data-width=["'](\d+)["']/);
4459
4488
  const hMatch = html.match(/data-height=["'](\d+)["']/);
@@ -4475,7 +4504,8 @@ function registerThumbnailRoutes(api, adapter) {
4475
4504
  motionKey = `_${createHash4("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4476
4505
  sourceMtime = Math.max(sourceMtime, Math.round(statSync7(motionFile).mtimeMs));
4477
4506
  }
4478
- const previewUrl = compPath === "index.html" ? `http://${c.req.header("host")}/api/projects/${project.id}/preview` : `http://${c.req.header("host")}/api/projects/${project.id}/preview/comp/${compPath}`;
4507
+ const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
4508
+ const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
4479
4509
  const cacheDir = join11(project.dir, ".thumbnails");
4480
4510
  const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
4481
4511
  const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
@@ -4549,15 +4579,17 @@ function registerThumbnailRoutes(api, adapter) {
4549
4579
 
4550
4580
  // src/routes/waveform.ts
4551
4581
  import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync8 } from "fs";
4552
- import { join as join12 } from "path";
4582
+ import { join as join12, resolve as resolve5 } from "path";
4583
+ import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
4553
4584
  function registerWaveformRoutes(api, adapter) {
4554
4585
  api.get("/projects/:id/waveform/*", async (c) => {
4555
4586
  const project = await adapter.resolveProject(c.req.param("id"));
4556
4587
  if (!project) return c.json({ error: "not found" }, 404);
4557
- const assetPath = decodeURIComponent(
4558
- c.req.path.replace(`/projects/${project.id}/waveform/`, "").split("?")[0] ?? ""
4559
- );
4560
- const audioPath = join12(project.dir, assetPath);
4588
+ const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
4589
+ const audioPath = resolve5(project.dir, assetPath);
4590
+ if (!isWithinProjectRoot2(project.dir, audioPath)) {
4591
+ return c.json({ error: "file not found" }, 404);
4592
+ }
4561
4593
  const stats = statSync8(audioPath, { throwIfNoEntry: false });
4562
4594
  if (!stats) return c.json({ error: "file not found" }, 404);
4563
4595
  const cacheDir = join12(project.dir, ".waveform-cache");
@@ -4584,7 +4616,7 @@ function registerWaveformRoutes(api, adapter) {
4584
4616
  }
4585
4617
 
4586
4618
  // src/routes/fonts.ts
4587
- import { closeSync as closeSync3, constants as constants2, fstatSync as fstatSync3, openSync as openSync3, readSync } from "fs";
4619
+ import { closeSync as closeSync4, constants as constants2, fstatSync as fstatSync4, openSync as openSync4, readSync } from "fs";
4588
4620
  import {
4589
4621
  collectFontFileEntries,
4590
4622
  fontDirectories,
@@ -4691,17 +4723,17 @@ function registerFontRoutes(api) {
4691
4723
  if (!located) return c.json({ error: "font not found" }, 404);
4692
4724
  let fd;
4693
4725
  try {
4694
- fd = openSync3(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4726
+ fd = openSync4(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4695
4727
  } catch {
4696
4728
  return c.json({ error: "font file not accessible" }, 404);
4697
4729
  }
4698
4730
  try {
4699
- const stat = fstatSync3(fd);
4700
- if (stat.size > SYSTEM_FONT_SIZE_LIMIT) {
4731
+ const stat2 = fstatSync4(fd);
4732
+ if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
4701
4733
  return c.json({ error: "font file too large" }, 413);
4702
4734
  }
4703
- const buffer = Buffer.alloc(stat.size);
4704
- readSync(fd, buffer, 0, stat.size, 0);
4735
+ const buffer = Buffer.alloc(stat2.size);
4736
+ readSync(fd, buffer, 0, stat2.size, 0);
4705
4737
  const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
4706
4738
  const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
4707
4739
  return new Response(buffer, {
@@ -4713,7 +4745,7 @@ function registerFontRoutes(api) {
4713
4745
  } catch {
4714
4746
  return c.json({ error: "failed to read font file" }, 500);
4715
4747
  } finally {
4716
- closeSync3(fd);
4748
+ closeSync4(fd);
4717
4749
  }
4718
4750
  });
4719
4751
  }
@@ -5074,6 +5106,80 @@ function registerGlobalAssetRoutes(api) {
5074
5106
  api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
5075
5107
  }
5076
5108
 
5109
+ // src/routes/history.ts
5110
+ var YOU = { kind: "person", name: "You" };
5111
+ async function historyOf(adapter, c) {
5112
+ const project = await adapter.resolveProject(c.req.param("id") ?? "");
5113
+ return project ? await adapter.history?.(project) ?? null : null;
5114
+ }
5115
+ async function bodyOf(c) {
5116
+ const body = await c.req.json().catch(() => null);
5117
+ return body && typeof body === "object" ? body : {};
5118
+ }
5119
+ var text = (value) => typeof value === "string" && value ? value : null;
5120
+ async function withHistory(adapter, c, task) {
5121
+ const history = await historyOf(adapter, c);
5122
+ if (!history) return c.json({ error: "This project has no history here." }, 404);
5123
+ try {
5124
+ return c.json(await task(history, await bodyOf(c)) ?? null);
5125
+ } catch (error) {
5126
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
5127
+ }
5128
+ }
5129
+ function registerHistoryRoutes(api, adapter) {
5130
+ const windows = /* @__PURE__ */ new Map();
5131
+ const base = "/projects/:id/history";
5132
+ api.get(base, (c) => withHistory(adapter, c, (history) => ({ entries: history.list() })));
5133
+ api.post(
5134
+ `${base}/step`,
5135
+ (c) => withHistory(
5136
+ adapter,
5137
+ c,
5138
+ (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU)
5139
+ )
5140
+ );
5141
+ api.post(
5142
+ `${base}/undo`,
5143
+ (c) => withHistory(adapter, c, (history, body) => {
5144
+ const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
5145
+ return history.undo(text(body.entryId) ?? "", { who: YOU, mode });
5146
+ })
5147
+ );
5148
+ api.post(
5149
+ `${base}/restore`,
5150
+ (c) => withHistory(adapter, c, (history, body) => history.restore(text(body.point) ?? "", YOU))
5151
+ );
5152
+ api.get(
5153
+ `${base}/peek/:point`,
5154
+ (c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
5155
+ );
5156
+ api.post(
5157
+ `${base}/pin`,
5158
+ (c) => withHistory(adapter, c, (history, body) => {
5159
+ history.pin(text(body.entryId) ?? "", body.pinned === true);
5160
+ return { ok: true };
5161
+ })
5162
+ );
5163
+ api.post(
5164
+ `${base}/window`,
5165
+ (c) => withHistory(adapter, c, async (history, body) => {
5166
+ const window = await history.beginWindow(YOU, text(body.label) ?? "Edited in Studio");
5167
+ windows.set(window.id, { history, window });
5168
+ return { windowId: window.id };
5169
+ })
5170
+ );
5171
+ api.post(
5172
+ `${base}/window/:windowId/close`,
5173
+ (c) => withHistory(adapter, c, async (history) => {
5174
+ const id = c.req.param("windowId") ?? "";
5175
+ const held = windows.get(id);
5176
+ if (held?.history !== history) throw new Error("That window is not open in this project.");
5177
+ windows.delete(id);
5178
+ return { entry: await held.window.close() };
5179
+ })
5180
+ );
5181
+ }
5182
+
5077
5183
  // src/createStudioApi.ts
5078
5184
  function createStudioApi(adapter) {
5079
5185
  const api = new Hono();
@@ -5089,9 +5195,587 @@ function createStudioApi(adapter) {
5089
5195
  registerFontRoutes(api);
5090
5196
  registerRegistryRoutes(api, adapter);
5091
5197
  registerGlobalAssetRoutes(api);
5198
+ registerHistoryRoutes(api, adapter);
5092
5199
  return api;
5093
5200
  }
5094
5201
 
5202
+ // src/history/projectHistory.ts
5203
+ import { randomUUID as randomUUID7 } from "crypto";
5204
+ import { rm as rm2 } from "fs/promises";
5205
+ import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync17 } from "fs";
5206
+ import { join as join17, resolve as resolve7 } from "path";
5207
+
5208
+ // src/history/blobStore.ts
5209
+ import { createHash as createHash5, randomUUID as randomUUID5 } from "crypto";
5210
+ import { constants as constants3, createReadStream as createReadStream2 } from "fs";
5211
+ import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
5212
+ import { dirname as dirname5, join as join15 } from "path";
5213
+ async function hashFile(path) {
5214
+ const hash = createHash5("sha256");
5215
+ for await (const chunk of createReadStream2(path)) hash.update(chunk);
5216
+ return hash.digest("hex");
5217
+ }
5218
+ async function cloneOrCopy(from, to) {
5219
+ await mkdir(dirname5(to), { recursive: true });
5220
+ const temp = `${to}.${randomUUID5()}.tmp`;
5221
+ try {
5222
+ await copyFile(from, temp, constants3.COPYFILE_FICLONE);
5223
+ await rename(temp, to);
5224
+ } catch (error) {
5225
+ await rm(temp, { force: true });
5226
+ throw error;
5227
+ }
5228
+ }
5229
+ async function openBlobStore(dir) {
5230
+ const sizes = /* @__PURE__ */ new Map();
5231
+ await mkdir(dir, { recursive: true });
5232
+ for (const shard of await readdir(dir))
5233
+ for (const hash of await readdir(join15(dir, shard)).catch(() => []))
5234
+ sizes.set(hash, (await stat(join15(dir, shard, hash))).size);
5235
+ const pathOf = (hash) => join15(dir, hash.slice(0, 2), hash);
5236
+ let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
5237
+ return {
5238
+ async put(absPath) {
5239
+ const temp = join15(dir, `incoming-${randomUUID5()}`);
5240
+ await mkdir(dir, { recursive: true });
5241
+ await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
5242
+ const hash = await hashFile(temp);
5243
+ if (sizes.has(hash)) {
5244
+ await rm(temp, { force: true });
5245
+ return hash;
5246
+ }
5247
+ await mkdir(dirname5(pathOf(hash)), { recursive: true });
5248
+ const size = (await stat(temp)).size;
5249
+ await rename(temp, pathOf(hash));
5250
+ sizes.set(hash, size);
5251
+ total += size;
5252
+ return hash;
5253
+ },
5254
+ read: (hash) => readFile2(pathOf(hash)),
5255
+ writeTo: (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
5256
+ bytes: () => total,
5257
+ size: (hash) => sizes.get(hash) ?? 0,
5258
+ async prune(keep) {
5259
+ for (const [hash, size] of sizes) {
5260
+ if (keep.has(hash)) continue;
5261
+ await rm(pathOf(hash), { force: true });
5262
+ sizes.delete(hash);
5263
+ total -= size;
5264
+ }
5265
+ }
5266
+ };
5267
+ }
5268
+
5269
+ // src/history/historyId.ts
5270
+ import { randomUUID as randomUUID6 } from "crypto";
5271
+ import { existsSync as existsSync11, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
5272
+ import { join as join16, resolve as resolve6 } from "path";
5273
+ var ID_PATH = join16(".hyperframes", "history-id");
5274
+ var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
5275
+ function readId(projectDir) {
5276
+ try {
5277
+ const id = readFileSync15(join16(projectDir, ID_PATH), "utf-8").trim();
5278
+ return ID_SHAPE.test(id) ? id : null;
5279
+ } catch {
5280
+ return null;
5281
+ }
5282
+ }
5283
+ function recordedDir(historyDir) {
5284
+ try {
5285
+ return JSON.parse(readFileSync15(join16(historyDir, "project.json"), "utf-8")).dir ?? null;
5286
+ } catch {
5287
+ return null;
5288
+ }
5289
+ }
5290
+ function projectHistoryId(projectDir, historyRoot) {
5291
+ const dir = resolve6(projectDir);
5292
+ let id = readId(dir);
5293
+ const was = id && recordedDir(join16(historyRoot, id));
5294
+ if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
5295
+ id = randomUUID6();
5296
+ mkdirSync7(join16(dir, ".hyperframes"), { recursive: true });
5297
+ writeFileSync6(join16(dir, ID_PATH), `${id}
5298
+ `);
5299
+ }
5300
+ mkdirSync7(join16(historyRoot, id), { recursive: true });
5301
+ writeFileSync6(join16(historyRoot, id, "project.json"), JSON.stringify({ dir }));
5302
+ return id;
5303
+ }
5304
+
5305
+ // src/history/historyLog.ts
5306
+ import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync8, openSync as openSync5, readFileSync as readFileSync16, writeSync as writeSync2 } from "fs";
5307
+ import { dirname as dirname6 } from "path";
5308
+ var START = "start";
5309
+ function parseRecord(line) {
5310
+ try {
5311
+ return JSON.parse(line);
5312
+ } catch {
5313
+ return null;
5314
+ }
5315
+ }
5316
+ function readLog(file, onUnreadable) {
5317
+ let text2;
5318
+ try {
5319
+ text2 = readFileSync16(file, "utf-8");
5320
+ } catch {
5321
+ return null;
5322
+ }
5323
+ const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5324
+ const lines = text2.split("\n");
5325
+ lines.forEach((line, index) => {
5326
+ const record = line ? parseRecord(line) : null;
5327
+ if (record) applyRecord(log, record);
5328
+ else if (line && index < lines.length - 1) onUnreadable(index + 1);
5329
+ });
5330
+ return log;
5331
+ }
5332
+ function applyRecord(log, record) {
5333
+ if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
5334
+ else if (record.type === "entry") log.entries.push(record.entry);
5335
+ else if (record.pinned) log.pins.add(record.id);
5336
+ else log.pins.delete(record.id);
5337
+ }
5338
+ function saveRecord(file, log, record) {
5339
+ let fd;
5340
+ try {
5341
+ fd = openSync5(file, constants4.O_WRONLY | constants4.O_APPEND);
5342
+ } catch (error) {
5343
+ if (error.code !== "ENOENT") throw error;
5344
+ return writeLog(file, log);
5345
+ }
5346
+ try {
5347
+ writeSync2(fd, `${JSON.stringify(record)}
5348
+ `);
5349
+ } finally {
5350
+ closeSync5(fd);
5351
+ }
5352
+ }
5353
+ function writeLog(file, log) {
5354
+ const records = [
5355
+ { type: "baseline", files: Object.fromEntries(log.baseline) },
5356
+ ...log.entries.map((entry) => ({ type: "entry", entry })),
5357
+ ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5358
+ ];
5359
+ mkdirSync8(dirname6(file), { recursive: true });
5360
+ replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5361
+ `).join(""), 420);
5362
+ }
5363
+ function applyEntry(manifest, entry) {
5364
+ for (const file of entry.files)
5365
+ if (file.after === null) manifest.delete(file.path);
5366
+ else manifest.set(file.path, file.after);
5367
+ }
5368
+ function manifestAt(log, point) {
5369
+ const manifest = new Map(log.baseline);
5370
+ if (point === START) return manifest;
5371
+ for (const entry of log.entries) {
5372
+ applyEntry(manifest, entry);
5373
+ if (entry.id === point) return manifest;
5374
+ }
5375
+ return null;
5376
+ }
5377
+ function undoneIds(entries) {
5378
+ const undone = /* @__PURE__ */ new Set();
5379
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
5380
+ const entry = entries[i];
5381
+ if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
5382
+ }
5383
+ return undone;
5384
+ }
5385
+ function stepTarget(entries, direction) {
5386
+ const undone = undoneIds(entries);
5387
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
5388
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
5389
+ const entry = entries[i];
5390
+ const isChange = !entry.undoes;
5391
+ if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
5392
+ if (direction !== "forward") continue;
5393
+ if (isChange) return void 0;
5394
+ const target = byId.get(entry.undoes);
5395
+ if (!undone.has(entry.id) && target && !target.undoes) return entry;
5396
+ }
5397
+ return void 0;
5398
+ }
5399
+ function foldOldest(log) {
5400
+ const oldest = log.entries[0];
5401
+ if (!oldest || log.pins.has(oldest.id)) return false;
5402
+ applyEntry(log.baseline, oldest);
5403
+ log.entries.shift();
5404
+ return true;
5405
+ }
5406
+ function referencedHashes(log, current) {
5407
+ const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
5408
+ for (const entry of log.entries)
5409
+ for (const file of entry.files) {
5410
+ if (file.before) keep.add(file.before);
5411
+ if (file.after) keep.add(file.after);
5412
+ }
5413
+ return keep;
5414
+ }
5415
+
5416
+ // src/history/projectHistory.ts
5417
+ var OUTSIDE = { kind: "outside", name: "Outside" };
5418
+ var OUTSIDE_LABEL = "Changed outside the app";
5419
+ var RACY_MS = 2e3;
5420
+ var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
5421
+ var Engine = class {
5422
+ constructor(options, projectId, blobs) {
5423
+ this.options = options;
5424
+ this.projectId = projectId;
5425
+ this.blobs = blobs;
5426
+ this.dir = resolve7(options.projectDir);
5427
+ this.home = join17(options.historyRoot, projectId);
5428
+ }
5429
+ options;
5430
+ projectId;
5431
+ blobs;
5432
+ dir;
5433
+ home;
5434
+ log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
5435
+ tracked = /* @__PURE__ */ new Map();
5436
+ windows = [];
5437
+ outside = null;
5438
+ quietTimer;
5439
+ maxTimer;
5440
+ notedTimer = null;
5441
+ listeners = /* @__PURE__ */ new Set();
5442
+ tail = Promise.resolve();
5443
+ get logFile() {
5444
+ return join17(this.home, "log.jsonl");
5445
+ }
5446
+ now() {
5447
+ return (this.options.now ?? Date.now)();
5448
+ }
5449
+ /** One operation at a time, in order: sweeps, windows and undos never interleave. */
5450
+ queue(task) {
5451
+ const run = this.tail.then(task);
5452
+ this.tail = run.catch(() => void 0);
5453
+ return run;
5454
+ }
5455
+ async start() {
5456
+ const log = readLog(
5457
+ this.logFile,
5458
+ (line) => this.options.onError?.(
5459
+ new Error(`History log line ${line} could not be read and was skipped.`)
5460
+ )
5461
+ );
5462
+ if (!log) return this.firstOpen();
5463
+ this.log = log;
5464
+ const cache = this.readStatCache();
5465
+ const last = this.log.entries.at(-1)?.id ?? START;
5466
+ for (const [path, hash] of manifestAt(this.log, last) ?? []) {
5467
+ const cached = cache.get(path);
5468
+ this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
5469
+ }
5470
+ await this.sweep();
5471
+ await this.commitOutside();
5472
+ }
5473
+ async firstOpen() {
5474
+ const sweptAt = Date.now();
5475
+ for (const file of listProjectFiles(this.dir))
5476
+ this.tracked.set(file.path, {
5477
+ hash: await this.blobs.put(join17(this.dir, file.path)),
5478
+ stat: statKey(file, sweptAt)
5479
+ });
5480
+ this.log.baseline = this.manifest();
5481
+ writeLog(this.logFile, this.log);
5482
+ this.saveStatCache();
5483
+ }
5484
+ manifest() {
5485
+ return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
5486
+ }
5487
+ readStatCache() {
5488
+ try {
5489
+ const files = JSON.parse(readFileSync17(join17(this.home, "stat.json"), "utf-8"));
5490
+ return new Map(Object.entries(files));
5491
+ } catch {
5492
+ return /* @__PURE__ */ new Map();
5493
+ }
5494
+ }
5495
+ saveStatCache() {
5496
+ const files = JSON.stringify(Object.fromEntries(this.tracked));
5497
+ mkdirSync9(this.home, { recursive: true });
5498
+ replaceFileAtomically(join17(this.home, "stat.json"), files, 420);
5499
+ }
5500
+ /**
5501
+ * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
5502
+ * project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
5503
+ */
5504
+ async sweep() {
5505
+ if (!existsSync12(this.dir)) return;
5506
+ const sweptAt = Date.now();
5507
+ const seen = listProjectFiles(this.dir);
5508
+ let changed = false;
5509
+ for (const file of seen)
5510
+ changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
5511
+ const present = new Set(seen.map((file) => file.path));
5512
+ for (const [path, known] of this.tracked) {
5513
+ if (present.has(path)) continue;
5514
+ this.tracked.delete(path);
5515
+ this.record(path, known.hash, null);
5516
+ changed = true;
5517
+ }
5518
+ if (changed) this.saveStatCache();
5519
+ }
5520
+ /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5521
+ async storeIfPresent(path) {
5522
+ try {
5523
+ return await this.blobs.put(join17(this.dir, path));
5524
+ } catch (error) {
5525
+ if (error.code === "ENOENT") return null;
5526
+ throw error;
5527
+ }
5528
+ }
5529
+ async observe(path, stat2) {
5530
+ const known = this.tracked.get(path) ?? { hash: null, stat: null };
5531
+ if (stat2 && known.stat === stat2) return false;
5532
+ const hash = await this.storeIfPresent(path);
5533
+ if (hash === null) return false;
5534
+ this.tracked.set(path, { hash, stat: stat2 });
5535
+ if (known.hash !== hash) this.record(path, known.hash, hash);
5536
+ return known.hash !== hash || known.stat !== stat2;
5537
+ }
5538
+ record(path, before, after) {
5539
+ const window = this.windows.at(-1);
5540
+ if (window) this.touch(window);
5541
+ const group = window ?? this.outsideGroup();
5542
+ const earlier = group.changes.get(path);
5543
+ const from = earlier ? earlier.before : before;
5544
+ if (from === after) group.changes.delete(path);
5545
+ else group.changes.set(path, { path, before: from, after });
5546
+ }
5547
+ outsideGroup() {
5548
+ const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
5549
+ const closeIn = (ms) => {
5550
+ const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
5551
+ timer.unref?.();
5552
+ return timer;
5553
+ };
5554
+ if (!this.outside) {
5555
+ this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
5556
+ this.maxTimer = closeIn(maxGroupMs);
5557
+ }
5558
+ clearTimeout(this.quietTimer);
5559
+ this.quietTimer = closeIn(quietMs);
5560
+ return this.outside;
5561
+ }
5562
+ newGroup(who, label) {
5563
+ return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5564
+ }
5565
+ async commitOutside() {
5566
+ clearTimeout(this.quietTimer);
5567
+ clearTimeout(this.maxTimer);
5568
+ const group = this.outside;
5569
+ this.outside = null;
5570
+ if (group) await this.commit(group);
5571
+ }
5572
+ async commit(group, extra = {}) {
5573
+ if (!group.changes.size) return null;
5574
+ const { changes, ...rest } = group;
5575
+ const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5576
+ const entry = { ...rest, endedAt: this.now(), files, ...extra };
5577
+ this.log.entries.push(entry);
5578
+ try {
5579
+ saveRecord(this.logFile, this.log, { type: "entry", entry });
5580
+ } catch (error) {
5581
+ this.log.entries.pop();
5582
+ throw error;
5583
+ }
5584
+ for (const listener of this.listeners) listener(entry);
5585
+ await this.keepWithinBudget();
5586
+ return entry;
5587
+ }
5588
+ /** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
5589
+ historyBytes() {
5590
+ let current = 0;
5591
+ for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
5592
+ current += this.blobs.size(hash);
5593
+ return this.blobs.bytes() - current;
5594
+ }
5595
+ async keepWithinBudget() {
5596
+ const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
5597
+ let folded = false;
5598
+ while (this.historyBytes() > budget && foldOldest(this.log)) {
5599
+ folded = true;
5600
+ await this.blobs.prune(referencedHashes(this.log, this.manifest()));
5601
+ }
5602
+ if (folded) writeLog(this.logFile, this.log);
5603
+ }
5604
+ /** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
5605
+ async settle() {
5606
+ await this.sweep();
5607
+ await this.commitOutside();
5608
+ }
5609
+ /** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
5610
+ noteChange(path) {
5611
+ if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
5612
+ this.notedTimer = setTimeout(() => {
5613
+ this.notedTimer = null;
5614
+ this.background(() => this.sweep());
5615
+ }, 20);
5616
+ this.notedTimer.unref?.();
5617
+ }
5618
+ background(task) {
5619
+ this.queue(task).catch((error) => this.options.onError?.(error));
5620
+ }
5621
+ beginWindow(who, label, idleMs) {
5622
+ return this.queue(async () => {
5623
+ await this.sweep();
5624
+ const window = { ...this.newGroup(who, label), idleMs };
5625
+ this.windows.push(window);
5626
+ this.touch(window);
5627
+ return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
5628
+ });
5629
+ }
5630
+ /** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
5631
+ touch(window) {
5632
+ clearTimeout(window.idleTimer);
5633
+ if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
5634
+ window.idleTimer = setTimeout(
5635
+ () => this.background(() => this.sweepAndEnd(window)),
5636
+ window.idleMs
5637
+ );
5638
+ window.idleTimer.unref?.();
5639
+ }
5640
+ async sweepAndEnd(window) {
5641
+ await this.sweep();
5642
+ return this.endWindow(window);
5643
+ }
5644
+ /** Commits an open window once; ending it again returns the entry it became. */
5645
+ async endWindow(window) {
5646
+ if (!this.windows.includes(window)) return window.entry ?? null;
5647
+ clearTimeout(window.idleTimer);
5648
+ this.windows = this.windows.filter((open) => open !== window);
5649
+ window.entry = await this.commit(window);
5650
+ return window.entry;
5651
+ }
5652
+ /** Every pending write, open window and outside group, committed: for flush and close. */
5653
+ async settleAll() {
5654
+ await this.sweep();
5655
+ for (const window of [...this.windows]) await this.endWindow(window);
5656
+ await this.commitOutside();
5657
+ }
5658
+ /** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
5659
+ async writeAs(who, label, target, extra) {
5660
+ const group = this.newGroup(who, label);
5661
+ this.windows.push(group);
5662
+ try {
5663
+ for (const [path, hash] of target) {
5664
+ if ((this.tracked.get(path)?.hash ?? null) === hash) continue;
5665
+ if (hash === null) await rm2(join17(this.dir, path), { force: true });
5666
+ else await this.blobs.writeTo(hash, join17(this.dir, path));
5667
+ }
5668
+ await this.sweep();
5669
+ } finally {
5670
+ this.windows = this.windows.filter((open) => open !== group);
5671
+ }
5672
+ return this.commit(group, extra);
5673
+ }
5674
+ entry(id) {
5675
+ const entry = this.log.entries.find((candidate) => candidate.id === id);
5676
+ if (!entry) throw new Error("That change is no longer kept in this project's history.");
5677
+ return entry;
5678
+ }
5679
+ undoLabel(entry) {
5680
+ if (!entry.undoes) return `Undid: ${entry.label}`;
5681
+ const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
5682
+ return `Redid: ${original?.label ?? entry.label}`;
5683
+ }
5684
+ async undoNow(id, who, mode) {
5685
+ const entry = this.entry(id);
5686
+ const changed = entry.files.filter(
5687
+ (file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after
5688
+ );
5689
+ if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
5690
+ if (mode === "back-to-before") {
5691
+ const index = this.log.entries.indexOf(entry);
5692
+ const point = index > 0 ? this.log.entries[index - 1].id : START;
5693
+ return {
5694
+ ok: true,
5695
+ entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
5696
+ };
5697
+ }
5698
+ const target = new Map(entry.files.map((file) => [file.path, file.before]));
5699
+ return {
5700
+ ok: true,
5701
+ entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
5702
+ };
5703
+ }
5704
+ conflict(entry, changed) {
5705
+ const paths = new Set(changed.map((file) => file.path));
5706
+ const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
5707
+ const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
5708
+ return { files: [...paths], newer: newer.map((later) => later.id) };
5709
+ }
5710
+ async restoreNow(point, who, label) {
5711
+ const files = manifestAt(this.log, point);
5712
+ if (!files) throw new Error("That point is no longer kept in this project's history.");
5713
+ const target = new Map(
5714
+ [...this.tracked.keys()].map((path) => [path, null])
5715
+ );
5716
+ for (const [path, hash] of files) target.set(path, hash);
5717
+ return this.writeAs(who, label, target, { restoredTo: point });
5718
+ }
5719
+ pointLabel(point) {
5720
+ return point === START ? "the start" : this.entry(point).label;
5721
+ }
5722
+ api() {
5723
+ return {
5724
+ projectId: this.projectId,
5725
+ beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
5726
+ noteChange: (path) => this.noteChange(path),
5727
+ list: () => {
5728
+ const undone = undoneIds(this.log.entries);
5729
+ return this.log.entries.map((entry) => ({
5730
+ ...entry,
5731
+ pinned: this.log.pins.has(entry.id),
5732
+ undone: undone.has(entry.id)
5733
+ }));
5734
+ },
5735
+ step: (direction, who) => this.queue(async () => {
5736
+ await this.settle();
5737
+ const target = stepTarget(this.log.entries, direction);
5738
+ return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
5739
+ }),
5740
+ undo: (id, { who, mode }) => this.queue(async () => {
5741
+ await this.settle();
5742
+ return this.undoNow(id, who, mode);
5743
+ }),
5744
+ restore: (point, who) => this.queue(async () => {
5745
+ await this.settle();
5746
+ return this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`);
5747
+ }),
5748
+ peek: (point) => {
5749
+ const files = manifestAt(this.log, point);
5750
+ return files && Object.fromEntries(files);
5751
+ },
5752
+ readBlob: (hash) => this.blobs.read(hash),
5753
+ pin: (id, pinned) => {
5754
+ this.entry(id);
5755
+ if (pinned) this.log.pins.add(id);
5756
+ else this.log.pins.delete(id);
5757
+ saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
5758
+ },
5759
+ onEntry: (listener) => {
5760
+ this.listeners.add(listener);
5761
+ return () => this.listeners.delete(listener);
5762
+ },
5763
+ flush: () => this.queue(() => this.settleAll()),
5764
+ close: async () => {
5765
+ if (this.notedTimer) clearTimeout(this.notedTimer);
5766
+ await this.queue(() => this.settleAll());
5767
+ }
5768
+ };
5769
+ }
5770
+ };
5771
+ async function openProjectHistory(options) {
5772
+ const projectId = projectHistoryId(options.projectDir, options.historyRoot);
5773
+ const blobs = await openBlobStore(join17(options.historyRoot, projectId, "blobs"));
5774
+ const engine = new Engine(options, projectId, blobs);
5775
+ await engine.queue(() => engine.start());
5776
+ return engine.api();
5777
+ }
5778
+
5095
5779
  // src/helpers/duplicateElement.ts
5096
5780
  import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
5097
5781
  function duplicateElementInHtml(source, target, newId, at) {
@@ -5211,6 +5895,7 @@ function updateBackgroundRemovalProgress(state, event) {
5211
5895
  state.avgMsPerFrame = event.avgMsPerFrame;
5212
5896
  }
5213
5897
  export {
5898
+ START as HISTORY_START,
5214
5899
  MIME_TYPES,
5215
5900
  PREVIEW_BUNDLE_OPTIONS,
5216
5901
  STUDIO_MANUAL_EDITS_PATH,
@@ -5234,9 +5919,11 @@ export {
5234
5919
  getMimeType,
5235
5920
  identifyFileWrite,
5236
5921
  isSafePath,
5922
+ openProjectHistory,
5237
5923
  parseSourceDocument,
5238
5924
  patchElementInHtml,
5239
5925
  removeElementFromHtml,
5926
+ requestSubPath,
5240
5927
  settledFileTag,
5241
5928
  splitElementInHtml,
5242
5929
  thumbnailDeviceScaleFactor,