@hyperframes/studio-server 0.8.82 → 0.8.84
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-G6IIFKXY.js → chunk-2YEGCDJW.js} +39 -20
- package/dist/chunk-2YEGCDJW.js.map +1 -0
- package/dist/{chunk-TJQ76IEA.js → chunk-I3DRHG52.js} +23 -71
- package/dist/chunk-I3DRHG52.js.map +1 -0
- package/dist/chunk-IG4DRZI2.js +1571 -0
- package/dist/chunk-IG4DRZI2.js.map +1 -0
- package/dist/chunk-PMXUKOC3.js +22 -0
- package/dist/chunk-PMXUKOC3.js.map +1 -0
- package/dist/chunk-TTWLTYSQ.js +79 -0
- package/dist/chunk-TTWLTYSQ.js.map +1 -0
- package/dist/chunk-WUHW3MWG.js +100 -0
- package/dist/chunk-WUHW3MWG.js.map +1 -0
- package/dist/{chunk-FTDTO3QP.js → chunk-YUPEBATT.js} +3 -3
- package/dist/{chunk-FTDTO3QP.js.map → chunk-YUPEBATT.js.map} +1 -1
- package/dist/helpers/hfIdPersist.d.ts +23 -0
- package/dist/helpers/hfIdPersist.js +13 -0
- package/dist/helpers/hfIdPersist.js.map +1 -0
- package/dist/helpers/mediaCodecMap.js +2 -1
- package/dist/helpers/mediaProxyPreview.d.ts +2 -1
- package/dist/helpers/mediaProxyPreview.js +4 -3
- package/dist/helpers/proxyTranscoder.d.ts +5 -2
- package/dist/helpers/proxyTranscoder.js +7 -2
- package/dist/helpers/safePath.d.ts +22 -0
- package/dist/helpers/safePath.js +27 -0
- package/dist/helpers/safePath.js.map +1 -0
- package/dist/history/index.d.ts +49 -0
- package/dist/history/index.js +29 -0
- package/dist/history/index.js.map +1 -0
- package/dist/index.d.ts +12 -54
- package/dist/index.js +357 -1915
- package/dist/index.js.map +1 -1
- package/dist/{mediaProxyPreview-BMHd9G3N.d.ts → mediaProxyPreview-CLu9FdIr.d.ts} +2 -139
- package/dist/projectHistory-Cm8Mdmmv.d.ts +139 -0
- package/package.json +15 -3
- package/dist/chunk-G6IIFKXY.js.map +0 -1
- package/dist/chunk-TJQ76IEA.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -14,6 +14,39 @@ import {
|
|
|
14
14
|
unwrapElementsFromHtml,
|
|
15
15
|
wrapElementsInHtml
|
|
16
16
|
} from "./chunk-W6TFBTP2.js";
|
|
17
|
+
import {
|
|
18
|
+
isCompositionSource,
|
|
19
|
+
stampProjectHfIds
|
|
20
|
+
} from "./chunk-WUHW3MWG.js";
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_HISTORY_ROOT,
|
|
23
|
+
DELETED_VERSION,
|
|
24
|
+
HistoryBusyError,
|
|
25
|
+
HistoryClosedError,
|
|
26
|
+
HistoryIdError,
|
|
27
|
+
MAX_WINDOW_IDLE_MS,
|
|
28
|
+
START,
|
|
29
|
+
STUDIO_SIGNATURE_MANIFEST_PATHS,
|
|
30
|
+
UNDO_MODES,
|
|
31
|
+
affectsProjectSignature,
|
|
32
|
+
clearFileWriteReceipt,
|
|
33
|
+
consumeFileWriteReceipt,
|
|
34
|
+
createProjectSignature,
|
|
35
|
+
createWriteToken,
|
|
36
|
+
fileContentVersion,
|
|
37
|
+
historyCache,
|
|
38
|
+
identifyFileWrite,
|
|
39
|
+
listProjectHistories,
|
|
40
|
+
openProjectHistory,
|
|
41
|
+
pruneGoneProjectHistories,
|
|
42
|
+
recordFileWriteReceipt,
|
|
43
|
+
resolveProjectAndSignature,
|
|
44
|
+
resolveProjectSignature,
|
|
45
|
+
settledFileTag
|
|
46
|
+
} from "./chunk-IG4DRZI2.js";
|
|
47
|
+
import {
|
|
48
|
+
replaceFileAtomically
|
|
49
|
+
} from "./chunk-PMXUKOC3.js";
|
|
17
50
|
import {
|
|
18
51
|
getElementScreenshotClip
|
|
19
52
|
} from "./chunk-W2SBTCO2.js";
|
|
@@ -22,28 +55,37 @@ import {
|
|
|
22
55
|
isAutoProxyEnabled,
|
|
23
56
|
proxyEtagSalt,
|
|
24
57
|
resolvePreviewMediaCodecProbeCache
|
|
25
|
-
} from "./chunk-
|
|
58
|
+
} from "./chunk-YUPEBATT.js";
|
|
26
59
|
import {
|
|
60
|
+
PROXY_PENDING_RETRY_AFTER_SECONDS,
|
|
27
61
|
ProxyCapacityError,
|
|
28
62
|
ProxyTranscodeError,
|
|
29
|
-
|
|
30
|
-
|
|
63
|
+
ProxyWaitTimeoutError,
|
|
64
|
+
proxyActivityMark,
|
|
65
|
+
resolveProxy,
|
|
66
|
+
waitForProxy
|
|
67
|
+
} from "./chunk-2YEGCDJW.js";
|
|
31
68
|
import {
|
|
32
69
|
PROXY_VARIANT_CONFIG,
|
|
33
70
|
decideMediaProxyEligibility,
|
|
34
|
-
isInHiddenOrVendorDir,
|
|
35
71
|
isProxyVariantRequest,
|
|
36
|
-
isSafePath,
|
|
37
|
-
pinWithinProject,
|
|
38
72
|
probeAssetCodec,
|
|
39
73
|
probeMediaMetadata,
|
|
74
|
+
recordProxyRequest,
|
|
75
|
+
resolveProxyVariantRequest
|
|
76
|
+
} from "./chunk-I3DRHG52.js";
|
|
77
|
+
import {
|
|
78
|
+
folderGone,
|
|
79
|
+
isInHiddenOrVendorDir,
|
|
80
|
+
isProjectRootMissing,
|
|
81
|
+
isSafePath,
|
|
82
|
+
mkdirWithinProject,
|
|
83
|
+
pinWithinProject,
|
|
40
84
|
realFilePath,
|
|
41
85
|
realpath,
|
|
42
|
-
recordProxyRequest,
|
|
43
|
-
resolveProxyVariantRequest,
|
|
44
86
|
resolveWithinProject,
|
|
45
87
|
walkDir
|
|
46
|
-
} from "./chunk-
|
|
88
|
+
} from "./chunk-TTWLTYSQ.js";
|
|
47
89
|
import {
|
|
48
90
|
STUDIO_MANUAL_EDITS_PATH,
|
|
49
91
|
createStudioManualEditsRenderBodyScript,
|
|
@@ -59,308 +101,13 @@ import { Hono } from "hono";
|
|
|
59
101
|
|
|
60
102
|
// src/routes/projects.ts
|
|
61
103
|
import { readFile } from "fs/promises";
|
|
62
|
-
import { join
|
|
63
|
-
|
|
64
|
-
// src/helpers/hfIdPersist.ts
|
|
65
|
-
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
66
|
-
import { createHash } from "crypto";
|
|
67
|
-
import {
|
|
68
|
-
closeSync,
|
|
69
|
-
constants,
|
|
70
|
-
fstatSync,
|
|
71
|
-
mkdirSync,
|
|
72
|
-
openSync,
|
|
73
|
-
readFileSync,
|
|
74
|
-
writeFileSync
|
|
75
|
-
} from "fs";
|
|
76
|
-
import { dirname, join } from "path";
|
|
77
|
-
|
|
78
|
-
// src/helpers/atomicFile.ts
|
|
79
|
-
import * as fs from "fs";
|
|
80
|
-
import { randomUUID } from "crypto";
|
|
81
|
-
function replaceFileAtomically(filePath, content, mode, operations = fs) {
|
|
82
|
-
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
83
|
-
try {
|
|
84
|
-
operations.writeFileSync(tempPath, content, { encoding: "utf-8", mode });
|
|
85
|
-
operations.chmodSync(tempPath, mode);
|
|
86
|
-
operations.renameSync(tempPath, filePath);
|
|
87
|
-
} catch (error) {
|
|
88
|
-
try {
|
|
89
|
-
operations.unlinkSync(tempPath);
|
|
90
|
-
} catch {
|
|
91
|
-
}
|
|
92
|
-
throw error;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// src/helpers/hfIdPersist.ts
|
|
97
|
-
var isCompositionSource = (html) => /data-composition-id\s*=/.test(html);
|
|
98
|
-
var STAMP_RECORD = join(".hyperframes", "hf-ids-stamped.json");
|
|
99
|
-
var contentHash = (text2) => createHash("sha1").update(text2).digest("base64url");
|
|
100
|
-
function stampProjectHfIds(projectDir) {
|
|
101
|
-
const recordPath = join(projectDir, STAMP_RECORD);
|
|
102
|
-
let last = {};
|
|
103
|
-
try {
|
|
104
|
-
last = JSON.parse(readFileSync(recordPath, "utf-8"));
|
|
105
|
-
} catch {
|
|
106
|
-
}
|
|
107
|
-
const next = {};
|
|
108
|
-
for (const file of walkDir(projectDir)) {
|
|
109
|
-
if (!file.endsWith(".html") || isInHiddenOrVendorDir(file)) continue;
|
|
110
|
-
const absPath = join(projectDir, file);
|
|
111
|
-
let html;
|
|
112
|
-
try {
|
|
113
|
-
html = readFileSync(absPath, "utf-8");
|
|
114
|
-
} catch {
|
|
115
|
-
continue;
|
|
116
|
-
}
|
|
117
|
-
let hash = contentHash(html);
|
|
118
|
-
if (last[file] !== hash && isCompositionSource(html)) {
|
|
119
|
-
const stamped = stampFileHfIds(absPath);
|
|
120
|
-
if (stamped === null) continue;
|
|
121
|
-
hash = contentHash(stamped);
|
|
122
|
-
}
|
|
123
|
-
next[file] = hash;
|
|
124
|
-
}
|
|
125
|
-
try {
|
|
126
|
-
mkdirSync(dirname(recordPath), { recursive: true });
|
|
127
|
-
writeFileSync(recordPath, JSON.stringify(next));
|
|
128
|
-
} catch {
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
function openNoFollow(filePath, flags) {
|
|
132
|
-
const noFollow = constants.O_NOFOLLOW ?? 0;
|
|
133
|
-
try {
|
|
134
|
-
return openSync(filePath, flags | noFollow);
|
|
135
|
-
} catch {
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
function isUnchanged(filePath, expected) {
|
|
140
|
-
const fd = openNoFollow(filePath, constants.O_RDONLY);
|
|
141
|
-
if (fd === null) return false;
|
|
142
|
-
try {
|
|
143
|
-
return readFileSync(fd, "utf-8") === expected;
|
|
144
|
-
} finally {
|
|
145
|
-
closeSync(fd);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
function stampFileHfIds(filePath) {
|
|
149
|
-
let fd = openNoFollow(filePath, constants.O_RDWR);
|
|
150
|
-
let writable = true;
|
|
151
|
-
if (fd === null) {
|
|
152
|
-
fd = openNoFollow(filePath, constants.O_RDONLY);
|
|
153
|
-
writable = false;
|
|
154
|
-
}
|
|
155
|
-
if (fd === null) return null;
|
|
156
|
-
try {
|
|
157
|
-
if (!fstatSync(fd).isFile()) return null;
|
|
158
|
-
const html = readFileSync(fd, "utf-8");
|
|
159
|
-
const normalized = ensureHfIds(html);
|
|
160
|
-
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;
|
|
161
|
-
const idsAfter = (normalized.match(/\bdata-hf-id=/g) ?? []).length;
|
|
162
|
-
if (writable && idsAfter > idsBefore) {
|
|
163
|
-
const mode = fstatSync(fd).mode;
|
|
164
|
-
closeSync(fd);
|
|
165
|
-
fd = null;
|
|
166
|
-
if (isUnchanged(filePath, html)) replaceFileAtomically(filePath, normalized, mode);
|
|
167
|
-
}
|
|
168
|
-
return normalized;
|
|
169
|
-
} catch (err) {
|
|
170
|
-
console.warn("[hyperframes] stampFileHfIds: failed to stamp ids:", err);
|
|
171
|
-
return null;
|
|
172
|
-
} finally {
|
|
173
|
-
if (fd !== null) closeSync(fd);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// src/helpers/projectSignature.ts
|
|
178
|
-
import { createHash as createHash2 } from "crypto";
|
|
179
|
-
import { lstatSync, readFileSync as readFileSync2, readdirSync } from "fs";
|
|
180
|
-
import { extname, isAbsolute, relative, resolve, sep } from "path";
|
|
181
|
-
var SIGNATURE_TEXT_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
182
|
-
".cjs",
|
|
183
|
-
".css",
|
|
184
|
-
".html",
|
|
185
|
-
".js",
|
|
186
|
-
".json",
|
|
187
|
-
".jsx",
|
|
188
|
-
".mjs",
|
|
189
|
-
".svg",
|
|
190
|
-
".ts",
|
|
191
|
-
".tsx"
|
|
192
|
-
]);
|
|
193
|
-
var SIGNATURE_EXCLUDED_DIRS = /* @__PURE__ */ new Set([
|
|
194
|
-
".cache",
|
|
195
|
-
".git",
|
|
196
|
-
".hyperframes",
|
|
197
|
-
".next",
|
|
198
|
-
".thumbnails",
|
|
199
|
-
".transcode-cache",
|
|
200
|
-
".vite",
|
|
201
|
-
".waveform-cache",
|
|
202
|
-
"build",
|
|
203
|
-
"coverage",
|
|
204
|
-
"dist",
|
|
205
|
-
"node_modules",
|
|
206
|
-
"outputs",
|
|
207
|
-
"renders"
|
|
208
|
-
]);
|
|
209
|
-
var MAX_SIGNATURE_TEXT_BYTES = 2e6;
|
|
210
|
-
var STUDIO_SIGNATURE_MANIFEST_PATHS = [
|
|
211
|
-
".hyperframes/studio-manual-edits.json",
|
|
212
|
-
".hyperframes/studio-motion.json"
|
|
213
|
-
];
|
|
214
|
-
function affectsProjectSignature(projectDir, changedPath) {
|
|
215
|
-
const relativePath = relative(resolve(projectDir), resolve(changedPath));
|
|
216
|
-
if (relativePath === "" || relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
|
217
|
-
return false;
|
|
218
|
-
}
|
|
219
|
-
const segments = relativePath.split(sep);
|
|
220
|
-
if (STUDIO_SIGNATURE_MANIFEST_PATHS.includes(segments.join("/"))) return true;
|
|
221
|
-
return !segments.some((segment) => SIGNATURE_EXCLUDED_DIRS.has(segment));
|
|
222
|
-
}
|
|
223
|
-
var projectSignatureCache = /* @__PURE__ */ new Map();
|
|
224
|
-
function isPathWithin(parentDir, childPath) {
|
|
225
|
-
const childRelativePath = relative(parentDir, childPath);
|
|
226
|
-
return childRelativePath === "" || !childRelativePath.startsWith("..") && !isAbsolute(childRelativePath);
|
|
227
|
-
}
|
|
228
|
-
function isTextContentEligible(file, size) {
|
|
229
|
-
return SIGNATURE_TEXT_EXTENSIONS.has(extname(file).toLowerCase()) && size <= MAX_SIGNATURE_TEXT_BYTES;
|
|
230
|
-
}
|
|
231
|
-
function collectProjectSignatureFiles(projectDir, dir, files) {
|
|
232
|
-
let entries;
|
|
233
|
-
try {
|
|
234
|
-
entries = readdirSync(dir).sort();
|
|
235
|
-
} catch {
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
for (const entry of entries) {
|
|
239
|
-
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
|
240
|
-
const file = resolve(dir, entry);
|
|
241
|
-
if (!isPathWithin(projectDir, file)) continue;
|
|
242
|
-
let stat3;
|
|
243
|
-
try {
|
|
244
|
-
stat3 = lstatSync(file);
|
|
245
|
-
} catch {
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
if (stat3.isSymbolicLink()) continue;
|
|
249
|
-
if (stat3.isDirectory()) {
|
|
250
|
-
collectProjectSignatureFiles(projectDir, file, files);
|
|
251
|
-
} else if (stat3.isFile()) {
|
|
252
|
-
files.push({
|
|
253
|
-
file,
|
|
254
|
-
mtimeMs: stat3.mtimeMs,
|
|
255
|
-
ctimeMs: stat3.ctimeMs,
|
|
256
|
-
size: stat3.size,
|
|
257
|
-
textContentEligible: isTextContentEligible(file, stat3.size)
|
|
258
|
-
});
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
263
|
-
const seen = new Set(files.map((entry) => entry.file));
|
|
264
|
-
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
|
|
265
|
-
const file = resolve(projectDir, manifestPath);
|
|
266
|
-
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
|
|
267
|
-
let stat3;
|
|
268
|
-
try {
|
|
269
|
-
stat3 = lstatSync(file);
|
|
270
|
-
} catch {
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
if (stat3.isSymbolicLink() || !stat3.isFile()) continue;
|
|
274
|
-
files.push({
|
|
275
|
-
file,
|
|
276
|
-
mtimeMs: stat3.mtimeMs,
|
|
277
|
-
ctimeMs: stat3.ctimeMs,
|
|
278
|
-
size: stat3.size,
|
|
279
|
-
textContentEligible: isTextContentEligible(file, stat3.size)
|
|
280
|
-
});
|
|
281
|
-
seen.add(file);
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
function createProjectFingerprint(projectDir, files) {
|
|
285
|
-
const hash = createHash2("sha256");
|
|
286
|
-
for (const entry of files) {
|
|
287
|
-
hash.update(relative(projectDir, entry.file));
|
|
288
|
-
hash.update("\0");
|
|
289
|
-
hash.update(String(entry.size));
|
|
290
|
-
hash.update("\0");
|
|
291
|
-
hash.update(String(entry.mtimeMs));
|
|
292
|
-
hash.update("\0");
|
|
293
|
-
hash.update(String(entry.ctimeMs));
|
|
294
|
-
hash.update("\0");
|
|
295
|
-
hash.update(entry.textContentEligible ? "text" : "binary");
|
|
296
|
-
hash.update("\0");
|
|
297
|
-
}
|
|
298
|
-
return hash.digest("hex").slice(0, 24);
|
|
299
|
-
}
|
|
300
|
-
function collectProjectFiles(normalizedProjectDir) {
|
|
301
|
-
const collected = [];
|
|
302
|
-
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
|
|
303
|
-
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
|
|
304
|
-
return collected;
|
|
305
|
-
}
|
|
306
|
-
function listProjectFiles(projectDir) {
|
|
307
|
-
const normalizedProjectDir = resolve(projectDir);
|
|
308
|
-
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
309
|
-
path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
|
|
310
|
-
size: entry.size,
|
|
311
|
-
mtimeMs: entry.mtimeMs,
|
|
312
|
-
ctimeMs: entry.ctimeMs
|
|
313
|
-
}));
|
|
314
|
-
}
|
|
315
|
-
function resolveProjectSignature(adapter, projectDir) {
|
|
316
|
-
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
|
|
317
|
-
}
|
|
318
|
-
async function resolveProjectAndSignature(adapter, projectId) {
|
|
319
|
-
const project = await adapter.resolveProject(projectId);
|
|
320
|
-
if (!project) return null;
|
|
321
|
-
return { project, signature: resolveProjectSignature(adapter, project.dir) };
|
|
322
|
-
}
|
|
323
|
-
function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
|
|
324
|
-
const normalizedProjectDir = resolve(projectDir);
|
|
325
|
-
const collected = collectProjectFiles(normalizedProjectDir);
|
|
326
|
-
const files = collected.filter(
|
|
327
|
-
(entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
|
|
328
|
-
);
|
|
329
|
-
files.sort((a, b) => a.file.localeCompare(b.file));
|
|
330
|
-
const fingerprint = createProjectFingerprint(normalizedProjectDir, files);
|
|
331
|
-
const cacheKey = excluding.size ? `${normalizedProjectDir}\0${createHash2("sha256").update([...excluding].sort().join("\0")).digest("hex")}` : normalizedProjectDir;
|
|
332
|
-
const cached = projectSignatureCache.get(cacheKey);
|
|
333
|
-
if (cached?.fingerprint === fingerprint) return cached.signature;
|
|
334
|
-
const hash = createHash2("sha256");
|
|
335
|
-
for (const entry of files) {
|
|
336
|
-
const relativePath = relative(normalizedProjectDir, entry.file);
|
|
337
|
-
hash.update(relativePath);
|
|
338
|
-
hash.update("\0");
|
|
339
|
-
hash.update(String(entry.size));
|
|
340
|
-
hash.update("\0");
|
|
341
|
-
if (entry.textContentEligible) {
|
|
342
|
-
try {
|
|
343
|
-
hash.update(readFileSync2(entry.file));
|
|
344
|
-
} catch {
|
|
345
|
-
hash.update(String(entry.mtimeMs));
|
|
346
|
-
}
|
|
347
|
-
} else {
|
|
348
|
-
hash.update(String(entry.mtimeMs));
|
|
349
|
-
}
|
|
350
|
-
hash.update("\0");
|
|
351
|
-
}
|
|
352
|
-
const signature = hash.digest("hex").slice(0, 24);
|
|
353
|
-
projectSignatureCache.set(cacheKey, { fingerprint, signature });
|
|
354
|
-
return signature;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// src/routes/projects.ts
|
|
104
|
+
import { join } from "path";
|
|
358
105
|
async function filterCompositionFiles(projectDir, files) {
|
|
359
106
|
const htmlFiles = files.filter((f) => f.endsWith(".html") && !isInHiddenOrVendorDir(f));
|
|
360
107
|
const checks = await Promise.all(
|
|
361
108
|
htmlFiles.map(async (f) => {
|
|
362
109
|
try {
|
|
363
|
-
const content = await readFile(
|
|
110
|
+
const content = await readFile(join(projectDir, f), "utf-8");
|
|
364
111
|
return isCompositionSource(content);
|
|
365
112
|
} catch {
|
|
366
113
|
return false;
|
|
@@ -400,23 +147,22 @@ function registerProjectRoutes(api, adapter) {
|
|
|
400
147
|
// src/routes/files.ts
|
|
401
148
|
import { bodyLimit } from "hono/body-limit";
|
|
402
149
|
import {
|
|
403
|
-
closeSync
|
|
150
|
+
closeSync,
|
|
404
151
|
existsSync as existsSync3,
|
|
405
|
-
lstatSync as
|
|
406
|
-
openSync
|
|
407
|
-
readFileSync as
|
|
152
|
+
lstatSync as lstatSync2,
|
|
153
|
+
openSync,
|
|
154
|
+
readFileSync as readFileSync4,
|
|
408
155
|
readlinkSync,
|
|
409
|
-
writeFileSync as
|
|
156
|
+
writeFileSync as writeFileSync4,
|
|
410
157
|
writeSync,
|
|
411
|
-
mkdirSync as mkdirSync4,
|
|
412
158
|
unlinkSync as unlinkSync2,
|
|
413
159
|
rmSync as rmSync3,
|
|
414
160
|
statSync as statSync3,
|
|
415
|
-
fstatSync
|
|
161
|
+
fstatSync,
|
|
416
162
|
renameSync as renameSync2,
|
|
417
|
-
readdirSync as
|
|
163
|
+
readdirSync as readdirSync2
|
|
418
164
|
} from "fs";
|
|
419
|
-
import { resolve as
|
|
165
|
+
import { resolve as resolve2, dirname as dirname3, join as join5 } from "path";
|
|
420
166
|
|
|
421
167
|
// src/helpers/mime.ts
|
|
422
168
|
var MIME_TYPES = {
|
|
@@ -468,15 +214,15 @@ function isAudioFile(name) {
|
|
|
468
214
|
import { spawn } from "child_process";
|
|
469
215
|
import {
|
|
470
216
|
existsSync,
|
|
471
|
-
writeFileSync
|
|
472
|
-
mkdirSync
|
|
473
|
-
lstatSync
|
|
217
|
+
writeFileSync,
|
|
218
|
+
mkdirSync,
|
|
219
|
+
lstatSync,
|
|
474
220
|
mkdtempSync,
|
|
475
221
|
renameSync,
|
|
476
222
|
rmSync,
|
|
477
223
|
statSync
|
|
478
224
|
} from "fs";
|
|
479
|
-
import { dirname
|
|
225
|
+
import { dirname, join as join2 } from "path";
|
|
480
226
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
|
481
227
|
var SAMPLE_RATE = 4e3;
|
|
482
228
|
var PEAK_COUNT = 4e3;
|
|
@@ -543,32 +289,32 @@ function decodeAudioPeaks(audioPath) {
|
|
|
543
289
|
});
|
|
544
290
|
}
|
|
545
291
|
async function generateWaveformCache(projectDir, assetPath) {
|
|
546
|
-
const audioPath =
|
|
292
|
+
const audioPath = join2(projectDir, assetPath);
|
|
547
293
|
if (!existsSync(audioPath)) return;
|
|
548
294
|
const stats = statSync(audioPath);
|
|
549
|
-
const cacheDir =
|
|
550
|
-
const cachePath =
|
|
295
|
+
const cacheDir = join2(projectDir, ".waveform-cache");
|
|
296
|
+
const cachePath = join2(cacheDir, buildWaveformCacheKey(assetPath, stats));
|
|
551
297
|
if (isWaveformCacheDirectory(cacheDir) && existsSync(cachePath)) return;
|
|
552
298
|
const peaks = await decodeAudioPeaks(audioPath);
|
|
553
299
|
writeWaveformCache(cachePath, peaks);
|
|
554
300
|
}
|
|
555
301
|
function isWaveformCacheDirectory(cacheDir) {
|
|
556
|
-
return
|
|
302
|
+
return lstatSync(cacheDir, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
557
303
|
}
|
|
558
304
|
function writeWaveformCache(cachePath, peaks) {
|
|
559
|
-
const cacheDir =
|
|
305
|
+
const cacheDir = dirname(cachePath);
|
|
560
306
|
try {
|
|
561
|
-
|
|
307
|
+
mkdirSync(cacheDir, { mode: 448 });
|
|
562
308
|
} catch (error) {
|
|
563
309
|
if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
|
|
564
310
|
}
|
|
565
311
|
if (!isWaveformCacheDirectory(cacheDir)) {
|
|
566
312
|
throw new Error("Waveform cache must be a directory, not a symlink");
|
|
567
313
|
}
|
|
568
|
-
const stagingDir = mkdtempSync(
|
|
314
|
+
const stagingDir = mkdtempSync(join2(cacheDir, ".waveform-"));
|
|
569
315
|
try {
|
|
570
|
-
const stagingPath =
|
|
571
|
-
|
|
316
|
+
const stagingPath = join2(stagingDir, "peaks.json");
|
|
317
|
+
writeFileSync(stagingPath, JSON.stringify(peaks), { flag: "wx" });
|
|
572
318
|
renameSync(stagingPath, cachePath);
|
|
573
319
|
} finally {
|
|
574
320
|
try {
|
|
@@ -580,12 +326,12 @@ function writeWaveformCache(cachePath, peaks) {
|
|
|
580
326
|
|
|
581
327
|
// src/helpers/mediaValidation.ts
|
|
582
328
|
import { spawnSync } from "child_process";
|
|
583
|
-
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as
|
|
329
|
+
import { mkdtempSync as mkdtempSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
584
330
|
import { tmpdir } from "os";
|
|
585
|
-
import { basename, join as
|
|
331
|
+
import { basename, join as join3 } from "path";
|
|
586
332
|
import { FFPROBE_PATH_ENV, findFfBinary as findFfBinary2 } from "@hyperframes/parsers/ff-binaries";
|
|
587
333
|
var VIDEO_EXT = /\.(mp4|webm|mov|mkv|avi|m4v|mxf|mts|m2ts|ts)$/i;
|
|
588
|
-
var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac)$/i;
|
|
334
|
+
var AUDIO_EXT = /\.(mp3|wav|ogg|m4a|aac|flac)$/i;
|
|
589
335
|
var defaultRunner = spawnSync;
|
|
590
336
|
var FFPROBE_MISSING = "not checked: ffprobe was not found. Install FFmpeg or set HYPERFRAMES_FFPROBE_PATH.";
|
|
591
337
|
function validateUploadedMedia(filePath, runner = defaultRunner) {
|
|
@@ -633,10 +379,10 @@ function checkStreams(stdout, isVideo, isAudio) {
|
|
|
633
379
|
}
|
|
634
380
|
}
|
|
635
381
|
function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
|
|
636
|
-
const tempDir = mkdtempSync2(
|
|
637
|
-
const tempPath =
|
|
382
|
+
const tempDir = mkdtempSync2(join3(tmpdir(), "hyperframes-upload-"));
|
|
383
|
+
const tempPath = join3(tempDir, basename(fileName));
|
|
638
384
|
try {
|
|
639
|
-
|
|
385
|
+
writeFileSync2(tempPath, buffer);
|
|
640
386
|
return validateUploadedMedia(tempPath, runner);
|
|
641
387
|
} finally {
|
|
642
388
|
rmSync2(tempDir, { recursive: true, force: true });
|
|
@@ -644,9 +390,9 @@ function validateUploadedMediaBuffer(fileName, buffer, runner = defaultRunner) {
|
|
|
644
390
|
}
|
|
645
391
|
|
|
646
392
|
// src/helpers/backupJournal.ts
|
|
647
|
-
import {
|
|
393
|
+
import { readdirSync, readFileSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
|
|
648
394
|
import { Buffer as Buffer2 } from "buffer";
|
|
649
|
-
import { join as
|
|
395
|
+
import { join as join4, relative } from "path";
|
|
650
396
|
var DEFAULT_KEEP_PER_FILE = 10;
|
|
651
397
|
function backupKeyForPath(path) {
|
|
652
398
|
return Buffer2.from(path, "utf-8").toString("base64url");
|
|
@@ -656,23 +402,24 @@ function timestampPrefix() {
|
|
|
656
402
|
}
|
|
657
403
|
function backupPathForResponse(projectDir, backupPath) {
|
|
658
404
|
if (!backupPath) return null;
|
|
659
|
-
const rel =
|
|
405
|
+
const rel = relative(projectDir, backupPath);
|
|
660
406
|
if (!rel || rel.startsWith("..")) return null;
|
|
661
407
|
return rel.split("\\").join("/");
|
|
662
408
|
}
|
|
663
409
|
function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
664
410
|
if (!isSafePath(projectDir, absPath)) return { backupPath: null };
|
|
665
411
|
try {
|
|
666
|
-
const content =
|
|
667
|
-
const relativePath =
|
|
668
|
-
const backupDir =
|
|
669
|
-
|
|
412
|
+
const content = readFileSync(absPath);
|
|
413
|
+
const relativePath = relative(projectDir, absPath);
|
|
414
|
+
const backupDir = join4(projectDir, ".hyperframes", "backup");
|
|
415
|
+
mkdirWithinProject(projectDir, backupDir);
|
|
670
416
|
const backupKey = backupKeyForPath(relativePath);
|
|
671
417
|
const backupPath = nextBackupPath(backupDir, backupKey);
|
|
672
|
-
|
|
418
|
+
writeFileSync3(backupPath, content);
|
|
673
419
|
pruneBackups(backupDir, backupKey, options.keepPerFile ?? DEFAULT_KEEP_PER_FILE);
|
|
674
420
|
return { backupPath };
|
|
675
421
|
} catch (error) {
|
|
422
|
+
if (isProjectRootMissing(error)) throw error;
|
|
676
423
|
if (error && typeof error === "object" && "code" in error && (error.code === "ENOENT" || error.code === "EISDIR")) {
|
|
677
424
|
return { backupPath: null };
|
|
678
425
|
}
|
|
@@ -681,18 +428,18 @@ function snapshotBeforeWrite(projectDir, absPath, options = {}) {
|
|
|
681
428
|
}
|
|
682
429
|
function nextBackupPath(backupDir, backupKey) {
|
|
683
430
|
const base = `${timestampPrefix()}-${backupKey}`;
|
|
684
|
-
let candidate =
|
|
431
|
+
let candidate = join4(backupDir, base);
|
|
685
432
|
let counter = 2;
|
|
686
433
|
while (true) {
|
|
687
434
|
try {
|
|
688
|
-
|
|
435
|
+
readFileSync(candidate);
|
|
689
436
|
} catch (error) {
|
|
690
437
|
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
|
691
438
|
return candidate;
|
|
692
439
|
}
|
|
693
440
|
throw error;
|
|
694
441
|
}
|
|
695
|
-
candidate =
|
|
442
|
+
candidate = join4(backupDir, `${base}-${counter}`);
|
|
696
443
|
counter += 1;
|
|
697
444
|
}
|
|
698
445
|
}
|
|
@@ -700,7 +447,7 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
|
|
|
700
447
|
const keep = Math.max(1, Math.floor(keepPerFile));
|
|
701
448
|
const suffix = `-${backupKey}`;
|
|
702
449
|
const numberedSuffix = new RegExp(`-${backupKey}-\\d+$`);
|
|
703
|
-
const matches =
|
|
450
|
+
const matches = readdirSync(backupDir).filter((name) => name.endsWith(suffix) || numberedSuffix.test(name)).map((name) => join4(backupDir, name)).sort((a, b) => {
|
|
704
451
|
return b.localeCompare(a);
|
|
705
452
|
});
|
|
706
453
|
for (const file of matches.slice(keep)) {
|
|
@@ -711,96 +458,37 @@ function pruneBackups(backupDir, backupKey, keepPerFile) {
|
|
|
711
458
|
}
|
|
712
459
|
}
|
|
713
460
|
|
|
714
|
-
// src/helpers/
|
|
715
|
-
|
|
716
|
-
var
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
}
|
|
721
|
-
var DELETED_VERSION = '"deleted"';
|
|
722
|
-
function hashVersion(hex) {
|
|
723
|
-
return `"sha256:${hex}"`;
|
|
724
|
-
}
|
|
725
|
-
function hashOfVersion(version) {
|
|
726
|
-
return /^"sha256:([0-9a-f]{64})"$/.exec(version)?.[1];
|
|
727
|
-
}
|
|
728
|
-
function settledFileTag(stat3, now = Date.now()) {
|
|
729
|
-
if (now - stat3.ctimeMs < 3e3) return null;
|
|
730
|
-
return [stat3.ino, stat3.ctimeMs, stat3.size].map((n) => n.toString(36)).join("-");
|
|
731
|
-
}
|
|
732
|
-
function createWriteToken(requestToken) {
|
|
733
|
-
const token = requestToken?.trim();
|
|
734
|
-
return token && token.length <= 200 ? token : randomUUID2();
|
|
735
|
-
}
|
|
736
|
-
function recordFileWriteReceipt(filePath, receipt) {
|
|
737
|
-
const absPath = realFilePath(filePath);
|
|
738
|
-
const now = Date.now();
|
|
739
|
-
for (const [path, list] of receipts) {
|
|
740
|
-
const live = list.filter((entry) => now - entry.recordedAt < RECEIPT_TTL_MS);
|
|
741
|
-
if (live.length > 0) receipts.set(path, live);
|
|
742
|
-
else receipts.delete(path);
|
|
743
|
-
}
|
|
744
|
-
receipts.set(absPath, [...receipts.get(absPath) ?? [], { ...receipt, recordedAt: now }]);
|
|
745
|
-
}
|
|
746
|
-
function clearFileWriteReceipt(filePath, version, writeToken) {
|
|
747
|
-
const absPath = realFilePath(filePath);
|
|
748
|
-
const current = (receipts.get(absPath) ?? []).filter(
|
|
749
|
-
(entry) => entry.version !== version || entry.writeToken !== writeToken
|
|
750
|
-
);
|
|
751
|
-
if (current.length > 0) receipts.set(absPath, current);
|
|
752
|
-
else receipts.delete(absPath);
|
|
753
|
-
}
|
|
754
|
-
function identifyFileWrite(filePath, expectedVersion) {
|
|
755
|
-
const receipt = newestReceipt(realFilePath(filePath), expectedVersion);
|
|
756
|
-
if (!receipt) return null;
|
|
757
|
-
const { path, version, writeToken } = receipt;
|
|
758
|
-
return { path, version, writeToken };
|
|
759
|
-
}
|
|
760
|
-
function bytesOverwrittenBy(filePath, version) {
|
|
761
|
-
return newestReceipt(realFilePath(filePath), version)?.overwrote;
|
|
762
|
-
}
|
|
763
|
-
function forgetOverwrittenBytes(filePath, versions) {
|
|
764
|
-
for (const receipt of receipts.get(realFilePath(filePath)) ?? [])
|
|
765
|
-
if (versions.has(receipt.version)) delete receipt.overwrote;
|
|
766
|
-
}
|
|
767
|
-
function newestReceipt(absPath, expectedVersion) {
|
|
768
|
-
const now = Date.now();
|
|
769
|
-
const current = (receipts.get(absPath) ?? []).filter(
|
|
770
|
-
(entry) => now - entry.recordedAt < RECEIPT_TTL_MS
|
|
771
|
-
);
|
|
772
|
-
if (current.length > 0) receipts.set(absPath, current);
|
|
773
|
-
else receipts.delete(absPath);
|
|
774
|
-
let receipt;
|
|
775
|
-
for (let i = current.length - 1; i >= 0 && !receipt; i -= 1) {
|
|
776
|
-
if (current[i]?.version === expectedVersion) receipt = current[i];
|
|
777
|
-
}
|
|
778
|
-
return receipt;
|
|
461
|
+
// src/helpers/projectDirMissing.ts
|
|
462
|
+
var PROJECT_DIR_MISSING = { error: "not found", why: "project_dir_missing" };
|
|
463
|
+
var projectDirMissing = (c) => c.json(PROJECT_DIR_MISSING, 404);
|
|
464
|
+
function replaceWithProjectDirMissing(c, hostHeaders) {
|
|
465
|
+
hostHeaders.delete("content-type");
|
|
466
|
+
c.res = void 0;
|
|
467
|
+
c.res = Response.json(PROJECT_DIR_MISSING, { status: 404, headers: hostHeaders });
|
|
779
468
|
}
|
|
780
|
-
var consumeFileWriteReceipt = identifyFileWrite;
|
|
781
469
|
|
|
782
470
|
// src/helpers/applyFileMutations.ts
|
|
783
|
-
import { readFileSync as
|
|
784
|
-
function applyFileMutations(projectDir, mutations, requestToken,
|
|
471
|
+
import { readFileSync as readFileSync2, statSync as statSync2 } from "fs";
|
|
472
|
+
function applyFileMutations(projectDir, mutations, requestToken, writeFile = (path, content) => replaceFileAtomically(path, content, statSync2(path).mode)) {
|
|
785
473
|
const prepared = mutations.map((mutation) => ({
|
|
786
474
|
...mutation,
|
|
787
|
-
before: mutation.before ??
|
|
475
|
+
before: mutation.before ?? readFileSync2(mutation.absPath, "utf-8")
|
|
788
476
|
}));
|
|
789
477
|
const results = [];
|
|
790
478
|
const attempted = [];
|
|
791
479
|
for (const mutation of prepared)
|
|
792
|
-
assertExpectedVersion(mutation,
|
|
480
|
+
assertExpectedVersion(mutation, readFileSync2(mutation.absPath, "utf-8"));
|
|
793
481
|
try {
|
|
794
482
|
for (const mutation of prepared) {
|
|
795
|
-
results.push(applyOneMutation(projectDir, mutation, requestToken,
|
|
483
|
+
results.push(applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted));
|
|
796
484
|
}
|
|
797
485
|
return results;
|
|
798
486
|
} catch (error) {
|
|
799
487
|
const rollbackErrors = [];
|
|
800
488
|
for (const mutation of attempted.reverse()) {
|
|
801
489
|
try {
|
|
802
|
-
if (!mutation.written ||
|
|
803
|
-
|
|
490
|
+
if (!mutation.written || readFileSync2(mutation.absPath, "utf-8") === mutation.after)
|
|
491
|
+
writeFile(mutation.absPath, mutation.before, "utf-8");
|
|
804
492
|
clearFileWriteReceipt(mutation.absPath, mutation.version, mutation.writeToken);
|
|
805
493
|
} catch (rollbackError) {
|
|
806
494
|
rollbackErrors.push(rollbackError);
|
|
@@ -815,8 +503,8 @@ function applyFileMutations(projectDir, mutations, requestToken, writeFile2 = (p
|
|
|
815
503
|
throw error;
|
|
816
504
|
}
|
|
817
505
|
}
|
|
818
|
-
function applyOneMutation(projectDir, mutation, requestToken,
|
|
819
|
-
const current =
|
|
506
|
+
function applyOneMutation(projectDir, mutation, requestToken, writeFile, attempted) {
|
|
507
|
+
const current = readFileSync2(mutation.absPath, "utf-8");
|
|
820
508
|
assertExpectedVersion(mutation, current);
|
|
821
509
|
if (mutation.after === mutation.before) {
|
|
822
510
|
return {
|
|
@@ -830,13 +518,13 @@ function applyOneMutation(projectDir, mutation, requestToken, writeFile2, attemp
|
|
|
830
518
|
}
|
|
831
519
|
const backup = snapshotBeforeWrite(projectDir, mutation.absPath);
|
|
832
520
|
if (backup.error) throw new Error(`backup failed: ${backup.error}`);
|
|
833
|
-
assertExpectedVersion(mutation,
|
|
521
|
+
assertExpectedVersion(mutation, readFileSync2(mutation.absPath, "utf-8"));
|
|
834
522
|
const before = current;
|
|
835
523
|
const version = fileContentVersion(mutation.after);
|
|
836
524
|
const writeToken = createWriteToken(requestToken);
|
|
837
525
|
const attempt = { ...mutation, before, version, writeToken, written: false };
|
|
838
526
|
attempted.push(attempt);
|
|
839
|
-
|
|
527
|
+
writeFile(mutation.absPath, mutation.after, "utf-8");
|
|
840
528
|
attempt.written = true;
|
|
841
529
|
recordFileWriteReceipt(mutation.absPath, {
|
|
842
530
|
path: mutation.sourceFile,
|
|
@@ -898,13 +586,14 @@ import {
|
|
|
898
586
|
syncPositionHoldsBeforeKeyframes
|
|
899
587
|
} from "@hyperframes/parsers/gsap-writer-acorn";
|
|
900
588
|
import { parseHTML as parseHTML2 } from "linkedom";
|
|
901
|
-
import { ensureHfIds
|
|
589
|
+
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
|
|
902
590
|
|
|
903
591
|
// src/helpers/compositionInsertion.ts
|
|
904
|
-
import { existsSync as existsSync2, readFileSync as
|
|
905
|
-
import { randomUUID
|
|
906
|
-
import { dirname as
|
|
592
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "fs";
|
|
593
|
+
import { randomUUID } from "crypto";
|
|
594
|
+
import { dirname as dirname2, relative as relative2, resolve, sep } from "path";
|
|
907
595
|
import { parseHTML } from "linkedom";
|
|
596
|
+
import { ProjectRootMissingError } from "@hyperframes/core";
|
|
908
597
|
var CompositionInsertionError = class extends Error {
|
|
909
598
|
constructor(message, status) {
|
|
910
599
|
super(message);
|
|
@@ -934,6 +623,7 @@ function positiveAttribute(root, ...names) {
|
|
|
934
623
|
}
|
|
935
624
|
function canonicalProjectPath(projectDir, candidate) {
|
|
936
625
|
if (!candidate) {
|
|
626
|
+
if (folderGone(projectDir)) throw new ProjectRootMissingError(projectDir);
|
|
937
627
|
throw new CompositionInsertionError("Composition source escapes the project", 400);
|
|
938
628
|
}
|
|
939
629
|
if (!existsSync2(candidate)) {
|
|
@@ -958,7 +648,7 @@ function canonicalDependency(projectDir, ownerAbs, sourcePath) {
|
|
|
958
648
|
validateSourcePath(sourcePath);
|
|
959
649
|
return canonicalProjectPath(
|
|
960
650
|
projectDir,
|
|
961
|
-
resolveWithinProject(projectDir,
|
|
651
|
+
resolveWithinProject(projectDir, relative2(projectDir, resolve(dirname2(ownerAbs), sourcePath)))
|
|
962
652
|
);
|
|
963
653
|
}
|
|
964
654
|
function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
|
|
@@ -973,7 +663,7 @@ function validateDependencyGraph(projectDir, targetAbs, sourceAbs) {
|
|
|
973
663
|
}
|
|
974
664
|
if (visited.has(file)) return;
|
|
975
665
|
visiting.add(file);
|
|
976
|
-
const source =
|
|
666
|
+
const source = readFileSync3(file, "utf-8");
|
|
977
667
|
const { document } = compositionRoot(source);
|
|
978
668
|
for (const host of descendants(document, "[data-composition-src]")) {
|
|
979
669
|
const dependency = host.getAttribute("data-composition-src");
|
|
@@ -1031,13 +721,13 @@ function uniqueHostId(root, base) {
|
|
|
1031
721
|
return `${base}_${suffix}`;
|
|
1032
722
|
}
|
|
1033
723
|
function relativeSourcePath(targetAbs, sourceAbs) {
|
|
1034
|
-
return
|
|
724
|
+
return relative2(dirname2(targetAbs), sourceAbs).split(sep).join("/");
|
|
1035
725
|
}
|
|
1036
726
|
function insertCompositionIntoSource(input) {
|
|
1037
727
|
const targetAbs = canonicalProjectFile(input.projectDir, input.targetPath);
|
|
1038
728
|
const sourceAbs = canonicalProjectFile(input.projectDir, input.sourcePath);
|
|
1039
729
|
validateDependencyGraph(input.projectDir, targetAbs, sourceAbs);
|
|
1040
|
-
const source =
|
|
730
|
+
const source = readFileSync3(sourceAbs, "utf-8");
|
|
1041
731
|
const sourceComposition = compositionRoot(source).root;
|
|
1042
732
|
const duration = positiveAttribute(
|
|
1043
733
|
sourceComposition,
|
|
@@ -1066,7 +756,7 @@ function insertCompositionIntoSource(input) {
|
|
|
1066
756
|
const host = document.createElement("div");
|
|
1067
757
|
host.id = hostId;
|
|
1068
758
|
host.className = "clip";
|
|
1069
|
-
host.setAttribute("data-hf-id", `hf-${
|
|
759
|
+
host.setAttribute("data-hf-id", `hf-${randomUUID()}`);
|
|
1070
760
|
host.setAttribute("data-composition-id", hostId);
|
|
1071
761
|
host.setAttribute("data-composition-src", relativeSourcePath(targetAbs, sourceAbs));
|
|
1072
762
|
host.setAttribute("data-start", String(Math.round(input.start * 100) / 100));
|
|
@@ -1163,15 +853,15 @@ async function loadGsapParser() {
|
|
|
1163
853
|
return import("@hyperframes/parsers/gsap-parser-recast");
|
|
1164
854
|
}
|
|
1165
855
|
function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
1166
|
-
if (!isSafePath(projectDir,
|
|
856
|
+
if (!isSafePath(projectDir, dirname3(lexicalPath))) return false;
|
|
1167
857
|
let stats;
|
|
1168
858
|
try {
|
|
1169
|
-
stats =
|
|
859
|
+
stats = lstatSync2(lexicalPath);
|
|
1170
860
|
} catch {
|
|
1171
861
|
return false;
|
|
1172
862
|
}
|
|
1173
863
|
if (!stats.isSymbolicLink()) return false;
|
|
1174
|
-
const target =
|
|
864
|
+
const target = resolve2(dirname3(lexicalPath), readlinkSync(lexicalPath));
|
|
1175
865
|
if (!isSafePath(projectDir, target)) return false;
|
|
1176
866
|
try {
|
|
1177
867
|
statSync3(lexicalPath);
|
|
@@ -1186,10 +876,8 @@ async function resolveProjectPath(c, adapter, route, opts) {
|
|
|
1186
876
|
if (!project) {
|
|
1187
877
|
return { error: c.json({ error: "not found" }, 404) };
|
|
1188
878
|
}
|
|
1189
|
-
if (
|
|
1190
|
-
return {
|
|
1191
|
-
error: c.json({ error: "not found", why: "project_dir_missing" }, 404)
|
|
1192
|
-
};
|
|
879
|
+
if (folderGone(project.dir)) {
|
|
880
|
+
return { error: projectDirMissing(c) };
|
|
1193
881
|
}
|
|
1194
882
|
const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
|
|
1195
883
|
if (filePath.includes("\0")) {
|
|
@@ -1197,7 +885,7 @@ async function resolveProjectPath(c, adapter, route, opts) {
|
|
|
1197
885
|
}
|
|
1198
886
|
const absPath = (opts?.pin ? pinWithinProject : resolveWithinProject)(project.dir, filePath);
|
|
1199
887
|
if (!absPath) {
|
|
1200
|
-
if (isDanglingSymlinkInProject(project.dir,
|
|
888
|
+
if (isDanglingSymlinkInProject(project.dir, resolve2(project.dir, filePath))) {
|
|
1201
889
|
return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
|
|
1202
890
|
}
|
|
1203
891
|
return { error: c.json({ error: "forbidden", why: "outside_project" }, 403) };
|
|
@@ -1261,10 +949,10 @@ function foldElementPatches(originalContent, patches) {
|
|
|
1261
949
|
return { content, matched };
|
|
1262
950
|
}
|
|
1263
951
|
var PATCH_CONFLICT_ATTEMPTS = 3;
|
|
1264
|
-
function commitElementPatchBatches(projectDir, batches,
|
|
952
|
+
function commitElementPatchBatches(projectDir, batches, writeFile = (path, content) => replaceFileAtomically(path, content, statSync3(path).mode), requestToken) {
|
|
1265
953
|
for (let attempt = 1; ; attempt += 1) {
|
|
1266
954
|
try {
|
|
1267
|
-
return foldAndCommitElementPatchBatches(projectDir, batches,
|
|
955
|
+
return foldAndCommitElementPatchBatches(projectDir, batches, writeFile, requestToken);
|
|
1268
956
|
} catch (error) {
|
|
1269
957
|
if (!(error instanceof FileChangedError)) throw error;
|
|
1270
958
|
if (attempt === PATCH_CONFLICT_ATTEMPTS)
|
|
@@ -1272,7 +960,7 @@ function commitElementPatchBatches(projectDir, batches, writeFile2 = (path, cont
|
|
|
1272
960
|
}
|
|
1273
961
|
}
|
|
1274
962
|
}
|
|
1275
|
-
function foldAndCommitElementPatchBatches(projectDir, batches,
|
|
963
|
+
function foldAndCommitElementPatchBatches(projectDir, batches, writeFile, requestToken) {
|
|
1276
964
|
const resolvedPaths = /* @__PURE__ */ new Set();
|
|
1277
965
|
const prepared = [];
|
|
1278
966
|
for (const batch of batches) {
|
|
@@ -1282,7 +970,7 @@ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, reque
|
|
|
1282
970
|
resolvedPaths.add(absPath);
|
|
1283
971
|
let before;
|
|
1284
972
|
try {
|
|
1285
|
-
before =
|
|
973
|
+
before = readFileSync4(absPath, "utf-8");
|
|
1286
974
|
} catch {
|
|
1287
975
|
return { error: "not-found", sourceFile: batch.sourceFile };
|
|
1288
976
|
}
|
|
@@ -1317,7 +1005,7 @@ function foldAndCommitElementPatchBatches(projectDir, batches, writeFile2, reque
|
|
|
1317
1005
|
after
|
|
1318
1006
|
})),
|
|
1319
1007
|
requestToken,
|
|
1320
|
-
|
|
1008
|
+
writeFile
|
|
1321
1009
|
);
|
|
1322
1010
|
const files = applied.map((file, index) => ({
|
|
1323
1011
|
sourceFile: file.sourceFile,
|
|
@@ -1338,7 +1026,7 @@ function commitElementPatchBatchesWithReceipts(c, projectDir, batches) {
|
|
|
1338
1026
|
);
|
|
1339
1027
|
}
|
|
1340
1028
|
function writeFileWithReceipt(c, filePath, absPath, html) {
|
|
1341
|
-
const overwrote =
|
|
1029
|
+
const overwrote = readFileSync4(absPath);
|
|
1342
1030
|
replaceFileAtomically(absPath, html, statSync3(absPath).mode);
|
|
1343
1031
|
const version = fileContentVersion(html);
|
|
1344
1032
|
const writeToken = createWriteToken(c.req.header("X-Hyperframes-Write-Token"));
|
|
@@ -1348,7 +1036,7 @@ function writeFileWithReceipt(c, filePath, absPath, html) {
|
|
|
1348
1036
|
function writeMutationResult(c, projectDir, filePath, absPath, html, original) {
|
|
1349
1037
|
const backup = snapshotBeforeWrite(projectDir, absPath);
|
|
1350
1038
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
1351
|
-
if (
|
|
1039
|
+
if (readFileSync4(absPath, "utf-8") !== original) {
|
|
1352
1040
|
return c.json({ error: "file changed", conflict: true, path: filePath }, 409);
|
|
1353
1041
|
}
|
|
1354
1042
|
const { version } = writeFileWithReceipt(c, filePath, absPath, html);
|
|
@@ -1393,9 +1081,8 @@ async function parseMutationBody(c) {
|
|
|
1393
1081
|
}
|
|
1394
1082
|
return { target: body.target, body };
|
|
1395
1083
|
}
|
|
1396
|
-
function ensureDir(filePath) {
|
|
1397
|
-
|
|
1398
|
-
if (!existsSync3(dir)) mkdirSync4(dir, { recursive: true });
|
|
1084
|
+
function ensureDir(projectDir, filePath) {
|
|
1085
|
+
mkdirWithinProject(projectDir, dirname3(filePath));
|
|
1399
1086
|
}
|
|
1400
1087
|
function generateCopyPath(projectDir, originalPath) {
|
|
1401
1088
|
const ext = originalPath.includes(".") ? "." + originalPath.split(".").pop() : "";
|
|
@@ -1404,7 +1091,7 @@ function generateCopyPath(projectDir, originalPath) {
|
|
|
1404
1091
|
const cleanBase = copyMatch ? base.slice(0, -copyMatch[0].length) : base;
|
|
1405
1092
|
let num = copyMatch ? copyMatch[1] ? parseInt(copyMatch[1]) + 1 : 2 : 1;
|
|
1406
1093
|
let candidate = num === 1 ? `${cleanBase} (copy)${ext}` : `${cleanBase} (copy ${num})${ext}`;
|
|
1407
|
-
while (existsSync3(
|
|
1094
|
+
while (existsSync3(resolve2(projectDir, candidate))) {
|
|
1408
1095
|
num++;
|
|
1409
1096
|
candidate = `${cleanBase} (copy ${num})${ext}`;
|
|
1410
1097
|
}
|
|
@@ -1412,8 +1099,8 @@ function generateCopyPath(projectDir, originalPath) {
|
|
|
1412
1099
|
}
|
|
1413
1100
|
function walkFiles(dir, filter) {
|
|
1414
1101
|
const results = [];
|
|
1415
|
-
for (const entry of
|
|
1416
|
-
const full =
|
|
1102
|
+
for (const entry of readdirSync2(dir, { withFileTypes: true })) {
|
|
1103
|
+
const full = join5(dir, entry.name);
|
|
1417
1104
|
if (entry.isDirectory()) {
|
|
1418
1105
|
if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders" || entry.name === ".transcode-cache")
|
|
1419
1106
|
continue;
|
|
@@ -1432,7 +1119,7 @@ function updateReferences(projectDir, oldPath, newPath) {
|
|
|
1432
1119
|
let updatedCount = 0;
|
|
1433
1120
|
for (const file of textFiles) {
|
|
1434
1121
|
if (!isSafePath(projectDir, file)) continue;
|
|
1435
|
-
const content =
|
|
1122
|
+
const content = readFileSync4(file, "utf-8");
|
|
1436
1123
|
if (!content.includes(oldPath)) continue;
|
|
1437
1124
|
const updated = content.split(oldPath).join(newPath);
|
|
1438
1125
|
if (updated !== content) {
|
|
@@ -1443,7 +1130,7 @@ function updateReferences(projectDir, oldPath, newPath) {
|
|
|
1443
1130
|
return updatedCount;
|
|
1444
1131
|
}
|
|
1445
1132
|
function extractGsapScriptBlock(html) {
|
|
1446
|
-
const { document } = parseHTML2(
|
|
1133
|
+
const { document } = parseHTML2(ensureHfIds(html));
|
|
1447
1134
|
const scripts = [
|
|
1448
1135
|
...document.querySelectorAll("script:not([src])"),
|
|
1449
1136
|
...Array.from(document.querySelectorAll("template")).flatMap(
|
|
@@ -1706,7 +1393,7 @@ function containsRawGsapExpression(value) {
|
|
|
1706
1393
|
return Object.values(value).some(containsRawGsapExpression);
|
|
1707
1394
|
}
|
|
1708
1395
|
async function prepareGsapMutationScript(c, res, firstMutation) {
|
|
1709
|
-
const beforeHtml =
|
|
1396
|
+
const beforeHtml = readFileSync4(res.absPath, "utf-8");
|
|
1710
1397
|
let html = beforeHtml;
|
|
1711
1398
|
let block = extractGsapScriptBlock(html);
|
|
1712
1399
|
if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
|
|
@@ -1773,7 +1460,7 @@ async function applyGsapMutations(c, res, mutations) {
|
|
|
1773
1460
|
const changed = block.scriptText !== initialScript;
|
|
1774
1461
|
const newHtml = changed ? block.replaceScript(block.scriptText) : html;
|
|
1775
1462
|
let backupPath = null;
|
|
1776
|
-
if (
|
|
1463
|
+
if (readFileSync4(res.absPath, "utf-8") !== beforeHtml) {
|
|
1777
1464
|
return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
|
|
1778
1465
|
}
|
|
1779
1466
|
if (changed) {
|
|
@@ -2527,7 +2214,7 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
|
2527
2214
|
skipped.push(name);
|
|
2528
2215
|
continue;
|
|
2529
2216
|
}
|
|
2530
|
-
const destPath =
|
|
2217
|
+
const destPath = resolve2(targetDir, name);
|
|
2531
2218
|
if (!isSafePath(projectDir, destPath)) continue;
|
|
2532
2219
|
let finalPath = destPath;
|
|
2533
2220
|
let finalName = name;
|
|
@@ -2538,13 +2225,13 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
|
2538
2225
|
let n = 1;
|
|
2539
2226
|
if (existsSync3(finalPath)) {
|
|
2540
2227
|
n = 2;
|
|
2541
|
-
while (n < MAX_COPY_INDEX && existsSync3(
|
|
2228
|
+
while (n < MAX_COPY_INDEX && existsSync3(resolve2(targetDir, `${base} (${n})${ext}`))) n++;
|
|
2542
2229
|
if (n >= MAX_COPY_INDEX) {
|
|
2543
2230
|
skipped.push(name);
|
|
2544
2231
|
continue;
|
|
2545
2232
|
}
|
|
2546
2233
|
finalName = `${base} (${n})${ext}`;
|
|
2547
|
-
finalPath =
|
|
2234
|
+
finalPath = resolve2(targetDir, finalName);
|
|
2548
2235
|
}
|
|
2549
2236
|
if (!isSafePath(projectDir, finalPath)) continue;
|
|
2550
2237
|
const buffer = Buffer.from(await value.arrayBuffer());
|
|
@@ -2556,11 +2243,11 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
|
2556
2243
|
let written = false;
|
|
2557
2244
|
while (n < MAX_COPY_INDEX && isSafePath(projectDir, finalPath)) {
|
|
2558
2245
|
try {
|
|
2559
|
-
const fd =
|
|
2246
|
+
const fd = openSync(finalPath, "wx");
|
|
2560
2247
|
try {
|
|
2561
|
-
|
|
2248
|
+
writeFileSync4(fd, buffer);
|
|
2562
2249
|
} finally {
|
|
2563
|
-
|
|
2250
|
+
closeSync(fd);
|
|
2564
2251
|
}
|
|
2565
2252
|
written = true;
|
|
2566
2253
|
break;
|
|
@@ -2570,14 +2257,14 @@ async function processUploadedFiles(formData, targetDir, projectDir) {
|
|
|
2570
2257
|
}
|
|
2571
2258
|
n++;
|
|
2572
2259
|
finalName = `${base} (${n})${ext}`;
|
|
2573
|
-
finalPath =
|
|
2260
|
+
finalPath = resolve2(targetDir, finalName);
|
|
2574
2261
|
}
|
|
2575
2262
|
}
|
|
2576
2263
|
if (!written) {
|
|
2577
2264
|
if (n >= MAX_COPY_INDEX) skipped.push(name);
|
|
2578
2265
|
continue;
|
|
2579
2266
|
}
|
|
2580
|
-
const relativePath = subDir ?
|
|
2267
|
+
const relativePath = subDir ? join5(subDir, finalName) : finalName;
|
|
2581
2268
|
uploaded.push(relativePath);
|
|
2582
2269
|
if (validation.unchecked) unchecked.push({ name: finalName, reason: validation.unchecked });
|
|
2583
2270
|
if (isAudioFile(finalName)) {
|
|
@@ -2593,7 +2280,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2593
2280
|
if ("error" in res) return res.error;
|
|
2594
2281
|
let fd;
|
|
2595
2282
|
try {
|
|
2596
|
-
fd =
|
|
2283
|
+
fd = openSync(res.absPath, "r");
|
|
2597
2284
|
} catch {
|
|
2598
2285
|
if (c.req.query("optional") === "1") {
|
|
2599
2286
|
return c.json({ filename: res.filePath, content: "", missing: true });
|
|
@@ -2601,10 +2288,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
2601
2288
|
return c.json({ error: "not found" }, 404);
|
|
2602
2289
|
}
|
|
2603
2290
|
try {
|
|
2604
|
-
if (!
|
|
2291
|
+
if (!fstatSync(fd).isFile()) {
|
|
2605
2292
|
return c.json({ error: "not found", why: "not_a_file" }, 404);
|
|
2606
2293
|
}
|
|
2607
|
-
const content =
|
|
2294
|
+
const content = readFileSync4(fd);
|
|
2608
2295
|
const version = fileContentVersion(content);
|
|
2609
2296
|
c.header("ETag", version);
|
|
2610
2297
|
return c.json({
|
|
@@ -2614,7 +2301,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2614
2301
|
missing: false
|
|
2615
2302
|
});
|
|
2616
2303
|
} finally {
|
|
2617
|
-
|
|
2304
|
+
closeSync(fd);
|
|
2618
2305
|
}
|
|
2619
2306
|
});
|
|
2620
2307
|
api.put("/projects/:id/files/*", async (c) => {
|
|
@@ -2626,7 +2313,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2626
2313
|
if (expectedVersion === null && !createOnly) {
|
|
2627
2314
|
let currentContent = null;
|
|
2628
2315
|
try {
|
|
2629
|
-
currentContent =
|
|
2316
|
+
currentContent = readFileSync4(res.absPath);
|
|
2630
2317
|
} catch (error) {
|
|
2631
2318
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
2632
2319
|
throw error;
|
|
@@ -2645,15 +2332,15 @@ function registerFileRoutes(api, adapter) {
|
|
|
2645
2332
|
let backup = { backupPath: null };
|
|
2646
2333
|
let overwrote;
|
|
2647
2334
|
if (createOnly) {
|
|
2648
|
-
ensureDir(res.absPath);
|
|
2335
|
+
ensureDir(res.project.dir, res.absPath);
|
|
2649
2336
|
let fd;
|
|
2650
2337
|
try {
|
|
2651
|
-
fd =
|
|
2338
|
+
fd = openSync(res.absPath, "wx");
|
|
2652
2339
|
} catch (error) {
|
|
2653
2340
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
|
|
2654
2341
|
throw error;
|
|
2655
2342
|
}
|
|
2656
|
-
const currentContent =
|
|
2343
|
+
const currentContent = readFileSync4(res.absPath);
|
|
2657
2344
|
return c.json(
|
|
2658
2345
|
{
|
|
2659
2346
|
error: "file conflict",
|
|
@@ -2667,12 +2354,12 @@ function registerFileRoutes(api, adapter) {
|
|
|
2667
2354
|
try {
|
|
2668
2355
|
writeSync(fd, body, 0, body.length, 0);
|
|
2669
2356
|
} finally {
|
|
2670
|
-
|
|
2357
|
+
closeSync(fd);
|
|
2671
2358
|
}
|
|
2672
2359
|
} else {
|
|
2673
2360
|
let fd;
|
|
2674
2361
|
try {
|
|
2675
|
-
fd =
|
|
2362
|
+
fd = openSync(res.absPath, "r+");
|
|
2676
2363
|
} catch (error) {
|
|
2677
2364
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
2678
2365
|
throw error;
|
|
@@ -2688,7 +2375,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2688
2375
|
);
|
|
2689
2376
|
}
|
|
2690
2377
|
try {
|
|
2691
|
-
const currentContent =
|
|
2378
|
+
const currentContent = readFileSync4(fd);
|
|
2692
2379
|
overwrote = currentContent;
|
|
2693
2380
|
const currentVersion = fileContentVersion(currentContent);
|
|
2694
2381
|
if (expectedVersion !== currentVersion) {
|
|
@@ -2704,12 +2391,12 @@ function registerFileRoutes(api, adapter) {
|
|
|
2704
2391
|
}
|
|
2705
2392
|
backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
2706
2393
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
2707
|
-
const mode =
|
|
2708
|
-
|
|
2394
|
+
const mode = fstatSync(fd).mode;
|
|
2395
|
+
closeSync(fd);
|
|
2709
2396
|
fd = null;
|
|
2710
2397
|
replaceFileAtomically(res.absPath, body, mode);
|
|
2711
2398
|
} finally {
|
|
2712
|
-
if (fd !== null)
|
|
2399
|
+
if (fd !== null) closeSync(fd);
|
|
2713
2400
|
}
|
|
2714
2401
|
}
|
|
2715
2402
|
const version = fileContentVersion(body);
|
|
@@ -2727,10 +2414,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
2727
2414
|
api.post("/projects/:id/files/*", async (c) => {
|
|
2728
2415
|
const res = await resolveProjectFile(c, adapter);
|
|
2729
2416
|
if ("error" in res) return res.error;
|
|
2730
|
-
ensureDir(res.absPath);
|
|
2417
|
+
ensureDir(res.project.dir, res.absPath);
|
|
2731
2418
|
const body = Buffer.from(await c.req.arrayBuffer());
|
|
2732
2419
|
try {
|
|
2733
|
-
|
|
2420
|
+
writeFileSync4(res.absPath, body, { flag: "wx" });
|
|
2734
2421
|
} catch (error) {
|
|
2735
2422
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
|
|
2736
2423
|
throw error;
|
|
@@ -2742,10 +2429,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
2742
2429
|
api.delete("/projects/:id/files/*", async (c) => {
|
|
2743
2430
|
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
|
2744
2431
|
if ("error" in res) return res.error;
|
|
2745
|
-
const
|
|
2432
|
+
const stat2 = statSync3(res.absPath);
|
|
2746
2433
|
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
2747
2434
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
2748
|
-
if (
|
|
2435
|
+
if (stat2.isDirectory()) {
|
|
2749
2436
|
rmSync3(res.absPath, { recursive: true });
|
|
2750
2437
|
} else {
|
|
2751
2438
|
unlinkSync2(res.absPath);
|
|
@@ -2764,7 +2451,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2764
2451
|
}
|
|
2765
2452
|
let before;
|
|
2766
2453
|
try {
|
|
2767
|
-
before =
|
|
2454
|
+
before = readFileSync4(ctx.absPath, "utf-8");
|
|
2768
2455
|
} catch (error) {
|
|
2769
2456
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "ENOENT") {
|
|
2770
2457
|
throw error;
|
|
@@ -2793,7 +2480,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2793
2480
|
}
|
|
2794
2481
|
const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
|
|
2795
2482
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
2796
|
-
const current =
|
|
2483
|
+
const current = readFileSync4(ctx.absPath, "utf-8");
|
|
2797
2484
|
if (current !== before) {
|
|
2798
2485
|
const currentVersion2 = fileContentVersion(current);
|
|
2799
2486
|
return c.json({ error: "file conflict", currentVersion: currentVersion2, currentContent: current }, 409);
|
|
@@ -2826,7 +2513,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2826
2513
|
}
|
|
2827
2514
|
const parsed = await parseMutationBody(c);
|
|
2828
2515
|
if ("error" in parsed) return parsed.error;
|
|
2829
|
-
const originalContent =
|
|
2516
|
+
const originalContent = readFileSync4(ctx.absPath, "utf-8");
|
|
2830
2517
|
return writeIfChanged(
|
|
2831
2518
|
c,
|
|
2832
2519
|
ctx.project.dir,
|
|
@@ -2847,7 +2534,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2847
2534
|
if (!Array.isArray(targets) || targets.length === 0) {
|
|
2848
2535
|
return c.json({ error: "targets required" }, 400);
|
|
2849
2536
|
}
|
|
2850
|
-
const originalContent =
|
|
2537
|
+
const originalContent = readFileSync4(ctx.absPath, "utf-8");
|
|
2851
2538
|
let next = originalContent;
|
|
2852
2539
|
for (const target of targets) {
|
|
2853
2540
|
next = removeElementFromHtml(next, target);
|
|
@@ -2880,7 +2567,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2880
2567
|
seen.add(absPath);
|
|
2881
2568
|
let before;
|
|
2882
2569
|
try {
|
|
2883
|
-
before =
|
|
2570
|
+
before = readFileSync4(absPath, "utf-8");
|
|
2884
2571
|
} catch {
|
|
2885
2572
|
return c.json({ error: `not found: ${file.path}` }, 404);
|
|
2886
2573
|
}
|
|
@@ -2907,7 +2594,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2907
2594
|
prepared.push(folded);
|
|
2908
2595
|
}
|
|
2909
2596
|
for (const file of prepared) {
|
|
2910
|
-
const current =
|
|
2597
|
+
const current = readFileSync4(file.absPath, "utf-8");
|
|
2911
2598
|
if (current !== file.before) {
|
|
2912
2599
|
return c.json(
|
|
2913
2600
|
{
|
|
@@ -2950,7 +2637,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2950
2637
|
const conflicts = [];
|
|
2951
2638
|
for (const file of written.reverse()) {
|
|
2952
2639
|
try {
|
|
2953
|
-
const current =
|
|
2640
|
+
const current = readFileSync4(file.absPath, "utf-8");
|
|
2954
2641
|
if (current !== file.after) {
|
|
2955
2642
|
conflicts.push(file.path);
|
|
2956
2643
|
continue;
|
|
@@ -2998,7 +2685,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
2998
2685
|
const fallbackTiming = typeof parsed.body.elementStart === "number" && typeof parsed.body.elementDuration === "number" ? { start: parsed.body.elementStart, duration: parsed.body.elementDuration } : void 0;
|
|
2999
2686
|
let originalContent;
|
|
3000
2687
|
try {
|
|
3001
|
-
originalContent =
|
|
2688
|
+
originalContent = readFileSync4(ctx.absPath, "utf-8");
|
|
3002
2689
|
} catch {
|
|
3003
2690
|
return c.json({ error: "not found" }, 404);
|
|
3004
2691
|
}
|
|
@@ -3056,7 +2743,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3056
2743
|
for (let attempt = 1; ; attempt += 1) {
|
|
3057
2744
|
let originalContent;
|
|
3058
2745
|
try {
|
|
3059
|
-
originalContent =
|
|
2746
|
+
originalContent = readFileSync4(ctx.absPath, "utf-8");
|
|
3060
2747
|
} catch {
|
|
3061
2748
|
return c.json({ error: "not found" }, 404);
|
|
3062
2749
|
}
|
|
@@ -3162,7 +2849,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3162
2849
|
}
|
|
3163
2850
|
let originalContent;
|
|
3164
2851
|
try {
|
|
3165
|
-
originalContent =
|
|
2852
|
+
originalContent = readFileSync4(ctx.absPath, "utf-8");
|
|
3166
2853
|
} catch {
|
|
3167
2854
|
return c.json({ error: "not found" }, 404);
|
|
3168
2855
|
}
|
|
@@ -3218,7 +2905,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3218
2905
|
).map((entry) => ({ target: entry.target, track: entry.track }));
|
|
3219
2906
|
let originalContent;
|
|
3220
2907
|
try {
|
|
3221
|
-
originalContent =
|
|
2908
|
+
originalContent = readFileSync4(ctx.absPath, "utf-8");
|
|
3222
2909
|
} catch {
|
|
3223
2910
|
return c.json({ error: "not found" }, 404);
|
|
3224
2911
|
}
|
|
@@ -3247,7 +2934,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3247
2934
|
if ("error" in parsed) return parsed.error;
|
|
3248
2935
|
let content;
|
|
3249
2936
|
try {
|
|
3250
|
-
content =
|
|
2937
|
+
content = readFileSync4(ctx.absPath, "utf-8");
|
|
3251
2938
|
} catch {
|
|
3252
2939
|
return c.json({ exists: false });
|
|
3253
2940
|
}
|
|
@@ -3268,7 +2955,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3268
2955
|
if (existsSync3(newAbs)) {
|
|
3269
2956
|
return c.json({ error: "already exists" }, 409);
|
|
3270
2957
|
}
|
|
3271
|
-
ensureDir(newAbs);
|
|
2958
|
+
ensureDir(res.project.dir, newAbs);
|
|
3272
2959
|
renameSync2(res.absPath, newAbs);
|
|
3273
2960
|
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
|
|
3274
2961
|
return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
|
|
@@ -3289,9 +2976,9 @@ function registerFileRoutes(api, adapter) {
|
|
|
3289
2976
|
if (!destAbs) {
|
|
3290
2977
|
return c.json({ error: "forbidden" }, 403);
|
|
3291
2978
|
}
|
|
3292
|
-
ensureDir(destAbs);
|
|
2979
|
+
ensureDir(project.dir, destAbs);
|
|
3293
2980
|
try {
|
|
3294
|
-
|
|
2981
|
+
writeFileSync4(destAbs, readFileSync4(srcAbs), { flag: "wx" });
|
|
3295
2982
|
} catch (error) {
|
|
3296
2983
|
if (!error || typeof error !== "object" || !("code" in error) || error.code !== "EEXIST") {
|
|
3297
2984
|
throw error;
|
|
@@ -3310,12 +2997,14 @@ function registerFileRoutes(api, adapter) {
|
|
|
3310
2997
|
async (c) => {
|
|
3311
2998
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3312
2999
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
3000
|
+
if (folderGone(project.dir)) return projectDirMissing(c);
|
|
3313
3001
|
const subDir = c.req.query("dir") ?? "";
|
|
3314
3002
|
const targetDir = subDir ? resolveWithinProject(project.dir, subDir) : project.dir;
|
|
3315
3003
|
if (!targetDir) return c.json({ error: "forbidden" }, 403);
|
|
3316
|
-
if (subDir && !existsSync3(targetDir)) mkdirSync4(targetDir, { recursive: true });
|
|
3317
3004
|
const formData = await c.req.formData();
|
|
3005
|
+
mkdirWithinProject(project.dir, targetDir);
|
|
3318
3006
|
const result = await processUploadedFiles(formData, targetDir, project.dir);
|
|
3007
|
+
if (folderGone(project.dir)) return projectDirMissing(c);
|
|
3319
3008
|
return c.json(
|
|
3320
3009
|
{
|
|
3321
3010
|
ok: true,
|
|
@@ -3333,7 +3022,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3333
3022
|
mustExist: true
|
|
3334
3023
|
});
|
|
3335
3024
|
if ("error" in res) return res.error;
|
|
3336
|
-
const html =
|
|
3025
|
+
const html = readFileSync4(res.absPath, "utf-8");
|
|
3337
3026
|
const block = extractGsapScriptBlock(html);
|
|
3338
3027
|
if (!block) {
|
|
3339
3028
|
return c.json({
|
|
@@ -3389,7 +3078,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3389
3078
|
if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
|
|
3390
3079
|
return c.json({ error: "expected and restore contents required" }, 400);
|
|
3391
3080
|
}
|
|
3392
|
-
const current =
|
|
3081
|
+
const current = readFileSync4(res.absPath, "utf-8");
|
|
3393
3082
|
if (current !== body.expected) {
|
|
3394
3083
|
return c.json({ ok: true, restored: false, conflict: true });
|
|
3395
3084
|
}
|
|
@@ -3399,10 +3088,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
3399
3088
|
}
|
|
3400
3089
|
|
|
3401
3090
|
// src/routes/preview.ts
|
|
3402
|
-
import { createReadStream, existsSync as existsSync5, readFileSync as
|
|
3091
|
+
import { createReadStream, existsSync as existsSync5, readFileSync as readFileSync6, statSync as statSync4 } from "fs";
|
|
3403
3092
|
import { Readable } from "stream";
|
|
3404
|
-
import { join as
|
|
3405
|
-
import { createHash
|
|
3093
|
+
import { join as join8, resolve as resolve4 } from "path";
|
|
3094
|
+
import { createHash } from "crypto";
|
|
3406
3095
|
import {
|
|
3407
3096
|
addScenePartsManifest,
|
|
3408
3097
|
injectScriptsIntoHtml,
|
|
@@ -3414,8 +3103,8 @@ import { injectTagsAtHeadStart } from "@hyperframes/core/compiler/html-document"
|
|
|
3414
3103
|
import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
|
|
3415
3104
|
|
|
3416
3105
|
// src/helpers/subComposition.ts
|
|
3417
|
-
import { existsSync as existsSync4, readFileSync as
|
|
3418
|
-
import { join as
|
|
3106
|
+
import { existsSync as existsSync4, readFileSync as readFileSync5 } from "fs";
|
|
3107
|
+
import { join as join6 } from "path";
|
|
3419
3108
|
import { parseHTML as parseHTML3 } from "linkedom";
|
|
3420
3109
|
import {
|
|
3421
3110
|
rewriteAssetPaths,
|
|
@@ -3425,7 +3114,7 @@ import {
|
|
|
3425
3114
|
import { stripEmbeddedRuntimeScripts } from "@hyperframes/core/compiler";
|
|
3426
3115
|
import { isFullHtmlDocument } from "@hyperframes/core/compiler/html-document";
|
|
3427
3116
|
function rewriteRelativePaths(root, compPath, projectDir) {
|
|
3428
|
-
const assetExists = (path) => existsSync4(
|
|
3117
|
+
const assetExists = (path) => existsSync4(join6(projectDir, path));
|
|
3429
3118
|
rewriteAssetPaths(
|
|
3430
3119
|
root.querySelectorAll("[src], [href]"),
|
|
3431
3120
|
compPath,
|
|
@@ -3522,9 +3211,9 @@ function tagRootCompositionFile(bodyHtml, compPath) {
|
|
|
3522
3211
|
return bodyHtml.slice(0, tagEnd) + ` data-composition-file="${compPath}"` + bodyHtml.slice(tagEnd);
|
|
3523
3212
|
}
|
|
3524
3213
|
function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, rawOverride) {
|
|
3525
|
-
const compFile =
|
|
3214
|
+
const compFile = join6(projectDir, compPath);
|
|
3526
3215
|
if (!existsSync4(compFile)) return null;
|
|
3527
|
-
const rawComp = rawOverride ??
|
|
3216
|
+
const rawComp = rawOverride ?? readFileSync5(compFile, "utf-8");
|
|
3528
3217
|
let compHeadContent = "";
|
|
3529
3218
|
let rewrittenContent;
|
|
3530
3219
|
let htmlAttrs = "";
|
|
@@ -3554,10 +3243,10 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
|
|
|
3554
3243
|
}
|
|
3555
3244
|
rewrittenContent = stripEmbeddedRuntimeScripts(rewrittenContent);
|
|
3556
3245
|
rewrittenContent = tagRootCompositionFile(rewrittenContent, compPath);
|
|
3557
|
-
const indexPath =
|
|
3246
|
+
const indexPath = join6(projectDir, "index.html");
|
|
3558
3247
|
let headContent = "";
|
|
3559
3248
|
if (existsSync4(indexPath)) {
|
|
3560
|
-
const indexHtml =
|
|
3249
|
+
const indexHtml = readFileSync5(indexPath, "utf-8");
|
|
3561
3250
|
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
3562
3251
|
headContent = headMatch?.[1] ?? "";
|
|
3563
3252
|
}
|
|
@@ -3594,18 +3283,18 @@ function hasBaseElement(html) {
|
|
|
3594
3283
|
}
|
|
3595
3284
|
|
|
3596
3285
|
// src/routes/preview.ts
|
|
3597
|
-
import { ensureHfIds as
|
|
3286
|
+
import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
|
|
3598
3287
|
|
|
3599
3288
|
// src/helpers/previewReads.ts
|
|
3600
3289
|
import { readlinkSync as readlinkSync2 } from "fs";
|
|
3601
|
-
import { dirname as
|
|
3290
|
+
import { dirname as dirname4, join as join7, parse, relative as relative3, resolve as resolve3 } from "path";
|
|
3602
3291
|
var ALWAYS_AFFECTS = ["hyperframes.json", ...STUDIO_SIGNATURE_MANIFEST_PATHS];
|
|
3603
3292
|
var REFERENCE = /\b(?:src|href|poster|data-composition-src)\s*=\s*(?:"([^"\n]*)"|'([^'\n]*)')|url\(\s*(?:"([^"\n]*)"|'([^'\n]*)'|([^"'\s)]+))/gi;
|
|
3604
|
-
var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) =>
|
|
3293
|
+
var pathKey = process.platform === "darwin" || process.platform === "win32" ? (path) => resolve3(path).toLowerCase() : (path) => resolve3(path);
|
|
3605
3294
|
var readsByProject = /* @__PURE__ */ new Map();
|
|
3606
3295
|
var builtProjects = /* @__PURE__ */ new Set();
|
|
3607
|
-
var projectKey = (projectDir) => pathKey(realFilePath(
|
|
3608
|
-
var inRealProject = (projectDir, path) =>
|
|
3296
|
+
var projectKey = (projectDir) => pathKey(realFilePath(resolve3(projectDir)));
|
|
3297
|
+
var inRealProject = (projectDir, path) => join7(realFilePath(resolve3(projectDir)), relative3(resolve3(projectDir), resolve3(projectDir, path)));
|
|
3609
3298
|
var readLink = (path) => {
|
|
3610
3299
|
try {
|
|
3611
3300
|
return readlinkSync2(path);
|
|
@@ -3621,10 +3310,10 @@ function linksOnTheWay(path) {
|
|
|
3621
3310
|
const part = rest.shift();
|
|
3622
3311
|
if (!part || part === ".") continue;
|
|
3623
3312
|
if (part === "..") {
|
|
3624
|
-
at =
|
|
3313
|
+
at = dirname4(at);
|
|
3625
3314
|
continue;
|
|
3626
3315
|
}
|
|
3627
|
-
const next =
|
|
3316
|
+
const next = join7(at, part);
|
|
3628
3317
|
const target = hops < 40 ? readLink(next) : null;
|
|
3629
3318
|
if (target === null) {
|
|
3630
3319
|
at = next;
|
|
@@ -3645,7 +3334,7 @@ function recordPreviewRead(projectDir, filePath) {
|
|
|
3645
3334
|
if (!reads) readsByProject.set(key, reads = /* @__PURE__ */ new Set());
|
|
3646
3335
|
const read = inRealProject(projectDir, filePath);
|
|
3647
3336
|
for (const found of [read, ...linksOnTheWay(read)]) {
|
|
3648
|
-
for (let path = pathKey(found); !reads.has(path); path =
|
|
3337
|
+
for (let path = pathKey(found); !reads.has(path); path = dirname4(path)) reads.add(path);
|
|
3649
3338
|
}
|
|
3650
3339
|
}
|
|
3651
3340
|
function recordPreviewReferences(projectDir, html) {
|
|
@@ -3742,10 +3431,10 @@ function injectProjectSignature(html, signature) {
|
|
|
3742
3431
|
${html}`;
|
|
3743
3432
|
}
|
|
3744
3433
|
function readStudioMotionManifestContent(projectDir) {
|
|
3745
|
-
const manifestPath =
|
|
3434
|
+
const manifestPath = join8(projectDir, STUDIO_MOTION_PATH);
|
|
3746
3435
|
if (!existsSync5(manifestPath)) return "";
|
|
3747
3436
|
try {
|
|
3748
|
-
return
|
|
3437
|
+
return readFileSync6(manifestPath, "utf-8");
|
|
3749
3438
|
} catch {
|
|
3750
3439
|
return "";
|
|
3751
3440
|
}
|
|
@@ -3856,7 +3545,7 @@ function parsePreviewVariablesParam(raw) {
|
|
|
3856
3545
|
}
|
|
3857
3546
|
function variablesEtagSalt(raw) {
|
|
3858
3547
|
if (!raw) return "";
|
|
3859
|
-
return `:vars:${
|
|
3548
|
+
return `:vars:${createHash("sha1").update(raw).digest("hex").slice(0, 12)}`;
|
|
3860
3549
|
}
|
|
3861
3550
|
function previewVariablesFromRequest(rawVariables) {
|
|
3862
3551
|
const parse2 = parsePreviewVariablesParam(rawVariables);
|
|
@@ -3890,19 +3579,19 @@ async function transformPreviewHtml(html, adapter, project, activeCompositionPat
|
|
|
3890
3579
|
}
|
|
3891
3580
|
}
|
|
3892
3581
|
function resolveProjectMainHtml(projectDir, projectId) {
|
|
3893
|
-
const indexPath =
|
|
3894
|
-
const blockHtmlPath =
|
|
3582
|
+
const indexPath = join8(projectDir, "index.html");
|
|
3583
|
+
const blockHtmlPath = join8(projectDir, `${projectId}.html`);
|
|
3895
3584
|
recordPreviewRead(projectDir, indexPath);
|
|
3896
3585
|
recordPreviewRead(projectDir, blockHtmlPath);
|
|
3897
3586
|
if (existsSync5(indexPath)) {
|
|
3898
3587
|
return {
|
|
3899
|
-
html:
|
|
3588
|
+
html: readFileSync6(indexPath, "utf-8"),
|
|
3900
3589
|
compositionPath: "index.html"
|
|
3901
3590
|
};
|
|
3902
3591
|
}
|
|
3903
3592
|
if (existsSync5(blockHtmlPath)) {
|
|
3904
3593
|
return {
|
|
3905
|
-
html:
|
|
3594
|
+
html: readFileSync6(blockHtmlPath, "utf-8"),
|
|
3906
3595
|
compositionPath: `${projectId}.html`
|
|
3907
3596
|
};
|
|
3908
3597
|
}
|
|
@@ -3929,7 +3618,7 @@ function registerPreviewRoutes(api, adapter) {
|
|
|
3929
3618
|
const previewBuilds = /* @__PURE__ */ new Map();
|
|
3930
3619
|
async function buildPreview(project, previewVariables, builtKey, capture) {
|
|
3931
3620
|
const diskMain = resolveProjectMainHtml(project.dir, project.id);
|
|
3932
|
-
const normalizedDisk = diskMain ?
|
|
3621
|
+
const normalizedDisk = diskMain ? ensureHfIds2(diskMain.html) : null;
|
|
3933
3622
|
try {
|
|
3934
3623
|
let bundled = await adapter.bundle(project.dir, {
|
|
3935
3624
|
stampHfIds: true,
|
|
@@ -3954,7 +3643,7 @@ ${runtimeTag}`;
|
|
|
3954
3643
|
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
|
3955
3644
|
}
|
|
3956
3645
|
bundled = injectStudioPreviewAugmentations(
|
|
3957
|
-
|
|
3646
|
+
ensureHfIds2(await transformPreviewHtml(bundled, adapter, project, mainCompositionPath)),
|
|
3958
3647
|
adapter,
|
|
3959
3648
|
project.dir,
|
|
3960
3649
|
mainCompositionPath,
|
|
@@ -3975,7 +3664,7 @@ ${runtimeTag}`;
|
|
|
3975
3664
|
} catch {
|
|
3976
3665
|
const fallback = resolveProjectMainHtml(project.dir, project.id);
|
|
3977
3666
|
if (fallback) {
|
|
3978
|
-
const fallbackHtml =
|
|
3667
|
+
const fallbackHtml = ensureHfIds2(fallback.html);
|
|
3979
3668
|
let fallbackAugmented = injectStudioPreviewAugmentations(
|
|
3980
3669
|
await transformPreviewHtml(fallbackHtml, adapter, project, fallback.compositionPath),
|
|
3981
3670
|
adapter,
|
|
@@ -4035,7 +3724,7 @@ ${etag}`;
|
|
|
4035
3724
|
function pinSubCompHfIds(compFile, compPath) {
|
|
4036
3725
|
if (!/\.html?$/i.test(compPath)) return void 0;
|
|
4037
3726
|
try {
|
|
4038
|
-
return
|
|
3727
|
+
return ensureHfIds2(readFileSync6(compFile, "utf-8"));
|
|
4039
3728
|
} catch {
|
|
4040
3729
|
return null;
|
|
4041
3730
|
}
|
|
@@ -4054,7 +3743,7 @@ ${etag}`;
|
|
|
4054
3743
|
if (!compFile || !existsSync5(compFile) || !statSync4(compFile).isFile()) {
|
|
4055
3744
|
return c.text("not found", 404);
|
|
4056
3745
|
}
|
|
4057
|
-
const compPathHash =
|
|
3746
|
+
const compPathHash = createHash("sha1").update(compPath).digest("hex");
|
|
4058
3747
|
const capture = c.req.query(PREVIEW_CAPTURE_PARAM) !== void 0;
|
|
4059
3748
|
const etag = `"comp:v2:${compPathHash}:${signature}${variablesEtagSalt(vars.raw)}${capture ? ":capture" : ""}"`;
|
|
4060
3749
|
const ifNoneMatch = c.req.header("If-None-Match");
|
|
@@ -4076,7 +3765,7 @@ ${etag}`;
|
|
|
4076
3765
|
);
|
|
4077
3766
|
if (!html) return c.text("not found", 404);
|
|
4078
3767
|
recordPreviewReferences(project.dir, html);
|
|
4079
|
-
html =
|
|
3768
|
+
html = ensureHfIds2(await transformPreviewHtml(html, adapter, project, compPath));
|
|
4080
3769
|
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath, capture);
|
|
4081
3770
|
if (previewVariables) html = injectPreviewVariables(html, previewVariables);
|
|
4082
3771
|
html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
|
|
@@ -4086,14 +3775,14 @@ ${etag}`;
|
|
|
4086
3775
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
4087
3776
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
4088
3777
|
const subPath = requestSubPath(c.req.url, "projects/:id/preview");
|
|
4089
|
-
const candidate =
|
|
3778
|
+
const candidate = resolve4(project.dir, subPath);
|
|
4090
3779
|
const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
|
|
4091
3780
|
if (!file) {
|
|
4092
3781
|
return c.text("not found", 404);
|
|
4093
3782
|
}
|
|
4094
3783
|
recordPreviewRead(project.dir, file);
|
|
4095
|
-
const
|
|
4096
|
-
if (!
|
|
3784
|
+
const stat2 = existsSync5(file) ? statSync4(file) : null;
|
|
3785
|
+
if (!stat2?.isFile()) {
|
|
4097
3786
|
return c.text("not found", 404);
|
|
4098
3787
|
}
|
|
4099
3788
|
const contentType = getMimeType(subPath);
|
|
@@ -4115,7 +3804,7 @@ ${etag}`;
|
|
|
4115
3804
|
return c.text("media proxy variant does not match asset", 422);
|
|
4116
3805
|
}
|
|
4117
3806
|
}
|
|
4118
|
-
const tag = settledFileTag(
|
|
3807
|
+
const tag = settledFileTag(stat2);
|
|
4119
3808
|
const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
|
|
4120
3809
|
const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
|
|
4121
3810
|
if (!isText && etag) {
|
|
@@ -4127,19 +3816,26 @@ ${etag}`;
|
|
|
4127
3816
|
let servedPath = file;
|
|
4128
3817
|
let servedContentType = contentType;
|
|
4129
3818
|
if (proxyVariant !== void 0) {
|
|
4130
|
-
recordProxyRequest();
|
|
4131
3819
|
try {
|
|
4132
|
-
servedPath = await resolveProxy(project.dir, file, proxyVariant);
|
|
3820
|
+
servedPath = await waitForProxy(resolveProxy(project.dir, file, proxyVariant), 0);
|
|
4133
3821
|
} catch (err) {
|
|
3822
|
+
if (err instanceof ProxyWaitTimeoutError) {
|
|
3823
|
+
return c.text("media proxy is being made", 202, {
|
|
3824
|
+
"Retry-After": String(PROXY_PENDING_RETRY_AFTER_SECONDS),
|
|
3825
|
+
"Cache-Control": "no-store"
|
|
3826
|
+
});
|
|
3827
|
+
}
|
|
4134
3828
|
if (err instanceof ProxyCapacityError) {
|
|
4135
3829
|
return c.text(err.message, 503, { "Retry-After": "5" });
|
|
4136
3830
|
}
|
|
3831
|
+
if (isProjectRootMissing(err)) throw err;
|
|
4137
3832
|
const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
|
|
4138
3833
|
return c.text(message, 502);
|
|
4139
3834
|
}
|
|
3835
|
+
recordProxyRequest();
|
|
4140
3836
|
servedContentType = PROXY_VARIANT_CONFIG[proxyVariant].contentType;
|
|
4141
3837
|
}
|
|
4142
|
-
const textBuffer = isText ? Buffer.from(
|
|
3838
|
+
const textBuffer = isText ? Buffer.from(readFileSync6(file, "utf-8"), "utf-8") : null;
|
|
4143
3839
|
const totalSize = textBuffer ? textBuffer.length : statSync4(servedPath).size;
|
|
4144
3840
|
const bodyFor = (start, end) => textBuffer ? new Uint8Array(textBuffer.subarray(start, end + 1)) : (
|
|
4145
3841
|
// Node's web stream type and the DOM one do not overlap for tsc on
|
|
@@ -4183,8 +3879,8 @@ ${etag}`;
|
|
|
4183
3879
|
}
|
|
4184
3880
|
|
|
4185
3881
|
// src/routes/lint.ts
|
|
4186
|
-
import { readFileSync as
|
|
4187
|
-
import { join as
|
|
3882
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
3883
|
+
import { join as join9 } from "path";
|
|
4188
3884
|
async function collectProjectFindings(adapter, projectDir) {
|
|
4189
3885
|
const findings = [];
|
|
4190
3886
|
const coveredFiles = /* @__PURE__ */ new Set();
|
|
@@ -4202,7 +3898,7 @@ async function lintUncoveredHtml(adapter, projectDir, htmlFiles, coveredFiles) {
|
|
|
4202
3898
|
const findings = [];
|
|
4203
3899
|
for (const file of htmlFiles) {
|
|
4204
3900
|
if (coveredFiles.has(file)) continue;
|
|
4205
|
-
const content =
|
|
3901
|
+
const content = readFileSync7(join9(projectDir, file), "utf-8");
|
|
4206
3902
|
const result = await adapter.lint(content, { filePath: file, isSubComposition: true });
|
|
4207
3903
|
for (const finding of result?.findings ?? []) {
|
|
4208
3904
|
findings.push({ ...finding, file });
|
|
@@ -4235,8 +3931,8 @@ function registerLintRoutes(api, adapter) {
|
|
|
4235
3931
|
|
|
4236
3932
|
// src/routes/render.ts
|
|
4237
3933
|
import { streamSSE } from "hono/streaming";
|
|
4238
|
-
import { existsSync as existsSync6, readFileSync as
|
|
4239
|
-
import { join as
|
|
3934
|
+
import { existsSync as existsSync6, readFileSync as readFileSync8, unlinkSync as unlinkSync3, readdirSync as readdirSync3, statSync as statSync5 } from "fs";
|
|
3935
|
+
import { join as join10 } from "path";
|
|
4240
3936
|
import { VALID_CANVAS_RESOLUTIONS } from "@hyperframes/parsers";
|
|
4241
3937
|
import { formatRenderOutputTimestamp, parseFps } from "@hyperframes/core";
|
|
4242
3938
|
var VALID_RESOLUTIONS = new Set(VALID_CANVAS_RESOLUTIONS);
|
|
@@ -4288,6 +3984,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4288
3984
|
let composition;
|
|
4289
3985
|
if (typeof body.composition === "string" && body.composition.length > 0) {
|
|
4290
3986
|
if (!resolveWithinProject(project.dir, body.composition)) {
|
|
3987
|
+
if (folderGone(project.dir)) return projectDirMissing(c);
|
|
4291
3988
|
return c.json({ error: "composition path must be within the project directory" }, 400);
|
|
4292
3989
|
}
|
|
4293
3990
|
composition = body.composition;
|
|
@@ -4302,9 +3999,9 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4302
3999
|
const now = /* @__PURE__ */ new Date();
|
|
4303
4000
|
const jobId = `${project.id}_${formatRenderOutputTimestamp(now)}`;
|
|
4304
4001
|
const rendersDir = adapter.rendersDir(project);
|
|
4305
|
-
|
|
4002
|
+
mkdirWithinProject(project.dir, rendersDir);
|
|
4306
4003
|
const ext = FORMAT_EXT[format] ?? ".mp4";
|
|
4307
|
-
const outputPath =
|
|
4004
|
+
const outputPath = join10(rendersDir, `${jobId}${ext}`);
|
|
4308
4005
|
const jobState = adapter.startRender({
|
|
4309
4006
|
project,
|
|
4310
4007
|
outputPath,
|
|
@@ -4373,7 +4070,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4373
4070
|
}
|
|
4374
4071
|
const contentType = renderContentType(job.outputPath);
|
|
4375
4072
|
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
|
|
4376
|
-
const content =
|
|
4073
|
+
const content = readFileSync8(job.outputPath);
|
|
4377
4074
|
return new Response(content, {
|
|
4378
4075
|
headers: {
|
|
4379
4076
|
"Content-Type": contentType,
|
|
@@ -4391,7 +4088,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4391
4088
|
}
|
|
4392
4089
|
const contentType = renderContentType(job.outputPath);
|
|
4393
4090
|
const filename = job.outputPath.split("/").pop() ?? `render.mp4`;
|
|
4394
|
-
const content =
|
|
4091
|
+
const content = readFileSync8(job.outputPath);
|
|
4395
4092
|
return new Response(content, {
|
|
4396
4093
|
headers: {
|
|
4397
4094
|
"Content-Type": contentType,
|
|
@@ -4405,7 +4102,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4405
4102
|
if (state.id === jobId && state.outputPath) {
|
|
4406
4103
|
const dir = state.outputPath.replace(/\/[^/]+$/, "");
|
|
4407
4104
|
for (const ext of [".mp4", ".webm", ".mov", ".meta.json"]) {
|
|
4408
|
-
const fp =
|
|
4105
|
+
const fp = join10(dir, `${jobId}${ext}`);
|
|
4409
4106
|
if (existsSync6(fp)) unlinkSync3(fp);
|
|
4410
4107
|
}
|
|
4411
4108
|
break;
|
|
@@ -4424,7 +4121,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4424
4121
|
if (!fp) return c.json({ error: "forbidden" }, 403);
|
|
4425
4122
|
if (!existsSync6(fp)) return c.json({ error: "not found" }, 404);
|
|
4426
4123
|
const contentType = renderContentType(fp);
|
|
4427
|
-
const content =
|
|
4124
|
+
const content = readFileSync8(fp);
|
|
4428
4125
|
return new Response(content, {
|
|
4429
4126
|
headers: {
|
|
4430
4127
|
"Content-Type": contentType,
|
|
@@ -4439,16 +4136,16 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4439
4136
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
4440
4137
|
const rendersDir = adapter.rendersDir(project);
|
|
4441
4138
|
if (!existsSync6(rendersDir)) return c.json({ renders: [] });
|
|
4442
|
-
const files =
|
|
4443
|
-
const fp =
|
|
4444
|
-
const
|
|
4139
|
+
const files = readdirSync3(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
|
|
4140
|
+
const fp = join10(rendersDir, f);
|
|
4141
|
+
const stat2 = statSync5(fp);
|
|
4445
4142
|
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
|
|
4446
|
-
const metaPath =
|
|
4143
|
+
const metaPath = join10(rendersDir, `${rid}.meta.json`);
|
|
4447
4144
|
let status = "complete";
|
|
4448
4145
|
let durationMs;
|
|
4449
4146
|
if (existsSync6(metaPath)) {
|
|
4450
4147
|
try {
|
|
4451
|
-
const meta = JSON.parse(
|
|
4148
|
+
const meta = JSON.parse(readFileSync8(metaPath, "utf-8"));
|
|
4452
4149
|
if (meta.status === "failed" && !existsSync6(fp)) status = "failed";
|
|
4453
4150
|
if (meta.durationMs) durationMs = meta.durationMs;
|
|
4454
4151
|
} catch {
|
|
@@ -4457,8 +4154,8 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4457
4154
|
return {
|
|
4458
4155
|
id: rid,
|
|
4459
4156
|
filename: f,
|
|
4460
|
-
size:
|
|
4461
|
-
createdAt:
|
|
4157
|
+
size: stat2.size,
|
|
4158
|
+
createdAt: stat2.mtimeMs,
|
|
4462
4159
|
status,
|
|
4463
4160
|
durationMs
|
|
4464
4161
|
};
|
|
@@ -4469,7 +4166,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4469
4166
|
id: file.id,
|
|
4470
4167
|
status: file.status,
|
|
4471
4168
|
progress: 100,
|
|
4472
|
-
outputPath:
|
|
4169
|
+
outputPath: join10(rendersDir, file.filename),
|
|
4473
4170
|
createdAt: file.createdAt
|
|
4474
4171
|
});
|
|
4475
4172
|
}
|
|
@@ -4481,7 +4178,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4481
4178
|
// src/routes/imageThumbnail.ts
|
|
4482
4179
|
import { HTTPException } from "hono/http-exception";
|
|
4483
4180
|
import { open, stat } from "fs/promises";
|
|
4484
|
-
import { createHash as
|
|
4181
|
+
import { createHash as createHash2 } from "crypto";
|
|
4485
4182
|
import sharp from "sharp";
|
|
4486
4183
|
|
|
4487
4184
|
// src/routes/thumbnailGenerationCoordinator.ts
|
|
@@ -4501,10 +4198,10 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4501
4198
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
4502
4199
|
let entry = this.entries.get(key);
|
|
4503
4200
|
if (!entry) {
|
|
4504
|
-
let
|
|
4201
|
+
let resolve6;
|
|
4505
4202
|
let reject;
|
|
4506
4203
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
4507
|
-
|
|
4204
|
+
resolve6 = resolvePromise;
|
|
4508
4205
|
reject = rejectPromise;
|
|
4509
4206
|
});
|
|
4510
4207
|
entry = {
|
|
@@ -4514,7 +4211,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4514
4211
|
state: "queued",
|
|
4515
4212
|
work,
|
|
4516
4213
|
promise,
|
|
4517
|
-
resolve:
|
|
4214
|
+
resolve: resolve6,
|
|
4518
4215
|
reject
|
|
4519
4216
|
};
|
|
4520
4217
|
this.entries.set(key, entry);
|
|
@@ -4528,7 +4225,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4528
4225
|
return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
|
|
4529
4226
|
}
|
|
4530
4227
|
lease(entry, signal) {
|
|
4531
|
-
return new Promise((
|
|
4228
|
+
return new Promise((resolve6, reject) => {
|
|
4532
4229
|
let released = false;
|
|
4533
4230
|
const release = () => {
|
|
4534
4231
|
if (released) return;
|
|
@@ -4552,7 +4249,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4552
4249
|
entry.promise.then(
|
|
4553
4250
|
(value) => {
|
|
4554
4251
|
release();
|
|
4555
|
-
|
|
4252
|
+
resolve6(value);
|
|
4556
4253
|
},
|
|
4557
4254
|
(reason) => {
|
|
4558
4255
|
release();
|
|
@@ -4617,14 +4314,14 @@ async function generateThumbnail(path, signal) {
|
|
|
4617
4314
|
async function resolveSource(adapter, id, url) {
|
|
4618
4315
|
const project = await adapter.resolveProject(id);
|
|
4619
4316
|
if (!project) throw new HTTPException(404);
|
|
4620
|
-
let
|
|
4317
|
+
let relative4;
|
|
4621
4318
|
try {
|
|
4622
|
-
|
|
4319
|
+
relative4 = requestSubPath(url, "projects/:id/image-thumbnail");
|
|
4623
4320
|
} catch {
|
|
4624
4321
|
throw new HTTPException(400);
|
|
4625
4322
|
}
|
|
4626
|
-
if (!/\.jpe?g$/i.test(
|
|
4627
|
-
const path = pinWithinProject(project.dir,
|
|
4323
|
+
if (!/\.jpe?g$/i.test(relative4)) throw new HTTPException(415);
|
|
4324
|
+
const path = pinWithinProject(project.dir, relative4);
|
|
4628
4325
|
if (!path) throw new HTTPException(404);
|
|
4629
4326
|
const info = await stat(path).catch(() => {
|
|
4630
4327
|
throw new HTTPException(404);
|
|
@@ -4652,7 +4349,7 @@ function registerImageThumbnailRoutes(api, adapter) {
|
|
|
4652
4349
|
api.get("/projects/:id/image-thumbnail/*", async (c) => {
|
|
4653
4350
|
try {
|
|
4654
4351
|
const { path, info } = await resolveSource(adapter, c.req.param("id"), c.req.url);
|
|
4655
|
-
const key =
|
|
4352
|
+
const key = createHash2("sha256").update(JSON.stringify([path, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])).digest("hex");
|
|
4656
4353
|
const etag = `"${key}"`;
|
|
4657
4354
|
c.header("ETag", etag);
|
|
4658
4355
|
c.header("Cache-Control", "private, no-cache");
|
|
@@ -4680,23 +4377,22 @@ function registerImageThumbnailRoutes(api, adapter) {
|
|
|
4680
4377
|
|
|
4681
4378
|
// src/routes/thumbnail.ts
|
|
4682
4379
|
import {
|
|
4683
|
-
closeSync as
|
|
4684
|
-
fstatSync as
|
|
4685
|
-
openSync as
|
|
4686
|
-
|
|
4687
|
-
|
|
4688
|
-
readdirSync as readdirSync5,
|
|
4380
|
+
closeSync as closeSync2,
|
|
4381
|
+
fstatSync as fstatSync2,
|
|
4382
|
+
openSync as openSync2,
|
|
4383
|
+
readFileSync as readFileSync10,
|
|
4384
|
+
readdirSync as readdirSync4,
|
|
4689
4385
|
renameSync as renameSync3,
|
|
4690
4386
|
rmSync as rmSync4,
|
|
4691
4387
|
statSync as statSync6,
|
|
4692
4388
|
unlinkSync as unlinkSync4,
|
|
4693
|
-
writeFileSync as
|
|
4389
|
+
writeFileSync as writeFileSync5
|
|
4694
4390
|
} from "fs";
|
|
4695
|
-
import { join as
|
|
4696
|
-
import { createHash as
|
|
4391
|
+
import { join as join11 } from "path";
|
|
4392
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "crypto";
|
|
4697
4393
|
|
|
4698
4394
|
// src/helpers/compositionInputs.ts
|
|
4699
|
-
import { readFileSync as
|
|
4395
|
+
import { readFileSync as readFileSync9 } from "fs";
|
|
4700
4396
|
var ROOT_COMPOSITION = "index.html";
|
|
4701
4397
|
var COMPOSITION_SRC = /\bdata-composition-src\s*=\s*(["'])(.*?)\1/g;
|
|
4702
4398
|
function projectReader(projectDir) {
|
|
@@ -4706,7 +4402,7 @@ function projectReader(projectDir) {
|
|
|
4706
4402
|
const file = resolveWithinProject(projectDir, compPath);
|
|
4707
4403
|
let source = null;
|
|
4708
4404
|
try {
|
|
4709
|
-
source = file ?
|
|
4405
|
+
source = file ? readFileSync9(file, "utf-8") : null;
|
|
4710
4406
|
} catch {
|
|
4711
4407
|
}
|
|
4712
4408
|
sources.set(compPath, source);
|
|
@@ -4762,14 +4458,14 @@ var prunedCacheDirs = /* @__PURE__ */ new Set();
|
|
|
4762
4458
|
function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
|
|
4763
4459
|
let entries;
|
|
4764
4460
|
try {
|
|
4765
|
-
entries =
|
|
4461
|
+
entries = readdirSync4(cacheDir, { withFileTypes: true });
|
|
4766
4462
|
} catch (err) {
|
|
4767
4463
|
if (err.code === "ENOENT") return;
|
|
4768
4464
|
throw err;
|
|
4769
4465
|
}
|
|
4770
4466
|
const files = entries.flatMap((entry) => {
|
|
4771
4467
|
if (!entry.isFile()) return [];
|
|
4772
|
-
const path =
|
|
4468
|
+
const path = join11(cacheDir, entry.name);
|
|
4773
4469
|
try {
|
|
4774
4470
|
const stats = statSync6(path);
|
|
4775
4471
|
return [{ path, bytes: stats.size, mtimeMs: stats.mtimeMs }];
|
|
@@ -4797,9 +4493,9 @@ function pruneThumbnailCache(cacheDir, protectedPaths, now = Date.now()) {
|
|
|
4797
4493
|
}
|
|
4798
4494
|
}
|
|
4799
4495
|
function writeThumbnailAtomically(path, buffer) {
|
|
4800
|
-
const temporaryPath = `${path}.${process.pid}.${
|
|
4496
|
+
const temporaryPath = `${path}.${process.pid}.${randomUUID2()}.tmp`;
|
|
4801
4497
|
try {
|
|
4802
|
-
|
|
4498
|
+
writeFileSync5(temporaryPath, buffer, { flag: "wx" });
|
|
4803
4499
|
renameSync3(temporaryPath, path);
|
|
4804
4500
|
} finally {
|
|
4805
4501
|
rmSync4(temporaryPath, { force: true });
|
|
@@ -4808,7 +4504,7 @@ function writeThumbnailAtomically(path, buffer) {
|
|
|
4808
4504
|
function readFileOnce(file) {
|
|
4809
4505
|
let fd;
|
|
4810
4506
|
try {
|
|
4811
|
-
fd =
|
|
4507
|
+
fd = openSync2(file, "r");
|
|
4812
4508
|
} catch (err) {
|
|
4813
4509
|
const code = err.code;
|
|
4814
4510
|
if (code === "ENOENT") return "missing";
|
|
@@ -4816,17 +4512,17 @@ function readFileOnce(file) {
|
|
|
4816
4512
|
throw err;
|
|
4817
4513
|
}
|
|
4818
4514
|
try {
|
|
4819
|
-
const
|
|
4820
|
-
if (!
|
|
4821
|
-
return { data:
|
|
4515
|
+
const stat2 = fstatSync2(fd);
|
|
4516
|
+
if (!stat2.isFile()) return "not-a-file";
|
|
4517
|
+
return { data: readFileSync10(fd), mtimeMs: stat2.mtimeMs };
|
|
4822
4518
|
} finally {
|
|
4823
|
-
|
|
4519
|
+
closeSync2(fd);
|
|
4824
4520
|
}
|
|
4825
4521
|
}
|
|
4826
4522
|
function manifestKey(file) {
|
|
4827
4523
|
const manifest = readFileOnce(file);
|
|
4828
4524
|
if (typeof manifest === "string") return { key: "", mtimeMs: -Infinity };
|
|
4829
|
-
const hash =
|
|
4525
|
+
const hash = createHash3("sha1").update(manifest.data).digest("hex").slice(0, 16);
|
|
4830
4526
|
return { key: `_${hash}`, mtimeMs: Math.round(manifest.mtimeMs) };
|
|
4831
4527
|
}
|
|
4832
4528
|
function registerThumbnailRoutes(api, adapter) {
|
|
@@ -4863,7 +4559,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4863
4559
|
let sourceKey = "";
|
|
4864
4560
|
if (source !== "missing") {
|
|
4865
4561
|
const html = source.data.toString("utf-8");
|
|
4866
|
-
sourceKey = `_${
|
|
4562
|
+
sourceKey = `_${createHash3("sha1").update(html).digest("hex").slice(0, 16)}`;
|
|
4867
4563
|
sourceMtime = Math.round(source.mtimeMs);
|
|
4868
4564
|
if (!vpWidth) {
|
|
4869
4565
|
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
|
@@ -4872,21 +4568,21 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4872
4568
|
if (hMatch?.[1]) compH = parseInt(hMatch[1]);
|
|
4873
4569
|
}
|
|
4874
4570
|
}
|
|
4875
|
-
const manualEdits = manifestKey(
|
|
4876
|
-
const motion = manifestKey(
|
|
4571
|
+
const manualEdits = manifestKey(join11(project.dir, STUDIO_MANUAL_EDITS_PATH));
|
|
4572
|
+
const motion = manifestKey(join11(project.dir, STUDIO_MOTION_PATH));
|
|
4877
4573
|
sourceMtime = Math.max(sourceMtime, manualEdits.mtimeMs, motion.mtimeMs);
|
|
4878
4574
|
const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
|
|
4879
4575
|
const previewPath = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
|
|
4880
4576
|
const previewUrl = `${previewPath}?${PREVIEW_CAPTURE_PARAM}=1`;
|
|
4881
|
-
const cacheDir =
|
|
4577
|
+
const cacheDir = join11(project.dir, ".thumbnails");
|
|
4882
4578
|
const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
|
|
4883
4579
|
const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
|
|
4884
|
-
const inputSignatureKey = `_${
|
|
4580
|
+
const inputSignatureKey = `_${createHash3("sha1").update(inputSignature).digest("hex").slice(0, 16)}`;
|
|
4885
4581
|
const outputScale = outputMode === "source" ? 1 : Math.min(1, THUMBNAIL_MAX_OUTPUT_WIDTH / compW, THUMBNAIL_MAX_OUTPUT_HEIGHT / compH);
|
|
4886
4582
|
const outputWidth = Math.max(1, Math.round(compW * outputScale));
|
|
4887
4583
|
const outputHeight = Math.max(1, Math.round(compH * outputScale));
|
|
4888
4584
|
const cacheKey = `${THUMBNAIL_CACHE_VERSION}${urlVersionKey}${inputSignatureKey}${manualEdits.key}${motion.key}${sourceKey}_${format}_${outputMode}_${compPath.replace(/\//g, "_")}_${compW}x${compH}_${outputWidth}x${outputHeight}_${sourceMtime}_${seekTime.toFixed(2)}${selectorKey}.${format === "png" ? "png" : "jpg"}`;
|
|
4889
|
-
const cachePath =
|
|
4585
|
+
const cachePath = join11(cacheDir, cacheKey);
|
|
4890
4586
|
if (!prunedCacheDirs.has(cacheDir)) {
|
|
4891
4587
|
prunedCacheDirs.add(cacheDir);
|
|
4892
4588
|
pruneThumbnailCache(
|
|
@@ -4900,12 +4596,14 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4900
4596
|
headers: { "Content-Type": contentType, "Cache-Control": "no-cache" }
|
|
4901
4597
|
});
|
|
4902
4598
|
}
|
|
4903
|
-
if (url.searchParams.get("cached") === "1")
|
|
4599
|
+
if (url.searchParams.get("cached") === "1")
|
|
4600
|
+
return c.body(null, 204, { "Cache-Control": "no-cache" });
|
|
4904
4601
|
try {
|
|
4905
4602
|
const buffer = await thumbnailGenerationCoordinator.acquire(
|
|
4906
4603
|
cachePath,
|
|
4907
4604
|
c.req.raw.signal,
|
|
4908
4605
|
async (signal) => {
|
|
4606
|
+
const previewCopiesAtStart = proxyActivityMark(project.dir);
|
|
4909
4607
|
const generated = await adapter.generateThumbnail({
|
|
4910
4608
|
project,
|
|
4911
4609
|
compPath,
|
|
@@ -4921,12 +4619,13 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4921
4619
|
signal
|
|
4922
4620
|
});
|
|
4923
4621
|
if (!generated) return null;
|
|
4622
|
+
const previewCopiesAtEnd = proxyActivityMark(project.dir);
|
|
4924
4623
|
const afterGeneration = await resolveProjectAndSignature(adapter, project.id);
|
|
4925
4624
|
const inputsUnchanged = (signature) => compositionInputSignature(project.dir, compPath, signature) === inputSignature;
|
|
4926
|
-
if (afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
|
|
4625
|
+
if (previewCopiesAtStart === null || previewCopiesAtEnd !== previewCopiesAtStart || afterGeneration?.project.dir !== project.dir || !inputsUnchanged(afterGeneration.signature) || !inputsUnchanged(createProjectSignature(project.dir))) {
|
|
4927
4626
|
return generated;
|
|
4928
4627
|
}
|
|
4929
|
-
|
|
4628
|
+
mkdirWithinProject(project.dir, cacheDir);
|
|
4930
4629
|
writeThumbnailAtomically(cachePath, generated);
|
|
4931
4630
|
return generated;
|
|
4932
4631
|
}
|
|
@@ -4945,6 +4644,7 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4945
4644
|
if (err instanceof DOMException && err.name === "AbortError") {
|
|
4946
4645
|
return new Response(null, { status: 499 });
|
|
4947
4646
|
}
|
|
4647
|
+
if (isProjectRootMissing(err)) throw err;
|
|
4948
4648
|
const msg = err instanceof Error ? err.message : String(err);
|
|
4949
4649
|
return c.json({ error: `Thumbnail generation failed: ${msg}` }, 500);
|
|
4950
4650
|
}
|
|
@@ -4952,25 +4652,25 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4952
4652
|
}
|
|
4953
4653
|
|
|
4954
4654
|
// src/routes/waveform.ts
|
|
4955
|
-
import { existsSync as existsSync7, readFileSync as
|
|
4956
|
-
import { join as
|
|
4655
|
+
import { existsSync as existsSync7, readFileSync as readFileSync11, statSync as statSync7 } from "fs";
|
|
4656
|
+
import { join as join12, resolve as resolve5 } from "path";
|
|
4957
4657
|
import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
|
|
4958
4658
|
function registerWaveformRoutes(api, adapter) {
|
|
4959
4659
|
api.get("/projects/:id/waveform/*", async (c) => {
|
|
4960
4660
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
4961
4661
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
4962
4662
|
const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
|
|
4963
|
-
const audioPath =
|
|
4663
|
+
const audioPath = resolve5(project.dir, assetPath);
|
|
4964
4664
|
if (!isWithinProjectRoot2(project.dir, audioPath)) {
|
|
4965
4665
|
return c.json({ error: "file not found" }, 404);
|
|
4966
4666
|
}
|
|
4967
4667
|
const stats = statSync7(audioPath, { throwIfNoEntry: false });
|
|
4968
4668
|
if (!stats) return c.json({ error: "file not found" }, 404);
|
|
4969
|
-
const cacheDir =
|
|
4970
|
-
const cachePath =
|
|
4669
|
+
const cacheDir = join12(project.dir, ".waveform-cache");
|
|
4670
|
+
const cachePath = join12(cacheDir, buildWaveformCacheKey(assetPath, stats));
|
|
4971
4671
|
try {
|
|
4972
4672
|
if (isWaveformCacheDirectory(cacheDir) && existsSync7(cachePath)) {
|
|
4973
|
-
const peaks2 = JSON.parse(
|
|
4673
|
+
const peaks2 = JSON.parse(readFileSync11(cachePath, "utf-8"));
|
|
4974
4674
|
return c.json({ peaks: peaks2 });
|
|
4975
4675
|
}
|
|
4976
4676
|
} catch {
|
|
@@ -4990,7 +4690,7 @@ function registerWaveformRoutes(api, adapter) {
|
|
|
4990
4690
|
}
|
|
4991
4691
|
|
|
4992
4692
|
// src/routes/fonts.ts
|
|
4993
|
-
import { closeSync as
|
|
4693
|
+
import { closeSync as closeSync3, constants, fstatSync as fstatSync3, openSync as openSync3, readSync } from "fs";
|
|
4994
4694
|
import {
|
|
4995
4695
|
collectFontFileEntries,
|
|
4996
4696
|
fontDirectories,
|
|
@@ -5097,17 +4797,17 @@ function registerFontRoutes(api) {
|
|
|
5097
4797
|
if (!located) return c.json({ error: "font not found" }, 404);
|
|
5098
4798
|
let fd;
|
|
5099
4799
|
try {
|
|
5100
|
-
fd =
|
|
4800
|
+
fd = openSync3(located.path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
5101
4801
|
} catch {
|
|
5102
4802
|
return c.json({ error: "font file not accessible" }, 404);
|
|
5103
4803
|
}
|
|
5104
4804
|
try {
|
|
5105
|
-
const
|
|
5106
|
-
if (
|
|
4805
|
+
const stat2 = fstatSync3(fd);
|
|
4806
|
+
if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
|
|
5107
4807
|
return c.json({ error: "font file too large" }, 413);
|
|
5108
4808
|
}
|
|
5109
|
-
const buffer = Buffer.alloc(
|
|
5110
|
-
readSync(fd, buffer, 0,
|
|
4809
|
+
const buffer = Buffer.alloc(stat2.size);
|
|
4810
|
+
readSync(fd, buffer, 0, stat2.size, 0);
|
|
5111
4811
|
const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
|
|
5112
4812
|
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
|
|
5113
4813
|
return new Response(buffer, {
|
|
@@ -5119,7 +4819,7 @@ function registerFontRoutes(api) {
|
|
|
5119
4819
|
} catch {
|
|
5120
4820
|
return c.json({ error: "failed to read font file" }, 500);
|
|
5121
4821
|
} finally {
|
|
5122
|
-
|
|
4822
|
+
closeSync3(fd);
|
|
5123
4823
|
}
|
|
5124
4824
|
});
|
|
5125
4825
|
}
|
|
@@ -5139,6 +4839,7 @@ function registerRegistryRoutes(api, adapter) {
|
|
|
5139
4839
|
}
|
|
5140
4840
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
5141
4841
|
if (!project) return c.json({ error: "Project not found" }, 404);
|
|
4842
|
+
if (folderGone(project.dir)) return projectDirMissing(c);
|
|
5142
4843
|
const body = await c.req.json().catch(() => null);
|
|
5143
4844
|
if (!body?.blockName) {
|
|
5144
4845
|
return c.json({ error: "blockName is required" }, 400);
|
|
@@ -5147,6 +4848,7 @@ function registerRegistryRoutes(api, adapter) {
|
|
|
5147
4848
|
const result = await adapter.installRegistryBlock({ project, blockName: body.blockName });
|
|
5148
4849
|
return c.json(result);
|
|
5149
4850
|
} catch (err) {
|
|
4851
|
+
if (isProjectRootMissing(err)) return projectDirMissing(c);
|
|
5150
4852
|
const message = err instanceof Error ? err.message : "Install failed";
|
|
5151
4853
|
return c.json({ error: message }, 500);
|
|
5152
4854
|
}
|
|
@@ -5253,8 +4955,8 @@ function registerSelectionRoutes(api, adapter) {
|
|
|
5253
4955
|
|
|
5254
4956
|
// src/routes/media.ts
|
|
5255
4957
|
import { streamSSE as streamSSE2 } from "hono/streaming";
|
|
5256
|
-
import { existsSync as existsSync8
|
|
5257
|
-
import { basename as basename2, dirname as
|
|
4958
|
+
import { existsSync as existsSync8 } from "fs";
|
|
4959
|
+
import { basename as basename2, dirname as dirname5, extname, join as join13, posix } from "path";
|
|
5258
4960
|
var VIDEO_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
5259
4961
|
".mp4",
|
|
5260
4962
|
".mov",
|
|
@@ -5272,10 +4974,10 @@ var VIDEO_OUTPUT_EXTENSIONS = /* @__PURE__ */ new Set([".webm", ".mov"]);
|
|
|
5272
4974
|
var QUALITIES = /* @__PURE__ */ new Set(["fast", "balanced", "best"]);
|
|
5273
4975
|
var DEVICES = /* @__PURE__ */ new Set(["auto", "cpu", "coreml", "cuda"]);
|
|
5274
4976
|
function isVideoPath(path) {
|
|
5275
|
-
return VIDEO_EXTENSIONS.has(
|
|
4977
|
+
return VIDEO_EXTENSIONS.has(extname(path).toLowerCase());
|
|
5276
4978
|
}
|
|
5277
4979
|
function isImagePath(path) {
|
|
5278
|
-
return IMAGE_EXTENSIONS.has(
|
|
4980
|
+
return IMAGE_EXTENSIONS.has(extname(path).toLowerCase());
|
|
5279
4981
|
}
|
|
5280
4982
|
function normalizeProjectAssetPath(path) {
|
|
5281
4983
|
return path.trim().replace(/^[.]\//, "").replace(/[?#].*$/, "");
|
|
@@ -5284,14 +4986,14 @@ function containsNullByte(path) {
|
|
|
5284
4986
|
return path.includes("\0");
|
|
5285
4987
|
}
|
|
5286
4988
|
function slugFileBase(path) {
|
|
5287
|
-
const name = basename2(path,
|
|
4989
|
+
const name = basename2(path, extname(path)).replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
5288
4990
|
return name || "media";
|
|
5289
4991
|
}
|
|
5290
4992
|
function uniqueAssetPath(projectDir, assetPath, taken) {
|
|
5291
|
-
const ext =
|
|
4993
|
+
const ext = extname(assetPath);
|
|
5292
4994
|
const withoutExt = assetPath.slice(0, -ext.length);
|
|
5293
4995
|
let candidate = assetPath;
|
|
5294
|
-
for (let index = 2; taken.has(candidate) || existsSync8(
|
|
4996
|
+
for (let index = 2; taken.has(candidate) || existsSync8(join13(projectDir, candidate)); index++) {
|
|
5295
4997
|
candidate = `${withoutExt}-${index}${ext}`;
|
|
5296
4998
|
}
|
|
5297
4999
|
return candidate;
|
|
@@ -5391,10 +5093,10 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5391
5093
|
const outputAssetPath = requestedOutput ? uniqueAssetPath(project.dir, requestedOutput, taken) : defaultOutputPath(project.dir, inputAssetPath, taken);
|
|
5392
5094
|
const outputPath = pinWithinProject(project.dir, outputAssetPath);
|
|
5393
5095
|
if (!outputPath) return c.json({ error: "forbidden" }, 403);
|
|
5394
|
-
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(
|
|
5096
|
+
if (inputIsVideo && !VIDEO_OUTPUT_EXTENSIONS.has(extname(outputAssetPath).toLowerCase())) {
|
|
5395
5097
|
return c.json({ error: "video background removal output must be .webm or .mov" }, 400);
|
|
5396
5098
|
}
|
|
5397
|
-
if (inputIsImage &&
|
|
5099
|
+
if (inputIsImage && extname(outputAssetPath).toLowerCase() !== ".png") {
|
|
5398
5100
|
return c.json({ error: "image background removal output must be .png" }, 400);
|
|
5399
5101
|
}
|
|
5400
5102
|
let backgroundOutputAssetPath;
|
|
@@ -5410,8 +5112,8 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5410
5112
|
return c.json({ error: "forbidden" }, 403);
|
|
5411
5113
|
}
|
|
5412
5114
|
}
|
|
5413
|
-
|
|
5414
|
-
if (backgroundOutputPath)
|
|
5115
|
+
mkdirWithinProject(project.dir, dirname5(outputPath));
|
|
5116
|
+
if (backgroundOutputPath) mkdirWithinProject(project.dir, dirname5(backgroundOutputPath));
|
|
5415
5117
|
const jobId = makeJobId(project.id, mediaJobs);
|
|
5416
5118
|
const state = adapter.startBackgroundRemoval({
|
|
5417
5119
|
project,
|
|
@@ -5468,14 +5170,14 @@ function registerMediaRoutes(api, adapter, options = {}) {
|
|
|
5468
5170
|
}
|
|
5469
5171
|
|
|
5470
5172
|
// src/routes/globalAssets.ts
|
|
5471
|
-
import { existsSync as existsSync9, readFileSync as
|
|
5173
|
+
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "fs";
|
|
5472
5174
|
import { homedir } from "os";
|
|
5473
|
-
import { join as
|
|
5175
|
+
import { join as join14 } from "path";
|
|
5474
5176
|
function readGlobalAssets(home = homedir()) {
|
|
5475
|
-
const manifestPath =
|
|
5177
|
+
const manifestPath = join14(home, ".media", "manifest.jsonl");
|
|
5476
5178
|
if (!existsSync9(manifestPath)) return [];
|
|
5477
5179
|
const out = [];
|
|
5478
|
-
for (const line of
|
|
5180
|
+
for (const line of readFileSync12(manifestPath, "utf8").split("\n")) {
|
|
5479
5181
|
if (!line.trim()) continue;
|
|
5480
5182
|
try {
|
|
5481
5183
|
const rec = JSON.parse(line);
|
|
@@ -5492,1259 +5194,6 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5492
5194
|
api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
|
|
5493
5195
|
}
|
|
5494
5196
|
|
|
5495
|
-
// src/history/projectHistory.ts
|
|
5496
|
-
import { randomUUID as randomUUID9 } from "crypto";
|
|
5497
|
-
import { readdir as readdir3, rm as rm3, rmdir, writeFile } from "fs/promises";
|
|
5498
|
-
import {
|
|
5499
|
-
existsSync as existsSync11,
|
|
5500
|
-
lstatSync as lstatSync4,
|
|
5501
|
-
statSync as statSync10,
|
|
5502
|
-
mkdirSync as mkdirSync11,
|
|
5503
|
-
readdirSync as readdirSync7,
|
|
5504
|
-
readFileSync as readFileSync19,
|
|
5505
|
-
realpathSync,
|
|
5506
|
-
rmSync as rmSync6
|
|
5507
|
-
} from "fs";
|
|
5508
|
-
import { homedir as homedir2 } from "os";
|
|
5509
|
-
import { dirname as dirname10, isAbsolute as isAbsolute2, join as join20, relative as relative5, resolve as resolve8, sep as sep3 } from "path";
|
|
5510
|
-
|
|
5511
|
-
// src/history/blobStore.ts
|
|
5512
|
-
import { createHash as createHash7, randomUUID as randomUUID5 } from "crypto";
|
|
5513
|
-
import { constants as constants3, createReadStream as createReadStream2, renameSync as renameSync4 } from "fs";
|
|
5514
|
-
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat as stat2 } from "fs/promises";
|
|
5515
|
-
import { dirname as dirname7, join as join16 } from "path";
|
|
5516
|
-
var BLOB_HASH = /^[0-9a-f]{64}$/;
|
|
5517
|
-
async function hashFile(path) {
|
|
5518
|
-
const hash = createHash7("sha256");
|
|
5519
|
-
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
5520
|
-
return hash.digest("hex");
|
|
5521
|
-
}
|
|
5522
|
-
async function cloneOrCopy(from, to, beforeReplace) {
|
|
5523
|
-
await mkdir(dirname7(to), { recursive: true });
|
|
5524
|
-
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5525
|
-
try {
|
|
5526
|
-
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
5527
|
-
beforeReplace?.();
|
|
5528
|
-
renameSync4(temp, to);
|
|
5529
|
-
} catch (error) {
|
|
5530
|
-
await rm(temp, { force: true });
|
|
5531
|
-
throw error;
|
|
5532
|
-
}
|
|
5533
|
-
}
|
|
5534
|
-
async function openBlobStore(dir) {
|
|
5535
|
-
const sizes = /* @__PURE__ */ new Map();
|
|
5536
|
-
await mkdir(dir, { recursive: true });
|
|
5537
|
-
for (const shard of await readdir(dir))
|
|
5538
|
-
for (const hash of await readdir(join16(dir, shard)).catch(() => []))
|
|
5539
|
-
sizes.set(hash, (await stat2(join16(dir, shard, hash))).size);
|
|
5540
|
-
const pathOf = (hash) => {
|
|
5541
|
-
if (!BLOB_HASH.test(hash)) throw new Error("That is not a history blob.");
|
|
5542
|
-
return join16(dir, hash.slice(0, 2), hash);
|
|
5543
|
-
};
|
|
5544
|
-
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
5545
|
-
return {
|
|
5546
|
-
async put(absPath) {
|
|
5547
|
-
const temp = join16(dir, `incoming-${randomUUID5()}`);
|
|
5548
|
-
await mkdir(dir, { recursive: true });
|
|
5549
|
-
await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
|
|
5550
|
-
const hash = await hashFile(temp);
|
|
5551
|
-
if (sizes.has(hash)) {
|
|
5552
|
-
await rm(temp, { force: true });
|
|
5553
|
-
return hash;
|
|
5554
|
-
}
|
|
5555
|
-
await mkdir(dirname7(pathOf(hash)), { recursive: true });
|
|
5556
|
-
const size = (await stat2(temp)).size;
|
|
5557
|
-
await rename(temp, pathOf(hash));
|
|
5558
|
-
sizes.set(hash, size);
|
|
5559
|
-
total += size;
|
|
5560
|
-
return hash;
|
|
5561
|
-
},
|
|
5562
|
-
has: (hash) => sizes.has(hash),
|
|
5563
|
-
read: async (hash) => readFile2(pathOf(hash)),
|
|
5564
|
-
writeTo: async (hash, absPath, beforeReplace) => cloneOrCopy(pathOf(hash), absPath, beforeReplace),
|
|
5565
|
-
bytes: () => total,
|
|
5566
|
-
size: (hash) => sizes.get(hash) ?? 0,
|
|
5567
|
-
async prune(keep) {
|
|
5568
|
-
for (const [hash, size] of sizes) {
|
|
5569
|
-
if (keep.has(hash)) continue;
|
|
5570
|
-
await rm(pathOf(hash), { force: true });
|
|
5571
|
-
sizes.delete(hash);
|
|
5572
|
-
total -= size;
|
|
5573
|
-
}
|
|
5574
|
-
}
|
|
5575
|
-
};
|
|
5576
|
-
}
|
|
5577
|
-
|
|
5578
|
-
// src/history/pruneHistories.ts
|
|
5579
|
-
import { randomUUID as randomUUID8 } from "crypto";
|
|
5580
|
-
import { readdirSync as readdirSync6, readFileSync as readFileSync17, renameSync as renameSync5, statSync as statSync9, writeFileSync as writeFileSync9 } from "fs";
|
|
5581
|
-
import { lstat, readdir as readdir2, rm as rm2 } from "fs/promises";
|
|
5582
|
-
import { tmpdir as tmpdir2 } from "os";
|
|
5583
|
-
import { dirname as dirname8, join as join19 } from "path";
|
|
5584
|
-
|
|
5585
|
-
// src/history/historyId.ts
|
|
5586
|
-
import { randomUUID as randomUUID6 } from "crypto";
|
|
5587
|
-
import { existsSync as existsSync10, mkdirSync as mkdirSync8, readFileSync as readFileSync15, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
|
|
5588
|
-
import { basename as basename3, join as join17, resolve as resolve7 } from "path";
|
|
5589
|
-
var ID_PATH = join17(".hyperframes", "history-id");
|
|
5590
|
-
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
5591
|
-
var isHistoryId = (name) => ID_SHAPE.test(name);
|
|
5592
|
-
function readId(projectDir) {
|
|
5593
|
-
try {
|
|
5594
|
-
const id = readFileSync15(join17(projectDir, ID_PATH), "utf-8").trim();
|
|
5595
|
-
return ID_SHAPE.test(id) ? id : null;
|
|
5596
|
-
} catch {
|
|
5597
|
-
return null;
|
|
5598
|
-
}
|
|
5599
|
-
}
|
|
5600
|
-
var sameFolder = (a, b) => a.ino === b.ino && a.birthtimeMs === b.birthtimeMs;
|
|
5601
|
-
function isRecordedFolder(historyDir, folder) {
|
|
5602
|
-
const was = readRecord(historyDir);
|
|
5603
|
-
if (typeof was?.ino === "number")
|
|
5604
|
-
return sameFolder({ ino: was.ino, birthtimeMs: was.born ?? NaN }, folder);
|
|
5605
|
-
const unreadableSo0878KeptTheId = typeof was?.dir !== "string";
|
|
5606
|
-
if (unreadableSo0878KeptTheId) return true;
|
|
5607
|
-
return !isCopyOf0878Folder(was.dir, basename3(historyDir), folder);
|
|
5608
|
-
}
|
|
5609
|
-
function readRecord(historyDir) {
|
|
5610
|
-
try {
|
|
5611
|
-
return JSON.parse(readFileSync15(join17(historyDir, "project.json"), "utf-8"));
|
|
5612
|
-
} catch {
|
|
5613
|
-
return null;
|
|
5614
|
-
}
|
|
5615
|
-
}
|
|
5616
|
-
function isCopyOf0878Folder(recordedDir, id, folder) {
|
|
5617
|
-
let there;
|
|
5618
|
-
try {
|
|
5619
|
-
there = statSync8(recordedDir, { throwIfNoEntry: false });
|
|
5620
|
-
} catch {
|
|
5621
|
-
return false;
|
|
5622
|
-
}
|
|
5623
|
-
return !!there && !sameFolder(there, folder) && readId(recordedDir) === id;
|
|
5624
|
-
}
|
|
5625
|
-
function projectHistoryId(projectDir, historyRoot) {
|
|
5626
|
-
const dir = resolve7(projectDir);
|
|
5627
|
-
const folder = statSync8(dir);
|
|
5628
|
-
let id = readId(dir);
|
|
5629
|
-
if (!id && existsSync10(join17(dir, ID_PATH)))
|
|
5630
|
-
throw new HistoryIdError(
|
|
5631
|
-
`${join17(dir, ID_PATH)} holds no history id this version can read; move it aside to start anew.`
|
|
5632
|
-
);
|
|
5633
|
-
if (!id || existsSync10(join17(historyRoot, id)) && !isRecordedFolder(join17(historyRoot, id), folder)) {
|
|
5634
|
-
id = randomUUID6();
|
|
5635
|
-
mkdirSync8(join17(dir, ".hyperframes"), { recursive: true });
|
|
5636
|
-
writeFileSync7(join17(dir, ID_PATH), `${id}
|
|
5637
|
-
`);
|
|
5638
|
-
}
|
|
5639
|
-
recordProject(join17(historyRoot, id), dir, folder);
|
|
5640
|
-
return id;
|
|
5641
|
-
}
|
|
5642
|
-
var HistoryIdError = class extends Error {
|
|
5643
|
-
constructor(message) {
|
|
5644
|
-
super(message);
|
|
5645
|
-
this.name = "HistoryIdError";
|
|
5646
|
-
}
|
|
5647
|
-
};
|
|
5648
|
-
function recordProject(historyDir, dir, folder) {
|
|
5649
|
-
const record = { dir, ino: folder.ino, born: folder.birthtimeMs, dev: folder.dev };
|
|
5650
|
-
mkdirSync8(historyDir, { recursive: true });
|
|
5651
|
-
replaceFileAtomically(join17(historyDir, "project.json"), JSON.stringify(record), 420);
|
|
5652
|
-
}
|
|
5653
|
-
|
|
5654
|
-
// src/history/ownerLock.ts
|
|
5655
|
-
import { randomUUID as randomUUID7 } from "crypto";
|
|
5656
|
-
import { linkSync, mkdirSync as mkdirSync9, readFileSync as readFileSync16, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "fs";
|
|
5657
|
-
import { join as join18 } from "path";
|
|
5658
|
-
var HistoryBusyError = class extends Error {
|
|
5659
|
-
constructor(pid) {
|
|
5660
|
-
super(`This project's history is open in another process (pid ${pid}).`);
|
|
5661
|
-
this.pid = pid;
|
|
5662
|
-
this.name = "HistoryBusyError";
|
|
5663
|
-
}
|
|
5664
|
-
pid;
|
|
5665
|
-
};
|
|
5666
|
-
function alive(pid) {
|
|
5667
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
5668
|
-
try {
|
|
5669
|
-
process.kill(pid, 0);
|
|
5670
|
-
return true;
|
|
5671
|
-
} catch (error) {
|
|
5672
|
-
return error.code === "EPERM";
|
|
5673
|
-
}
|
|
5674
|
-
}
|
|
5675
|
-
function ownerOf(file) {
|
|
5676
|
-
try {
|
|
5677
|
-
const text2 = readFileSync16(file, "utf-8");
|
|
5678
|
-
return /^\d+$/.test(text2) ? Number(text2) : Number.NaN;
|
|
5679
|
-
} catch (error) {
|
|
5680
|
-
if (error.code === "ENOENT") return null;
|
|
5681
|
-
throw error;
|
|
5682
|
-
}
|
|
5683
|
-
}
|
|
5684
|
-
function claim(file) {
|
|
5685
|
-
const draft = `${file}-${randomUUID7()}.tmp`;
|
|
5686
|
-
writeFileSync8(draft, String(process.pid));
|
|
5687
|
-
try {
|
|
5688
|
-
linkSync(draft, file);
|
|
5689
|
-
return true;
|
|
5690
|
-
} catch (error) {
|
|
5691
|
-
if (error.code !== "EEXIST") throw error;
|
|
5692
|
-
return false;
|
|
5693
|
-
} finally {
|
|
5694
|
-
rmSync5(draft, { force: true });
|
|
5695
|
-
}
|
|
5696
|
-
}
|
|
5697
|
-
function releaseOwn(file) {
|
|
5698
|
-
if (ownerOf(file) === process.pid) rmSync5(file, { force: true });
|
|
5699
|
-
}
|
|
5700
|
-
function evictDeadOwner(file) {
|
|
5701
|
-
const evictor = `${file}.evict`;
|
|
5702
|
-
if (!claim(evictor)) {
|
|
5703
|
-
const holder = ownerOf(evictor);
|
|
5704
|
-
if (holder !== null && !alive(holder)) rmSync5(evictor, { force: true });
|
|
5705
|
-
return false;
|
|
5706
|
-
}
|
|
5707
|
-
try {
|
|
5708
|
-
const pid = ownerOf(file);
|
|
5709
|
-
if (pid !== null && !alive(pid)) rmSync5(file, { force: true });
|
|
5710
|
-
return true;
|
|
5711
|
-
} finally {
|
|
5712
|
-
releaseOwn(evictor);
|
|
5713
|
-
}
|
|
5714
|
-
}
|
|
5715
|
-
async function takeHistoryOwnership(home, waitMs) {
|
|
5716
|
-
const file = join18(home, "owner.pid");
|
|
5717
|
-
const deadline = Date.now() + waitMs;
|
|
5718
|
-
mkdirSync9(home, { recursive: true });
|
|
5719
|
-
for (; ; ) {
|
|
5720
|
-
if (claim(file)) {
|
|
5721
|
-
let held = true;
|
|
5722
|
-
return () => {
|
|
5723
|
-
if (held) releaseOwn(file);
|
|
5724
|
-
held = false;
|
|
5725
|
-
};
|
|
5726
|
-
}
|
|
5727
|
-
const pid = ownerOf(file);
|
|
5728
|
-
if (pid === null) continue;
|
|
5729
|
-
if (!alive(pid) && evictDeadOwner(file)) continue;
|
|
5730
|
-
if (Date.now() >= deadline) throw new HistoryBusyError(pid);
|
|
5731
|
-
await new Promise((settle) => setTimeout(settle, 50));
|
|
5732
|
-
}
|
|
5733
|
-
}
|
|
5734
|
-
|
|
5735
|
-
// src/history/pruneHistories.ts
|
|
5736
|
-
var DAY_MS = 24 * 60 * 60 * 1e3;
|
|
5737
|
-
var KEEP_GONE_PROJECT_HISTORY_MS = 14 * DAY_MS;
|
|
5738
|
-
var PRUNED_PREFIX = ".pruned-";
|
|
5739
|
-
var LAST_PRUNE_FILE = ".last-prune";
|
|
5740
|
-
async function pruneGoneProjectHistories(historyRoot, options = {}) {
|
|
5741
|
-
return (await pruneWithin(historyRoot, options)).pruned;
|
|
5742
|
-
}
|
|
5743
|
-
async function pruneWithin(historyRoot, {
|
|
5744
|
-
now = Date.now(),
|
|
5745
|
-
dryRun = false,
|
|
5746
|
-
tempDir = tmpdir2(),
|
|
5747
|
-
onError = (error) => console.warn("[history] could not prune a history:", error),
|
|
5748
|
-
budgetMs = Infinity,
|
|
5749
|
-
measure = true
|
|
5750
|
-
}) {
|
|
5751
|
-
const started = performance.now();
|
|
5752
|
-
let names;
|
|
5753
|
-
try {
|
|
5754
|
-
names = readdirSync6(historyRoot);
|
|
5755
|
-
} catch (error) {
|
|
5756
|
-
if (error.code === "ENOENT") return { pruned: [], finished: true };
|
|
5757
|
-
throw error;
|
|
5758
|
-
}
|
|
5759
|
-
const pruned = [];
|
|
5760
|
-
for (const name of names) {
|
|
5761
|
-
if (performance.now() - started >= budgetMs) return { pruned, finished: false };
|
|
5762
|
-
try {
|
|
5763
|
-
const gone = await pruneEntry(historyRoot, name, { now, dryRun, tempDir, measure });
|
|
5764
|
-
if (gone) pruned.push(gone);
|
|
5765
|
-
} catch (error) {
|
|
5766
|
-
onError(error);
|
|
5767
|
-
}
|
|
5768
|
-
}
|
|
5769
|
-
return { pruned, finished: true };
|
|
5770
|
-
}
|
|
5771
|
-
async function pruneEntry(historyRoot, name, run) {
|
|
5772
|
-
if (name.startsWith(PRUNED_PREFIX)) {
|
|
5773
|
-
if (!run.dryRun) await rm2(join19(historyRoot, name), { recursive: true, force: true });
|
|
5774
|
-
return null;
|
|
5775
|
-
}
|
|
5776
|
-
return isHistoryId(name) ? pruneOne(historyRoot, name, run) : null;
|
|
5777
|
-
}
|
|
5778
|
-
async function pruneOne(historyRoot, id, run) {
|
|
5779
|
-
const home = join19(historyRoot, id);
|
|
5780
|
-
if (abandonedProject(home, id, run.now, run.tempDir) === null) return null;
|
|
5781
|
-
const release = await takeUnlessBusy(home);
|
|
5782
|
-
if (!release) return null;
|
|
5783
|
-
let taken;
|
|
5784
|
-
try {
|
|
5785
|
-
taken = recheckAndMoveAway(historyRoot, id, run);
|
|
5786
|
-
} catch (error) {
|
|
5787
|
-
release();
|
|
5788
|
-
throw error;
|
|
5789
|
-
}
|
|
5790
|
-
if (!taken?.trash) release();
|
|
5791
|
-
if (!taken) return null;
|
|
5792
|
-
const bytes = await clearAway(taken.trash, home, run.measure);
|
|
5793
|
-
return { id, projectDir: taken.projectDir, bytes };
|
|
5794
|
-
}
|
|
5795
|
-
async function clearAway(trash, home, measure) {
|
|
5796
|
-
const bytes = measure ? await bytesIn(trash ?? home) : 0;
|
|
5797
|
-
if (trash) await rm2(trash, { recursive: true, force: true });
|
|
5798
|
-
return bytes;
|
|
5799
|
-
}
|
|
5800
|
-
async function takeUnlessBusy(home) {
|
|
5801
|
-
try {
|
|
5802
|
-
return await takeHistoryOwnership(home, 0);
|
|
5803
|
-
} catch (error) {
|
|
5804
|
-
if (error instanceof HistoryBusyError) return null;
|
|
5805
|
-
throw error;
|
|
5806
|
-
}
|
|
5807
|
-
}
|
|
5808
|
-
function recheckAndMoveAway(historyRoot, id, { now, dryRun, tempDir }) {
|
|
5809
|
-
const home = join19(historyRoot, id);
|
|
5810
|
-
const projectDir = abandonedProject(home, id, now, tempDir);
|
|
5811
|
-
if (projectDir === null) return null;
|
|
5812
|
-
if (dryRun) return { projectDir, trash: null };
|
|
5813
|
-
const trash = join19(historyRoot, `${PRUNED_PREFIX}${id}-${randomUUID8()}`);
|
|
5814
|
-
renameSync5(home, trash);
|
|
5815
|
-
return { projectDir, trash };
|
|
5816
|
-
}
|
|
5817
|
-
function abandonedProject(home, id, now, tempDir) {
|
|
5818
|
-
const record = readRecord(home);
|
|
5819
|
-
const dir = record?.dir;
|
|
5820
|
-
if (typeof dir !== "string") return null;
|
|
5821
|
-
if (now - lastUsed(home) < KEEP_GONE_PROJECT_HISTORY_MS && !isSafePath(tempDir, dir)) return null;
|
|
5822
|
-
return projectGone(dir, id, record?.dev, tempDir) ? dir : null;
|
|
5823
|
-
}
|
|
5824
|
-
function projectGone(dir, id, dev, tempDir) {
|
|
5825
|
-
if (!statSync9(join19(dir, ID_PATH), { throwIfNoEntry: false }))
|
|
5826
|
-
return diskStillHere(dir, dev, tempDir);
|
|
5827
|
-
const there = readId(dir);
|
|
5828
|
-
return there !== null && there !== id;
|
|
5829
|
-
}
|
|
5830
|
-
function diskStillHere(dir, dev, tempDir) {
|
|
5831
|
-
let at = dir;
|
|
5832
|
-
let stat3 = statSync9(at, { throwIfNoEntry: false });
|
|
5833
|
-
while (!stat3) {
|
|
5834
|
-
if (dirname8(at) === at) return false;
|
|
5835
|
-
at = dirname8(at);
|
|
5836
|
-
stat3 = statSync9(at, { throwIfNoEntry: false });
|
|
5837
|
-
}
|
|
5838
|
-
if (typeof dev === "number") return stat3.dev === dev;
|
|
5839
|
-
return at === dir || isSafePath(tempDir, at);
|
|
5840
|
-
}
|
|
5841
|
-
function lastUsed(home) {
|
|
5842
|
-
const mtime = (file) => statSync9(join19(home, file), { throwIfNoEntry: false })?.mtimeMs ?? 0;
|
|
5843
|
-
return Math.max(mtime("project.json"), mtime("log.jsonl"));
|
|
5844
|
-
}
|
|
5845
|
-
async function bytesIn(dir) {
|
|
5846
|
-
let total = 0;
|
|
5847
|
-
for (const entry of await readdir2(dir, { withFileTypes: true })) {
|
|
5848
|
-
const path = join19(dir, entry.name);
|
|
5849
|
-
total += entry.isDirectory() ? await bytesIn(path) : (await lstat(path)).size;
|
|
5850
|
-
}
|
|
5851
|
-
return total;
|
|
5852
|
-
}
|
|
5853
|
-
function prunedWithinADay(stamp, now) {
|
|
5854
|
-
try {
|
|
5855
|
-
const last = Number(readFileSync17(stamp, "utf-8"));
|
|
5856
|
-
return last <= now && now - last < DAY_MS;
|
|
5857
|
-
} catch {
|
|
5858
|
-
return false;
|
|
5859
|
-
}
|
|
5860
|
-
}
|
|
5861
|
-
var pruning = /* @__PURE__ */ new Set();
|
|
5862
|
-
function pruneGoneProjectHistoriesDaily(historyRoot, now, { onError, budgetMs } = {}) {
|
|
5863
|
-
const stamp = join19(historyRoot, LAST_PRUNE_FILE);
|
|
5864
|
-
if (pruning.has(historyRoot) || prunedWithinADay(stamp, now)) return;
|
|
5865
|
-
pruning.add(historyRoot);
|
|
5866
|
-
setImmediate(() => {
|
|
5867
|
-
pruneWithin(historyRoot, { now, onError, budgetMs, measure: false }).then(({ finished }) => finished && writeFileSync9(stamp, String(now))).catch((error) => onError?.(error)).finally(() => pruning.delete(historyRoot));
|
|
5868
|
-
});
|
|
5869
|
-
}
|
|
5870
|
-
|
|
5871
|
-
// src/history/historyLog.ts
|
|
5872
|
-
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync10, openSync as openSync5, readFileSync as readFileSync18, writeSync as writeSync2 } from "fs";
|
|
5873
|
-
import { dirname as dirname9 } from "path";
|
|
5874
|
-
var START = "start";
|
|
5875
|
-
function parseRecord(line) {
|
|
5876
|
-
try {
|
|
5877
|
-
return JSON.parse(line);
|
|
5878
|
-
} catch {
|
|
5879
|
-
return null;
|
|
5880
|
-
}
|
|
5881
|
-
}
|
|
5882
|
-
function readLog(file, onUnreadable) {
|
|
5883
|
-
let text2;
|
|
5884
|
-
try {
|
|
5885
|
-
text2 = readFileSync18(file, "utf-8");
|
|
5886
|
-
} catch {
|
|
5887
|
-
return null;
|
|
5888
|
-
}
|
|
5889
|
-
const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5890
|
-
const lines = text2.split("\n");
|
|
5891
|
-
lines.forEach((line, index) => {
|
|
5892
|
-
const record = line ? parseRecord(line) : null;
|
|
5893
|
-
if (record) applyRecord(log, record);
|
|
5894
|
-
else if (line && index < lines.length - 1) onUnreadable(index + 1);
|
|
5895
|
-
});
|
|
5896
|
-
return log;
|
|
5897
|
-
}
|
|
5898
|
-
function applyRecord(log, record) {
|
|
5899
|
-
if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
|
|
5900
|
-
else if (record.type === "entry") log.entries.push(record.entry);
|
|
5901
|
-
else if (record.pinned) log.pins.add(record.id);
|
|
5902
|
-
else log.pins.delete(record.id);
|
|
5903
|
-
}
|
|
5904
|
-
function saveRecord(file, log, record) {
|
|
5905
|
-
let fd;
|
|
5906
|
-
try {
|
|
5907
|
-
fd = openSync5(file, constants4.O_WRONLY | constants4.O_APPEND);
|
|
5908
|
-
} catch (error) {
|
|
5909
|
-
if (error.code !== "ENOENT") throw error;
|
|
5910
|
-
return writeLog(file, log);
|
|
5911
|
-
}
|
|
5912
|
-
try {
|
|
5913
|
-
writeSync2(fd, `${JSON.stringify(record)}
|
|
5914
|
-
`);
|
|
5915
|
-
} finally {
|
|
5916
|
-
closeSync5(fd);
|
|
5917
|
-
}
|
|
5918
|
-
}
|
|
5919
|
-
function writeLog(file, log) {
|
|
5920
|
-
const records = [
|
|
5921
|
-
{ type: "baseline", files: Object.fromEntries(log.baseline) },
|
|
5922
|
-
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
5923
|
-
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
5924
|
-
];
|
|
5925
|
-
mkdirSync10(dirname9(file), { recursive: true });
|
|
5926
|
-
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
5927
|
-
`).join(""), 420);
|
|
5928
|
-
}
|
|
5929
|
-
function applyEntry(manifest, entry, side) {
|
|
5930
|
-
for (const file of entry.files) {
|
|
5931
|
-
const hash = file[side];
|
|
5932
|
-
if (hash === null) manifest.delete(file.path);
|
|
5933
|
-
else manifest.set(file.path, hash);
|
|
5934
|
-
}
|
|
5935
|
-
}
|
|
5936
|
-
function manifestAt(log, point) {
|
|
5937
|
-
return point === START ? new Map(log.baseline) : manifestAround(log, point, "after");
|
|
5938
|
-
}
|
|
5939
|
-
function manifestAround(log, id, side) {
|
|
5940
|
-
const manifest = new Map(log.baseline);
|
|
5941
|
-
for (const entry of log.entries) {
|
|
5942
|
-
applyEntry(manifest, entry, entry.id === id ? side : "after");
|
|
5943
|
-
if (entry.id === id) return manifest;
|
|
5944
|
-
}
|
|
5945
|
-
return null;
|
|
5946
|
-
}
|
|
5947
|
-
function undoneIds(entries) {
|
|
5948
|
-
const undone = /* @__PURE__ */ new Set();
|
|
5949
|
-
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
5950
|
-
const entry = entries[i];
|
|
5951
|
-
if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
|
|
5952
|
-
}
|
|
5953
|
-
return undone;
|
|
5954
|
-
}
|
|
5955
|
-
function stepTarget(entries, direction, mine) {
|
|
5956
|
-
const undone = undoneIds(entries);
|
|
5957
|
-
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
5958
|
-
const owns = ownerOf2(entries, byId, mine);
|
|
5959
|
-
const newestFirst = [...entries].reverse();
|
|
5960
|
-
if (direction === "back")
|
|
5961
|
-
return newestFirst.find((entry) => !entry.undoes && !undone.has(entry.id) && owns(entry));
|
|
5962
|
-
for (const entry of newestFirst) {
|
|
5963
|
-
if (!entry.undoes) {
|
|
5964
|
-
if (owns(entry)) return void 0;
|
|
5965
|
-
continue;
|
|
5966
|
-
}
|
|
5967
|
-
const target = byId.get(entry.undoes);
|
|
5968
|
-
if (mine(entry) && !undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5969
|
-
}
|
|
5970
|
-
return void 0;
|
|
5971
|
-
}
|
|
5972
|
-
function ownerOf2(entries, byId, mine) {
|
|
5973
|
-
const redoneBy = /* @__PURE__ */ new Map();
|
|
5974
|
-
for (const entry of entries) {
|
|
5975
|
-
const target = entry.undoes ? byId.get(entry.undoes) : void 0;
|
|
5976
|
-
if (target?.undoes) redoneBy.set(target.undoes, entry);
|
|
5977
|
-
}
|
|
5978
|
-
return (entry) => mine(entry) || mine(redoneBy.get(entry.id) ?? entry);
|
|
5979
|
-
}
|
|
5980
|
-
function foldOldest(log) {
|
|
5981
|
-
const oldest = log.entries[0];
|
|
5982
|
-
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
5983
|
-
applyEntry(log.baseline, oldest, "after");
|
|
5984
|
-
log.entries.shift();
|
|
5985
|
-
return true;
|
|
5986
|
-
}
|
|
5987
|
-
function referencedHashes(log, current) {
|
|
5988
|
-
const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
|
|
5989
|
-
for (const entry of log.entries)
|
|
5990
|
-
for (const file of entry.files) {
|
|
5991
|
-
if (file.before) keep.add(file.before);
|
|
5992
|
-
if (file.after) keep.add(file.after);
|
|
5993
|
-
}
|
|
5994
|
-
return keep;
|
|
5995
|
-
}
|
|
5996
|
-
|
|
5997
|
-
// src/history/projectHistory.ts
|
|
5998
|
-
var DEFAULT_HISTORY_ROOT = join20(homedir2(), ".cache", "hyperframes", "history");
|
|
5999
|
-
var MAX_WINDOW_IDLE_MS = 10 * 6e4;
|
|
6000
|
-
var UNDO_MODES = ["just-this", "back-to-before", "keep-later-edits"];
|
|
6001
|
-
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
6002
|
-
var OUTSIDE_LABEL = "Changed outside the app";
|
|
6003
|
-
var RACY_MS = 2e3;
|
|
6004
|
-
var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
|
|
6005
|
-
var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
|
|
6006
|
-
function splitAt(change, at, kept) {
|
|
6007
|
-
if (at === change.after) return [change, null];
|
|
6008
|
-
if (!at || at === change.before || !kept) return [null, change];
|
|
6009
|
-
return [
|
|
6010
|
-
{ ...change, after: at },
|
|
6011
|
-
{ ...change, before: at }
|
|
6012
|
-
];
|
|
6013
|
-
}
|
|
6014
|
-
var isMissingFolder = (error) => ["ENOENT", "ENOTDIR"].includes(error.code ?? "");
|
|
6015
|
-
async function removeEmptyFolders(dir) {
|
|
6016
|
-
let entries;
|
|
6017
|
-
try {
|
|
6018
|
-
entries = await readdir3(dir, { withFileTypes: true });
|
|
6019
|
-
} catch (error) {
|
|
6020
|
-
if (isMissingFolder(error)) return;
|
|
6021
|
-
throw error;
|
|
6022
|
-
}
|
|
6023
|
-
for (const entry of entries)
|
|
6024
|
-
if (entry.isDirectory()) await removeEmptyFolders(join20(dir, entry.name));
|
|
6025
|
-
await rmdir(dir);
|
|
6026
|
-
}
|
|
6027
|
-
function fileLeftIn(dir, at, deleted) {
|
|
6028
|
-
for (const entry of readdirSync7(dir, { withFileTypes: true })) {
|
|
6029
|
-
const path = `${at}/${entry.name}`;
|
|
6030
|
-
const left = entry.isDirectory() ? fileLeftIn(join20(dir, entry.name), path, deleted) : deleted.has(path) ? void 0 : path;
|
|
6031
|
-
if (left) return left;
|
|
6032
|
-
}
|
|
6033
|
-
return void 0;
|
|
6034
|
-
}
|
|
6035
|
-
function ignoresCase(dir) {
|
|
6036
|
-
const self = statSync10(join20(dir, ID_PATH), { throwIfNoEntry: false });
|
|
6037
|
-
const other = statSync10(join20(dir, ID_PATH.toUpperCase()), { throwIfNoEntry: false });
|
|
6038
|
-
return self !== void 0 && other?.ino === self.ino && other.dev === self.dev;
|
|
6039
|
-
}
|
|
6040
|
-
var blocks = (removed, added) => added.startsWith(`${removed}/`) || removed.startsWith(`${added}/`);
|
|
6041
|
-
var takesWrite = (window, at) => window.idleMs === void 0 || at - (window.lastWriteAt ?? at) <= window.idleMs;
|
|
6042
|
-
function addChange(group, path, before, after) {
|
|
6043
|
-
const earlier = group.changes.get(path);
|
|
6044
|
-
const from = earlier ? earlier.before : before;
|
|
6045
|
-
if (from === after) group.changes.delete(path);
|
|
6046
|
-
else group.changes.set(path, { path, before: from, after });
|
|
6047
|
-
}
|
|
6048
|
-
var Engine = class {
|
|
6049
|
-
constructor(options, projectId, blobs) {
|
|
6050
|
-
this.options = options;
|
|
6051
|
-
this.projectId = projectId;
|
|
6052
|
-
this.blobs = blobs;
|
|
6053
|
-
this.dir = resolve8(options.projectDir);
|
|
6054
|
-
this.home = join20(options.historyRoot, projectId);
|
|
6055
|
-
this.foldsCase = ignoresCase(this.dir);
|
|
6056
|
-
const folder = statSync10(this.dir, { throwIfNoEntry: false });
|
|
6057
|
-
if (!folder || readId(this.dir) !== projectId || !isRecordedFolder(this.home, folder))
|
|
6058
|
-
throw this.replaced();
|
|
6059
|
-
this.folder = { dev: folder.dev, ino: folder.ino, birthtimeMs: folder.birthtimeMs };
|
|
6060
|
-
}
|
|
6061
|
-
options;
|
|
6062
|
-
projectId;
|
|
6063
|
-
blobs;
|
|
6064
|
-
dir;
|
|
6065
|
-
foldsCase;
|
|
6066
|
-
folder;
|
|
6067
|
-
home;
|
|
6068
|
-
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
6069
|
-
tracked = /* @__PURE__ */ new Map();
|
|
6070
|
-
windows = [];
|
|
6071
|
-
outside = null;
|
|
6072
|
-
/** A coalescing claim, open until another key, its idle timer, an operation, a window, or another write. */
|
|
6073
|
-
claimed = null;
|
|
6074
|
-
writeToken;
|
|
6075
|
-
quietTimer;
|
|
6076
|
-
maxTimer;
|
|
6077
|
-
notedTimer = null;
|
|
6078
|
-
listeners = /* @__PURE__ */ new Set();
|
|
6079
|
-
tail = Promise.resolve();
|
|
6080
|
-
closed = false;
|
|
6081
|
-
closing;
|
|
6082
|
-
/** A path as the project's disk compares names: folded where `A` and `a` are one name. */
|
|
6083
|
-
nameKey(path) {
|
|
6084
|
-
return this.foldsCase ? path.toLowerCase() : path;
|
|
6085
|
-
}
|
|
6086
|
-
get logFile() {
|
|
6087
|
-
return join20(this.home, "log.jsonl");
|
|
6088
|
-
}
|
|
6089
|
-
now() {
|
|
6090
|
-
return (this.options.now ?? Date.now)();
|
|
6091
|
-
}
|
|
6092
|
-
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
6093
|
-
queue(task) {
|
|
6094
|
-
if (this.closed)
|
|
6095
|
-
return Promise.reject(new HistoryClosedError("This project's history is closed."));
|
|
6096
|
-
const run = this.tail.then(task);
|
|
6097
|
-
this.tail = run.catch(() => void 0);
|
|
6098
|
-
return run;
|
|
6099
|
-
}
|
|
6100
|
-
/** Another project if the folder or its id changed, or the id is gone: a recreated folder may reuse the inode. */
|
|
6101
|
-
whereFolder() {
|
|
6102
|
-
const now = statSync10(this.dir, { throwIfNoEntry: false });
|
|
6103
|
-
if (!now) return "gone";
|
|
6104
|
-
const same = now.dev === this.folder.dev && sameFolder(now, this.folder);
|
|
6105
|
-
return same && readId(this.dir) === this.projectId ? "here" : "replaced";
|
|
6106
|
-
}
|
|
6107
|
-
assertWritable() {
|
|
6108
|
-
const folder = this.whereFolder();
|
|
6109
|
-
if (folder === "gone") throw new Error(`The project folder is gone: ${this.dir}`);
|
|
6110
|
-
if (folder === "replaced") throw this.replaced();
|
|
6111
|
-
}
|
|
6112
|
-
persistLog(record) {
|
|
6113
|
-
if (record) saveRecord(this.logFile, this.log, record);
|
|
6114
|
-
else writeLog(this.logFile, this.log);
|
|
6115
|
-
if (!existsSync11(join20(this.home, "project.json")))
|
|
6116
|
-
recordProject(this.home, this.dir, this.folder);
|
|
6117
|
-
}
|
|
6118
|
-
replaced() {
|
|
6119
|
-
return new HistoryClosedError(`${this.dir} is now another project.`);
|
|
6120
|
-
}
|
|
6121
|
-
assertOpen() {
|
|
6122
|
-
if (this.closed) throw new HistoryClosedError("This project's history is closed.");
|
|
6123
|
-
if (this.whereFolder() === "replaced") throw this.replaced();
|
|
6124
|
-
}
|
|
6125
|
-
async start() {
|
|
6126
|
-
const log = readLog(
|
|
6127
|
-
this.logFile,
|
|
6128
|
-
(line) => this.options.onError?.(
|
|
6129
|
-
new Error(`History log line ${line} could not be read and was skipped.`)
|
|
6130
|
-
)
|
|
6131
|
-
);
|
|
6132
|
-
if (!log) return this.firstOpen();
|
|
6133
|
-
this.log = log;
|
|
6134
|
-
const cache = this.readStatCache();
|
|
6135
|
-
const last = this.log.entries.at(-1)?.id ?? START;
|
|
6136
|
-
for (const [path, hash] of manifestAt(this.log, last) ?? []) {
|
|
6137
|
-
const cached = cache.get(path);
|
|
6138
|
-
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
6139
|
-
}
|
|
6140
|
-
this.reopenClosedWindow();
|
|
6141
|
-
await this.settleAll();
|
|
6142
|
-
}
|
|
6143
|
-
reopenClosedWindow() {
|
|
6144
|
-
const closed = this.options.closedWindow;
|
|
6145
|
-
if (!closed || this.log.entries.some((entry) => entry.id === closed.id)) return;
|
|
6146
|
-
const { id, who, label, startedAt, lastWriteAt, idleMs } = closed;
|
|
6147
|
-
this.windows.push({ id, who, label, startedAt, lastWriteAt, idleMs, changes: /* @__PURE__ */ new Map() });
|
|
6148
|
-
}
|
|
6149
|
-
async firstOpen() {
|
|
6150
|
-
const sweptAt = Date.now();
|
|
6151
|
-
for (const file of listProjectFiles(this.dir)) {
|
|
6152
|
-
const hash = await this.storeIfPresent(file.path);
|
|
6153
|
-
if (this.whereFolder() !== "here") throw this.replaced();
|
|
6154
|
-
if (hash !== null) this.tracked.set(file.path, { hash, stat: statKey(file, sweptAt) });
|
|
6155
|
-
}
|
|
6156
|
-
this.log.baseline = this.manifest();
|
|
6157
|
-
this.persistLog();
|
|
6158
|
-
this.saveStatCache();
|
|
6159
|
-
}
|
|
6160
|
-
manifest() {
|
|
6161
|
-
return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
|
|
6162
|
-
}
|
|
6163
|
-
readStatCache() {
|
|
6164
|
-
try {
|
|
6165
|
-
const files = JSON.parse(readFileSync19(join20(this.home, "stat.json"), "utf-8"));
|
|
6166
|
-
return new Map(Object.entries(files));
|
|
6167
|
-
} catch {
|
|
6168
|
-
return /* @__PURE__ */ new Map();
|
|
6169
|
-
}
|
|
6170
|
-
}
|
|
6171
|
-
saveStatCache() {
|
|
6172
|
-
const files = JSON.stringify(Object.fromEntries(this.tracked));
|
|
6173
|
-
mkdirSync11(this.home, { recursive: true });
|
|
6174
|
-
replaceFileAtomically(join20(this.home, "stat.json"), files, 420);
|
|
6175
|
-
}
|
|
6176
|
-
/**
|
|
6177
|
-
* Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
|
|
6178
|
-
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
6179
|
-
*/
|
|
6180
|
-
async sweep() {
|
|
6181
|
-
if (this.whereFolder() !== "here") return;
|
|
6182
|
-
const sweptAt = Date.now();
|
|
6183
|
-
const changedAt = (file) => Math.min(sweptAt, Math.max(file.mtimeMs, file.ctimeMs));
|
|
6184
|
-
const seen = listProjectFiles(this.dir);
|
|
6185
|
-
const present = new Set(seen.map((file) => file.path));
|
|
6186
|
-
const removed = [...this.tracked.keys()].filter((path) => !present.has(path)).map((path) => {
|
|
6187
|
-
const standing = this.standingAt(path);
|
|
6188
|
-
return { at: standing ? changedAt(standing.stat) : sweptAt, path, file: null };
|
|
6189
|
-
});
|
|
6190
|
-
const appearedAt = (file) => Math.max(
|
|
6191
|
-
changedAt(file),
|
|
6192
|
-
...removed.filter((gone) => blocks(this.nameKey(gone.path), this.nameKey(file.path))).map((gone) => gone.at)
|
|
6193
|
-
);
|
|
6194
|
-
const events = [
|
|
6195
|
-
...removed,
|
|
6196
|
-
...seen.map((file) => ({ at: appearedAt(file), path: file.path, file }))
|
|
6197
|
-
].sort((a, b) => a.at - b.at);
|
|
6198
|
-
let changed = false;
|
|
6199
|
-
for (const { at, path, file } of events) {
|
|
6200
|
-
if (file) {
|
|
6201
|
-
changed = await this.observe(path, statKey(file, sweptAt), at) || changed;
|
|
6202
|
-
continue;
|
|
6203
|
-
}
|
|
6204
|
-
const known = this.tracked.get(path);
|
|
6205
|
-
this.tracked.delete(path);
|
|
6206
|
-
await this.record(path, known.hash, null, at);
|
|
6207
|
-
changed = true;
|
|
6208
|
-
}
|
|
6209
|
-
if (changed) this.saveStatCache();
|
|
6210
|
-
}
|
|
6211
|
-
/** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
|
|
6212
|
-
async storeIfPresent(path) {
|
|
6213
|
-
try {
|
|
6214
|
-
return await this.blobs.put(join20(this.dir, path));
|
|
6215
|
-
} catch (error) {
|
|
6216
|
-
if (error.code === "ENOENT") return null;
|
|
6217
|
-
throw error;
|
|
6218
|
-
}
|
|
6219
|
-
}
|
|
6220
|
-
async observe(path, stat3, at) {
|
|
6221
|
-
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
6222
|
-
if (stat3 && known.stat === stat3) return false;
|
|
6223
|
-
const hash = await this.storeIfPresent(path);
|
|
6224
|
-
if (hash === null || this.whereFolder() !== "here") return false;
|
|
6225
|
-
this.tracked.set(path, { hash, stat: stat3 });
|
|
6226
|
-
if (known.hash !== hash) await this.record(path, known.hash, hash, at);
|
|
6227
|
-
return known.hash !== hash || known.stat !== stat3;
|
|
6228
|
-
}
|
|
6229
|
-
async record(path, before, after, at) {
|
|
6230
|
-
const endedBeforeThisWrite = this.windows.filter((open2) => !takesWrite(open2, at));
|
|
6231
|
-
if (endedBeforeThisWrite.length) await this.commitOutside();
|
|
6232
|
-
for (const window2 of endedBeforeThisWrite) await this.endWindow(window2);
|
|
6233
|
-
const window = this.windows.at(-1);
|
|
6234
|
-
if (window) this.touch(window, at);
|
|
6235
|
-
addChange(window ?? this.outsideGroup(), path, before, after);
|
|
6236
|
-
}
|
|
6237
|
-
/** ponytail: another's write between Studio's write and its sweep folds into Studio's entry (per-write tokens). */
|
|
6238
|
-
async claimNow(who, label, paths, { coalesceKey, idleMs, overwrote = {} }) {
|
|
6239
|
-
await this.sweep();
|
|
6240
|
-
const taken = await this.takeClaimed(who, paths, overwrote);
|
|
6241
|
-
if (!taken.length) return this.claimNothing(coalesceKey);
|
|
6242
|
-
const held = this.heldFor(coalesceKey, taken);
|
|
6243
|
-
if (!held) await this.commitClaim();
|
|
6244
|
-
await this.commitPending();
|
|
6245
|
-
const group = held ?? this.newGroup(who, label);
|
|
6246
|
-
for (const change of taken) addChange(group, change.path, change.before, change.after);
|
|
6247
|
-
if (coalesceKey) return this.holdClaim(group, coalesceKey, idleMs);
|
|
6248
|
-
const entry = await this.commit(group);
|
|
6249
|
-
return entry && { id: entry.id };
|
|
6250
|
-
}
|
|
6251
|
-
/** A claim that took nothing still ends a held claim under another key. */
|
|
6252
|
-
async claimNothing(coalesceKey) {
|
|
6253
|
-
if (coalesceKey !== this.claimed?.key) await this.commitClaim();
|
|
6254
|
-
return null;
|
|
6255
|
-
}
|
|
6256
|
-
/** Commits every other writer's pending changes, oldest first; open windows stay open. */
|
|
6257
|
-
async commitPending() {
|
|
6258
|
-
await this.commitOutside();
|
|
6259
|
-
for (const window of [...this.windows])
|
|
6260
|
-
if (window.changes.size) await this.commitCut(window, [...window.changes.keys()]);
|
|
6261
|
-
}
|
|
6262
|
-
pendingElsewhere() {
|
|
6263
|
-
return Boolean(this.outside?.changes.size) || this.windows.some((open2) => open2.changes.size > 0);
|
|
6264
|
-
}
|
|
6265
|
-
/** The held claim `taken` continues: same key, each file from its own last change, no held file changed since. */
|
|
6266
|
-
heldFor(key, taken) {
|
|
6267
|
-
const group = key && this.claimed?.key === key && !this.pendingElsewhere() ? this.claimed.group : null;
|
|
6268
|
-
const after = (path, before) => {
|
|
6269
|
-
const own = group?.changes.get(path);
|
|
6270
|
-
return own ? own.after : before;
|
|
6271
|
-
};
|
|
6272
|
-
const takenPaths = new Set(taken.map((change) => change.path));
|
|
6273
|
-
const untouched = (change) => takenPaths.has(change.path) || (this.tracked.get(change.path)?.hash ?? null) === change.after;
|
|
6274
|
-
return group && taken.every((change) => after(change.path, change.before) === change.before) && [...group.changes.values()].every(untouched) ? group : null;
|
|
6275
|
-
}
|
|
6276
|
-
logPath(path) {
|
|
6277
|
-
return relative5(this.dir, resolve8(this.dir, path)).split(sep3).join("/");
|
|
6278
|
-
}
|
|
6279
|
-
/** Takes `paths`' uncommitted changes from others, cut at what `who` overwrote. */
|
|
6280
|
-
async takeClaimed(who, paths, overwrote) {
|
|
6281
|
-
const wanted = new Set(paths.map((path) => this.logPath(path)));
|
|
6282
|
-
const at = new Map(
|
|
6283
|
-
Object.entries(overwrote).map(([path, version]) => [
|
|
6284
|
-
this.logPath(path),
|
|
6285
|
-
hashOfVersion(version)
|
|
6286
|
-
])
|
|
6287
|
-
);
|
|
6288
|
-
const others = this.windows.filter((open2) => !sameWho(open2.who, who));
|
|
6289
|
-
const groups = this.outside ? [this.outside, ...others] : others;
|
|
6290
|
-
const hits = groups.flatMap(
|
|
6291
|
-
(group) => [...group.changes.values()].filter((change) => wanted.has(change.path)).map((change) => ({ group, change }))
|
|
6292
|
-
);
|
|
6293
|
-
const cuts = [];
|
|
6294
|
-
const walked = hits.map(() => /* @__PURE__ */ new Set());
|
|
6295
|
-
for (const [i, { change }] of hits.entries()) {
|
|
6296
|
-
const told = at.get(change.path);
|
|
6297
|
-
cuts.push(await this.overwrittenBy(change, told, walked[i]) ?? told);
|
|
6298
|
-
}
|
|
6299
|
-
hits.forEach(
|
|
6300
|
-
({ change }, i) => forgetOverwrittenBytes(
|
|
6301
|
-
join20(this.dir, change.path),
|
|
6302
|
-
new Set([...walked[i]].map(hashVersion))
|
|
6303
|
-
)
|
|
6304
|
-
);
|
|
6305
|
-
return hits.flatMap(({ group, change }, i) => this.cutOut(group, change, cuts[i]) ?? []);
|
|
6306
|
-
}
|
|
6307
|
-
/** Walks the server's writes back from `change.after` to the bytes they replaced, stored so an undo restores them. */
|
|
6308
|
-
async overwrittenBy(change, told, seen) {
|
|
6309
|
-
const absPath = join20(this.dir, change.path);
|
|
6310
|
-
let bytes;
|
|
6311
|
-
for (let hash = change.after; hash; ) {
|
|
6312
|
-
if (seen.has(hash)) return void 0;
|
|
6313
|
-
seen.add(hash);
|
|
6314
|
-
const replaced = bytesOverwrittenBy(absPath, hashVersion(hash));
|
|
6315
|
-
if (replaced === void 0) break;
|
|
6316
|
-
bytes = replaced;
|
|
6317
|
-
hash = hashOfVersion(fileContentVersion(replaced));
|
|
6318
|
-
if (hash === change.before || hash === told) return hash;
|
|
6319
|
-
}
|
|
6320
|
-
if (bytes === void 0) return void 0;
|
|
6321
|
-
const staged = join20(this.home, `overwrote-${randomUUID9()}`);
|
|
6322
|
-
await writeFile(staged, bytes);
|
|
6323
|
-
try {
|
|
6324
|
-
return await this.blobs.put(staged);
|
|
6325
|
-
} finally {
|
|
6326
|
-
await rm3(staged, { force: true });
|
|
6327
|
-
}
|
|
6328
|
-
}
|
|
6329
|
-
cutOut(group, change, cut) {
|
|
6330
|
-
const [kept, claimed] = splitAt(change, cut, cut !== void 0 && this.blobs.has(cut));
|
|
6331
|
-
group.changes.delete(change.path);
|
|
6332
|
-
if (kept) group.changes.set(kept.path, kept);
|
|
6333
|
-
return claimed;
|
|
6334
|
-
}
|
|
6335
|
-
/** Commits the window's cut `paths` as their own entry, other files staying: a window holds one change per file,
|
|
6336
|
-
* so the writer's next write to a cut file would otherwise span the claimer's edit. */
|
|
6337
|
-
async commitCut(window, paths) {
|
|
6338
|
-
const part = { ...this.newGroup(window.who, window.label), startedAt: window.startedAt };
|
|
6339
|
-
for (const path of paths) {
|
|
6340
|
-
part.changes.set(path, window.changes.get(path));
|
|
6341
|
-
window.changes.delete(path);
|
|
6342
|
-
}
|
|
6343
|
-
(window.parts ??= /* @__PURE__ */ new Set()).add(part.id);
|
|
6344
|
-
window.entry = await this.commit(part);
|
|
6345
|
-
}
|
|
6346
|
-
holdClaim(group, key, idleMs = this.options.quietMs ?? 2e3) {
|
|
6347
|
-
clearTimeout(this.claimed?.timer);
|
|
6348
|
-
const timer = Number.isFinite(idleMs) ? setTimeout(() => this.background(() => this.commitClaim()), idleMs) : void 0;
|
|
6349
|
-
timer?.unref?.();
|
|
6350
|
-
this.claimed = { group, key, timer };
|
|
6351
|
-
return group.changes.size ? { id: group.id } : null;
|
|
6352
|
-
}
|
|
6353
|
-
async commitClaim() {
|
|
6354
|
-
const held = this.claimed;
|
|
6355
|
-
this.claimed = null;
|
|
6356
|
-
if (!held) return;
|
|
6357
|
-
clearTimeout(held.timer);
|
|
6358
|
-
await this.commit(held.group);
|
|
6359
|
-
}
|
|
6360
|
-
outsideGroup() {
|
|
6361
|
-
const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
|
|
6362
|
-
const closeIn = (ms) => {
|
|
6363
|
-
const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
|
|
6364
|
-
timer.unref?.();
|
|
6365
|
-
return timer;
|
|
6366
|
-
};
|
|
6367
|
-
if (!this.outside) {
|
|
6368
|
-
this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
|
|
6369
|
-
this.maxTimer = closeIn(maxGroupMs);
|
|
6370
|
-
}
|
|
6371
|
-
clearTimeout(this.quietTimer);
|
|
6372
|
-
this.quietTimer = closeIn(quietMs);
|
|
6373
|
-
return this.outside;
|
|
6374
|
-
}
|
|
6375
|
-
newGroup(who, label) {
|
|
6376
|
-
return { id: randomUUID9(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
6377
|
-
}
|
|
6378
|
-
async commitOutside() {
|
|
6379
|
-
if (this.outside?.changes.size) await this.commitClaim();
|
|
6380
|
-
clearTimeout(this.quietTimer);
|
|
6381
|
-
clearTimeout(this.maxTimer);
|
|
6382
|
-
const group = this.outside;
|
|
6383
|
-
this.outside = null;
|
|
6384
|
-
if (group) await this.commit(group);
|
|
6385
|
-
}
|
|
6386
|
-
async commit(group, extra = {}) {
|
|
6387
|
-
if (!group.changes.size) return null;
|
|
6388
|
-
const pending = this.pendingEntry(group);
|
|
6389
|
-
const endedAt = Math.max(pending.endedAt, this.log.entries.at(-1)?.endedAt ?? 0);
|
|
6390
|
-
const entry = { ...pending, endedAt, ...extra };
|
|
6391
|
-
this.log.entries.push(entry);
|
|
6392
|
-
try {
|
|
6393
|
-
this.persistLog({ type: "entry", entry });
|
|
6394
|
-
} catch (error) {
|
|
6395
|
-
this.log.entries.pop();
|
|
6396
|
-
throw error;
|
|
6397
|
-
}
|
|
6398
|
-
for (const listener of this.listeners) listener(entry);
|
|
6399
|
-
await this.keepWithinBudget();
|
|
6400
|
-
return entry;
|
|
6401
|
-
}
|
|
6402
|
-
/** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
|
|
6403
|
-
historyBytes() {
|
|
6404
|
-
let current = 0;
|
|
6405
|
-
for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
|
|
6406
|
-
current += this.blobs.size(hash);
|
|
6407
|
-
return this.blobs.bytes() - current;
|
|
6408
|
-
}
|
|
6409
|
-
async keepWithinBudget() {
|
|
6410
|
-
const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
|
|
6411
|
-
let folded = false;
|
|
6412
|
-
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
6413
|
-
folded = true;
|
|
6414
|
-
await this.blobs.prune(
|
|
6415
|
-
/* @__PURE__ */ new Set([...referencedHashes(this.log, this.manifest()), ...this.pendingHashes()])
|
|
6416
|
-
);
|
|
6417
|
-
}
|
|
6418
|
-
if (folded) this.persistLog();
|
|
6419
|
-
}
|
|
6420
|
-
/** Hashes only pending changes point at yet, such as a claim's stored cut. */
|
|
6421
|
-
pendingHashes() {
|
|
6422
|
-
const changes = [...this.windows, this.outside, this.claimed?.group].flatMap((group) => [
|
|
6423
|
-
...group?.changes.values() ?? []
|
|
6424
|
-
]);
|
|
6425
|
-
return changes.flatMap((change) => [change.before, change.after]).filter((hash) => hash !== null);
|
|
6426
|
-
}
|
|
6427
|
-
/** Before an operation or a window: every write is filed and every pending change committed, in write order. */
|
|
6428
|
-
async settle() {
|
|
6429
|
-
await this.sweep();
|
|
6430
|
-
await this.commitClaim();
|
|
6431
|
-
await this.commitPending();
|
|
6432
|
-
}
|
|
6433
|
-
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
6434
|
-
noteChange(path) {
|
|
6435
|
-
if (this.notedTimer || !affectsProjectSignature(this.dir, resolve8(this.dir, path))) return;
|
|
6436
|
-
this.notedTimer = setTimeout(() => {
|
|
6437
|
-
this.notedTimer = null;
|
|
6438
|
-
this.background(() => this.sweep());
|
|
6439
|
-
}, 20);
|
|
6440
|
-
this.notedTimer.unref?.();
|
|
6441
|
-
}
|
|
6442
|
-
background(task) {
|
|
6443
|
-
this.queue(task).catch((error) => {
|
|
6444
|
-
if (!(error instanceof HistoryClosedError)) this.options.onError?.(error);
|
|
6445
|
-
});
|
|
6446
|
-
}
|
|
6447
|
-
beginWindow(who, label, idleMs) {
|
|
6448
|
-
return this.queue(async () => {
|
|
6449
|
-
await this.settle();
|
|
6450
|
-
const window = { ...this.newGroup(who, label), idleMs };
|
|
6451
|
-
this.windows.push(window);
|
|
6452
|
-
this.touch(window, Date.now());
|
|
6453
|
-
const close = () => this.queue(() => this.sweepAndEnd(window));
|
|
6454
|
-
return { id: window.id, startedAt: window.startedAt, close };
|
|
6455
|
-
});
|
|
6456
|
-
}
|
|
6457
|
-
/** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
|
|
6458
|
-
touch(window, at) {
|
|
6459
|
-
window.lastWriteAt = Math.max(window.lastWriteAt ?? at, at);
|
|
6460
|
-
clearTimeout(window.idleTimer);
|
|
6461
|
-
if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
|
|
6462
|
-
window.idleTimer = setTimeout(
|
|
6463
|
-
() => this.background(() => this.sweepAndEnd(window)),
|
|
6464
|
-
window.idleMs
|
|
6465
|
-
);
|
|
6466
|
-
window.idleTimer.unref?.();
|
|
6467
|
-
}
|
|
6468
|
-
async sweepAndEnd(window) {
|
|
6469
|
-
await this.sweep();
|
|
6470
|
-
return this.endWindow(window);
|
|
6471
|
-
}
|
|
6472
|
-
/** Commits an open window once; ending it again returns the entry it became. */
|
|
6473
|
-
async endWindow(window) {
|
|
6474
|
-
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
6475
|
-
clearTimeout(window.idleTimer);
|
|
6476
|
-
await this.commitClaim();
|
|
6477
|
-
this.windows = this.windows.filter((open2) => open2 !== window);
|
|
6478
|
-
window.entry = await this.commit(window) ?? window.entry ?? null;
|
|
6479
|
-
return window.entry;
|
|
6480
|
-
}
|
|
6481
|
-
/** Every pending write, open window and outside group, committed: for flush and close. */
|
|
6482
|
-
async settleAll() {
|
|
6483
|
-
await this.sweep();
|
|
6484
|
-
await this.commitClaim();
|
|
6485
|
-
for (const window of [...this.windows]) await this.endWindow(window);
|
|
6486
|
-
await this.commitOutside();
|
|
6487
|
-
}
|
|
6488
|
-
/** What took a removed path's place, dating its removal: an entry at the path, or a file at a folder above it. */
|
|
6489
|
-
standingAt(path) {
|
|
6490
|
-
const parts = path.split("/");
|
|
6491
|
-
for (let depth = 1; depth <= parts.length; depth += 1) {
|
|
6492
|
-
const at = parts.slice(0, depth).join("/");
|
|
6493
|
-
const stat3 = lstatSync4(join20(this.dir, at), { throwIfNoEntry: false });
|
|
6494
|
-
if (!stat3) return void 0;
|
|
6495
|
-
if (at === path || !stat3.isDirectory()) return { at, stat: stat3 };
|
|
6496
|
-
}
|
|
6497
|
-
return void 0;
|
|
6498
|
-
}
|
|
6499
|
-
/** 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. */
|
|
6500
|
-
inTheWay(path, deleted) {
|
|
6501
|
-
const standing = this.standingAt(path);
|
|
6502
|
-
if (!standing) return void 0;
|
|
6503
|
-
if (standing.stat.isSymbolicLink()) return standing.at;
|
|
6504
|
-
if (standing.at === path && !standing.stat.isDirectory()) return void 0;
|
|
6505
|
-
const real = realpathSync.native(join20(this.dir, standing.at));
|
|
6506
|
-
const at = relative5(realpathSync.native(this.dir), real).split(sep3).join("/");
|
|
6507
|
-
if (standing.at !== path) return deleted.has(at) ? void 0 : at;
|
|
6508
|
-
return fileLeftIn(real, at, deleted);
|
|
6509
|
-
}
|
|
6510
|
-
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
6511
|
-
async writeAs(who, label, target, extra) {
|
|
6512
|
-
this.assertWritable();
|
|
6513
|
-
const changes = [...target].filter(([path, hash]) => (this.tracked.get(path)?.hash ?? null) !== hash).sort(([, a], [, b]) => Number(a !== null) - Number(b !== null));
|
|
6514
|
-
const deleted = new Set(changes.filter(([, hash]) => hash === null).map(([path]) => path));
|
|
6515
|
-
const blocked = changes.filter(([, hash]) => hash !== null).map(([path]) => this.inTheWay(path, deleted)).find(Boolean);
|
|
6516
|
-
if (blocked) throw new Error(`${blocked} is in the way; move or delete it, then try again.`);
|
|
6517
|
-
const group = this.newGroup(who, label);
|
|
6518
|
-
this.windows.push(group);
|
|
6519
|
-
try {
|
|
6520
|
-
for (const [path, hash] of changes) await this.writeProjectFile(path, hash);
|
|
6521
|
-
await this.sweep();
|
|
6522
|
-
} finally {
|
|
6523
|
-
this.windows = this.windows.filter((open2) => open2 !== group);
|
|
6524
|
-
}
|
|
6525
|
-
return this.commit(group, extra);
|
|
6526
|
-
}
|
|
6527
|
-
/** Writes one project file (null deletes it), first leaving the running operation's receipt for its echo. */
|
|
6528
|
-
async writeProjectFile(path, hash) {
|
|
6529
|
-
this.assertWritable();
|
|
6530
|
-
const absPath = join20(this.dir, path);
|
|
6531
|
-
const { writeToken } = this;
|
|
6532
|
-
if (writeToken) {
|
|
6533
|
-
const version = hash === null ? DELETED_VERSION : hashVersion(hash);
|
|
6534
|
-
recordFileWriteReceipt(absPath, { path, version, writeToken });
|
|
6535
|
-
}
|
|
6536
|
-
if (hash === null) {
|
|
6537
|
-
this.assertWritable();
|
|
6538
|
-
rmSync6(absPath, { force: true });
|
|
6539
|
-
} else {
|
|
6540
|
-
await removeEmptyFolders(absPath);
|
|
6541
|
-
await this.blobs.writeTo(hash, absPath, () => this.assertWritable());
|
|
6542
|
-
}
|
|
6543
|
-
}
|
|
6544
|
-
entry(id) {
|
|
6545
|
-
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
6546
|
-
if (!entry) throw new Error("That change is no longer kept in this project's history.");
|
|
6547
|
-
return entry;
|
|
6548
|
-
}
|
|
6549
|
-
undoLabel(entry) {
|
|
6550
|
-
if (!entry.undoes) return `Undid: ${entry.label}`;
|
|
6551
|
-
const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
|
|
6552
|
-
return `Redid: ${original?.label ?? entry.label}`;
|
|
6553
|
-
}
|
|
6554
|
-
async undoNow(id, who, mode) {
|
|
6555
|
-
const entry = this.entry(id);
|
|
6556
|
-
const changed = this.movedOn(entry);
|
|
6557
|
-
if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
|
|
6558
|
-
if (mode === "back-to-before") {
|
|
6559
|
-
const index = this.log.entries.indexOf(entry);
|
|
6560
|
-
const point = index > 0 ? this.log.entries[index - 1].id : START;
|
|
6561
|
-
return {
|
|
6562
|
-
ok: true,
|
|
6563
|
-
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
6564
|
-
};
|
|
6565
|
-
}
|
|
6566
|
-
const target = new Map(
|
|
6567
|
-
entry.files.filter((file) => mode !== "keep-later-edits" || !changed.includes(file)).map((file) => [file.path, file.before])
|
|
6568
|
-
);
|
|
6569
|
-
return {
|
|
6570
|
-
ok: true,
|
|
6571
|
-
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
6572
|
-
};
|
|
6573
|
-
}
|
|
6574
|
-
movedOn(entry) {
|
|
6575
|
-
return entry.files.filter((file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after);
|
|
6576
|
-
}
|
|
6577
|
-
next(direction, who) {
|
|
6578
|
-
const mine = (author) => sameWho(author, who) || sameWho(author, OUTSIDE);
|
|
6579
|
-
const pending = [...[...this.windows].reverse(), this.outside, this.claimed?.group].find(
|
|
6580
|
-
(group) => group?.changes.size && mine(group.who)
|
|
6581
|
-
);
|
|
6582
|
-
if (pending) return direction === "back" ? this.pendingEntry(pending) : void 0;
|
|
6583
|
-
const everyone = this.options.undoScope === "everyone";
|
|
6584
|
-
const ofOpenTurn = (entry) => this.windows.some((open2) => open2.parts?.has(entry.id));
|
|
6585
|
-
return stepTarget(
|
|
6586
|
-
this.log.entries,
|
|
6587
|
-
direction,
|
|
6588
|
-
(entry) => mine(entry.who) || everyone && !ofOpenTurn(entry)
|
|
6589
|
-
);
|
|
6590
|
-
}
|
|
6591
|
-
/** A pending group as the entry it becomes once committed (a window's part gets a fresh id). */
|
|
6592
|
-
pendingEntry(group) {
|
|
6593
|
-
const files = [...group.changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
6594
|
-
const { id, who, label, startedAt, lastWriteAt } = group;
|
|
6595
|
-
return { id, who, label, startedAt, endedAt: lastWriteAt ?? this.now(), files };
|
|
6596
|
-
}
|
|
6597
|
-
conflict(entry, changed) {
|
|
6598
|
-
const paths = new Set(changed.map((file) => file.path));
|
|
6599
|
-
const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
|
|
6600
|
-
const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
|
|
6601
|
-
return { files: [...paths], newer: newer.map((later) => later.id) };
|
|
6602
|
-
}
|
|
6603
|
-
async restoreNow(point, who, label) {
|
|
6604
|
-
const files = manifestAt(this.log, point);
|
|
6605
|
-
if (!files) throw new Error("That point is no longer kept in this project's history.");
|
|
6606
|
-
const target = new Map(
|
|
6607
|
-
[...this.tracked.keys()].map((path) => [path, null])
|
|
6608
|
-
);
|
|
6609
|
-
for (const [path, hash] of files) target.set(path, hash);
|
|
6610
|
-
return this.writeAs(who, label, target, { restoredTo: point });
|
|
6611
|
-
}
|
|
6612
|
-
/** Settles, then runs `task` with its writes labelled `writeToken`. */
|
|
6613
|
-
operation(writeToken, task) {
|
|
6614
|
-
return this.queue(async () => {
|
|
6615
|
-
await this.settle();
|
|
6616
|
-
this.writeToken = writeToken;
|
|
6617
|
-
try {
|
|
6618
|
-
return await task();
|
|
6619
|
-
} finally {
|
|
6620
|
-
this.writeToken = void 0;
|
|
6621
|
-
}
|
|
6622
|
-
});
|
|
6623
|
-
}
|
|
6624
|
-
pointLabel(point) {
|
|
6625
|
-
return point === START ? "the start" : this.entry(point).label;
|
|
6626
|
-
}
|
|
6627
|
-
api() {
|
|
6628
|
-
return {
|
|
6629
|
-
projectId: this.projectId,
|
|
6630
|
-
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
6631
|
-
claim: (who, label, paths, options = {}) => this.queue(() => this.claimNow(who, label, paths, options)),
|
|
6632
|
-
noteChange: (path) => {
|
|
6633
|
-
if (!this.closed) this.noteChange(path);
|
|
6634
|
-
},
|
|
6635
|
-
list: () => {
|
|
6636
|
-
this.assertOpen();
|
|
6637
|
-
const undone = undoneIds(this.log.entries);
|
|
6638
|
-
return this.log.entries.map((entry) => ({
|
|
6639
|
-
...entry,
|
|
6640
|
-
pinned: this.log.pins.has(entry.id),
|
|
6641
|
-
undone: undone.has(entry.id)
|
|
6642
|
-
}));
|
|
6643
|
-
},
|
|
6644
|
-
step: (direction, who, { writeToken } = {}) => this.operation(writeToken, async () => {
|
|
6645
|
-
const target = this.next(direction, who);
|
|
6646
|
-
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
6647
|
-
}),
|
|
6648
|
-
undo: (id, { who, mode, writeToken }) => this.operation(writeToken, () => this.undoNow(id, who, mode)),
|
|
6649
|
-
restore: (point, who, { writeToken } = {}) => this.operation(
|
|
6650
|
-
writeToken,
|
|
6651
|
-
() => this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`)
|
|
6652
|
-
),
|
|
6653
|
-
peek: (point) => {
|
|
6654
|
-
this.assertOpen();
|
|
6655
|
-
const files = manifestAt(this.log, point);
|
|
6656
|
-
return files && Object.fromEntries(files);
|
|
6657
|
-
},
|
|
6658
|
-
checkout: (entryId, side, emptyDir) => this.queue(async () => {
|
|
6659
|
-
this.entry(entryId);
|
|
6660
|
-
if (!existsSync11(this.dir)) throw new Error(`The project folder is gone: ${this.dir}`);
|
|
6661
|
-
const dest = resolve8(emptyDir);
|
|
6662
|
-
if (isWithin(this.dir, dest))
|
|
6663
|
-
throw new Error(`Checkout writes outside the project only: ${dest}`);
|
|
6664
|
-
if ((await readdir3(dest).catch(missingIsEmpty)).length > 0)
|
|
6665
|
-
throw new Error(`Checkout writes into an empty folder only: ${dest}`);
|
|
6666
|
-
const files = manifestAround(this.log, entryId, side);
|
|
6667
|
-
try {
|
|
6668
|
-
for (const [path, hash] of files) await this.blobs.writeTo(hash, join20(dest, path));
|
|
6669
|
-
} catch (error) {
|
|
6670
|
-
for (const name of await readdir3(dest).catch(() => []))
|
|
6671
|
-
await rm3(join20(dest, name), { recursive: true, force: true });
|
|
6672
|
-
throw error;
|
|
6673
|
-
}
|
|
6674
|
-
}),
|
|
6675
|
-
next: (direction, who) => {
|
|
6676
|
-
this.assertOpen();
|
|
6677
|
-
return this.next(direction, who);
|
|
6678
|
-
},
|
|
6679
|
-
readBlob: async (hash) => {
|
|
6680
|
-
this.assertOpen();
|
|
6681
|
-
return this.blobs.read(hash);
|
|
6682
|
-
},
|
|
6683
|
-
pin: (id, pinned) => {
|
|
6684
|
-
this.assertOpen();
|
|
6685
|
-
this.entry(id);
|
|
6686
|
-
if (pinned) this.log.pins.add(id);
|
|
6687
|
-
else this.log.pins.delete(id);
|
|
6688
|
-
this.persistLog({ type: "pin", id, pinned });
|
|
6689
|
-
},
|
|
6690
|
-
onEntry: (listener) => {
|
|
6691
|
-
this.listeners.add(listener);
|
|
6692
|
-
return () => this.listeners.delete(listener);
|
|
6693
|
-
},
|
|
6694
|
-
flush: () => this.queue(() => this.settleAll()),
|
|
6695
|
-
replacedAtPath: () => this.whereFolder() === "replaced",
|
|
6696
|
-
close: () => this.closing ??= (async () => {
|
|
6697
|
-
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
6698
|
-
const settled = this.queue(() => this.settleAll());
|
|
6699
|
-
this.closed = true;
|
|
6700
|
-
await settled;
|
|
6701
|
-
})()
|
|
6702
|
-
};
|
|
6703
|
-
}
|
|
6704
|
-
};
|
|
6705
|
-
function isWithin(dir, path) {
|
|
6706
|
-
let probe = resolve8(path);
|
|
6707
|
-
while (!existsSync11(probe) && dirname10(probe) !== probe) probe = dirname10(probe);
|
|
6708
|
-
const rel = relative5(realpathSync.native(dir), realpathSync.native(probe));
|
|
6709
|
-
return rel === "" || rel !== ".." && !rel.startsWith(`..${sep3}`) && !isAbsolute2(rel);
|
|
6710
|
-
}
|
|
6711
|
-
function missingIsEmpty(error) {
|
|
6712
|
-
if (error.code === "ENOENT") return [];
|
|
6713
|
-
throw error;
|
|
6714
|
-
}
|
|
6715
|
-
var HistoryClosedError = class extends Error {
|
|
6716
|
-
constructor(message) {
|
|
6717
|
-
super(message);
|
|
6718
|
-
this.name = "HistoryClosedError";
|
|
6719
|
-
}
|
|
6720
|
-
};
|
|
6721
|
-
async function openProjectHistory(options) {
|
|
6722
|
-
if (!existsSync11(options.projectDir))
|
|
6723
|
-
throw new HistoryClosedError(`${options.projectDir} is gone or now another project.`);
|
|
6724
|
-
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
6725
|
-
const release = await takeHistoryOwnership(
|
|
6726
|
-
join20(options.historyRoot, projectId),
|
|
6727
|
-
options.ownerWaitMs ?? 5e3
|
|
6728
|
-
);
|
|
6729
|
-
try {
|
|
6730
|
-
const blobs = await openBlobStore(join20(options.historyRoot, projectId, "blobs"));
|
|
6731
|
-
const engine = new Engine(options, projectId, blobs);
|
|
6732
|
-
await engine.queue(() => engine.start());
|
|
6733
|
-
pruneGoneProjectHistoriesDaily(options.historyRoot, engine.now(), {
|
|
6734
|
-
onError: options.onError,
|
|
6735
|
-
budgetMs: options.pruneGoneProjectsBudgetMs
|
|
6736
|
-
});
|
|
6737
|
-
const api = engine.api();
|
|
6738
|
-
return {
|
|
6739
|
-
...api,
|
|
6740
|
-
close: () => api.close().finally(release)
|
|
6741
|
-
};
|
|
6742
|
-
} catch (error) {
|
|
6743
|
-
release();
|
|
6744
|
-
throw error;
|
|
6745
|
-
}
|
|
6746
|
-
}
|
|
6747
|
-
|
|
6748
5197
|
// src/routes/history.ts
|
|
6749
5198
|
var YOU = { kind: "person", name: "You" };
|
|
6750
5199
|
async function historyOf(adapter, c) {
|
|
@@ -6884,6 +5333,21 @@ function registerHistoryRoutes(api, adapter) {
|
|
|
6884
5333
|
// src/createStudioApi.ts
|
|
6885
5334
|
function createStudioApi(adapter) {
|
|
6886
5335
|
const api = new Hono();
|
|
5336
|
+
api.use(async function answerProjectDirMissingAfterErrorHandlers(c, next) {
|
|
5337
|
+
const hostHeaders = new Headers(c.res.headers);
|
|
5338
|
+
await next();
|
|
5339
|
+
if (isProjectRootMissing(c.error)) replaceWithProjectDirMissing(c, hostHeaders);
|
|
5340
|
+
});
|
|
5341
|
+
api.use("/projects/:id/*", async function answerProjectDirMissingForVanishedFolder(c, next) {
|
|
5342
|
+
const hostHeaders = new Headers(c.res.headers);
|
|
5343
|
+
const dirBeforeRoute = await Promise.resolve().then(() => adapter.resolveProject(c.req.param("id"))).then(
|
|
5344
|
+
(project) => project?.dir,
|
|
5345
|
+
() => void 0
|
|
5346
|
+
);
|
|
5347
|
+
await next();
|
|
5348
|
+
if (c.res.status >= 403 && dirBeforeRoute && folderGone(dirBeforeRoute))
|
|
5349
|
+
replaceWithProjectDirMissing(c, hostHeaders);
|
|
5350
|
+
});
|
|
6887
5351
|
registerProjectRoutes(api, adapter);
|
|
6888
5352
|
registerFileRoutes(api, adapter);
|
|
6889
5353
|
registerPreviewRoutes(api, adapter);
|
|
@@ -6901,33 +5365,9 @@ function createStudioApi(adapter) {
|
|
|
6901
5365
|
return api;
|
|
6902
5366
|
}
|
|
6903
5367
|
|
|
6904
|
-
// src/history/historyCache.ts
|
|
6905
|
-
function historyCache(open2) {
|
|
6906
|
-
const histories = /* @__PURE__ */ new Map();
|
|
6907
|
-
const get = async (projectDir) => {
|
|
6908
|
-
let cached = histories.get(projectDir);
|
|
6909
|
-
if (!cached) histories.set(projectDir, cached = open2(projectDir));
|
|
6910
|
-
const opened = await cached;
|
|
6911
|
-
if (!opened?.replacedAtPath()) return opened;
|
|
6912
|
-
const reopen = () => open2(projectDir);
|
|
6913
|
-
if (histories.get(projectDir) === cached)
|
|
6914
|
-
histories.set(projectDir, opened.close().then(reopen, reopen));
|
|
6915
|
-
return get(projectDir);
|
|
6916
|
-
};
|
|
6917
|
-
return {
|
|
6918
|
-
get,
|
|
6919
|
-
/** The history if one was opened, without opening it. */
|
|
6920
|
-
peek: (projectDir) => histories.get(projectDir),
|
|
6921
|
-
forget: (projectDir) => histories.delete(projectDir),
|
|
6922
|
-
closeAll: () => Promise.all(
|
|
6923
|
-
[...histories.values()].map((opened) => opened.then((history) => history?.close()))
|
|
6924
|
-
)
|
|
6925
|
-
};
|
|
6926
|
-
}
|
|
6927
|
-
|
|
6928
5368
|
// src/helpers/duplicateElement.ts
|
|
6929
5369
|
import { sameInstant } from "@hyperframes/core/clip-facts";
|
|
6930
|
-
import { ensureHfIds as
|
|
5370
|
+
import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
|
|
6931
5371
|
function duplicateElementInHtml(source, target, newId, at) {
|
|
6932
5372
|
const { document, wrappedFragment } = parseSourceDocument(source);
|
|
6933
5373
|
const element = findTargetElement(document, target);
|
|
@@ -6947,7 +5387,7 @@ function duplicateElementInHtml(source, target, newId, at) {
|
|
|
6947
5387
|
clone.setAttribute("data-start", String(at));
|
|
6948
5388
|
element.parentElement.insertBefore(clone, element.nextSibling);
|
|
6949
5389
|
const html = wrappedFragment ? document.body.innerHTML || "" : document.toString();
|
|
6950
|
-
return { html:
|
|
5390
|
+
return { html: ensureHfIds3(html), matched: true, newId: uniqueId };
|
|
6951
5391
|
}
|
|
6952
5392
|
function nextUniqueId(document, newId) {
|
|
6953
5393
|
let uniqueId = newId;
|
|
@@ -6979,27 +5419,28 @@ function numericAttribute(element, name) {
|
|
|
6979
5419
|
}
|
|
6980
5420
|
|
|
6981
5421
|
// src/helpers/previewDocumentStore.ts
|
|
6982
|
-
import { createHash as
|
|
6983
|
-
import {
|
|
6984
|
-
import { join as
|
|
6985
|
-
function createPreviewDocumentStore(
|
|
6986
|
-
const
|
|
5422
|
+
import { createHash as createHash4 } from "crypto";
|
|
5423
|
+
import { readFileSync as readFileSync13, readdirSync as readdirSync5, rmSync as rmSync5 } from "fs";
|
|
5424
|
+
import { join as join15 } from "path";
|
|
5425
|
+
function createPreviewDocumentStore(projectDir, salt) {
|
|
5426
|
+
const dir = join15(projectDir, ".hyperframes", "preview");
|
|
5427
|
+
const fileFor = (key) => join15(dir, `${createHash4("sha256").update(`${salt}
|
|
6987
5428
|
${key}`).digest("hex").slice(0, 32)}.html`);
|
|
6988
5429
|
return {
|
|
6989
5430
|
read(key) {
|
|
6990
5431
|
try {
|
|
6991
|
-
return
|
|
5432
|
+
return readFileSync13(fileFor(key), "utf-8");
|
|
6992
5433
|
} catch {
|
|
6993
5434
|
return null;
|
|
6994
5435
|
}
|
|
6995
5436
|
},
|
|
6996
5437
|
write(key, html) {
|
|
6997
5438
|
try {
|
|
6998
|
-
|
|
5439
|
+
mkdirWithinProject(projectDir, dir);
|
|
6999
5440
|
const file = fileFor(key);
|
|
7000
5441
|
replaceFileAtomically(file, html, 420);
|
|
7001
|
-
for (const name of
|
|
7002
|
-
if (
|
|
5442
|
+
for (const name of readdirSync5(dir)) {
|
|
5443
|
+
if (join15(dir, name) !== file) rmSync5(join15(dir, name), { force: true });
|
|
7003
5444
|
}
|
|
7004
5445
|
} catch {
|
|
7005
5446
|
}
|
|
@@ -7109,6 +5550,7 @@ export {
|
|
|
7109
5550
|
historyCache,
|
|
7110
5551
|
identifyFileWrite,
|
|
7111
5552
|
isSafePath,
|
|
5553
|
+
listProjectHistories,
|
|
7112
5554
|
openProjectHistory,
|
|
7113
5555
|
parseSourceDocument,
|
|
7114
5556
|
patchElementInHtml,
|