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