@hyperframes/studio-server 0.8.76 → 0.8.78

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
@@ -22,7 +22,7 @@ import {
22
22
  isAutoProxyEnabled,
23
23
  proxyEtagSalt,
24
24
  resolvePreviewMediaCodecProbeCache
25
- } from "./chunk-5PGWTBCL.js";
25
+ } from "./chunk-X3T65KC2.js";
26
26
  import {
27
27
  ProxyCapacityError,
28
28
  ProxyTranscodeError,
@@ -72,9 +72,18 @@ function isInHiddenOrVendorDir(relPath) {
72
72
  const segments = relPath.split("/");
73
73
  return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
74
74
  }
75
+ var UNREADABLE_DIR_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "ENOENT", "ENOTDIR"]);
75
76
  function walkDir(dir, prefix = "") {
77
+ let entries;
78
+ try {
79
+ entries = readdirSync(dir, { withFileTypes: true });
80
+ } catch (err) {
81
+ const code = err.code;
82
+ if (!prefix || !code || !UNREADABLE_DIR_CODES.has(code)) throw err;
83
+ return [];
84
+ }
76
85
  const files = [];
77
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
86
+ for (const entry of entries) {
78
87
  const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
79
88
  if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
80
89
  if (entry.isDirectory()) {
@@ -161,6 +170,15 @@ function openNoFollow(filePath, flags) {
161
170
  return null;
162
171
  }
163
172
  }
173
+ function isUnchanged(filePath, expected) {
174
+ const fd = openNoFollow(filePath, constants.O_RDONLY);
175
+ if (fd === null) return false;
176
+ try {
177
+ return readFileSync(fd, "utf-8") === expected;
178
+ } finally {
179
+ closeSync(fd);
180
+ }
181
+ }
164
182
  function stampFileHfIds(filePath) {
165
183
  let fd = openNoFollow(filePath, constants.O_RDWR);
166
184
  let writable = true;
@@ -179,7 +197,7 @@ function stampFileHfIds(filePath) {
179
197
  const mode = fstatSync(fd).mode;
180
198
  closeSync(fd);
181
199
  fd = null;
182
- replaceFileAtomically(filePath, normalized, mode);
200
+ if (isUnchanged(filePath, html)) replaceFileAtomically(filePath, normalized, mode);
183
201
  }
184
202
  return normalized;
185
203
  } catch (err) {
@@ -776,6 +794,8 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
776
794
  }));
777
795
  const results = [];
778
796
  const attempted = [];
797
+ for (const mutation of prepared)
798
+ assertExpectedVersion(mutation.expectedVersion, readFileSync4(mutation.absPath, "utf-8"));
779
799
  try {
780
800
  for (const mutation of prepared) {
781
801
  results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted));
@@ -785,7 +805,8 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
785
805
  const rollbackErrors = [];
786
806
  for (const mutation of attempted.reverse()) {
787
807
  try {
788
- writeFile(mutation.absPath, mutation.before, "utf-8");
808
+ if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
809
+ writeFile(mutation.absPath, mutation.before, "utf-8");
789
810
  clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
790
811
  } catch (rollbackError) {
791
812
  rollbackErrors.push(rollbackError);
@@ -818,8 +839,10 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
818
839
  const before = current;
819
840
  const version = fileContentVersion(mutation.after);
820
841
  const writeToken = createWriteToken(requestToken);
821
- attempted.push({ ...mutation, before, version, writeToken });
842
+ const attempt = { ...mutation, before, version, writeToken, written: false };
843
+ attempted.push(attempt);
822
844
  writeFile(mutation.absPath, mutation.after, "utf-8");
845
+ attempt.written = true;
823
846
  recordFileWriteReceipt(mutation.absPath, {
824
847
  path: mutation.sourceFile,
825
848
  version,
@@ -1133,6 +1156,7 @@ function requestSubPath(url, route) {
1133
1156
  }
1134
1157
 
1135
1158
  // src/routes/files.ts
1159
+ import { insertBeforeCloseTag } from "@hyperframes/core/compiler/html-document";
1136
1160
  async function loadGsapParser() {
1137
1161
  return import("@hyperframes/parsers/gsap-parser-recast");
1138
1162
  }
@@ -1679,8 +1703,8 @@ async function prepareGsapMutationScript(c, res, firstMutation) {
1679
1703
  `window.__timelines["${compId}"] = tl;`,
1680
1704
  "</script>"
1681
1705
  ].join("\n");
1682
- html = html.includes("</body>") ? html.replace("</body>", `${bootstrap}
1683
- </body>`) : `${html}
1706
+ html = insertBeforeCloseTag(html, "body", `${bootstrap}
1707
+ `) ?? `${html}
1684
1708
  ${bootstrap}`;
1685
1709
  block = extractGsapScriptBlock(html);
1686
1710
  }
@@ -3325,6 +3349,7 @@ import { join as join9, resolve as resolve4 } from "path";
3325
3349
  import { createHash as createHash4 } from "crypto";
3326
3350
  import {
3327
3351
  injectScriptsIntoHtml,
3352
+ insertBeforeCloseTag as insertBeforeCloseTag2,
3328
3353
  stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
3329
3354
  } from "@hyperframes/core/compiler";
3330
3355
  import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
@@ -3550,9 +3575,8 @@ function injectProjectSignature(html, signature) {
3550
3575
  tag
3551
3576
  );
3552
3577
  }
3553
- if (html.includes("</head>")) return html.replace("</head>", `${tag}
3554
- </head>`);
3555
- return `${tag}
3578
+ return insertBeforeCloseTag2(html, "head", `${tag}
3579
+ `) ?? `${tag}
3556
3580
  ${html}`;
3557
3581
  }
3558
3582
  function readStudioMotionManifestContent(projectDir) {
@@ -3577,9 +3601,8 @@ function parseStudioMotionManifestContent(content) {
3577
3601
  }
3578
3602
  }
3579
3603
  function injectScriptTagIntoHead(html, scriptTag) {
3580
- if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}
3581
- </head>`);
3582
- return `${scriptTag}
3604
+ return insertBeforeCloseTag2(html, "head", `${scriptTag}
3605
+ `) ?? `${scriptTag}
3583
3606
  ${html}`;
3584
3607
  }
3585
3608
  function htmlHasGsap(html) {
@@ -3749,8 +3772,8 @@ function registerPreviewRoutes(api, adapter) {
3749
3772
  }
3750
3773
  if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
3751
3774
  const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
3752
- bundled = bundled.includes("</body>") ? bundled.replace("</body>", `${runtimeTag}
3753
- </body>`) : bundled + `
3775
+ bundled = insertBeforeCloseTag2(bundled, "body", `${runtimeTag}
3776
+ `) ?? `${bundled}
3754
3777
  ${runtimeTag}`;
3755
3778
  }
3756
3779
  const baseHref = `/api/projects/${project.id}/preview/`;
@@ -5174,157 +5197,12 @@ function registerGlobalAssetRoutes(api) {
5174
5197
  api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
5175
5198
  }
5176
5199
 
5177
- // src/routes/history.ts
5178
- var YOU = { kind: "person", name: "You" };
5179
- var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5180
- async function historyOf(adapter, c) {
5181
- const project = await adapter.resolveProject(c.req.param("id") ?? "");
5182
- return project ? await adapter.history?.(project) ?? null : null;
5183
- }
5184
- async function bodyOf(c) {
5185
- const body = await c.req.json().catch(() => null);
5186
- return body && typeof body === "object" ? body : {};
5187
- }
5188
- var text = (value) => typeof value === "string" && value ? value : null;
5189
- function writing(c) {
5190
- const header = c.req.header("X-Hyperframes-Write-Token");
5191
- return header ? { writeToken: createWriteToken(header) } : {};
5192
- }
5193
- function idleOf(body) {
5194
- const idleMs = body.idleMs;
5195
- return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
5196
- }
5197
- function versionsOf(value) {
5198
- if (!value || typeof value !== "object") return void 0;
5199
- const pairs = Object.entries(value).filter(
5200
- (pair) => typeof pair[1] === "string"
5201
- );
5202
- return pairs.length ? Object.fromEntries(pairs) : void 0;
5203
- }
5204
- function nextStep(history, direction) {
5205
- const target = history.next(direction);
5206
- if (!target) return null;
5207
- const paths = target.files.map((file) => file.path);
5208
- return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
5209
- }
5210
- async function withHistory(adapter, c, task) {
5211
- const history = await historyOf(adapter, c);
5212
- if (!history) return c.json({ error: "This project has no history here." }, 404);
5213
- try {
5214
- return c.json(await task(history, await bodyOf(c)) ?? null);
5215
- } catch (error) {
5216
- return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
5217
- }
5218
- }
5219
- function registerHistoryRoutes(api, adapter) {
5220
- const windows = /* @__PURE__ */ new Map();
5221
- const base = "/projects/:id/history";
5222
- api.get(
5223
- base,
5224
- (c) => withHistory(adapter, c, (history) => {
5225
- const entries = history.list();
5226
- return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
5227
- })
5228
- );
5229
- api.post(
5230
- `${base}/step`,
5231
- (c) => withHistory(
5232
- adapter,
5233
- c,
5234
- (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
5235
- )
5236
- );
5237
- api.post(
5238
- `${base}/undo`,
5239
- (c) => withHistory(adapter, c, (history, body) => {
5240
- const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
5241
- return history.undo(text(body.entryId) ?? "", { who: YOU, mode, ...writing(c) });
5242
- })
5243
- );
5244
- api.post(
5245
- `${base}/restore`,
5246
- (c) => withHistory(
5247
- adapter,
5248
- c,
5249
- (history, body) => history.restore(text(body.point) ?? "", YOU, writing(c))
5250
- )
5251
- );
5252
- api.get(
5253
- `${base}/peek/:point`,
5254
- (c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
5255
- );
5256
- api.post(
5257
- `${base}/pin`,
5258
- (c) => withHistory(adapter, c, (history, body) => {
5259
- history.pin(text(body.entryId) ?? "", body.pinned === true);
5260
- return { ok: true };
5261
- })
5262
- );
5263
- api.post(
5264
- `${base}/claim`,
5265
- (c) => withHistory(adapter, c, async (history, body) => {
5266
- const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
5267
- const coalesceKey = text(body.coalesceKey) ?? void 0;
5268
- const idleMs = idleOf(body);
5269
- const overwrote = versionsOf(body.overwrote);
5270
- const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
5271
- ...coalesceKey && { coalesceKey },
5272
- ...idleMs && { idleMs },
5273
- ...overwrote && { overwrote }
5274
- });
5275
- return { claimed };
5276
- })
5277
- );
5278
- api.post(
5279
- `${base}/window`,
5280
- (c) => withHistory(adapter, c, async (history, body) => {
5281
- const idleMs = idleOf(body);
5282
- const window = await history.beginWindow(
5283
- YOU,
5284
- text(body.label) ?? "Edited in Studio",
5285
- idleMs ? { idleMs } : void 0
5286
- );
5287
- windows.set(window.id, { history, window });
5288
- return { windowId: window.id };
5289
- })
5290
- );
5291
- api.post(
5292
- `${base}/window/:windowId/close`,
5293
- (c) => withHistory(adapter, c, async (history) => {
5294
- const id = c.req.param("windowId") ?? "";
5295
- const held = windows.get(id);
5296
- if (held?.history !== history) throw new Error("That window is not open in this project.");
5297
- windows.delete(id);
5298
- return { entry: await held.window.close() };
5299
- })
5300
- );
5301
- }
5302
-
5303
- // src/createStudioApi.ts
5304
- function createStudioApi(adapter) {
5305
- const api = new Hono();
5306
- registerProjectRoutes(api, adapter);
5307
- registerFileRoutes(api, adapter);
5308
- registerPreviewRoutes(api, adapter);
5309
- registerLintRoutes(api, adapter);
5310
- registerRenderRoutes(api, adapter);
5311
- registerThumbnailRoutes(api, adapter);
5312
- registerSelectionRoutes(api, adapter);
5313
- registerMediaRoutes(api, adapter);
5314
- registerWaveformRoutes(api, adapter);
5315
- registerFontRoutes(api);
5316
- registerRegistryRoutes(api, adapter);
5317
- registerGlobalAssetRoutes(api);
5318
- registerHistoryRoutes(api, adapter);
5319
- return api;
5320
- }
5321
-
5322
5200
  // src/history/projectHistory.ts
5323
- import { randomUUID as randomUUID7 } from "crypto";
5201
+ import { randomUUID as randomUUID8 } from "crypto";
5324
5202
  import { rm as rm2 } from "fs/promises";
5325
- import { existsSync as existsSync12, mkdirSync as mkdirSync10, readFileSync as readFileSync17 } from "fs";
5203
+ import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync18 } from "fs";
5326
5204
  import { homedir as homedir2 } from "os";
5327
- import { join as join18, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5205
+ import { join as join19, relative as relative4, resolve as resolve7, sep as sep3 } from "path";
5328
5206
 
5329
5207
  // src/history/blobStore.ts
5330
5208
  import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
@@ -5428,8 +5306,89 @@ function projectHistoryId(projectDir, historyRoot) {
5428
5306
  return id;
5429
5307
  }
5430
5308
 
5309
+ // src/history/ownerLock.ts
5310
+ import { randomUUID as randomUUID7 } from "crypto";
5311
+ import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
5312
+ import { join as join18 } from "path";
5313
+ var HistoryBusyError = class extends Error {
5314
+ constructor(pid) {
5315
+ super(`This project's history is open in another process (pid ${pid}).`);
5316
+ this.pid = pid;
5317
+ this.name = "HistoryBusyError";
5318
+ }
5319
+ pid;
5320
+ };
5321
+ function alive(pid) {
5322
+ if (!Number.isInteger(pid) || pid <= 0) return false;
5323
+ try {
5324
+ process.kill(pid, 0);
5325
+ return true;
5326
+ } catch (error) {
5327
+ return error.code === "EPERM";
5328
+ }
5329
+ }
5330
+ function ownerOf(file) {
5331
+ try {
5332
+ const text2 = readFileSync16(file, "utf-8");
5333
+ return /^\d+$/.test(text2) ? Number(text2) : Number.NaN;
5334
+ } catch (error) {
5335
+ if (error.code === "ENOENT") return null;
5336
+ throw error;
5337
+ }
5338
+ }
5339
+ function claim(file) {
5340
+ const draft = `${file}-${randomUUID7()}.tmp`;
5341
+ writeFileSync8(draft, String(process.pid));
5342
+ try {
5343
+ linkSync(draft, file);
5344
+ return true;
5345
+ } catch (error) {
5346
+ if (error.code !== "EEXIST") throw error;
5347
+ return false;
5348
+ } finally {
5349
+ rmSync5(draft, { force: true });
5350
+ }
5351
+ }
5352
+ function releaseOwn(file) {
5353
+ if (ownerOf(file) === process.pid) rmSync5(file, { force: true });
5354
+ }
5355
+ function evictDeadOwner(file) {
5356
+ const evictor = `${file}.evict`;
5357
+ if (!claim(evictor)) {
5358
+ const holder = ownerOf(evictor);
5359
+ if (holder !== null && !alive(holder)) rmSync5(evictor, { force: true });
5360
+ return false;
5361
+ }
5362
+ try {
5363
+ const pid = ownerOf(file);
5364
+ if (pid !== null && !alive(pid)) rmSync5(file, { force: true });
5365
+ return true;
5366
+ } finally {
5367
+ releaseOwn(evictor);
5368
+ }
5369
+ }
5370
+ async function takeHistoryOwnership(home, waitMs) {
5371
+ const file = join18(home, "owner.pid");
5372
+ const deadline = Date.now() + waitMs;
5373
+ mkdirSync9(home, { recursive: true });
5374
+ for (; ; ) {
5375
+ if (claim(file)) {
5376
+ let held = true;
5377
+ return () => {
5378
+ if (held) releaseOwn(file);
5379
+ held = false;
5380
+ };
5381
+ }
5382
+ const pid = ownerOf(file);
5383
+ if (pid === null) continue;
5384
+ if (!alive(pid) && evictDeadOwner(file)) continue;
5385
+ if (Date.now() >= deadline) throw new HistoryBusyError(pid);
5386
+ await new Promise((settle) => setTimeout(settle, 50));
5387
+ }
5388
+ }
5389
+
5431
5390
  // src/history/historyLog.ts
5432
- import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync16, writeSync as writeSync2 } from "fs";
5391
+ import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
5433
5392
  import { dirname as dirname7 } from "path";
5434
5393
  var START = "start";
5435
5394
  function parseRecord(line) {
@@ -5442,7 +5401,7 @@ function parseRecord(line) {
5442
5401
  function readLog(file, onUnreadable) {
5443
5402
  let text2;
5444
5403
  try {
5445
- text2 = readFileSync16(file, "utf-8");
5404
+ text2 = readFileSync17(file, "utf-8");
5446
5405
  } catch {
5447
5406
  return null;
5448
5407
  }
@@ -5482,7 +5441,7 @@ function writeLog(file, log) {
5482
5441
  ...log.entries.map((entry) => ({ type: "entry", entry })),
5483
5442
  ...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
5484
5443
  ];
5485
- mkdirSync9(dirname7(file), { recursive: true });
5444
+ mkdirSync10(dirname7(file), { recursive: true });
5486
5445
  replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
5487
5446
  `).join(""), 420);
5488
5447
  }
@@ -5540,7 +5499,8 @@ function referencedHashes(log, current) {
5540
5499
  }
5541
5500
 
5542
5501
  // src/history/projectHistory.ts
5543
- var DEFAULT_HISTORY_ROOT = join18(homedir2(), ".cache", "hyperframes", "history");
5502
+ var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
5503
+ var MAX_WINDOW_IDLE_MS = 10 * 6e4;
5544
5504
  var OUTSIDE = { kind: "outside", name: "Outside" };
5545
5505
  var OUTSIDE_LABEL = "Changed outside the app";
5546
5506
  var RACY_MS = 2e3;
@@ -5554,6 +5514,7 @@ function splitAt(change, at, kept) {
5554
5514
  { ...change, before: at }
5555
5515
  ];
5556
5516
  }
5517
+ var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
5557
5518
  function addChange(group, path, before, after) {
5558
5519
  const earlier = group.changes.get(path);
5559
5520
  const from = earlier ? earlier.before : before;
@@ -5566,7 +5527,7 @@ var Engine = class {
5566
5527
  this.projectId = projectId;
5567
5528
  this.blobs = blobs;
5568
5529
  this.dir = resolve7(options.projectDir);
5569
- this.home = join18(options.historyRoot, projectId);
5530
+ this.home = join19(options.historyRoot, projectId);
5570
5531
  }
5571
5532
  options;
5572
5533
  projectId;
@@ -5586,7 +5547,7 @@ var Engine = class {
5586
5547
  listeners = /* @__PURE__ */ new Set();
5587
5548
  tail = Promise.resolve();
5588
5549
  get logFile() {
5589
- return join18(this.home, "log.jsonl");
5550
+ return join19(this.home, "log.jsonl");
5590
5551
  }
5591
5552
  now() {
5592
5553
  return (this.options.now ?? Date.now)();
@@ -5612,14 +5573,22 @@ var Engine = class {
5612
5573
  const cached = cache.get(path);
5613
5574
  this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
5614
5575
  }
5576
+ this.reopenClosedWindow();
5615
5577
  await this.sweep();
5578
+ for (const window of [...this.windows]) await this.endWindow(window);
5616
5579
  await this.commitOutside();
5617
5580
  }
5581
+ reopenClosedWindow() {
5582
+ const closed = this.options.closedWindow;
5583
+ if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
5584
+ const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
5585
+ this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: /* @__PURE__ */ new Map() });
5586
+ }
5618
5587
  async firstOpen() {
5619
5588
  const sweptAt = Date.now();
5620
5589
  for (const file of listProjectFiles(this.dir))
5621
5590
  this.tracked.set(file.path, {
5622
- hash: await this.blobs.put(join18(this.dir, file.path)),
5591
+ hash: await this.blobs.put(join19(this.dir, file.path)),
5623
5592
  stat: statKey(file, sweptAt)
5624
5593
  });
5625
5594
  this.log.baseline = this.manifest();
@@ -5631,7 +5600,7 @@ var Engine = class {
5631
5600
  }
5632
5601
  readStatCache() {
5633
5602
  try {
5634
- const files = JSON.parse(readFileSync17(join18(this.home, "stat.json"), "utf-8"));
5603
+ const files = JSON.parse(readFileSync18(join19(this.home, "stat.json"), "utf-8"));
5635
5604
  return new Map(Object.entries(files));
5636
5605
  } catch {
5637
5606
  return /* @__PURE__ */ new Map();
@@ -5639,8 +5608,8 @@ var Engine = class {
5639
5608
  }
5640
5609
  saveStatCache() {
5641
5610
  const files = JSON.stringify(Object.fromEntries(this.tracked));
5642
- mkdirSync10(this.home, { recursive: true });
5643
- replaceFileAtomically(join18(this.home, "stat.json"), files, 420);
5611
+ mkdirSync11(this.home, { recursive: true });
5612
+ replaceFileAtomically(join19(this.home, "stat.json"), files, 420);
5644
5613
  }
5645
5614
  /**
5646
5615
  * Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
@@ -5649,15 +5618,17 @@ var Engine = class {
5649
5618
  async sweep() {
5650
5619
  if (!existsSync12(this.dir)) return;
5651
5620
  const sweptAt = Date.now();
5652
- const seen = listProjectFiles(this.dir);
5621
+ const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
5622
+ const seen = listProjectFiles(this.dir).sort((a, b) => changedAt(a) - changedAt(b));
5653
5623
  let changed = false;
5654
5624
  for (const file of seen)
5655
- changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
5625
+ changed = await this.observe(file.path, statKey(file, sweptAt), changedAt(file)) || changed;
5656
5626
  const present = new Set(seen.map((file) => file.path));
5657
5627
  for (const [path, known] of this.tracked) {
5658
5628
  if (present.has(path)) continue;
5659
5629
  this.tracked.delete(path);
5660
- this.record(path, known.hash, null);
5630
+ const removedAt = sweptAt;
5631
+ await this.record(path, known.hash, null, removedAt);
5661
5632
  changed = true;
5662
5633
  }
5663
5634
  if (changed) this.saveStatCache();
@@ -5665,24 +5636,27 @@ var Engine = class {
5665
5636
  /** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
5666
5637
  async storeIfPresent(path) {
5667
5638
  try {
5668
- return await this.blobs.put(join18(this.dir, path));
5639
+ return await this.blobs.put(join19(this.dir, path));
5669
5640
  } catch (error) {
5670
5641
  if (error.code === "ENOENT") return null;
5671
5642
  throw error;
5672
5643
  }
5673
5644
  }
5674
- async observe(path, stat2) {
5645
+ async observe(path, stat2, at) {
5675
5646
  const known = this.tracked.get(path) ?? { hash: null, stat: null };
5676
5647
  if (stat2 && known.stat === stat2) return false;
5677
5648
  const hash = await this.storeIfPresent(path);
5678
5649
  if (hash === null) return false;
5679
5650
  this.tracked.set(path, { hash, stat: stat2 });
5680
- if (known.hash !== hash) this.record(path, known.hash, hash);
5651
+ if (known.hash !== hash) await this.record(path, known.hash, hash, at);
5681
5652
  return known.hash !== hash || known.stat !== stat2;
5682
5653
  }
5683
- record(path, before, after) {
5654
+ async record(path, before, after, at) {
5655
+ const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
5656
+ if (endedBeforeThisWrite.length) await this.commitOutside();
5657
+ for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
5684
5658
  const window = this.windows.at(-1);
5685
- if (window) this.touch(window);
5659
+ if (window) this.touch(window, at);
5686
5660
  addChange(window ?? this.outsideGroup(), path, before, after);
5687
5661
  }
5688
5662
  /** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
@@ -5790,7 +5764,7 @@ var Engine = class {
5790
5764
  return this.outside;
5791
5765
  }
5792
5766
  newGroup(who, label) {
5793
- return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5767
+ return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
5794
5768
  }
5795
5769
  async commitOutside() {
5796
5770
  clearTimeout(this.quietTimer);
@@ -5801,9 +5775,11 @@ var Engine = class {
5801
5775
  }
5802
5776
  async commit(group, extra = {}) {
5803
5777
  if (!group.changes.size) return null;
5804
- const { id, who, label, startedAt, changes } = group;
5778
+ const { id, who, label, startedAt, lastWriteAt, changes } = group;
5805
5779
  const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
5806
- const entry = { id, who, label, startedAt, endedAt: this.now(), files, ...extra };
5780
+ const previousEnd = this.log.entries.at(-1)?.endedAt ?? 0;
5781
+ const endedAt = Math.max(lastWriteAt ?? this.now(), previousEnd);
5782
+ const entry = { id, who, label, startedAt, endedAt, files, ...extra };
5807
5783
  this.log.entries.push(entry);
5808
5784
  try {
5809
5785
  saveRecord(this.logFile, this.log, { type: "entry", entry });
@@ -5854,12 +5830,14 @@ var Engine = class {
5854
5830
  await this.sweep();
5855
5831
  const window = { ...this.newGroup(who, label), idleMs };
5856
5832
  this.windows.push(window);
5857
- this.touch(window);
5858
- return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
5833
+ this.touch(window, Date.now());
5834
+ const close = () => this.queue(() => this.sweepAndEnd(window));
5835
+ return { id: window.id, startedAt: window.startedAt, close };
5859
5836
  });
5860
5837
  }
5861
5838
  /** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
5862
- touch(window) {
5839
+ touch(window, at) {
5840
+ window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
5863
5841
  clearTimeout(window.idleTimer);
5864
5842
  if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
5865
5843
  window.idleTimer = setTimeout(
@@ -5904,7 +5882,7 @@ var Engine = class {
5904
5882
  }
5905
5883
  /** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
5906
5884
  async writeProjectFile(path, hash) {
5907
- const absPath = join18(this.dir, path);
5885
+ const absPath = join19(this.dir, path);
5908
5886
  const { writeToken } = this;
5909
5887
  if (writeToken) {
5910
5888
  const version = hash === null ? DELETED_VERSION : hashVersion(hash);
@@ -6033,10 +6011,180 @@ var Engine = class {
6033
6011
  };
6034
6012
  async function openProjectHistory(options) {
6035
6013
  const projectId = projectHistoryId(options.projectDir, options.historyRoot);
6036
- const blobs = await openBlobStore(join18(options.historyRoot, projectId, "blobs"));
6037
- const engine = new Engine(options, projectId, blobs);
6038
- await engine.queue(() => engine.start());
6039
- return engine.api();
6014
+ const release = await takeHistoryOwnership(
6015
+ join19(options.historyRoot, projectId),
6016
+ options.ownerWaitMs ?? 5e3
6017
+ );
6018
+ try {
6019
+ const blobs = await openBlobStore(join19(options.historyRoot, projectId, "blobs"));
6020
+ const engine = new Engine(options, projectId, blobs);
6021
+ await engine.queue(() => engine.start());
6022
+ const api = engine.api();
6023
+ return {
6024
+ ...api,
6025
+ close: () => api.close().finally(release)
6026
+ };
6027
+ } catch (error) {
6028
+ release();
6029
+ throw error;
6030
+ }
6031
+ }
6032
+
6033
+ // src/routes/history.ts
6034
+ var YOU = { kind: "person", name: "You" };
6035
+ async function historyOf(adapter, c) {
6036
+ const project = await adapter.resolveProject(c.req.param("id") ?? "");
6037
+ return project ? await adapter.history?.(project) ?? null : null;
6038
+ }
6039
+ async function bodyOf(c) {
6040
+ const body = await c.req.json().catch(() => null);
6041
+ return body && typeof body === "object" ? body : {};
6042
+ }
6043
+ var text = (value) => typeof value === "string" && value ? value : null;
6044
+ function writing(c) {
6045
+ const header = c.req.header("X-Hyperframes-Write-Token");
6046
+ return header ? { writeToken: createWriteToken(header) } : {};
6047
+ }
6048
+ function whoOf(body) {
6049
+ const who = body.who;
6050
+ const name = text(who?.name);
6051
+ return who?.kind === "agent" && name ? { kind: "agent", name } : YOU;
6052
+ }
6053
+ function idleOf(body) {
6054
+ const idleMs = body.idleMs;
6055
+ return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
6056
+ }
6057
+ function versionsOf(value) {
6058
+ if (!value || typeof value !== "object") return void 0;
6059
+ const pairs = Object.entries(value).filter(
6060
+ (pair) => typeof pair[1] === "string"
6061
+ );
6062
+ return pairs.length ? Object.fromEntries(pairs) : void 0;
6063
+ }
6064
+ function nextStep(history, direction) {
6065
+ const target = history.next(direction);
6066
+ if (!target) return null;
6067
+ const paths = target.files.map((file) => file.path);
6068
+ return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
6069
+ }
6070
+ async function withHistory(adapter, c, task) {
6071
+ const history = await historyOf(adapter, c);
6072
+ if (!history) return c.json({ error: "This project has no history here." }, 404);
6073
+ try {
6074
+ return c.json(await task(history, await bodyOf(c)) ?? null);
6075
+ } catch (error) {
6076
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
6077
+ }
6078
+ }
6079
+ function registerHistoryRoutes(api, adapter) {
6080
+ const windows = /* @__PURE__ */ new Map();
6081
+ const base = "/projects/:id/history";
6082
+ api.get(
6083
+ base,
6084
+ (c) => withHistory(adapter, c, (history) => {
6085
+ const entries = history.list();
6086
+ return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
6087
+ })
6088
+ );
6089
+ api.post(
6090
+ `${base}/step`,
6091
+ (c) => withHistory(
6092
+ adapter,
6093
+ c,
6094
+ (history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
6095
+ )
6096
+ );
6097
+ api.post(
6098
+ `${base}/undo`,
6099
+ (c) => withHistory(adapter, c, (history, body) => {
6100
+ const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
6101
+ return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
6102
+ })
6103
+ );
6104
+ api.post(
6105
+ `${base}/restore`,
6106
+ (c) => withHistory(
6107
+ adapter,
6108
+ c,
6109
+ (history, body) => history.restore(text(body.point) ?? "", whoOf(body), writing(c))
6110
+ )
6111
+ );
6112
+ api.get(
6113
+ `${base}/peek/:point`,
6114
+ (c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
6115
+ );
6116
+ api.post(
6117
+ `${base}/pin`,
6118
+ (c) => withHistory(adapter, c, (history, body) => {
6119
+ history.pin(text(body.entryId) ?? "", body.pinned === true);
6120
+ return { ok: true };
6121
+ })
6122
+ );
6123
+ api.get(`${base}/blob/:hash`, async (c) => {
6124
+ const history = await historyOf(adapter, c);
6125
+ const hash = c.req.param("hash") ?? "";
6126
+ const missing = () => c.json({ error: "That file is not kept in this project's history." }, 404);
6127
+ if (!history || !/^[0-9a-f]{64}$/.test(hash)) return missing();
6128
+ const bytes = await history.readBlob(hash).catch(() => null);
6129
+ return bytes ? c.body(new Uint8Array(bytes)) : missing();
6130
+ });
6131
+ api.post(
6132
+ `${base}/claim`,
6133
+ (c) => withHistory(adapter, c, async (history, body) => {
6134
+ const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
6135
+ const coalesceKey = text(body.coalesceKey) ?? void 0;
6136
+ const idleMs = idleOf(body);
6137
+ const overwrote = versionsOf(body.overwrote);
6138
+ const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
6139
+ ...coalesceKey && { coalesceKey },
6140
+ ...idleMs && { idleMs },
6141
+ ...overwrote && { overwrote }
6142
+ });
6143
+ return { claimed };
6144
+ })
6145
+ );
6146
+ api.post(
6147
+ `${base}/window`,
6148
+ (c) => withHistory(adapter, c, async (history, body) => {
6149
+ const idleMs = idleOf(body);
6150
+ const window = await history.beginWindow(
6151
+ whoOf(body),
6152
+ text(body.label) ?? "Edited in Studio",
6153
+ idleMs ? { idleMs } : void 0
6154
+ );
6155
+ windows.set(window.id, { history, window });
6156
+ return { windowId: window.id, startedAt: window.startedAt };
6157
+ })
6158
+ );
6159
+ api.post(
6160
+ `${base}/window/:windowId/close`,
6161
+ (c) => withHistory(adapter, c, async (history) => {
6162
+ const id = c.req.param("windowId") ?? "";
6163
+ const held = windows.get(id);
6164
+ if (held?.history !== history) throw new Error("That window is not open in this project.");
6165
+ windows.delete(id);
6166
+ return { entry: await held.window.close() };
6167
+ })
6168
+ );
6169
+ }
6170
+
6171
+ // src/createStudioApi.ts
6172
+ function createStudioApi(adapter) {
6173
+ const api = new Hono();
6174
+ registerProjectRoutes(api, adapter);
6175
+ registerFileRoutes(api, adapter);
6176
+ registerPreviewRoutes(api, adapter);
6177
+ registerLintRoutes(api, adapter);
6178
+ registerRenderRoutes(api, adapter);
6179
+ registerThumbnailRoutes(api, adapter);
6180
+ registerSelectionRoutes(api, adapter);
6181
+ registerMediaRoutes(api, adapter);
6182
+ registerWaveformRoutes(api, adapter);
6183
+ registerFontRoutes(api);
6184
+ registerRegistryRoutes(api, adapter);
6185
+ registerGlobalAssetRoutes(api);
6186
+ registerHistoryRoutes(api, adapter);
6187
+ return api;
6040
6188
  }
6041
6189
 
6042
6190
  // src/helpers/duplicateElement.ts
@@ -6092,26 +6240,26 @@ function numericAttribute(element, name) {
6092
6240
 
6093
6241
  // src/helpers/previewDocumentStore.ts
6094
6242
  import { createHash as createHash7 } from "crypto";
6095
- import { mkdirSync as mkdirSync11, readFileSync as readFileSync18, readdirSync as readdirSync7, rmSync as rmSync5 } from "fs";
6096
- import { join as join19 } from "path";
6243
+ import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync7, rmSync as rmSync6 } from "fs";
6244
+ import { join as join20 } from "path";
6097
6245
  function createPreviewDocumentStore(dir, salt) {
6098
- const fileFor = (key) => join19(dir, `${createHash7("sha256").update(`${salt}
6246
+ const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
6099
6247
  ${key}`).digest("hex").slice(0, 32)}.html`);
6100
6248
  return {
6101
6249
  read(key) {
6102
6250
  try {
6103
- return readFileSync18(fileFor(key), "utf-8");
6251
+ return readFileSync19(fileFor(key), "utf-8");
6104
6252
  } catch {
6105
6253
  return null;
6106
6254
  }
6107
6255
  },
6108
6256
  write(key, html) {
6109
6257
  try {
6110
- mkdirSync11(dir, { recursive: true });
6258
+ mkdirSync12(dir, { recursive: true });
6111
6259
  const file = fileFor(key);
6112
6260
  replaceFileAtomically(file, html, 420);
6113
6261
  for (const name of readdirSync7(dir)) {
6114
- if (join19(dir, name) !== file) rmSync5(join19(dir, name), { force: true });
6262
+ if (join20(dir, name) !== file) rmSync6(join20(dir, name), { force: true });
6115
6263
  }
6116
6264
  } catch {
6117
6265
  }
@@ -6190,6 +6338,8 @@ export {
6190
6338
  DEFAULT_HISTORY_ROOT,
6191
6339
  DELETED_VERSION,
6192
6340
  START as HISTORY_START,
6341
+ HistoryBusyError,
6342
+ MAX_WINDOW_IDLE_MS,
6193
6343
  MIME_TYPES,
6194
6344
  PREVIEW_BUNDLE_OPTIONS,
6195
6345
  STUDIO_MANUAL_EDITS_PATH,