@hyperframes/studio-server 0.7.56 → 0.7.58

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
@@ -896,6 +896,21 @@ function resolveProjectFile(c, adapter, opts) {
896
896
  function resolveFileMutationContext(c, adapter, operation) {
897
897
  return resolveProjectPath(c, adapter, (id) => `/projects/${id}/file-mutations/${operation}/`);
898
898
  }
899
+ function isElementPatchRequest(value) {
900
+ if (typeof value !== "object" || value === null) return false;
901
+ if (!("target" in value) || typeof value.target !== "object" || value.target === null) {
902
+ return false;
903
+ }
904
+ return "operations" in value && Array.isArray(value.operations) && value.operations.length > 0;
905
+ }
906
+ function isElementPatchBatchRequest(value) {
907
+ return typeof value === "object" && value !== null && "sourceFile" in value && typeof value.sourceFile === "string" && value.sourceFile.length > 0 && "patches" in value && Array.isArray(value.patches) && value.patches.length > 0 && value.patches.every(isElementPatchRequest);
908
+ }
909
+ function findUnsafeElementPatchBatchValues(batches) {
910
+ return batches.flatMap(
911
+ (batch) => batch.patches.flatMap((patch) => findUnsafeDomPatchValues(patch))
912
+ );
913
+ }
899
914
  function foldElementPatches(originalContent, patches) {
900
915
  let content = originalContent;
901
916
  const matched = [];
@@ -906,6 +921,90 @@ function foldElementPatches(originalContent, patches) {
906
921
  }
907
922
  return { content, matched };
908
923
  }
924
+ function commitElementPatchBatches(projectDir, batches, writeFile = writeFileSync4) {
925
+ const resolvedPaths = /* @__PURE__ */ new Set();
926
+ const prepared = [];
927
+ for (const batch of batches) {
928
+ const absPath = resolveWithinProject(projectDir, batch.sourceFile);
929
+ if (!absPath) return { error: "forbidden", sourceFile: batch.sourceFile };
930
+ if (resolvedPaths.has(absPath)) return { error: "duplicate", sourceFile: batch.sourceFile };
931
+ resolvedPaths.add(absPath);
932
+ let before;
933
+ try {
934
+ before = readFileSync3(absPath, "utf-8");
935
+ } catch {
936
+ return { error: "not-found", sourceFile: batch.sourceFile };
937
+ }
938
+ const folded = foldElementPatches(before, batch.patches);
939
+ prepared.push({
940
+ sourceFile: batch.sourceFile,
941
+ absPath,
942
+ before,
943
+ matched: folded.matched,
944
+ after: folded.content
945
+ });
946
+ }
947
+ const durable = prepared.every((file) => file.matched.every(Boolean));
948
+ if (!durable) {
949
+ return {
950
+ durable: false,
951
+ files: prepared.map((file) => ({
952
+ sourceFile: file.sourceFile,
953
+ changed: false,
954
+ matched: file.matched,
955
+ before: file.before,
956
+ after: file.before
957
+ }))
958
+ };
959
+ }
960
+ const files = [];
961
+ const attemptedWrites = [];
962
+ try {
963
+ for (const file of prepared) {
964
+ if (file.after === file.before) {
965
+ files.push({
966
+ sourceFile: file.sourceFile,
967
+ changed: false,
968
+ matched: file.matched,
969
+ before: file.before,
970
+ after: file.before
971
+ });
972
+ continue;
973
+ }
974
+ const backup = snapshotBeforeWrite(projectDir, file.absPath);
975
+ if (backup.error) {
976
+ throw new Error(`Failed to create backup for ${file.sourceFile}: ${backup.error}`);
977
+ }
978
+ attemptedWrites.push(file);
979
+ writeFile(file.absPath, file.after, "utf-8");
980
+ files.push({
981
+ sourceFile: file.sourceFile,
982
+ changed: true,
983
+ matched: file.matched,
984
+ before: file.before,
985
+ after: file.after,
986
+ backupPath: backupPathForResponse(projectDir, backup.backupPath)
987
+ });
988
+ }
989
+ } catch (error) {
990
+ const rollbackErrors = [];
991
+ for (const file of attemptedWrites.reverse()) {
992
+ try {
993
+ writeFile(file.absPath, file.before, "utf-8");
994
+ } catch (rollbackError) {
995
+ rollbackErrors.push(rollbackError);
996
+ }
997
+ }
998
+ if (rollbackErrors.length > 0) {
999
+ throw new AggregateError(
1000
+ [error, ...rollbackErrors],
1001
+ "Element patch batch failed and rollback did not complete"
1002
+ );
1003
+ }
1004
+ throw error;
1005
+ }
1006
+ return { durable: true, files };
1007
+ }
909
1008
  function writeIfChanged(c, projectDir, filePath, absPath, original, next) {
910
1009
  if (next === original) {
911
1010
  return c.json({ ok: true, changed: false, content: original, path: filePath });
@@ -931,6 +1030,11 @@ function rejectUnsafeMutationValues(c, unsafeFields) {
931
1030
  400
932
1031
  );
933
1032
  }
1033
+ function elementPatchBatchCommitErrorResponse(c, error, sourceFile) {
1034
+ if (error === "not-found") return c.json({ error, sourceFile }, 404);
1035
+ if (error === "forbidden") return c.json({ error, sourceFile }, 403);
1036
+ return c.json({ error: "duplicate source file", sourceFile }, 400);
1037
+ }
934
1038
  async function parseMutationBody(c) {
935
1039
  const body = await c.req.json().catch(() => null);
936
1040
  if (!body?.target) {
@@ -1233,7 +1337,8 @@ function validateGsapMutationRequest(c, body) {
1233
1337
  return null;
1234
1338
  }
1235
1339
  async function prepareGsapMutationScript(c, res, firstMutation) {
1236
- let html = readFileSync3(res.absPath, "utf-8");
1340
+ const beforeHtml = readFileSync3(res.absPath, "utf-8");
1341
+ let html = beforeHtml;
1237
1342
  let block = extractGsapScriptBlock(html);
1238
1343
  if (!block && (firstMutation.type === "add" || firstMutation.type === "add-with-keyframes")) {
1239
1344
  const compId = html.match(/data-composition-id="([^"]+)"/)?.[1] ?? "main";
@@ -1265,14 +1370,14 @@ ${bootstrap}`;
1265
1370
  });
1266
1371
  }
1267
1372
  if (!block) return c.json({ error: "no GSAP script found in file" }, 400);
1268
- return { html, block };
1373
+ return { html, beforeHtml, block };
1269
1374
  }
1270
1375
  async function applyGsapMutations(c, res, mutations) {
1271
1376
  const firstMutation = mutations[0];
1272
1377
  if (!firstMutation) return c.json({ error: "mutations array required" }, 400);
1273
1378
  const prepared = await prepareGsapMutationScript(c, res, firstMutation);
1274
1379
  if (prepared instanceof Response) return prepared;
1275
- const { html, block } = prepared;
1380
+ const { html, beforeHtml, block } = prepared;
1276
1381
  const initialScript = block.scriptText;
1277
1382
  const skippedSelectors = /* @__PURE__ */ new Set();
1278
1383
  const respond = (data, status) => status ? c.json(data, status) : c.json(data);
@@ -1292,6 +1397,9 @@ async function applyGsapMutations(c, res, mutations) {
1292
1397
  const changed = block.scriptText !== initialScript;
1293
1398
  const newHtml = changed ? block.replaceScript(block.scriptText) : html;
1294
1399
  let backupPath = null;
1400
+ if (readFileSync3(res.absPath, "utf-8") !== beforeHtml) {
1401
+ return c.json({ error: "file changed during GSAP mutation", conflict: true }, 409);
1402
+ }
1295
1403
  if (changed) {
1296
1404
  const backup = snapshotBeforeWrite(res.project.dir, res.absPath);
1297
1405
  if (backup.error) console.warn(`Failed to create backup for ${res.filePath}: ${backup.error}`);
@@ -1303,7 +1411,7 @@ async function applyGsapMutations(c, res, mutations) {
1303
1411
  changed,
1304
1412
  mutated: changed,
1305
1413
  parsed: parseGsapScriptAcorn(block.scriptText),
1306
- before: html,
1414
+ before: beforeHtml,
1307
1415
  after: newHtml,
1308
1416
  scriptText: block.scriptText,
1309
1417
  path: res.filePath,
@@ -2112,45 +2220,46 @@ function registerFileRoutes(api, adapter) {
2112
2220
  backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
2113
2221
  });
2114
2222
  });
2223
+ api.post("/projects/:id/file-mutations/patch-element-batches", async (c) => {
2224
+ const project = await adapter.resolveProject(c.req.param("id"));
2225
+ if (!project) return c.json({ error: "not found" }, 404);
2226
+ const body = await c.req.json().catch(() => null);
2227
+ if (typeof body !== "object" || body === null || !("batches" in body) || !Array.isArray(body.batches) || body.batches.length === 0 || !body.batches.every(isElementPatchBatchRequest)) {
2228
+ return c.json({ error: "batches with sourceFile and patches required" }, 400);
2229
+ }
2230
+ const unsafeFields = findUnsafeElementPatchBatchValues(body.batches);
2231
+ if (unsafeFields.length > 0) return rejectUnsafeMutationValues(c, unsafeFields);
2232
+ const result = commitElementPatchBatches(project.dir, body.batches);
2233
+ if ("error" in result) {
2234
+ return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
2235
+ }
2236
+ return c.json(result);
2237
+ });
2115
2238
  api.post("/projects/:id/file-mutations/patch-elements-batch/*", async (c) => {
2116
2239
  const ctx = await resolveFileMutationContext(c, adapter, "patch-elements-batch");
2117
2240
  if ("error" in ctx) return ctx.error;
2118
2241
  const body = await c.req.json().catch(() => null);
2119
- if (!body || !Array.isArray(body.patches) || body.patches.length === 0 || body.patches.some(
2120
- (patch) => !patch?.target || !Array.isArray(patch.operations) || patch.operations.length === 0
2121
- )) {
2242
+ if (!body || !Array.isArray(body.patches) || body.patches.length === 0 || !body.patches.every(isElementPatchRequest)) {
2122
2243
  return c.json({ error: "patches with target and operations required" }, 400);
2123
2244
  }
2124
- const unsafeFields = body.patches.flatMap((patch) => findUnsafeDomPatchValues(patch));
2245
+ const batch = { sourceFile: ctx.filePath, patches: body.patches };
2246
+ const unsafeFields = findUnsafeElementPatchBatchValues([batch]);
2125
2247
  if (unsafeFields.length > 0) {
2126
2248
  return rejectUnsafeMutationValues(c, unsafeFields);
2127
2249
  }
2128
- let originalContent;
2129
- try {
2130
- originalContent = readFileSync3(ctx.absPath, "utf-8");
2131
- } catch {
2132
- return c.json({ error: "not found" }, 404);
2133
- }
2134
- const result = foldElementPatches(originalContent, body.patches);
2135
- if (result.content === originalContent) {
2136
- return c.json({
2137
- ok: true,
2138
- changed: false,
2139
- matched: result.matched,
2140
- content: originalContent,
2141
- path: ctx.filePath
2142
- });
2250
+ const result = commitElementPatchBatches(ctx.project.dir, [batch]);
2251
+ if ("error" in result) {
2252
+ return elementPatchBatchCommitErrorResponse(c, result.error, result.sourceFile);
2143
2253
  }
2144
- const backup = snapshotBeforeWrite(ctx.project.dir, ctx.absPath);
2145
- if (backup.error) console.warn(`Failed to create backup for ${ctx.filePath}: ${backup.error}`);
2146
- writeFileSync4(ctx.absPath, result.content, "utf-8");
2254
+ const file = result.files[0];
2255
+ if (!file) return c.json({ error: "empty element patch result" }, 500);
2147
2256
  return c.json({
2148
2257
  ok: true,
2149
- changed: true,
2150
- matched: result.matched,
2151
- content: result.content,
2152
- path: ctx.filePath,
2153
- backupPath: backupPathForResponse(ctx.project.dir, backup.backupPath)
2258
+ changed: file.changed,
2259
+ matched: file.matched,
2260
+ content: file.after,
2261
+ path: file.sourceFile,
2262
+ backupPath: file.backupPath
2154
2263
  });
2155
2264
  });
2156
2265
  api.post("/projects/:id/file-mutations/wrap-elements/*", async (c) => {
@@ -2328,6 +2437,11 @@ function registerFileRoutes(api, adapter) {
2328
2437
  const parsed = parseGsapScriptAcorn(block.scriptText);
2329
2438
  return c.json(parsed);
2330
2439
  });
2440
+ api.get("/projects/:id/gsap-mutation-capabilities", async (c) => {
2441
+ const project = await adapter.resolveProject(c.req.param("id"));
2442
+ if (!project) return c.json({ error: "not found" }, 404);
2443
+ return c.json({ atomicOwnershipPairs: true });
2444
+ });
2331
2445
  api.post("/projects/:id/gsap-mutations/*", async (c) => {
2332
2446
  const res = await resolveProjectPath(c, adapter, (id) => `/projects/${id}/gsap-mutations/`, {
2333
2447
  mustExist: true
@@ -2357,6 +2471,25 @@ function registerFileRoutes(api, adapter) {
2357
2471
  }
2358
2472
  return applyGsapMutations(c, res, body.mutations);
2359
2473
  });
2474
+ api.post("/projects/:id/gsap-mutation-rollback/*", async (c) => {
2475
+ const res = await resolveProjectPath(
2476
+ c,
2477
+ adapter,
2478
+ (id) => `/projects/${id}/gsap-mutation-rollback/`,
2479
+ { mustExist: true }
2480
+ );
2481
+ if ("error" in res) return res.error;
2482
+ const body = await c.req.json().catch(() => null);
2483
+ if (!body || typeof body.expected !== "string" || typeof body.restore !== "string") {
2484
+ return c.json({ error: "expected and restore contents required" }, 400);
2485
+ }
2486
+ const current = readFileSync3(res.absPath, "utf-8");
2487
+ if (current !== body.expected) {
2488
+ return c.json({ ok: true, restored: false, conflict: true });
2489
+ }
2490
+ writeFileSync4(res.absPath, body.restore, "utf-8");
2491
+ return c.json({ ok: true, restored: true, conflict: false });
2492
+ });
2360
2493
  }
2361
2494
 
2362
2495
  // src/routes/preview.ts