@hyperframes/studio-server 0.7.59 → 0.7.60

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.d.ts CHANGED
@@ -223,6 +223,16 @@ declare function walkDir(dir: string, prefix?: string): string[];
223
223
  declare const MIME_TYPES: Record<string, string>;
224
224
  declare function getMimeType(path: string): string;
225
225
 
226
+ interface FileWriteReceipt {
227
+ path: string;
228
+ version: string;
229
+ writeToken: string;
230
+ }
231
+ /** Strong content version used as both the JSON version and HTTP ETag. */
232
+ declare function fileContentVersion(content: string): string;
233
+ /** Attach one API write's identity to the corresponding filesystem-watch echo. */
234
+ declare function consumeFileWriteReceipt(absPath: string): FileWriteReceipt | null;
235
+
226
236
  /**
227
237
  * Build a standalone HTML page for a sub-composition.
228
238
  *
@@ -274,4 +284,4 @@ type BackgroundRemovalRender = (options: {
274
284
  }>;
275
285
  declare function createBackgroundRemovalJob(opts: BackgroundRemovalJobOptions, render: BackgroundRemovalRender): MediaProcessingJobState;
276
286
 
277
- export { type BackgroundRemovalRender, type LintResult, MIME_TYPES, type MediaProcessingJobState, type RenderJobState, type ResolvedProject, type StudioApiAdapter, type StudioSelectionResponse, type StudioSelectionSnapshot, type StudioSelectionTextField, buildSubCompositionHtml, createBackgroundRemovalJob, createProjectSignature, createStudioApi, getMimeType, walkDir };
287
+ export { type BackgroundRemovalRender, type FileWriteReceipt, type LintResult, MIME_TYPES, type MediaProcessingJobState, type RenderJobState, type ResolvedProject, type StudioApiAdapter, type StudioSelectionResponse, type StudioSelectionSnapshot, type StudioSelectionTextField, buildSubCompositionHtml, consumeFileWriteReceipt, createBackgroundRemovalJob, createProjectSignature, createStudioApi, fileContentVersion, getMimeType, walkDir };
package/dist/index.js CHANGED
@@ -288,9 +288,13 @@ function registerStoryboardRoutes(api, adapter) {
288
288
  // src/routes/files.ts
289
289
  import { bodyLimit } from "hono/body-limit";
290
290
  import {
291
+ closeSync,
291
292
  existsSync as existsSync3,
293
+ ftruncateSync,
294
+ openSync,
292
295
  readFileSync as readFileSync4,
293
296
  writeFileSync as writeFileSync4,
297
+ writeSync,
294
298
  mkdirSync as mkdirSync3,
295
299
  unlinkSync as unlinkSync2,
296
300
  rmSync as rmSync2,
@@ -544,6 +548,38 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
544
548
  }
545
549
  }
546
550
 
551
+ // src/helpers/fileVersion.ts
552
+ import { createHash as createHash2, randomUUID } from "crypto";
553
+ var RECEIPT_TTL_MS = 1e4;
554
+ var receipts = /* @__PURE__ */ new Map();
555
+ function fileContentVersion(content) {
556
+ return `"sha256:${createHash2("sha256").update(content, "utf8").digest("hex")}"`;
557
+ }
558
+ function createWriteToken(requestToken) {
559
+ const token = requestToken?.trim();
560
+ return token && token.length <= 200 ? token : randomUUID();
561
+ }
562
+ function recordFileWriteReceipt(absPath, receipt) {
563
+ const now = Date.now();
564
+ const current = (receipts.get(absPath) ?? []).filter(
565
+ (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
566
+ );
567
+ current.push({ ...receipt, recordedAt: now });
568
+ receipts.set(absPath, current);
569
+ }
570
+ function consumeFileWriteReceipt(absPath) {
571
+ const now = Date.now();
572
+ const current = (receipts.get(absPath) ?? []).filter(
573
+ (entry) => now - entry.recordedAt < RECEIPT_TTL_MS
574
+ );
575
+ const receipt = current.shift() ?? null;
576
+ if (current.length > 0) receipts.set(absPath, current);
577
+ else receipts.delete(absPath);
578
+ if (!receipt) return null;
579
+ const { path, version, writeToken } = receipt;
580
+ return { path, version, writeToken };
581
+ }
582
+
547
583
  // src/helpers/finiteMutation.ts
548
584
  function findUnsafeMutationValues(value, path = "body", options = {}) {
549
585
  if (value === null) {
@@ -1570,9 +1606,11 @@ async function applyGsapMutations(c, res, mutations) {
1570
1606
  after: newHtml,
1571
1607
  scriptText: block.scriptText,
1572
1608
  path: res.filePath,
1609
+ version: fileContentVersion(newHtml),
1573
1610
  backupPath
1574
1611
  };
1575
1612
  if (skippedSelectors.size > 0) responsePayload.skippedSelectors = [...skippedSelectors];
1613
+ c.header("ETag", responsePayload.version);
1576
1614
  return c.json(responsePayload);
1577
1615
  }
1578
1616
  function executeGsapMutationAcorn(body, block, respond) {
@@ -2233,19 +2271,111 @@ function registerFileRoutes(api, adapter) {
2233
2271
  return c.json({ error: "not found" }, 404);
2234
2272
  }
2235
2273
  const content = readFileSync4(res.absPath, "utf-8");
2236
- return c.json({ filename: res.filePath, content });
2274
+ const version = fileContentVersion(content);
2275
+ c.header("ETag", version);
2276
+ return c.json({ filename: res.filePath, content, version });
2237
2277
  });
2238
2278
  api.put("/projects/:id/files/*", async (c) => {
2239
2279
  const res = await resolveProjectFile(c, adapter);
2240
2280
  if ("error" in res) return res.error;
2241
- ensureDir(res.absPath);
2242
2281
  const body = await c.req.text();
2243
- const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2244
- if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
2245
- writeFileSync4(res.absPath, body, "utf-8");
2282
+ const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
2283
+ const createOnly = c.req.header("If-None-Match")?.trim() === "*";
2284
+ if (expectedVersion === null && !createOnly) {
2285
+ let currentContent = null;
2286
+ try {
2287
+ currentContent = readFileSync4(res.absPath, "utf-8");
2288
+ } catch (error) {
2289
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2290
+ throw error;
2291
+ }
2292
+ }
2293
+ return c.json(
2294
+ {
2295
+ error: "precondition required",
2296
+ path: res.filePath,
2297
+ currentVersion: currentContent === null ? null : fileContentVersion(currentContent),
2298
+ currentContent
2299
+ },
2300
+ 428
2301
+ );
2302
+ }
2303
+ let backup = { backupPath: null };
2304
+ if (createOnly) {
2305
+ ensureDir(res.absPath);
2306
+ let fd;
2307
+ try {
2308
+ fd = openSync(res.absPath, "wx");
2309
+ } catch (error) {
2310
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
2311
+ throw error;
2312
+ }
2313
+ const currentContent = readFileSync4(res.absPath, "utf-8");
2314
+ return c.json(
2315
+ {
2316
+ error: "file conflict",
2317
+ path: res.filePath,
2318
+ currentVersion: fileContentVersion(currentContent),
2319
+ currentContent
2320
+ },
2321
+ 409
2322
+ );
2323
+ }
2324
+ try {
2325
+ writeSync(fd, body, 0, "utf-8");
2326
+ } finally {
2327
+ closeSync(fd);
2328
+ }
2329
+ } else {
2330
+ let fd;
2331
+ try {
2332
+ fd = openSync(res.absPath, "r+");
2333
+ } catch (error) {
2334
+ if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
2335
+ throw error;
2336
+ }
2337
+ return c.json(
2338
+ {
2339
+ error: "file conflict",
2340
+ path: res.filePath,
2341
+ currentVersion: null,
2342
+ currentContent: null
2343
+ },
2344
+ 409
2345
+ );
2346
+ }
2347
+ try {
2348
+ const currentContent = readFileSync4(fd, "utf-8");
2349
+ const currentVersion = fileContentVersion(currentContent);
2350
+ if (expectedVersion !== currentVersion) {
2351
+ return c.json(
2352
+ {
2353
+ error: "file conflict",
2354
+ path: res.filePath,
2355
+ currentVersion,
2356
+ currentContent
2357
+ },
2358
+ 409
2359
+ );
2360
+ }
2361
+ backup = snapshotBeforeWrite(res.project.dir, res.absPath);
2362
+ if (backup.error)
2363
+ console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
2364
+ ftruncateSync(fd, 0);
2365
+ writeSync(fd, body, 0, "utf-8");
2366
+ } finally {
2367
+ closeSync(fd);
2368
+ }
2369
+ }
2370
+ const version = fileContentVersion(body);
2371
+ const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
2372
+ recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
2373
+ c.header("ETag", version);
2246
2374
  return c.json({
2247
2375
  ok: true,
2248
2376
  path: res.filePath,
2377
+ version,
2378
+ writeToken,
2249
2379
  backupPath: backupPathForResponse(res.project.dir, backup.backupPath)
2250
2380
  });
2251
2381
  });
@@ -2317,17 +2447,28 @@ function registerFileRoutes(api, adapter) {
2317
2447
  fallbackTiming
2318
2448
  );
2319
2449
  if (!result.matched) {
2320
- return c.json({ ok: false, changed: false, content: originalContent, path: ctx.filePath });
2450
+ const version2 = fileContentVersion(originalContent);
2451
+ c.header("ETag", version2);
2452
+ return c.json({
2453
+ ok: false,
2454
+ changed: false,
2455
+ content: originalContent,
2456
+ path: ctx.filePath,
2457
+ version: version2
2458
+ });
2321
2459
  }
2322
2460
  const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2323
2461
  if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
2324
2462
  writeFileSync4(ctx.absPath, result.html, "utf-8");
2463
+ const version = fileContentVersion(result.html);
2464
+ c.header("ETag", version);
2325
2465
  return c.json({
2326
2466
  ok: true,
2327
2467
  changed: true,
2328
2468
  content: result.html,
2329
2469
  newId: result.newId,
2330
2470
  path: ctx.filePath,
2471
+ version,
2331
2472
  backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
2332
2473
  });
2333
2474
  });
@@ -2650,7 +2791,7 @@ function registerFileRoutes(api, adapter) {
2650
2791
  // src/routes/preview.ts
2651
2792
  import { existsSync as existsSync5, readFileSync as readFileSync7, statSync as statSync2 } from "fs";
2652
2793
  import { join as join8 } from "path";
2653
- import { createHash as createHash2 } from "crypto";
2794
+ import { createHash as createHash3 } from "crypto";
2654
2795
  import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2 } from "@hyperframes/core/compiler";
2655
2796
 
2656
2797
  // src/helpers/subComposition.ts
@@ -3022,14 +3163,14 @@ import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
3022
3163
  // src/helpers/hfIdPersist.ts
3023
3164
  import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
3024
3165
  import {
3025
- closeSync,
3166
+ closeSync as closeSync2,
3026
3167
  constants,
3027
3168
  fstatSync,
3028
- ftruncateSync,
3029
- openSync,
3169
+ ftruncateSync as ftruncateSync2,
3170
+ openSync as openSync2,
3030
3171
  readFileSync as readFileSync6,
3031
3172
  writeFileSync as writeFileSync5,
3032
- writeSync
3173
+ writeSync as writeSync2
3033
3174
  } from "fs";
3034
3175
  function persistHfIdsIfNeeded(filePath, html) {
3035
3176
  const normalized = ensureHfIds2(html);
@@ -3050,7 +3191,7 @@ function persistHfIdsIfNeeded(filePath, html) {
3050
3191
  function openNoFollow(filePath, flags) {
3051
3192
  const noFollow = constants.O_NOFOLLOW ?? 0;
3052
3193
  try {
3053
- return openSync(filePath, flags | noFollow);
3194
+ return openSync2(filePath, flags | noFollow);
3054
3195
  } catch {
3055
3196
  return null;
3056
3197
  }
@@ -3070,15 +3211,15 @@ function stampFileHfIds(filePath) {
3070
3211
  const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
3071
3212
  const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
3072
3213
  if (writable && idsAfter > idsBefore) {
3073
- ftruncateSync(fd, 0);
3074
- writeSync(fd, normalized, 0, "utf-8");
3214
+ ftruncateSync2(fd, 0);
3215
+ writeSync2(fd, normalized, 0, "utf-8");
3075
3216
  }
3076
3217
  return normalized;
3077
3218
  } catch (err) {
3078
3219
  console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
3079
3220
  return null;
3080
3221
  } finally {
3081
- closeSync(fd);
3222
+ closeSync2(fd);
3082
3223
  }
3083
3224
  }
3084
3225
 
@@ -3234,7 +3375,7 @@ function parsePreviewVariablesParam(raw) {
3234
3375
  }
3235
3376
  function variablesEtagSalt(raw) {
3236
3377
  if (!raw) return "";
3237
- return `:vars:${createHash2("sha1").update(raw).digest("hex").slice(0, 12)}`;
3378
+ return `:vars:${createHash3("sha1").update(raw).digest("hex").slice(0, 12)}`;
3238
3379
  }
3239
3380
  function previewVariablesFromRequest(rawVariables) {
3240
3381
  const parse = parsePreviewVariablesParam(rawVariables);
@@ -3706,7 +3847,7 @@ function registerRenderRoutes(api, adapter) {
3706
3847
  // src/routes/thumbnail.ts
3707
3848
  import { existsSync as existsSync7, readFileSync as readFileSync10, writeFileSync as writeFileSync6, mkdirSync as mkdirSync5, statSync as statSync4 } from "fs";
3708
3849
  import { join as join11 } from "path";
3709
- import { createHash as createHash3 } from "crypto";
3850
+ import { createHash as createHash4 } from "crypto";
3710
3851
 
3711
3852
  // src/helpers/manualEditsRenderScript.ts
3712
3853
  var STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json";
@@ -4318,7 +4459,7 @@ function registerThumbnailRoutes(api, adapter) {
4318
4459
  const htmlFile = join11(project.dir, compPath);
4319
4460
  if (existsSync7(htmlFile)) {
4320
4461
  const html = readFileSync10(htmlFile, "utf-8");
4321
- sourceKey = `_${createHash3("sha1").update(html).digest("hex").slice(0, 16)}`;
4462
+ sourceKey = `_${createHash4("sha1").update(html).digest("hex").slice(0, 16)}`;
4322
4463
  sourceMtime = Math.round(statSync4(htmlFile).mtimeMs);
4323
4464
  if (!vpWidth) {
4324
4465
  const wMatch = html.match(/data-width=["'](\d+)["']/);
@@ -4331,14 +4472,14 @@ function registerThumbnailRoutes(api, adapter) {
4331
4472
  let manualEditsKey = "";
4332
4473
  if (existsSync7(manualEditsFile)) {
4333
4474
  const manualEditsContent = readFileSync10(manualEditsFile, "utf-8");
4334
- manualEditsKey = `_${createHash3("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4475
+ manualEditsKey = `_${createHash4("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
4335
4476
  sourceMtime = Math.max(sourceMtime, Math.round(statSync4(manualEditsFile).mtimeMs));
4336
4477
  }
4337
4478
  const motionFile = join11(project.dir, STUDIO_MOTION_PATH);
4338
4479
  let motionKey = "";
4339
4480
  if (existsSync7(motionFile)) {
4340
4481
  const motionContent = readFileSync10(motionFile, "utf-8");
4341
- motionKey = `_${createHash3("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4482
+ motionKey = `_${createHash4("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
4342
4483
  sourceMtime = Math.max(sourceMtime, Math.round(statSync4(motionFile).mtimeMs));
4343
4484
  }
4344
4485
  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}`;
@@ -4419,7 +4560,7 @@ function registerWaveformRoutes(api, adapter) {
4419
4560
  }
4420
4561
 
4421
4562
  // src/routes/fonts.ts
4422
- import { closeSync as closeSync2, constants as constants2, fstatSync as fstatSync2, openSync as openSync2, readSync } from "fs";
4563
+ import { closeSync as closeSync3, constants as constants2, fstatSync as fstatSync2, openSync as openSync3, readSync } from "fs";
4423
4564
  import {
4424
4565
  collectFontFileEntries,
4425
4566
  fontDirectories,
@@ -4526,7 +4667,7 @@ function registerFontRoutes(api) {
4526
4667
  if (!located) return c.json({ error: "font not found" }, 404);
4527
4668
  let fd;
4528
4669
  try {
4529
- fd = openSync2(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4670
+ fd = openSync3(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
4530
4671
  } catch {
4531
4672
  return c.json({ error: "font file not accessible" }, 404);
4532
4673
  }
@@ -4548,7 +4689,7 @@ function registerFontRoutes(api) {
4548
4689
  } catch {
4549
4690
  return c.json({ error: "failed to read font file" }, 500);
4550
4691
  } finally {
4551
- closeSync2(fd);
4692
+ closeSync3(fd);
4552
4693
  }
4553
4694
  });
4554
4695
  }
@@ -5082,12 +5223,14 @@ export {
5082
5223
  STUDIO_MANUAL_EDITS_PATH,
5083
5224
  STUDIO_MOTION_PATH,
5084
5225
  buildSubCompositionHtml,
5226
+ consumeFileWriteReceipt,
5085
5227
  createBackgroundRemovalJob,
5086
5228
  createProjectSignature,
5087
5229
  createStudioApi,
5088
5230
  createStudioManualEditsRenderBodyScript,
5089
5231
  createStudioMotionRenderBodyScript,
5090
5232
  createStudioPositionSeekReapplyScript,
5233
+ fileContentVersion,
5091
5234
  getElementScreenshotClip,
5092
5235
  getMimeType,
5093
5236
  isSafePath,