@hyperframes/studio-server 0.8.77 → 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",
|
|
@@ -72,9 +88,18 @@ function isInHiddenOrVendorDir(relPath) {
|
|
|
72
88
|
const segments = relPath.split("/");
|
|
73
89
|
return segments.slice(0, -1).some((seg) => seg.startsWith(".") || seg === "node_modules");
|
|
74
90
|
}
|
|
91
|
+
var UNREADABLE_DIR_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "ENOENT", "ENOTDIR"]);
|
|
75
92
|
function walkDir(dir, prefix = "") {
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
96
|
+
} catch (err) {
|
|
97
|
+
const code = err.code;
|
|
98
|
+
if (!prefix || !code || !UNREADABLE_DIR_CODES.has(code)) throw err;
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
76
101
|
const files = [];
|
|
77
|
-
for (const entry of
|
|
102
|
+
for (const entry of entries) {
|
|
78
103
|
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
79
104
|
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
|
|
80
105
|
if (entry.isDirectory()) {
|
|
@@ -98,7 +123,7 @@ import {
|
|
|
98
123
|
readFileSync,
|
|
99
124
|
writeFileSync
|
|
100
125
|
} from "fs";
|
|
101
|
-
import { dirname, join as join2 } from "path";
|
|
126
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
102
127
|
|
|
103
128
|
// src/helpers/atomicFile.ts
|
|
104
129
|
import * as fs from "fs";
|
|
@@ -148,7 +173,7 @@ function stampProjectHfIds(projectDir) {
|
|
|
148
173
|
next[file] = hash;
|
|
149
174
|
}
|
|
150
175
|
try {
|
|
151
|
-
mkdirSync(
|
|
176
|
+
mkdirSync(dirname2(recordPath), { recursive: true });
|
|
152
177
|
writeFileSync(recordPath, JSON.stringify(next));
|
|
153
178
|
} catch {
|
|
154
179
|
}
|
|
@@ -161,6 +186,15 @@ function openNoFollow(filePath, flags) {
|
|
|
161
186
|
return null;
|
|
162
187
|
}
|
|
163
188
|
}
|
|
189
|
+
function isUnchanged(filePath, expected) {
|
|
190
|
+
const fd = openNoFollow(filePath, constants.O_RDONLY);
|
|
191
|
+
if (fd === null) return false;
|
|
192
|
+
try {
|
|
193
|
+
return readFileSync(fd, "utf-8") === expected;
|
|
194
|
+
} finally {
|
|
195
|
+
closeSync(fd);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
164
198
|
function stampFileHfIds(filePath) {
|
|
165
199
|
let fd = openNoFollow(filePath, constants.O_RDWR);
|
|
166
200
|
let writable = true;
|
|
@@ -179,7 +213,7 @@ function stampFileHfIds(filePath) {
|
|
|
179
213
|
const mode = fstatSync(fd).mode;
|
|
180
214
|
closeSync(fd);
|
|
181
215
|
fd = null;
|
|
182
|
-
replaceFileAtomically(filePath, normalized, mode);
|
|
216
|
+
if (isUnchanged(filePath, html)) replaceFileAtomically(filePath, normalized, mode);
|
|
183
217
|
}
|
|
184
218
|
return normalized;
|
|
185
219
|
} catch (err) {
|
|
@@ -193,7 +227,7 @@ function stampFileHfIds(filePath) {
|
|
|
193
227
|
// src/helpers/projectSignature.ts
|
|
194
228
|
import { createHash as createHash2 } from "crypto";
|
|
195
229
|
import { lstatSync, readFileSync as readFileSync2, readdirSync as readdirSync2 } from "fs";
|
|
196
|
-
import { extname, isAbsolute, relative, resolve, sep } from "path";
|
|
230
|
+
import { extname, isAbsolute as isAbsolute2, relative as relative2, resolve, sep as sep2 } from "path";
|
|
197
231
|
var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
198
232
|
".cjs",
|
|
199
233
|
".css",
|
|
@@ -228,18 +262,18 @@ var STUDIO_SIGNATURE_MANIFEST_PATHS = [
|
|
|
228
262
|
".hyperframes/studio-motion.json"
|
|
229
263
|
];
|
|
230
264
|
function affectsProjectSignature(projectDir, changedPath) {
|
|
231
|
-
const relativePath =
|
|
232
|
-
if (relativePath === "" || relativePath.startsWith("..") ||
|
|
265
|
+
const relativePath = relative2(resolve(projectDir), resolve(changedPath));
|
|
266
|
+
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute2(relativePath)) {
|
|
233
267
|
return false;
|
|
234
268
|
}
|
|
235
|
-
const segments = relativePath.split(
|
|
269
|
+
const segments = relativePath.split(sep2);
|
|
236
270
|
if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
|
|
237
271
|
return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
|
|
238
272
|
}
|
|
239
273
|
var projectSignatureCache = /* @__PURE__ */ new Map();
|
|
240
274
|
function isPathWithin(parentDir, childPath) {
|
|
241
|
-
const childRelativePath =
|
|
242
|
-
return childRelativePath === "" || !childRelativePath.startsWith("..") && !
|
|
275
|
+
const childRelativePath = relative2(parentDir, childPath);
|
|
276
|
+
return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute2(childRelativePath);
|
|
243
277
|
}
|
|
244
278
|
function isTextContentEligible(file, size) {
|
|
245
279
|
return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
|
|
@@ -300,7 +334,7 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
|
300
334
|
function createProjectFingerprint(projectDir, files) {
|
|
301
335
|
const hash = createHash2("sha256");
|
|
302
336
|
for (const entry of files) {
|
|
303
|
-
hash.update(
|
|
337
|
+
hash.update(relative2(projectDir, entry.file));
|
|
304
338
|
hash.update("\0");
|
|
305
339
|
hash.update(String(entry.size));
|
|
306
340
|
hash.update("\0");
|
|
@@ -322,7 +356,7 @@ function collectProjectFiles(normalizedProjectDir) {
|
|
|
322
356
|
function listProjectFiles(projectDir) {
|
|
323
357
|
const normalizedProjectDir = resolve(projectDir);
|
|
324
358
|
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
325
|
-
path:
|
|
359
|
+
path: relative2(normalizedProjectDir, entry.file).split(sep2).join("/"),
|
|
326
360
|
size: entry.size,
|
|
327
361
|
mtimeMs: entry.mtimeMs,
|
|
328
362
|
ctimeMs: entry.ctimeMs
|
|
@@ -340,7 +374,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
|
|
|
340
374
|
const normalizedProjectDir = resolve(projectDir);
|
|
341
375
|
const collected = collectProjectFiles(normalizedProjectDir);
|
|
342
376
|
const files = collected.filter(
|
|
343
|
-
(entry) => !excluding.has(
|
|
377
|
+
(entry) => !excluding.has(relative2(normalizedProjectDir, entry.file).split(sep2).join("/"))
|
|
344
378
|
);
|
|
345
379
|
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
346
380
|
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
|
@@ -349,7 +383,7 @@ function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set(
|
|
|
349
383
|
if (cached?.fingerprint === fingerprint) return cached.signature;
|
|
350
384
|
const hash = createHash2("sha256");
|
|
351
385
|
for (const entry of files) {
|
|
352
|
-
const relativePath =
|
|
386
|
+
const relativePath = relative2(normalizedProjectDir, entry.file);
|
|
353
387
|
hash.update(relativePath);
|
|
354
388
|
hash.update("\0");
|
|
355
389
|
hash.update(String(entry.size));
|
|
@@ -432,7 +466,7 @@ import {
|
|
|
432
466
|
renameSync as renameSync2,
|
|
433
467
|
readdirSync as readdirSync4
|
|
434
468
|
} from "fs";
|
|
435
|
-
import { resolve as resolve3, dirname as
|
|
469
|
+
import { resolve as resolve3, dirname as dirname5, join as join7 } from "path";
|
|
436
470
|
|
|
437
471
|
// src/helpers/mime.ts
|
|
438
472
|
var MIME_TYPES = {
|
|
@@ -492,7 +526,7 @@ import {
|
|
|
492
526
|
rmSync,
|
|
493
527
|
statSync
|
|
494
528
|
} from "fs";
|
|
495
|
-
import { dirname as
|
|
529
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
496
530
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
|
497
531
|
var SAMPLE_RATE = 4e3;
|
|
498
532
|
var PEAK_COUNT = 4e3;
|
|
@@ -572,7 +606,7 @@ function isWaveformCacheDirectory(cacheDir) {
|
|
|
572
606
|
return lstatSync2(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
573
607
|
}
|
|
574
608
|
function writeWaveformCache(cachePath, peaks) {
|
|
575
|
-
const cacheDir =
|
|
609
|
+
const cacheDir = dirname3(cachePath);
|
|
576
610
|
try {
|
|
577
611
|
mkdirSync2(cacheDir, { mode: 448 });
|
|
578
612
|
} catch (error) {
|
|
@@ -598,7 +632,7 @@ function writeWaveformCache(cachePath, peaks) {
|
|
|
598
632
|
import { spawnSync } from "child_process";
|
|
599
633
|
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
|
|
600
634
|
import { tmpdir } from "os";
|
|
601
|
-
import { basename, join as join5 } from "path";
|
|
635
|
+
import { basename as basename2, join as join5 } from "path";
|
|
602
636
|
var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
|
|
603
637
|
var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
604
638
|
function validateUploadedMedia(filePath, runner = spawnSync) {
|
|
@@ -636,7 +670,7 @@ function validateUploadedMedia(filePath, runner = spawnSync) {
|
|
|
636
670
|
}
|
|
637
671
|
function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
|
|
638
672
|
const tempDir = mkdtempSync2(join5(tmpdir(), "hyperframes-upload-"));
|
|
639
|
-
const tempPath = join5(tempDir,
|
|
673
|
+
const tempPath = join5(tempDir, basename2(fileName));
|
|
640
674
|
try {
|
|
641
675
|
writeFileSync3(tempPath, buffer);
|
|
642
676
|
return validateUploadedMedia(tempPath, runner);
|
|
@@ -648,7 +682,7 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = spawnSync) {
|
|
|
648
682
|
// src/helpers/backupJournal.ts
|
|
649
683
|
import { mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync3, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
|
|
650
684
|
import { Buffer as Buffer2 } from "buffer";
|
|
651
|
-
import { join as join6, relative as
|
|
685
|
+
import { join as join6, relative as relative3 } from "path";
|
|
652
686
|
var DEFAULT_KEEP_PER_FILE = 10;
|
|
653
687
|
function backupKeyForPath(path) {
|
|
654
688
|
return Buffer2.from(path, "utf-8").toString("base64url");
|
|
@@ -658,7 +692,7 @@ function timestampPrefix() {
|
|
|
658
692
|
}
|
|
659
693
|
function backupPathForResponse(projectDir, backupPath) {
|
|
660
694
|
if (!backupPath) return null;
|
|
661
|
-
const rel =
|
|
695
|
+
const rel = relative3(projectDir, backupPath);
|
|
662
696
|
if (!rel || rel.startsWith("..")) return null;
|
|
663
697
|
return rel.split("\\").join("/");
|
|
664
698
|
}
|
|
@@ -666,7 +700,7 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
|
666
700
|
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
|
|
667
701
|
try {
|
|
668
702
|
const content = readFileSync3(absPath);
|
|
669
|
-
const relativePath =
|
|
703
|
+
const relativePath = relative3(projectDir, absPath);
|
|
670
704
|
const backupDir = join6(projectDir, ".hyperframes", "backup");
|
|
671
705
|
mkdirSync3(backupDir, { recursive: true });
|
|
672
706
|
const backupKey = backupKeyForPath(relativePath);
|
|
@@ -735,22 +769,38 @@ function createWriteToken(requestToken) {
|
|
|
735
769
|
const token = requestToken?.trim();
|
|
736
770
|
return token && token.length <= 200 ? token : randomUUID2();
|
|
737
771
|
}
|
|
738
|
-
function recordFileWriteReceipt(
|
|
772
|
+
function recordFileWriteReceipt(filePath, receipt) {
|
|
773
|
+
const absPath = realFilePath(filePath);
|
|
739
774
|
const now = Date.now();
|
|
740
|
-
const
|
|
741
|
-
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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 }]);
|
|
745
781
|
}
|
|
746
|
-
function clearFileWriteReceipt(
|
|
782
|
+
function clearFileWriteReceipt(filePath, version, writeToken) {
|
|
783
|
+
const absPath = realFilePath(filePath);
|
|
747
784
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
748
785
|
(entry) => entry.version !== version || entry.writeToken !== writeToken
|
|
749
786
|
);
|
|
750
787
|
if (current.length > 0) receipts.set(absPath, current);
|
|
751
788
|
else receipts.delete(absPath);
|
|
752
789
|
}
|
|
753
|
-
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) {
|
|
754
804
|
const now = Date.now();
|
|
755
805
|
const current = (receipts.get(absPath) ?? []).filter(
|
|
756
806
|
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS
|
|
@@ -761,31 +811,32 @@ function identifyFileWrite(absPath, expectedVersion) {
|
|
|
761
811
|
for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
|
|
762
812
|
if (current[i]?.version === expectedVersion) receipt = current[i];
|
|
763
813
|
}
|
|
764
|
-
|
|
765
|
-
const { path, version, writeToken } = receipt;
|
|
766
|
-
return { path, version, writeToken };
|
|
814
|
+
return receipt;
|
|
767
815
|
}
|
|
768
816
|
var consumeFileWriteReceipt = identifyFileWrite;
|
|
769
817
|
|
|
770
818
|
// src/helpers/applyFileMutations.ts
|
|
771
819
|
import { readFileSync as readFileSync4, statSync as statSync2 } from "fs";
|
|
772
|
-
function applyFileMutations(projectDir, mutations, requestToken,
|
|
820
|
+
function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
|
|
773
821
|
const prepared = mutations.map((mutation) => ({
|
|
774
822
|
...mutation,
|
|
775
823
|
before: mutation.before ?? readFileSync4(mutation.absPath, "utf-8")
|
|
776
824
|
}));
|
|
777
825
|
const results = [];
|
|
778
826
|
const attempted = [];
|
|
827
|
+
for (const mutation of prepared)
|
|
828
|
+
assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
|
|
779
829
|
try {
|
|
780
830
|
for (const mutation of prepared) {
|
|
781
|
-
results.push(applyOneMutation(projectDir, mutation, requestToken,
|
|
831
|
+
results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted));
|
|
782
832
|
}
|
|
783
833
|
return results;
|
|
784
834
|
} catch (error) {
|
|
785
835
|
const rollbackErrors = [];
|
|
786
836
|
for (const mutation of attempted.reverse()) {
|
|
787
837
|
try {
|
|
788
|
-
|
|
838
|
+
if (!mutation.written || readFileSync4(mutation.absPath, "utf-8") === mutation.after)
|
|
839
|
+
writeFile2(mutation.absPath, mutation.before, "utf-8");
|
|
789
840
|
clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
|
|
790
841
|
} catch (rollbackError) {
|
|
791
842
|
rollbackErrors.push(rollbackError);
|
|
@@ -800,9 +851,9 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile = (pa
|
|
|
800
851
|
throw error;
|
|
801
852
|
}
|
|
802
853
|
}
|
|
803
|
-
function applyOneMutation(projectDir, mutation, requestToken,
|
|
854
|
+
function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attempted) {
|
|
804
855
|
const current = readFileSync4(mutation.absPath, "utf-8");
|
|
805
|
-
assertExpectedVersion(mutation
|
|
856
|
+
assertExpectedVersion(mutation, current);
|
|
806
857
|
if (mutation.after === mutation.before) {
|
|
807
858
|
return {
|
|
808
859
|
...mutation,
|
|
@@ -815,15 +866,19 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
|
|
|
815
866
|
}
|
|
816
867
|
const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
|
|
817
868
|
if (backup.error) throw new Error(`backup failed: ${backup.error}`);
|
|
869
|
+
assertExpectedVersion(mutation, readFileSync4(mutation.absPath, "utf-8"));
|
|
818
870
|
const before = current;
|
|
819
871
|
const version = fileContentVersion(mutation.after);
|
|
820
872
|
const writeToken = createWriteToken(requestToken);
|
|
821
|
-
|
|
822
|
-
|
|
873
|
+
const attempt = { ...mutation, before, version, writeToken, written: false };
|
|
874
|
+
attempted.push(attempt);
|
|
875
|
+
writeFile2(mutation.absPath, mutation.after, "utf-8");
|
|
876
|
+
attempt.written = true;
|
|
823
877
|
recordFileWriteReceipt(mutation.absPath, {
|
|
824
878
|
path: mutation.sourceFile,
|
|
825
879
|
version,
|
|
826
|
-
writeToken
|
|
880
|
+
writeToken,
|
|
881
|
+
overwrote: before
|
|
827
882
|
});
|
|
828
883
|
return {
|
|
829
884
|
...mutation,
|
|
@@ -834,10 +889,16 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempt
|
|
|
834
889
|
writeToken
|
|
835
890
|
};
|
|
836
891
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
892
|
+
var FileChangedError = class extends Error {
|
|
893
|
+
constructor(sourceFile) {
|
|
894
|
+
super("file changed since the timeline was read");
|
|
895
|
+
this.sourceFile = sourceFile;
|
|
840
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);
|
|
841
902
|
}
|
|
842
903
|
|
|
843
904
|
// src/routes/files.ts
|
|
@@ -876,9 +937,9 @@ import { parseHTML as parseHTML2 } from "linkedom";
|
|
|
876
937
|
import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
|
|
877
938
|
|
|
878
939
|
// src/helpers/compositionInsertion.ts
|
|
879
|
-
import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync } from "fs";
|
|
940
|
+
import { existsSync as existsSync2, readFileSync as readFileSync5, realpathSync as realpathSync2 } from "fs";
|
|
880
941
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
881
|
-
import { dirname as
|
|
942
|
+
import { dirname as dirname4, relative as relative4, resolve as resolve2, sep as sep3 } from "path";
|
|
882
943
|
import { parseHTML } from "linkedom";
|
|
883
944
|
var CompositionInsertionError = class extends Error {
|
|
884
945
|
constructor(message, status) {
|
|
@@ -914,8 +975,8 @@ function canonicalProjectPath(projectDir, candidate) {
|
|
|
914
975
|
if (!existsSync2(candidate)) {
|
|
915
976
|
throw new CompositionInsertionError("Composition source was not found", 404);
|
|
916
977
|
}
|
|
917
|
-
const canonical =
|
|
918
|
-
if (!isSafePath(
|
|
978
|
+
const canonical = realpathSync2(candidate);
|
|
979
|
+
if (!isSafePath(realpathSync2(projectDir), canonical)) {
|
|
919
980
|
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
|
920
981
|
}
|
|
921
982
|
return canonical;
|
|
@@ -927,13 +988,13 @@ function validateSourcePath(sourcePath) {
|
|
|
927
988
|
}
|
|
928
989
|
function canonicalProjectFile(projectDir, sourcePath) {
|
|
929
990
|
validateSourcePath(sourcePath);
|
|
930
|
-
return canonicalProjectPath(projectDir,
|
|
991
|
+
return canonicalProjectPath(projectDir, resolveWithinProject2(projectDir, sourcePath));
|
|
931
992
|
}
|
|
932
993
|
function canonicalDependency(projectDir, ownerAbs, sourcePath) {
|
|
933
994
|
validateSourcePath(sourcePath);
|
|
934
995
|
return canonicalProjectPath(
|
|
935
996
|
projectDir,
|
|
936
|
-
|
|
997
|
+
resolveWithinProject2(projectDir, relative4(projectDir, resolve2(dirname4(ownerAbs), sourcePath)))
|
|
937
998
|
);
|
|
938
999
|
}
|
|
939
1000
|
function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
|
|
@@ -1006,7 +1067,7 @@ function uniqueHostId(root, base) {
|
|
|
1006
1067
|
return `${base}_${suffix}`;
|
|
1007
1068
|
}
|
|
1008
1069
|
function relativeSourcePath(targetAbs, sourceAbs) {
|
|
1009
|
-
return
|
|
1070
|
+
return relative4(dirname4(targetAbs), sourceAbs).split(sep3).join("/");
|
|
1010
1071
|
}
|
|
1011
1072
|
function insertCompositionIntoSource(input) {
|
|
1012
1073
|
const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
|
|
@@ -1138,7 +1199,7 @@ async function loadGsapParser() {
|
|
|
1138
1199
|
return import("@hyperframes/parsers/gsap-parser-recast");
|
|
1139
1200
|
}
|
|
1140
1201
|
function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
1141
|
-
if (!isSafePath(projectDir,
|
|
1202
|
+
if (!isSafePath(projectDir, dirname5(lexicalPath))) return false;
|
|
1142
1203
|
let stats;
|
|
1143
1204
|
try {
|
|
1144
1205
|
stats = lstatSync3(lexicalPath);
|
|
@@ -1146,7 +1207,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
|
1146
1207
|
return false;
|
|
1147
1208
|
}
|
|
1148
1209
|
if (!stats.isSymbolicLink()) return false;
|
|
1149
|
-
const target = resolve3(
|
|
1210
|
+
const target = resolve3(dirname5(lexicalPath), readlinkSync(lexicalPath));
|
|
1150
1211
|
if (!isSafePath(projectDir, target)) return false;
|
|
1151
1212
|
try {
|
|
1152
1213
|
statSync3(lexicalPath);
|
|
@@ -1170,7 +1231,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
|
|
|
1170
1231
|
if (filePath.includes("\0")) {
|
|
1171
1232
|
return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
|
|
1172
1233
|
}
|
|
1173
|
-
const absPath =
|
|
1234
|
+
const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject2)(project.dir, filePath);
|
|
1174
1235
|
if (!absPath) {
|
|
1175
1236
|
if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
|
|
1176
1237
|
return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
|
|
@@ -1186,7 +1247,7 @@ function resolveProjectFile(c, adapter, opts) {
|
|
|
1186
1247
|
return resolveProjectPath(c, adapter, "files", opts);
|
|
1187
1248
|
}
|
|
1188
1249
|
function resolveFileMutationContext(c, adapter, operation) {
|
|
1189
|
-
return resolveProjectPath(c, adapter, `file-mutations/${operation}
|
|
1250
|
+
return resolveProjectPath(c, adapter, `file-mutations/${operation}`, { pin: true });
|
|
1190
1251
|
}
|
|
1191
1252
|
function isOptionalInteger(value) {
|
|
1192
1253
|
return value === void 0 || Number.isInteger(value);
|
|
@@ -1235,11 +1296,23 @@ function foldElementPatches(originalContent, patches) {
|
|
|
1235
1296
|
}
|
|
1236
1297
|
return { content, matched };
|
|
1237
1298
|
}
|
|
1238
|
-
|
|
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) {
|
|
1239
1312
|
const resolvedPaths = /* @__PURE__ */ new Set();
|
|
1240
1313
|
const prepared = [];
|
|
1241
1314
|
for (const batch of batches) {
|
|
1242
|
-
const absPath =
|
|
1315
|
+
const absPath = pinWithinProject(projectDir, batch.sourceFile);
|
|
1243
1316
|
if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
|
|
1244
1317
|
if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
|
|
1245
1318
|
resolvedPaths.add(absPath);
|
|
@@ -1280,7 +1353,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile = (path, conte
|
|
|
1280
1353
|
after
|
|
1281
1354
|
})),
|
|
1282
1355
|
requestToken,
|
|
1283
|
-
|
|
1356
|
+
writeFile2
|
|
1284
1357
|
);
|
|
1285
1358
|
const files = applied.map((file, index) => ({
|
|
1286
1359
|
sourceFile: file.sourceFile,
|
|
@@ -1296,23 +1369,24 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
|
|
|
1296
1369
|
return commitElementPatchBatches(
|
|
1297
1370
|
projectDir,
|
|
1298
1371
|
batches,
|
|
1299
|
-
|
|
1372
|
+
void 0,
|
|
1300
1373
|
c.req.header("X-Hyperframes-Write-Token")
|
|
1301
1374
|
);
|
|
1302
1375
|
}
|
|
1303
|
-
function
|
|
1376
|
+
function writeFileWithReceipt(c, filePath, absPath, html) {
|
|
1377
|
+
const overwrote = readFileSync6(absPath);
|
|
1378
|
+
replaceFileAtomically(absPath, html, statSync3(absPath).mode);
|
|
1304
1379
|
const version = fileContentVersion(html);
|
|
1305
1380
|
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
|
1306
|
-
recordFileWriteReceipt(absPath, { path: filePath, version, writeToken });
|
|
1381
|
+
recordFileWriteReceipt(absPath, { path: filePath, version, writeToken, overwrote });
|
|
1307
1382
|
return { version, writeToken };
|
|
1308
1383
|
}
|
|
1309
|
-
function
|
|
1310
|
-
replaceFileAtomically(absPath, html, statSync3(absPath).mode);
|
|
1311
|
-
return recordMutationReceipt(c, filePath, absPath, html);
|
|
1312
|
-
}
|
|
1313
|
-
function writeMutationResult(c, projectDir, filePath, absPath, html) {
|
|
1384
|
+
function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
|
|
1314
1385
|
const backup = snapshotBeforeWrite(projectDir, absPath);
|
|
1315
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
|
+
}
|
|
1316
1390
|
const { version } = writeFileWithReceipt(c, filePath, absPath, html);
|
|
1317
1391
|
return { backupPath: backupPathForResponse(projectDir, backup.backupPath), version };
|
|
1318
1392
|
}
|
|
@@ -1320,7 +1394,7 @@ function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
|
|
|
1320
1394
|
if (next === original) {
|
|
1321
1395
|
return c.json({ ok: true, changed: false, content: original, path: filePath });
|
|
1322
1396
|
}
|
|
1323
|
-
const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next);
|
|
1397
|
+
const mutationResult = writeMutationResult(c, projectDir, filePath, absPath, next, original);
|
|
1324
1398
|
if (mutationResult instanceof Response) return mutationResult;
|
|
1325
1399
|
const { backupPath } = mutationResult;
|
|
1326
1400
|
return c.json({
|
|
@@ -1342,6 +1416,8 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
|
|
|
1342
1416
|
);
|
|
1343
1417
|
}
|
|
1344
1418
|
function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
|
|
1419
|
+
if (error === "conflict")
|
|
1420
|
+
return c.json({ error: "file changed", conflict: true, sourceFile }, 409);
|
|
1345
1421
|
if (error === "not-found") return c.json({ error, sourceFile }, 404);
|
|
1346
1422
|
if (error === "forbidden") return c.json({ error, sourceFile }, 403);
|
|
1347
1423
|
return c.json({ error: "duplicate source file", sourceFile }, 400);
|
|
@@ -1354,7 +1430,7 @@ async function parseMutationBody(c) {
|
|
|
1354
1430
|
return { target: body.target, body };
|
|
1355
1431
|
}
|
|
1356
1432
|
function ensureDir(filePath) {
|
|
1357
|
-
const dir =
|
|
1433
|
+
const dir = dirname5(filePath);
|
|
1358
1434
|
if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
|
|
1359
1435
|
}
|
|
1360
1436
|
function generateCopyPath(projectDir, originalPath) {
|
|
@@ -1742,7 +1818,8 @@ async function applyGsapMutations(c, res, mutations) {
|
|
|
1742
1818
|
res.project.dir,
|
|
1743
1819
|
res.filePath,
|
|
1744
1820
|
res.absPath,
|
|
1745
|
-
newHtml
|
|
1821
|
+
newHtml,
|
|
1822
|
+
beforeHtml
|
|
1746
1823
|
);
|
|
1747
1824
|
if (mutationResult instanceof Response) return mutationResult;
|
|
1748
1825
|
backupPath = mutationResult.backupPath;
|
|
@@ -2575,7 +2652,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2575
2652
|
}
|
|
2576
2653
|
});
|
|
2577
2654
|
api.put("/projects/:id/files/*", async (c) => {
|
|
2578
|
-
const res = await resolveProjectFile(c, adapter);
|
|
2655
|
+
const res = await resolveProjectFile(c, adapter, { pin: true });
|
|
2579
2656
|
if ("error" in res) return res.error;
|
|
2580
2657
|
const body = Buffer.from(await c.req.arrayBuffer());
|
|
2581
2658
|
const expectedVersion = c.req.header("If-Match")?.trim() ?? null;
|
|
@@ -2600,6 +2677,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2600
2677
|
);
|
|
2601
2678
|
}
|
|
2602
2679
|
let backup = { backupPath: null };
|
|
2680
|
+
let overwrote;
|
|
2603
2681
|
if (createOnly) {
|
|
2604
2682
|
ensureDir(res.absPath);
|
|
2605
2683
|
let fd;
|
|
@@ -2645,6 +2723,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2645
2723
|
}
|
|
2646
2724
|
try {
|
|
2647
2725
|
const currentContent = readFileSync6(fd);
|
|
2726
|
+
overwrote = currentContent;
|
|
2648
2727
|
const currentVersion = fileContentVersion(currentContent);
|
|
2649
2728
|
if (expectedVersion !== currentVersion) {
|
|
2650
2729
|
return c.json(
|
|
@@ -2669,7 +2748,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2669
2748
|
}
|
|
2670
2749
|
const version = fileContentVersion(body);
|
|
2671
2750
|
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
|
2672
|
-
recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken });
|
|
2751
|
+
recordFileWriteReceipt(res.absPath, { path: res.filePath, version, writeToken, overwrote });
|
|
2673
2752
|
c.header("ETag", version);
|
|
2674
2753
|
return c.json({
|
|
2675
2754
|
ok: true,
|
|
@@ -2748,6 +2827,11 @@ function registerFileRoutes(api, adapter) {
|
|
|
2748
2827
|
}
|
|
2749
2828
|
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
|
2750
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
|
+
}
|
|
2751
2835
|
const { version, writeToken } = writeFileWithReceipt(
|
|
2752
2836
|
c,
|
|
2753
2837
|
ctx.filePath,
|
|
@@ -2824,7 +2908,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2824
2908
|
const seen = /* @__PURE__ */ new Set();
|
|
2825
2909
|
const prepared = [];
|
|
2826
2910
|
for (const file of files) {
|
|
2827
|
-
const absPath =
|
|
2911
|
+
const absPath = pinWithinProject(project.dir, file.path);
|
|
2828
2912
|
if (!absPath) return c.json({ error: `forbidden path: ${file.path}` }, 403);
|
|
2829
2913
|
if (seen.has(absPath)) return c.json({ error: `duplicate path: ${file.path}` }, 400);
|
|
2830
2914
|
seen.add(absPath);
|
|
@@ -2892,7 +2976,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
2892
2976
|
recordFileWriteReceipt(file.absPath, {
|
|
2893
2977
|
path: file.path,
|
|
2894
2978
|
version: fileContentVersion(file.after),
|
|
2895
|
-
writeToken
|
|
2979
|
+
writeToken,
|
|
2980
|
+
overwrote: file.before
|
|
2896
2981
|
});
|
|
2897
2982
|
}
|
|
2898
2983
|
} catch (error) {
|
|
@@ -2974,7 +3059,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
2974
3059
|
ctx.project.dir,
|
|
2975
3060
|
ctx.filePath,
|
|
2976
3061
|
ctx.absPath,
|
|
2977
|
-
result.html
|
|
3062
|
+
result.html,
|
|
3063
|
+
originalContent
|
|
2978
3064
|
);
|
|
2979
3065
|
if (mutationResult instanceof Response) return mutationResult;
|
|
2980
3066
|
const { version, backupPath } = mutationResult;
|
|
@@ -3001,48 +3087,54 @@ function registerFileRoutes(api, adapter) {
|
|
|
3001
3087
|
if (unsafeFields.length > 0) {
|
|
3002
3088
|
return rejectUnsafeMutationValues(c, unsafeFields);
|
|
3003
3089
|
}
|
|
3004
|
-
let
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
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);
|
|
3018
3128
|
return c.json({
|
|
3019
3129
|
ok: true,
|
|
3020
|
-
changed:
|
|
3130
|
+
changed: true,
|
|
3021
3131
|
matched,
|
|
3022
|
-
content:
|
|
3132
|
+
content: patched,
|
|
3023
3133
|
path: ctx.filePath,
|
|
3024
|
-
version
|
|
3134
|
+
version,
|
|
3135
|
+
backupPath
|
|
3025
3136
|
});
|
|
3026
3137
|
}
|
|
3027
|
-
const mutationResult = writeMutationResult(
|
|
3028
|
-
c,
|
|
3029
|
-
ctx.project.dir,
|
|
3030
|
-
ctx.filePath,
|
|
3031
|
-
ctx.absPath,
|
|
3032
|
-
patched
|
|
3033
|
-
);
|
|
3034
|
-
if (mutationResult instanceof Response) return mutationResult;
|
|
3035
|
-
const { backupPath, version } = mutationResult;
|
|
3036
|
-
c.header("ETag", version);
|
|
3037
|
-
return c.json({
|
|
3038
|
-
ok: true,
|
|
3039
|
-
changed: true,
|
|
3040
|
-
matched,
|
|
3041
|
-
content: patched,
|
|
3042
|
-
path: ctx.filePath,
|
|
3043
|
-
version,
|
|
3044
|
-
backupPath
|
|
3045
|
-
});
|
|
3046
3138
|
});
|
|
3047
3139
|
api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
|
|
3048
3140
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
@@ -3132,7 +3224,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
3132
3224
|
ctx.project.dir,
|
|
3133
3225
|
ctx.filePath,
|
|
3134
3226
|
ctx.absPath,
|
|
3135
|
-
result.html
|
|
3227
|
+
result.html,
|
|
3228
|
+
originalContent
|
|
3136
3229
|
);
|
|
3137
3230
|
if (mutationResult instanceof Response) return mutationResult;
|
|
3138
3231
|
const { backupPath } = mutationResult;
|
|
@@ -3202,7 +3295,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3202
3295
|
if (!body.newPath || body.newPath.includes("\0")) {
|
|
3203
3296
|
return c.json({ error: "newPath required" }, 400);
|
|
3204
3297
|
}
|
|
3205
|
-
const newAbs =
|
|
3298
|
+
const newAbs = resolveWithinProject2(res.project.dir, body.newPath);
|
|
3206
3299
|
if (!newAbs) {
|
|
3207
3300
|
return c.json({ error: "forbidden" }, 403);
|
|
3208
3301
|
}
|
|
@@ -3221,17 +3314,24 @@ function registerFileRoutes(api, adapter) {
|
|
|
3221
3314
|
if (!body.path || body.path.includes("\0")) {
|
|
3222
3315
|
return c.json({ error: "path required" }, 400);
|
|
3223
3316
|
}
|
|
3224
|
-
const srcAbs =
|
|
3317
|
+
const srcAbs = resolveWithinProject2(project.dir, body.path);
|
|
3225
3318
|
if (!srcAbs || !existsSync3(srcAbs)) {
|
|
3226
3319
|
return c.json({ error: "not found" }, 404);
|
|
3227
3320
|
}
|
|
3228
3321
|
const copyPath = generateCopyPath(project.dir, body.path);
|
|
3229
|
-
const destAbs =
|
|
3322
|
+
const destAbs = resolveWithinProject2(project.dir, copyPath);
|
|
3230
3323
|
if (!destAbs) {
|
|
3231
3324
|
return c.json({ error: "forbidden" }, 403);
|
|
3232
3325
|
}
|
|
3233
3326
|
ensureDir(destAbs);
|
|
3234
|
-
|
|
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
|
+
}
|
|
3235
3335
|
return c.json({ ok: true, path: copyPath }, 201);
|
|
3236
3336
|
});
|
|
3237
3337
|
const MAX_UPLOAD_BYTES = 500 * 1024 * 1024;
|
|
@@ -3245,7 +3345,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3245
3345
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3246
3346
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
3247
3347
|
const subDir = c.req.query("dir") ?? "";
|
|
3248
|
-
const targetDir = subDir ?
|
|
3348
|
+
const targetDir = subDir ? resolveWithinProject2(project.dir, subDir) : project.dir;
|
|
3249
3349
|
if (!targetDir) return c.json({ error: "forbidden" }, 403);
|
|
3250
3350
|
if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
|
|
3251
3351
|
const formData = await c.req.formData();
|
|
@@ -3281,7 +3381,8 @@ function registerFileRoutes(api, adapter) {
|
|
|
3281
3381
|
});
|
|
3282
3382
|
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
|
3283
3383
|
const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
|
|
3284
|
-
mustExist: true
|
|
3384
|
+
mustExist: true,
|
|
3385
|
+
pin: true
|
|
3285
3386
|
});
|
|
3286
3387
|
if ("error" in res) return res.error;
|
|
3287
3388
|
const body = await c.req.json().catch(() => null);
|
|
@@ -3291,7 +3392,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
3291
3392
|
return applyGsapMutations(c, res, [body]);
|
|
3292
3393
|
});
|
|
3293
3394
|
api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
|
|
3294
|
-
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
|
+
});
|
|
3295
3399
|
if ("error" in res) return res.error;
|
|
3296
3400
|
const body = await c.req.json().catch(() => null);
|
|
3297
3401
|
if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
|
|
@@ -3304,7 +3408,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
3304
3408
|
return applyGsapMutations(c, res, body.mutations);
|
|
3305
3409
|
});
|
|
3306
3410
|
api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
|
|
3307
|
-
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
|
+
});
|
|
3308
3415
|
if ("error" in res) return res.error;
|
|
3309
3416
|
const body = await c.req.json().catch(() => null);
|
|
3310
3417
|
if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
|
|
@@ -3325,6 +3432,7 @@ import { Readable } from "stream";
|
|
|
3325
3432
|
import { join as join9, resolve as resolve4 } from "path";
|
|
3326
3433
|
import { createHash as createHash4 } from "crypto";
|
|
3327
3434
|
import {
|
|
3435
|
+
addScenePartsManifest,
|
|
3328
3436
|
injectScriptsIntoHtml,
|
|
3329
3437
|
insertBeforeCloseTag as insertBeforeCloseTag2,
|
|
3330
3438
|
stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
|
|
@@ -3341,9 +3449,7 @@ import {
|
|
|
3341
3449
|
rewriteInlineStyleAssetUrls
|
|
3342
3450
|
} from "@hyperframes/core";
|
|
3343
3451
|
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
|
3344
|
-
|
|
3345
|
-
return /^\s*(?:<!doctype\s|<html[\s>])/i.test(html);
|
|
3346
|
-
}
|
|
3452
|
+
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
|
|
3347
3453
|
function rewriteRelativePaths(root, compPath, projectDir) {
|
|
3348
3454
|
const assetExists = (path) => existsSync4(join8(projectDir, path));
|
|
3349
3455
|
rewriteAssetPaths(
|
|
@@ -3721,7 +3827,8 @@ function resolveProjectMainHtml(projectDir, projectId) {
|
|
|
3721
3827
|
var PREVIEW_BUNDLE_OPTIONS = {
|
|
3722
3828
|
runtime: "placeholder",
|
|
3723
3829
|
inlineAssets: false,
|
|
3724
|
-
staticGuard: false
|
|
3830
|
+
staticGuard: false,
|
|
3831
|
+
sceneParts: true
|
|
3725
3832
|
};
|
|
3726
3833
|
function registerPreviewRoutes(api, adapter) {
|
|
3727
3834
|
const previewCacheHeaders = (etag) => ({
|
|
@@ -3771,6 +3878,7 @@ ${runtimeTag}`;
|
|
|
3771
3878
|
mainCompositionPath,
|
|
3772
3879
|
mediaCodecProbeCache
|
|
3773
3880
|
);
|
|
3881
|
+
bundled = addScenePartsManifest(bundled, [`meta[name="${PROJECT_SIGNATURE_META}"]`]);
|
|
3774
3882
|
rememberPreview(builtKey, bundled);
|
|
3775
3883
|
adapter.previewDocuments?.write(builtKey, bundled);
|
|
3776
3884
|
return bundled;
|
|
@@ -3848,7 +3956,7 @@ ${etag}`;
|
|
|
3848
3956
|
if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
|
|
3849
3957
|
const previewVariables = vars.values;
|
|
3850
3958
|
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
|
|
3851
|
-
const compFile =
|
|
3959
|
+
const compFile = resolveWithinProject2(project.dir, compPath);
|
|
3852
3960
|
if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
|
|
3853
3961
|
return c.text("not found", 404);
|
|
3854
3962
|
}
|
|
@@ -4082,7 +4190,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4082
4190
|
const outputResolution = VALID_RESOLUTIONS.has(body.resolution ?? "") ? body.resolution : void 0;
|
|
4083
4191
|
let composition;
|
|
4084
4192
|
if (typeof body.composition === "string" && body.composition.length > 0) {
|
|
4085
|
-
if (!
|
|
4193
|
+
if (!resolveWithinProject2(project.dir, body.composition)) {
|
|
4086
4194
|
return c.json({ error: "composition path must be within the project directory" }, 400);
|
|
4087
4195
|
}
|
|
4088
4196
|
composition = body.composition;
|
|
@@ -4215,7 +4323,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4215
4323
|
const filename = c.req.path.split("/renders/file/")[1];
|
|
4216
4324
|
if (!filename) return c.json({ error: "missing filename" }, 400);
|
|
4217
4325
|
const rendersDir = adapter.rendersDir(project);
|
|
4218
|
-
const fp =
|
|
4326
|
+
const fp = resolveWithinProject2(rendersDir, filename);
|
|
4219
4327
|
if (!fp) return c.json({ error: "forbidden" }, 403);
|
|
4220
4328
|
if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
|
|
4221
4329
|
const contentType = renderContentType(fp);
|
|
@@ -4299,7 +4407,7 @@ function projectReader(projectDir) {
|
|
|
4299
4407
|
const sources = /* @__PURE__ */ new Map();
|
|
4300
4408
|
return (compPath) => {
|
|
4301
4409
|
if (!sources.has(compPath)) {
|
|
4302
|
-
const file =
|
|
4410
|
+
const file = resolveWithinProject2(projectDir, compPath);
|
|
4303
4411
|
let source = null;
|
|
4304
4412
|
try {
|
|
4305
4413
|
source = file ? readFileSync11(file, "utf-8") : null;
|
|
@@ -4524,7 +4632,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4524
4632
|
const { project, signature: projectSignature } = resolved;
|
|
4525
4633
|
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
|
|
4526
4634
|
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
4527
|
-
const htmlFile =
|
|
4635
|
+
const htmlFile = resolveWithinProject2(project.dir, compPath);
|
|
4528
4636
|
const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
|
|
4529
4637
|
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
|
|
4530
4638
|
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
|
|
@@ -4948,7 +5056,7 @@ function registerSelectionRoutes(api, adapter) {
|
|
|
4948
5056
|
// src/routes/media.ts
|
|
4949
5057
|
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
4950
5058
|
import { existsSync as existsSync9, mkdirSync as mkdirSync7 } from "fs";
|
|
4951
|
-
import { basename as
|
|
5059
|
+
import { basename as basename3, dirname as dirname6, extname as extname2, join as join14 } from "path";
|
|
4952
5060
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
4953
5061
|
".mp4",
|
|
4954
5062
|
".mov",
|
|
@@ -4978,7 +5086,7 @@ function containsNullByte(path) {
|
|
|
4978
5086
|
return path.includes("\0");
|
|
4979
5087
|
}
|
|
4980
5088
|
function slugFileBase(path) {
|
|
4981
|
-
const name =
|
|
5089
|
+
const name = basename3(path, extname2(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
4982
5090
|
return name || "media";
|
|
4983
5091
|
}
|
|
4984
5092
|
function uniqueAssetPath(projectDir, assetPath) {
|
|
@@ -5034,7 +5142,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5034
5142
|
if (/^(?:https?:|data:|blob:)/i.test(assetPath)) {
|
|
5035
5143
|
return c.json({ error: "media metadata requires a project-local asset" }, 400);
|
|
5036
5144
|
}
|
|
5037
|
-
const filePath =
|
|
5145
|
+
const filePath = resolveWithinProject2(project.dir, assetPath);
|
|
5038
5146
|
if (!filePath) return c.json({ error: "forbidden" }, 403);
|
|
5039
5147
|
if (!existsSync9(filePath)) return c.json({ error: "media not found" }, 404);
|
|
5040
5148
|
return c.json({ path: assetPath, metadata: await readMediaMetadata(filePath) });
|
|
@@ -5056,7 +5164,7 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5056
5164
|
if (/^(?:https?:|data:|blob:)/i.test(inputAssetPath)) {
|
|
5057
5165
|
return c.json({ error: "background removal requires a project-local media asset" }, 400);
|
|
5058
5166
|
}
|
|
5059
|
-
const inputPath =
|
|
5167
|
+
const inputPath = resolveWithinProject2(project.dir, inputAssetPath);
|
|
5060
5168
|
if (!inputPath) return c.json({ error: "forbidden" }, 403);
|
|
5061
5169
|
if (!existsSync9(inputPath)) return c.json({ error: "input media not found" }, 404);
|
|
5062
5170
|
const inputIsVideo = isVideoPath(inputAssetPath);
|
|
@@ -5068,11 +5176,11 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5068
5176
|
if (requestedOutput && containsNullByte(requestedOutput)) {
|
|
5069
5177
|
return c.json({ error: "forbidden" }, 403);
|
|
5070
5178
|
}
|
|
5071
|
-
if (requestedOutput && !
|
|
5179
|
+
if (requestedOutput && !resolveWithinProject2(project.dir, requestedOutput)) {
|
|
5072
5180
|
return c.json({ error: "forbidden" }, 403);
|
|
5073
5181
|
}
|
|
5074
5182
|
const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput) : defaultOutputPath(project.dir, inputAssetPath);
|
|
5075
|
-
const outputPath =
|
|
5183
|
+
const outputPath = resolveWithinProject2(project.dir, outputAssetPath);
|
|
5076
5184
|
if (!outputPath) return c.json({ error: "forbidden" }, 403);
|
|
5077
5185
|
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname2(outputAssetPath).toLowerCase())) {
|
|
5078
5186
|
return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
|
|
@@ -5087,13 +5195,13 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5087
5195
|
return c.json({ error: "background plates are only supported for video inputs" }, 400);
|
|
5088
5196
|
}
|
|
5089
5197
|
backgroundOutputAssetPath = defaultPlatePath(project.dir, inputAssetPath);
|
|
5090
|
-
backgroundOutputPath =
|
|
5198
|
+
backgroundOutputPath = resolveWithinProject2(project.dir, backgroundOutputAssetPath) ?? void 0;
|
|
5091
5199
|
if (!backgroundOutputPath) {
|
|
5092
5200
|
return c.json({ error: "forbidden" }, 403);
|
|
5093
5201
|
}
|
|
5094
5202
|
}
|
|
5095
|
-
mkdirSync7(
|
|
5096
|
-
if (backgroundOutputPath) mkdirSync7(
|
|
5203
|
+
mkdirSync7(dirname6(outputPath), { recursive: true });
|
|
5204
|
+
if (backgroundOutputPath) mkdirSync7(dirname6(backgroundOutputPath), { recursive: true });
|
|
5097
5205
|
const jobId = makeJobId(project.id, mediaJobs);
|
|
5098
5206
|
const state = adapter.startBackgroundRemoval({
|
|
5099
5207
|
project,
|
|
@@ -5174,175 +5282,40 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5174
5282
|
api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
|
|
5175
5283
|
}
|
|
5176
5284
|
|
|
5177
|
-
// src/routes/history.ts
|
|
5178
|
-
var YOU = { kind: "person", name: "You" };
|
|
5179
|
-
var MAX_WINDOW_IDLE_MS = 10 * 6e4;
|
|
5180
|
-
async function historyOf(adapter, c) {
|
|
5181
|
-
const project = await adapter.resolveProject(c.req.param("id") ?? "");
|
|
5182
|
-
return project ? await adapter.history?.(project) ?? null : null;
|
|
5183
|
-
}
|
|
5184
|
-
async function bodyOf(c) {
|
|
5185
|
-
const body = await c.req.json().catch(() => null);
|
|
5186
|
-
return body && typeof body === "object" ? body : {};
|
|
5187
|
-
}
|
|
5188
|
-
var text = (value) => typeof value === "string" && value ? value : null;
|
|
5189
|
-
function writing(c) {
|
|
5190
|
-
const header = c.req.header("X-Hyperframes-Write-Token");
|
|
5191
|
-
return header ? { writeToken: createWriteToken(header) } : {};
|
|
5192
|
-
}
|
|
5193
|
-
function idleOf(body) {
|
|
5194
|
-
const idleMs = body.idleMs;
|
|
5195
|
-
return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
|
|
5196
|
-
}
|
|
5197
|
-
function versionsOf(value) {
|
|
5198
|
-
if (!value || typeof value !== "object") return void 0;
|
|
5199
|
-
const pairs = Object.entries(value).filter(
|
|
5200
|
-
(pair) => typeof pair[1] === "string"
|
|
5201
|
-
);
|
|
5202
|
-
return pairs.length ? Object.fromEntries(pairs) : void 0;
|
|
5203
|
-
}
|
|
5204
|
-
function nextStep(history, direction) {
|
|
5205
|
-
const target = history.next(direction);
|
|
5206
|
-
if (!target) return null;
|
|
5207
|
-
const paths = target.files.map((file) => file.path);
|
|
5208
|
-
return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
|
|
5209
|
-
}
|
|
5210
|
-
async function withHistory(adapter, c, task) {
|
|
5211
|
-
const history = await historyOf(adapter, c);
|
|
5212
|
-
if (!history) return c.json({ error: "This project has no history here." }, 404);
|
|
5213
|
-
try {
|
|
5214
|
-
return c.json(await task(history, await bodyOf(c)) ?? null);
|
|
5215
|
-
} catch (error) {
|
|
5216
|
-
return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
5217
|
-
}
|
|
5218
|
-
}
|
|
5219
|
-
function registerHistoryRoutes(api, adapter) {
|
|
5220
|
-
const windows = /* @__PURE__ */ new Map();
|
|
5221
|
-
const base = "/projects/:id/history";
|
|
5222
|
-
api.get(
|
|
5223
|
-
base,
|
|
5224
|
-
(c) => withHistory(adapter, c, (history) => {
|
|
5225
|
-
const entries = history.list();
|
|
5226
|
-
return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
|
|
5227
|
-
})
|
|
5228
|
-
);
|
|
5229
|
-
api.post(
|
|
5230
|
-
`${base}/step`,
|
|
5231
|
-
(c) => withHistory(
|
|
5232
|
-
adapter,
|
|
5233
|
-
c,
|
|
5234
|
-
(history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
|
|
5235
|
-
)
|
|
5236
|
-
);
|
|
5237
|
-
api.post(
|
|
5238
|
-
`${base}/undo`,
|
|
5239
|
-
(c) => withHistory(adapter, c, (history, body) => {
|
|
5240
|
-
const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
|
|
5241
|
-
return history.undo(text(body.entryId) ?? "", { who: YOU, mode, ...writing(c) });
|
|
5242
|
-
})
|
|
5243
|
-
);
|
|
5244
|
-
api.post(
|
|
5245
|
-
`${base}/restore`,
|
|
5246
|
-
(c) => withHistory(
|
|
5247
|
-
adapter,
|
|
5248
|
-
c,
|
|
5249
|
-
(history, body) => history.restore(text(body.point) ?? "", YOU, writing(c))
|
|
5250
|
-
)
|
|
5251
|
-
);
|
|
5252
|
-
api.get(
|
|
5253
|
-
`${base}/peek/:point`,
|
|
5254
|
-
(c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
|
|
5255
|
-
);
|
|
5256
|
-
api.post(
|
|
5257
|
-
`${base}/pin`,
|
|
5258
|
-
(c) => withHistory(adapter, c, (history, body) => {
|
|
5259
|
-
history.pin(text(body.entryId) ?? "", body.pinned === true);
|
|
5260
|
-
return { ok: true };
|
|
5261
|
-
})
|
|
5262
|
-
);
|
|
5263
|
-
api.post(
|
|
5264
|
-
`${base}/claim`,
|
|
5265
|
-
(c) => withHistory(adapter, c, async (history, body) => {
|
|
5266
|
-
const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
|
|
5267
|
-
const coalesceKey = text(body.coalesceKey) ?? void 0;
|
|
5268
|
-
const idleMs = idleOf(body);
|
|
5269
|
-
const overwrote = versionsOf(body.overwrote);
|
|
5270
|
-
const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
|
|
5271
|
-
...coalesceKey && { coalesceKey },
|
|
5272
|
-
...idleMs && { idleMs },
|
|
5273
|
-
...overwrote && { overwrote }
|
|
5274
|
-
});
|
|
5275
|
-
return { claimed };
|
|
5276
|
-
})
|
|
5277
|
-
);
|
|
5278
|
-
api.post(
|
|
5279
|
-
`${base}/window`,
|
|
5280
|
-
(c) => withHistory(adapter, c, async (history, body) => {
|
|
5281
|
-
const idleMs = idleOf(body);
|
|
5282
|
-
const window = await history.beginWindow(
|
|
5283
|
-
YOU,
|
|
5284
|
-
text(body.label) ?? "Edited in Studio",
|
|
5285
|
-
idleMs ? { idleMs } : void 0
|
|
5286
|
-
);
|
|
5287
|
-
windows.set(window.id, { history, window });
|
|
5288
|
-
return { windowId: window.id };
|
|
5289
|
-
})
|
|
5290
|
-
);
|
|
5291
|
-
api.post(
|
|
5292
|
-
`${base}/window/:windowId/close`,
|
|
5293
|
-
(c) => withHistory(adapter, c, async (history) => {
|
|
5294
|
-
const id = c.req.param("windowId") ?? "";
|
|
5295
|
-
const held = windows.get(id);
|
|
5296
|
-
if (held?.history !== history) throw new Error("That window is not open in this project.");
|
|
5297
|
-
windows.delete(id);
|
|
5298
|
-
return { entry: await held.window.close() };
|
|
5299
|
-
})
|
|
5300
|
-
);
|
|
5301
|
-
}
|
|
5302
|
-
|
|
5303
|
-
// src/createStudioApi.ts
|
|
5304
|
-
function createStudioApi(adapter) {
|
|
5305
|
-
const api = new Hono();
|
|
5306
|
-
registerProjectRoutes(api, adapter);
|
|
5307
|
-
registerFileRoutes(api, adapter);
|
|
5308
|
-
registerPreviewRoutes(api, adapter);
|
|
5309
|
-
registerLintRoutes(api, adapter);
|
|
5310
|
-
registerRenderRoutes(api, adapter);
|
|
5311
|
-
registerThumbnailRoutes(api, adapter);
|
|
5312
|
-
registerSelectionRoutes(api, adapter);
|
|
5313
|
-
registerMediaRoutes(api, adapter);
|
|
5314
|
-
registerWaveformRoutes(api, adapter);
|
|
5315
|
-
registerFontRoutes(api);
|
|
5316
|
-
registerRegistryRoutes(api, adapter);
|
|
5317
|
-
registerGlobalAssetRoutes(api);
|
|
5318
|
-
registerHistoryRoutes(api, adapter);
|
|
5319
|
-
return api;
|
|
5320
|
-
}
|
|
5321
|
-
|
|
5322
5285
|
// src/history/projectHistory.ts
|
|
5323
5286
|
import { randomUUID as randomUUID8 } from "crypto";
|
|
5324
|
-
import { rm as rm2 } from "fs/promises";
|
|
5325
|
-
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";
|
|
5326
5298
|
import { homedir as homedir2 } from "os";
|
|
5327
|
-
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";
|
|
5328
5300
|
|
|
5329
5301
|
// src/history/blobStore.ts
|
|
5330
5302
|
import { createHash as createHash6, randomUUID as randomUUID5 } from "crypto";
|
|
5331
|
-
import { constants as constants3, createReadStream as createReadStream2 } from "fs";
|
|
5303
|
+
import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
|
|
5332
5304
|
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
|
|
5333
|
-
import { dirname as
|
|
5305
|
+
import { dirname as dirname7, join as join16 } from "path";
|
|
5334
5306
|
var BLOB_HASH = /^[0-9a-f]{64}$/;
|
|
5335
5307
|
async function hashFile(path) {
|
|
5336
5308
|
const hash = createHash6("sha256");
|
|
5337
5309
|
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
5338
5310
|
return hash.digest("hex");
|
|
5339
5311
|
}
|
|
5340
|
-
async function cloneOrCopy(from, to) {
|
|
5341
|
-
await mkdir(
|
|
5312
|
+
async function cloneOrCopy(from, to, beforeReplace) {
|
|
5313
|
+
await mkdir(dirname7(to), { recursive: true });
|
|
5342
5314
|
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5343
5315
|
try {
|
|
5344
5316
|
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
5345
|
-
|
|
5317
|
+
beforeReplace?.();
|
|
5318
|
+
renameSync4(temp, to);
|
|
5346
5319
|
} catch (error) {
|
|
5347
5320
|
await rm(temp, { force: true });
|
|
5348
5321
|
throw error;
|
|
@@ -5369,7 +5342,7 @@ async function openBlobStore(dir) {
|
|
|
5369
5342
|
await rm(temp, { force: true });
|
|
5370
5343
|
return hash;
|
|
5371
5344
|
}
|
|
5372
|
-
await mkdir(
|
|
5345
|
+
await mkdir(dirname7(pathOf(hash)), { recursive: true });
|
|
5373
5346
|
const size = (await stat(temp)).size;
|
|
5374
5347
|
await rename(temp, pathOf(hash));
|
|
5375
5348
|
sizes.set(hash, size);
|
|
@@ -5378,7 +5351,7 @@ async function openBlobStore(dir) {
|
|
|
5378
5351
|
},
|
|
5379
5352
|
has: (hash) => sizes.has(hash),
|
|
5380
5353
|
read: async (hash) => readFile2(pathOf(hash)),
|
|
5381
|
-
writeTo: async (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
|
|
5354
|
+
writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
|
|
5382
5355
|
bytes: () => total,
|
|
5383
5356
|
size: (hash) => sizes.get(hash) ?? 0,
|
|
5384
5357
|
async prune(keep) {
|
|
@@ -5394,7 +5367,7 @@ async function openBlobStore(dir) {
|
|
|
5394
5367
|
|
|
5395
5368
|
// src/history/historyId.ts
|
|
5396
5369
|
import { randomUUID as randomUUID6 } from "crypto";
|
|
5397
|
-
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";
|
|
5398
5371
|
import { join as join17, resolve as resolve6 } from "path";
|
|
5399
5372
|
var ID_PATH = join17(".hyperframes", "history-id");
|
|
5400
5373
|
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
@@ -5406,27 +5379,33 @@ function readId(projectDir) {
|
|
|
5406
5379
|
return null;
|
|
5407
5380
|
}
|
|
5408
5381
|
}
|
|
5409
|
-
|
|
5382
|
+
var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
5383
|
+
function isRecordedFolder(historyDir, folder) {
|
|
5410
5384
|
try {
|
|
5411
|
-
|
|
5385
|
+
const was = JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
|
|
5386
|
+
return sameFolder({ ino: was.ino, birthtimeMs: was.born }, folder);
|
|
5412
5387
|
} catch {
|
|
5413
|
-
return
|
|
5388
|
+
return false;
|
|
5414
5389
|
}
|
|
5415
5390
|
}
|
|
5416
5391
|
function projectHistoryId(projectDir, historyRoot) {
|
|
5417
5392
|
const dir = resolve6(projectDir);
|
|
5393
|
+
const folder = statSync8(dir);
|
|
5418
5394
|
let id = readId(dir);
|
|
5419
|
-
|
|
5420
|
-
if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
|
|
5395
|
+
if (!id || existsSync11(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
|
|
5421
5396
|
id = randomUUID6();
|
|
5422
5397
|
mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
|
|
5423
5398
|
writeFileSync7(join17(dir, ID_PATH), `${id}
|
|
5424
5399
|
`);
|
|
5425
5400
|
}
|
|
5426
|
-
|
|
5427
|
-
writeFileSync7(join17(historyRoot, id, "project.json"), JSON.stringify({ dir }));
|
|
5401
|
+
recordProject(join17(historyRoot, id), dir, folder);
|
|
5428
5402
|
return id;
|
|
5429
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
|
+
}
|
|
5430
5409
|
|
|
5431
5410
|
// src/history/ownerLock.ts
|
|
5432
5411
|
import { randomUUID as randomUUID7 } from "crypto";
|
|
@@ -5511,7 +5490,7 @@ async function takeHistoryOwnership(home, waitMs) {
|
|
|
5511
5490
|
|
|
5512
5491
|
// src/history/historyLog.ts
|
|
5513
5492
|
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync17, writeSync as writeSync2 } from "fs";
|
|
5514
|
-
import { dirname as
|
|
5493
|
+
import { dirname as dirname8 } from "path";
|
|
5515
5494
|
var START = "start";
|
|
5516
5495
|
function parseRecord(line) {
|
|
5517
5496
|
try {
|
|
@@ -5563,21 +5542,25 @@ function writeLog(file, log) {
|
|
|
5563
5542
|
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
5564
5543
|
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
5565
5544
|
];
|
|
5566
|
-
mkdirSync10(
|
|
5545
|
+
mkdirSync10(dirname8(file), { recursive: true });
|
|
5567
5546
|
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
5568
5547
|
`).join(""), 420);
|
|
5569
5548
|
}
|
|
5570
|
-
function applyEntry(manifest, entry) {
|
|
5571
|
-
for (const file of entry.files)
|
|
5572
|
-
|
|
5573
|
-
|
|
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
|
+
}
|
|
5574
5555
|
}
|
|
5575
5556
|
function manifestAt(log, point) {
|
|
5557
|
+
return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
|
|
5558
|
+
}
|
|
5559
|
+
function manifestAround(log, id, side) {
|
|
5576
5560
|
const manifest = new Map(log.baseline);
|
|
5577
|
-
if (point === START) return manifest;
|
|
5578
5561
|
for (const entry of log.entries) {
|
|
5579
|
-
applyEntry(manifest, entry);
|
|
5580
|
-
if (entry.id ===
|
|
5562
|
+
applyEntry(manifest, entry, entry.id === id ? side : "after");
|
|
5563
|
+
if (entry.id === id) return manifest;
|
|
5581
5564
|
}
|
|
5582
5565
|
return null;
|
|
5583
5566
|
}
|
|
@@ -5589,24 +5572,35 @@ function undoneIds(entries) {
|
|
|
5589
5572
|
}
|
|
5590
5573
|
return undone;
|
|
5591
5574
|
}
|
|
5592
|
-
function stepTarget(entries, direction) {
|
|
5575
|
+
function stepTarget(entries, direction, mine) {
|
|
5593
5576
|
const undone = undoneIds(entries);
|
|
5594
5577
|
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
5595
|
-
|
|
5596
|
-
|
|
5597
|
-
|
|
5598
|
-
|
|
5599
|
-
|
|
5600
|
-
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
|
+
}
|
|
5601
5587
|
const target = byId.get(entry.undoes);
|
|
5602
|
-
if (!undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5588
|
+
if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5603
5589
|
}
|
|
5604
5590
|
return void 0;
|
|
5605
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
|
+
}
|
|
5606
5600
|
function foldOldest(log) {
|
|
5607
5601
|
const oldest = log.entries[0];
|
|
5608
5602
|
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
5609
|
-
applyEntry(log.baseline, oldest);
|
|
5603
|
+
applyEntry(log.baseline, oldest, "after");
|
|
5610
5604
|
log.entries.shift();
|
|
5611
5605
|
return true;
|
|
5612
5606
|
}
|
|
@@ -5622,6 +5616,8 @@ function referencedHashes(log, current) {
|
|
|
5622
5616
|
|
|
5623
5617
|
// src/history/projectHistory.ts
|
|
5624
5618
|
var DEFAULT_HISTORY_ROOT = join19(homedir2(), ".cache", "hyperframes", "history");
|
|
5619
|
+
var MAX_WINDOW_IDLE_MS = 10 * 6e4;
|
|
5620
|
+
var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
|
|
5625
5621
|
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
5626
5622
|
var OUTSIDE_LABEL = "Changed outside the app";
|
|
5627
5623
|
var RACY_MS = 2e3;
|
|
@@ -5635,6 +5631,34 @@ function splitAt(change, at, kept) {
|
|
|
5635
5631
|
{ ...change, before: at }
|
|
5636
5632
|
];
|
|
5637
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}/`);
|
|
5661
|
+
var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
|
|
5638
5662
|
function addChange(group, path, before, after) {
|
|
5639
5663
|
const earlier = group.changes.get(path);
|
|
5640
5664
|
const from = earlier ? earlier.before : before;
|
|
@@ -5648,17 +5672,24 @@ var Engine = class {
|
|
|
5648
5672
|
this.blobs = blobs;
|
|
5649
5673
|
this.dir = resolve7(options.projectDir);
|
|
5650
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 };
|
|
5651
5680
|
}
|
|
5652
5681
|
options;
|
|
5653
5682
|
projectId;
|
|
5654
5683
|
blobs;
|
|
5655
5684
|
dir;
|
|
5685
|
+
foldsCase;
|
|
5686
|
+
folder;
|
|
5656
5687
|
home;
|
|
5657
5688
|
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5658
5689
|
tracked = /* @__PURE__ */ new Map();
|
|
5659
5690
|
windows = [];
|
|
5660
5691
|
outside = null;
|
|
5661
|
-
/** 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. */
|
|
5662
5693
|
claimed = null;
|
|
5663
5694
|
writeToken;
|
|
5664
5695
|
quietTimer;
|
|
@@ -5666,6 +5697,12 @@ var Engine = class {
|
|
|
5666
5697
|
notedTimer = null;
|
|
5667
5698
|
listeners = /* @__PURE__ */ new Set();
|
|
5668
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
|
+
}
|
|
5669
5706
|
get logFile() {
|
|
5670
5707
|
return join19(this.home, "log.jsonl");
|
|
5671
5708
|
}
|
|
@@ -5674,10 +5711,37 @@ var Engine = class {
|
|
|
5674
5711
|
}
|
|
5675
5712
|
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
5676
5713
|
queue(task) {
|
|
5714
|
+
if (this.closed)
|
|
5715
|
+
return Promise.reject(new HistoryClosedError("This project's history is closed."));
|
|
5677
5716
|
const run = this.tail.then(task);
|
|
5678
5717
|
this.tail = run.catch(() => void 0);
|
|
5679
5718
|
return run;
|
|
5680
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
|
+
}
|
|
5681
5745
|
async start() {
|
|
5682
5746
|
const log = readLog(
|
|
5683
5747
|
this.logFile,
|
|
@@ -5693,18 +5757,24 @@ var Engine = class {
|
|
|
5693
5757
|
const cached = cache.get(path);
|
|
5694
5758
|
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
5695
5759
|
}
|
|
5696
|
-
|
|
5697
|
-
await this.
|
|
5760
|
+
this.reopenClosedWindow();
|
|
5761
|
+
await this.settleAll();
|
|
5762
|
+
}
|
|
5763
|
+
reopenClosedWindow() {
|
|
5764
|
+
const closed = this.options.closedWindow;
|
|
5765
|
+
if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
|
|
5766
|
+
const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
|
|
5767
|
+
this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: /* @__PURE__ */ new Map() });
|
|
5698
5768
|
}
|
|
5699
5769
|
async firstOpen() {
|
|
5700
5770
|
const sweptAt = Date.now();
|
|
5701
|
-
for (const file of listProjectFiles(this.dir))
|
|
5702
|
-
this.
|
|
5703
|
-
|
|
5704
|
-
|
|
5705
|
-
|
|
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
|
+
}
|
|
5706
5776
|
this.log.baseline = this.manifest();
|
|
5707
|
-
|
|
5777
|
+
this.persistLog();
|
|
5708
5778
|
this.saveStatCache();
|
|
5709
5779
|
}
|
|
5710
5780
|
manifest() {
|
|
@@ -5728,17 +5798,32 @@ var Engine = class {
|
|
|
5728
5798
|
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
5729
5799
|
*/
|
|
5730
5800
|
async sweep() {
|
|
5731
|
-
if (
|
|
5801
|
+
if (this.whereFolder() !== "here") return;
|
|
5732
5802
|
const sweptAt = Date.now();
|
|
5803
|
+
const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
|
|
5733
5804
|
const seen = listProjectFiles(this.dir);
|
|
5734
|
-
let changed = false;
|
|
5735
|
-
for (const file of seen)
|
|
5736
|
-
changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
|
|
5737
5805
|
const present = new Set(seen.map((file) => file.path));
|
|
5738
|
-
|
|
5739
|
-
|
|
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);
|
|
5740
5825
|
this.tracked.delete(path);
|
|
5741
|
-
this.record(path, known.hash, null);
|
|
5826
|
+
await this.record(path, known.hash, null, at);
|
|
5742
5827
|
changed = true;
|
|
5743
5828
|
}
|
|
5744
5829
|
if (changed) this.saveStatCache();
|
|
@@ -5752,45 +5837,54 @@ var Engine = class {
|
|
|
5752
5837
|
throw error;
|
|
5753
5838
|
}
|
|
5754
5839
|
}
|
|
5755
|
-
async observe(path, stat2) {
|
|
5840
|
+
async observe(path, stat2, at) {
|
|
5756
5841
|
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
5757
5842
|
if (stat2 && known.stat === stat2) return false;
|
|
5758
5843
|
const hash = await this.storeIfPresent(path);
|
|
5759
|
-
if (hash === null) return false;
|
|
5844
|
+
if (hash === null || this.whereFolder() !== "here") return false;
|
|
5760
5845
|
this.tracked.set(path, { hash, stat: stat2 });
|
|
5761
|
-
if (known.hash !== hash) this.record(path, known.hash, hash);
|
|
5846
|
+
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
|
5762
5847
|
return known.hash !== hash || known.stat !== stat2;
|
|
5763
5848
|
}
|
|
5764
|
-
record(path, before, after) {
|
|
5849
|
+
async record(path, before, after, at) {
|
|
5850
|
+
const endedBeforeThisWrite = this.windows.filter((open) => !takesWrite(open, at));
|
|
5851
|
+
if (endedBeforeThisWrite.length) await this.commitOutside();
|
|
5852
|
+
for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
|
|
5765
5853
|
const window = this.windows.at(-1);
|
|
5766
|
-
if (window) this.touch(window);
|
|
5854
|
+
if (window) this.touch(window, at);
|
|
5767
5855
|
addChange(window ?? this.outsideGroup(), path, before, after);
|
|
5768
5856
|
}
|
|
5769
5857
|
/** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
|
|
5770
5858
|
async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
|
|
5771
5859
|
await this.sweep();
|
|
5772
|
-
const
|
|
5773
|
-
if (!taken.length)
|
|
5774
|
-
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
5775
|
-
return null;
|
|
5776
|
-
}
|
|
5860
|
+
const taken = await this.takeClaimed(who, paths, overwrote);
|
|
5861
|
+
if (!taken.length) return this.claimNothing(coalesceKey);
|
|
5777
5862
|
const held = this.heldFor(coalesceKey, taken);
|
|
5778
5863
|
if (!held) await this.commitClaim();
|
|
5779
|
-
await this.
|
|
5864
|
+
await this.commitPending();
|
|
5780
5865
|
const group = held ?? this.newGroup(who, label);
|
|
5781
5866
|
for (const change of taken) addChange(group, change.path, change.before, change.after);
|
|
5782
5867
|
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
|
|
5783
5868
|
const entry = await this.commit(group);
|
|
5784
5869
|
return entry && { id: entry.id };
|
|
5785
5870
|
}
|
|
5786
|
-
|
|
5787
|
-
|
|
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() {
|
|
5788
5878
|
await this.commitOutside();
|
|
5789
|
-
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);
|
|
5790
5884
|
}
|
|
5791
5885
|
/** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
|
|
5792
5886
|
heldFor(key, taken) {
|
|
5793
|
-
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;
|
|
5794
5888
|
const after = (path, before) => {
|
|
5795
5889
|
const own = group?.changes.get(path);
|
|
5796
5890
|
return own ? own.after : before;
|
|
@@ -5800,10 +5894,10 @@ var Engine = class {
|
|
|
5800
5894
|
return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
|
|
5801
5895
|
}
|
|
5802
5896
|
logPath(path) {
|
|
5803
|
-
return
|
|
5897
|
+
return relative5(this.dir, resolve7(this.dir, path)).split(sep4).join("/");
|
|
5804
5898
|
}
|
|
5805
|
-
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote
|
|
5806
|
-
takeClaimed(who, paths, overwrote) {
|
|
5899
|
+
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
|
|
5900
|
+
async takeClaimed(who, paths, overwrote) {
|
|
5807
5901
|
const wanted = new Set(paths.map((path) => this.logPath(path)));
|
|
5808
5902
|
const at = new Map(
|
|
5809
5903
|
Object.entries(overwrote).map(([path, version]) => [
|
|
@@ -5813,23 +5907,50 @@ var Engine = class {
|
|
|
5813
5907
|
);
|
|
5814
5908
|
const others = this.windows.filter((open) => !sameWho(open.who, who));
|
|
5815
5909
|
const groups = this.outside ? [this.outside, ...others] : others;
|
|
5816
|
-
const
|
|
5817
|
-
|
|
5818
|
-
|
|
5819
|
-
|
|
5820
|
-
|
|
5821
|
-
|
|
5822
|
-
|
|
5823
|
-
|
|
5824
|
-
|
|
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 });
|
|
5825
5947
|
}
|
|
5826
|
-
return { taken, cut };
|
|
5827
5948
|
}
|
|
5828
5949
|
cutOut(group, change, cut) {
|
|
5829
5950
|
const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
|
|
5830
5951
|
group.changes.delete(change.path);
|
|
5831
5952
|
if (kept) group.changes.set(kept.path, kept);
|
|
5832
|
-
return
|
|
5953
|
+
return claimed;
|
|
5833
5954
|
}
|
|
5834
5955
|
/** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
|
|
5835
5956
|
* so the writer's next write to a cut file would otherwise span the claimer's edit. */
|
|
@@ -5843,8 +5964,8 @@ var Engine = class {
|
|
|
5843
5964
|
}
|
|
5844
5965
|
holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
|
|
5845
5966
|
clearTimeout(this.claimed?.timer);
|
|
5846
|
-
const timer = setTimeout(() => this.background(() => this.commitClaim()), idleMs);
|
|
5847
|
-
timer
|
|
5967
|
+
const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
|
|
5968
|
+
timer?.unref?.();
|
|
5848
5969
|
this.claimed = { group, key, timer };
|
|
5849
5970
|
return group.changes.size ? { id: group.id } : null;
|
|
5850
5971
|
}
|
|
@@ -5874,6 +5995,7 @@ var Engine = class {
|
|
|
5874
5995
|
return { id: randomUUID8(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
5875
5996
|
}
|
|
5876
5997
|
async commitOutside() {
|
|
5998
|
+
if (this.outside?.changes.size) await this.commitClaim();
|
|
5877
5999
|
clearTimeout(this.quietTimer);
|
|
5878
6000
|
clearTimeout(this.maxTimer);
|
|
5879
6001
|
const group = this.outside;
|
|
@@ -5882,12 +6004,12 @@ var Engine = class {
|
|
|
5882
6004
|
}
|
|
5883
6005
|
async commit(group, extra = {}) {
|
|
5884
6006
|
if (!group.changes.size) return null;
|
|
5885
|
-
const
|
|
5886
|
-
const
|
|
5887
|
-
const entry = {
|
|
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 };
|
|
5888
6010
|
this.log.entries.push(entry);
|
|
5889
6011
|
try {
|
|
5890
|
-
|
|
6012
|
+
this.persistLog({ type: "entry", entry });
|
|
5891
6013
|
} catch (error) {
|
|
5892
6014
|
this.log.entries.pop();
|
|
5893
6015
|
throw error;
|
|
@@ -5908,15 +6030,24 @@ var Engine = class {
|
|
|
5908
6030
|
let folded = false;
|
|
5909
6031
|
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
5910
6032
|
folded = true;
|
|
5911
|
-
await this.blobs.prune(
|
|
6033
|
+
await this.blobs.prune(
|
|
6034
|
+
/* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
|
|
6035
|
+
);
|
|
5912
6036
|
}
|
|
5913
|
-
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);
|
|
5914
6045
|
}
|
|
5915
|
-
/** Before an operation: every write
|
|
6046
|
+
/** Before an operation or a window: every write is filed and every pending change committed, in write order. */
|
|
5916
6047
|
async settle() {
|
|
5917
6048
|
await this.sweep();
|
|
5918
6049
|
await this.commitClaim();
|
|
5919
|
-
await this.
|
|
6050
|
+
await this.commitPending();
|
|
5920
6051
|
}
|
|
5921
6052
|
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
5922
6053
|
noteChange(path) {
|
|
@@ -5928,19 +6059,23 @@ var Engine = class {
|
|
|
5928
6059
|
this.notedTimer.unref?.();
|
|
5929
6060
|
}
|
|
5930
6061
|
background(task) {
|
|
5931
|
-
this.queue(task).catch((error) =>
|
|
6062
|
+
this.queue(task).catch((error) => {
|
|
6063
|
+
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
|
|
6064
|
+
});
|
|
5932
6065
|
}
|
|
5933
6066
|
beginWindow(who, label, idleMs) {
|
|
5934
6067
|
return this.queue(async () => {
|
|
5935
|
-
await this.
|
|
6068
|
+
await this.settle();
|
|
5936
6069
|
const window = { ...this.newGroup(who, label), idleMs };
|
|
5937
6070
|
this.windows.push(window);
|
|
5938
|
-
this.touch(window);
|
|
5939
|
-
|
|
6071
|
+
this.touch(window, Date.now());
|
|
6072
|
+
const close = () => this.queue(() => this.sweepAndEnd(window));
|
|
6073
|
+
return { id: window.id, startedAt: window.startedAt, close };
|
|
5940
6074
|
});
|
|
5941
6075
|
}
|
|
5942
6076
|
/** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
|
|
5943
|
-
touch(window) {
|
|
6077
|
+
touch(window, at) {
|
|
6078
|
+
window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
|
|
5944
6079
|
clearTimeout(window.idleTimer);
|
|
5945
6080
|
if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
|
|
5946
6081
|
window.idleTimer = setTimeout(
|
|
@@ -5957,6 +6092,7 @@ var Engine = class {
|
|
|
5957
6092
|
async endWindow(window) {
|
|
5958
6093
|
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
5959
6094
|
clearTimeout(window.idleTimer);
|
|
6095
|
+
await this.commitClaim();
|
|
5960
6096
|
this.windows = this.windows.filter((open) => open !== window);
|
|
5961
6097
|
window.entry = await this.commit(window) ?? window.entry ?? null;
|
|
5962
6098
|
return window.entry;
|
|
@@ -5968,15 +6104,39 @@ var Engine = class {
|
|
|
5968
6104
|
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5969
6105
|
await this.commitOutside();
|
|
5970
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
|
+
}
|
|
5971
6129
|
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
5972
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.`);
|
|
5973
6136
|
const group = this.newGroup(who, label);
|
|
5974
6137
|
this.windows.push(group);
|
|
5975
6138
|
try {
|
|
5976
|
-
for (const [path, hash] of
|
|
5977
|
-
if ((this.tracked.get(path)?.hash ?? null) !== hash)
|
|
5978
|
-
await this.writeProjectFile(path, hash);
|
|
5979
|
-
}
|
|
6139
|
+
for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
|
|
5980
6140
|
await this.sweep();
|
|
5981
6141
|
} finally {
|
|
5982
6142
|
this.windows = this.windows.filter((open) => open !== group);
|
|
@@ -5985,14 +6145,20 @@ var Engine = class {
|
|
|
5985
6145
|
}
|
|
5986
6146
|
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
|
|
5987
6147
|
async writeProjectFile(path, hash) {
|
|
6148
|
+
this.assertWritable();
|
|
5988
6149
|
const absPath = join19(this.dir, path);
|
|
5989
6150
|
const { writeToken } = this;
|
|
5990
6151
|
if (writeToken) {
|
|
5991
6152
|
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
|
|
5992
6153
|
recordFileWriteReceipt(absPath, { path, version, writeToken });
|
|
5993
6154
|
}
|
|
5994
|
-
if (hash === null)
|
|
5995
|
-
|
|
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
|
+
}
|
|
5996
6162
|
}
|
|
5997
6163
|
entry(id) {
|
|
5998
6164
|
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
@@ -6016,7 +6182,9 @@ var Engine = class {
|
|
|
6016
6182
|
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
6017
6183
|
};
|
|
6018
6184
|
}
|
|
6019
|
-
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
|
+
);
|
|
6020
6188
|
return {
|
|
6021
6189
|
ok: true,
|
|
6022
6190
|
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
@@ -6025,15 +6193,19 @@ var Engine = class {
|
|
|
6025
6193
|
movedOn(entry) {
|
|
6026
6194
|
return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
|
|
6027
6195
|
}
|
|
6028
|
-
|
|
6029
|
-
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
if (
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
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 };
|
|
6037
6209
|
}
|
|
6038
6210
|
conflict(entry, changed) {
|
|
6039
6211
|
const paths = new Set(changed.map((file) => file.path));
|
|
@@ -6070,8 +6242,11 @@ var Engine = class {
|
|
|
6070
6242
|
projectId: this.projectId,
|
|
6071
6243
|
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
6072
6244
|
claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
|
|
6073
|
-
noteChange: (path) =>
|
|
6245
|
+
noteChange: (path) => {
|
|
6246
|
+
if (!this.closed) this.noteChange(path);
|
|
6247
|
+
},
|
|
6074
6248
|
list: () => {
|
|
6249
|
+
this.assertOpen();
|
|
6075
6250
|
const undone = undoneIds(this.log.entries);
|
|
6076
6251
|
return this.log.entries.map((entry) => ({
|
|
6077
6252
|
...entry,
|
|
@@ -6080,7 +6255,7 @@ var Engine = class {
|
|
|
6080
6255
|
}));
|
|
6081
6256
|
},
|
|
6082
6257
|
step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
|
|
6083
|
-
const target = this.next(direction);
|
|
6258
|
+
const target = this.next(direction, who);
|
|
6084
6259
|
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
6085
6260
|
}),
|
|
6086
6261
|
undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
|
|
@@ -6089,30 +6264,76 @@ var Engine = class {
|
|
|
6089
6264
|
() => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
|
|
6090
6265
|
),
|
|
6091
6266
|
peek: (point) => {
|
|
6267
|
+
this.assertOpen();
|
|
6092
6268
|
const files = manifestAt(this.log, point);
|
|
6093
6269
|
return files && Object.fromEntries(files);
|
|
6094
6270
|
},
|
|
6095
|
-
|
|
6096
|
-
|
|
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
|
+
},
|
|
6097
6296
|
pin: (id, pinned) => {
|
|
6297
|
+
this.assertOpen();
|
|
6098
6298
|
this.entry(id);
|
|
6099
6299
|
if (pinned) this.log.pins.add(id);
|
|
6100
6300
|
else this.log.pins.delete(id);
|
|
6101
|
-
|
|
6301
|
+
this.persistLog({ type: "pin", id, pinned });
|
|
6102
6302
|
},
|
|
6103
6303
|
onEntry: (listener) => {
|
|
6104
6304
|
this.listeners.add(listener);
|
|
6105
6305
|
return () => this.listeners.delete(listener);
|
|
6106
6306
|
},
|
|
6107
6307
|
flush: () => this.queue(() => this.settleAll()),
|
|
6108
|
-
|
|
6308
|
+
replacedAtPath: () => this.whereFolder() === "replaced",
|
|
6309
|
+
close: () => this.closing ??= (async () => {
|
|
6109
6310
|
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
6110
|
-
|
|
6111
|
-
|
|
6311
|
+
const settled = this.queue(() => this.settleAll());
|
|
6312
|
+
this.closed = true;
|
|
6313
|
+
await settled;
|
|
6314
|
+
})()
|
|
6112
6315
|
};
|
|
6113
6316
|
}
|
|
6114
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
|
+
};
|
|
6115
6334
|
async function openProjectHistory(options) {
|
|
6335
|
+
if (!existsSync12(options.projectDir))
|
|
6336
|
+
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
|
|
6116
6337
|
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
6117
6338
|
const release = await takeHistoryOwnership(
|
|
6118
6339
|
join19(options.historyRoot, projectId),
|
|
@@ -6133,6 +6354,185 @@ async function openProjectHistory(options) {
|
|
|
6133
6354
|
}
|
|
6134
6355
|
}
|
|
6135
6356
|
|
|
6357
|
+
// src/routes/history.ts
|
|
6358
|
+
var YOU = { kind: "person", name: "You" };
|
|
6359
|
+
async function historyOf(adapter, c) {
|
|
6360
|
+
const project = await adapter.resolveProject(c.req.param("id") ?? "");
|
|
6361
|
+
return project ? await adapter.history?.(project) ?? null : null;
|
|
6362
|
+
}
|
|
6363
|
+
async function bodyOf(c) {
|
|
6364
|
+
const body = await c.req.json().catch(() => null);
|
|
6365
|
+
return body && typeof body === "object" ? body : {};
|
|
6366
|
+
}
|
|
6367
|
+
var text = (value) => typeof value === "string" && value ? value : null;
|
|
6368
|
+
function writing(c) {
|
|
6369
|
+
const header = c.req.header("X-Hyperframes-Write-Token");
|
|
6370
|
+
return header ? { writeToken: createWriteToken(header) } : {};
|
|
6371
|
+
}
|
|
6372
|
+
function whoOf(body) {
|
|
6373
|
+
const who = body.who;
|
|
6374
|
+
const name = text(who?.name);
|
|
6375
|
+
return who?.kind === "agent" && name ? { kind: "agent", name } : YOU;
|
|
6376
|
+
}
|
|
6377
|
+
function idleOf(body) {
|
|
6378
|
+
const idleMs = body.idleMs;
|
|
6379
|
+
return typeof idleMs === "number" && idleMs > 0 ? Math.min(idleMs, MAX_WINDOW_IDLE_MS) : void 0;
|
|
6380
|
+
}
|
|
6381
|
+
function versionsOf(value) {
|
|
6382
|
+
if (!value || typeof value !== "object") return void 0;
|
|
6383
|
+
const pairs = Object.entries(value).filter(
|
|
6384
|
+
(pair) => typeof pair[1] === "string"
|
|
6385
|
+
);
|
|
6386
|
+
return pairs.length ? Object.fromEntries(pairs) : void 0;
|
|
6387
|
+
}
|
|
6388
|
+
function nextStep(history, direction) {
|
|
6389
|
+
const target = history.next(direction, YOU);
|
|
6390
|
+
if (!target) return null;
|
|
6391
|
+
const paths = target.files.map((file) => file.path);
|
|
6392
|
+
return { id: target.id, label: target.label, endedAt: target.endedAt, paths };
|
|
6393
|
+
}
|
|
6394
|
+
async function withHistory(adapter, c, task) {
|
|
6395
|
+
const history = await historyOf(adapter, c);
|
|
6396
|
+
if (!history) return c.json({ error: "This project has no history here." }, 404);
|
|
6397
|
+
try {
|
|
6398
|
+
return c.json(await task(history, await bodyOf(c)) ?? null);
|
|
6399
|
+
} catch (error) {
|
|
6400
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
6401
|
+
}
|
|
6402
|
+
}
|
|
6403
|
+
function registerHistoryRoutes(api, adapter) {
|
|
6404
|
+
const windows = /* @__PURE__ */ new Map();
|
|
6405
|
+
const base = "/projects/:id/history";
|
|
6406
|
+
api.get(
|
|
6407
|
+
base,
|
|
6408
|
+
(c) => withHistory(adapter, c, (history) => {
|
|
6409
|
+
const entries = history.list();
|
|
6410
|
+
return { entries, back: nextStep(history, "back"), forward: nextStep(history, "forward") };
|
|
6411
|
+
})
|
|
6412
|
+
);
|
|
6413
|
+
api.post(
|
|
6414
|
+
`${base}/step`,
|
|
6415
|
+
(c) => withHistory(
|
|
6416
|
+
adapter,
|
|
6417
|
+
c,
|
|
6418
|
+
(history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU, writing(c))
|
|
6419
|
+
)
|
|
6420
|
+
);
|
|
6421
|
+
api.post(
|
|
6422
|
+
`${base}/undo`,
|
|
6423
|
+
(c) => withHistory(adapter, c, (history, body) => {
|
|
6424
|
+
const mode = UNDO_MODES.find((known) => known === body.mode);
|
|
6425
|
+
return history.undo(text(body.entryId) ?? "", { who: whoOf(body), mode, ...writing(c) });
|
|
6426
|
+
})
|
|
6427
|
+
);
|
|
6428
|
+
api.post(
|
|
6429
|
+
`${base}/restore`,
|
|
6430
|
+
(c) => withHistory(
|
|
6431
|
+
adapter,
|
|
6432
|
+
c,
|
|
6433
|
+
(history, body) => history.restore(text(body.point) ?? "", whoOf(body), writing(c))
|
|
6434
|
+
)
|
|
6435
|
+
);
|
|
6436
|
+
api.get(
|
|
6437
|
+
`${base}/peek/:point`,
|
|
6438
|
+
(c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
|
|
6439
|
+
);
|
|
6440
|
+
api.post(
|
|
6441
|
+
`${base}/pin`,
|
|
6442
|
+
(c) => withHistory(adapter, c, (history, body) => {
|
|
6443
|
+
history.pin(text(body.entryId) ?? "", body.pinned === true);
|
|
6444
|
+
return { ok: true };
|
|
6445
|
+
})
|
|
6446
|
+
);
|
|
6447
|
+
api.get(`${base}/blob/:hash`, async (c) => {
|
|
6448
|
+
const history = await historyOf(adapter, c);
|
|
6449
|
+
const hash = c.req.param("hash") ?? "";
|
|
6450
|
+
const missing = () => c.json({ error: "That file is not kept in this project's history." }, 404);
|
|
6451
|
+
if (!history || !/^[0-9a-f]{64}$/.test(hash)) return missing();
|
|
6452
|
+
const bytes = await history.readBlob(hash).catch(() => null);
|
|
6453
|
+
return bytes ? c.body(new Uint8Array(bytes)) : missing();
|
|
6454
|
+
});
|
|
6455
|
+
api.post(
|
|
6456
|
+
`${base}/claim`,
|
|
6457
|
+
(c) => withHistory(adapter, c, async (history, body) => {
|
|
6458
|
+
const paths = Array.isArray(body.paths) ? body.paths.filter((path) => text(path)) : [];
|
|
6459
|
+
const coalesceKey = text(body.coalesceKey) ?? void 0;
|
|
6460
|
+
const overwrote = versionsOf(body.overwrote);
|
|
6461
|
+
const claimed = await history.claim(YOU, text(body.label) ?? "Edited in Studio", paths, {
|
|
6462
|
+
...coalesceKey && { coalesceKey, idleMs: idleOf(body) ?? Infinity },
|
|
6463
|
+
...overwrote && { overwrote }
|
|
6464
|
+
});
|
|
6465
|
+
return { claimed };
|
|
6466
|
+
})
|
|
6467
|
+
);
|
|
6468
|
+
api.post(
|
|
6469
|
+
`${base}/window`,
|
|
6470
|
+
(c) => withHistory(adapter, c, async (history, body) => {
|
|
6471
|
+
const idleMs = idleOf(body);
|
|
6472
|
+
const window = await history.beginWindow(
|
|
6473
|
+
whoOf(body),
|
|
6474
|
+
text(body.label) ?? "Edited in Studio",
|
|
6475
|
+
idleMs ? { idleMs } : void 0
|
|
6476
|
+
);
|
|
6477
|
+
windows.set(window.id, { history, window });
|
|
6478
|
+
return { windowId: window.id, startedAt: window.startedAt };
|
|
6479
|
+
})
|
|
6480
|
+
);
|
|
6481
|
+
api.post(
|
|
6482
|
+
`${base}/window/:windowId/close`,
|
|
6483
|
+
(c) => withHistory(adapter, c, async (history) => {
|
|
6484
|
+
const id = c.req.param("windowId") ?? "";
|
|
6485
|
+
const held = windows.get(id);
|
|
6486
|
+
if (held?.history !== history) throw new Error("That window is not open in this project.");
|
|
6487
|
+
windows.delete(id);
|
|
6488
|
+
return { entry: await held.window.close() };
|
|
6489
|
+
})
|
|
6490
|
+
);
|
|
6491
|
+
}
|
|
6492
|
+
|
|
6493
|
+
// src/createStudioApi.ts
|
|
6494
|
+
function createStudioApi(adapter) {
|
|
6495
|
+
const api = new Hono();
|
|
6496
|
+
registerProjectRoutes(api, adapter);
|
|
6497
|
+
registerFileRoutes(api, adapter);
|
|
6498
|
+
registerPreviewRoutes(api, adapter);
|
|
6499
|
+
registerLintRoutes(api, adapter);
|
|
6500
|
+
registerRenderRoutes(api, adapter);
|
|
6501
|
+
registerThumbnailRoutes(api, adapter);
|
|
6502
|
+
registerSelectionRoutes(api, adapter);
|
|
6503
|
+
registerMediaRoutes(api, adapter);
|
|
6504
|
+
registerWaveformRoutes(api, adapter);
|
|
6505
|
+
registerFontRoutes(api);
|
|
6506
|
+
registerRegistryRoutes(api, adapter);
|
|
6507
|
+
registerGlobalAssetRoutes(api);
|
|
6508
|
+
registerHistoryRoutes(api, adapter);
|
|
6509
|
+
return api;
|
|
6510
|
+
}
|
|
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
|
+
|
|
6136
6536
|
// src/helpers/duplicateElement.ts
|
|
6137
6537
|
import { ensureHfIds as ensureHfIds4 } from "@hyperframes/parsers/hf-ids";
|
|
6138
6538
|
function duplicateElementInHtml(source, target, newId, at) {
|
|
@@ -6186,7 +6586,7 @@ function numericAttribute(element, name) {
|
|
|
6186
6586
|
|
|
6187
6587
|
// src/helpers/previewDocumentStore.ts
|
|
6188
6588
|
import { createHash as createHash7 } from "crypto";
|
|
6189
|
-
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";
|
|
6190
6590
|
import { join as join20 } from "path";
|
|
6191
6591
|
function createPreviewDocumentStore(dir, salt) {
|
|
6192
6592
|
const fileFor = (key) => join20(dir, `${createHash7("sha256").update(`${salt}
|
|
@@ -6204,8 +6604,8 @@ ${key}`).digest("hex").slice(0, 32)}.html`);
|
|
|
6204
6604
|
mkdirSync12(dir, { recursive: true });
|
|
6205
6605
|
const file = fileFor(key);
|
|
6206
6606
|
replaceFileAtomically(file, html, 420);
|
|
6207
|
-
for (const name of
|
|
6208
|
-
if (join20(dir, name) !== file)
|
|
6607
|
+
for (const name of readdirSync8(dir)) {
|
|
6608
|
+
if (join20(dir, name) !== file) rmSync7(join20(dir, name), { force: true });
|
|
6209
6609
|
}
|
|
6210
6610
|
} catch {
|
|
6211
6611
|
}
|
|
@@ -6285,10 +6685,13 @@ export {
|
|
|
6285
6685
|
DELETED_VERSION,
|
|
6286
6686
|
START as HISTORY_START,
|
|
6287
6687
|
HistoryBusyError,
|
|
6688
|
+
HistoryClosedError,
|
|
6689
|
+
MAX_WINDOW_IDLE_MS,
|
|
6288
6690
|
MIME_TYPES,
|
|
6289
6691
|
PREVIEW_BUNDLE_OPTIONS,
|
|
6290
6692
|
STUDIO_MANUAL_EDITS_PATH,
|
|
6291
6693
|
STUDIO_MOTION_PATH,
|
|
6694
|
+
UNDO_MODES,
|
|
6292
6695
|
affectsProjectSignature,
|
|
6293
6696
|
applyFileMutations,
|
|
6294
6697
|
buildSubCompositionHtml,
|
|
@@ -6307,6 +6710,7 @@ export {
|
|
|
6307
6710
|
findTargetElement,
|
|
6308
6711
|
getElementScreenshotClip,
|
|
6309
6712
|
getMimeType,
|
|
6713
|
+
historyCache,
|
|
6310
6714
|
identifyFileWrite,
|
|
6311
6715
|
isSafePath,
|
|
6312
6716
|
openProjectHistory,
|