@hyperframes/studio-server 0.8.78 → 0.8.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -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;
|
|
@@ -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);
|
|
@@ -753,22 +769,38 @@ 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,
|
|
@@ -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") {
|
|
@@ -3348,6 +3432,7 @@ import { Readable } from "stream";
|
|
|
3348
3432
|
import { join as join9, resolve as resolve4 } 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(
|
|
@@ -3744,7 +3827,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
|
|
|
3744
3827
|
var PREVIEW_BUNDLE_OPTIONS = {
|
|
3745
3828
|
runtime: "placeholder",
|
|
3746
3829
|
inlineAssets: false,
|
|
3747
|
-
staticGuard: false
|
|
3830
|
+
staticGuard: false,
|
|
3831
|
+
sceneParts: true
|
|
3748
3832
|
};
|
|
3749
3833
|
function registerPreviewRoutes(api, adapter) {
|
|
3750
3834
|
const previewCacheHeaders = (etag) => ({
|
|
@@ -3794,6 +3878,7 @@ ${runtimeTag}`;
|
|
|
3794
3878
|
mainCompositionPath,
|
|
3795
3879
|
mediaCodecProbeCache
|
|
3796
3880
|
);
|
|
3881
|
+
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
|
|
3797
3882
|
rememberPreview(builtKey, bundled);
|
|
3798
3883
|
adapter.previewDocuments?.write(builtKey, bundled);
|
|
3799
3884
|
return bundled;
|
|
@@ -3871,7 +3956,7 @@ ${etag}`;
|
|
|
3871
3956
|
if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
|
|
3872
3957
|
const previewVariables = vars.values;
|
|
3873
3958
|
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
|
|
3874
|
-
const compFile =
|
|
3959
|
+
const compFile = resolveWithinProject2(project.dir, compPath);
|
|
3875
3960
|
if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
|
|
3876
3961
|
return c.text("not found", 404);
|
|
3877
3962
|
}
|
|
@@ -4105,7 +4190,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4105
4190
|
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
|
|
4106
4191
|
let composition;
|
|
4107
4192
|
if (typeof body.composition === "string" && body.composition.length > 0) {
|
|
4108
|
-
if (!
|
|
4193
|
+
if (!resolveWithinProject2(project.dir, body.composition)) {
|
|
4109
4194
|
return c.json({ error: "composition path must be within the project directory" }, 400);
|
|
4110
4195
|
}
|
|
4111
4196
|
composition = body.composition;
|
|
@@ -4238,7 +4323,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4238
4323
|
const filename = c.req.path.split("/renders/file/")[1];
|
|
4239
4324
|
if (!filename) return c.json({ error: "missing filename" }, 400);
|
|
4240
4325
|
const rendersDir = adapter.rendersDir(project);
|
|
4241
|
-
const fp =
|
|
4326
|
+
const fp = resolveWithinProject2(rendersDir, filename);
|
|
4242
4327
|
if (!fp) return c.json({ error: "forbidden" }, 403);
|
|
4243
4328
|
if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
|
|
4244
4329
|
const contentType = renderContentType(fp);
|
|
@@ -4322,7 +4407,7 @@ function projectReader(projectDir) {
|
|
|
4322
4407
|
const sources = /* @__PURE__ */ new Map();
|
|
4323
4408
|
return (compPath) => {
|
|
4324
4409
|
if (!sources.has(compPath)) {
|
|
4325
|
-
const file =
|
|
4410
|
+
const file = resolveWithinProject2(projectDir, compPath);
|
|
4326
4411
|
let source = null;
|
|
4327
4412
|
try {
|
|
4328
4413
|
source = file ? readFileSync11(file, "utf-8") : null;
|
|
@@ -4547,7 +4632,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4547
4632
|
const { project, signature: projectSignature } = resolved;
|
|
4548
4633
|
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
|
|
4549
4634
|
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
4550
|
-
const htmlFile =
|
|
4635
|
+
const htmlFile = resolveWithinProject2(project.dir, compPath);
|
|
4551
4636
|
const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
|
|
4552
4637
|
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
|
|
4553
4638
|
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
|
|
@@ -4971,7 +5056,7 @@ function registerSelectionRoutes(api, adapter) {
|
|
|
4971
5056
|
// src/routes/media.ts
|
|
4972
5057
|
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
4973
5058
|
import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
|
|
4974
|
-
import { basename as
|
|
5059
|
+
import { basename as basename3, dirname as dirname6, extname as extname2, join as join14 } from "path";
|
|
4975
5060
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4976
5061
|
".mp4",
|
|
4977
5062
|
".mov",
|
|
@@ -5001,7 +5086,7 @@ function containsNullByte(path) {
|
|
|
5001
5086
|
return path.includes("\0");
|
|
5002
5087
|
}
|
|
5003
5088
|
function slugFileBase(path) {
|
|
5004
|
-
const name =
|
|
5089
|
+
const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
5005
5090
|
return name || "media";
|
|
5006
5091
|
}
|
|
5007
5092
|
function uniqueAssetPath(projectDir, assetPath) {
|
|
@@ -5057,7 +5142,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5057
5142
|
if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
|
|
5058
5143
|
return c.json({ error: "media metadata requires a project-local asset" }, 400);
|
|
5059
5144
|
}
|
|
5060
|
-
const filePath =
|
|
5145
|
+
const filePath = resolveWithinProject2(project.dir, assetPath);
|
|
5061
5146
|
if (!filePath) return c.json({ error: "forbidden" }, 403);
|
|
5062
5147
|
if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
|
|
5063
5148
|
return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
|
|
@@ -5079,7 +5164,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5079
5164
|
if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
|
|
5080
5165
|
return c.json({ error: "background removal requires a project-local media asset" }, 400);
|
|
5081
5166
|
}
|
|
5082
|
-
const inputPath =
|
|
5167
|
+
const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
|
|
5083
5168
|
if (!inputPath) return c.json({ error: "forbidden" }, 403);
|
|
5084
5169
|
if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
|
|
5085
5170
|
const inputIsVideo = isVideoPath(inputAssetPath);
|
|
@@ -5091,11 +5176,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5091
5176
|
if (requestedOutput && containsNullByte(requestedOutput)) {
|
|
5092
5177
|
return c.json({ error: "forbidden" }, 403);
|
|
5093
5178
|
}
|
|
5094
|
-
if (requestedOutput && !
|
|
5179
|
+
if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
|
|
5095
5180
|
return c.json({ error: "forbidden" }, 403);
|
|
5096
5181
|
}
|
|
5097
5182
|
const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
|
|
5098
|
-
const outputPath =
|
|
5183
|
+
const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
|
|
5099
5184
|
if (!outputPath) return c.json({ error: "forbidden" }, 403);
|
|
5100
5185
|
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
|
|
5101
5186
|
return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
|
|
@@ -5110,13 +5195,13 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5110
5195
|
return c.json({ error: "background plates are only supported for video inputs" }, 400);
|
|
5111
5196
|
}
|
|
5112
5197
|
backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
|
|
5113
|
-
backgroundOutputPath =
|
|
5198
|
+
backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
|
|
5114
5199
|
if (!backgroundOutputPath) {
|
|
5115
5200
|
return c.json({ error: "forbidden" }, 403);
|
|
5116
5201
|
}
|
|
5117
5202
|
}
|
|
5118
|
-
mkdirSync7(
|
|
5119
|
-
if (backgroundOutputPath) mkdirSync7(
|
|
5203
|
+
mkdirSync7(dirname6(outputPath), { recursive: true });
|
|
5204
|
+
if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
|
|
5120
5205
|
const jobId = makeJobId(project.id, mediaJobs);
|
|
5121
5206
|
const state = adapter.startBackgroundRemoval({
|
|
5122
5207
|
project,
|
|
@@ -5199,28 +5284,38 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5199
5284
|
|
|
5200
5285
|
// src/history/projectHistory.ts
|
|
5201
5286
|
import { randomUUID as randomUUID8 } from "crypto";
|
|
5202
|
-
import { rm as rm2 } from "fs/promises";
|
|
5203
|
-
import {
|
|
5287
|
+
import { readdir as readdir2, rm as rm2, rmdir, writeFile } from "fs/promises";
|
|
5288
|
+
import {
|
|
5289
|
+
existsSync as existsSync12,
|
|
5290
|
+
lstatSync as lstatSync4,
|
|
5291
|
+
statSync as statSync9,
|
|
5292
|
+
mkdirSync as mkdirSync11,
|
|
5293
|
+
readdirSync as readdirSync7,
|
|
5294
|
+
readFileSync as readFileSync18,
|
|
5295
|
+
realpathSync as realpathSync3,
|
|
5296
|
+
rmSync as rmSync6
|
|
5297
|
+
} from "fs";
|
|
5204
5298
|
import { homedir as homedir2 } from "os";
|
|
5205
|
-
import { join as join19, relative as
|
|
5299
|
+
import { dirname as dirname9, isAbsolute as isAbsolute3, join as join19, relative as relative5, resolve as resolve7, sep as sep4 } from "path";
|
|
5206
5300
|
|
|
5207
5301
|
// src/history/blobStore.ts
|
|
5208
5302
|
import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
|
|
5209
|
-
import { constants as constants3, createReadStream as createReadStream2 } from "fs";
|
|
5303
|
+
import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
|
|
5210
5304
|
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
|
|
5211
|
-
import { dirname as
|
|
5305
|
+
import { dirname as dirname7, join as join16 } from "path";
|
|
5212
5306
|
var BLOB_HASH = /^[0-9a-f]{64}$/;
|
|
5213
5307
|
async function hashFile(path) {
|
|
5214
5308
|
const hash = createHash6("sha256");
|
|
5215
5309
|
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
5216
5310
|
return hash.digest("hex");
|
|
5217
5311
|
}
|
|
5218
|
-
async function cloneOrCopy(from, to) {
|
|
5219
|
-
await mkdir(
|
|
5312
|
+
async function cloneOrCopy(from, to, beforeReplace) {
|
|
5313
|
+
await mkdir(dirname7(to), { recursive: true });
|
|
5220
5314
|
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5221
5315
|
try {
|
|
5222
5316
|
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
5223
|
-
|
|
5317
|
+
beforeReplace?.();
|
|
5318
|
+
renameSync4(temp, to);
|
|
5224
5319
|
} catch (error) {
|
|
5225
5320
|
await rm(temp, { force: true });
|
|
5226
5321
|
throw error;
|
|
@@ -5247,7 +5342,7 @@ async function openBlobStore(dir) {
|
|
|
5247
5342
|
await rm(temp, { force: true });
|
|
5248
5343
|
return hash;
|
|
5249
5344
|
}
|
|
5250
|
-
await mkdir(
|
|
5345
|
+
await mkdir(dirname7(pathOf(hash)), { recursive: true });
|
|
5251
5346
|
const size = (await stat(temp)).size;
|
|
5252
5347
|
await rename(temp, pathOf(hash));
|
|
5253
5348
|
sizes.set(hash, size);
|
|
@@ -5256,7 +5351,7 @@ async function openBlobStore(dir) {
|
|
|
5256
5351
|
},
|
|
5257
5352
|
has: (hash) => sizes.has(hash),
|
|
5258
5353
|
read: async (hash) => readFile2(pathOf(hash)),
|
|
5259
|
-
writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
|
|
5354
|
+
writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
|
|
5260
5355
|
bytes: () => total,
|
|
5261
5356
|
size: (hash) => sizes.get(hash) ?? 0,
|
|
5262
5357
|
async prune(keep) {
|
|
@@ -5272,7 +5367,7 @@ async function openBlobStore(dir) {
|
|
|
5272
5367
|
|
|
5273
5368
|
// src/history/historyId.ts
|
|
5274
5369
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
5275
|
-
import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, writeFileSync as writeFileSync7 } from "fs";
|
|
5370
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
5276
5371
|
import { join as join17, resolve as resolve6 } from "path";
|
|
5277
5372
|
var ID_PATH = join17(".hyperframes", "history-id");
|
|
5278
5373
|
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
@@ -5284,27 +5379,33 @@ function readId(projectDir) {
|
|
|
5284
5379
|
return null;
|
|
5285
5380
|
}
|
|
5286
5381
|
}
|
|
5287
|
-
|
|
5382
|
+
var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
5383
|
+
function isRecordedFolder(historyDir, folder) {
|
|
5288
5384
|
try {
|
|
5289
|
-
|
|
5385
|
+
const was = JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
|
|
5386
|
+
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
|
|
5290
5387
|
} catch {
|
|
5291
|
-
return
|
|
5388
|
+
return false;
|
|
5292
5389
|
}
|
|
5293
5390
|
}
|
|
5294
5391
|
function projectHistoryId(projectDir, historyRoot) {
|
|
5295
5392
|
const dir = resolve6(projectDir);
|
|
5393
|
+
const folder = statSync8(dir);
|
|
5296
5394
|
let id = readId(dir);
|
|
5297
|
-
|
|
5298
|
-
if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
|
|
5395
|
+
if (!id || existsSync11(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
|
|
5299
5396
|
id = randomUUID6();
|
|
5300
5397
|
mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
|
|
5301
5398
|
writeFileSync7(join17(dir, ID_PATH), `${id}
|
|
5302
5399
|
`);
|
|
5303
5400
|
}
|
|
5304
|
-
|
|
5305
|
-
writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
|
|
5401
|
+
recordProject(join17(historyRoot, id), dir, folder);
|
|
5306
5402
|
return id;
|
|
5307
5403
|
}
|
|
5404
|
+
function recordProject(historyDir, dir, folder) {
|
|
5405
|
+
const record = { dir, ino: folder.ino, born: folder.birthtimeMs };
|
|
5406
|
+
mkdirSync8(historyDir, { recursive: true });
|
|
5407
|
+
replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
|
|
5408
|
+
}
|
|
5308
5409
|
|
|
5309
5410
|
// src/history/ownerLock.ts
|
|
5310
5411
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
@@ -5389,7 +5490,7 @@ async function takeHistoryOwnership(home, waitMs) {
|
|
|
5389
5490
|
|
|
5390
5491
|
// src/history/historyLog.ts
|
|
5391
5492
|
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
|
|
5493
|
+
import { dirname as dirname8 } from "path";
|
|
5393
5494
|
var START = "start";
|
|
5394
5495
|
function parseRecord(line) {
|
|
5395
5496
|
try {
|
|
@@ -5441,21 +5542,25 @@ function writeLog(file, log) {
|
|
|
5441
5542
|
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
5442
5543
|
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
5443
5544
|
];
|
|
5444
|
-
mkdirSync10(
|
|
5545
|
+
mkdirSync10(dirname8(file), { recursive: true });
|
|
5445
5546
|
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
5446
5547
|
`).join(""), 420);
|
|
5447
5548
|
}
|
|
5448
|
-
function applyEntry(manifest, entry) {
|
|
5449
|
-
for (const file of entry.files)
|
|
5450
|
-
|
|
5451
|
-
|
|
5549
|
+
function applyEntry(manifest, entry, side) {
|
|
5550
|
+
for (const file of entry.files) {
|
|
5551
|
+
const hash = file[side];
|
|
5552
|
+
if (hash === null) manifest.delete(file.path);
|
|
5553
|
+
else manifest.set(file.path, hash);
|
|
5554
|
+
}
|
|
5452
5555
|
}
|
|
5453
5556
|
function manifestAt(log, point) {
|
|
5557
|
+
return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
|
|
5558
|
+
}
|
|
5559
|
+
function manifestAround(log, id, side) {
|
|
5454
5560
|
const manifest = new Map(log.baseline);
|
|
5455
|
-
if (point === START) return manifest;
|
|
5456
5561
|
for (const entry of log.entries) {
|
|
5457
|
-
applyEntry(manifest, entry);
|
|
5458
|
-
if (entry.id ===
|
|
5562
|
+
applyEntry(manifest, entry, entry.id === id ? side : "after");
|
|
5563
|
+
if (entry.id === id) return manifest;
|
|
5459
5564
|
}
|
|
5460
5565
|
return null;
|
|
5461
5566
|
}
|
|
@@ -5467,24 +5572,35 @@ function undoneIds(entries) {
|
|
|
5467
5572
|
}
|
|
5468
5573
|
return undone;
|
|
5469
5574
|
}
|
|
5470
|
-
function stepTarget(entries, direction) {
|
|
5575
|
+
function stepTarget(entries, direction, mine) {
|
|
5471
5576
|
const undone = undoneIds(entries);
|
|
5472
5577
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
if (
|
|
5578
|
+
const owns = ownerOf2(entries, byId, mine);
|
|
5579
|
+
const newestFirst = [...entries].reverse();
|
|
5580
|
+
if (direction === "back")
|
|
5581
|
+
return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
|
|
5582
|
+
for (const entry of newestFirst) {
|
|
5583
|
+
if (!entry.undoes) {
|
|
5584
|
+
if (owns(entry)) return void 0;
|
|
5585
|
+
continue;
|
|
5586
|
+
}
|
|
5479
5587
|
const target = byId.get(entry.undoes);
|
|
5480
|
-
if (!undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5588
|
+
if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5481
5589
|
}
|
|
5482
5590
|
return void 0;
|
|
5483
5591
|
}
|
|
5592
|
+
function ownerOf2(entries, byId, mine) {
|
|
5593
|
+
const redoneBy = /* @__PURE__ */ new Map();
|
|
5594
|
+
for (const entry of entries) {
|
|
5595
|
+
const target = entry.undoes ? byId.get(entry.undoes) : void 0;
|
|
5596
|
+
if (target?.undoes) redoneBy.set(target.undoes, entry);
|
|
5597
|
+
}
|
|
5598
|
+
return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
|
|
5599
|
+
}
|
|
5484
5600
|
function foldOldest(log) {
|
|
5485
5601
|
const oldest = log.entries[0];
|
|
5486
5602
|
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
5487
|
-
applyEntry(log.baseline, oldest);
|
|
5603
|
+
applyEntry(log.baseline, oldest, "after");
|
|
5488
5604
|
log.entries.shift();
|
|
5489
5605
|
return true;
|
|
5490
5606
|
}
|
|
@@ -5501,6 +5617,7 @@ function referencedHashes(log, current) {
|
|
|
5501
5617
|
// src/history/projectHistory.ts
|
|
5502
5618
|
var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
|
|
5503
5619
|
var MAX_WINDOW_IDLE_MS = 10 * 6e4;
|
|
5620
|
+
var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
|
|
5504
5621
|
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
5505
5622
|
var OUTSIDE_LABEL = "Changed outside the app";
|
|
5506
5623
|
var RACY_MS = 2e3;
|
|
@@ -5514,6 +5631,33 @@ function splitAt(change, at, kept) {
|
|
|
5514
5631
|
{ ...change, before: at }
|
|
5515
5632
|
];
|
|
5516
5633
|
}
|
|
5634
|
+
var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
|
|
5635
|
+
async function removeEmptyFolders(dir) {
|
|
5636
|
+
let entries;
|
|
5637
|
+
try {
|
|
5638
|
+
entries = await readdir2(dir, { withFileTypes: true });
|
|
5639
|
+
} catch (error) {
|
|
5640
|
+
if (isMissingFolder(error)) return;
|
|
5641
|
+
throw error;
|
|
5642
|
+
}
|
|
5643
|
+
for (const entry of entries)
|
|
5644
|
+
if (entry.isDirectory()) await removeEmptyFolders(join19(dir, entry.name));
|
|
5645
|
+
await rmdir(dir);
|
|
5646
|
+
}
|
|
5647
|
+
function fileLeftIn(dir, at, deleted) {
|
|
5648
|
+
for (const entry of readdirSync7(dir, { withFileTypes: true })) {
|
|
5649
|
+
const path = `${at}/${entry.name}`;
|
|
5650
|
+
const left = entry.isDirectory() ? fileLeftIn(join19(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
|
|
5651
|
+
if (left) return left;
|
|
5652
|
+
}
|
|
5653
|
+
return void 0;
|
|
5654
|
+
}
|
|
5655
|
+
function ignoresCase(dir) {
|
|
5656
|
+
const self = statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false });
|
|
5657
|
+
const other = statSync9(join19(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
|
|
5658
|
+
return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
|
|
5659
|
+
}
|
|
5660
|
+
var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
|
|
5517
5661
|
var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
|
|
5518
5662
|
function addChange(group, path, before, after) {
|
|
5519
5663
|
const earlier = group.changes.get(path);
|
|
@@ -5528,17 +5672,24 @@ var Engine = class {
|
|
|
5528
5672
|
this.blobs = blobs;
|
|
5529
5673
|
this.dir = resolve7(options.projectDir);
|
|
5530
5674
|
this.home = join19(options.historyRoot, projectId);
|
|
5675
|
+
this.foldsCase = ignoresCase(this.dir);
|
|
5676
|
+
const folder = statSync9(this.dir, { throwIfNoEntry: false });
|
|
5677
|
+
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
|
|
5678
|
+
throw this.replaced();
|
|
5679
|
+
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
|
|
5531
5680
|
}
|
|
5532
5681
|
options;
|
|
5533
5682
|
projectId;
|
|
5534
5683
|
blobs;
|
|
5535
5684
|
dir;
|
|
5685
|
+
foldsCase;
|
|
5686
|
+
folder;
|
|
5536
5687
|
home;
|
|
5537
5688
|
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5538
5689
|
tracked = /* @__PURE__ */ new Map();
|
|
5539
5690
|
windows = [];
|
|
5540
5691
|
outside = null;
|
|
5541
|
-
/** A coalescing claim, open until another key, its idle timer,
|
|
5692
|
+
/** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
|
|
5542
5693
|
claimed = null;
|
|
5543
5694
|
writeToken;
|
|
5544
5695
|
quietTimer;
|
|
@@ -5546,6 +5697,12 @@ var Engine = class {
|
|
|
5546
5697
|
notedTimer = null;
|
|
5547
5698
|
listeners = /* @__PURE__ */ new Set();
|
|
5548
5699
|
tail = Promise.resolve();
|
|
5700
|
+
closed = false;
|
|
5701
|
+
closing;
|
|
5702
|
+
/** A path as the project's disk compares names: folded where `A` and `a` are one name. */
|
|
5703
|
+
nameKey(path) {
|
|
5704
|
+
return this.foldsCase ? path.toLowerCase() : path;
|
|
5705
|
+
}
|
|
5549
5706
|
get logFile() {
|
|
5550
5707
|
return join19(this.home, "log.jsonl");
|
|
5551
5708
|
}
|
|
@@ -5554,10 +5711,37 @@ var Engine = class {
|
|
|
5554
5711
|
}
|
|
5555
5712
|
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
5556
5713
|
queue(task) {
|
|
5714
|
+
if (this.closed)
|
|
5715
|
+
return Promise.reject(new HistoryClosedError("This project's history is closed."));
|
|
5557
5716
|
const run = this.tail.then(task);
|
|
5558
5717
|
this.tail = run.catch(() => void 0);
|
|
5559
5718
|
return run;
|
|
5560
5719
|
}
|
|
5720
|
+
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
|
|
5721
|
+
whereFolder() {
|
|
5722
|
+
const now = statSync9(this.dir, { throwIfNoEntry: false });
|
|
5723
|
+
if (!now) return "gone";
|
|
5724
|
+
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
|
|
5725
|
+
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
|
|
5726
|
+
}
|
|
5727
|
+
assertWritable() {
|
|
5728
|
+
const folder = this.whereFolder();
|
|
5729
|
+
if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
|
|
5730
|
+
if (folder === "replaced") throw this.replaced();
|
|
5731
|
+
}
|
|
5732
|
+
persistLog(record) {
|
|
5733
|
+
if (record) saveRecord(this.logFile, this.log, record);
|
|
5734
|
+
else writeLog(this.logFile, this.log);
|
|
5735
|
+
if (!existsSync12(join19(this.home, "project.json")))
|
|
5736
|
+
recordProject(this.home, this.dir, this.folder);
|
|
5737
|
+
}
|
|
5738
|
+
replaced() {
|
|
5739
|
+
return new HistoryClosedError(`${this.dir} is now another project.`);
|
|
5740
|
+
}
|
|
5741
|
+
assertOpen() {
|
|
5742
|
+
if (this.closed) throw new HistoryClosedError("This project's history is closed.");
|
|
5743
|
+
if (this.whereFolder() === "replaced") throw this.replaced();
|
|
5744
|
+
}
|
|
5561
5745
|
async start() {
|
|
5562
5746
|
const log = readLog(
|
|
5563
5747
|
this.logFile,
|
|
@@ -5574,9 +5758,7 @@ var Engine = class {
|
|
|
5574
5758
|
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
5575
5759
|
}
|
|
5576
5760
|
this.reopenClosedWindow();
|
|
5577
|
-
await this.
|
|
5578
|
-
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5579
|
-
await this.commitOutside();
|
|
5761
|
+
await this.settleAll();
|
|
5580
5762
|
}
|
|
5581
5763
|
reopenClosedWindow() {
|
|
5582
5764
|
const closed = this.options.closedWindow;
|
|
@@ -5586,13 +5768,13 @@ var Engine = class {
|
|
|
5586
5768
|
}
|
|
5587
5769
|
async firstOpen() {
|
|
5588
5770
|
const sweptAt = Date.now();
|
|
5589
|
-
for (const file of listProjectFiles(this.dir))
|
|
5590
|
-
this.
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5771
|
+
for (const file of listProjectFiles(this.dir)) {
|
|
5772
|
+
const hash = await this.storeIfPresent(file.path);
|
|
5773
|
+
if (this.whereFolder() !== "here") throw this.replaced();
|
|
5774
|
+
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
|
|
5775
|
+
}
|
|
5594
5776
|
this.log.baseline = this.manifest();
|
|
5595
|
-
|
|
5777
|
+
this.persistLog();
|
|
5596
5778
|
this.saveStatCache();
|
|
5597
5779
|
}
|
|
5598
5780
|
manifest() {
|
|
@@ -5616,19 +5798,32 @@ var Engine = class {
|
|
|
5616
5798
|
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
5617
5799
|
*/
|
|
5618
5800
|
async sweep() {
|
|
5619
|
-
if (
|
|
5801
|
+
if (this.whereFolder() !== "here") return;
|
|
5620
5802
|
const sweptAt = Date.now();
|
|
5621
5803
|
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;
|
|
5804
|
+
const seen = listProjectFiles(this.dir);
|
|
5626
5805
|
const present = new Set(seen.map((file) => file.path));
|
|
5627
|
-
|
|
5628
|
-
|
|
5806
|
+
const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
|
|
5807
|
+
const standing = this.standingAt(path);
|
|
5808
|
+
return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
|
|
5809
|
+
});
|
|
5810
|
+
const appearedAt = (file) => Math.max(
|
|
5811
|
+
changedAt(file),
|
|
5812
|
+
...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
|
|
5813
|
+
);
|
|
5814
|
+
const events = [
|
|
5815
|
+
...removed,
|
|
5816
|
+
...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
|
|
5817
|
+
].sort((a, b) => a.at - b.at);
|
|
5818
|
+
let changed = false;
|
|
5819
|
+
for (const { at, path, file } of events) {
|
|
5820
|
+
if (file) {
|
|
5821
|
+
changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
|
|
5822
|
+
continue;
|
|
5823
|
+
}
|
|
5824
|
+
const known = this.tracked.get(path);
|
|
5629
5825
|
this.tracked.delete(path);
|
|
5630
|
-
|
|
5631
|
-
await this.record(path, known.hash, null, removedAt);
|
|
5826
|
+
await this.record(path, known.hash, null, at);
|
|
5632
5827
|
changed = true;
|
|
5633
5828
|
}
|
|
5634
5829
|
if (changed) this.saveStatCache();
|
|
@@ -5646,7 +5841,7 @@ var Engine = class {
|
|
|
5646
5841
|
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
5647
5842
|
if (stat2 && known.stat === stat2) return false;
|
|
5648
5843
|
const hash = await this.storeIfPresent(path);
|
|
5649
|
-
if (hash === null) return false;
|
|
5844
|
+
if (hash === null || this.whereFolder() !== "here") return false;
|
|
5650
5845
|
this.tracked.set(path, { hash, stat: stat2 });
|
|
5651
5846
|
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
|
5652
5847
|
return known.hash !== hash || known.stat !== stat2;
|
|
@@ -5662,28 +5857,34 @@ var Engine = class {
|
|
|
5662
5857
|
/** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
|
|
5663
5858
|
async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
|
|
5664
5859
|
await this.sweep();
|
|
5665
|
-
const
|
|
5666
|
-
if (!taken.length)
|
|
5667
|
-
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
5668
|
-
return null;
|
|
5669
|
-
}
|
|
5860
|
+
const taken = await this.takeClaimed(who, paths, overwrote);
|
|
5861
|
+
if (!taken.length) return this.claimNothing(coalesceKey);
|
|
5670
5862
|
const held = this.heldFor(coalesceKey, taken);
|
|
5671
5863
|
if (!held) await this.commitClaim();
|
|
5672
|
-
await this.
|
|
5864
|
+
await this.commitPending();
|
|
5673
5865
|
const group = held ?? this.newGroup(who, label);
|
|
5674
5866
|
for (const change of taken) addChange(group, change.path, change.before, change.after);
|
|
5675
5867
|
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
|
|
5676
5868
|
const entry = await this.commit(group);
|
|
5677
5869
|
return entry && { id: entry.id };
|
|
5678
5870
|
}
|
|
5679
|
-
|
|
5680
|
-
|
|
5871
|
+
/** A claim that took nothing still ends a held claim under another key. */
|
|
5872
|
+
async claimNothing(coalesceKey) {
|
|
5873
|
+
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
5874
|
+
return null;
|
|
5875
|
+
}
|
|
5876
|
+
/** Commits every other writer's pending changes, oldest first; open windows stay open. */
|
|
5877
|
+
async commitPending() {
|
|
5681
5878
|
await this.commitOutside();
|
|
5682
|
-
for (const
|
|
5879
|
+
for (const window of [...this.windows])
|
|
5880
|
+
if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
|
|
5881
|
+
}
|
|
5882
|
+
pendingElsewhere() {
|
|
5883
|
+
return Boolean(this.outside?.changes.size) || this.windows.some((open) => open.changes.size > 0);
|
|
5683
5884
|
}
|
|
5684
5885
|
/** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
|
|
5685
5886
|
heldFor(key, taken) {
|
|
5686
|
-
const group = key && this.claimed?.key === key ? this.claimed.group : null;
|
|
5887
|
+
const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
|
|
5687
5888
|
const after = (path, before) => {
|
|
5688
5889
|
const own = group?.changes.get(path);
|
|
5689
5890
|
return own ? own.after : before;
|
|
@@ -5693,10 +5894,10 @@ var Engine = class {
|
|
|
5693
5894
|
return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
|
|
5694
5895
|
}
|
|
5695
5896
|
logPath(path) {
|
|
5696
|
-
return
|
|
5897
|
+
return relative5(this.dir, resolve7(this.dir, path)).split(sep4).join("/");
|
|
5697
5898
|
}
|
|
5698
|
-
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote
|
|
5699
|
-
takeClaimed(who, paths, overwrote) {
|
|
5899
|
+
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
|
|
5900
|
+
async takeClaimed(who, paths, overwrote) {
|
|
5700
5901
|
const wanted = new Set(paths.map((path) => this.logPath(path)));
|
|
5701
5902
|
const at = new Map(
|
|
5702
5903
|
Object.entries(overwrote).map(([path, version]) => [
|
|
@@ -5706,23 +5907,50 @@ var Engine = class {
|
|
|
5706
5907
|
);
|
|
5707
5908
|
const others = this.windows.filter((open) => !sameWho(open.who, who));
|
|
5708
5909
|
const groups = this.outside ? [this.outside, ...others] : others;
|
|
5709
|
-
const
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
|
|
5713
|
-
|
|
5714
|
-
|
|
5715
|
-
|
|
5716
|
-
|
|
5717
|
-
|
|
5910
|
+
const hits = groups.flatMap(
|
|
5911
|
+
(group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
|
|
5912
|
+
);
|
|
5913
|
+
const cuts = [];
|
|
5914
|
+
const walked = hits.map(() => /* @__PURE__ */ new Set());
|
|
5915
|
+
for (const [i, { change }] of hits.entries()) {
|
|
5916
|
+
const told = at.get(change.path);
|
|
5917
|
+
cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
|
|
5918
|
+
}
|
|
5919
|
+
hits.forEach(
|
|
5920
|
+
({ change }, i) => forgetOverwrittenBytes(
|
|
5921
|
+
join19(this.dir, change.path),
|
|
5922
|
+
new Set([...walked[i]].map(hashVersion))
|
|
5923
|
+
)
|
|
5924
|
+
);
|
|
5925
|
+
return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
|
|
5926
|
+
}
|
|
5927
|
+
/** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
|
|
5928
|
+
async overwrittenBy(change, told, seen) {
|
|
5929
|
+
const absPath = join19(this.dir, change.path);
|
|
5930
|
+
let bytes;
|
|
5931
|
+
for (let hash = change.after; hash; ) {
|
|
5932
|
+
if (seen.has(hash)) return void 0;
|
|
5933
|
+
seen.add(hash);
|
|
5934
|
+
const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
|
|
5935
|
+
if (replaced === void 0) break;
|
|
5936
|
+
bytes = replaced;
|
|
5937
|
+
hash = hashOfVersion(fileContentVersion(replaced));
|
|
5938
|
+
if (hash === change.before || hash === told) return hash;
|
|
5939
|
+
}
|
|
5940
|
+
if (bytes === void 0) return void 0;
|
|
5941
|
+
const staged = join19(this.home, `overwrote-${randomUUID8()}`);
|
|
5942
|
+
await writeFile(staged, bytes);
|
|
5943
|
+
try {
|
|
5944
|
+
return await this.blobs.put(staged);
|
|
5945
|
+
} finally {
|
|
5946
|
+
await rm2(staged, { force: true });
|
|
5718
5947
|
}
|
|
5719
|
-
return { taken, cut };
|
|
5720
5948
|
}
|
|
5721
5949
|
cutOut(group, change, cut) {
|
|
5722
5950
|
const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
|
|
5723
5951
|
group.changes.delete(change.path);
|
|
5724
5952
|
if (kept) group.changes.set(kept.path, kept);
|
|
5725
|
-
return
|
|
5953
|
+
return claimed;
|
|
5726
5954
|
}
|
|
5727
5955
|
/** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
|
|
5728
5956
|
* so the writer's next write to a cut file would otherwise span the claimer's edit. */
|
|
@@ -5736,8 +5964,8 @@ var Engine = class {
|
|
|
5736
5964
|
}
|
|
5737
5965
|
holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
|
|
5738
5966
|
clearTimeout(this.claimed?.timer);
|
|
5739
|
-
const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
|
|
5740
|
-
timer
|
|
5967
|
+
const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
|
|
5968
|
+
timer?.unref?.();
|
|
5741
5969
|
this.claimed = { group, key, timer };
|
|
5742
5970
|
return group.changes.size ? { id: group.id } : null;
|
|
5743
5971
|
}
|
|
@@ -5767,6 +5995,7 @@ var Engine = class {
|
|
|
5767
5995
|
return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
5768
5996
|
}
|
|
5769
5997
|
async commitOutside() {
|
|
5998
|
+
if (this.outside?.changes.size) await this.commitClaim();
|
|
5770
5999
|
clearTimeout(this.quietTimer);
|
|
5771
6000
|
clearTimeout(this.maxTimer);
|
|
5772
6001
|
const group = this.outside;
|
|
@@ -5775,14 +6004,12 @@ var Engine = class {
|
|
|
5775
6004
|
}
|
|
5776
6005
|
async commit(group, extra = {}) {
|
|
5777
6006
|
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 };
|
|
6007
|
+
const pending = this.pendingEntry(group);
|
|
6008
|
+
const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
|
|
6009
|
+
const entry = { ...pending, endedAt, ...extra };
|
|
5783
6010
|
this.log.entries.push(entry);
|
|
5784
6011
|
try {
|
|
5785
|
-
|
|
6012
|
+
this.persistLog({ type: "entry", entry });
|
|
5786
6013
|
} catch (error) {
|
|
5787
6014
|
this.log.entries.pop();
|
|
5788
6015
|
throw error;
|
|
@@ -5803,15 +6030,24 @@ var Engine = class {
|
|
|
5803
6030
|
let folded = false;
|
|
5804
6031
|
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
5805
6032
|
folded = true;
|
|
5806
|
-
await this.blobs.prune(
|
|
6033
|
+
await this.blobs.prune(
|
|
6034
|
+
/* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
|
|
6035
|
+
);
|
|
5807
6036
|
}
|
|
5808
|
-
if (folded)
|
|
6037
|
+
if (folded) this.persistLog();
|
|
6038
|
+
}
|
|
6039
|
+
/** Hashes only pending changes point at yet, such as a claim's stored cut. */
|
|
6040
|
+
pendingHashes() {
|
|
6041
|
+
const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
|
|
6042
|
+
...group?.changes.values() ?? []
|
|
6043
|
+
]);
|
|
6044
|
+
return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
|
|
5809
6045
|
}
|
|
5810
|
-
/** Before an operation: every write
|
|
6046
|
+
/** Before an operation or a window: every write is filed and every pending change committed, in write order. */
|
|
5811
6047
|
async settle() {
|
|
5812
6048
|
await this.sweep();
|
|
5813
6049
|
await this.commitClaim();
|
|
5814
|
-
await this.
|
|
6050
|
+
await this.commitPending();
|
|
5815
6051
|
}
|
|
5816
6052
|
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
5817
6053
|
noteChange(path) {
|
|
@@ -5823,11 +6059,13 @@ var Engine = class {
|
|
|
5823
6059
|
this.notedTimer.unref?.();
|
|
5824
6060
|
}
|
|
5825
6061
|
background(task) {
|
|
5826
|
-
this.queue(task).catch((error) =>
|
|
6062
|
+
this.queue(task).catch((error) => {
|
|
6063
|
+
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
|
|
6064
|
+
});
|
|
5827
6065
|
}
|
|
5828
6066
|
beginWindow(who, label, idleMs) {
|
|
5829
6067
|
return this.queue(async () => {
|
|
5830
|
-
await this.
|
|
6068
|
+
await this.settle();
|
|
5831
6069
|
const window = { ...this.newGroup(who, label), idleMs };
|
|
5832
6070
|
this.windows.push(window);
|
|
5833
6071
|
this.touch(window, Date.now());
|
|
@@ -5854,6 +6092,7 @@ var Engine = class {
|
|
|
5854
6092
|
async endWindow(window) {
|
|
5855
6093
|
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
5856
6094
|
clearTimeout(window.idleTimer);
|
|
6095
|
+
await this.commitClaim();
|
|
5857
6096
|
this.windows = this.windows.filter((open) => open !== window);
|
|
5858
6097
|
window.entry = await this.commit(window) ?? window.entry ?? null;
|
|
5859
6098
|
return window.entry;
|
|
@@ -5865,15 +6104,39 @@ var Engine = class {
|
|
|
5865
6104
|
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5866
6105
|
await this.commitOutside();
|
|
5867
6106
|
}
|
|
6107
|
+
/** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
|
|
6108
|
+
standingAt(path) {
|
|
6109
|
+
const parts = path.split("/");
|
|
6110
|
+
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
6111
|
+
const at = parts.slice(0, depth).join("/");
|
|
6112
|
+
const stat2 = lstatSync4(join19(this.dir, at), { throwIfNoEntry: false });
|
|
6113
|
+
if (!stat2) return void 0;
|
|
6114
|
+
if (at === path || !stat2.isDirectory()) return { at, stat: stat2 };
|
|
6115
|
+
}
|
|
6116
|
+
return void 0;
|
|
6117
|
+
}
|
|
6118
|
+
/** 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. */
|
|
6119
|
+
inTheWay(path, deleted) {
|
|
6120
|
+
const standing = this.standingAt(path);
|
|
6121
|
+
if (!standing) return void 0;
|
|
6122
|
+
if (standing.stat.isSymbolicLink()) return standing.at;
|
|
6123
|
+
if (standing.at === path && !standing.stat.isDirectory()) return void 0;
|
|
6124
|
+
const real = realpathSync3.native(join19(this.dir, standing.at));
|
|
6125
|
+
const at = relative5(realpathSync3.native(this.dir), real).split(sep4).join("/");
|
|
6126
|
+
if (standing.at !== path) return deleted.has(at) ? void 0 : at;
|
|
6127
|
+
return fileLeftIn(real, at, deleted);
|
|
6128
|
+
}
|
|
5868
6129
|
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
5869
6130
|
async writeAs(who, label, target, extra) {
|
|
6131
|
+
this.assertWritable();
|
|
6132
|
+
const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
|
|
6133
|
+
const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
|
|
6134
|
+
const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
|
|
6135
|
+
if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
|
|
5870
6136
|
const group = this.newGroup(who, label);
|
|
5871
6137
|
this.windows.push(group);
|
|
5872
6138
|
try {
|
|
5873
|
-
for (const [path, hash] of
|
|
5874
|
-
if ((this.tracked.get(path)?.hash ?? null) !== hash)
|
|
5875
|
-
await this.writeProjectFile(path, hash);
|
|
5876
|
-
}
|
|
6139
|
+
for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
|
|
5877
6140
|
await this.sweep();
|
|
5878
6141
|
} finally {
|
|
5879
6142
|
this.windows = this.windows.filter((open) => open !== group);
|
|
@@ -5882,14 +6145,20 @@ var Engine = class {
|
|
|
5882
6145
|
}
|
|
5883
6146
|
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
|
|
5884
6147
|
async writeProjectFile(path, hash) {
|
|
6148
|
+
this.assertWritable();
|
|
5885
6149
|
const absPath = join19(this.dir, path);
|
|
5886
6150
|
const { writeToken } = this;
|
|
5887
6151
|
if (writeToken) {
|
|
5888
6152
|
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
|
|
5889
6153
|
recordFileWriteReceipt(absPath, { path, version, writeToken });
|
|
5890
6154
|
}
|
|
5891
|
-
if (hash === null)
|
|
5892
|
-
|
|
6155
|
+
if (hash === null) {
|
|
6156
|
+
this.assertWritable();
|
|
6157
|
+
rmSync6(absPath, { force: true });
|
|
6158
|
+
} else {
|
|
6159
|
+
await removeEmptyFolders(absPath);
|
|
6160
|
+
await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
|
|
6161
|
+
}
|
|
5893
6162
|
}
|
|
5894
6163
|
entry(id) {
|
|
5895
6164
|
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
@@ -5913,7 +6182,9 @@ var Engine = class {
|
|
|
5913
6182
|
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
5914
6183
|
};
|
|
5915
6184
|
}
|
|
5916
|
-
const target = new Map(
|
|
6185
|
+
const target = new Map(
|
|
6186
|
+
entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
|
|
6187
|
+
);
|
|
5917
6188
|
return {
|
|
5918
6189
|
ok: true,
|
|
5919
6190
|
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
@@ -5922,15 +6193,19 @@ var Engine = class {
|
|
|
5922
6193
|
movedOn(entry) {
|
|
5923
6194
|
return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
|
|
5924
6195
|
}
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
if (
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
6196
|
+
next(direction, who) {
|
|
6197
|
+
const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
|
|
6198
|
+
const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
|
|
6199
|
+
(group) => group?.changes.size && mine(group.who)
|
|
6200
|
+
);
|
|
6201
|
+
if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
|
|
6202
|
+
return stepTarget(this.log.entries, direction, (entry) => mine(entry.who));
|
|
6203
|
+
}
|
|
6204
|
+
/** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
|
|
6205
|
+
pendingEntry(group) {
|
|
6206
|
+
const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
6207
|
+
const { id, who, label, startedAt, lastWriteAt } = group;
|
|
6208
|
+
return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
|
|
5934
6209
|
}
|
|
5935
6210
|
conflict(entry, changed) {
|
|
5936
6211
|
const paths = new Set(changed.map((file) => file.path));
|
|
@@ -5967,8 +6242,11 @@ var Engine = class {
|
|
|
5967
6242
|
projectId: this.projectId,
|
|
5968
6243
|
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
5969
6244
|
claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
|
|
5970
|
-
noteChange: (path) =>
|
|
6245
|
+
noteChange: (path) => {
|
|
6246
|
+
if (!this.closed) this.noteChange(path);
|
|
6247
|
+
},
|
|
5971
6248
|
list: () => {
|
|
6249
|
+
this.assertOpen();
|
|
5972
6250
|
const undone = undoneIds(this.log.entries);
|
|
5973
6251
|
return this.log.entries.map((entry) => ({
|
|
5974
6252
|
...entry,
|
|
@@ -5977,7 +6255,7 @@ var Engine = class {
|
|
|
5977
6255
|
}));
|
|
5978
6256
|
},
|
|
5979
6257
|
step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
|
|
5980
|
-
const target = this.next(direction);
|
|
6258
|
+
const target = this.next(direction, who);
|
|
5981
6259
|
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
5982
6260
|
}),
|
|
5983
6261
|
undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
|
|
@@ -5986,30 +6264,76 @@ var Engine = class {
|
|
|
5986
6264
|
() => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
|
|
5987
6265
|
),
|
|
5988
6266
|
peek: (point) => {
|
|
6267
|
+
this.assertOpen();
|
|
5989
6268
|
const files = manifestAt(this.log, point);
|
|
5990
6269
|
return files && Object.fromEntries(files);
|
|
5991
6270
|
},
|
|
5992
|
-
|
|
5993
|
-
|
|
6271
|
+
checkout: (entryId, side, emptyDir) => this.queue(async () => {
|
|
6272
|
+
this.entry(entryId);
|
|
6273
|
+
if (!existsSync12(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
|
|
6274
|
+
const dest = resolve7(emptyDir);
|
|
6275
|
+
if (isWithin(this.dir, dest))
|
|
6276
|
+
throw new Error(`Checkout writes outside the project only: ${dest}`);
|
|
6277
|
+
if ((await readdir2(dest).catch(missingIsEmpty)).length > 0)
|
|
6278
|
+
throw new Error(`Checkout writes into an empty folder only: ${dest}`);
|
|
6279
|
+
const files = manifestAround(this.log, entryId, side);
|
|
6280
|
+
try {
|
|
6281
|
+
for (const [path, hash] of files) await this.blobs.writeTo(hash, join19(dest, path));
|
|
6282
|
+
} catch (error) {
|
|
6283
|
+
for (const name of await readdir2(dest).catch(() => []))
|
|
6284
|
+
await rm2(join19(dest, name), { recursive: true, force: true });
|
|
6285
|
+
throw error;
|
|
6286
|
+
}
|
|
6287
|
+
}),
|
|
6288
|
+
next: (direction, who) => {
|
|
6289
|
+
this.assertOpen();
|
|
6290
|
+
return this.next(direction, who);
|
|
6291
|
+
},
|
|
6292
|
+
readBlob: async (hash) => {
|
|
6293
|
+
this.assertOpen();
|
|
6294
|
+
return this.blobs.read(hash);
|
|
6295
|
+
},
|
|
5994
6296
|
pin: (id, pinned) => {
|
|
6297
|
+
this.assertOpen();
|
|
5995
6298
|
this.entry(id);
|
|
5996
6299
|
if (pinned) this.log.pins.add(id);
|
|
5997
6300
|
else this.log.pins.delete(id);
|
|
5998
|
-
|
|
6301
|
+
this.persistLog({ type: "pin", id, pinned });
|
|
5999
6302
|
},
|
|
6000
6303
|
onEntry: (listener) => {
|
|
6001
6304
|
this.listeners.add(listener);
|
|
6002
6305
|
return () => this.listeners.delete(listener);
|
|
6003
6306
|
},
|
|
6004
6307
|
flush: () => this.queue(() => this.settleAll()),
|
|
6005
|
-
|
|
6308
|
+
replacedAtPath: () => this.whereFolder() === "replaced",
|
|
6309
|
+
close: () => this.closing ??= (async () => {
|
|
6006
6310
|
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
6007
|
-
|
|
6008
|
-
|
|
6311
|
+
const settled = this.queue(() => this.settleAll());
|
|
6312
|
+
this.closed = true;
|
|
6313
|
+
await settled;
|
|
6314
|
+
})()
|
|
6009
6315
|
};
|
|
6010
6316
|
}
|
|
6011
6317
|
};
|
|
6318
|
+
function isWithin(dir, path) {
|
|
6319
|
+
let probe = resolve7(path);
|
|
6320
|
+
while (!existsSync12(probe) && dirname9(probe) !== probe) probe = dirname9(probe);
|
|
6321
|
+
const rel = relative5(realpathSync3.native(dir), realpathSync3.native(probe));
|
|
6322
|
+
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep4}`) && !isAbsolute3(rel);
|
|
6323
|
+
}
|
|
6324
|
+
function missingIsEmpty(error) {
|
|
6325
|
+
if (error.code === "ENOENT") return [];
|
|
6326
|
+
throw error;
|
|
6327
|
+
}
|
|
6328
|
+
var HistoryClosedError = class extends Error {
|
|
6329
|
+
constructor(message) {
|
|
6330
|
+
super(message);
|
|
6331
|
+
this.name = "HistoryClosedError";
|
|
6332
|
+
}
|
|
6333
|
+
};
|
|
6012
6334
|
async function openProjectHistory(options) {
|
|
6335
|
+
if (!existsSync12(options.projectDir))
|
|
6336
|
+
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
|
|
6013
6337
|
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
6014
6338
|
const release = await takeHistoryOwnership(
|
|
6015
6339
|
join19(options.historyRoot, projectId),
|
|
@@ -6062,7 +6386,7 @@ function versionsOf(value) {
|
|
|
6062
6386
|
return pairs.length ? Object.fromEntries(pairs) : void 0;
|
|
6063
6387
|
}
|
|
6064
6388
|
function nextStep(history, direction) {
|
|
6065
|
-
const target = history.next(direction);
|
|
6389
|
+
const target = history.next(direction, YOU);
|
|
6066
6390
|
if (!target) return null;
|
|
6067
6391
|
const paths = target.files.map((file) => file.path);
|
|
6068
6392
|
return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
|
|
@@ -6097,7 +6421,7 @@ function registerHistoryRoutes(api, adapter) {
|
|
|
6097
6421
|
api.post(
|
|
6098
6422
|
`${base}/undo`,
|
|
6099
6423
|
(c) => withHistory(adapter, c, (history, body) => {
|
|
6100
|
-
const mode =
|
|
6424
|
+
const mode = UNDO_MODES.find((known) => known === body.mode);
|
|
6101
6425
|
return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
|
|
6102
6426
|
})
|
|
6103
6427
|
);
|
|
@@ -6133,11 +6457,9 @@ function registerHistoryRoutes(api, adapter) {
|
|
|
6133
6457
|
(c) => withHistory(adapter, c, async (history, body) => {
|
|
6134
6458
|
const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
|
|
6135
6459
|
const coalesceKey = text(body.coalesceKey) ?? void 0;
|
|
6136
|
-
const idleMs = idleOf(body);
|
|
6137
6460
|
const overwrote = versionsOf(body.overwrote);
|
|
6138
6461
|
const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
|
|
6139
|
-
...coalesceKey && { coalesceKey },
|
|
6140
|
-
...idleMs && { idleMs },
|
|
6462
|
+
...coalesceKey && { coalesceKey, idleMs: idleOf(body) ?? Infinity },
|
|
6141
6463
|
...overwrote && { overwrote }
|
|
6142
6464
|
});
|
|
6143
6465
|
return { claimed };
|
|
@@ -6187,6 +6509,30 @@ function createStudioApi(adapter) {
|
|
|
6187
6509
|
return api;
|
|
6188
6510
|
}
|
|
6189
6511
|
|
|
6512
|
+
// src/history/historyCache.ts
|
|
6513
|
+
function historyCache(open) {
|
|
6514
|
+
const histories = /* @__PURE__ */ new Map();
|
|
6515
|
+
const get = async (projectDir) => {
|
|
6516
|
+
let cached = histories.get(projectDir);
|
|
6517
|
+
if (!cached) histories.set(projectDir, cached = open(projectDir));
|
|
6518
|
+
const opened = await cached;
|
|
6519
|
+
if (!opened?.replacedAtPath()) return opened;
|
|
6520
|
+
const reopen = () => open(projectDir);
|
|
6521
|
+
if (histories.get(projectDir) === cached)
|
|
6522
|
+
histories.set(projectDir, opened.close().then(reopen, reopen));
|
|
6523
|
+
return get(projectDir);
|
|
6524
|
+
};
|
|
6525
|
+
return {
|
|
6526
|
+
get,
|
|
6527
|
+
/** The history if one was opened, without opening it. */
|
|
6528
|
+
peek: (projectDir) => histories.get(projectDir),
|
|
6529
|
+
forget: (projectDir) => histories.delete(projectDir),
|
|
6530
|
+
closeAll: () => Promise.all(
|
|
6531
|
+
[...histories.values()].map((opened) => opened.then((history) => history?.close()))
|
|
6532
|
+
)
|
|
6533
|
+
};
|
|
6534
|
+
}
|
|
6535
|
+
|
|
6190
6536
|
// src/helpers/duplicateElement.ts
|
|
6191
6537
|
import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
|
|
6192
6538
|
function duplicateElementInHtml(source, target, newId, at) {
|
|
@@ -6240,7 +6586,7 @@ function numericAttribute(element, name) {
|
|
|
6240
6586
|
|
|
6241
6587
|
// src/helpers/previewDocumentStore.ts
|
|
6242
6588
|
import { createHash as createHash7 } from "crypto";
|
|
6243
|
-
import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as
|
|
6589
|
+
import { mkdirSync as mkdirSync12, readFileSync as readFileSync19, readdirSync as readdirSync8, rmSync as rmSync7 } from "fs";
|
|
6244
6590
|
import { join as join20 } from "path";
|
|
6245
6591
|
function createPreviewDocumentStore(dir, salt) {
|
|
6246
6592
|
const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
|
|
@@ -6258,8 +6604,8 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
|
|
|
6258
6604
|
mkdirSync12(dir, { recursive: true });
|
|
6259
6605
|
const file = fileFor(key);
|
|
6260
6606
|
replaceFileAtomically(file, html, 420);
|
|
6261
|
-
for (const name of
|
|
6262
|
-
if (join20(dir, name) !== file)
|
|
6607
|
+
for (const name of readdirSync8(dir)) {
|
|
6608
|
+
if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
|
|
6263
6609
|
}
|
|
6264
6610
|
} catch {
|
|
6265
6611
|
}
|
|
@@ -6339,11 +6685,13 @@ export {
|
|
|
6339
6685
|
DELETED_VERSION,
|
|
6340
6686
|
START as HISTORY_START,
|
|
6341
6687
|
HistoryBusyError,
|
|
6688
|
+
HistoryClosedError,
|
|
6342
6689
|
MAX_WINDOW_IDLE_MS,
|
|
6343
6690
|
MIME_TYPES,
|
|
6344
6691
|
PREVIEW_BUNDLE_OPTIONS,
|
|
6345
6692
|
STUDIO_MANUAL_EDITS_PATH,
|
|
6346
6693
|
STUDIO_MOTION_PATH,
|
|
6694
|
+
UNDO_MODES,
|
|
6347
6695
|
affectsProjectSignature,
|
|
6348
6696
|
applyFileMutations,
|
|
6349
6697
|
buildSubCompositionHtml,
|
|
@@ -6362,6 +6710,7 @@ export {
|
|
|
6362
6710
|
findTargetElement,
|
|
6363
6711
|
getElementScreenshotClip,
|
|
6364
6712
|
getMimeType,
|
|
6713
|
+
historyCache,
|
|
6365
6714
|
identifyFileWrite,
|
|
6366
6715
|
isSafePath,
|
|
6367
6716
|
openProjectHistory,
|