@hyperframes/studio-server 0.8.69 → 0.8.71

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.d.ts CHANGED
@@ -80,6 +80,13 @@ interface AppliedFileMutation {
80
80
  /** Applies prepared HTML mutations with the same journal and receipt semantics as Studio. */
81
81
  declare function applyFileMutations(projectDir: string, mutations: readonly FileMutationInput[], requestToken?: string, writeFile?: (path: string, content: string, encoding: "utf-8") => void): AppliedFileMutation[];
82
82
 
83
+ /** The bundler options every adapter's `bundle()` uses. This route serves project files under a
84
+ * `<base href>`, so assets keep their URLs: inlined base64 multiplies the document per reference. */
85
+ declare const PREVIEW_BUNDLE_OPTIONS: {
86
+ readonly runtime: "placeholder";
87
+ readonly inlineAssets: false;
88
+ };
89
+
83
90
  declare const MIME_TYPES: Record<string, string>;
84
91
  declare function getMimeType(path: string): string;
85
92
 
@@ -90,6 +97,15 @@ interface FileWriteReceipt {
90
97
  }
91
98
  /** Strong content version used as both the JSON version and HTTP ETag. */
92
99
  declare function fileContentVersion(content: string | Uint8Array): string;
100
+ /** A validator from a file's inode, change time and size, or null while the change is under three
101
+ * seconds old. Change time, not mtime: copy tools (`cp -p`, rsync, robocopy) set mtime back but not
102
+ * ctime, which Node reads from NTFS ChangeTime on Windows. A same-size rewrite within one tick keeps
103
+ * both, so only a settled file is tagged. */
104
+ declare function settledFileTag(stat: {
105
+ ino: number;
106
+ ctimeMs: number;
107
+ size: number;
108
+ }, now?: number): string | null;
93
109
  /**
94
110
  * Attach one API write's identity to the watcher echo for its exact bytes.
95
111
  *
@@ -167,4 +183,4 @@ type BackgroundRemovalRender = (options: {
167
183
  }>;
168
184
  declare function createBackgroundRemovalJob(opts: BackgroundRemovalJobOptions, render: BackgroundRemovalRender): MediaProcessingJobState;
169
185
 
170
- export { type AppliedFileMutation, type BackgroundRemovalRender, type DuplicateElementResult, type FileMutationInput, type FileWriteReceipt, MIME_TYPES, MediaProcessingJobState, SourceMutationTarget, StudioApiAdapter, type ThumbnailOutputDimensions, affectsProjectSignature, applyFileMutations, buildSubCompositionHtml, compositionsAffectedBy, consumeFileWriteReceipt, createBackgroundRemovalJob, createProjectSignature, createStudioApi, duplicateElementInHtml, fileContentVersion, getMimeType, identifyFileWrite, thumbnailDeviceScaleFactor, walkDir };
186
+ export { type AppliedFileMutation, type BackgroundRemovalRender, type DuplicateElementResult, type FileMutationInput, type FileWriteReceipt, MIME_TYPES, MediaProcessingJobState, PREVIEW_BUNDLE_OPTIONS, SourceMutationTarget, StudioApiAdapter, type ThumbnailOutputDimensions, affectsProjectSignature, applyFileMutations, buildSubCompositionHtml, compositionsAffectedBy, consumeFileWriteReceipt, createBackgroundRemovalJob, createProjectSignature, createStudioApi, duplicateElementInHtml, fileContentVersion, getMimeType, identifyFileWrite, settledFileTag, thumbnailDeviceScaleFactor, walkDir };
package/dist/index.js CHANGED
@@ -302,8 +302,10 @@ import { bodyLimit } from "hono/body-limit";
302
302
  import {
303
303
  closeSync,
304
304
  existsSync as existsSync3,
305
+ lstatSync as lstatSync3,
305
306
  openSync,
306
307
  readFileSync as readFileSync5,
308
+ readlinkSync,
307
309
  writeFileSync as writeFileSync4,
308
310
  writeSync,
309
311
  mkdirSync as mkdirSync3,
@@ -620,6 +622,10 @@ var receipts = /* @__PURE__ */ new Map();
620
622
  function fileContentVersion(content) {
621
623
  return `"sha256:${createHash2("sha256").update(content).digest("hex")}"`;
622
624
  }
625
+ function settledFileTag(stat, now = Date.now()) {
626
+ if (now - stat.ctimeMs < 3e3) return null;
627
+ return [stat.ino, stat.ctimeMs, stat.size].map((n) => n.toString(36)).join("-");
628
+ }
623
629
  function createWriteToken(requestToken) {
624
630
  const token = requestToken?.trim();
625
631
  return token && token.length <= 200 ? token : randomUUID2();
@@ -1016,6 +1022,24 @@ function resolveGsapWriter(env) {
1016
1022
  async function loadGsapParser() {
1017
1023
  return import("@hyperframes/parsers/gsap-parser-recast");
1018
1024
  }
1025
+ function isDanglingSymlinkInProject(projectDir, lexicalPath) {
1026
+ if (!isSafePath(projectDir, dirname3(lexicalPath))) return false;
1027
+ let stats;
1028
+ try {
1029
+ stats = lstatSync3(lexicalPath);
1030
+ } catch {
1031
+ return false;
1032
+ }
1033
+ if (!stats.isSymbolicLink()) return false;
1034
+ const target = resolve3(dirname3(lexicalPath), readlinkSync(lexicalPath));
1035
+ if (!isSafePath(projectDir, target)) return false;
1036
+ try {
1037
+ statSync3(lexicalPath);
1038
+ return false;
1039
+ } catch {
1040
+ return true;
1041
+ }
1042
+ }
1019
1043
  async function resolveProjectPath(c, adapter, pathPrefix, opts) {
1020
1044
  const id = c.req.param("id");
1021
1045
  const project = await adapter.resolveProject(id);
@@ -1033,6 +1057,9 @@ async function resolveProjectPath(c, adapter, pathPrefix, opts) {
1033
1057
  }
1034
1058
  const absPath = resolveWithinProject(project.dir, filePath);
1035
1059
  if (!absPath) {
1060
+ if (isDanglingSymlinkInProject(project.dir, resolve3(project.dir, filePath))) {
1061
+ return { error: c.json({ error: "not found", why: "dangling_symlink" }, 404) };
1062
+ }
1036
1063
  return { error: c.json({ error: "forbidden", why: "outside_project" }, 403) };
1037
1064
  }
1038
1065
  if (opts?.mustExist && !existsSync3(absPath)) {
@@ -2406,21 +2433,31 @@ function registerFileRoutes(api, adapter) {
2406
2433
  api.get("/projects/:id/files/*", async (c) => {
2407
2434
  const res = await resolveProjectFile(c, adapter);
2408
2435
  if ("error" in res) return res.error;
2409
- if (!existsSync3(res.absPath)) {
2436
+ let fd;
2437
+ try {
2438
+ fd = openSync(res.absPath, "r");
2439
+ } catch {
2410
2440
  if (c.req.query("optional") === "1") {
2411
2441
  return c.json({ filename: res.filePath, content: "", missing: true });
2412
2442
  }
2413
2443
  return c.json({ error: "not found" }, 404);
2414
2444
  }
2415
- const content = readFileSync5(res.absPath);
2416
- const version = fileContentVersion(content);
2417
- c.header("ETag", version);
2418
- return c.json({
2419
- filename: res.filePath,
2420
- content: content.toString("utf-8"),
2421
- version,
2422
- missing: false
2423
- });
2445
+ try {
2446
+ if (!fstatSync(fd).isFile()) {
2447
+ return c.json({ error: "not found", why: "not_a_file" }, 404);
2448
+ }
2449
+ const content = readFileSync5(fd);
2450
+ const version = fileContentVersion(content);
2451
+ c.header("ETag", version);
2452
+ return c.json({
2453
+ filename: res.filePath,
2454
+ content: content.toString("utf-8"),
2455
+ version,
2456
+ missing: false
2457
+ });
2458
+ } finally {
2459
+ closeSync(fd);
2460
+ }
2424
2461
  });
2425
2462
  api.put("/projects/:id/files/*", async (c) => {
2426
2463
  const res = await resolveProjectFile(c, adapter);
@@ -3182,7 +3219,10 @@ import { createReadStream, existsSync as existsSync5, readFileSync as readFileSy
3182
3219
  import { Readable } from "stream";
3183
3220
  import { join as join8, resolve as resolve4 } from "path";
3184
3221
  import { createHash as createHash3 } from "crypto";
3185
- import { injectScriptsIntoHtml, stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2 } from "@hyperframes/core/compiler";
3222
+ import {
3223
+ injectScriptsIntoHtml,
3224
+ stripEmbeddedRuntimeScripts as stripEmbeddedRuntimeScripts2
3225
+ } from "@hyperframes/core/compiler";
3186
3226
  import { isWithinProjectRoot } from "@hyperframes/parsers/asset-resolution";
3187
3227
 
3188
3228
  // src/helpers/subComposition.ts
@@ -3627,6 +3667,10 @@ function resolveProjectMainHtml(projectDir, projectId) {
3627
3667
  }
3628
3668
  return null;
3629
3669
  }
3670
+ var PREVIEW_BUNDLE_OPTIONS = {
3671
+ runtime: "placeholder",
3672
+ inlineAssets: false
3673
+ };
3630
3674
  function registerPreviewRoutes(api, adapter) {
3631
3675
  const previewCacheHeaders = (etag) => ({
3632
3676
  "Cache-Control": "private, no-cache",
@@ -3789,12 +3833,10 @@ ${runtimeTag}`;
3789
3833
  return c.text("media proxy variant does not match asset", 422);
3790
3834
  }
3791
3835
  }
3792
- const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}${proxyEtagSalt(proxyVariant)}"`;
3793
- const cacheHeaders = isText ? { "Cache-Control": "no-store" } : {
3794
- "Cache-Control": "private, no-cache",
3795
- ETag: etag
3796
- };
3797
- if (!isText) {
3836
+ const tag = settledFileTag(stat);
3837
+ const etag = tag && `"${tag}${proxyEtagSalt(proxyVariant)}"`;
3838
+ const cacheHeaders = isText ? { "Cache-Control": "no-store" } : { "Cache-Control": "private, no-cache", ...etag && { ETag: etag } };
3839
+ if (!isText && etag) {
3798
3840
  const ifNoneMatch = c.req.header("If-None-Match");
3799
3841
  if (ifNoneMatch === etag) {
3800
3842
  return new Response(null, { status: 304, headers: cacheHeaders });
@@ -5170,6 +5212,7 @@ function updateBackgroundRemovalProgress(state, event) {
5170
5212
  }
5171
5213
  export {
5172
5214
  MIME_TYPES,
5215
+ PREVIEW_BUNDLE_OPTIONS,
5173
5216
  STUDIO_MANUAL_EDITS_PATH,
5174
5217
  STUDIO_MOTION_PATH,
5175
5218
  affectsProjectSignature,
@@ -5194,6 +5237,7 @@ export {
5194
5237
  parseSourceDocument,
5195
5238
  patchElementInHtml,
5196
5239
  removeElementFromHtml,
5240
+ settledFileTag,
5197
5241
  splitElementInHtml,
5198
5242
  thumbnailDeviceScaleFactor,
5199
5243
  walkDir