@hyperframes/studio-server 0.8.72 → 0.8.74
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js
CHANGED
|
@@ -151,22 +151,22 @@ function collectProjectSignatureFiles(projectDir, dir, files) {
|
|
|
151
151
|
if (SIGNATURE_EXCLUDED_DIRS.has(entry)) continue;
|
|
152
152
|
const file = resolve(dir, entry);
|
|
153
153
|
if (!isPathWithin(projectDir, file)) continue;
|
|
154
|
-
let
|
|
154
|
+
let stat2;
|
|
155
155
|
try {
|
|
156
|
-
|
|
156
|
+
stat2 = lstatSync(file);
|
|
157
157
|
} catch {
|
|
158
158
|
continue;
|
|
159
159
|
}
|
|
160
|
-
if (
|
|
161
|
-
if (
|
|
160
|
+
if (stat2.isSymbolicLink()) continue;
|
|
161
|
+
if (stat2.isDirectory()) {
|
|
162
162
|
collectProjectSignatureFiles(projectDir, file, files);
|
|
163
|
-
} else if (
|
|
163
|
+
} else if (stat2.isFile()) {
|
|
164
164
|
files.push({
|
|
165
165
|
file,
|
|
166
|
-
mtimeMs:
|
|
167
|
-
ctimeMs:
|
|
168
|
-
size:
|
|
169
|
-
textContentEligible: isTextContentEligible(file,
|
|
166
|
+
mtimeMs: stat2.mtimeMs,
|
|
167
|
+
ctimeMs: stat2.ctimeMs,
|
|
168
|
+
size: stat2.size,
|
|
169
|
+
textContentEligible: isTextContentEligible(file, stat2.size)
|
|
170
170
|
});
|
|
171
171
|
}
|
|
172
172
|
}
|
|
@@ -176,19 +176,19 @@ function collectProjectSignatureManifestFiles(projectDir, files) {
|
|
|
176
176
|
for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) {
|
|
177
177
|
const file = resolve(projectDir, manifestPath);
|
|
178
178
|
if (seen.has(file) || !isPathWithin(projectDir, file)) continue;
|
|
179
|
-
let
|
|
179
|
+
let stat2;
|
|
180
180
|
try {
|
|
181
|
-
|
|
181
|
+
stat2 = lstatSync(file);
|
|
182
182
|
} catch {
|
|
183
183
|
continue;
|
|
184
184
|
}
|
|
185
|
-
if (
|
|
185
|
+
if (stat2.isSymbolicLink() || !stat2.isFile()) continue;
|
|
186
186
|
files.push({
|
|
187
187
|
file,
|
|
188
|
-
mtimeMs:
|
|
189
|
-
ctimeMs:
|
|
190
|
-
size:
|
|
191
|
-
textContentEligible: isTextContentEligible(file,
|
|
188
|
+
mtimeMs: stat2.mtimeMs,
|
|
189
|
+
ctimeMs: stat2.ctimeMs,
|
|
190
|
+
size: stat2.size,
|
|
191
|
+
textContentEligible: isTextContentEligible(file, stat2.size)
|
|
192
192
|
});
|
|
193
193
|
seen.add(file);
|
|
194
194
|
}
|
|
@@ -209,6 +209,21 @@ function createProjectFingerprint(projectDir, files) {
|
|
|
209
209
|
}
|
|
210
210
|
return hash.digest("hex").slice(0, 24);
|
|
211
211
|
}
|
|
212
|
+
function collectProjectFiles(normalizedProjectDir) {
|
|
213
|
+
const collected = [];
|
|
214
|
+
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
|
|
215
|
+
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
|
|
216
|
+
return collected;
|
|
217
|
+
}
|
|
218
|
+
function listProjectFiles(projectDir) {
|
|
219
|
+
const normalizedProjectDir = resolve(projectDir);
|
|
220
|
+
return collectProjectFiles(normalizedProjectDir).map((entry) => ({
|
|
221
|
+
path: relative(normalizedProjectDir, entry.file).split(sep).join("/"),
|
|
222
|
+
size: entry.size,
|
|
223
|
+
mtimeMs: entry.mtimeMs,
|
|
224
|
+
ctimeMs: entry.ctimeMs
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
212
227
|
function resolveProjectSignature(adapter, projectDir) {
|
|
213
228
|
return adapter.getProjectSignature?.(projectDir) ?? createProjectSignature(projectDir);
|
|
214
229
|
}
|
|
@@ -219,9 +234,7 @@ async function resolveProjectAndSignature(adapter, projectId) {
|
|
|
219
234
|
}
|
|
220
235
|
function createProjectSignature(projectDir, excluding = /* @__PURE__ */ new Set()) {
|
|
221
236
|
const normalizedProjectDir = resolve(projectDir);
|
|
222
|
-
const collected =
|
|
223
|
-
collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, collected);
|
|
224
|
-
collectProjectSignatureManifestFiles(normalizedProjectDir, collected);
|
|
237
|
+
const collected = collectProjectFiles(normalizedProjectDir);
|
|
225
238
|
const files = collected.filter(
|
|
226
239
|
(entry) => !excluding.has(relative(normalizedProjectDir, entry.file).split(sep).join("/"))
|
|
227
240
|
);
|
|
@@ -622,9 +635,9 @@ var receipts = /* @__PURE__ */ new Map();
|
|
|
622
635
|
function fileContentVersion(content) {
|
|
623
636
|
return `"sha256:${createHash2("sha256").update(content).digest("hex")}"`;
|
|
624
637
|
}
|
|
625
|
-
function settledFileTag(
|
|
626
|
-
if (now -
|
|
627
|
-
return [
|
|
638
|
+
function settledFileTag(stat2, now = Date.now()) {
|
|
639
|
+
if (now - stat2.ctimeMs < 3e3) return null;
|
|
640
|
+
return [stat2.ino, stat2.ctimeMs, stat2.size].map((n) => n.toString(36)).join("-");
|
|
628
641
|
}
|
|
629
642
|
function createWriteToken(requestToken) {
|
|
630
643
|
const token = requestToken?.trim();
|
|
@@ -1018,6 +1031,14 @@ function resolveGsapWriter(env) {
|
|
|
1018
1031
|
throw new Error(`Invalid ${GSAP_WRITER_MIGRATION.flag}=${configured}; expected recast or acorn`);
|
|
1019
1032
|
}
|
|
1020
1033
|
|
|
1034
|
+
// src/helpers/requestSubPath.ts
|
|
1035
|
+
function requestSubPath(url, route) {
|
|
1036
|
+
const routeSegments = route.split("/");
|
|
1037
|
+
const segments = new URL(url).pathname.split("/");
|
|
1038
|
+
const start = segments.indexOf(routeSegments[0] ?? "");
|
|
1039
|
+
return decodeURIComponent(segments.slice(start + routeSegments.length).join("/"));
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1021
1042
|
// src/routes/files.ts
|
|
1022
1043
|
async function loadGsapParser() {
|
|
1023
1044
|
return import("@hyperframes/parsers/gsap-parser-recast");
|
|
@@ -1040,7 +1061,7 @@ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
|
|
|
1040
1061
|
return true;
|
|
1041
1062
|
}
|
|
1042
1063
|
}
|
|
1043
|
-
async function resolveProjectPath(c, adapter,
|
|
1064
|
+
async function resolveProjectPath(c, adapter, route, opts) {
|
|
1044
1065
|
const id = c.req.param("id");
|
|
1045
1066
|
const project = await adapter.resolveProject(id);
|
|
1046
1067
|
if (!project) {
|
|
@@ -1051,7 +1072,7 @@ async function resolveProjectPath(c, adapter, pathPrefix, opts) {
|
|
|
1051
1072
|
error: c.json({ error: "not found", why: "project_dir_missing" }, 404)
|
|
1052
1073
|
};
|
|
1053
1074
|
}
|
|
1054
|
-
const filePath =
|
|
1075
|
+
const filePath = requestSubPath(c.req.url, `projects/:id/${route}`);
|
|
1055
1076
|
if (filePath.includes("\0")) {
|
|
1056
1077
|
return { error: c.json({ error: "forbidden", why: "nul" }, 403) };
|
|
1057
1078
|
}
|
|
@@ -1068,10 +1089,10 @@ async function resolveProjectPath(c, adapter, pathPrefix, opts) {
|
|
|
1068
1089
|
return { project, filePath, absPath };
|
|
1069
1090
|
}
|
|
1070
1091
|
function resolveProjectFile(c, adapter, opts) {
|
|
1071
|
-
return resolveProjectPath(c, adapter,
|
|
1092
|
+
return resolveProjectPath(c, adapter, "files", opts);
|
|
1072
1093
|
}
|
|
1073
1094
|
function resolveFileMutationContext(c, adapter, operation) {
|
|
1074
|
-
return resolveProjectPath(c, adapter,
|
|
1095
|
+
return resolveProjectPath(c, adapter, `file-mutations/${operation}`);
|
|
1075
1096
|
}
|
|
1076
1097
|
function isOptionalInteger(value) {
|
|
1077
1098
|
return value === void 0 || Number.isInteger(value);
|
|
@@ -2582,10 +2603,10 @@ function registerFileRoutes(api, adapter) {
|
|
|
2582
2603
|
api.delete("/projects/:id/files/*", async (c) => {
|
|
2583
2604
|
const res = await resolveProjectFile(c, adapter, { mustExist: true });
|
|
2584
2605
|
if ("error" in res) return res.error;
|
|
2585
|
-
const
|
|
2606
|
+
const stat2 = statSync3(res.absPath);
|
|
2586
2607
|
const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
|
|
2587
2608
|
if (backup.error) return c.json({ error: `backup failed: ${backup.error}` }, 500);
|
|
2588
|
-
if (
|
|
2609
|
+
if (stat2.isDirectory()) {
|
|
2589
2610
|
rmSync3(res.absPath, { recursive: true });
|
|
2590
2611
|
} else {
|
|
2591
2612
|
unlinkSync2(res.absPath);
|
|
@@ -3142,7 +3163,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3142
3163
|
}
|
|
3143
3164
|
);
|
|
3144
3165
|
api.get("/projects/:id/gsap-animations/*", async (c) => {
|
|
3145
|
-
const res = await resolveProjectPath(c, adapter,
|
|
3166
|
+
const res = await resolveProjectPath(c, adapter, "gsap-animations", {
|
|
3146
3167
|
mustExist: true
|
|
3147
3168
|
});
|
|
3148
3169
|
if ("error" in res) return res.error;
|
|
@@ -3165,7 +3186,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3165
3186
|
return c.json({ atomicOwnershipPairs: true });
|
|
3166
3187
|
});
|
|
3167
3188
|
api.post("/projects/:id/gsap-mutations/*", async (c) => {
|
|
3168
|
-
const res = await resolveProjectPath(c, adapter,
|
|
3189
|
+
const res = await resolveProjectPath(c, adapter, "gsap-mutations", {
|
|
3169
3190
|
mustExist: true
|
|
3170
3191
|
});
|
|
3171
3192
|
if ("error" in res) return res.error;
|
|
@@ -3176,12 +3197,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3176
3197
|
return applyGsapMutations(c, res, [body]);
|
|
3177
3198
|
});
|
|
3178
3199
|
api.post("/projects/:id/gsap-mutations-batch/*", async (c) => {
|
|
3179
|
-
const res = await resolveProjectPath(
|
|
3180
|
-
c,
|
|
3181
|
-
adapter,
|
|
3182
|
-
(id) => `/projects/${id}/gsap-mutations-batch/`,
|
|
3183
|
-
{ mustExist: true }
|
|
3184
|
-
);
|
|
3200
|
+
const res = await resolveProjectPath(c, adapter, "gsap-mutations-batch", { mustExist: true });
|
|
3185
3201
|
if ("error" in res) return res.error;
|
|
3186
3202
|
const body = await c.req.json().catch(() => null);
|
|
3187
3203
|
if (!body || !Array.isArray(body.mutations) || body.mutations.length === 0) {
|
|
@@ -3194,12 +3210,7 @@ function registerFileRoutes(api, adapter) {
|
|
|
3194
3210
|
return applyGsapMutations(c, res, body.mutations);
|
|
3195
3211
|
});
|
|
3196
3212
|
api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
|
|
3197
|
-
const res = await resolveProjectPath(
|
|
3198
|
-
c,
|
|
3199
|
-
adapter,
|
|
3200
|
-
(id) => `/projects/${id}/gsap-mutation-rollback/`,
|
|
3201
|
-
{ mustExist: true }
|
|
3202
|
-
);
|
|
3213
|
+
const res = await resolveProjectPath(c, adapter, "gsap-mutation-rollback", { mustExist: true });
|
|
3203
3214
|
if ("error" in res) return res.error;
|
|
3204
3215
|
const body = await c.req.json().catch(() => null);
|
|
3205
3216
|
if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
|
|
@@ -3375,7 +3386,7 @@ function buildSubCompositionHtml(projectDir, compPath, runtimeUrl, baseHref, raw
|
|
|
3375
3386
|
const headMatch = indexHtml.match(/<head[^>]*>([\s\S]*?)<\/head>/i);
|
|
3376
3387
|
headContent = headMatch?.[1] ?? "";
|
|
3377
3388
|
}
|
|
3378
|
-
if (baseHref && !headContent
|
|
3389
|
+
if (baseHref && !hasBaseElement(headContent)) {
|
|
3379
3390
|
headContent = `<base href="${baseHref}">
|
|
3380
3391
|
${headContent}`;
|
|
3381
3392
|
}
|
|
@@ -3403,6 +3414,9 @@ ${rewrittenContent}
|
|
|
3403
3414
|
</body>
|
|
3404
3415
|
</html>`;
|
|
3405
3416
|
}
|
|
3417
|
+
function hasBaseElement(html) {
|
|
3418
|
+
return parseHTML3(html).document.querySelector("base") !== null;
|
|
3419
|
+
}
|
|
3406
3420
|
|
|
3407
3421
|
// src/routes/preview.ts
|
|
3408
3422
|
import { ensureHfIds as ensureHfIds2 } from "@hyperframes/parsers/hf-ids";
|
|
@@ -3709,7 +3723,7 @@ function registerPreviewRoutes(api, adapter) {
|
|
|
3709
3723
|
${runtimeTag}`;
|
|
3710
3724
|
}
|
|
3711
3725
|
const baseHref = `/api/projects/${project.id}/preview/`;
|
|
3712
|
-
if (!bundled
|
|
3726
|
+
if (!hasBaseElement(bundled)) {
|
|
3713
3727
|
bundled = bundled.replace(/<head>/i, `<head><base href="${baseHref}">`);
|
|
3714
3728
|
}
|
|
3715
3729
|
bundled = injectStudioPreviewAugmentations(
|
|
@@ -3766,9 +3780,7 @@ ${runtimeTag}`;
|
|
|
3766
3780
|
const vars = previewVariablesFromRequest(c.req.query("variables"));
|
|
3767
3781
|
if (vars.error !== void 0) return c.json({ error: vars.error }, 400);
|
|
3768
3782
|
const previewVariables = vars.values;
|
|
3769
|
-
const compPath =
|
|
3770
|
-
c.req.path.replace(`/projects/${project.id}/preview/comp/`, "").split("?")[0] ?? ""
|
|
3771
|
-
);
|
|
3783
|
+
const compPath = requestSubPath(c.req.url, "projects/:id/preview/comp");
|
|
3772
3784
|
const compFile = resolveWithinProject(project.dir, compPath);
|
|
3773
3785
|
if (!compFile || !existsSync5(compFile) || !statSync5(compFile).isFile()) {
|
|
3774
3786
|
return c.text("not found", 404);
|
|
@@ -3802,16 +3814,14 @@ ${runtimeTag}`;
|
|
|
3802
3814
|
api.get("/projects/:id/preview/*", async (c) => {
|
|
3803
3815
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
3804
3816
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
3805
|
-
const subPath =
|
|
3806
|
-
c.req.path.replace(`/projects/${project.id}/preview/`, "").split("?")[0] ?? ""
|
|
3807
|
-
);
|
|
3817
|
+
const subPath = requestSubPath(c.req.url, "projects/:id/preview");
|
|
3808
3818
|
const candidate = resolve4(project.dir, subPath);
|
|
3809
3819
|
const file = isWithinProjectRoot(project.dir, candidate) ? candidate : null;
|
|
3810
3820
|
if (!file) {
|
|
3811
3821
|
return c.text("not found", 404);
|
|
3812
3822
|
}
|
|
3813
|
-
const
|
|
3814
|
-
if (!
|
|
3823
|
+
const stat2 = existsSync5(file) ? statSync5(file) : null;
|
|
3824
|
+
if (!stat2?.isFile()) {
|
|
3815
3825
|
return c.text("not found", 404);
|
|
3816
3826
|
}
|
|
3817
3827
|
const contentType = getMimeType(subPath);
|
|
@@ -3833,7 +3843,7 @@ ${runtimeTag}`;
|
|
|
3833
3843
|
return c.text("media proxy variant does not match asset", 422);
|
|
3834
3844
|
}
|
|
3835
3845
|
}
|
|
3836
|
-
const tag = settledFileTag(
|
|
3846
|
+
const tag = settledFileTag(stat2);
|
|
3837
3847
|
const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
|
|
3838
3848
|
const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
|
|
3839
3849
|
if (!isText && etag) {
|
|
@@ -4159,7 +4169,7 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4159
4169
|
if (!existsSync6(rendersDir)) return c.json({ renders: [] });
|
|
4160
4170
|
const files = readdirSync5(rendersDir).filter((f) => f.endsWith(".mp4") || f.endsWith(".webm") || f.endsWith(".mov")).map((f) => {
|
|
4161
4171
|
const fp = join10(rendersDir, f);
|
|
4162
|
-
const
|
|
4172
|
+
const stat2 = statSync6(fp);
|
|
4163
4173
|
const rid = f.replace(/\.(mp4|webm|mov)$/, "");
|
|
4164
4174
|
const metaPath = join10(rendersDir, `${rid}.meta.json`);
|
|
4165
4175
|
let status = "complete";
|
|
@@ -4175,8 +4185,8 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4175
4185
|
return {
|
|
4176
4186
|
id: rid,
|
|
4177
4187
|
filename: f,
|
|
4178
|
-
size:
|
|
4179
|
-
createdAt:
|
|
4188
|
+
size: stat2.size,
|
|
4189
|
+
createdAt: stat2.mtimeMs,
|
|
4180
4190
|
status,
|
|
4181
4191
|
durationMs
|
|
4182
4192
|
};
|
|
@@ -4198,7 +4208,10 @@ function registerRenderRoutes(api, adapter) {
|
|
|
4198
4208
|
|
|
4199
4209
|
// src/routes/thumbnail.ts
|
|
4200
4210
|
import {
|
|
4211
|
+
closeSync as closeSync3,
|
|
4201
4212
|
existsSync as existsSync7,
|
|
4213
|
+
fstatSync as fstatSync3,
|
|
4214
|
+
openSync as openSync3,
|
|
4202
4215
|
mkdirSync as mkdirSync5,
|
|
4203
4216
|
readFileSync as readFileSync12,
|
|
4204
4217
|
readdirSync as readdirSync6,
|
|
@@ -4285,10 +4298,10 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4285
4298
|
if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
|
|
4286
4299
|
let entry = this.entries.get(key);
|
|
4287
4300
|
if (!entry) {
|
|
4288
|
-
let
|
|
4301
|
+
let resolve8;
|
|
4289
4302
|
let reject;
|
|
4290
4303
|
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
4291
|
-
|
|
4304
|
+
resolve8 = resolvePromise;
|
|
4292
4305
|
reject = rejectPromise;
|
|
4293
4306
|
});
|
|
4294
4307
|
entry = {
|
|
@@ -4298,7 +4311,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4298
4311
|
state: "queued",
|
|
4299
4312
|
work,
|
|
4300
4313
|
promise,
|
|
4301
|
-
resolve:
|
|
4314
|
+
resolve: resolve8,
|
|
4302
4315
|
reject
|
|
4303
4316
|
};
|
|
4304
4317
|
this.entries.set(key, entry);
|
|
@@ -4312,7 +4325,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4312
4325
|
return /* @__PURE__ */ new Set([...this.entries.keys(), ...[...this.activeEntries].map((entry) => entry.key)]);
|
|
4313
4326
|
}
|
|
4314
4327
|
lease(entry, signal) {
|
|
4315
|
-
return new Promise((
|
|
4328
|
+
return new Promise((resolve8, reject) => {
|
|
4316
4329
|
let released = false;
|
|
4317
4330
|
const release = () => {
|
|
4318
4331
|
if (released) return;
|
|
@@ -4336,7 +4349,7 @@ var ThumbnailGenerationCoordinator = class {
|
|
|
4336
4349
|
entry.promise.then(
|
|
4337
4350
|
(value) => {
|
|
4338
4351
|
release();
|
|
4339
|
-
|
|
4352
|
+
resolve8(value);
|
|
4340
4353
|
},
|
|
4341
4354
|
(reason) => {
|
|
4342
4355
|
release();
|
|
@@ -4418,6 +4431,22 @@ function writeThumbnailAtomically(path, buffer) {
|
|
|
4418
4431
|
rmSync4(temporaryPath, { force: true });
|
|
4419
4432
|
}
|
|
4420
4433
|
}
|
|
4434
|
+
function readCompositionSource(file) {
|
|
4435
|
+
let fd;
|
|
4436
|
+
try {
|
|
4437
|
+
fd = openSync3(file, "r");
|
|
4438
|
+
} catch (err) {
|
|
4439
|
+
if (err.code === "ENOENT") return "missing";
|
|
4440
|
+
throw err;
|
|
4441
|
+
}
|
|
4442
|
+
try {
|
|
4443
|
+
const stat2 = fstatSync3(fd);
|
|
4444
|
+
if (!stat2.isFile()) return "not-a-file";
|
|
4445
|
+
return { html: readFileSync12(fd, "utf-8"), mtimeMs: stat2.mtimeMs };
|
|
4446
|
+
} finally {
|
|
4447
|
+
closeSync3(fd);
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4421
4450
|
function registerThumbnailRoutes(api, adapter) {
|
|
4422
4451
|
api.get("/projects/:id/thumbnail/*", async (c) => {
|
|
4423
4452
|
if (!adapter.generateThumbnail) {
|
|
@@ -4426,10 +4455,11 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4426
4455
|
const resolved = await resolveProjectAndSignature(adapter, c.req.param("id"));
|
|
4427
4456
|
if (!resolved) return c.json({ error: "not found" }, 404);
|
|
4428
4457
|
const { project, signature: projectSignature } = resolved;
|
|
4429
|
-
let compPath =
|
|
4430
|
-
c.req.path.replace(`/projects/${project.id}/thumbnail/`, "").split("?")[0] ?? ""
|
|
4431
|
-
);
|
|
4458
|
+
let compPath = requestSubPath(c.req.url, "projects/:id/thumbnail");
|
|
4432
4459
|
if (compPath && !compPath.includes(".")) compPath += ".html";
|
|
4460
|
+
const htmlFile = resolveWithinProject(project.dir, compPath);
|
|
4461
|
+
const source = htmlFile ? readCompositionSource(htmlFile) : "not-a-file";
|
|
4462
|
+
if (source === "not-a-file") return c.json({ error: "not found" }, 404);
|
|
4433
4463
|
const inputSignature = compositionInputSignature(project.dir, compPath, projectSignature);
|
|
4434
4464
|
const url = new URL(c.req.url, `http://${c.req.header("host") || "localhost"}`);
|
|
4435
4465
|
const rawSeekTime = url.searchParams.get("t");
|
|
@@ -4449,11 +4479,10 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4449
4479
|
let compH = vpHeight || 1080;
|
|
4450
4480
|
let sourceMtime = 0;
|
|
4451
4481
|
let sourceKey = "";
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
const html = readFileSync12(htmlFile, "utf-8");
|
|
4482
|
+
if (source !== "missing") {
|
|
4483
|
+
const { html } = source;
|
|
4455
4484
|
sourceKey = `_${createHash4("sha1").update(html).digest("hex").slice(0, 16)}`;
|
|
4456
|
-
sourceMtime = Math.round(
|
|
4485
|
+
sourceMtime = Math.round(source.mtimeMs);
|
|
4457
4486
|
if (!vpWidth) {
|
|
4458
4487
|
const wMatch = html.match(/data-width=["'](\d+)["']/);
|
|
4459
4488
|
const hMatch = html.match(/data-height=["'](\d+)["']/);
|
|
@@ -4475,7 +4504,8 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4475
4504
|
motionKey = `_${createHash4("sha1").update(motionContent).digest("hex").slice(0, 16)}`;
|
|
4476
4505
|
sourceMtime = Math.max(sourceMtime, Math.round(statSync7(motionFile).mtimeMs));
|
|
4477
4506
|
}
|
|
4478
|
-
const
|
|
4507
|
+
const projectUrl = `http://${c.req.header("host")}/api/projects/${encodeURIComponent(project.id)}`;
|
|
4508
|
+
const previewUrl = compPath === "index.html" ? `${projectUrl}/preview` : `${projectUrl}/preview/comp/${compPath.split("/").map(encodeURIComponent).join("/")}`;
|
|
4479
4509
|
const cacheDir = join11(project.dir, ".thumbnails");
|
|
4480
4510
|
const selectorKey = selector ? `_${selector.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 80)}_${selectorIndex ?? 0}` : "";
|
|
4481
4511
|
const urlVersionKey = urlVersion ? `_${urlVersion.replace(/[^a-zA-Z0-9_-]+/g, "_").slice(0, 32)}` : "";
|
|
@@ -4549,15 +4579,17 @@ function registerThumbnailRoutes(api, adapter) {
|
|
|
4549
4579
|
|
|
4550
4580
|
// src/routes/waveform.ts
|
|
4551
4581
|
import { existsSync as existsSync8, readFileSync as readFileSync13, statSync as statSync8 } from "fs";
|
|
4552
|
-
import { join as join12 } from "path";
|
|
4582
|
+
import { join as join12, resolve as resolve5 } from "path";
|
|
4583
|
+
import { isWithinProjectRoot as isWithinProjectRoot2 } from "@hyperframes/parsers/asset-resolution";
|
|
4553
4584
|
function registerWaveformRoutes(api, adapter) {
|
|
4554
4585
|
api.get("/projects/:id/waveform/*", async (c) => {
|
|
4555
4586
|
const project = await adapter.resolveProject(c.req.param("id"));
|
|
4556
4587
|
if (!project) return c.json({ error: "not found" }, 404);
|
|
4557
|
-
const assetPath =
|
|
4558
|
-
|
|
4559
|
-
)
|
|
4560
|
-
|
|
4588
|
+
const assetPath = requestSubPath(c.req.url, "projects/:id/waveform");
|
|
4589
|
+
const audioPath = resolve5(project.dir, assetPath);
|
|
4590
|
+
if (!isWithinProjectRoot2(project.dir, audioPath)) {
|
|
4591
|
+
return c.json({ error: "file not found" }, 404);
|
|
4592
|
+
}
|
|
4561
4593
|
const stats = statSync8(audioPath, { throwIfNoEntry: false });
|
|
4562
4594
|
if (!stats) return c.json({ error: "file not found" }, 404);
|
|
4563
4595
|
const cacheDir = join12(project.dir, ".waveform-cache");
|
|
@@ -4584,7 +4616,7 @@ function registerWaveformRoutes(api, adapter) {
|
|
|
4584
4616
|
}
|
|
4585
4617
|
|
|
4586
4618
|
// src/routes/fonts.ts
|
|
4587
|
-
import { closeSync as
|
|
4619
|
+
import { closeSync as closeSync4, constants as constants2, fstatSync as fstatSync4, openSync as openSync4, readSync } from "fs";
|
|
4588
4620
|
import {
|
|
4589
4621
|
collectFontFileEntries,
|
|
4590
4622
|
fontDirectories,
|
|
@@ -4691,17 +4723,17 @@ function registerFontRoutes(api) {
|
|
|
4691
4723
|
if (!located) return c.json({ error: "font not found" }, 404);
|
|
4692
4724
|
let fd;
|
|
4693
4725
|
try {
|
|
4694
|
-
fd =
|
|
4726
|
+
fd = openSync4(located.path, constants2.O_RDONLY | constants2.O_NOFOLLOW);
|
|
4695
4727
|
} catch {
|
|
4696
4728
|
return c.json({ error: "font file not accessible" }, 404);
|
|
4697
4729
|
}
|
|
4698
4730
|
try {
|
|
4699
|
-
const
|
|
4700
|
-
if (
|
|
4731
|
+
const stat2 = fstatSync4(fd);
|
|
4732
|
+
if (stat2.size > SYSTEM_FONT_SIZE_LIMIT) {
|
|
4701
4733
|
return c.json({ error: "font file too large" }, 413);
|
|
4702
4734
|
}
|
|
4703
|
-
const buffer = Buffer.alloc(
|
|
4704
|
-
readSync(fd, buffer, 0,
|
|
4735
|
+
const buffer = Buffer.alloc(stat2.size);
|
|
4736
|
+
readSync(fd, buffer, 0, stat2.size, 0);
|
|
4705
4737
|
const mimeType = located.format === "otf" ? "font/otf" : located.format === "woff2" ? "font/woff2" : located.format === "woff" ? "font/woff" : located.format === "ttc" ? "font/collection" : "font/ttf";
|
|
4706
4738
|
const fileName = `${family.replace(/[^a-zA-Z0-9 -]/g, "")}.${located.format}`;
|
|
4707
4739
|
return new Response(buffer, {
|
|
@@ -4713,7 +4745,7 @@ function registerFontRoutes(api) {
|
|
|
4713
4745
|
} catch {
|
|
4714
4746
|
return c.json({ error: "failed to read font file" }, 500);
|
|
4715
4747
|
} finally {
|
|
4716
|
-
|
|
4748
|
+
closeSync4(fd);
|
|
4717
4749
|
}
|
|
4718
4750
|
});
|
|
4719
4751
|
}
|
|
@@ -5074,6 +5106,80 @@ function registerGlobalAssetRoutes(api) {
|
|
|
5074
5106
|
api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) }));
|
|
5075
5107
|
}
|
|
5076
5108
|
|
|
5109
|
+
// src/routes/history.ts
|
|
5110
|
+
var YOU = { kind: "person", name: "You" };
|
|
5111
|
+
async function historyOf(adapter, c) {
|
|
5112
|
+
const project = await adapter.resolveProject(c.req.param("id") ?? "");
|
|
5113
|
+
return project ? await adapter.history?.(project) ?? null : null;
|
|
5114
|
+
}
|
|
5115
|
+
async function bodyOf(c) {
|
|
5116
|
+
const body = await c.req.json().catch(() => null);
|
|
5117
|
+
return body && typeof body === "object" ? body : {};
|
|
5118
|
+
}
|
|
5119
|
+
var text = (value) => typeof value === "string" && value ? value : null;
|
|
5120
|
+
async function withHistory(adapter, c, task) {
|
|
5121
|
+
const history = await historyOf(adapter, c);
|
|
5122
|
+
if (!history) return c.json({ error: "This project has no history here." }, 404);
|
|
5123
|
+
try {
|
|
5124
|
+
return c.json(await task(history, await bodyOf(c)) ?? null);
|
|
5125
|
+
} catch (error) {
|
|
5126
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
5127
|
+
}
|
|
5128
|
+
}
|
|
5129
|
+
function registerHistoryRoutes(api, adapter) {
|
|
5130
|
+
const windows = /* @__PURE__ */ new Map();
|
|
5131
|
+
const base = "/projects/:id/history";
|
|
5132
|
+
api.get(base, (c) => withHistory(adapter, c, (history) => ({ entries: history.list() })));
|
|
5133
|
+
api.post(
|
|
5134
|
+
`${base}/step`,
|
|
5135
|
+
(c) => withHistory(
|
|
5136
|
+
adapter,
|
|
5137
|
+
c,
|
|
5138
|
+
(history, body) => history.step(body.direction === "forward" ? "forward" : "back", YOU)
|
|
5139
|
+
)
|
|
5140
|
+
);
|
|
5141
|
+
api.post(
|
|
5142
|
+
`${base}/undo`,
|
|
5143
|
+
(c) => withHistory(adapter, c, (history, body) => {
|
|
5144
|
+
const mode = body.mode === "just-this" || body.mode === "back-to-before" ? body.mode : void 0;
|
|
5145
|
+
return history.undo(text(body.entryId) ?? "", { who: YOU, mode });
|
|
5146
|
+
})
|
|
5147
|
+
);
|
|
5148
|
+
api.post(
|
|
5149
|
+
`${base}/restore`,
|
|
5150
|
+
(c) => withHistory(adapter, c, (history, body) => history.restore(text(body.point) ?? "", YOU))
|
|
5151
|
+
);
|
|
5152
|
+
api.get(
|
|
5153
|
+
`${base}/peek/:point`,
|
|
5154
|
+
(c) => withHistory(adapter, c, (history) => ({ files: history.peek(c.req.param("point")) }))
|
|
5155
|
+
);
|
|
5156
|
+
api.post(
|
|
5157
|
+
`${base}/pin`,
|
|
5158
|
+
(c) => withHistory(adapter, c, (history, body) => {
|
|
5159
|
+
history.pin(text(body.entryId) ?? "", body.pinned === true);
|
|
5160
|
+
return { ok: true };
|
|
5161
|
+
})
|
|
5162
|
+
);
|
|
5163
|
+
api.post(
|
|
5164
|
+
`${base}/window`,
|
|
5165
|
+
(c) => withHistory(adapter, c, async (history, body) => {
|
|
5166
|
+
const window = await history.beginWindow(YOU, text(body.label) ?? "Edited in Studio");
|
|
5167
|
+
windows.set(window.id, { history, window });
|
|
5168
|
+
return { windowId: window.id };
|
|
5169
|
+
})
|
|
5170
|
+
);
|
|
5171
|
+
api.post(
|
|
5172
|
+
`${base}/window/:windowId/close`,
|
|
5173
|
+
(c) => withHistory(adapter, c, async (history) => {
|
|
5174
|
+
const id = c.req.param("windowId") ?? "";
|
|
5175
|
+
const held = windows.get(id);
|
|
5176
|
+
if (held?.history !== history) throw new Error("That window is not open in this project.");
|
|
5177
|
+
windows.delete(id);
|
|
5178
|
+
return { entry: await held.window.close() };
|
|
5179
|
+
})
|
|
5180
|
+
);
|
|
5181
|
+
}
|
|
5182
|
+
|
|
5077
5183
|
// src/createStudioApi.ts
|
|
5078
5184
|
function createStudioApi(adapter) {
|
|
5079
5185
|
const api = new Hono();
|
|
@@ -5089,9 +5195,587 @@ function createStudioApi(adapter) {
|
|
|
5089
5195
|
registerFontRoutes(api);
|
|
5090
5196
|
registerRegistryRoutes(api, adapter);
|
|
5091
5197
|
registerGlobalAssetRoutes(api);
|
|
5198
|
+
registerHistoryRoutes(api, adapter);
|
|
5092
5199
|
return api;
|
|
5093
5200
|
}
|
|
5094
5201
|
|
|
5202
|
+
// src/history/projectHistory.ts
|
|
5203
|
+
import { randomUUID as randomUUID7 } from "crypto";
|
|
5204
|
+
import { rm as rm2 } from "fs/promises";
|
|
5205
|
+
import { existsSync as existsSync12, mkdirSync as mkdirSync9, readFileSync as readFileSync17 } from "fs";
|
|
5206
|
+
import { join as join17, resolve as resolve7 } from "path";
|
|
5207
|
+
|
|
5208
|
+
// src/history/blobStore.ts
|
|
5209
|
+
import { createHash as createHash5, randomUUID as randomUUID5 } from "crypto";
|
|
5210
|
+
import { constants as constants3, createReadStream as createReadStream2 } from "fs";
|
|
5211
|
+
import { copyFile, mkdir, readdir, readFile as readFile2, rename, rm, stat } from "fs/promises";
|
|
5212
|
+
import { dirname as dirname5, join as join15 } from "path";
|
|
5213
|
+
async function hashFile(path) {
|
|
5214
|
+
const hash = createHash5("sha256");
|
|
5215
|
+
for await (const chunk of createReadStream2(path)) hash.update(chunk);
|
|
5216
|
+
return hash.digest("hex");
|
|
5217
|
+
}
|
|
5218
|
+
async function cloneOrCopy(from, to) {
|
|
5219
|
+
await mkdir(dirname5(to), { recursive: true });
|
|
5220
|
+
const temp = `${to}.${randomUUID5()}.tmp`;
|
|
5221
|
+
try {
|
|
5222
|
+
await copyFile(from, temp, constants3.COPYFILE_FICLONE);
|
|
5223
|
+
await rename(temp, to);
|
|
5224
|
+
} catch (error) {
|
|
5225
|
+
await rm(temp, { force: true });
|
|
5226
|
+
throw error;
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
async function openBlobStore(dir) {
|
|
5230
|
+
const sizes = /* @__PURE__ */ new Map();
|
|
5231
|
+
await mkdir(dir, { recursive: true });
|
|
5232
|
+
for (const shard of await readdir(dir))
|
|
5233
|
+
for (const hash of await readdir(join15(dir, shard)).catch(() => []))
|
|
5234
|
+
sizes.set(hash, (await stat(join15(dir, shard, hash))).size);
|
|
5235
|
+
const pathOf = (hash) => join15(dir, hash.slice(0, 2), hash);
|
|
5236
|
+
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
5237
|
+
return {
|
|
5238
|
+
async put(absPath) {
|
|
5239
|
+
const temp = join15(dir, `incoming-${randomUUID5()}`);
|
|
5240
|
+
await mkdir(dir, { recursive: true });
|
|
5241
|
+
await copyFile(absPath, temp, constants3.COPYFILE_FICLONE);
|
|
5242
|
+
const hash = await hashFile(temp);
|
|
5243
|
+
if (sizes.has(hash)) {
|
|
5244
|
+
await rm(temp, { force: true });
|
|
5245
|
+
return hash;
|
|
5246
|
+
}
|
|
5247
|
+
await mkdir(dirname5(pathOf(hash)), { recursive: true });
|
|
5248
|
+
const size = (await stat(temp)).size;
|
|
5249
|
+
await rename(temp, pathOf(hash));
|
|
5250
|
+
sizes.set(hash, size);
|
|
5251
|
+
total += size;
|
|
5252
|
+
return hash;
|
|
5253
|
+
},
|
|
5254
|
+
read: (hash) => readFile2(pathOf(hash)),
|
|
5255
|
+
writeTo: (hash, absPath) => cloneOrCopy(pathOf(hash), absPath),
|
|
5256
|
+
bytes: () => total,
|
|
5257
|
+
size: (hash) => sizes.get(hash) ?? 0,
|
|
5258
|
+
async prune(keep) {
|
|
5259
|
+
for (const [hash, size] of sizes) {
|
|
5260
|
+
if (keep.has(hash)) continue;
|
|
5261
|
+
await rm(pathOf(hash), { force: true });
|
|
5262
|
+
sizes.delete(hash);
|
|
5263
|
+
total -= size;
|
|
5264
|
+
}
|
|
5265
|
+
}
|
|
5266
|
+
};
|
|
5267
|
+
}
|
|
5268
|
+
|
|
5269
|
+
// src/history/historyId.ts
|
|
5270
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
5271
|
+
import { existsSync as existsSync11, mkdirSync as mkdirSync7, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
|
|
5272
|
+
import { join as join16, resolve as resolve6 } from "path";
|
|
5273
|
+
var ID_PATH = join16(".hyperframes", "history-id");
|
|
5274
|
+
var ID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
5275
|
+
function readId(projectDir) {
|
|
5276
|
+
try {
|
|
5277
|
+
const id = readFileSync15(join16(projectDir, ID_PATH), "utf-8").trim();
|
|
5278
|
+
return ID_SHAPE.test(id) ? id : null;
|
|
5279
|
+
} catch {
|
|
5280
|
+
return null;
|
|
5281
|
+
}
|
|
5282
|
+
}
|
|
5283
|
+
function recordedDir(historyDir) {
|
|
5284
|
+
try {
|
|
5285
|
+
return JSON.parse(readFileSync15(join16(historyDir, "project.json"), "utf-8")).dir ?? null;
|
|
5286
|
+
} catch {
|
|
5287
|
+
return null;
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
function projectHistoryId(projectDir, historyRoot) {
|
|
5291
|
+
const dir = resolve6(projectDir);
|
|
5292
|
+
let id = readId(dir);
|
|
5293
|
+
const was = id && recordedDir(join16(historyRoot, id));
|
|
5294
|
+
if (!id || was && was !== dir && existsSync11(was) && readId(was) === id) {
|
|
5295
|
+
id = randomUUID6();
|
|
5296
|
+
mkdirSync7(join16(dir, ".hyperframes"), { recursive: true });
|
|
5297
|
+
writeFileSync6(join16(dir, ID_PATH), `${id}
|
|
5298
|
+
`);
|
|
5299
|
+
}
|
|
5300
|
+
mkdirSync7(join16(historyRoot, id), { recursive: true });
|
|
5301
|
+
writeFileSync6(join16(historyRoot, id, "project.json"), JSON.stringify({ dir }));
|
|
5302
|
+
return id;
|
|
5303
|
+
}
|
|
5304
|
+
|
|
5305
|
+
// src/history/historyLog.ts
|
|
5306
|
+
import { closeSync as closeSync5, constants as constants4, mkdirSync as mkdirSync8, openSync as openSync5, readFileSync as readFileSync16, writeSync as writeSync2 } from "fs";
|
|
5307
|
+
import { dirname as dirname6 } from "path";
|
|
5308
|
+
var START = "start";
|
|
5309
|
+
function parseRecord(line) {
|
|
5310
|
+
try {
|
|
5311
|
+
return JSON.parse(line);
|
|
5312
|
+
} catch {
|
|
5313
|
+
return null;
|
|
5314
|
+
}
|
|
5315
|
+
}
|
|
5316
|
+
function readLog(file, onUnreadable) {
|
|
5317
|
+
let text2;
|
|
5318
|
+
try {
|
|
5319
|
+
text2 = readFileSync16(file, "utf-8");
|
|
5320
|
+
} catch {
|
|
5321
|
+
return null;
|
|
5322
|
+
}
|
|
5323
|
+
const log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5324
|
+
const lines = text2.split("\n");
|
|
5325
|
+
lines.forEach((line, index) => {
|
|
5326
|
+
const record = line ? parseRecord(line) : null;
|
|
5327
|
+
if (record) applyRecord(log, record);
|
|
5328
|
+
else if (line && index < lines.length - 1) onUnreadable(index + 1);
|
|
5329
|
+
});
|
|
5330
|
+
return log;
|
|
5331
|
+
}
|
|
5332
|
+
function applyRecord(log, record) {
|
|
5333
|
+
if (record.type === "baseline") log.baseline = new Map(Object.entries(record.files));
|
|
5334
|
+
else if (record.type === "entry") log.entries.push(record.entry);
|
|
5335
|
+
else if (record.pinned) log.pins.add(record.id);
|
|
5336
|
+
else log.pins.delete(record.id);
|
|
5337
|
+
}
|
|
5338
|
+
function saveRecord(file, log, record) {
|
|
5339
|
+
let fd;
|
|
5340
|
+
try {
|
|
5341
|
+
fd = openSync5(file, constants4.O_WRONLY | constants4.O_APPEND);
|
|
5342
|
+
} catch (error) {
|
|
5343
|
+
if (error.code !== "ENOENT") throw error;
|
|
5344
|
+
return writeLog(file, log);
|
|
5345
|
+
}
|
|
5346
|
+
try {
|
|
5347
|
+
writeSync2(fd, `${JSON.stringify(record)}
|
|
5348
|
+
`);
|
|
5349
|
+
} finally {
|
|
5350
|
+
closeSync5(fd);
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
function writeLog(file, log) {
|
|
5354
|
+
const records = [
|
|
5355
|
+
{ type: "baseline", files: Object.fromEntries(log.baseline) },
|
|
5356
|
+
...log.entries.map((entry) => ({ type: "entry", entry })),
|
|
5357
|
+
...[...log.pins].map((id) => ({ type: "pin", id, pinned: true }))
|
|
5358
|
+
];
|
|
5359
|
+
mkdirSync8(dirname6(file), { recursive: true });
|
|
5360
|
+
replaceFileAtomically(file, records.map((r) => `${JSON.stringify(r)}
|
|
5361
|
+
`).join(""), 420);
|
|
5362
|
+
}
|
|
5363
|
+
function applyEntry(manifest, entry) {
|
|
5364
|
+
for (const file of entry.files)
|
|
5365
|
+
if (file.after === null) manifest.delete(file.path);
|
|
5366
|
+
else manifest.set(file.path, file.after);
|
|
5367
|
+
}
|
|
5368
|
+
function manifestAt(log, point) {
|
|
5369
|
+
const manifest = new Map(log.baseline);
|
|
5370
|
+
if (point === START) return manifest;
|
|
5371
|
+
for (const entry of log.entries) {
|
|
5372
|
+
applyEntry(manifest, entry);
|
|
5373
|
+
if (entry.id === point) return manifest;
|
|
5374
|
+
}
|
|
5375
|
+
return null;
|
|
5376
|
+
}
|
|
5377
|
+
function undoneIds(entries) {
|
|
5378
|
+
const undone = /* @__PURE__ */ new Set();
|
|
5379
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
5380
|
+
const entry = entries[i];
|
|
5381
|
+
if (entry.undoes && !undone.has(entry.id)) undone.add(entry.undoes);
|
|
5382
|
+
}
|
|
5383
|
+
return undone;
|
|
5384
|
+
}
|
|
5385
|
+
function stepTarget(entries, direction) {
|
|
5386
|
+
const undone = undoneIds(entries);
|
|
5387
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
5388
|
+
for (let i = entries.length - 1; i >= 0; i -= 1) {
|
|
5389
|
+
const entry = entries[i];
|
|
5390
|
+
const isChange = !entry.undoes;
|
|
5391
|
+
if (direction === "back" && isChange && !undone.has(entry.id)) return entry;
|
|
5392
|
+
if (direction !== "forward") continue;
|
|
5393
|
+
if (isChange) return void 0;
|
|
5394
|
+
const target = byId.get(entry.undoes);
|
|
5395
|
+
if (!undone.has(entry.id) && target && !target.undoes) return entry;
|
|
5396
|
+
}
|
|
5397
|
+
return void 0;
|
|
5398
|
+
}
|
|
5399
|
+
function foldOldest(log) {
|
|
5400
|
+
const oldest = log.entries[0];
|
|
5401
|
+
if (!oldest || log.pins.has(oldest.id)) return false;
|
|
5402
|
+
applyEntry(log.baseline, oldest);
|
|
5403
|
+
log.entries.shift();
|
|
5404
|
+
return true;
|
|
5405
|
+
}
|
|
5406
|
+
function referencedHashes(log, current) {
|
|
5407
|
+
const keep = /* @__PURE__ */ new Set([...log.baseline.values(), ...current.values()]);
|
|
5408
|
+
for (const entry of log.entries)
|
|
5409
|
+
for (const file of entry.files) {
|
|
5410
|
+
if (file.before) keep.add(file.before);
|
|
5411
|
+
if (file.after) keep.add(file.after);
|
|
5412
|
+
}
|
|
5413
|
+
return keep;
|
|
5414
|
+
}
|
|
5415
|
+
|
|
5416
|
+
// src/history/projectHistory.ts
|
|
5417
|
+
var OUTSIDE = { kind: "outside", name: "Outside" };
|
|
5418
|
+
var OUTSIDE_LABEL = "Changed outside the app";
|
|
5419
|
+
var RACY_MS = 2e3;
|
|
5420
|
+
var statKey = (file, sweptAt) => sweptAt - Math.max(file.mtimeMs, file.ctimeMs) < RACY_MS ? "" : `${file.size}:${file.mtimeMs}:${file.ctimeMs}`;
|
|
5421
|
+
var Engine = class {
|
|
5422
|
+
constructor(options, projectId, blobs) {
|
|
5423
|
+
this.options = options;
|
|
5424
|
+
this.projectId = projectId;
|
|
5425
|
+
this.blobs = blobs;
|
|
5426
|
+
this.dir = resolve7(options.projectDir);
|
|
5427
|
+
this.home = join17(options.historyRoot, projectId);
|
|
5428
|
+
}
|
|
5429
|
+
options;
|
|
5430
|
+
projectId;
|
|
5431
|
+
blobs;
|
|
5432
|
+
dir;
|
|
5433
|
+
home;
|
|
5434
|
+
log = { baseline: /* @__PURE__ */ new Map(), entries: [], pins: /* @__PURE__ */ new Set() };
|
|
5435
|
+
tracked = /* @__PURE__ */ new Map();
|
|
5436
|
+
windows = [];
|
|
5437
|
+
outside = null;
|
|
5438
|
+
quietTimer;
|
|
5439
|
+
maxTimer;
|
|
5440
|
+
notedTimer = null;
|
|
5441
|
+
listeners = /* @__PURE__ */ new Set();
|
|
5442
|
+
tail = Promise.resolve();
|
|
5443
|
+
get logFile() {
|
|
5444
|
+
return join17(this.home, "log.jsonl");
|
|
5445
|
+
}
|
|
5446
|
+
now() {
|
|
5447
|
+
return (this.options.now ?? Date.now)();
|
|
5448
|
+
}
|
|
5449
|
+
/** One operation at a time, in order: sweeps, windows and undos never interleave. */
|
|
5450
|
+
queue(task) {
|
|
5451
|
+
const run = this.tail.then(task);
|
|
5452
|
+
this.tail = run.catch(() => void 0);
|
|
5453
|
+
return run;
|
|
5454
|
+
}
|
|
5455
|
+
async start() {
|
|
5456
|
+
const log = readLog(
|
|
5457
|
+
this.logFile,
|
|
5458
|
+
(line) => this.options.onError?.(
|
|
5459
|
+
new Error(`History log line ${line} could not be read and was skipped.`)
|
|
5460
|
+
)
|
|
5461
|
+
);
|
|
5462
|
+
if (!log) return this.firstOpen();
|
|
5463
|
+
this.log = log;
|
|
5464
|
+
const cache = this.readStatCache();
|
|
5465
|
+
const last = this.log.entries.at(-1)?.id ?? START;
|
|
5466
|
+
for (const [path, hash] of manifestAt(this.log, last) ?? []) {
|
|
5467
|
+
const cached = cache.get(path);
|
|
5468
|
+
this.tracked.set(path, { hash, stat: cached?.hash === hash ? cached.stat : "" });
|
|
5469
|
+
}
|
|
5470
|
+
await this.sweep();
|
|
5471
|
+
await this.commitOutside();
|
|
5472
|
+
}
|
|
5473
|
+
async firstOpen() {
|
|
5474
|
+
const sweptAt = Date.now();
|
|
5475
|
+
for (const file of listProjectFiles(this.dir))
|
|
5476
|
+
this.tracked.set(file.path, {
|
|
5477
|
+
hash: await this.blobs.put(join17(this.dir, file.path)),
|
|
5478
|
+
stat: statKey(file, sweptAt)
|
|
5479
|
+
});
|
|
5480
|
+
this.log.baseline = this.manifest();
|
|
5481
|
+
writeLog(this.logFile, this.log);
|
|
5482
|
+
this.saveStatCache();
|
|
5483
|
+
}
|
|
5484
|
+
manifest() {
|
|
5485
|
+
return new Map([...this.tracked].map(([path, file]) => [path, file.hash]));
|
|
5486
|
+
}
|
|
5487
|
+
readStatCache() {
|
|
5488
|
+
try {
|
|
5489
|
+
const files = JSON.parse(readFileSync17(join17(this.home, "stat.json"), "utf-8"));
|
|
5490
|
+
return new Map(Object.entries(files));
|
|
5491
|
+
} catch {
|
|
5492
|
+
return /* @__PURE__ */ new Map();
|
|
5493
|
+
}
|
|
5494
|
+
}
|
|
5495
|
+
saveStatCache() {
|
|
5496
|
+
const files = JSON.stringify(Object.fromEntries(this.tracked));
|
|
5497
|
+
mkdirSync9(this.home, { recursive: true });
|
|
5498
|
+
replaceFileAtomically(join17(this.home, "stat.json"), files, 420);
|
|
5499
|
+
}
|
|
5500
|
+
/**
|
|
5501
|
+
* Takes in every write since the last sweep and files each change to its writer. ponytail: always the whole
|
|
5502
|
+
* project (a stat per file, a hash only when the stat moved); per-path sweeps if projects reach tens of thousands.
|
|
5503
|
+
*/
|
|
5504
|
+
async sweep() {
|
|
5505
|
+
if (!existsSync12(this.dir)) return;
|
|
5506
|
+
const sweptAt = Date.now();
|
|
5507
|
+
const seen = listProjectFiles(this.dir);
|
|
5508
|
+
let changed = false;
|
|
5509
|
+
for (const file of seen)
|
|
5510
|
+
changed = await this.observe(file.path, statKey(file, sweptAt)) || changed;
|
|
5511
|
+
const present = new Set(seen.map((file) => file.path));
|
|
5512
|
+
for (const [path, known] of this.tracked) {
|
|
5513
|
+
if (present.has(path)) continue;
|
|
5514
|
+
this.tracked.delete(path);
|
|
5515
|
+
this.record(path, known.hash, null);
|
|
5516
|
+
changed = true;
|
|
5517
|
+
}
|
|
5518
|
+
if (changed) this.saveStatCache();
|
|
5519
|
+
}
|
|
5520
|
+
/** The file's hash once copied in; null when it was removed before the copy (the next sweep records that). */
|
|
5521
|
+
async storeIfPresent(path) {
|
|
5522
|
+
try {
|
|
5523
|
+
return await this.blobs.put(join17(this.dir, path));
|
|
5524
|
+
} catch (error) {
|
|
5525
|
+
if (error.code === "ENOENT") return null;
|
|
5526
|
+
throw error;
|
|
5527
|
+
}
|
|
5528
|
+
}
|
|
5529
|
+
async observe(path, stat2) {
|
|
5530
|
+
const known = this.tracked.get(path) ?? { hash: null, stat: null };
|
|
5531
|
+
if (stat2 && known.stat === stat2) return false;
|
|
5532
|
+
const hash = await this.storeIfPresent(path);
|
|
5533
|
+
if (hash === null) return false;
|
|
5534
|
+
this.tracked.set(path, { hash, stat: stat2 });
|
|
5535
|
+
if (known.hash !== hash) this.record(path, known.hash, hash);
|
|
5536
|
+
return known.hash !== hash || known.stat !== stat2;
|
|
5537
|
+
}
|
|
5538
|
+
record(path, before, after) {
|
|
5539
|
+
const window = this.windows.at(-1);
|
|
5540
|
+
if (window) this.touch(window);
|
|
5541
|
+
const group = window ?? this.outsideGroup();
|
|
5542
|
+
const earlier = group.changes.get(path);
|
|
5543
|
+
const from = earlier ? earlier.before : before;
|
|
5544
|
+
if (from === after) group.changes.delete(path);
|
|
5545
|
+
else group.changes.set(path, { path, before: from, after });
|
|
5546
|
+
}
|
|
5547
|
+
outsideGroup() {
|
|
5548
|
+
const { quietMs = 2e3, maxGroupMs = 3e4 } = this.options;
|
|
5549
|
+
const closeIn = (ms) => {
|
|
5550
|
+
const timer = setTimeout(() => this.background(() => this.commitOutside()), ms);
|
|
5551
|
+
timer.unref?.();
|
|
5552
|
+
return timer;
|
|
5553
|
+
};
|
|
5554
|
+
if (!this.outside) {
|
|
5555
|
+
this.outside = this.newGroup(OUTSIDE, OUTSIDE_LABEL);
|
|
5556
|
+
this.maxTimer = closeIn(maxGroupMs);
|
|
5557
|
+
}
|
|
5558
|
+
clearTimeout(this.quietTimer);
|
|
5559
|
+
this.quietTimer = closeIn(quietMs);
|
|
5560
|
+
return this.outside;
|
|
5561
|
+
}
|
|
5562
|
+
newGroup(who, label) {
|
|
5563
|
+
return { id: randomUUID7(), who, label, startedAt: this.now(), changes: /* @__PURE__ */ new Map() };
|
|
5564
|
+
}
|
|
5565
|
+
async commitOutside() {
|
|
5566
|
+
clearTimeout(this.quietTimer);
|
|
5567
|
+
clearTimeout(this.maxTimer);
|
|
5568
|
+
const group = this.outside;
|
|
5569
|
+
this.outside = null;
|
|
5570
|
+
if (group) await this.commit(group);
|
|
5571
|
+
}
|
|
5572
|
+
async commit(group, extra = {}) {
|
|
5573
|
+
if (!group.changes.size) return null;
|
|
5574
|
+
const { changes, ...rest } = group;
|
|
5575
|
+
const files = [...changes.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
5576
|
+
const entry = { ...rest, endedAt: this.now(), files, ...extra };
|
|
5577
|
+
this.log.entries.push(entry);
|
|
5578
|
+
try {
|
|
5579
|
+
saveRecord(this.logFile, this.log, { type: "entry", entry });
|
|
5580
|
+
} catch (error) {
|
|
5581
|
+
this.log.entries.pop();
|
|
5582
|
+
throw error;
|
|
5583
|
+
}
|
|
5584
|
+
for (const listener of this.listeners) listener(entry);
|
|
5585
|
+
await this.keepWithinBudget();
|
|
5586
|
+
return entry;
|
|
5587
|
+
}
|
|
5588
|
+
/** Stored bytes beyond what the current files need: a project larger than the budget still keeps its history. */
|
|
5589
|
+
historyBytes() {
|
|
5590
|
+
let current = 0;
|
|
5591
|
+
for (const hash of new Set([...this.tracked.values()].map((file) => file.hash)))
|
|
5592
|
+
current += this.blobs.size(hash);
|
|
5593
|
+
return this.blobs.bytes() - current;
|
|
5594
|
+
}
|
|
5595
|
+
async keepWithinBudget() {
|
|
5596
|
+
const budget = this.options.budgetBytes ?? 2 * 1024 ** 3;
|
|
5597
|
+
let folded = false;
|
|
5598
|
+
while (this.historyBytes() > budget && foldOldest(this.log)) {
|
|
5599
|
+
folded = true;
|
|
5600
|
+
await this.blobs.prune(referencedHashes(this.log, this.manifest()));
|
|
5601
|
+
}
|
|
5602
|
+
if (folded) writeLog(this.logFile, this.log);
|
|
5603
|
+
}
|
|
5604
|
+
/** Before an operation: every write so far is filed, and the outside group is closed so it sorts first. */
|
|
5605
|
+
async settle() {
|
|
5606
|
+
await this.sweep();
|
|
5607
|
+
await this.commitOutside();
|
|
5608
|
+
}
|
|
5609
|
+
/** A watcher saw a write: one sweep per burst takes it in (a deleted folder is reported by its name alone). */
|
|
5610
|
+
noteChange(path) {
|
|
5611
|
+
if (this.notedTimer || !affectsProjectSignature(this.dir, resolve7(this.dir, path))) return;
|
|
5612
|
+
this.notedTimer = setTimeout(() => {
|
|
5613
|
+
this.notedTimer = null;
|
|
5614
|
+
this.background(() => this.sweep());
|
|
5615
|
+
}, 20);
|
|
5616
|
+
this.notedTimer.unref?.();
|
|
5617
|
+
}
|
|
5618
|
+
background(task) {
|
|
5619
|
+
this.queue(task).catch((error) => this.options.onError?.(error));
|
|
5620
|
+
}
|
|
5621
|
+
beginWindow(who, label, idleMs) {
|
|
5622
|
+
return this.queue(async () => {
|
|
5623
|
+
await this.sweep();
|
|
5624
|
+
const window = { ...this.newGroup(who, label), idleMs };
|
|
5625
|
+
this.windows.push(window);
|
|
5626
|
+
this.touch(window);
|
|
5627
|
+
return { id: window.id, close: () => this.queue(() => this.sweepAndEnd(window)) };
|
|
5628
|
+
});
|
|
5629
|
+
}
|
|
5630
|
+
/** A window with no write for its idleMs ends, so a close that never comes cannot hold every later write. */
|
|
5631
|
+
touch(window) {
|
|
5632
|
+
clearTimeout(window.idleTimer);
|
|
5633
|
+
if (window.idleMs === void 0 || !Number.isFinite(window.idleMs)) return;
|
|
5634
|
+
window.idleTimer = setTimeout(
|
|
5635
|
+
() => this.background(() => this.sweepAndEnd(window)),
|
|
5636
|
+
window.idleMs
|
|
5637
|
+
);
|
|
5638
|
+
window.idleTimer.unref?.();
|
|
5639
|
+
}
|
|
5640
|
+
async sweepAndEnd(window) {
|
|
5641
|
+
await this.sweep();
|
|
5642
|
+
return this.endWindow(window);
|
|
5643
|
+
}
|
|
5644
|
+
/** Commits an open window once; ending it again returns the entry it became. */
|
|
5645
|
+
async endWindow(window) {
|
|
5646
|
+
if (!this.windows.includes(window)) return window.entry ?? null;
|
|
5647
|
+
clearTimeout(window.idleTimer);
|
|
5648
|
+
this.windows = this.windows.filter((open) => open !== window);
|
|
5649
|
+
window.entry = await this.commit(window);
|
|
5650
|
+
return window.entry;
|
|
5651
|
+
}
|
|
5652
|
+
/** Every pending write, open window and outside group, committed: for flush and close. */
|
|
5653
|
+
async settleAll() {
|
|
5654
|
+
await this.sweep();
|
|
5655
|
+
for (const window of [...this.windows]) await this.endWindow(window);
|
|
5656
|
+
await this.commitOutside();
|
|
5657
|
+
}
|
|
5658
|
+
/** Writes `target` (path to hash, null deletes) as one entry of `who`'s. */
|
|
5659
|
+
async writeAs(who, label, target, extra) {
|
|
5660
|
+
const group = this.newGroup(who, label);
|
|
5661
|
+
this.windows.push(group);
|
|
5662
|
+
try {
|
|
5663
|
+
for (const [path, hash] of target) {
|
|
5664
|
+
if ((this.tracked.get(path)?.hash ?? null) === hash) continue;
|
|
5665
|
+
if (hash === null) await rm2(join17(this.dir, path), { force: true });
|
|
5666
|
+
else await this.blobs.writeTo(hash, join17(this.dir, path));
|
|
5667
|
+
}
|
|
5668
|
+
await this.sweep();
|
|
5669
|
+
} finally {
|
|
5670
|
+
this.windows = this.windows.filter((open) => open !== group);
|
|
5671
|
+
}
|
|
5672
|
+
return this.commit(group, extra);
|
|
5673
|
+
}
|
|
5674
|
+
entry(id) {
|
|
5675
|
+
const entry = this.log.entries.find((candidate) => candidate.id === id);
|
|
5676
|
+
if (!entry) throw new Error("That change is no longer kept in this project's history.");
|
|
5677
|
+
return entry;
|
|
5678
|
+
}
|
|
5679
|
+
undoLabel(entry) {
|
|
5680
|
+
if (!entry.undoes) return `Undid: ${entry.label}`;
|
|
5681
|
+
const original = this.log.entries.find((candidate) => candidate.id === entry.undoes);
|
|
5682
|
+
return `Redid: ${original?.label ?? entry.label}`;
|
|
5683
|
+
}
|
|
5684
|
+
async undoNow(id, who, mode) {
|
|
5685
|
+
const entry = this.entry(id);
|
|
5686
|
+
const changed = entry.files.filter(
|
|
5687
|
+
(file) => (this.tracked.get(file.path)?.hash ?? null) !== file.after
|
|
5688
|
+
);
|
|
5689
|
+
if (changed.length && !mode) return { ok: false, conflict: this.conflict(entry, changed) };
|
|
5690
|
+
if (mode === "back-to-before") {
|
|
5691
|
+
const index = this.log.entries.indexOf(entry);
|
|
5692
|
+
const point = index > 0 ? this.log.entries[index - 1].id : START;
|
|
5693
|
+
return {
|
|
5694
|
+
ok: true,
|
|
5695
|
+
entry: await this.restoreNow(point, who, `Went back to before: ${entry.label}`)
|
|
5696
|
+
};
|
|
5697
|
+
}
|
|
5698
|
+
const target = new Map(entry.files.map((file) => [file.path, file.before]));
|
|
5699
|
+
return {
|
|
5700
|
+
ok: true,
|
|
5701
|
+
entry: await this.writeAs(who, this.undoLabel(entry), target, { undoes: id })
|
|
5702
|
+
};
|
|
5703
|
+
}
|
|
5704
|
+
conflict(entry, changed) {
|
|
5705
|
+
const paths = new Set(changed.map((file) => file.path));
|
|
5706
|
+
const after = this.log.entries.slice(this.log.entries.indexOf(entry) + 1);
|
|
5707
|
+
const newer = after.filter((later) => later.files.some((file) => paths.has(file.path)));
|
|
5708
|
+
return { files: [...paths], newer: newer.map((later) => later.id) };
|
|
5709
|
+
}
|
|
5710
|
+
async restoreNow(point, who, label) {
|
|
5711
|
+
const files = manifestAt(this.log, point);
|
|
5712
|
+
if (!files) throw new Error("That point is no longer kept in this project's history.");
|
|
5713
|
+
const target = new Map(
|
|
5714
|
+
[...this.tracked.keys()].map((path) => [path, null])
|
|
5715
|
+
);
|
|
5716
|
+
for (const [path, hash] of files) target.set(path, hash);
|
|
5717
|
+
return this.writeAs(who, label, target, { restoredTo: point });
|
|
5718
|
+
}
|
|
5719
|
+
pointLabel(point) {
|
|
5720
|
+
return point === START ? "the start" : this.entry(point).label;
|
|
5721
|
+
}
|
|
5722
|
+
api() {
|
|
5723
|
+
return {
|
|
5724
|
+
projectId: this.projectId,
|
|
5725
|
+
beginWindow: (who, label, options = {}) => this.beginWindow(who, label, options.idleMs ?? this.options.maxGroupMs ?? 3e4),
|
|
5726
|
+
noteChange: (path) => this.noteChange(path),
|
|
5727
|
+
list: () => {
|
|
5728
|
+
const undone = undoneIds(this.log.entries);
|
|
5729
|
+
return this.log.entries.map((entry) => ({
|
|
5730
|
+
...entry,
|
|
5731
|
+
pinned: this.log.pins.has(entry.id),
|
|
5732
|
+
undone: undone.has(entry.id)
|
|
5733
|
+
}));
|
|
5734
|
+
},
|
|
5735
|
+
step: (direction, who) => this.queue(async () => {
|
|
5736
|
+
await this.settle();
|
|
5737
|
+
const target = stepTarget(this.log.entries, direction);
|
|
5738
|
+
return target ? this.undoNow(target.id, who) : { ok: true, entry: null };
|
|
5739
|
+
}),
|
|
5740
|
+
undo: (id, { who, mode }) => this.queue(async () => {
|
|
5741
|
+
await this.settle();
|
|
5742
|
+
return this.undoNow(id, who, mode);
|
|
5743
|
+
}),
|
|
5744
|
+
restore: (point, who) => this.queue(async () => {
|
|
5745
|
+
await this.settle();
|
|
5746
|
+
return this.restoreNow(point, who, `Restored: ${this.pointLabel(point)}`);
|
|
5747
|
+
}),
|
|
5748
|
+
peek: (point) => {
|
|
5749
|
+
const files = manifestAt(this.log, point);
|
|
5750
|
+
return files && Object.fromEntries(files);
|
|
5751
|
+
},
|
|
5752
|
+
readBlob: (hash) => this.blobs.read(hash),
|
|
5753
|
+
pin: (id, pinned) => {
|
|
5754
|
+
this.entry(id);
|
|
5755
|
+
if (pinned) this.log.pins.add(id);
|
|
5756
|
+
else this.log.pins.delete(id);
|
|
5757
|
+
saveRecord(this.logFile, this.log, { type: "pin", id, pinned });
|
|
5758
|
+
},
|
|
5759
|
+
onEntry: (listener) => {
|
|
5760
|
+
this.listeners.add(listener);
|
|
5761
|
+
return () => this.listeners.delete(listener);
|
|
5762
|
+
},
|
|
5763
|
+
flush: () => this.queue(() => this.settleAll()),
|
|
5764
|
+
close: async () => {
|
|
5765
|
+
if (this.notedTimer) clearTimeout(this.notedTimer);
|
|
5766
|
+
await this.queue(() => this.settleAll());
|
|
5767
|
+
}
|
|
5768
|
+
};
|
|
5769
|
+
}
|
|
5770
|
+
};
|
|
5771
|
+
async function openProjectHistory(options) {
|
|
5772
|
+
const projectId = projectHistoryId(options.projectDir, options.historyRoot);
|
|
5773
|
+
const blobs = await openBlobStore(join17(options.historyRoot, projectId, "blobs"));
|
|
5774
|
+
const engine = new Engine(options, projectId, blobs);
|
|
5775
|
+
await engine.queue(() => engine.start());
|
|
5776
|
+
return engine.api();
|
|
5777
|
+
}
|
|
5778
|
+
|
|
5095
5779
|
// src/helpers/duplicateElement.ts
|
|
5096
5780
|
import { ensureHfIds as ensureHfIds3 } from "@hyperframes/parsers/hf-ids";
|
|
5097
5781
|
function duplicateElementInHtml(source, target, newId, at) {
|
|
@@ -5211,6 +5895,7 @@ function updateBackgroundRemovalProgress(state, event) {
|
|
|
5211
5895
|
state.avgMsPerFrame = event.avgMsPerFrame;
|
|
5212
5896
|
}
|
|
5213
5897
|
export {
|
|
5898
|
+
START as HISTORY_START,
|
|
5214
5899
|
MIME_TYPES,
|
|
5215
5900
|
PREVIEW_BUNDLE_OPTIONS,
|
|
5216
5901
|
STUDIO_MANUAL_EDITS_PATH,
|
|
@@ -5234,9 +5919,11 @@ export {
|
|
|
5234
5919
|
getMimeType,
|
|
5235
5920
|
identifyFileWrite,
|
|
5236
5921
|
isSafePath,
|
|
5922
|
+
openProjectHistory,
|
|
5237
5923
|
parseSourceDocument,
|
|
5238
5924
|
patchElementInHtml,
|
|
5239
5925
|
removeElementFromHtml,
|
|
5926
|
+
requestSubPath,
|
|
5240
5927
|
settledFileTag,
|
|
5241
5928
|
splitElementInHtml,
|
|
5242
5929
|
thumbnailDeviceScaleFactor,
|