@hyperframes/studio-server 0.8.78 → 0.8.80
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/{chunk-45POWYFS.js → chunk-56JREE33.js} +2 -2
- package/dist/{chunk-4MNTZVVZ.js → chunk-KOB22VPG.js} +14 -10
- package/dist/chunk-KOB22VPG.js.map +1 -0
- package/dist/{chunk-QYDRVMJ6.js → chunk-P5EJPIH3.js} +5 -3
- package/dist/chunk-P5EJPIH3.js.map +1 -0
- package/dist/{chunk-X3T65KC2.js → chunk-YGANSFUU.js} +3 -3
- package/dist/helpers/mediaCodecMap.d.ts +7 -7
- package/dist/helpers/mediaCodecMap.js +1 -1
- package/dist/helpers/mediaProxyPreview.d.ts +1 -1
- package/dist/helpers/mediaProxyPreview.js +3 -3
- package/dist/helpers/proxyTranscoder.js +2 -2
- package/dist/helpers/sourceMutation.js +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +1024 -483
- package/dist/index.js.map +1 -1
- package/dist/{mediaProxyPreview-BmJ8AmLd.d.ts → mediaProxyPreview-B7ckRI-l.d.ts} +20 -10
- package/package.json +4 -3
- package/dist/chunk-4MNTZVVZ.js.map +0 -1
- package/dist/chunk-QYDRVMJ6.js.map +0 -1
- /package/dist/{chunk-45POWYFS.js.map → chunk-56JREE33.js.map} +0 -0
- /package/dist/{chunk-X3T65KC2.js.map → chunk-YGANSFUU.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
splitElementInHtml,
|
|
14
14
|
unwrapElementsFromHtml,
|
|
15
15
|
wrapElementsInHtml
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-P5EJPIH3.js";
|
|
17
17
|
import {
|
|
18
18
|
getElementScreenshotClip
|
|
19
19
|
} from "./chunk-W2SBTCO2.js";
|
|
@@ -22,12 +22,12 @@ import {
|
|
|
22
22
|
isAutoProxyEnabled,
|
|
23
23
|
proxyEtagSalt,
|
|
24
24
|
resolvePreviewMediaCodecProbeCache
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-YGANSFUU.js";
|
|
26
26
|
import {
|
|
27
27
|
ProxyCapacityError,
|
|
28
28
|
ProxyTranscodeError,
|
|
29
29
|
resolveProxy
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-56JREE33.js";
|
|
31
31
|
import {
|
|
32
32
|
PROXY_VARIANT_CONFIG,
|
|
33
33
|
decideMediaProxyEligibility,
|
|
@@ -36,7 +36,7 @@ import {
|
|
|
36
36
|
probeMediaMetadata,
|
|
37
37
|
recordProxyRequest,
|
|
38
38
|
resolveProxyVariantRequest
|
|
39
|
-
} from "./chunk-
|
|
39
|
+
} from "./chunk-KOB22VPG.js";
|
|
40
40
|
import {
|
|
41
41
|
STUDIO_MANUAL_EDITS_PATH,
|
|
42
42
|
createStudioManualEditsRenderBodyScript,
|
|
@@ -55,9 +55,25 @@ import { readFile } from "fs/promises";
|
|
|
55
55
|
import { join as join3 } from "path";
|
|
56
56
|
|
|
57
57
|
// src/helpers/safePath.ts
|
|
58
|
-
import { join } from "path";
|
|
59
|
-
import { readdirSync } from "fs";
|
|
60
|
-
import {
|
|
58
|
+
import { basename, dirname, isAbsolute, join, relative, sep } from "path";
|
|
59
|
+
import { readdirSync, realpathSync } from "fs";
|
|
60
|
+
import { resolveWithinProject } from "@hyperframes/core";
|
|
61
|
+
import { isSafePath, resolveWithinProject as resolveWithinProject2 } from "@hyperframes/core";
|
|
62
|
+
function realFilePath(filePath) {
|
|
63
|
+
try {
|
|
64
|
+
return realpathSync(filePath);
|
|
65
|
+
} catch {
|
|
66
|
+
const dir = dirname(filePath);
|
|
67
|
+
return dir === filePath ? filePath : join(realFilePath(dir), basename(filePath));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function pinWithinProject(base, relativePath) {
|
|
71
|
+
const checked = resolveWithinProject(base, relativePath);
|
|
72
|
+
if (!checked) return null;
|
|
73
|
+
const inside = relative(realFilePath(base), realFilePath(checked));
|
|
74
|
+
const escapes = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
75
|
+
return escapes ? null : join(base, inside);
|
|
76
|
+
}
|
|
61
77
|
var IGNORE_DIRS = /* @__PURE__ */ new Set([
|
|
62
78
|
".thumbnails",
|
|
63
79
|
".transcode-cache",
|
|
@@ -107,7 +123,7 @@ import {
|
|
|
107
123
|
readFileSync,
|
|
108
124
|
writeFileSync
|
|
109
125
|
} from "fs";
|
|
110
|
-
import { dirname, join as join2 } from "path";
|
|
126
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
111
127
|
|
|
112
128
|
// src/helpers/atomicFile.ts
|
|
113
129
|
import * as fs from "fs";
|
|
@@ -157,7 +173,7 @@ function stampProjectHfIds(projectDir) {
|
|
|
157
173
|
next[file] = hash;
|
|
158
174
|
}
|
|
159
175
|
try {
|
|
160
|
-
mkdirSync(
|
|
176
|
+
mkdirSync(dirname2(recordPath), { recursive: true });
|
|
161
177
|
writeFileSync(recordPath, JSON.stringify(next));
|
|
162
178
|
} catch {
|
|
163
179
|
}
|
|
@@ -211,7 +227,7 @@ function stampFileHfIds(filePath) {
|
|
|
211
227
|
// src/helpers/projectSignature.ts
|
|
212
228
|
import { createHash as createHash2 } from "crypto";
|
|
213
229
|
import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
|
|
214
|
-
import { extname, isAbsolute, relative, resolve, sep } from "path";
|
|
230
|
+
import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
|
|
215
231
|
var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
216
232
|
".cjs",
|
|
217
233
|
".css",
|
|
@@ -246,18 +262,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
|
|
|
246
262
|
".hyperframes/studio-motion.json"
|
|
247
263
|
];
|
|
248
264
|
function affectsProjectSignature(projectDir, changedPath) {
|
|
249
|
-
const relativePath =
|
|
250
|
-
if (relativePath === "" || relativePath.startsWith("..") ||
|
|
265
|
+
const relativePath = relative2(resolve(projectDir), resolve(changedPath));
|
|
266
|
+
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
|
|
251
267
|
return false;
|
|
252
268
|
}
|
|
253
|
-
const segments = relativePath.split(
|
|
269
|
+
const segments = relativePath.split(sep2);
|
|
254
270
|
if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
|
|
255
271
|
return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
|
|
256
272
|
}
|
|
257
273
|
var projectSignatureCache = /* @__PURE__ */ new Map();
|
|
258
274
|
function isPathWithin(parentDir, childPath) {
|
|
259
|
-
const childRelativePath =
|
|
260
|
-
return childRelativePath === "" || !childRelativePath.startsWith("..") && !
|
|
275
|
+
const childRelativePath = relative2(parentDir, childPath);
|
|
276
|
+
return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
|
|
261
277
|
}
|
|
262
278
|
function isTextContentEligible(file, size) {
|
|
263
279
|
return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
|
|
@@ -273,22 +289,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
|
|
|
273
289
|
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
|
274
290
|
const file = resolve(dir, entry);
|
|
275
291
|
if (!isPathWithin(projectDir, file)) continue;
|
|
276
|
-
let
|
|
292
|
+
let stat3;
|
|
277
293
|
try {
|
|
278
|
-
|
|
294
|
+
stat3 = lstatSync(file);
|
|
279
295
|
} catch {
|
|
280
296
|
continue;
|
|
281
297
|
}
|
|
282
|
-
if (
|
|
283
|
-
if (
|
|
298
|
+
if (stat3.isSymbolicLink()) continue;
|
|
299
|
+
if (stat3.isDirectory()) {
|
|
284
300
|
collectProjectSignatureFiles(projectDir, file, files);
|
|
285
|
-
} else if (
|
|
301
|
+
} else if (stat3.isFile()) {
|
|
286
302
|
files.push({
|
|
287
303
|
file,
|
|
288
|
-
mtimeMs:
|
|
289
|
-
ctimeMs:
|
|
290
|
-
size:
|
|
291
|
-
textContentEligible: isTextContentEligible(file,
|
|
304
|
+
mtimeMs: stat3.mtimeMs,
|
|
305
|
+
ctimeMs: stat3.ctimeMs,
|
|
306
|
+
size: stat3.size,
|
|
307
|
+
textContentEligible: isTextContentEligible(file, stat3.size)
|
|
292
308
|
});
|
|
293
309
|
}
|
|
294
310
|
}
|
|
@@ -298,19 +314,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
|
298
314
|
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
|
|
299
315
|
const file = resolve(projectDir, manifestPath);
|
|
300
316
|
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
|
|
301
|
-
let
|
|
317
|
+
let stat3;
|
|
302
318
|
try {
|
|
303
|
-
|
|
319
|
+
stat3 = lstatSync(file);
|
|
304
320
|
} catch {
|
|
305
321
|
continue;
|
|
306
322
|
}
|
|
307
|
-
if (
|
|
323
|
+
if (stat3.isSymbolicLink() || !stat3.isFile()) continue;
|
|
308
324
|
files.push({
|
|
309
325
|
file,
|
|
310
|
-
mtimeMs:
|
|
311
|
-
ctimeMs:
|
|
312
|
-
size:
|
|
313
|
-
textContentEligible: isTextContentEligible(file,
|
|
326
|
+
mtimeMs: stat3.mtimeMs,
|
|
327
|
+
ctimeMs: stat3.ctimeMs,
|
|
328
|
+
size: stat3.size,
|
|
329
|
+
textContentEligible: isTextContentEligible(file, stat3.size)
|
|
314
330
|
});
|
|
315
331
|
seen.add(file);
|
|
316
332
|
}
|
|
@@ -318,7 +334,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
|
318
334
|
function createProjectFingerprint(projectDir, files) {
|
|
319
335
|
const hash = createHash2("sha256");
|
|
320
336
|
for (const entry of files) {
|
|
321
|
-
hash.update(
|
|
337
|
+
hash.update(relative2(projectDir, entry.file));
|
|
322
338
|
hash.update("\0");
|
|
323
339
|
hash.update(String(entry.size));
|
|
324
340
|
hash.update("\0");
|
|
@@ -340,7 +356,7 @@ function collectProjectFiles(normalizedProjectDir) {
|
|
|
340
356
|
function listProjectFiles(projectDir) {
|
|
341
357
|
const normalizedProjectDir = resolve(projectDir);
|
|
342
358
|
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
343
|
-
path:
|
|
359
|
+
path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
|
|
344
360
|
size: entry.size,
|
|
345
361
|
mtimeMs: entry.mtimeMs,
|
|
346
362
|
ctimeMs: entry.ctimeMs
|
|
@@ -358,7 +374,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
|
|
|
358
374
|
const normalizedProjectDir = resolve(projectDir);
|
|
359
375
|
const collected = collectProjectFiles(normalizedProjectDir);
|
|
360
376
|
const files = collected.filter(
|
|
361
|
-
(entry) => !excluding.has(
|
|
377
|
+
(entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
|
|
362
378
|
);
|
|
363
379
|
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
364
380
|
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
|
@@ -367,7 +383,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
|
|
|
367
383
|
if (cached?.fingerprint === fingerprint) return cached.signature;
|
|
368
384
|
const hash = createHash2("sha256");
|
|
369
385
|
for (const entry of files) {
|
|
370
|
-
const relativePath =
|
|
386
|
+
const relativePath = relative2(normalizedProjectDir, entry.file);
|
|
371
387
|
hash.update(relativePath);
|
|
372
388
|
hash.update("\0");
|
|
373
389
|
hash.update(String(entry.size));
|
|
@@ -450,7 +466,7 @@ import {
|
|
|
450
466
|
renameSync as renameSync2,
|
|
451
467
|
readdirSync as readdirSync4
|
|
452
468
|
} from "fs";
|
|
453
|
-
import { resolve as resolve3, dirname as
|
|
469
|
+
import { resolve as resolve3, dirname as dirname5, join as join7 } from "path";
|
|
454
470
|
|
|
455
471
|
// src/helpers/mime.ts
|
|
456
472
|
var MIME_TYPES = {
|
|
@@ -510,7 +526,7 @@ import {
|
|
|
510
526
|
rmSync,
|
|
511
527
|
statSync
|
|
512
528
|
} from "fs";
|
|
513
|
-
import { dirname as
|
|
529
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
514
530
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
|
515
531
|
var SAMPLE_RATE = 4e3;
|
|
516
532
|
var PEAK_COUNT = 4e3;
|
|
@@ -590,7 +606,7 @@ function isWaveformCacheDirectory(cacheDir) {
|
|
|
590
606
|
return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
591
607
|
}
|
|
592
608
|
function writeWaveformCache(cachePath, peaks) {
|
|
593
|
-
const cacheDir =
|
|
609
|
+
const cacheDir = dirname3(cachePath);
|
|
594
610
|
try {
|
|
595
611
|
mkdirSync2(cacheDir, { mode: 448 });
|
|
596
612
|
} catch (error) {
|
|
@@ -616,7 +632,7 @@ function writeWaveformCache(cachePath, peaks) {
|
|
|
616
632
|
import { spawnSync } from "child_process";
|
|
617
633
|
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
618
634
|
import { tmpdir } from "os";
|
|
619
|
-
import { basename, join as join5 } from "path";
|
|
635
|
+
import { basename as basename2, join as join5 } from "path";
|
|
620
636
|
var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
|
|
621
637
|
var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
622
638
|
function validateUploadedMedia(filePath, runner = spawnSync) {
|
|
@@ -654,7 +670,7 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
|
|
|
654
670
|
}
|
|
655
671
|
function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
|
|
656
672
|
const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
|
|
657
|
-
const tempPath = join5(tempDir,
|
|
673
|
+
const tempPath = join5(tempDir, basename2(fileName));
|
|
658
674
|
try {
|
|
659
675
|
writeFileSync3(tempPath, buffer);
|
|
660
676
|
return validateUploadedMedia(tempPath, runner);
|
|
@@ -666,7 +682,7 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
|
|
|
666
682
|
// src/helpers/backupJournal.ts
|
|
667
683
|
import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
|
|
668
684
|
import { Buffer as Buffer2 } from "buffer";
|
|
669
|
-
import { join as join6, relative as
|
|
685
|
+
import { join as join6, relative as relative3 } from "path";
|
|
670
686
|
var DEFAULT_KEEP_PER_FILE = 10;
|
|
671
687
|
function backupKeyForPath(path) {
|
|
672
688
|
return Buffer2.from(path, "utf-8").toString("base64url");
|
|
@@ -676,7 +692,7 @@ function timestampPrefix() {
|
|
|
676
692
|
}
|
|
677
693
|
function backupPathForResponse(projectDir, backupPath) {
|
|
678
694
|
if (!backupPath) return null;
|
|
679
|
-
const rel =
|
|
695
|
+
const rel = relative3(projectDir, backupPath);
|
|
680
696
|
if (!rel || rel.startsWith("..")) return null;
|
|
681
697
|
return rel.split("\\").join("/");
|
|
682
698
|
}
|
|
@@ -684,7 +700,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
|
684
700
|
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
|
|
685
701
|
try {
|
|
686
702
|
const content = readFileSync3(absPath);
|
|
687
|
-
const relativePath =
|
|
703
|
+
const relativePath = relative3(projectDir, absPath);
|
|
688
704
|
const backupDir = join6(projectDir, ".hyperframes", "backup");
|
|
689
705
|
mkdirSync3(backupDir, { recursive: true });
|
|
690
706
|
const backupKey = backupKeyForPath(relativePath);
|
|
@@ -745,30 +761,46 @@ function hashVersion(hex) {
|
|
|
745
761
|
function hashOfVersion(version) {
|
|
746
762
|
return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
|
|
747
763
|
}
|
|
748
|
-
function settledFileTag(
|
|
749
|
-
if (now -
|
|
750
|
-
return [
|
|
764
|
+
function settledFileTag(stat3, now = Date.now()) {
|
|
765
|
+
if (now - stat3.ctimeMs < 3e3) return null;
|
|
766
|
+
return [stat3.ino, stat3.ctimeMs, stat3.size].map((n) => n.toString(36)).join("-");
|
|
751
767
|
}
|
|
752
768
|
function createWriteToken(requestToken) {
|
|
753
769
|
const token = requestToken?.trim();
|
|
754
770
|
return token && token.length <= 200 ? token : randomUUID2();
|
|
755
771
|
}
|
|
756
|
-
function recordFileWriteReceipt(
|
|
772
|
+
function recordFileWriteReceipt(filePath, receipt) {
|
|
773
|
+
const absPath = realFilePath(filePath);
|
|
757
774
|
const now = Date.now();
|
|
758
|
-
const
|
|
759
|
-
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
775
|
+
for (const [path, list] of receipts) {
|
|
776
|
+
const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
|
|
777
|
+
if (live.length > 0) receipts.set(path, live);
|
|
778
|
+
else receipts.delete(path);
|
|
779
|
+
}
|
|
780
|
+
receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
|
|
763
781
|
}
|
|
764
|
-
function clearFileWriteReceipt(
|
|
782
|
+
function clearFileWriteReceipt(filePath, version, writeToken) {
|
|
783
|
+
const absPath = realFilePath(filePath);
|
|
765
784
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
766
785
|
(entry) => entry.version !== version || entry.writeToken !== writeToken
|
|
767
786
|
);
|
|
768
787
|
if (current.length > 0) receipts.set(absPath, current);
|
|
769
788
|
else receipts.delete(absPath);
|
|
770
789
|
}
|
|
771
|
-
function identifyFileWrite(
|
|
790
|
+
function identifyFileWrite(filePath, expectedVersion) {
|
|
791
|
+
const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
|
|
792
|
+
if (!receipt) return null;
|
|
793
|
+
const { path, version, writeToken } = receipt;
|
|
794
|
+
return { path, version, writeToken };
|
|
795
|
+
}
|
|
796
|
+
function bytesOverwrittenBy(filePath, version) {
|
|
797
|
+
return newestReceipt(realFilePath(filePath), version)?.overwrote;
|
|
798
|
+
}
|
|
799
|
+
function forgetOverwrittenBytes(filePath, versions) {
|
|
800
|
+
for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
|
|
801
|
+
if (versions.has(receipt.version)) delete receipt.overwrote;
|
|
802
|
+
}
|
|
803
|
+
function newestReceipt(absPath, expectedVersion) {
|
|
772
804
|
const now = Date.now();
|
|
773
805
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
774
806
|
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS
|
|
@@ -779,15 +811,13 @@ function identifyFileWrite(absPath, expectedVersion) {
|
|
|
779
811
|
for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
|
|
780
812
|
if (current[i]?.version === expectedVersion) receipt = current[i];
|
|
781
813
|
}
|
|
782
|
-
|
|
783
|
-
const { path, version, writeToken } = receipt;
|
|
784
|
-
return { path, version, writeToken };
|
|
814
|
+
return receipt;
|
|
785
815
|
}
|
|
786
816
|
var consumeFileWriteReceipt = identifyFileWrite;
|
|
787
817
|
|
|
788
818
|
// src/helpers/applyFileMutations.ts
|
|
789
819
|
import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
|
|
790
|
-
function applyFileMutations(projectDir, mutations, requestToken,
|
|
820
|
+
function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
|
|
791
821
|
const prepared = mutations.map((mutation) => ({
|
|
792
822
|
...mutation,
|
|
793
823
|
before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
|
|
@@ -795,10 +825,10 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
|
|
|
795
825
|
const results = [];
|
|
796
826
|
const attempted = [];
|
|
797
827
|
for (const mutation of prepared)
|
|
798
|
-
assertExpectedVersion(mutation
|
|
828
|
+
assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
|
|
799
829
|
try {
|
|
800
830
|
for (const mutation of prepared) {
|
|
801
|
-
results.push(applyOneMutation(projectDir, mutation, requestToken,
|
|
831
|
+
results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted));
|
|
802
832
|
}
|
|
803
833
|
return results;
|
|
804
834
|
} catch (error) {
|
|
@@ -806,7 +836,7 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
|
|
|
806
836
|
for (const mutation of attempted.reverse()) {
|
|
807
837
|
try {
|
|
808
838
|
if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
|
|
809
|
-
|
|
839
|
+
writeFile2(mutation.absPath, mutation.before, "utf-8");
|
|
810
840
|
clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
|
|
811
841
|
} catch (rollbackError) {
|
|
812
842
|
rollbackErrors.push(rollbackError);
|
|
@@ -821,9 +851,9 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
|
|
|
821
851
|
throw error;
|
|
822
852
|
}
|
|
823
853
|
}
|
|
824
|
-
function applyOneMutation(projectDir, mutation, requestToken,
|
|
854
|
+
function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted) {
|
|
825
855
|
const current = readFileSync4(mutation.absPath, "utf-8");
|
|
826
|
-
assertExpectedVersion(mutation
|
|
856
|
+
assertExpectedVersion(mutation, current);
|
|
827
857
|
if (mutation.after === mutation.before) {
|
|
828
858
|
return {
|
|
829
859
|
...mutation,
|
|
@@ -836,17 +866,19 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
|
|
|
836
866
|
}
|
|
837
867
|
const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
|
|
838
868
|
if (backup.error) throw new Error(`backup failed: ${backup.error}`);
|
|
869
|
+
assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
|
|
839
870
|
const before = current;
|
|
840
871
|
const version = fileContentVersion(mutation.after);
|
|
841
872
|
const writeToken = createWriteToken(requestToken);
|
|
842
873
|
const attempt = { ...mutation, before, version, writeToken, written: false };
|
|
843
874
|
attempted.push(attempt);
|
|
844
|
-
|
|
875
|
+
writeFile2(mutation.absPath, mutation.after, "utf-8");
|
|
845
876
|
attempt.written = true;
|
|
846
877
|
recordFileWriteReceipt(mutation.absPath, {
|
|
847
878
|
path: mutation.sourceFile,
|
|
848
879
|
version,
|
|
849
|
-
writeToken
|
|
880
|
+
writeToken,
|
|
881
|
+
overwrote: before
|
|
850
882
|
});
|
|
851
883
|
return {
|
|
852
884
|
...mutation,
|
|
@@ -857,10 +889,16 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
|
|
|
857
889
|
writeToken
|
|
858
890
|
};
|
|
859
891
|
}
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
892
|
+
var FileChangedError = class extends Error {
|
|
893
|
+
constructor(sourceFile) {
|
|
894
|
+
super("file changed since the timeline was read");
|
|
895
|
+
this.sourceFile = sourceFile;
|
|
863
896
|
}
|
|
897
|
+
sourceFile;
|
|
898
|
+
};
|
|
899
|
+
function assertExpectedVersion(mutation, current) {
|
|
900
|
+
const stale = mutation.expectedVersion === void 0 ? current !== mutation.before : fileContentVersion(current) !== mutation.expectedVersion;
|
|
901
|
+
if (stale) throw new FileChangedError(mutation.sourceFile);
|
|
864
902
|
}
|
|
865
903
|
|
|
866
904
|
// src/routes/files.ts
|
|
@@ -899,9 +937,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
|
|
|
899
937
|
import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
|
|
900
938
|
|
|
901
939
|
// src/helpers/compositionInsertion.ts
|
|
902
|
-
import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync } from "fs";
|
|
940
|
+
import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
|
|
903
941
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
904
|
-
import { dirname as
|
|
942
|
+
import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
|
|
905
943
|
import { parseHTML } from "linkedom";
|
|
906
944
|
var CompositionInsertionError = class extends Error {
|
|
907
945
|
constructor(message, status) {
|
|
@@ -937,8 +975,8 @@ function canonicalProjectPath(projectDir, candidate) {
|
|
|
937
975
|
if (!existsSync2(candidate)) {
|
|
938
976
|
throw new CompositionInsertionError("Composition source was not found", 404);
|
|
939
977
|
}
|
|
940
|
-
const canonical =
|
|
941
|
-
if (!isSafePath(
|
|
978
|
+
const canonical = realpathSync2(candidate);
|
|
979
|
+
if (!isSafePath(realpathSync2(projectDir), canonical)) {
|
|
942
980
|
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
|
943
981
|
}
|
|
944
982
|
return canonical;
|
|
@@ -950,13 +988,13 @@ function validateSourcePath(sourcePath) {
|
|
|
950
988
|
}
|
|
951
989
|
function canonicalProjectFile(projectDir, sourcePath) {
|
|
952
990
|
validateSourcePath(sourcePath);
|
|
953
|
-
return canonicalProjectPath(projectDir,
|
|
991
|
+
return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
|
|
954
992
|
}
|
|
955
993
|
function canonicalDependency(projectDir, ownerAbs, sourcePath) {
|
|
956
994
|
validateSourcePath(sourcePath);
|
|
957
995
|
return canonicalProjectPath(
|
|
958
996
|
projectDir,
|
|
959
|
-
|
|
997
|
+
resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
|
|
960
998
|
);
|
|
961
999
|
}
|
|
962
1000
|
function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
|
|
@@ -1029,7 +1067,7 @@ function uniqueHostId(root, base) {
|
|
|
1029
1067
|
return `${base}_${suffix}`;
|
|
1030
1068
|
}
|
|
1031
1069
|
function relativeSourcePath(targetAbs, sourceAbs) {
|
|
1032
|
-
return
|
|
1070
|
+
return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
|
|
1033
1071
|
}
|
|
1034
1072
|
function insertCompositionIntoSource(input) {
|
|
1035
1073
|
const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
|
|
@@ -1161,7 +1199,7 @@ async function loadGsapParser() {
|
|
|
1161
1199
|
return import("@hyperframes/parsers/gsap-parser-recast");
|
|
1162
1200
|
}
|
|
1163
1201
|
function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
1164
|
-
if (!isSafePath(projectDir,
|
|
1202
|
+
if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
|
|
1165
1203
|
let stats;
|
|
1166
1204
|
try {
|
|
1167
1205
|
stats = lstatSync3(lexicalPath);
|
|
@@ -1169,7 +1207,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
|
1169
1207
|
return false;
|
|
1170
1208
|
}
|
|
1171
1209
|
if (!stats.isSymbolicLink()) return false;
|
|
1172
|
-
const target = resolve3(
|
|
1210
|
+
const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
|
|
1173
1211
|
if (!isSafePath(projectDir, target)) return false;
|
|
1174
1212
|
try {
|
|
1175
1213
|
statSync3(lexicalPath);
|
|
@@ -1193,7 +1231,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
|
|
|
1193
1231
|
if (filePath.includes("\0")) {
|
|
1194
1232
|
return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
|
|
1195
1233
|
}
|
|
1196
|
-
const absPath =
|
|
1234
|
+
const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
|
|
1197
1235
|
if (!absPath) {
|
|
1198
1236
|
if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
|
|
1199
1237
|
return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
|
|
@@ -1209,7 +1247,7 @@ function resolveProjectFile(c, adapter, opts) {
|
|
|
1209
1247
|
return resolveProjectPath(c, adapter, "files", opts);
|
|
1210
1248
|
}
|
|
1211
1249
|
function resolveFileMutationContext(c, adapter, operation) {
|
|
1212
|
-
return resolveProjectPath(c, adapter, `file-mutations/${operation}
|
|
1250
|
+
return resolveProjectPath(c, adapter, `file-mutations/${operation}`, { pin: true });
|
|
1213
1251
|
}
|
|
1214
1252
|
function isOptionalInteger(value) {
|
|
1215
1253
|
return value === void 0 || Number.isInteger(value);
|
|
@@ -1258,11 +1296,23 @@ function foldElementPatches(originalContent, patches) {
|
|
|
1258
1296
|
}
|
|
1259
1297
|
return { content, matched };
|
|
1260
1298
|
}
|
|
1261
|
-
|
|
1299
|
+
var PATCH_CONFLICT_ATTEMPTS = 3;
|
|
1300
|
+
function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
|
|
1301
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
1302
|
+
try {
|
|
1303
|
+
return foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken);
|
|
1304
|
+
} catch (error) {
|
|
1305
|
+
if (!(error instanceof FileChangedError)) throw error;
|
|
1306
|
+
if (attempt === PATCH_CONFLICT_ATTEMPTS)
|
|
1307
|
+
return { error: "conflict", sourceFile: error.sourceFile };
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, requestToken) {
|
|
1262
1312
|
const resolvedPaths = /* @__PURE__ */ new Set();
|
|
1263
1313
|
const prepared = [];
|
|
1264
1314
|
for (const batch of batches) {
|
|
1265
|
-
const absPath =
|
|
1315
|
+
const absPath = pinWithinProject(projectDir, batch.sourceFile);
|
|
1266
1316
|
if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
|
|
1267
1317
|
if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
|
|
1268
1318
|
resolvedPaths.add(absPath);
|
|
@@ -1303,7 +1353,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = (path, conte
|
|
|
1303
1353
|
after
|
|
1304
1354
|
})),
|
|
1305
1355
|
requestToken,
|
|
1306
|
-
|
|
1356
|
+
writeFile2
|
|
1307
1357
|
);
|
|
1308
1358
|
const files = applied.map((file, index) => ({
|
|
1309
1359
|
sourceFile: file.sourceFile,
|
|
@@ -1319,23 +1369,24 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
|
|
|
1319
1369
|
return commitElementPatchBatches(
|
|
1320
1370
|
projectDir,
|
|
1321
1371
|
batches,
|
|
1322
|
-
|
|
1372
|
+
void 0,
|
|
1323
1373
|
c.req.header("X-Hyperframes-Write-Token")
|
|
1324
1374
|
);
|
|
1325
1375
|
}
|
|
1326
|
-
function
|
|
1376
|
+
function writeFileWithReceipt(c, filePath, absPath, html) {
|
|
1377
|
+
const overwrote = readFileSync6(absPath);
|
|
1378
|
+
replaceFileAtomically(absPath, html, statSync3(absPath).mode);
|
|
1327
1379
|
const version = fileContentVersion(html);
|
|
1328
1380
|
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
|
1329
|
-
recordFileWriteReceipt(absPath, { path: filePath, version, writeToken });
|
|
1381
|
+
recordFileWriteReceipt(absPath, { path: filePath, version, writeToken, overwrote });
|
|
1330
1382
|
return { version, writeToken };
|
|
1331
1383
|
}
|
|
1332
|
-
function
|
|
1333
|
-
replaceFileAtomically(absPath, html, statSync3(absPath).mode);
|
|
1334
|
-
return recordMutationReceipt(c, filePath, absPath, html);
|
|
1335
|
-
}
|
|
1336
|
-
function writeMutationResult(c, projectDir, filePath, absPath, html) {
|
|
1384
|
+
function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
|
|
1337
1385
|
const backup = snapshotBeforeWrite(projectDir, absPath);
|
|
1338
1386
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
1387
|
+
if (readFileSync6(absPath, "utf-8") !== original) {
|
|
1388
|
+
return c.json({ error: "file changed", conflict: true, path: filePath }, 409);
|
|
1389
|
+
}
|
|
1339
1390
|
const { version } = writeFileWithReceipt(c, filePath, absPath, html);
|
|
1340
1391
|
return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
|
|
1341
1392
|
}
|
|
@@ -1343,7 +1394,7 @@ function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
|
|
|
1343
1394
|
if (next === original) {
|
|
1344
1395
|
return c.json({ ok: true, changed: false, content: original, path: filePath });
|
|
1345
1396
|
}
|
|
1346
|
-
const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next);
|
|
1397
|
+
const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next, original);
|
|
1347
1398
|
if (mutationResult instanceof Response) return mutationResult;
|
|
1348
1399
|
const { backupPath } = mutationResult;
|
|
1349
1400
|
return c.json({
|
|
@@ -1365,6 +1416,8 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
|
|
|
1365
1416
|
);
|
|
1366
1417
|
}
|
|
1367
1418
|
function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
|
|
1419
|
+
if (error === "conflict")
|
|
1420
|
+
return c.json({ error: "file changed", conflict: true, sourceFile }, 409);
|
|
1368
1421
|
if (error === "not-found") return c.json({ error, sourceFile }, 404);
|
|
1369
1422
|
if (error === "forbidden") return c.json({ error, sourceFile }, 403);
|
|
1370
1423
|
return c.json({ error: "duplicate source file", sourceFile }, 400);
|
|
@@ -1377,7 +1430,7 @@ async function parseMutationBody(c) {
|
|
|
1377
1430
|
return { target: body.target, body };
|
|
1378
1431
|
}
|
|
1379
1432
|
function ensureDir(filePath) {
|
|
1380
|
-
const dir =
|
|
1433
|
+
const dir = dirname5(filePath);
|
|
1381
1434
|
if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
|
|
1382
1435
|
}
|
|
1383
1436
|
function generateCopyPath(projectDir, originalPath) {
|
|
@@ -1765,7 +1818,8 @@ async function applyGsapMutations(c, res, mutations) {
|
|
|
1765
1818
|
res.project.dir,
|
|
1766
1819
|
res.filePath,
|
|
1767
1820
|
res.absPath,
|
|
1768
|
-
newHtml
|
|
1821
|
+
newHtml,
|
|
1822
|
+
beforeHtml
|
|
1769
1823
|
);
|
|
1770
1824
|
if (mutationResult instanceof Response) return mutationResult;
|
|
1771
1825
|
backupPath = mutationResult.backupPath;
|
|
@@ -2598,7 +2652,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2598
2652
|
}
|
|
2599
2653
|
});
|
|
2600
2654
|
api.put("/projects/:id/files/*", async (c) => {
|
|
2601
|
-
const res = await resolveProjectFile(c, adapter);
|
|
2655
|
+
const res = await resolveProjectFile(c, adapter, { pin: true });
|
|
2602
2656
|
if ("error" in res) return res.error;
|
|
2603
2657
|
const body = Buffer.from(await c.req.arrayBuffer());
|
|
2604
2658
|
const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
|
|
@@ -2623,6 +2677,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2623
2677
|
);
|
|
2624
2678
|
}
|
|
2625
2679
|
let backup = { backupPath: null };
|
|
2680
|
+
let overwrote;
|
|
2626
2681
|
if (createOnly) {
|
|
2627
2682
|
ensureDir(res.absPath);
|
|
2628
2683
|
let fd;
|
|
@@ -2668,6 +2723,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2668
2723
|
}
|
|
2669
2724
|
try {
|
|
2670
2725
|
const currentContent = readFileSync6(fd);
|
|
2726
|
+
overwrote = currentContent;
|
|
2671
2727
|
const currentVersion = fileContentVersion(currentContent);
|
|
2672
2728
|
if (expectedVersion !== currentVersion) {
|
|
2673
2729
|
return c.json(
|
|
@@ -2692,7 +2748,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2692
2748
|
}
|
|
2693
2749
|
const version = fileContentVersion(body);
|
|
2694
2750
|
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
|
2695
|
-
recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
|
|
2751
|
+
recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken, overwrote });
|
|
2696
2752
|
c.header("ETag", version);
|
|
2697
2753
|
return c.json({
|
|
2698
2754
|
ok: true,
|
|
@@ -2720,10 +2776,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
2720
2776
|
api.delete("/projects/:id/files/*", async (c) => {
|
|
2721
2777
|
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
|
2722
2778
|
if ("error" in res) return res.error;
|
|
2723
|
-
const
|
|
2779
|
+
const stat3 = statSync3(res.absPath);
|
|
2724
2780
|
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
2725
2781
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
2726
|
-
if (
|
|
2782
|
+
if (stat3.isDirectory()) {
|
|
2727
2783
|
rmSync3(res.absPath, { recursive: true });
|
|
2728
2784
|
} else {
|
|
2729
2785
|
unlinkSync2(res.absPath);
|
|
@@ -2771,6 +2827,11 @@ function registerFileRoutes(api, adapter) {
|
|
|
2771
2827
|
}
|
|
2772
2828
|
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
|
2773
2829
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
2830
|
+
const current = readFileSync6(ctx.absPath, "utf-8");
|
|
2831
|
+
if (current !== before) {
|
|
2832
|
+
const currentVersion2 = fileContentVersion(current);
|
|
2833
|
+
return c.json({ error: "file conflict", currentVersion: currentVersion2, currentContent: current }, 409);
|
|
2834
|
+
}
|
|
2774
2835
|
const { version, writeToken } = writeFileWithReceipt(
|
|
2775
2836
|
c,
|
|
2776
2837
|
ctx.filePath,
|
|
@@ -2847,7 +2908,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2847
2908
|
const seen = /* @__PURE__ */ new Set();
|
|
2848
2909
|
const prepared = [];
|
|
2849
2910
|
for (const file of files) {
|
|
2850
|
-
const absPath =
|
|
2911
|
+
const absPath = pinWithinProject(project.dir, file.path);
|
|
2851
2912
|
if (!absPath) return c.json({ error: `forbidden path: ${file.path}` }, 403);
|
|
2852
2913
|
if (seen.has(absPath)) return c.json({ error: `duplicate path: ${file.path}` }, 400);
|
|
2853
2914
|
seen.add(absPath);
|
|
@@ -2915,7 +2976,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
2915
2976
|
recordFileWriteReceipt(file.absPath, {
|
|
2916
2977
|
path: file.path,
|
|
2917
2978
|
version: fileContentVersion(file.after),
|
|
2918
|
-
writeToken
|
|
2979
|
+
writeToken,
|
|
2980
|
+
overwrote: file.before
|
|
2919
2981
|
});
|
|
2920
2982
|
}
|
|
2921
2983
|
} catch (error) {
|
|
@@ -2997,7 +3059,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
2997
3059
|
ctx.project.dir,
|
|
2998
3060
|
ctx.filePath,
|
|
2999
3061
|
ctx.absPath,
|
|
3000
|
-
result.html
|
|
3062
|
+
result.html,
|
|
3063
|
+
originalContent
|
|
3001
3064
|
);
|
|
3002
3065
|
if (mutationResult instanceof Response) return mutationResult;
|
|
3003
3066
|
const { version, backupPath } = mutationResult;
|
|
@@ -3024,48 +3087,54 @@ function registerFileRoutes(api, adapter) {
|
|
|
3024
3087
|
if (unsafeFields.length > 0) {
|
|
3025
3088
|
return rejectUnsafeMutationValues(c, unsafeFields);
|
|
3026
3089
|
}
|
|
3027
|
-
let
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3090
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
3091
|
+
let originalContent;
|
|
3092
|
+
try {
|
|
3093
|
+
originalContent = readFileSync6(ctx.absPath, "utf-8");
|
|
3094
|
+
} catch {
|
|
3095
|
+
return c.json({ error: "not found" }, 404);
|
|
3096
|
+
}
|
|
3097
|
+
const { html: patched, matched } = patchElementInHtml(
|
|
3098
|
+
originalContent,
|
|
3099
|
+
parsed.target,
|
|
3100
|
+
parsed.body.operations
|
|
3101
|
+
);
|
|
3102
|
+
if (patched === originalContent) {
|
|
3103
|
+
const version2 = fileContentVersion(originalContent);
|
|
3104
|
+
c.header("ETag", version2);
|
|
3105
|
+
return c.json({
|
|
3106
|
+
ok: true,
|
|
3107
|
+
changed: false,
|
|
3108
|
+
matched,
|
|
3109
|
+
content: originalContent,
|
|
3110
|
+
path: ctx.filePath,
|
|
3111
|
+
version: version2
|
|
3112
|
+
});
|
|
3113
|
+
}
|
|
3114
|
+
const mutationResult = writeMutationResult(
|
|
3115
|
+
c,
|
|
3116
|
+
ctx.project.dir,
|
|
3117
|
+
ctx.filePath,
|
|
3118
|
+
ctx.absPath,
|
|
3119
|
+
patched,
|
|
3120
|
+
originalContent
|
|
3121
|
+
);
|
|
3122
|
+
if (mutationResult instanceof Response) {
|
|
3123
|
+
if (mutationResult.status === 409 && attempt < PATCH_CONFLICT_ATTEMPTS) continue;
|
|
3124
|
+
return mutationResult;
|
|
3125
|
+
}
|
|
3126
|
+
const { backupPath, version } = mutationResult;
|
|
3127
|
+
c.header("ETag", version);
|
|
3041
3128
|
return c.json({
|
|
3042
3129
|
ok: true,
|
|
3043
|
-
changed:
|
|
3130
|
+
changed: true,
|
|
3044
3131
|
matched,
|
|
3045
|
-
content:
|
|
3132
|
+
content: patched,
|
|
3046
3133
|
path: ctx.filePath,
|
|
3047
|
-
version
|
|
3134
|
+
version,
|
|
3135
|
+
backupPath
|
|
3048
3136
|
});
|
|
3049
3137
|
}
|
|
3050
|
-
const mutationResult = writeMutationResult(
|
|
3051
|
-
c,
|
|
3052
|
-
ctx.project.dir,
|
|
3053
|
-
ctx.filePath,
|
|
3054
|
-
ctx.absPath,
|
|
3055
|
-
patched
|
|
3056
|
-
);
|
|
3057
|
-
if (mutationResult instanceof Response) return mutationResult;
|
|
3058
|
-
const { backupPath, version } = mutationResult;
|
|
3059
|
-
c.header("ETag", version);
|
|
3060
|
-
return c.json({
|
|
3061
|
-
ok: true,
|
|
3062
|
-
changed: true,
|
|
3063
|
-
matched,
|
|
3064
|
-
content: patched,
|
|
3065
|
-
path: ctx.filePath,
|
|
3066
|
-
version,
|
|
3067
|
-
backupPath
|
|
3068
|
-
});
|
|
3069
3138
|
});
|
|
3070
3139
|
api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
|
|
3071
3140
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
@@ -3155,7 +3224,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
3155
3224
|
ctx.project.dir,
|
|
3156
3225
|
ctx.filePath,
|
|
3157
3226
|
ctx.absPath,
|
|
3158
|
-
result.html
|
|
3227
|
+
result.html,
|
|
3228
|
+
originalContent
|
|
3159
3229
|
);
|
|
3160
3230
|
if (mutationResult instanceof Response) return mutationResult;
|
|
3161
3231
|
const { backupPath } = mutationResult;
|
|
@@ -3225,7 +3295,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3225
3295
|
if (!body.newPath || body.newPath.includes("\0")) {
|
|
3226
3296
|
return c.json({ error: "newPath required" }, 400);
|
|
3227
3297
|
}
|
|
3228
|
-
const newAbs =
|
|
3298
|
+
const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
|
|
3229
3299
|
if (!newAbs) {
|
|
3230
3300
|
return c.json({ error: "forbidden" }, 403);
|
|
3231
3301
|
}
|
|
@@ -3244,17 +3314,24 @@ function registerFileRoutes(api, adapter) {
|
|
|
3244
3314
|
if (!body.path || body.path.includes("\0")) {
|
|
3245
3315
|
return c.json({ error: "path required" }, 400);
|
|
3246
3316
|
}
|
|
3247
|
-
const srcAbs =
|
|
3317
|
+
const srcAbs = resolveWithinProject2(project.dir, body.path);
|
|
3248
3318
|
if (!srcAbs || !existsSync3(srcAbs)) {
|
|
3249
3319
|
return c.json({ error: "not found" }, 404);
|
|
3250
3320
|
}
|
|
3251
3321
|
const copyPath = generateCopyPath(project.dir, body.path);
|
|
3252
|
-
const destAbs =
|
|
3322
|
+
const destAbs = resolveWithinProject2(project.dir, copyPath);
|
|
3253
3323
|
if (!destAbs) {
|
|
3254
3324
|
return c.json({ error: "forbidden" }, 403);
|
|
3255
3325
|
}
|
|
3256
3326
|
ensureDir(destAbs);
|
|
3257
|
-
|
|
3327
|
+
try {
|
|
3328
|
+
writeFileSync5(destAbs, readFileSync6(srcAbs), { flag: "wx" });
|
|
3329
|
+
} catch (error) {
|
|
3330
|
+
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
|
|
3331
|
+
throw error;
|
|
3332
|
+
}
|
|
3333
|
+
return c.json({ error: "already exists" }, 409);
|
|
3334
|
+
}
|
|
3258
3335
|
return c.json({ ok: true, path: copyPath }, 201);
|
|
3259
3336
|
});
|
|
3260
3337
|
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
@@ -3268,7 +3345,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3268
3345
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3269
3346
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
3270
3347
|
const subDir = c.req.query("dir") ?? "";
|
|
3271
|
-
const targetDir = subDir ?
|
|
3348
|
+
const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
|
|
3272
3349
|
if (!targetDir) return c.json({ error: "forbidden" }, 403);
|
|
3273
3350
|
if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
|
|
3274
3351
|
const formData = await c.req.formData();
|
|
@@ -3304,7 +3381,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
3304
3381
|
});
|
|
3305
3382
|
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
|
3306
3383
|
const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
|
|
3307
|
-
mustExist: true
|
|
3384
|
+
mustExist: true,
|
|
3385
|
+
pin: true
|
|
3308
3386
|
});
|
|
3309
3387
|
if ("error" in res) return res.error;
|
|
3310
3388
|
const body = await c.req.json().catch(() => null);
|
|
@@ -3314,7 +3392,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
3314
3392
|
return applyGsapMutations(c, res, [body]);
|
|
3315
3393
|
});
|
|
3316
3394
|
api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
|
|
3317
|
-
const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", {
|
|
3395
|
+
const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", {
|
|
3396
|
+
mustExist: true,
|
|
3397
|
+
pin: true
|
|
3398
|
+
});
|
|
3318
3399
|
if ("error" in res) return res.error;
|
|
3319
3400
|
const body = await c.req.json().catch(() => null);
|
|
3320
3401
|
if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
|
|
@@ -3327,7 +3408,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
3327
3408
|
return applyGsapMutations(c, res, body.mutations);
|
|
3328
3409
|
});
|
|
3329
3410
|
api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
|
|
3330
|
-
const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", {
|
|
3411
|
+
const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", {
|
|
3412
|
+
mustExist: true,
|
|
3413
|
+
pin: true
|
|
3414
|
+
});
|
|
3331
3415
|
if ("error" in res) return res.error;
|
|
3332
3416
|
const body = await c.req.json().catch(() => null);
|
|
3333
3417
|
if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
|
|
@@ -3345,9 +3429,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
3345
3429
|
// src/routes/preview.ts
|
|
3346
3430
|
import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync8, statSync as statSync4 } from "fs";
|
|
3347
3431
|
import { Readable } from "stream";
|
|
3348
|
-
import { join as
|
|
3432
|
+
import { join as join10, resolve as resolve5 } from "path";
|
|
3349
3433
|
import { createHash as createHash4 } from "crypto";
|
|
3350
3434
|
import {
|
|
3435
|
+
addScenePartsManifest,
|
|
3351
3436
|
injectScriptsIntoHtml,
|
|
3352
3437
|
insertBeforeCloseTag as insertBeforeCloseTag2,
|
|
3353
3438
|
stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
|
|
@@ -3364,9 +3449,7 @@ import {
|
|
|
3364
3449
|
rewriteInlineStyleAssetUrls
|
|
3365
3450
|
} from "@hyperframes/core";
|
|
3366
3451
|
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
|
3367
|
-
|
|
3368
|
-
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
|
|
3369
|
-
}
|
|
3452
|
+
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
|
|
3370
3453
|
function rewriteRelativePaths(root, compPath, projectDir) {
|
|
3371
3454
|
const assetExists = (path) => existsSync4(join8(projectDir, path));
|
|
3372
3455
|
rewriteAssetPaths(
|
|
@@ -3539,6 +3622,83 @@ function hasBaseElement(html) {
|
|
|
3539
3622
|
// src/routes/preview.ts
|
|
3540
3623
|
import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
|
|
3541
3624
|
|
|
3625
|
+
// src/helpers/previewReads.ts
|
|
3626
|
+
import { readlinkSync as readlinkSync2 } from "fs";
|
|
3627
|
+
import { dirname as dirname6, join as join9, parse, relative as relative5, resolve as resolve4 } from "path";
|
|
3628
|
+
var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
|
|
3629
|
+
var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
|
|
3630
|
+
var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve4(path).toLowerCase() : (path) => resolve4(path);
|
|
3631
|
+
var readsByProject = /* @__PURE__ */ new Map();
|
|
3632
|
+
var builtProjects = /* @__PURE__ */ new Set();
|
|
3633
|
+
var projectKey = (projectDir) => pathKey(realFilePath(resolve4(projectDir)));
|
|
3634
|
+
var inRealProject = (projectDir, path) => join9(realFilePath(resolve4(projectDir)), relative5(resolve4(projectDir), resolve4(projectDir, path)));
|
|
3635
|
+
var readLink = (path) => {
|
|
3636
|
+
try {
|
|
3637
|
+
return readlinkSync2(path);
|
|
3638
|
+
} catch {
|
|
3639
|
+
return null;
|
|
3640
|
+
}
|
|
3641
|
+
};
|
|
3642
|
+
function linksOnTheWay(path) {
|
|
3643
|
+
const passed = [];
|
|
3644
|
+
let at = parse(path).root;
|
|
3645
|
+
let rest = path.slice(at.length).split(/[\\/]+/);
|
|
3646
|
+
for (let hops = 0; rest.length > 0; ) {
|
|
3647
|
+
const part = rest.shift();
|
|
3648
|
+
if (!part || part === ".") continue;
|
|
3649
|
+
if (part === "..") {
|
|
3650
|
+
at = dirname6(at);
|
|
3651
|
+
continue;
|
|
3652
|
+
}
|
|
3653
|
+
const next = join9(at, part);
|
|
3654
|
+
const target = hops < 40 ? readLink(next) : null;
|
|
3655
|
+
if (target === null) {
|
|
3656
|
+
at = next;
|
|
3657
|
+
continue;
|
|
3658
|
+
}
|
|
3659
|
+
hops++;
|
|
3660
|
+
passed.push(next);
|
|
3661
|
+
const root = parse(target).root;
|
|
3662
|
+
if (root) at = root;
|
|
3663
|
+
rest = [...target.slice(root.length).split(/[\\/]+/), ...rest];
|
|
3664
|
+
}
|
|
3665
|
+
passed.push(at);
|
|
3666
|
+
return passed;
|
|
3667
|
+
}
|
|
3668
|
+
function recordPreviewRead(projectDir, filePath) {
|
|
3669
|
+
const key = projectKey(projectDir);
|
|
3670
|
+
let reads = readsByProject.get(key);
|
|
3671
|
+
if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
|
|
3672
|
+
const read = inRealProject(projectDir, filePath);
|
|
3673
|
+
for (const found of [read, ...linksOnTheWay(read)]) {
|
|
3674
|
+
for (let path = pathKey(found); !reads.has(path); path = dirname6(path)) reads.add(path);
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
function recordPreviewReferences(projectDir, html) {
|
|
3678
|
+
const named = /* @__PURE__ */ new Set();
|
|
3679
|
+
for (const match of html.matchAll(REFERENCE)) {
|
|
3680
|
+
const url = (match[1] ?? match[2] ?? match[3] ?? match[4] ?? match[5] ?? "").trim();
|
|
3681
|
+
if (!url || /^(?:[a-z][a-z0-9+.-]*:|[/#])/i.test(url)) continue;
|
|
3682
|
+
const path = url.split(/[?#]/)[0] ?? "";
|
|
3683
|
+
let decoded = path;
|
|
3684
|
+
try {
|
|
3685
|
+
decoded = decodeURIComponent(path);
|
|
3686
|
+
} catch {
|
|
3687
|
+
}
|
|
3688
|
+
if (decoded) named.add(decoded);
|
|
3689
|
+
}
|
|
3690
|
+
for (const path of named) recordPreviewRead(projectDir, path);
|
|
3691
|
+
}
|
|
3692
|
+
function recordPreviewBuilt(projectDir) {
|
|
3693
|
+
for (const path of ALWAYS_AFFECTS) recordPreviewRead(projectDir, path);
|
|
3694
|
+
builtProjects.add(projectKey(projectDir));
|
|
3695
|
+
}
|
|
3696
|
+
function affectsPreview(projectDir, changedPath) {
|
|
3697
|
+
const key = projectKey(projectDir);
|
|
3698
|
+
if (!builtProjects.has(key)) return true;
|
|
3699
|
+
return readsByProject.get(key)?.has(pathKey(inRealProject(projectDir, changedPath))) ?? false;
|
|
3700
|
+
}
|
|
3701
|
+
|
|
3542
3702
|
// src/helpers/variablesPayload.ts
|
|
3543
3703
|
var VARIABLES_PAYLOAD_ERROR = "variables must be a JSON object of {variableId: value}";
|
|
3544
3704
|
function isVariablesPayload(value) {
|
|
@@ -3580,7 +3740,7 @@ function injectProjectSignature(html, signature) {
|
|
|
3580
3740
|
${html}`;
|
|
3581
3741
|
}
|
|
3582
3742
|
function readStudioMotionManifestContent(projectDir) {
|
|
3583
|
-
const manifestPath =
|
|
3743
|
+
const manifestPath = join10(projectDir, STUDIO_MOTION_PATH);
|
|
3584
3744
|
if (!existsSync5(manifestPath)) return "";
|
|
3585
3745
|
try {
|
|
3586
3746
|
return readFileSync8(manifestPath, "utf-8");
|
|
@@ -3696,9 +3856,9 @@ function variablesEtagSalt(raw) {
|
|
|
3696
3856
|
return `:vars:${createHash4("sha1").update(raw).digest("hex").slice(0, 12)}`;
|
|
3697
3857
|
}
|
|
3698
3858
|
function previewVariablesFromRequest(rawVariables) {
|
|
3699
|
-
const
|
|
3700
|
-
if (!
|
|
3701
|
-
return { raw: rawVariables, values:
|
|
3859
|
+
const parse2 = parsePreviewVariablesParam(rawVariables);
|
|
3860
|
+
if (!parse2.ok) return { error: parse2.error };
|
|
3861
|
+
return { raw: rawVariables, values: parse2.values };
|
|
3702
3862
|
}
|
|
3703
3863
|
function injectStudioPreviewAugmentations(html, adapter, projectDir, activeCompositionPath) {
|
|
3704
3864
|
return injectStudioMotionScript(
|
|
@@ -3725,14 +3885,16 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
|
|
|
3725
3885
|
}
|
|
3726
3886
|
}
|
|
3727
3887
|
function resolveProjectMainHtml(projectDir, projectId) {
|
|
3728
|
-
const indexPath =
|
|
3888
|
+
const indexPath = join10(projectDir, "index.html");
|
|
3889
|
+
const blockHtmlPath = join10(projectDir, `${projectId}.html`);
|
|
3890
|
+
recordPreviewRead(projectDir, indexPath);
|
|
3891
|
+
recordPreviewRead(projectDir, blockHtmlPath);
|
|
3729
3892
|
if (existsSync5(indexPath)) {
|
|
3730
3893
|
return {
|
|
3731
3894
|
html: readFileSync8(indexPath, "utf-8"),
|
|
3732
3895
|
compositionPath: "index.html"
|
|
3733
3896
|
};
|
|
3734
3897
|
}
|
|
3735
|
-
const blockHtmlPath = join9(projectDir, `${projectId}.html`);
|
|
3736
3898
|
if (existsSync5(blockHtmlPath)) {
|
|
3737
3899
|
return {
|
|
3738
3900
|
html: readFileSync8(blockHtmlPath, "utf-8"),
|
|
@@ -3744,7 +3906,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
|
|
|
3744
3906
|
var PREVIEW_BUNDLE_OPTIONS = {
|
|
3745
3907
|
runtime: "placeholder",
|
|
3746
3908
|
inlineAssets: false,
|
|
3747
|
-
staticGuard: false
|
|
3909
|
+
staticGuard: false,
|
|
3910
|
+
sceneParts: true
|
|
3748
3911
|
};
|
|
3749
3912
|
function registerPreviewRoutes(api, adapter) {
|
|
3750
3913
|
const previewCacheHeaders = (etag) => ({
|
|
@@ -3763,13 +3926,18 @@ function registerPreviewRoutes(api, adapter) {
|
|
|
3763
3926
|
const diskMain = resolveProjectMainHtml(project.dir, project.id);
|
|
3764
3927
|
const normalizedDisk = diskMain ? ensureHfIds3(diskMain.html) : null;
|
|
3765
3928
|
try {
|
|
3766
|
-
let bundled = await adapter.bundle(project.dir, {
|
|
3929
|
+
let bundled = await adapter.bundle(project.dir, {
|
|
3930
|
+
stampHfIds: true,
|
|
3931
|
+
onRead: (filePath) => recordPreviewRead(project.dir, filePath)
|
|
3932
|
+
});
|
|
3767
3933
|
let mainCompositionPath = "index.html";
|
|
3768
3934
|
if (!bundled) {
|
|
3769
3935
|
if (!diskMain) return null;
|
|
3770
3936
|
bundled = stripEmbeddedRuntimeScripts2(normalizedDisk ?? diskMain.html);
|
|
3771
3937
|
mainCompositionPath = diskMain.compositionPath;
|
|
3772
3938
|
}
|
|
3939
|
+
recordPreviewReferences(project.dir, bundled);
|
|
3940
|
+
recordPreviewBuilt(project.dir);
|
|
3773
3941
|
if (!bundled.includes("hyperframe.runtime") && !bundled.includes("hyperframes-preview-runtime")) {
|
|
3774
3942
|
const runtimeTag = `<script src="${adapter.runtimeUrl}"></script>`;
|
|
3775
3943
|
bundled = insertBeforeCloseTag2(bundled, "body", `${runtimeTag}
|
|
@@ -3794,6 +3962,7 @@ ${runtimeTag}`;
|
|
|
3794
3962
|
mainCompositionPath,
|
|
3795
3963
|
mediaCodecProbeCache
|
|
3796
3964
|
);
|
|
3965
|
+
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
|
|
3797
3966
|
rememberPreview(builtKey, bundled);
|
|
3798
3967
|
adapter.previewDocuments?.write(builtKey, bundled);
|
|
3799
3968
|
return bundled;
|
|
@@ -3871,7 +4040,9 @@ ${etag}`;
|
|
|
3871
4040
|
if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
|
|
3872
4041
|
const previewVariables = vars.values;
|
|
3873
4042
|
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
|
|
3874
|
-
const compFile =
|
|
4043
|
+
const compFile = resolveWithinProject2(project.dir, compPath);
|
|
4044
|
+
if (compFile) recordPreviewRead(project.dir, compFile);
|
|
4045
|
+
recordPreviewRead(project.dir, "index.html");
|
|
3875
4046
|
if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
|
|
3876
4047
|
return c.text("not found", 404);
|
|
3877
4048
|
}
|
|
@@ -3895,6 +4066,7 @@ ${etag}`;
|
|
|
3895
4066
|
stamped
|
|
3896
4067
|
);
|
|
3897
4068
|
if (!html) return c.text("not found", 404);
|
|
4069
|
+
recordPreviewReferences(project.dir, html);
|
|
3898
4070
|
html = ensureHfIds3(await transformPreviewHtml(html, adapter, project, compPath));
|
|
3899
4071
|
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
|
|
3900
4072
|
if (previewVariables) html = injectPreviewVariables(html, previewVariables);
|
|
@@ -3905,13 +4077,14 @@ ${etag}`;
|
|
|
3905
4077
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3906
4078
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
3907
4079
|
const subPath = requestSubPath(c.req.url, "projects/:id/preview");
|
|
3908
|
-
const candidate =
|
|
4080
|
+
const candidate = resolve5(project.dir, subPath);
|
|
3909
4081
|
const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
|
|
3910
4082
|
if (!file) {
|
|
3911
4083
|
return c.text("not found", 404);
|
|
3912
4084
|
}
|
|
3913
|
-
|
|
3914
|
-
|
|
4085
|
+
recordPreviewRead(project.dir, file);
|
|
4086
|
+
const stat3 = existsSync5(file) ? statSync4(file) : null;
|
|
4087
|
+
if (!stat3?.isFile()) {
|
|
3915
4088
|
return c.text("not found", 404);
|
|
3916
4089
|
}
|
|
3917
4090
|
const contentType = getMimeType(subPath);
|
|
@@ -3922,7 +4095,7 @@ ${etag}`;
|
|
|
3922
4095
|
if (!isProxyVariantRequest(proxyParam) || !contentType.startsWith("video/") || !isAutoProxyEnabled(adapter)) {
|
|
3923
4096
|
return c.text("not found", 404);
|
|
3924
4097
|
}
|
|
3925
|
-
const facts = await probeAssetCodec(file);
|
|
4098
|
+
const facts = await probeAssetCodec(file, void 0, mediaCodecProbeCache);
|
|
3926
4099
|
const eligibility = decideMediaProxyEligibility(facts);
|
|
3927
4100
|
if (!eligibility.eligible) {
|
|
3928
4101
|
return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
|
|
@@ -3933,7 +4106,7 @@ ${etag}`;
|
|
|
3933
4106
|
return c.text("media proxy variant does not match asset", 422);
|
|
3934
4107
|
}
|
|
3935
4108
|
}
|
|
3936
|
-
const tag = settledFileTag(
|
|
4109
|
+
const tag = settledFileTag(stat3);
|
|
3937
4110
|
const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
|
|
3938
4111
|
const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
|
|
3939
4112
|
if (!isText && etag) {
|
|
@@ -4002,7 +4175,7 @@ ${etag}`;
|
|
|
4002
4175
|
|
|
4003
4176
|
// src/routes/lint.ts
|
|
4004
4177
|
import { readFileSync as readFileSync9 } from "fs";
|
|
4005
|
-
import { join as
|
|
4178
|
+
import { join as join11 } from "path";
|
|
4006
4179
|
async function collectProjectFindings(adapter, projectDir) {
|
|
4007
4180
|
const findings = [];
|
|
4008
4181
|
const coveredFiles = /* @__PURE__ */ new Set();
|
|
@@ -4020,7 +4193,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
|
|
|
4020
4193
|
const findings = [];
|
|
4021
4194
|
for (const file of htmlFiles) {
|
|
4022
4195
|
if (coveredFiles.has(file)) continue;
|
|
4023
|
-
const content = readFileSync9(
|
|
4196
|
+
const content = readFileSync9(join11(projectDir, file), "utf-8");
|
|
4024
4197
|
const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
|
|
4025
4198
|
for (const finding of result?.findings ?? []) {
|
|
4026
4199
|
findings.push({ ...finding, file });
|
|
@@ -4054,7 +4227,7 @@ function registerLintRoutes(api, adapter) {
|
|
|
4054
4227
|
// src/routes/render.ts
|
|
4055
4228
|
import { streamSSE } from "hono/streaming";
|
|
4056
4229
|
import { existsSync as existsSync6, readFileSync as readFileSync10, mkdirSync as mkdirSync5, unlinkSync as unlinkSync3, readdirSync as readdirSync5, statSync as statSync5 } from "fs";
|
|
4057
|
-
import { join as
|
|
4230
|
+
import { join as join12 } from "path";
|
|
4058
4231
|
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
|
4059
4232
|
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
|
|
4060
4233
|
var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
|
|
@@ -4105,7 +4278,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4105
4278
|
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
|
|
4106
4279
|
let composition;
|
|
4107
4280
|
if (typeof body.composition === "string" && body.composition.length > 0) {
|
|
4108
|
-
if (!
|
|
4281
|
+
if (!resolveWithinProject2(project.dir, body.composition)) {
|
|
4109
4282
|
return c.json({ error: "composition path must be within the project directory" }, 400);
|
|
4110
4283
|
}
|
|
4111
4284
|
composition = body.composition;
|
|
@@ -4122,7 +4295,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4122
4295
|
const rendersDir = adapter.rendersDir(project);
|
|
4123
4296
|
if (!existsSync6(rendersDir)) mkdirSync5(rendersDir, { recursive: true });
|
|
4124
4297
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
|
4125
|
-
const outputPath =
|
|
4298
|
+
const outputPath = join12(rendersDir, `${jobId}${ext}`);
|
|
4126
4299
|
const jobState = adapter.startRender({
|
|
4127
4300
|
project,
|
|
4128
4301
|
outputPath,
|
|
@@ -4223,7 +4396,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4223
4396
|
if (state.id === jobId && state.outputPath) {
|
|
4224
4397
|
const dir = state.outputPath.replace(/\/[^/]+$/, "");
|
|
4225
4398
|
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
|
|
4226
|
-
const fp =
|
|
4399
|
+
const fp = join12(dir, `${jobId}${ext}`);
|
|
4227
4400
|
if (existsSync6(fp)) unlinkSync3(fp);
|
|
4228
4401
|
}
|
|
4229
4402
|
break;
|
|
@@ -4238,7 +4411,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4238
4411
|
const filename = c.req.path.split("/renders/file/")[1];
|
|
4239
4412
|
if (!filename) return c.json({ error: "missing filename" }, 400);
|
|
4240
4413
|
const rendersDir = adapter.rendersDir(project);
|
|
4241
|
-
const fp =
|
|
4414
|
+
const fp = resolveWithinProject2(rendersDir, filename);
|
|
4242
4415
|
if (!fp) return c.json({ error: "forbidden" }, 403);
|
|
4243
4416
|
if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
|
|
4244
4417
|
const contentType = renderContentType(fp);
|
|
@@ -4258,10 +4431,10 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4258
4431
|
const rendersDir = adapter.rendersDir(project);
|
|
4259
4432
|
if (!existsSync6(rendersDir)) return c.json({ renders: [] });
|
|
4260
4433
|
const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
|
|
4261
|
-
const fp =
|
|
4262
|
-
const
|
|
4434
|
+
const fp = join12(rendersDir, f);
|
|
4435
|
+
const stat3 = statSync5(fp);
|
|
4263
4436
|
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
|
|
4264
|
-
const metaPath =
|
|
4437
|
+
const metaPath = join12(rendersDir, `${rid}.meta.json`);
|
|
4265
4438
|
let status = "complete";
|
|
4266
4439
|
let durationMs;
|
|
4267
4440
|
if (existsSync6(metaPath)) {
|
|
@@ -4275,8 +4448,8 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4275
4448
|
return {
|
|
4276
4449
|
id: rid,
|
|
4277
4450
|
filename: f,
|
|
4278
|
-
size:
|
|
4279
|
-
createdAt:
|
|
4451
|
+
size: stat3.size,
|
|
4452
|
+
createdAt: stat3.mtimeMs,
|
|
4280
4453
|
status,
|
|
4281
4454
|
durationMs
|
|
4282
4455
|
};
|
|
@@ -4287,7 +4460,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4287
4460
|
id: file.id,
|
|
4288
4461
|
status: file.status,
|
|
4289
4462
|
progress: 100,
|
|
4290
|
-
outputPath:
|
|
4463
|
+
outputPath: join12(rendersDir, file.filename),
|
|
4291
4464
|
createdAt: file.createdAt
|
|
4292
4465
|
});
|
|
4293
4466
|
}
|
|
@@ -4296,80 +4469,11 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4296
4469
|
});
|
|
4297
4470
|
}
|
|
4298
4471
|
|
|
4299
|
-
// src/routes/
|
|
4300
|
-
import {
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
openSync as openSync3,
|
|
4305
|
-
mkdirSync as mkdirSync6,
|
|
4306
|
-
readFileSync as readFileSync12,
|
|
4307
|
-
readdirSync as readdirSync6,
|
|
4308
|
-
renameSync as renameSync3,
|
|
4309
|
-
rmSync as rmSync4,
|
|
4310
|
-
statSync as statSync6,
|
|
4311
|
-
unlinkSync as unlinkSync4,
|
|
4312
|
-
writeFileSync as writeFileSync6
|
|
4313
|
-
} from "fs";
|
|
4314
|
-
import { join as join12 } from "path";
|
|
4315
|
-
import { createHash as createHash5, randomUUID as randomUUID4 } from "crypto";
|
|
4316
|
-
|
|
4317
|
-
// src/helpers/compositionInputs.ts
|
|
4318
|
-
import { readFileSync as readFileSync11 } from "fs";
|
|
4319
|
-
var ROOT_COMPOSITION = "index.html";
|
|
4320
|
-
var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
|
|
4321
|
-
function projectReader(projectDir) {
|
|
4322
|
-
const sources = /* @__PURE__ */ new Map();
|
|
4323
|
-
return (compPath) => {
|
|
4324
|
-
if (!sources.has(compPath)) {
|
|
4325
|
-
const file = resolveWithinProject(projectDir, compPath);
|
|
4326
|
-
let source = null;
|
|
4327
|
-
try {
|
|
4328
|
-
source = file ? readFileSync11(file, "utf-8") : null;
|
|
4329
|
-
} catch {
|
|
4330
|
-
}
|
|
4331
|
-
sources.set(compPath, source);
|
|
4332
|
-
}
|
|
4333
|
-
return sources.get(compPath) ?? null;
|
|
4334
|
-
};
|
|
4335
|
-
}
|
|
4336
|
-
function normalizeSource(src) {
|
|
4337
|
-
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
|
|
4338
|
-
const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
|
|
4339
|
-
return path || null;
|
|
4340
|
-
}
|
|
4341
|
-
function closureOf(read, compPath) {
|
|
4342
|
-
const closure = /* @__PURE__ */ new Set();
|
|
4343
|
-
const visit = (path) => {
|
|
4344
|
-
if (closure.has(path)) return;
|
|
4345
|
-
closure.add(path);
|
|
4346
|
-
for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
|
|
4347
|
-
const mounted = normalizeSource(match[2] ?? "");
|
|
4348
|
-
if (mounted) visit(mounted);
|
|
4349
|
-
}
|
|
4350
|
-
};
|
|
4351
|
-
visit(compPath);
|
|
4352
|
-
return closure;
|
|
4353
|
-
}
|
|
4354
|
-
var inputSignatures = /* @__PURE__ */ new Map();
|
|
4355
|
-
function compositionInputSignature(projectDir, compPath, projectSignature) {
|
|
4356
|
-
const key = `${projectDir}\0${compPath}`;
|
|
4357
|
-
const known = inputSignatures.get(key);
|
|
4358
|
-
if (known?.projectSignature === projectSignature) return known.inputSignature;
|
|
4359
|
-
const read = projectReader(projectDir);
|
|
4360
|
-
const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
|
|
4361
|
-
const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
|
|
4362
|
-
const inputSignature = createProjectSignature(projectDir, new Set(siblings));
|
|
4363
|
-
inputSignatures.set(key, { projectSignature, inputSignature });
|
|
4364
|
-
return inputSignature;
|
|
4365
|
-
}
|
|
4366
|
-
function compositionsAffectedBy(projectDir, changedPath) {
|
|
4367
|
-
const changed = changedPath.replace(/\\/g, "/");
|
|
4368
|
-
const read = projectReader(projectDir);
|
|
4369
|
-
const mounted = closureOf(read, ROOT_COMPOSITION);
|
|
4370
|
-
if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
|
|
4371
|
-
return [...mounted].filter((path) => closureOf(read, path).has(changed));
|
|
4372
|
-
}
|
|
4472
|
+
// src/routes/imageThumbnail.ts
|
|
4473
|
+
import { HTTPException } from "hono/http-exception";
|
|
4474
|
+
import { open, stat } from "fs/promises";
|
|
4475
|
+
import { createHash as createHash5 } from "crypto";
|
|
4476
|
+
import sharp from "sharp";
|
|
4373
4477
|
|
|
4374
4478
|
// src/routes/thumbnailGenerationCoordinator.ts
|
|
4375
4479
|
var ThumbnailGenerationCoordinator = class {
|
|
@@ -4388,10 +4492,10 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4388
4492
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
4389
4493
|
let entry = this.entries.get(key);
|
|
4390
4494
|
if (!entry) {
|
|
4391
|
-
let
|
|
4495
|
+
let resolve9;
|
|
4392
4496
|
let reject;
|
|
4393
4497
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
4394
|
-
|
|
4498
|
+
resolve9 = resolvePromise;
|
|
4395
4499
|
reject = rejectPromise;
|
|
4396
4500
|
});
|
|
4397
4501
|
entry = {
|
|
@@ -4401,7 +4505,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4401
4505
|
state: "queued",
|
|
4402
4506
|
work,
|
|
4403
4507
|
promise,
|
|
4404
|
-
resolve:
|
|
4508
|
+
resolve: resolve9,
|
|
4405
4509
|
reject
|
|
4406
4510
|
};
|
|
4407
4511
|
this.entries.set(key, entry);
|
|
@@ -4415,7 +4519,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4415
4519
|
return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
|
|
4416
4520
|
}
|
|
4417
4521
|
lease(entry, signal) {
|
|
4418
|
-
return new Promise((
|
|
4522
|
+
return new Promise((resolve9, reject) => {
|
|
4419
4523
|
let released = false;
|
|
4420
4524
|
const release = () => {
|
|
4421
4525
|
if (released) return;
|
|
@@ -4439,7 +4543,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4439
4543
|
entry.promise.then(
|
|
4440
4544
|
(value) => {
|
|
4441
4545
|
release();
|
|
4442
|
-
|
|
4546
|
+
resolve9(value);
|
|
4443
4547
|
},
|
|
4444
4548
|
(reason) => {
|
|
4445
4549
|
release();
|
|
@@ -4474,6 +4578,171 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4474
4578
|
};
|
|
4475
4579
|
var thumbnailGenerationCoordinator = new ThumbnailGenerationCoordinator(1);
|
|
4476
4580
|
|
|
4581
|
+
// src/routes/imageThumbnail.ts
|
|
4582
|
+
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
4583
|
+
var MAX_CACHE_BYTES = 8 * 1024 * 1024;
|
|
4584
|
+
async function generateThumbnail(path, signal) {
|
|
4585
|
+
const file = await open(path, "r");
|
|
4586
|
+
try {
|
|
4587
|
+
const info = await file.stat();
|
|
4588
|
+
if (!info.isFile() || info.size > MAX_SOURCE_BYTES) throw new Error("Unsupported image");
|
|
4589
|
+
const source = Buffer.alloc(info.size);
|
|
4590
|
+
let offset = 0;
|
|
4591
|
+
while (offset < source.length) {
|
|
4592
|
+
signal.throwIfAborted();
|
|
4593
|
+
const { bytesRead } = await file.read(source, offset, source.length - offset, offset);
|
|
4594
|
+
if (!bytesRead) break;
|
|
4595
|
+
offset += bytesRead;
|
|
4596
|
+
}
|
|
4597
|
+
if (source[0] !== 255 || source[1] !== 216) throw new Error("Unsupported image");
|
|
4598
|
+
const image = sharp(source.subarray(0, offset), { limitInputPixels: 4e7 });
|
|
4599
|
+
if ((await image.metadata()).format !== "jpeg") throw new Error("Unsupported image");
|
|
4600
|
+
signal.throwIfAborted();
|
|
4601
|
+
const result = await image.rotate().resize(240, 135, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 80 }).toBuffer();
|
|
4602
|
+
signal.throwIfAborted();
|
|
4603
|
+
return result;
|
|
4604
|
+
} finally {
|
|
4605
|
+
await file.close();
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
4608
|
+
async function resolveSource(adapter, id, url) {
|
|
4609
|
+
const project = await adapter.resolveProject(id);
|
|
4610
|
+
if (!project) throw new HTTPException(404);
|
|
4611
|
+
let relative7;
|
|
4612
|
+
try {
|
|
4613
|
+
relative7 = requestSubPath(url, "projects/:id/image-thumbnail");
|
|
4614
|
+
} catch {
|
|
4615
|
+
throw new HTTPException(400);
|
|
4616
|
+
}
|
|
4617
|
+
if (!/\.jpe?g$/i.test(relative7)) throw new HTTPException(415);
|
|
4618
|
+
const path = pinWithinProject(project.dir, relative7);
|
|
4619
|
+
if (!path) throw new HTTPException(404);
|
|
4620
|
+
const info = await stat(path).catch(() => {
|
|
4621
|
+
throw new HTTPException(404);
|
|
4622
|
+
});
|
|
4623
|
+
if (!info.isFile()) throw new HTTPException(404);
|
|
4624
|
+
if (info.size > MAX_SOURCE_BYTES) throw new HTTPException(413);
|
|
4625
|
+
return { path, info };
|
|
4626
|
+
}
|
|
4627
|
+
function registerImageThumbnailRoutes(api, adapter) {
|
|
4628
|
+
const coordinator = new ThumbnailGenerationCoordinator(2);
|
|
4629
|
+
const cache = /* @__PURE__ */ new Map();
|
|
4630
|
+
let cacheBytes = 0;
|
|
4631
|
+
const remember = (key, image) => {
|
|
4632
|
+
cacheBytes -= cache.get(key)?.byteLength ?? 0;
|
|
4633
|
+
cache.delete(key);
|
|
4634
|
+
cache.set(key, image);
|
|
4635
|
+
cacheBytes += image.byteLength;
|
|
4636
|
+
while (cacheBytes > MAX_CACHE_BYTES || cache.size > 128) {
|
|
4637
|
+
const oldest = cache.keys().next().value;
|
|
4638
|
+
if (oldest === void 0) break;
|
|
4639
|
+
cacheBytes -= cache.get(oldest)?.byteLength ?? 0;
|
|
4640
|
+
cache.delete(oldest);
|
|
4641
|
+
}
|
|
4642
|
+
};
|
|
4643
|
+
api.get("/projects/:id/image-thumbnail/*", async (c) => {
|
|
4644
|
+
try {
|
|
4645
|
+
const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url);
|
|
4646
|
+
const key = createHash5("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
|
|
4647
|
+
const etag = `"${key}"`;
|
|
4648
|
+
c.header("ETag", etag);
|
|
4649
|
+
c.header("Cache-Control", "private, no-cache");
|
|
4650
|
+
c.header("X-Content-Type-Options", "nosniff");
|
|
4651
|
+
if (c.req.header("If-None-Match") === etag) return c.body(null, 304);
|
|
4652
|
+
let result = cache.get(key);
|
|
4653
|
+
if (result) remember(key, result);
|
|
4654
|
+
else {
|
|
4655
|
+
if (coordinator.protectedKeys().size >= 32) return c.text("thumbnail queue full", 429);
|
|
4656
|
+
result = await coordinator.acquire(key, c.req.raw.signal, async (signal) => {
|
|
4657
|
+
const image = await generateThumbnail(path, signal);
|
|
4658
|
+
remember(key, image);
|
|
4659
|
+
return image;
|
|
4660
|
+
}) ?? void 0;
|
|
4661
|
+
}
|
|
4662
|
+
if (!result) return c.text("image unavailable", 422);
|
|
4663
|
+
c.header("Content-Type", "image/jpeg");
|
|
4664
|
+
return c.body(new Uint8Array(result));
|
|
4665
|
+
} catch (error) {
|
|
4666
|
+
if (error instanceof HTTPException) return error.getResponse();
|
|
4667
|
+
return c.text("image unavailable", 422);
|
|
4668
|
+
}
|
|
4669
|
+
});
|
|
4670
|
+
}
|
|
4671
|
+
|
|
4672
|
+
// src/routes/thumbnail.ts
|
|
4673
|
+
import {
|
|
4674
|
+
closeSync as closeSync3,
|
|
4675
|
+
fstatSync as fstatSync3,
|
|
4676
|
+
openSync as openSync3,
|
|
4677
|
+
mkdirSync as mkdirSync6,
|
|
4678
|
+
readFileSync as readFileSync12,
|
|
4679
|
+
readdirSync as readdirSync6,
|
|
4680
|
+
renameSync as renameSync3,
|
|
4681
|
+
rmSync as rmSync4,
|
|
4682
|
+
statSync as statSync6,
|
|
4683
|
+
unlinkSync as unlinkSync4,
|
|
4684
|
+
writeFileSync as writeFileSync6
|
|
4685
|
+
} from "fs";
|
|
4686
|
+
import { join as join13 } from "path";
|
|
4687
|
+
import { createHash as createHash6, randomUUID as randomUUID4 } from "crypto";
|
|
4688
|
+
|
|
4689
|
+
// src/helpers/compositionInputs.ts
|
|
4690
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
4691
|
+
var ROOT_COMPOSITION = "index.html";
|
|
4692
|
+
var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
|
|
4693
|
+
function projectReader(projectDir) {
|
|
4694
|
+
const sources = /* @__PURE__ */ new Map();
|
|
4695
|
+
return (compPath) => {
|
|
4696
|
+
if (!sources.has(compPath)) {
|
|
4697
|
+
const file = resolveWithinProject2(projectDir, compPath);
|
|
4698
|
+
let source = null;
|
|
4699
|
+
try {
|
|
4700
|
+
source = file ? readFileSync11(file, "utf-8") : null;
|
|
4701
|
+
} catch {
|
|
4702
|
+
}
|
|
4703
|
+
sources.set(compPath, source);
|
|
4704
|
+
}
|
|
4705
|
+
return sources.get(compPath) ?? null;
|
|
4706
|
+
};
|
|
4707
|
+
}
|
|
4708
|
+
function normalizeSource(src) {
|
|
4709
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(src) || src.startsWith("//")) return null;
|
|
4710
|
+
const path = src.split(/[?#]/)[0]?.replace(/^\.?\//, "") ?? "";
|
|
4711
|
+
return path || null;
|
|
4712
|
+
}
|
|
4713
|
+
function closureOf(read, compPath) {
|
|
4714
|
+
const closure = /* @__PURE__ */ new Set();
|
|
4715
|
+
const visit = (path) => {
|
|
4716
|
+
if (closure.has(path)) return;
|
|
4717
|
+
closure.add(path);
|
|
4718
|
+
for (const match of (read(path) ?? "").matchAll(COMPOSITION_SRC)) {
|
|
4719
|
+
const mounted = normalizeSource(match[2] ?? "");
|
|
4720
|
+
if (mounted) visit(mounted);
|
|
4721
|
+
}
|
|
4722
|
+
};
|
|
4723
|
+
visit(compPath);
|
|
4724
|
+
return closure;
|
|
4725
|
+
}
|
|
4726
|
+
var inputSignatures = /* @__PURE__ */ new Map();
|
|
4727
|
+
function compositionInputSignature(projectDir, compPath, projectSignature) {
|
|
4728
|
+
const key = `${projectDir}\0${compPath}`;
|
|
4729
|
+
const known = inputSignatures.get(key);
|
|
4730
|
+
if (known?.projectSignature === projectSignature) return known.inputSignature;
|
|
4731
|
+
const read = projectReader(projectDir);
|
|
4732
|
+
const inputs = closureOf(read, normalizeSource(compPath) ?? compPath).add(ROOT_COMPOSITION);
|
|
4733
|
+
const siblings = [...closureOf(read, ROOT_COMPOSITION)].filter((path) => !inputs.has(path));
|
|
4734
|
+
const inputSignature = createProjectSignature(projectDir, new Set(siblings));
|
|
4735
|
+
inputSignatures.set(key, { projectSignature, inputSignature });
|
|
4736
|
+
return inputSignature;
|
|
4737
|
+
}
|
|
4738
|
+
function compositionsAffectedBy(projectDir, changedPath) {
|
|
4739
|
+
const changed = changedPath.replace(/\\/g, "/");
|
|
4740
|
+
const read = projectReader(projectDir);
|
|
4741
|
+
const mounted = closureOf(read, ROOT_COMPOSITION);
|
|
4742
|
+
if (changed === ROOT_COMPOSITION || !mounted.has(changed)) return null;
|
|
4743
|
+
return [...mounted].filter((path) => closureOf(read, path).has(changed));
|
|
4744
|
+
}
|
|
4745
|
+
|
|
4477
4746
|
// src/routes/thumbnail.ts
|
|
4478
4747
|
var THUMBNAIL_CACHE_VERSION = "v4";
|
|
4479
4748
|
var THUMBNAIL_MAX_OUTPUT_WIDTH = 240;
|
|
@@ -4482,10 +4751,16 @@ var THUMBNAIL_CACHE_MAX_BYTES = 512 * 1024 * 1024;
|
|
|
4482
4751
|
var THUMBNAIL_CACHE_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
|
|
4483
4752
|
var prunedCacheDirs = /* @__PURE__ */ new Set();
|
|
4484
4753
|
function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
|
|
4485
|
-
|
|
4486
|
-
|
|
4754
|
+
let entries;
|
|
4755
|
+
try {
|
|
4756
|
+
entries = readdirSync6(cacheDir, { withFileTypes: true });
|
|
4757
|
+
} catch (err) {
|
|
4758
|
+
if (err.code === "ENOENT") return;
|
|
4759
|
+
throw err;
|
|
4760
|
+
}
|
|
4761
|
+
const files = entries.flatMap((entry) => {
|
|
4487
4762
|
if (!entry.isFile()) return [];
|
|
4488
|
-
const path =
|
|
4763
|
+
const path = join13(cacheDir, entry.name);
|
|
4489
4764
|
try {
|
|
4490
4765
|
const stats = statSync6(path);
|
|
4491
4766
|
return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
|
|
@@ -4521,22 +4796,30 @@ function writeThumbnailAtomically(path, buffer) {
|
|
|
4521
4796
|
rmSync4(temporaryPath, { force: true });
|
|
4522
4797
|
}
|
|
4523
4798
|
}
|
|
4524
|
-
function
|
|
4799
|
+
function readFileOnce(file) {
|
|
4525
4800
|
let fd;
|
|
4526
4801
|
try {
|
|
4527
4802
|
fd = openSync3(file, "r");
|
|
4528
4803
|
} catch (err) {
|
|
4529
|
-
|
|
4804
|
+
const code = err.code;
|
|
4805
|
+
if (code === "ENOENT") return "missing";
|
|
4806
|
+
if (code === "ENOTDIR") return "not-a-file";
|
|
4530
4807
|
throw err;
|
|
4531
4808
|
}
|
|
4532
4809
|
try {
|
|
4533
|
-
const
|
|
4534
|
-
if (!
|
|
4535
|
-
return {
|
|
4810
|
+
const stat3 = fstatSync3(fd);
|
|
4811
|
+
if (!stat3.isFile()) return "not-a-file";
|
|
4812
|
+
return { data: readFileSync12(fd), mtimeMs: stat3.mtimeMs };
|
|
4536
4813
|
} finally {
|
|
4537
4814
|
closeSync3(fd);
|
|
4538
4815
|
}
|
|
4539
4816
|
}
|
|
4817
|
+
function manifestKey(file) {
|
|
4818
|
+
const manifest = readFileOnce(file);
|
|
4819
|
+
if (typeof manifest === "string") return { key: "", mtimeMs: -Infinity };
|
|
4820
|
+
const hash = createHash6("sha1").update(manifest.data).digest("hex").slice(0, 16);
|
|
4821
|
+
return { key: `_${hash}`, mtimeMs: Math.round(manifest.mtimeMs) };
|
|
4822
|
+
}
|
|
4540
4823
|
function registerThumbnailRoutes(api, adapter) {
|
|
4541
4824
|
api.get("/projects/:id/thumbnail/*", async (c) => {
|
|
4542
4825
|
if (!adapter.generateThumbnail) {
|
|
@@ -4547,8 +4830,8 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4547
4830
|
const { project, signature: projectSignature } = resolved;
|
|
4548
4831
|
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
|
|
4549
4832
|
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
4550
|
-
const htmlFile =
|
|
4551
|
-
const source = htmlFile ?
|
|
4833
|
+
const htmlFile = resolveWithinProject2(project.dir, compPath);
|
|
4834
|
+
const source = htmlFile ? readFileOnce(htmlFile) : "not-a-file";
|
|
4552
4835
|
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
|
|
4553
4836
|
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
|
|
4554
4837
|
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
|
|
@@ -4570,8 +4853,8 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4570
4853
|
let sourceMtime = 0;
|
|
4571
4854
|
let sourceKey = "";
|
|
4572
4855
|
if (source !== "missing") {
|
|
4573
|
-
const
|
|
4574
|
-
sourceKey = `_${
|
|
4856
|
+
const html = source.data.toString("utf-8");
|
|
4857
|
+
sourceKey = `_${createHash6("sha1").update(html).digest("hex").slice(0, 16)}`;
|
|
4575
4858
|
sourceMtime = Math.round(source.mtimeMs);
|
|
4576
4859
|
if (!vpWidth) {
|
|
4577
4860
|
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
|
@@ -4580,31 +4863,20 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4580
4863
|
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
|
|
4581
4864
|
}
|
|
4582
4865
|
}
|
|
4583
|
-
const
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
const manualEditsContent = readFileSync12(manualEditsFile, "utf-8");
|
|
4587
|
-
manualEditsKey = `_${createHash5("sha1").update(manualEditsContent).digest("hex").slice(0, 16)}`;
|
|
4588
|
-
sourceMtime = Math.max(sourceMtime, Math.round(statSync6(manualEditsFile).mtimeMs));
|
|
4589
|
-
}
|
|
4590
|
-
const motionFile = join12(project.dir, STUDIO_MOTION_PATH);
|
|
4591
|
-
let motionKey = "";
|
|
4592
|
-
if (existsSync7(motionFile)) {
|
|
4593
|
-
const motionContent = readFileSync12(motionFile, "utf-8");
|
|
4594
|
-
motionKey = `_${createHash5("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
|
|
4595
|
-
sourceMtime = Math.max(sourceMtime, Math.round(statSync6(motionFile).mtimeMs));
|
|
4596
|
-
}
|
|
4866
|
+
const manualEdits = manifestKey(join13(project.dir, STUDIO_MANUAL_EDITS_PATH));
|
|
4867
|
+
const motion = manifestKey(join13(project.dir, STUDIO_MOTION_PATH));
|
|
4868
|
+
sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
|
|
4597
4869
|
const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
|
|
4598
4870
|
const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
|
|
4599
|
-
const cacheDir =
|
|
4871
|
+
const cacheDir = join13(project.dir, ".thumbnails");
|
|
4600
4872
|
const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
|
|
4601
4873
|
const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
|
|
4602
|
-
const inputSignatureKey = `_${
|
|
4874
|
+
const inputSignatureKey = `_${createHash6("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
|
|
4603
4875
|
const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
|
|
4604
4876
|
const outputWidth = Math.max(1, Math.round(compW * outputScale));
|
|
4605
4877
|
const outputHeight = Math.max(1, Math.round(compH * outputScale));
|
|
4606
|
-
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${
|
|
4607
|
-
const cachePath =
|
|
4878
|
+
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
|
|
4879
|
+
const cachePath = join13(cacheDir, cacheKey);
|
|
4608
4880
|
if (!prunedCacheDirs.has(cacheDir)) {
|
|
4609
4881
|
prunedCacheDirs.add(cacheDir);
|
|
4610
4882
|
pruneThumbnailCache(
|
|
@@ -4612,8 +4884,9 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4612
4884
|
/* @__PURE__ */ new Set([...thumbnailGenerationCoordinator.protectedKeys(), cachePath])
|
|
4613
4885
|
);
|
|
4614
4886
|
}
|
|
4615
|
-
|
|
4616
|
-
|
|
4887
|
+
const cached = readFileOnce(cachePath);
|
|
4888
|
+
if (typeof cached === "object") {
|
|
4889
|
+
return new Response(new Uint8Array(cached.data), {
|
|
4617
4890
|
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
|
|
4618
4891
|
});
|
|
4619
4892
|
}
|
|
@@ -4643,7 +4916,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4643
4916
|
if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
|
|
4644
4917
|
return generated;
|
|
4645
4918
|
}
|
|
4646
|
-
|
|
4919
|
+
mkdirSync6(cacheDir, { recursive: true });
|
|
4647
4920
|
writeThumbnailAtomically(cachePath, generated);
|
|
4648
4921
|
return generated;
|
|
4649
4922
|
}
|
|
@@ -4669,24 +4942,24 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4669
4942
|
}
|
|
4670
4943
|
|
|
4671
4944
|
// src/routes/waveform.ts
|
|
4672
|
-
import { existsSync as
|
|
4673
|
-
import { join as
|
|
4945
|
+
import { existsSync as existsSync7, readFileSync as readFileSync13, statSync as statSync7 } from "fs";
|
|
4946
|
+
import { join as join14, resolve as resolve6 } from "path";
|
|
4674
4947
|
import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
|
|
4675
4948
|
function registerWaveformRoutes(api, adapter) {
|
|
4676
4949
|
api.get("/projects/:id/waveform/*", async (c) => {
|
|
4677
4950
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
4678
4951
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
4679
4952
|
const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
|
|
4680
|
-
const audioPath =
|
|
4953
|
+
const audioPath = resolve6(project.dir, assetPath);
|
|
4681
4954
|
if (!isWithinProjectRoot2(project.dir, audioPath)) {
|
|
4682
4955
|
return c.json({ error: "file not found" }, 404);
|
|
4683
4956
|
}
|
|
4684
4957
|
const stats = statSync7(audioPath, { throwIfNoEntry: false });
|
|
4685
4958
|
if (!stats) return c.json({ error: "file not found" }, 404);
|
|
4686
|
-
const cacheDir =
|
|
4687
|
-
const cachePath =
|
|
4959
|
+
const cacheDir = join14(project.dir, ".waveform-cache");
|
|
4960
|
+
const cachePath = join14(cacheDir, buildWaveformCacheKey(assetPath, stats));
|
|
4688
4961
|
try {
|
|
4689
|
-
if (isWaveformCacheDirectory(cacheDir) &&
|
|
4962
|
+
if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
|
|
4690
4963
|
const peaks2 = JSON.parse(readFileSync13(cachePath, "utf-8"));
|
|
4691
4964
|
return c.json({ peaks: peaks2 });
|
|
4692
4965
|
}
|
|
@@ -4819,12 +5092,12 @@ function registerFontRoutes(api) {
|
|
|
4819
5092
|
return c.json({ error: "font file not accessible" }, 404);
|
|
4820
5093
|
}
|
|
4821
5094
|
try {
|
|
4822
|
-
const
|
|
4823
|
-
if (
|
|
5095
|
+
const stat3 = fstatSync4(fd);
|
|
5096
|
+
if (stat3.size > SYSTEM_FONT_SIZE_LIMIT) {
|
|
4824
5097
|
return c.json({ error: "font file too large" }, 413);
|
|
4825
5098
|
}
|
|
4826
|
-
const buffer = Buffer.alloc(
|
|
4827
|
-
readSync(fd, buffer, 0,
|
|
5099
|
+
const buffer = Buffer.alloc(stat3.size);
|
|
5100
|
+
readSync(fd, buffer, 0, stat3.size, 0);
|
|
4828
5101
|
const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
|
|
4829
5102
|
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
|
|
4830
5103
|
return new Response(buffer, {
|
|
@@ -4852,7 +5125,7 @@ function registerRegistryRoutes(api, adapter) {
|
|
|
4852
5125
|
});
|
|
4853
5126
|
api.post("/projects/:id/registry/install", async (c) => {
|
|
4854
5127
|
if (!adapter.installRegistryBlock) {
|
|
4855
|
-
return c.json({ error: "
|
|
5128
|
+
return c.json({ error: "Installing catalog items needs hyperframes preview" }, 501);
|
|
4856
5129
|
}
|
|
4857
5130
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
4858
5131
|
if (!project) return c.json({ error: "Project not found" }, 404);
|
|
@@ -4970,8 +5243,8 @@ function registerSelectionRoutes(api, adapter) {
|
|
|
4970
5243
|
|
|
4971
5244
|
// src/routes/media.ts
|
|
4972
5245
|
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
4973
|
-
import { existsSync as
|
|
4974
|
-
import { basename as
|
|
5246
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync7 } from "fs";
|
|
5247
|
+
import { basename as basename3, dirname as dirname7, extname as extname2, join as join15 } from "path";
|
|
4975
5248
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4976
5249
|
".mp4",
|
|
4977
5250
|
".mov",
|
|
@@ -5001,14 +5274,14 @@ function containsNullByte(path) {
|
|
|
5001
5274
|
return path.includes("\0");
|
|
5002
5275
|
}
|
|
5003
5276
|
function slugFileBase(path) {
|
|
5004
|
-
const name =
|
|
5277
|
+
const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
5005
5278
|
return name || "media";
|
|
5006
5279
|
}
|
|
5007
5280
|
function uniqueAssetPath(projectDir, assetPath) {
|
|
5008
5281
|
const ext = extname2(assetPath);
|
|
5009
5282
|
const withoutExt = assetPath.slice(0, -ext.length);
|
|
5010
5283
|
let candidate = assetPath;
|
|
5011
|
-
for (let index = 2;
|
|
5284
|
+
for (let index = 2; existsSync8(join15(projectDir, candidate)); index++) {
|
|
5012
5285
|
candidate = `${withoutExt}-${index}${ext}`;
|
|
5013
5286
|
}
|
|
5014
5287
|
return candidate;
|
|
@@ -5057,9 +5330,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5057
5330
|
if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
|
|
5058
5331
|
return c.json({ error: "media metadata requires a project-local asset" }, 400);
|
|
5059
5332
|
}
|
|
5060
|
-
const filePath =
|
|
5333
|
+
const filePath = resolveWithinProject2(project.dir, assetPath);
|
|
5061
5334
|
if (!filePath) return c.json({ error: "forbidden" }, 403);
|
|
5062
|
-
if (!
|
|
5335
|
+
if (!existsSync8(filePath)) return c.json({ error: "media not found" }, 404);
|
|
5063
5336
|
return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
|
|
5064
5337
|
});
|
|
5065
5338
|
api.post(
|
|
@@ -5079,9 +5352,9 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5079
5352
|
if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
|
|
5080
5353
|
return c.json({ error: "background removal requires a project-local media asset" }, 400);
|
|
5081
5354
|
}
|
|
5082
|
-
const inputPath =
|
|
5355
|
+
const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
|
|
5083
5356
|
if (!inputPath) return c.json({ error: "forbidden" }, 403);
|
|
5084
|
-
if (!
|
|
5357
|
+
if (!existsSync8(inputPath)) return c.json({ error: "input media not found" }, 404);
|
|
5085
5358
|
const inputIsVideo = isVideoPath(inputAssetPath);
|
|
5086
5359
|
const inputIsImage = isImagePath(inputAssetPath);
|
|
5087
5360
|
if (!inputIsVideo && !inputIsImage) {
|
|
@@ -5091,11 +5364,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5091
5364
|
if (requestedOutput && containsNullByte(requestedOutput)) {
|
|
5092
5365
|
return c.json({ error: "forbidden" }, 403);
|
|
5093
5366
|
}
|
|
5094
|
-
if (requestedOutput && !
|
|
5367
|
+
if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
|
|
5095
5368
|
return c.json({ error: "forbidden" }, 403);
|
|
5096
5369
|
}
|
|
5097
5370
|
const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
|
|
5098
|
-
const outputPath =
|
|
5371
|
+
const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
|
|
5099
5372
|
if (!outputPath) return c.json({ error: "forbidden" }, 403);
|
|
5100
5373
|
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
|
|
5101
5374
|
return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
|
|
@@ -5110,13 +5383,13 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5110
5383
|
return c.json({ error: "background plates are only supported for video inputs" }, 400);
|
|
5111
5384
|
}
|
|
5112
5385
|
backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
|
|
5113
|
-
backgroundOutputPath =
|
|
5386
|
+
backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
|
|
5114
5387
|
if (!backgroundOutputPath) {
|
|
5115
5388
|
return c.json({ error: "forbidden" }, 403);
|
|
5116
5389
|
}
|
|
5117
5390
|
}
|
|
5118
|
-
mkdirSync7(
|
|
5119
|
-
if (backgroundOutputPath) mkdirSync7(
|
|
5391
|
+
mkdirSync7(dirname7(outputPath), { recursive: true });
|
|
5392
|
+
if (backgroundOutputPath) mkdirSync7(dirname7(backgroundOutputPath), { recursive: true });
|
|
5120
5393
|
const jobId = makeJobId(project.id, mediaJobs);
|
|
5121
5394
|
const state = adapter.startBackgroundRemoval({
|
|
5122
5395
|
project,
|
|
@@ -5173,12 +5446,12 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5173
5446
|
}
|
|
5174
5447
|
|
|
5175
5448
|
// src/routes/globalAssets.ts
|
|
5176
|
-
import { existsSync as
|
|
5449
|
+
import { existsSync as existsSync9, readFileSync as readFileSync14 } from "fs";
|
|
5177
5450
|
import { homedir } from "os";
|
|
5178
|
-
import { join as
|
|
5451
|
+
import { join as join16 } from "path";
|
|
5179
5452
|
function readGlobalAssets(home = homedir()) {
|
|
5180
|
-
const manifestPath =
|
|
5181
|
-
if (!
|
|
5453
|
+
const manifestPath = join16(home, ".media", "manifest.jsonl");
|
|
5454
|
+
if (!existsSync9(manifestPath)) return [];
|
|
5182
5455
|
const out = [];
|
|
5183
5456
|
for (const line of readFileSync14(manifestPath, "utf8").split("\n")) {
|
|
5184
5457
|
if (!line.trim()) continue;
|
|
@@ -5199,28 +5472,38 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5199
5472
|
|
|
5200
5473
|
// src/history/projectHistory.ts
|
|
5201
5474
|
import { randomUUID as randomUUID8 } from "crypto";
|
|
5202
|
-
import { rm as rm2 } from "fs/promises";
|
|
5203
|
-
import {
|
|
5475
|
+
import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
|
|
5476
|
+
import {
|
|
5477
|
+
existsSync as existsSync11,
|
|
5478
|
+
lstatSync as lstatSync4,
|
|
5479
|
+
statSync as statSync9,
|
|
5480
|
+
mkdirSync as mkdirSync11,
|
|
5481
|
+
readdirSync as readdirSync7,
|
|
5482
|
+
readFileSync as readFileSync18,
|
|
5483
|
+
realpathSync as realpathSync3,
|
|
5484
|
+
rmSync as rmSync6
|
|
5485
|
+
} from "fs";
|
|
5204
5486
|
import { homedir as homedir2 } from "os";
|
|
5205
|
-
import { join as
|
|
5487
|
+
import { dirname as dirname10, isAbsolute as isAbsolute3, join as join20, relative as relative6, resolve as resolve8, sep as sep4 } from "path";
|
|
5206
5488
|
|
|
5207
5489
|
// src/history/blobStore.ts
|
|
5208
|
-
import { createHash as
|
|
5209
|
-
import { constants as constants3, createReadStream as createReadStream2 } from "fs";
|
|
5210
|
-
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
|
|
5211
|
-
import { dirname as
|
|
5490
|
+
import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
|
|
5491
|
+
import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
|
|
5492
|
+
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
|
|
5493
|
+
import { dirname as dirname8, join as join17 } from "path";
|
|
5212
5494
|
var BLOB_HASH = /^[0-9a-f]{64}$/;
|
|
5213
5495
|
async function hashFile(path) {
|
|
5214
|
-
const hash =
|
|
5496
|
+
const hash = createHash7("sha256");
|
|
5215
5497
|
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
5216
5498
|
return hash.digest("hex");
|
|
5217
5499
|
}
|
|
5218
|
-
async function cloneOrCopy(from, to) {
|
|
5219
|
-
await mkdir(
|
|
5500
|
+
async function cloneOrCopy(from, to, beforeReplace) {
|
|
5501
|
+
await mkdir(dirname8(to), { recursive: true });
|
|
5220
5502
|
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5221
5503
|
try {
|
|
5222
5504
|
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
5223
|
-
|
|
5505
|
+
beforeReplace?.();
|
|
5506
|
+
renameSync4(temp, to);
|
|
5224
5507
|
} catch (error) {
|
|
5225
5508
|
await rm(temp, { force: true });
|
|
5226
5509
|
throw error;
|
|
@@ -5230,16 +5513,16 @@ async function openBlobStore(dir) {
|
|
|
5230
5513
|
const sizes = /* @__PURE__ */ new Map();
|
|
5231
5514
|
await mkdir(dir, { recursive: true });
|
|
5232
5515
|
for (const shard of await readdir(dir))
|
|
5233
|
-
for (const hash of await readdir(
|
|
5234
|
-
sizes.set(hash, (await
|
|
5516
|
+
for (const hash of await readdir(join17(dir, shard)).catch(() => []))
|
|
5517
|
+
sizes.set(hash, (await stat2(join17(dir, shard, hash))).size);
|
|
5235
5518
|
const pathOf = (hash) => {
|
|
5236
5519
|
if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
|
|
5237
|
-
return
|
|
5520
|
+
return join17(dir, hash.slice(0, 2), hash);
|
|
5238
5521
|
};
|
|
5239
5522
|
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
5240
5523
|
return {
|
|
5241
5524
|
async put(absPath) {
|
|
5242
|
-
const temp =
|
|
5525
|
+
const temp = join17(dir, `incoming-${randomUUID5()}`);
|
|
5243
5526
|
await mkdir(dir, { recursive: true });
|
|
5244
5527
|
await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
|
|
5245
5528
|
const hash = await hashFile(temp);
|
|
@@ -5247,8 +5530,8 @@ async function openBlobStore(dir) {
|
|
|
5247
5530
|
await rm(temp, { force: true });
|
|
5248
5531
|
return hash;
|
|
5249
5532
|
}
|
|
5250
|
-
await mkdir(
|
|
5251
|
-
const size = (await
|
|
5533
|
+
await mkdir(dirname8(pathOf(hash)), { recursive: true });
|
|
5534
|
+
const size = (await stat2(temp)).size;
|
|
5252
5535
|
await rename(temp, pathOf(hash));
|
|
5253
5536
|
sizes.set(hash, size);
|
|
5254
5537
|
total += size;
|
|
@@ -5256,7 +5539,7 @@ async function openBlobStore(dir) {
|
|
|
5256
5539
|
},
|
|
5257
5540
|
has: (hash) => sizes.has(hash),
|
|
5258
5541
|
read: async (hash) => readFile2(pathOf(hash)),
|
|
5259
|
-
writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
|
|
5542
|
+
writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
|
|
5260
5543
|
bytes: () => total,
|
|
5261
5544
|
size: (hash) => sizes.get(hash) ?? 0,
|
|
5262
5545
|
async prune(keep) {
|
|
@@ -5272,44 +5555,50 @@ async function openBlobStore(dir) {
|
|
|
5272
5555
|
|
|
5273
5556
|
// src/history/historyId.ts
|
|
5274
5557
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
5275
|
-
import { existsSync as
|
|
5276
|
-
import { join as
|
|
5277
|
-
var ID_PATH =
|
|
5558
|
+
import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
5559
|
+
import { join as join18, resolve as resolve7 } from "path";
|
|
5560
|
+
var ID_PATH = join18(".hyperframes", "history-id");
|
|
5278
5561
|
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
5279
5562
|
function readId(projectDir) {
|
|
5280
5563
|
try {
|
|
5281
|
-
const id = readFileSync15(
|
|
5564
|
+
const id = readFileSync15(join18(projectDir, ID_PATH), "utf-8").trim();
|
|
5282
5565
|
return ID_SHAPE.test(id) ? id : null;
|
|
5283
5566
|
} catch {
|
|
5284
5567
|
return null;
|
|
5285
5568
|
}
|
|
5286
5569
|
}
|
|
5287
|
-
|
|
5570
|
+
var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
5571
|
+
function isRecordedFolder(historyDir, folder) {
|
|
5288
5572
|
try {
|
|
5289
|
-
|
|
5573
|
+
const was = JSON.parse(readFileSync15(join18(historyDir, "project.json"), "utf-8"));
|
|
5574
|
+
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
|
|
5290
5575
|
} catch {
|
|
5291
|
-
return
|
|
5576
|
+
return false;
|
|
5292
5577
|
}
|
|
5293
5578
|
}
|
|
5294
5579
|
function projectHistoryId(projectDir, historyRoot) {
|
|
5295
|
-
const dir =
|
|
5580
|
+
const dir = resolve7(projectDir);
|
|
5581
|
+
const folder = statSync8(dir);
|
|
5296
5582
|
let id = readId(dir);
|
|
5297
|
-
|
|
5298
|
-
if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
|
|
5583
|
+
if (!id || existsSync10(join18(historyRoot, id)) && !isRecordedFolder(join18(historyRoot, id), folder)) {
|
|
5299
5584
|
id = randomUUID6();
|
|
5300
|
-
mkdirSync8(
|
|
5301
|
-
writeFileSync7(
|
|
5585
|
+
mkdirSync8(join18(dir, ".hyperframes"), { recursive: true });
|
|
5586
|
+
writeFileSync7(join18(dir, ID_PATH), `${id}
|
|
5302
5587
|
`);
|
|
5303
5588
|
}
|
|
5304
|
-
|
|
5305
|
-
writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
|
|
5589
|
+
recordProject(join18(historyRoot, id), dir, folder);
|
|
5306
5590
|
return id;
|
|
5307
5591
|
}
|
|
5592
|
+
function recordProject(historyDir, dir, folder) {
|
|
5593
|
+
const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
|
|
5594
|
+
mkdirSync8(historyDir, { recursive: true });
|
|
5595
|
+
replaceFileAtomically(join18(historyDir, "project.json"), JSON.stringify(record), 420);
|
|
5596
|
+
}
|
|
5308
5597
|
|
|
5309
5598
|
// src/history/ownerLock.ts
|
|
5310
5599
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
5311
5600
|
import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
|
|
5312
|
-
import { join as
|
|
5601
|
+
import { join as join19 } from "path";
|
|
5313
5602
|
var HistoryBusyError = class extends Error {
|
|
5314
5603
|
constructor(pid) {
|
|
5315
5604
|
super(`This project's history is open in another process (pid ${pid}).`);
|
|
@@ -5368,7 +5657,7 @@ function evictDeadOwner(file) {
|
|
|
5368
5657
|
}
|
|
5369
5658
|
}
|
|
5370
5659
|
async function takeHistoryOwnership(home, waitMs) {
|
|
5371
|
-
const file =
|
|
5660
|
+
const file = join19(home, "owner.pid");
|
|
5372
5661
|
const deadline = Date.now() + waitMs;
|
|
5373
5662
|
mkdirSync9(home, { recursive: true });
|
|
5374
5663
|
for (; ; ) {
|
|
@@ -5389,7 +5678,7 @@ async function takeHistoryOwnership(home, waitMs) {
|
|
|
5389
5678
|
|
|
5390
5679
|
// src/history/historyLog.ts
|
|
5391
5680
|
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
|
|
5392
|
-
import { dirname as
|
|
5681
|
+
import { dirname as dirname9 } from "path";
|
|
5393
5682
|
var START = "start";
|
|
5394
5683
|
function parseRecord(line) {
|
|
5395
5684
|
try {
|
|
@@ -5441,21 +5730,25 @@ function writeLog(file, log) {
|
|
|
5441
5730
|
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
5442
5731
|
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
5443
5732
|
];
|
|
5444
|
-
mkdirSync10(
|
|
5733
|
+
mkdirSync10(dirname9(file), { recursive: true });
|
|
5445
5734
|
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
5446
5735
|
`).join(""), 420);
|
|
5447
5736
|
}
|
|
5448
|
-
function applyEntry(manifest, entry) {
|
|
5449
|
-
for (const file of entry.files)
|
|
5450
|
-
|
|
5451
|
-
|
|
5737
|
+
function applyEntry(manifest, entry, side) {
|
|
5738
|
+
for (const file of entry.files) {
|
|
5739
|
+
const hash = file[side];
|
|
5740
|
+
if (hash === null) manifest.delete(file.path);
|
|
5741
|
+
else manifest.set(file.path, hash);
|
|
5742
|
+
}
|
|
5452
5743
|
}
|
|
5453
5744
|
function manifestAt(log, point) {
|
|
5745
|
+
return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
|
|
5746
|
+
}
|
|
5747
|
+
function manifestAround(log, id, side) {
|
|
5454
5748
|
const manifest = new Map(log.baseline);
|
|
5455
|
-
if (point === START) return manifest;
|
|
5456
5749
|
for (const entry of log.entries) {
|
|
5457
|
-
applyEntry(manifest, entry);
|
|
5458
|
-
if (entry.id ===
|
|
5750
|
+
applyEntry(manifest, entry, entry.id === id ? side : "after");
|
|
5751
|
+
if (entry.id === id) return manifest;
|
|
5459
5752
|
}
|
|
5460
5753
|
return null;
|
|
5461
5754
|
}
|
|
@@ -5467,24 +5760,35 @@ function undoneIds(entries) {
|
|
|
5467
5760
|
}
|
|
5468
5761
|
return undone;
|
|
5469
5762
|
}
|
|
5470
|
-
function stepTarget(entries, direction) {
|
|
5763
|
+
function stepTarget(entries, direction, mine) {
|
|
5471
5764
|
const undone = undoneIds(entries);
|
|
5472
5765
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
if (
|
|
5766
|
+
const owns = ownerOf2(entries, byId, mine);
|
|
5767
|
+
const newestFirst = [...entries].reverse();
|
|
5768
|
+
if (direction === "back")
|
|
5769
|
+
return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
|
|
5770
|
+
for (const entry of newestFirst) {
|
|
5771
|
+
if (!entry.undoes) {
|
|
5772
|
+
if (owns(entry)) return void 0;
|
|
5773
|
+
continue;
|
|
5774
|
+
}
|
|
5479
5775
|
const target = byId.get(entry.undoes);
|
|
5480
|
-
if (!undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5776
|
+
if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5481
5777
|
}
|
|
5482
5778
|
return void 0;
|
|
5483
5779
|
}
|
|
5780
|
+
function ownerOf2(entries, byId, mine) {
|
|
5781
|
+
const redoneBy = /* @__PURE__ */ new Map();
|
|
5782
|
+
for (const entry of entries) {
|
|
5783
|
+
const target = entry.undoes ? byId.get(entry.undoes) : void 0;
|
|
5784
|
+
if (target?.undoes) redoneBy.set(target.undoes, entry);
|
|
5785
|
+
}
|
|
5786
|
+
return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
|
|
5787
|
+
}
|
|
5484
5788
|
function foldOldest(log) {
|
|
5485
5789
|
const oldest = log.entries[0];
|
|
5486
5790
|
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
5487
|
-
applyEntry(log.baseline, oldest);
|
|
5791
|
+
applyEntry(log.baseline, oldest, "after");
|
|
5488
5792
|
log.entries.shift();
|
|
5489
5793
|
return true;
|
|
5490
5794
|
}
|
|
@@ -5499,8 +5803,9 @@ function referencedHashes(log, current) {
|
|
|
5499
5803
|
}
|
|
5500
5804
|
|
|
5501
5805
|
// src/history/projectHistory.ts
|
|
5502
|
-
var DEFAULT_HISTORY_ROOT =
|
|
5806
|
+
var DEFAULT_HISTORY_ROOT = join20(homedir2(), ".cache", "hyperframes", "history");
|
|
5503
5807
|
var MAX_WINDOW_IDLE_MS = 10 * 6e4;
|
|
5808
|
+
var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
|
|
5504
5809
|
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
5505
5810
|
var OUTSIDE_LABEL = "Changed outside the app";
|
|
5506
5811
|
var RACY_MS = 2e3;
|
|
@@ -5514,6 +5819,33 @@ function splitAt(change, at, kept) {
|
|
|
5514
5819
|
{ ...change, before: at }
|
|
5515
5820
|
];
|
|
5516
5821
|
}
|
|
5822
|
+
var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
|
|
5823
|
+
async function removeEmptyFolders(dir) {
|
|
5824
|
+
let entries;
|
|
5825
|
+
try {
|
|
5826
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
5827
|
+
} catch (error) {
|
|
5828
|
+
if (isMissingFolder(error)) return;
|
|
5829
|
+
throw error;
|
|
5830
|
+
}
|
|
5831
|
+
for (const entry of entries)
|
|
5832
|
+
if (entry.isDirectory()) await removeEmptyFolders(join20(dir, entry.name));
|
|
5833
|
+
await rmdir(dir);
|
|
5834
|
+
}
|
|
5835
|
+
function fileLeftIn(dir, at, deleted) {
|
|
5836
|
+
for (const entry of readdirSync7(dir, { withFileTypes: true })) {
|
|
5837
|
+
const path = `${at}/${entry.name}`;
|
|
5838
|
+
const left = entry.isDirectory() ? fileLeftIn(join20(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
|
|
5839
|
+
if (left) return left;
|
|
5840
|
+
}
|
|
5841
|
+
return void 0;
|
|
5842
|
+
}
|
|
5843
|
+
function ignoresCase(dir) {
|
|
5844
|
+
const self = statSync9(join20(dir, ID_PATH), { throwIfNoEntry: false });
|
|
5845
|
+
const other = statSync9(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
|
|
5846
|
+
return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
|
|
5847
|
+
}
|
|
5848
|
+
var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
|
|
5517
5849
|
var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
|
|
5518
5850
|
function addChange(group, path, before, after) {
|
|
5519
5851
|
const earlier = group.changes.get(path);
|
|
@@ -5526,19 +5858,26 @@ var Engine = class {
|
|
|
5526
5858
|
this.options = options;
|
|
5527
5859
|
this.projectId = projectId;
|
|
5528
5860
|
this.blobs = blobs;
|
|
5529
|
-
this.dir =
|
|
5530
|
-
this.home =
|
|
5861
|
+
this.dir = resolve8(options.projectDir);
|
|
5862
|
+
this.home = join20(options.historyRoot, projectId);
|
|
5863
|
+
this.foldsCase = ignoresCase(this.dir);
|
|
5864
|
+
const folder = statSync9(this.dir, { throwIfNoEntry: false });
|
|
5865
|
+
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
|
|
5866
|
+
throw this.replaced();
|
|
5867
|
+
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
|
|
5531
5868
|
}
|
|
5532
5869
|
options;
|
|
5533
5870
|
projectId;
|
|
5534
5871
|
blobs;
|
|
5535
5872
|
dir;
|
|
5873
|
+
foldsCase;
|
|
5874
|
+
folder;
|
|
5536
5875
|
home;
|
|
5537
5876
|
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5538
5877
|
tracked = /* @__PURE__ */ new Map();
|
|
5539
5878
|
windows = [];
|
|
5540
5879
|
outside = null;
|
|
5541
|
-
/** A coalescing claim, open until another key, its idle timer,
|
|
5880
|
+
/** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
|
|
5542
5881
|
claimed = null;
|
|
5543
5882
|
writeToken;
|
|
5544
5883
|
quietTimer;
|
|
@@ -5546,18 +5885,51 @@ var Engine = class {
|
|
|
5546
5885
|
notedTimer = null;
|
|
5547
5886
|
listeners = /* @__PURE__ */ new Set();
|
|
5548
5887
|
tail = Promise.resolve();
|
|
5888
|
+
closed = false;
|
|
5889
|
+
closing;
|
|
5890
|
+
/** A path as the project's disk compares names: folded where `A` and `a` are one name. */
|
|
5891
|
+
nameKey(path) {
|
|
5892
|
+
return this.foldsCase ? path.toLowerCase() : path;
|
|
5893
|
+
}
|
|
5549
5894
|
get logFile() {
|
|
5550
|
-
return
|
|
5895
|
+
return join20(this.home, "log.jsonl");
|
|
5551
5896
|
}
|
|
5552
5897
|
now() {
|
|
5553
5898
|
return (this.options.now ?? Date.now)();
|
|
5554
5899
|
}
|
|
5555
5900
|
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
5556
5901
|
queue(task) {
|
|
5902
|
+
if (this.closed)
|
|
5903
|
+
return Promise.reject(new HistoryClosedError("This project's history is closed."));
|
|
5557
5904
|
const run = this.tail.then(task);
|
|
5558
5905
|
this.tail = run.catch(() => void 0);
|
|
5559
5906
|
return run;
|
|
5560
5907
|
}
|
|
5908
|
+
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
|
|
5909
|
+
whereFolder() {
|
|
5910
|
+
const now = statSync9(this.dir, { throwIfNoEntry: false });
|
|
5911
|
+
if (!now) return "gone";
|
|
5912
|
+
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
|
|
5913
|
+
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
|
|
5914
|
+
}
|
|
5915
|
+
assertWritable() {
|
|
5916
|
+
const folder = this.whereFolder();
|
|
5917
|
+
if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
|
|
5918
|
+
if (folder === "replaced") throw this.replaced();
|
|
5919
|
+
}
|
|
5920
|
+
persistLog(record) {
|
|
5921
|
+
if (record) saveRecord(this.logFile, this.log, record);
|
|
5922
|
+
else writeLog(this.logFile, this.log);
|
|
5923
|
+
if (!existsSync11(join20(this.home, "project.json")))
|
|
5924
|
+
recordProject(this.home, this.dir, this.folder);
|
|
5925
|
+
}
|
|
5926
|
+
replaced() {
|
|
5927
|
+
return new HistoryClosedError(`${this.dir} is now another project.`);
|
|
5928
|
+
}
|
|
5929
|
+
assertOpen() {
|
|
5930
|
+
if (this.closed) throw new HistoryClosedError("This project's history is closed.");
|
|
5931
|
+
if (this.whereFolder() === "replaced") throw this.replaced();
|
|
5932
|
+
}
|
|
5561
5933
|
async start() {
|
|
5562
5934
|
const log = readLog(
|
|
5563
5935
|
this.logFile,
|
|
@@ -5574,9 +5946,7 @@ var Engine = class {
|
|
|
5574
5946
|
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
5575
5947
|
}
|
|
5576
5948
|
this.reopenClosedWindow();
|
|
5577
|
-
await this.
|
|
5578
|
-
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5579
|
-
await this.commitOutside();
|
|
5949
|
+
await this.settleAll();
|
|
5580
5950
|
}
|
|
5581
5951
|
reopenClosedWindow() {
|
|
5582
5952
|
const closed = this.options.closedWindow;
|
|
@@ -5586,13 +5956,13 @@ var Engine = class {
|
|
|
5586
5956
|
}
|
|
5587
5957
|
async firstOpen() {
|
|
5588
5958
|
const sweptAt = Date.now();
|
|
5589
|
-
for (const file of listProjectFiles(this.dir))
|
|
5590
|
-
this.
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5959
|
+
for (const file of listProjectFiles(this.dir)) {
|
|
5960
|
+
const hash = await this.storeIfPresent(file.path);
|
|
5961
|
+
if (this.whereFolder() !== "here") throw this.replaced();
|
|
5962
|
+
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
|
|
5963
|
+
}
|
|
5594
5964
|
this.log.baseline = this.manifest();
|
|
5595
|
-
|
|
5965
|
+
this.persistLog();
|
|
5596
5966
|
this.saveStatCache();
|
|
5597
5967
|
}
|
|
5598
5968
|
manifest() {
|
|
@@ -5600,7 +5970,7 @@ var Engine = class {
|
|
|
5600
5970
|
}
|
|
5601
5971
|
readStatCache() {
|
|
5602
5972
|
try {
|
|
5603
|
-
const files = JSON.parse(readFileSync18(
|
|
5973
|
+
const files = JSON.parse(readFileSync18(join20(this.home, "stat.json"), "utf-8"));
|
|
5604
5974
|
return new Map(Object.entries(files));
|
|
5605
5975
|
} catch {
|
|
5606
5976
|
return /* @__PURE__ */ new Map();
|
|
@@ -5609,26 +5979,39 @@ var Engine = class {
|
|
|
5609
5979
|
saveStatCache() {
|
|
5610
5980
|
const files = JSON.stringify(Object.fromEntries(this.tracked));
|
|
5611
5981
|
mkdirSync11(this.home, { recursive: true });
|
|
5612
|
-
replaceFileAtomically(
|
|
5982
|
+
replaceFileAtomically(join20(this.home, "stat.json"), files, 420);
|
|
5613
5983
|
}
|
|
5614
5984
|
/**
|
|
5615
5985
|
* Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
|
|
5616
5986
|
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
5617
5987
|
*/
|
|
5618
5988
|
async sweep() {
|
|
5619
|
-
if (
|
|
5989
|
+
if (this.whereFolder() !== "here") return;
|
|
5620
5990
|
const sweptAt = Date.now();
|
|
5621
5991
|
const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
|
|
5622
|
-
const seen = listProjectFiles(this.dir)
|
|
5623
|
-
let changed = false;
|
|
5624
|
-
for (const file of seen)
|
|
5625
|
-
changed = await this.observe(file.path, statKey(file, sweptAt), changedAt(file)) || changed;
|
|
5992
|
+
const seen = listProjectFiles(this.dir);
|
|
5626
5993
|
const present = new Set(seen.map((file) => file.path));
|
|
5627
|
-
|
|
5628
|
-
|
|
5994
|
+
const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
|
|
5995
|
+
const standing = this.standingAt(path);
|
|
5996
|
+
return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
|
|
5997
|
+
});
|
|
5998
|
+
const appearedAt = (file) => Math.max(
|
|
5999
|
+
changedAt(file),
|
|
6000
|
+
...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
|
|
6001
|
+
);
|
|
6002
|
+
const events = [
|
|
6003
|
+
...removed,
|
|
6004
|
+
...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
|
|
6005
|
+
].sort((a, b) => a.at - b.at);
|
|
6006
|
+
let changed = false;
|
|
6007
|
+
for (const { at, path, file } of events) {
|
|
6008
|
+
if (file) {
|
|
6009
|
+
changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
|
|
6010
|
+
continue;
|
|
6011
|
+
}
|
|
6012
|
+
const known = this.tracked.get(path);
|
|
5629
6013
|
this.tracked.delete(path);
|
|
5630
|
-
|
|
5631
|
-
await this.record(path, known.hash, null, removedAt);
|
|
6014
|
+
await this.record(path, known.hash, null, at);
|
|
5632
6015
|
changed = true;
|
|
5633
6016
|
}
|
|
5634
6017
|
if (changed) this.saveStatCache();
|
|
@@ -5636,23 +6019,23 @@ var Engine = class {
|
|
|
5636
6019
|
/** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
|
|
5637
6020
|
async storeIfPresent(path) {
|
|
5638
6021
|
try {
|
|
5639
|
-
return await this.blobs.put(
|
|
6022
|
+
return await this.blobs.put(join20(this.dir, path));
|
|
5640
6023
|
} catch (error) {
|
|
5641
6024
|
if (error.code === "ENOENT") return null;
|
|
5642
6025
|
throw error;
|
|
5643
6026
|
}
|
|
5644
6027
|
}
|
|
5645
|
-
async observe(path,
|
|
6028
|
+
async observe(path, stat3, at) {
|
|
5646
6029
|
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
5647
|
-
if (
|
|
6030
|
+
if (stat3 && known.stat === stat3) return false;
|
|
5648
6031
|
const hash = await this.storeIfPresent(path);
|
|
5649
|
-
if (hash === null) return false;
|
|
5650
|
-
this.tracked.set(path, { hash, stat:
|
|
6032
|
+
if (hash === null || this.whereFolder() !== "here") return false;
|
|
6033
|
+
this.tracked.set(path, { hash, stat: stat3 });
|
|
5651
6034
|
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
|
5652
|
-
return known.hash !== hash || known.stat !==
|
|
6035
|
+
return known.hash !== hash || known.stat !== stat3;
|
|
5653
6036
|
}
|
|
5654
6037
|
async record(path, before, after, at) {
|
|
5655
|
-
const endedBeforeThisWrite = this.windows.filter((
|
|
6038
|
+
const endedBeforeThisWrite = this.windows.filter((open2) => !takesWrite(open2, at));
|
|
5656
6039
|
if (endedBeforeThisWrite.length) await this.commitOutside();
|
|
5657
6040
|
for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
|
|
5658
6041
|
const window = this.windows.at(-1);
|
|
@@ -5662,28 +6045,34 @@ var Engine = class {
|
|
|
5662
6045
|
/** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
|
|
5663
6046
|
async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
|
|
5664
6047
|
await this.sweep();
|
|
5665
|
-
const
|
|
5666
|
-
if (!taken.length)
|
|
5667
|
-
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
5668
|
-
return null;
|
|
5669
|
-
}
|
|
6048
|
+
const taken = await this.takeClaimed(who, paths, overwrote);
|
|
6049
|
+
if (!taken.length) return this.claimNothing(coalesceKey);
|
|
5670
6050
|
const held = this.heldFor(coalesceKey, taken);
|
|
5671
6051
|
if (!held) await this.commitClaim();
|
|
5672
|
-
await this.
|
|
6052
|
+
await this.commitPending();
|
|
5673
6053
|
const group = held ?? this.newGroup(who, label);
|
|
5674
6054
|
for (const change of taken) addChange(group, change.path, change.before, change.after);
|
|
5675
6055
|
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
|
|
5676
6056
|
const entry = await this.commit(group);
|
|
5677
6057
|
return entry && { id: entry.id };
|
|
5678
6058
|
}
|
|
5679
|
-
|
|
5680
|
-
|
|
6059
|
+
/** A claim that took nothing still ends a held claim under another key. */
|
|
6060
|
+
async claimNothing(coalesceKey) {
|
|
6061
|
+
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
6062
|
+
return null;
|
|
6063
|
+
}
|
|
6064
|
+
/** Commits every other writer's pending changes, oldest first; open windows stay open. */
|
|
6065
|
+
async commitPending() {
|
|
5681
6066
|
await this.commitOutside();
|
|
5682
|
-
for (const
|
|
6067
|
+
for (const window of [...this.windows])
|
|
6068
|
+
if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
|
|
6069
|
+
}
|
|
6070
|
+
pendingElsewhere() {
|
|
6071
|
+
return Boolean(this.outside?.changes.size) || this.windows.some((open2) => open2.changes.size > 0);
|
|
5683
6072
|
}
|
|
5684
6073
|
/** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
|
|
5685
6074
|
heldFor(key, taken) {
|
|
5686
|
-
const group = key && this.claimed?.key === key ? this.claimed.group : null;
|
|
6075
|
+
const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
|
|
5687
6076
|
const after = (path, before) => {
|
|
5688
6077
|
const own = group?.changes.get(path);
|
|
5689
6078
|
return own ? own.after : before;
|
|
@@ -5693,10 +6082,10 @@ var Engine = class {
|
|
|
5693
6082
|
return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
|
|
5694
6083
|
}
|
|
5695
6084
|
logPath(path) {
|
|
5696
|
-
return
|
|
6085
|
+
return relative6(this.dir, resolve8(this.dir, path)).split(sep4).join("/");
|
|
5697
6086
|
}
|
|
5698
|
-
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote
|
|
5699
|
-
takeClaimed(who, paths, overwrote) {
|
|
6087
|
+
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
|
|
6088
|
+
async takeClaimed(who, paths, overwrote) {
|
|
5700
6089
|
const wanted = new Set(paths.map((path) => this.logPath(path)));
|
|
5701
6090
|
const at = new Map(
|
|
5702
6091
|
Object.entries(overwrote).map(([path, version]) => [
|
|
@@ -5704,25 +6093,52 @@ var Engine = class {
|
|
|
5704
6093
|
hashOfVersion(version)
|
|
5705
6094
|
])
|
|
5706
6095
|
);
|
|
5707
|
-
const others = this.windows.filter((
|
|
6096
|
+
const others = this.windows.filter((open2) => !sameWho(open2.who, who));
|
|
5708
6097
|
const groups = this.outside ? [this.outside, ...others] : others;
|
|
5709
|
-
const
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
6098
|
+
const hits = groups.flatMap(
|
|
6099
|
+
(group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
|
|
6100
|
+
);
|
|
6101
|
+
const cuts = [];
|
|
6102
|
+
const walked = hits.map(() => /* @__PURE__ */ new Set());
|
|
6103
|
+
for (const [i, { change }] of hits.entries()) {
|
|
6104
|
+
const told = at.get(change.path);
|
|
6105
|
+
cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
|
|
6106
|
+
}
|
|
6107
|
+
hits.forEach(
|
|
6108
|
+
({ change }, i) => forgetOverwrittenBytes(
|
|
6109
|
+
join20(this.dir, change.path),
|
|
6110
|
+
new Set([...walked[i]].map(hashVersion))
|
|
6111
|
+
)
|
|
6112
|
+
);
|
|
6113
|
+
return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
|
|
6114
|
+
}
|
|
6115
|
+
/** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
|
|
6116
|
+
async overwrittenBy(change, told, seen) {
|
|
6117
|
+
const absPath = join20(this.dir, change.path);
|
|
6118
|
+
let bytes;
|
|
6119
|
+
for (let hash = change.after; hash; ) {
|
|
6120
|
+
if (seen.has(hash)) return void 0;
|
|
6121
|
+
seen.add(hash);
|
|
6122
|
+
const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
|
|
6123
|
+
if (replaced === void 0) break;
|
|
6124
|
+
bytes = replaced;
|
|
6125
|
+
hash = hashOfVersion(fileContentVersion(replaced));
|
|
6126
|
+
if (hash === change.before || hash === told) return hash;
|
|
6127
|
+
}
|
|
6128
|
+
if (bytes === void 0) return void 0;
|
|
6129
|
+
const staged = join20(this.home, `overwrote-${randomUUID8()}`);
|
|
6130
|
+
await writeFile(staged, bytes);
|
|
6131
|
+
try {
|
|
6132
|
+
return await this.blobs.put(staged);
|
|
6133
|
+
} finally {
|
|
6134
|
+
await rm2(staged, { force: true });
|
|
5718
6135
|
}
|
|
5719
|
-
return { taken, cut };
|
|
5720
6136
|
}
|
|
5721
6137
|
cutOut(group, change, cut) {
|
|
5722
6138
|
const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
|
|
5723
6139
|
group.changes.delete(change.path);
|
|
5724
6140
|
if (kept) group.changes.set(kept.path, kept);
|
|
5725
|
-
return
|
|
6141
|
+
return claimed;
|
|
5726
6142
|
}
|
|
5727
6143
|
/** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
|
|
5728
6144
|
* so the writer's next write to a cut file would otherwise span the claimer's edit. */
|
|
@@ -5736,8 +6152,8 @@ var Engine = class {
|
|
|
5736
6152
|
}
|
|
5737
6153
|
holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
|
|
5738
6154
|
clearTimeout(this.claimed?.timer);
|
|
5739
|
-
const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
|
|
5740
|
-
timer
|
|
6155
|
+
const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
|
|
6156
|
+
timer?.unref?.();
|
|
5741
6157
|
this.claimed = { group, key, timer };
|
|
5742
6158
|
return group.changes.size ? { id: group.id } : null;
|
|
5743
6159
|
}
|
|
@@ -5767,6 +6183,7 @@ var Engine = class {
|
|
|
5767
6183
|
return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
5768
6184
|
}
|
|
5769
6185
|
async commitOutside() {
|
|
6186
|
+
if (this.outside?.changes.size) await this.commitClaim();
|
|
5770
6187
|
clearTimeout(this.quietTimer);
|
|
5771
6188
|
clearTimeout(this.maxTimer);
|
|
5772
6189
|
const group = this.outside;
|
|
@@ -5775,14 +6192,12 @@ var Engine = class {
|
|
|
5775
6192
|
}
|
|
5776
6193
|
async commit(group, extra = {}) {
|
|
5777
6194
|
if (!group.changes.size) return null;
|
|
5778
|
-
const
|
|
5779
|
-
const
|
|
5780
|
-
const
|
|
5781
|
-
const endedAt = Math.max(lastWriteAt ?? this.now(), previousEnd);
|
|
5782
|
-
const entry = { id, who, label, startedAt, endedAt, files, ...extra };
|
|
6195
|
+
const pending = this.pendingEntry(group);
|
|
6196
|
+
const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
|
|
6197
|
+
const entry = { ...pending, endedAt, ...extra };
|
|
5783
6198
|
this.log.entries.push(entry);
|
|
5784
6199
|
try {
|
|
5785
|
-
|
|
6200
|
+
this.persistLog({ type: "entry", entry });
|
|
5786
6201
|
} catch (error) {
|
|
5787
6202
|
this.log.entries.pop();
|
|
5788
6203
|
throw error;
|
|
@@ -5803,19 +6218,28 @@ var Engine = class {
|
|
|
5803
6218
|
let folded = false;
|
|
5804
6219
|
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
5805
6220
|
folded = true;
|
|
5806
|
-
await this.blobs.prune(
|
|
6221
|
+
await this.blobs.prune(
|
|
6222
|
+
/* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
|
|
6223
|
+
);
|
|
5807
6224
|
}
|
|
5808
|
-
if (folded)
|
|
6225
|
+
if (folded) this.persistLog();
|
|
6226
|
+
}
|
|
6227
|
+
/** Hashes only pending changes point at yet, such as a claim's stored cut. */
|
|
6228
|
+
pendingHashes() {
|
|
6229
|
+
const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
|
|
6230
|
+
...group?.changes.values() ?? []
|
|
6231
|
+
]);
|
|
6232
|
+
return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
|
|
5809
6233
|
}
|
|
5810
|
-
/** Before an operation: every write
|
|
6234
|
+
/** Before an operation or a window: every write is filed and every pending change committed, in write order. */
|
|
5811
6235
|
async settle() {
|
|
5812
6236
|
await this.sweep();
|
|
5813
6237
|
await this.commitClaim();
|
|
5814
|
-
await this.
|
|
6238
|
+
await this.commitPending();
|
|
5815
6239
|
}
|
|
5816
6240
|
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
5817
6241
|
noteChange(path) {
|
|
5818
|
-
if (this.notedTimer || !affectsProjectSignature(this.dir,
|
|
6242
|
+
if (this.notedTimer || !affectsProjectSignature(this.dir, resolve8(this.dir, path))) return;
|
|
5819
6243
|
this.notedTimer = setTimeout(() => {
|
|
5820
6244
|
this.notedTimer = null;
|
|
5821
6245
|
this.background(() => this.sweep());
|
|
@@ -5823,11 +6247,13 @@ var Engine = class {
|
|
|
5823
6247
|
this.notedTimer.unref?.();
|
|
5824
6248
|
}
|
|
5825
6249
|
background(task) {
|
|
5826
|
-
this.queue(task).catch((error) =>
|
|
6250
|
+
this.queue(task).catch((error) => {
|
|
6251
|
+
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
|
|
6252
|
+
});
|
|
5827
6253
|
}
|
|
5828
6254
|
beginWindow(who, label, idleMs) {
|
|
5829
6255
|
return this.queue(async () => {
|
|
5830
|
-
await this.
|
|
6256
|
+
await this.settle();
|
|
5831
6257
|
const window = { ...this.newGroup(who, label), idleMs };
|
|
5832
6258
|
this.windows.push(window);
|
|
5833
6259
|
this.touch(window, Date.now());
|
|
@@ -5854,7 +6280,8 @@ var Engine = class {
|
|
|
5854
6280
|
async endWindow(window) {
|
|
5855
6281
|
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
5856
6282
|
clearTimeout(window.idleTimer);
|
|
5857
|
-
|
|
6283
|
+
await this.commitClaim();
|
|
6284
|
+
this.windows = this.windows.filter((open2) => open2 !== window);
|
|
5858
6285
|
window.entry = await this.commit(window) ?? window.entry ?? null;
|
|
5859
6286
|
return window.entry;
|
|
5860
6287
|
}
|
|
@@ -5865,31 +6292,61 @@ var Engine = class {
|
|
|
5865
6292
|
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5866
6293
|
await this.commitOutside();
|
|
5867
6294
|
}
|
|
6295
|
+
/** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
|
|
6296
|
+
standingAt(path) {
|
|
6297
|
+
const parts = path.split("/");
|
|
6298
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
6299
|
+
const at = parts.slice(0, depth).join("/");
|
|
6300
|
+
const stat3 = lstatSync4(join20(this.dir, at), { throwIfNoEntry: false });
|
|
6301
|
+
if (!stat3) return void 0;
|
|
6302
|
+
if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
|
|
6303
|
+
}
|
|
6304
|
+
return void 0;
|
|
6305
|
+
}
|
|
6306
|
+
/** What a write of `path` would have to delete: a file or link at or above it, or a file in a folder at it. */
|
|
6307
|
+
inTheWay(path, deleted) {
|
|
6308
|
+
const standing = this.standingAt(path);
|
|
6309
|
+
if (!standing) return void 0;
|
|
6310
|
+
if (standing.stat.isSymbolicLink()) return standing.at;
|
|
6311
|
+
if (standing.at === path && !standing.stat.isDirectory()) return void 0;
|
|
6312
|
+
const real = realpathSync3.native(join20(this.dir, standing.at));
|
|
6313
|
+
const at = relative6(realpathSync3.native(this.dir), real).split(sep4).join("/");
|
|
6314
|
+
if (standing.at !== path) return deleted.has(at) ? void 0 : at;
|
|
6315
|
+
return fileLeftIn(real, at, deleted);
|
|
6316
|
+
}
|
|
5868
6317
|
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
5869
6318
|
async writeAs(who, label, target, extra) {
|
|
6319
|
+
this.assertWritable();
|
|
6320
|
+
const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
|
|
6321
|
+
const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
|
|
6322
|
+
const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
|
|
6323
|
+
if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
|
|
5870
6324
|
const group = this.newGroup(who, label);
|
|
5871
6325
|
this.windows.push(group);
|
|
5872
6326
|
try {
|
|
5873
|
-
for (const [path, hash] of
|
|
5874
|
-
if ((this.tracked.get(path)?.hash ?? null) !== hash)
|
|
5875
|
-
await this.writeProjectFile(path, hash);
|
|
5876
|
-
}
|
|
6327
|
+
for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
|
|
5877
6328
|
await this.sweep();
|
|
5878
6329
|
} finally {
|
|
5879
|
-
this.windows = this.windows.filter((
|
|
6330
|
+
this.windows = this.windows.filter((open2) => open2 !== group);
|
|
5880
6331
|
}
|
|
5881
6332
|
return this.commit(group, extra);
|
|
5882
6333
|
}
|
|
5883
6334
|
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
|
|
5884
6335
|
async writeProjectFile(path, hash) {
|
|
5885
|
-
|
|
6336
|
+
this.assertWritable();
|
|
6337
|
+
const absPath = join20(this.dir, path);
|
|
5886
6338
|
const { writeToken } = this;
|
|
5887
6339
|
if (writeToken) {
|
|
5888
6340
|
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
|
|
5889
6341
|
recordFileWriteReceipt(absPath, { path, version, writeToken });
|
|
5890
6342
|
}
|
|
5891
|
-
if (hash === null)
|
|
5892
|
-
|
|
6343
|
+
if (hash === null) {
|
|
6344
|
+
this.assertWritable();
|
|
6345
|
+
rmSync6(absPath, { force: true });
|
|
6346
|
+
} else {
|
|
6347
|
+
await removeEmptyFolders(absPath);
|
|
6348
|
+
await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
|
|
6349
|
+
}
|
|
5893
6350
|
}
|
|
5894
6351
|
entry(id) {
|
|
5895
6352
|
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
@@ -5913,7 +6370,9 @@ var Engine = class {
|
|
|
5913
6370
|
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
5914
6371
|
};
|
|
5915
6372
|
}
|
|
5916
|
-
const target = new Map(
|
|
6373
|
+
const target = new Map(
|
|
6374
|
+
entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
|
|
6375
|
+
);
|
|
5917
6376
|
return {
|
|
5918
6377
|
ok: true,
|
|
5919
6378
|
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
@@ -5922,15 +6381,19 @@ var Engine = class {
|
|
|
5922
6381
|
movedOn(entry) {
|
|
5923
6382
|
return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
|
|
5924
6383
|
}
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
if (
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
6384
|
+
next(direction, who) {
|
|
6385
|
+
const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
|
|
6386
|
+
const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
|
|
6387
|
+
(group) => group?.changes.size && mine(group.who)
|
|
6388
|
+
);
|
|
6389
|
+
if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
|
|
6390
|
+
return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
|
|
6391
|
+
}
|
|
6392
|
+
/** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
|
|
6393
|
+
pendingEntry(group) {
|
|
6394
|
+
const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
6395
|
+
const { id, who, label, startedAt, lastWriteAt } = group;
|
|
6396
|
+
return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
|
|
5934
6397
|
}
|
|
5935
6398
|
conflict(entry, changed) {
|
|
5936
6399
|
const paths = new Set(changed.map((file) => file.path));
|
|
@@ -5967,8 +6430,11 @@ var Engine = class {
|
|
|
5967
6430
|
projectId: this.projectId,
|
|
5968
6431
|
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
5969
6432
|
claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
|
|
5970
|
-
noteChange: (path) =>
|
|
6433
|
+
noteChange: (path) => {
|
|
6434
|
+
if (!this.closed) this.noteChange(path);
|
|
6435
|
+
},
|
|
5971
6436
|
list: () => {
|
|
6437
|
+
this.assertOpen();
|
|
5972
6438
|
const undone = undoneIds(this.log.entries);
|
|
5973
6439
|
return this.log.entries.map((entry) => ({
|
|
5974
6440
|
...entry,
|
|
@@ -5977,7 +6443,7 @@ var Engine = class {
|
|
|
5977
6443
|
}));
|
|
5978
6444
|
},
|
|
5979
6445
|
step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
|
|
5980
|
-
const target = this.next(direction);
|
|
6446
|
+
const target = this.next(direction, who);
|
|
5981
6447
|
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
5982
6448
|
}),
|
|
5983
6449
|
undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
|
|
@@ -5986,37 +6452,83 @@ var Engine = class {
|
|
|
5986
6452
|
() => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
|
|
5987
6453
|
),
|
|
5988
6454
|
peek: (point) => {
|
|
6455
|
+
this.assertOpen();
|
|
5989
6456
|
const files = manifestAt(this.log, point);
|
|
5990
6457
|
return files && Object.fromEntries(files);
|
|
5991
6458
|
},
|
|
5992
|
-
|
|
5993
|
-
|
|
6459
|
+
checkout: (entryId, side, emptyDir) => this.queue(async () => {
|
|
6460
|
+
this.entry(entryId);
|
|
6461
|
+
if (!existsSync11(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
|
|
6462
|
+
const dest = resolve8(emptyDir);
|
|
6463
|
+
if (isWithin(this.dir, dest))
|
|
6464
|
+
throw new Error(`Checkout writes outside the project only: ${dest}`);
|
|
6465
|
+
if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
|
|
6466
|
+
throw new Error(`Checkout writes into an empty folder only: ${dest}`);
|
|
6467
|
+
const files = manifestAround(this.log, entryId, side);
|
|
6468
|
+
try {
|
|
6469
|
+
for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
|
|
6470
|
+
} catch (error) {
|
|
6471
|
+
for (const name of await readdir2(dest).catch(() => []))
|
|
6472
|
+
await rm2(join20(dest, name), { recursive: true, force: true });
|
|
6473
|
+
throw error;
|
|
6474
|
+
}
|
|
6475
|
+
}),
|
|
6476
|
+
next: (direction, who) => {
|
|
6477
|
+
this.assertOpen();
|
|
6478
|
+
return this.next(direction, who);
|
|
6479
|
+
},
|
|
6480
|
+
readBlob: async (hash) => {
|
|
6481
|
+
this.assertOpen();
|
|
6482
|
+
return this.blobs.read(hash);
|
|
6483
|
+
},
|
|
5994
6484
|
pin: (id, pinned) => {
|
|
6485
|
+
this.assertOpen();
|
|
5995
6486
|
this.entry(id);
|
|
5996
6487
|
if (pinned) this.log.pins.add(id);
|
|
5997
6488
|
else this.log.pins.delete(id);
|
|
5998
|
-
|
|
6489
|
+
this.persistLog({ type: "pin", id, pinned });
|
|
5999
6490
|
},
|
|
6000
6491
|
onEntry: (listener) => {
|
|
6001
6492
|
this.listeners.add(listener);
|
|
6002
6493
|
return () => this.listeners.delete(listener);
|
|
6003
6494
|
},
|
|
6004
6495
|
flush: () => this.queue(() => this.settleAll()),
|
|
6005
|
-
|
|
6496
|
+
replacedAtPath: () => this.whereFolder() === "replaced",
|
|
6497
|
+
close: () => this.closing ??= (async () => {
|
|
6006
6498
|
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
6007
|
-
|
|
6008
|
-
|
|
6499
|
+
const settled = this.queue(() => this.settleAll());
|
|
6500
|
+
this.closed = true;
|
|
6501
|
+
await settled;
|
|
6502
|
+
})()
|
|
6009
6503
|
};
|
|
6010
6504
|
}
|
|
6011
6505
|
};
|
|
6506
|
+
function isWithin(dir, path) {
|
|
6507
|
+
let probe = resolve8(path);
|
|
6508
|
+
while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
|
|
6509
|
+
const rel = relative6(realpathSync3.native(dir), realpathSync3.native(probe));
|
|
6510
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
|
|
6511
|
+
}
|
|
6512
|
+
function missingIsEmpty(error) {
|
|
6513
|
+
if (error.code === "ENOENT") return [];
|
|
6514
|
+
throw error;
|
|
6515
|
+
}
|
|
6516
|
+
var HistoryClosedError = class extends Error {
|
|
6517
|
+
constructor(message) {
|
|
6518
|
+
super(message);
|
|
6519
|
+
this.name = "HistoryClosedError";
|
|
6520
|
+
}
|
|
6521
|
+
};
|
|
6012
6522
|
async function openProjectHistory(options) {
|
|
6523
|
+
if (!existsSync11(options.projectDir))
|
|
6524
|
+
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
|
|
6013
6525
|
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
6014
6526
|
const release = await takeHistoryOwnership(
|
|
6015
|
-
|
|
6527
|
+
join20(options.historyRoot, projectId),
|
|
6016
6528
|
options.ownerWaitMs ?? 5e3
|
|
6017
6529
|
);
|
|
6018
6530
|
try {
|
|
6019
|
-
const blobs = await openBlobStore(
|
|
6531
|
+
const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
|
|
6020
6532
|
const engine = new Engine(options, projectId, blobs);
|
|
6021
6533
|
await engine.queue(() => engine.start());
|
|
6022
6534
|
const api = engine.api();
|
|
@@ -6062,7 +6574,7 @@ function versionsOf(value) {
|
|
|
6062
6574
|
return pairs.length ? Object.fromEntries(pairs) : void 0;
|
|
6063
6575
|
}
|
|
6064
6576
|
function nextStep(history, direction) {
|
|
6065
|
-
const target = history.next(direction);
|
|
6577
|
+
const target = history.next(direction, YOU);
|
|
6066
6578
|
if (!target) return null;
|
|
6067
6579
|
const paths = target.files.map((file) => file.path);
|
|
6068
6580
|
return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
|
|
@@ -6097,7 +6609,7 @@ function registerHistoryRoutes(api, adapter) {
|
|
|
6097
6609
|
api.post(
|
|
6098
6610
|
`${base}/undo`,
|
|
6099
6611
|
(c) => withHistory(adapter, c, (history, body) => {
|
|
6100
|
-
const mode =
|
|
6612
|
+
const mode = UNDO_MODES.find((known) => known === body.mode);
|
|
6101
6613
|
return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
|
|
6102
6614
|
})
|
|
6103
6615
|
);
|
|
@@ -6133,11 +6645,9 @@ function registerHistoryRoutes(api, adapter) {
|
|
|
6133
6645
|
(c) => withHistory(adapter, c, async (history, body) => {
|
|
6134
6646
|
const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
|
|
6135
6647
|
const coalesceKey = text(body.coalesceKey) ?? void 0;
|
|
6136
|
-
const idleMs = idleOf(body);
|
|
6137
6648
|
const overwrote = versionsOf(body.overwrote);
|
|
6138
6649
|
const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
|
|
6139
|
-
...coalesceKey && { coalesceKey },
|
|
6140
|
-
...idleMs && { idleMs },
|
|
6650
|
+
...coalesceKey && { coalesceKey, idleMs: idleOf(body) ?? Infinity },
|
|
6141
6651
|
...overwrote && { overwrote }
|
|
6142
6652
|
});
|
|
6143
6653
|
return { claimed };
|
|
@@ -6177,6 +6687,7 @@ function createStudioApi(adapter) {
|
|
|
6177
6687
|
registerLintRoutes(api, adapter);
|
|
6178
6688
|
registerRenderRoutes(api, adapter);
|
|
6179
6689
|
registerThumbnailRoutes(api, adapter);
|
|
6690
|
+
registerImageThumbnailRoutes(api, adapter);
|
|
6180
6691
|
registerSelectionRoutes(api, adapter);
|
|
6181
6692
|
registerMediaRoutes(api, adapter);
|
|
6182
6693
|
registerWaveformRoutes(api, adapter);
|
|
@@ -6187,7 +6698,32 @@ function createStudioApi(adapter) {
|
|
|
6187
6698
|
return api;
|
|
6188
6699
|
}
|
|
6189
6700
|
|
|
6701
|
+
// src/history/historyCache.ts
|
|
6702
|
+
function historyCache(open2) {
|
|
6703
|
+
const histories = /* @__PURE__ */ new Map();
|
|
6704
|
+
const get = async (projectDir) => {
|
|
6705
|
+
let cached = histories.get(projectDir);
|
|
6706
|
+
if (!cached) histories.set(projectDir, cached = open2(projectDir));
|
|
6707
|
+
const opened = await cached;
|
|
6708
|
+
if (!opened?.replacedAtPath()) return opened;
|
|
6709
|
+
const reopen = () => open2(projectDir);
|
|
6710
|
+
if (histories.get(projectDir) === cached)
|
|
6711
|
+
histories.set(projectDir, opened.close().then(reopen, reopen));
|
|
6712
|
+
return get(projectDir);
|
|
6713
|
+
};
|
|
6714
|
+
return {
|
|
6715
|
+
get,
|
|
6716
|
+
/** The history if one was opened, without opening it. */
|
|
6717
|
+
peek: (projectDir) => histories.get(projectDir),
|
|
6718
|
+
forget: (projectDir) => histories.delete(projectDir),
|
|
6719
|
+
closeAll: () => Promise.all(
|
|
6720
|
+
[...histories.values()].map((opened) => opened.then((history) => history?.close()))
|
|
6721
|
+
)
|
|
6722
|
+
};
|
|
6723
|
+
}
|
|
6724
|
+
|
|
6190
6725
|
// src/helpers/duplicateElement.ts
|
|
6726
|
+
import { sameInstant } from "@hyperframes/core/clip-facts";
|
|
6191
6727
|
import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
|
|
6192
6728
|
function duplicateElementInHtml(source, target, newId, at) {
|
|
6193
6729
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
@@ -6222,9 +6758,10 @@ function rippleElements(document, element, at, duration, track) {
|
|
|
6222
6758
|
continue;
|
|
6223
6759
|
}
|
|
6224
6760
|
const start = numericAttribute(candidate, "data-start");
|
|
6225
|
-
if (start
|
|
6226
|
-
|
|
6227
|
-
|
|
6761
|
+
if (start === null) continue;
|
|
6762
|
+
const metInsertionPoint = sameInstant(start, at);
|
|
6763
|
+
if (start < at && !metInsertionPoint) continue;
|
|
6764
|
+
candidate.setAttribute("data-start", String((metInsertionPoint ? at : start) + duration));
|
|
6228
6765
|
}
|
|
6229
6766
|
}
|
|
6230
6767
|
function stripStableIds(element) {
|
|
@@ -6239,11 +6776,11 @@ function numericAttribute(element, name) {
|
|
|
6239
6776
|
}
|
|
6240
6777
|
|
|
6241
6778
|
// src/helpers/previewDocumentStore.ts
|
|
6242
|
-
import { createHash as
|
|
6243
|
-
import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as
|
|
6244
|
-
import { join as
|
|
6779
|
+
import { createHash as createHash8 } from "crypto";
|
|
6780
|
+
import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
|
|
6781
|
+
import { join as join21 } from "path";
|
|
6245
6782
|
function createPreviewDocumentStore(dir, salt) {
|
|
6246
|
-
const fileFor = (key) =>
|
|
6783
|
+
const fileFor = (key) => join21(dir, `${createHash8("sha256").update(`${salt}
|
|
6247
6784
|
${key}`).digest("hex").slice(0, 32)}.html`);
|
|
6248
6785
|
return {
|
|
6249
6786
|
read(key) {
|
|
@@ -6258,8 +6795,8 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
|
|
|
6258
6795
|
mkdirSync12(dir, { recursive: true });
|
|
6259
6796
|
const file = fileFor(key);
|
|
6260
6797
|
replaceFileAtomically(file, html, 420);
|
|
6261
|
-
for (const name of
|
|
6262
|
-
if (
|
|
6798
|
+
for (const name of readdirSync8(dir)) {
|
|
6799
|
+
if (join21(dir, name) !== file) rmSync7(join21(dir, name), { force: true });
|
|
6263
6800
|
}
|
|
6264
6801
|
} catch {
|
|
6265
6802
|
}
|
|
@@ -6339,11 +6876,14 @@ export {
|
|
|
6339
6876
|
DELETED_VERSION,
|
|
6340
6877
|
START as HISTORY_START,
|
|
6341
6878
|
HistoryBusyError,
|
|
6879
|
+
HistoryClosedError,
|
|
6342
6880
|
MAX_WINDOW_IDLE_MS,
|
|
6343
6881
|
MIME_TYPES,
|
|
6344
6882
|
PREVIEW_BUNDLE_OPTIONS,
|
|
6345
6883
|
STUDIO_MANUAL_EDITS_PATH,
|
|
6346
6884
|
STUDIO_MOTION_PATH,
|
|
6885
|
+
UNDO_MODES,
|
|
6886
|
+
affectsPreview,
|
|
6347
6887
|
affectsProjectSignature,
|
|
6348
6888
|
applyFileMutations,
|
|
6349
6889
|
buildSubCompositionHtml,
|
|
@@ -6362,6 +6902,7 @@ export {
|
|
|
6362
6902
|
findTargetElement,
|
|
6363
6903
|
getElementScreenshotClip,
|
|
6364
6904
|
getMimeType,
|
|
6905
|
+
historyCache,
|
|
6365
6906
|
identifyFileWrite,
|
|
6366
6907
|
isSafePath,
|
|
6367
6908
|
openProjectHistory,
|