@hyperframes/share 0.9.28-alpha.0 → 0.9.28-alpha.1

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.
@@ -0,0 +1,3 @@
1
+ /** Explicit, project-relative exclusions. This only removes files; it cannot override safety exclusions.
2
+ * @param {string} root @returns {Promise<(path: string) => boolean>} */
3
+ export function readSourceIgnore(root: string): Promise<(path: string) => boolean>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyperframes/share",
3
- "version": "0.9.28-alpha.0",
3
+ "version": "0.9.28-alpha.1",
4
4
  "description": "Shared review identity, version upload and sharing state for HyperFrames hosts",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
  // A project's files as change detection sees them: every file the film can use (isOutsideFilm), keyed by mtime and
3
3
  // size, except html, keyed by content without the data-hf-id stamps. A run's change set and the watcher both read it.
4
+ import { readSourceIgnore } from "./sourceIgnore.mjs";
4
5
  import fs from "node:fs/promises";
5
6
  import { constants } from "node:fs";
6
7
  import path from "node:path";
@@ -60,6 +61,7 @@ async function entryKind(entry, full, followSymlinks) {
60
61
  export async function snapshotProject(dir, { byContent = false, followSymlinks = false } = {}) {
61
62
  /** @type {Snapshot} */
62
63
  const result = new Map();
64
+ const ignored = await readSourceIgnore(dir);
63
65
  /** @param {string} current @param {Set<string>} ancestors */
64
66
  async function walk(current, ancestors) {
65
67
  const real = await fs.realpath(current).catch(() => null);
@@ -74,7 +76,7 @@ export async function snapshotProject(dir, { byContent = false, followSymlinks =
74
76
  }
75
77
  for (const entry of entries) {
76
78
  const full = path.join(current, entry.name);
77
- if (isOutsideFilm(path.relative(dir, full))) continue;
79
+ if (excludedFromSnapshot(dir, full, ignored)) continue;
78
80
  const kind = await entryKind(entry, full, followSymlinks);
79
81
  if (kind?.isDirectory()) await walk(full, parents);
80
82
  else if (kind?.isFile()) {
@@ -100,11 +102,12 @@ export function snapshotFingerprint(snapshot) {
100
102
  export async function snapshotFiles(dir, files) {
101
103
  /** @type {Map<string, unknown>} */
102
104
  const result = new Map();
105
+ const ignored = await readSourceIgnore(dir);
103
106
  for (const file of files) {
104
107
  const full = path.join(dir, file);
105
108
  const stat = await fs.lstat(full).catch(() => null);
106
109
  if (stat?.isDirectory()) return null;
107
- const holds = stat?.isFile() && !isOutsideFilm(path.relative(dir, full));
110
+ const holds = stat?.isFile() && !excludedFromSnapshot(dir, full, ignored);
108
111
  result.set(file, holds ? await snapshotKey(full, false) : undefined);
109
112
  }
110
113
  return result;
@@ -125,3 +128,9 @@ export function diffChangedFiles(before, after) {
125
128
  /** @param {string} projectPath @param {{ byContent?: boolean }} [options] */
126
129
  export const projectFingerprint = async (projectPath, options) =>
127
130
  snapshotFingerprint(await snapshotProject(projectPath, { ...options, followSymlinks: true }));
131
+
132
+ /** @param {string} dir @param {string} full @param {(path: string) => boolean} ignored */
133
+ function excludedFromSnapshot(dir, full, ignored) {
134
+ const relative = path.relative(dir, full);
135
+ return isOutsideFilm(relative) || ignored(relative.split(path.sep).join("/"));
136
+ }
@@ -705,9 +705,17 @@ async function sendVersion(r, report, source, id, state) {
705
705
  const done = await watch(r, id, uploaded, size, report, clock);
706
706
  return finishRun(r, state, pending, id, done, first);
707
707
  } catch (error) {
708
- if (spent(error, state.pending)) await writeState(r.projectPath, { ...state, pending: null });
709
- console.error("[review] version failed:", error);
710
- return said(error);
708
+ const failure = said(error);
709
+ await writeState(r.projectPath, {
710
+ ...state,
711
+ pending: spent(error, state.pending) ? null : state.pending,
712
+ failure: {
713
+ ...failure,
714
+ projectId: id,
715
+ exportKey: await failedExportKey(r.projectPath, pending),
716
+ },
717
+ });
718
+ return failure;
711
719
  }
712
720
  }
713
721
 
@@ -773,7 +781,6 @@ export async function addVersion(request) {
773
781
  if (kept) failures.set(key, { ...failure, exportKey: exportKeyOf(r.projectPath) });
774
782
  return failure?.sentence ?? null;
775
783
  } catch (error) {
776
- console.error("[review] version failed:", error);
777
784
  const sentence =
778
785
  error instanceof SharingStateError ? error.message : "Couldn't add this version. Try again.";
779
786
  failures.set(key, {
@@ -14,7 +14,8 @@ const SAID = {
14
14
  export_daily_limit: (error) =>
15
15
  `You've added the most versions for today. Try again ${laterIn(error.retryAfterMs)}.`,
16
16
  content_rejected: "This video can't be shared.",
17
- export_blocked: "Adding videos to review pages isn't available for this account.",
17
+ export_blocked:
18
+ "Sharing new videos is blocked because of recent content moderation strikes in this workspace. Rendering again will not lift the block. For appeal instructions, visit https://help.heygen.com/en/articles/7842567-the-ethical-use-of-synthetic-media",
18
19
  feature_unavailable: "Review pages aren't available for your account yet.",
19
20
  permission_denied: "This account can't add videos to this review page yet.",
20
21
  upload_expired: "The upload took too long. Try again.",
@@ -0,0 +1,69 @@
1
+ // @ts-check
2
+ import { constants } from "node:fs";
3
+ import { lstat, open } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ /** Explicit, project-relative exclusions. This only removes files; it cannot override safety exclusions.
7
+ * @param {string} root @returns {Promise<(path: string) => boolean>} */
8
+ export async function readSourceIgnore(root) {
9
+ const file = join(root, ".hyperframesignore");
10
+ const invalid = () =>
11
+ new Error(".hyperframesignore must be a regular file inside the project, smaller than 64 KiB.");
12
+ const handle = await open(
13
+ file,
14
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0),
15
+ ).catch(async (/** @type {NodeJS.ErrnoException} */ error) => {
16
+ if (error.code !== "ENOENT") throw invalid();
17
+ // An absent file means no exclusions; a dangling symlink is invalid.
18
+ const named = await lstat(file).catch((/** @type {NodeJS.ErrnoException} */ missing) => {
19
+ if (missing.code === "ENOENT") return null;
20
+ throw invalid();
21
+ });
22
+ if (named) throw invalid();
23
+ return null;
24
+ });
25
+ if (!handle) return () => false;
26
+ let text;
27
+ try {
28
+ // Validate the opened descriptor before reading. lstat additionally rejects links on
29
+ // platforms without O_NOFOLLOW; it never authorizes a later open of this path.
30
+ const [stat, named] = await Promise.all([handle.stat(), lstat(file)]);
31
+ if (
32
+ !stat.isFile() ||
33
+ stat.size >= 65536 ||
34
+ !named.isFile() ||
35
+ stat.ino !== named.ino ||
36
+ stat.dev !== named.dev
37
+ )
38
+ throw invalid();
39
+ const buffer = Buffer.alloc(65536);
40
+ let length = 0;
41
+ while (length < buffer.length) {
42
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
43
+ if (!bytesRead) break;
44
+ length += bytesRead;
45
+ }
46
+ if (length === buffer.length) throw invalid();
47
+ text = buffer.subarray(0, length).toString("utf8");
48
+ } finally {
49
+ await handle.close();
50
+ }
51
+ const paths = text
52
+ .split(/\r?\n/)
53
+ .map((line) => line.trim())
54
+ .filter((line) => line && !line.startsWith("#"));
55
+ const entries = paths.map((entry) => {
56
+ const path = entry.replace(/\/$/, "");
57
+ if (
58
+ path === "index.html" ||
59
+ /[\\*?![\]:]/.test(path) ||
60
+ path.includes("\0") ||
61
+ path.split("/").some((part) => !part || part === "." || part === "..")
62
+ )
63
+ throw new Error(
64
+ ".hyperframesignore accepts literal project-relative file or folder paths, one per line. Wildcards and excluding index.html are not supported.",
65
+ );
66
+ return path;
67
+ });
68
+ return (path) => entries.some((entry) => path === entry || path.startsWith(`${entry}/`));
69
+ }
@@ -2,6 +2,7 @@
2
2
  // Captures a project folder as the canonical manifest the server accepts: every portable regular file with its
3
3
  // SHA-256 and length. A file that cannot be read, a link, a special file or a name no client can store fails the
4
4
  // capture with a list of them; it never becomes a deletion. Limits are checked from sizes alone, before any hashing.
5
+ import { readSourceIgnore } from "../sourceIgnore.mjs";
5
6
  import { createHash } from "node:crypto";
6
7
  import { constants } from "node:fs";
7
8
  import { lstat, open, readdir, realpath } from "node:fs/promises";
@@ -83,6 +84,7 @@ async function listFolder(root, dir) {
83
84
  async function walkProject(root, renderOutput) {
84
85
  /** @type {Walk} */
85
86
  const walk = { files: [], problems: [], waiting: [], skippedLinks: [] };
87
+ const ignored = await readSourceIgnore(root);
86
88
  const dirs = [""];
87
89
  for (let dir = dirs.pop(); dir !== undefined; dir = dirs.pop()) {
88
90
  let entries;
@@ -95,7 +97,7 @@ async function walkProject(root, renderOutput) {
95
97
  }
96
98
  for (const entry of entries) {
97
99
  const rel = dir ? `${dir}/${entry.name}` : entry.name;
98
- if (prefiltered(entry.name) || renderOutput(rel)) continue;
100
+ if (prefiltered(entry.name) || renderOutput(rel) || ignored(rel)) continue;
99
101
  await visit(root, rel, entry, walk, dirs);
100
102
  }
101
103
  }