@hyperframes/share 0.9.28-alpha.0 → 0.9.28

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.
Files changed (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +1 -58
  3. package/dist/index.d.mts +0 -1
  4. package/dist/projectLock.d.mts +2 -2
  5. package/dist/projectSnapshot.d.mts +1 -3
  6. package/dist/reviewClient.d.mts +16 -13
  7. package/dist/reviewExport.d.mts +1 -11
  8. package/dist/reviewUpload.d.mts +0 -2
  9. package/package.json +1 -1
  10. package/scripts/copy-types.mjs +0 -3
  11. package/src/index.mjs +0 -6
  12. package/src/projectLock.mjs +2 -9
  13. package/src/projectSnapshot.mjs +4 -11
  14. package/src/reviewClient.mjs +83 -48
  15. package/src/reviewExport.mjs +37 -145
  16. package/src/reviewUpload.mjs +16 -18
  17. package/dist/shareSources.d.mts +0 -24
  18. package/dist/sources/adapterTypes.d.ts +0 -39
  19. package/dist/sources/api.d.mts +0 -29
  20. package/dist/sources/capture.d.mts +0 -24
  21. package/dist/sources/contract.d.mts +0 -2
  22. package/dist/sources/errors.d.mts +0 -24
  23. package/dist/sources/limits.d.mts +0 -12
  24. package/dist/sources/manifest.d.mts +0 -23
  25. package/dist/sources/pathPolicy.d.mts +0 -25
  26. package/dist/sources/projectState.d.mts +0 -9
  27. package/dist/sources/store.d.mts +0 -3
  28. package/dist/sources/transfer.d.mts +0 -45
  29. package/dist/sources/types.d.ts +0 -353
  30. package/dist/sources/unicode.d.mts +0 -15
  31. package/dist/sources/upload.d.mts +0 -9
  32. package/dist/sources/uploadFiles.d.mts +0 -31
  33. package/dist/sources/wire.d.mts +0 -21
  34. package/src/shareSources.mjs +0 -236
  35. package/src/sources/adapterTypes.d.ts +0 -39
  36. package/src/sources/api.mjs +0 -114
  37. package/src/sources/capture.mjs +0 -262
  38. package/src/sources/contract/LICENSE-Unicode.txt +0 -39
  39. package/src/sources/contract/path-policy.json +0 -311
  40. package/src/sources/contract/unicode-15.1.0.json +0 -6179
  41. package/src/sources/contract.mjs +0 -13
  42. package/src/sources/errors.mjs +0 -77
  43. package/src/sources/limits.mjs +0 -48
  44. package/src/sources/manifest.mjs +0 -154
  45. package/src/sources/pathPolicy.mjs +0 -197
  46. package/src/sources/projectState.mjs +0 -59
  47. package/src/sources/store.mjs +0 -82
  48. package/src/sources/transfer.mjs +0 -196
  49. package/src/sources/types.d.ts +0 -353
  50. package/src/sources/unicode.mjs +0 -163
  51. package/src/sources/upload.mjs +0 -201
  52. package/src/sources/uploadFiles.mjs +0 -263
  53. package/src/sources/wire.mjs +0 -205
@@ -1,236 +0,0 @@
1
- // @ts-check
2
- import { realpath } from "node:fs/promises";
3
- import { randomUUID } from "node:crypto";
4
- import { basename } from "node:path";
5
- import { projectFingerprint } from "./projectSnapshot.mjs";
6
- import { withProjectShareLock } from "./projectLock.mjs";
7
- import { captureProject } from "./sources/capture.mjs";
8
- import { sourceApi } from "./sources/api.mjs";
9
- import { createProjectShareSourceStore } from "./sources/store.mjs";
10
- import { realWait } from "./sources/transfer.mjs";
11
- import { recoverSourceUpload, uploadSource } from "./sources/upload.mjs";
12
-
13
- /** @typedef {import("./sources/adapterTypes.js").SourceResult} SourceResult
14
- * @typedef {import("./sources/types.js").Binding} Binding */
15
- /** @param {string} reason @returns {SourceResult} */
16
- const unavailable = (reason) => ({ at: "unavailable", reason });
17
- /** Content identity for sharing, including portable hidden render inputs. The Desktop watch inventory stays separate.
18
- * @param {string} projectPath @returns {Promise<string | null>} */
19
- export async function shareSourceFingerprint(projectPath) {
20
- const capture = await captureProject(await realpath(projectPath));
21
- return capture.at === "captured" && !captureRefusal(capture) ? capture.manifest.sha256 : null;
22
- }
23
- /** @param {Binding} binding @param {string} space */
24
- function bindingRefusal(binding, space) {
25
- if (binding.ownerSpaceId !== space) return "source_other_workspace";
26
- if (binding.sharedFrom) return "source_owned_by_someone_else";
27
- if (binding.gettingFromCloud || binding.duplicate) return "source_operation_in_progress";
28
- return null;
29
- }
30
- /** @param {import("./sources/types.js").Capture} capture */
31
- function captureRefusal(capture) {
32
- if (capture.at === "too-big") return `source_${capture.over.limit}_exceeded`;
33
- if (capture.at === "problems") return `source_${capture.problems[0]?.problem ?? "invalid"}`;
34
- if (capture.settled.agentTurnOpen || capture.settled.projectLocked || capture.waiting.length)
35
- return "source_edit_in_progress";
36
- return null;
37
- }
38
-
39
- /** Prepare source explicitly before adding the video version. No unexpected network or filesystem failure is
40
- * converted to video-only. `filesForExport` revalidates the prepared bytes and never starts another upload.
41
- * @param {import("./sources/adapterTypes.js").SourceOptions} options */
42
- export function shareSources(options) {
43
- const store = options.store ?? createProjectShareSourceStore();
44
- const fingerprint = options.fingerprint ?? projectFingerprint;
45
- const signal = options.signal ?? new AbortController().signal;
46
- /** @type {Map<string, { rendered: string, digest: string, snapshot: import("./types/reviewWire.js").SnapshotRef, account: string, backend: string }>} */
47
- const prepared = new Map();
48
- /** @param {string} projectPath @param {string | null} rendered @returns {Promise<SourceResult>} */
49
- const prepare = async (projectPath, rendered) => {
50
- const root = await realpath(projectPath);
51
- prepared.delete(root);
52
- if (!enabled(options)) return unavailable("source_disabled");
53
- if (!rendered || (await fingerprint(root)) !== rendered)
54
- return unavailable("source_render_changed");
55
- return withProjectShareLock(
56
- root,
57
- "source",
58
- async () => {
59
- const signed = await options.access();
60
- if (!signed?.owner) return unavailable("source_signed_out");
61
- const key = { backend: signed.heygenApi, account: signed.owner, projectPath: root };
62
- const guardedAccess = async (/** @type {string | undefined} */ refused) => {
63
- const next = await options.access(refused);
64
- if (!next || next.owner !== key.account || next.heygenApi !== key.backend)
65
- throw new Error("Source account or backend changed while sharing.");
66
- return next;
67
- };
68
- const api =
69
- options.api ?? sourceApi({ access: guardedAccess, fetchImpl: options.fetchImpl });
70
- const { own, existing, binding } = await selectBinding(key, options, store);
71
- const refused = bindingRefusal(binding, options.spaceId);
72
- if (refused) return unavailable(refused);
73
- const result = await prepareBinding({
74
- binding,
75
- own: Boolean(own),
76
- external: Boolean(existing),
77
- api,
78
- store,
79
- options,
80
- signal,
81
- fingerprint,
82
- rendered,
83
- });
84
- const manifest = result.manifest;
85
- await guardedAccess(undefined);
86
- if (result.at !== "ready" || !manifest) return result;
87
- if (!(await stillMatches(root, rendered, manifest.sha256, fingerprint)))
88
- return unavailable("source_render_changed");
89
- prepared.set(root, {
90
- rendered,
91
- digest: manifest.sha256,
92
- snapshot: result.snapshot,
93
- account: key.account,
94
- backend: key.backend,
95
- });
96
- return { at: "ready", snapshot: result.snapshot };
97
- },
98
- { signal },
99
- );
100
- };
101
- /** @param {string} projectPath @param {string | null} rendered */
102
- const filesForExport = async (projectPath, rendered) => {
103
- const root = await realpath(projectPath);
104
- const held = prepared.get(root);
105
- const signed = await options.access();
106
- if (
107
- !held ||
108
- !rendered ||
109
- held.rendered !== rendered ||
110
- !sameOwner(signed, held) ||
111
- !enabled(options)
112
- )
113
- return null;
114
- return (await stillMatches(root, rendered, held.digest, fingerprint)) ? held.snapshot : null;
115
- };
116
- return { prepare, filesForExport };
117
- }
118
-
119
- /** @param {import("./sources/adapterTypes.js").SourceOptions} options */
120
- const enabled = (options) => options.enabled?.() ?? true;
121
- /** @param {Awaited<ReturnType<import("./signedInRequest.mjs").SignedInAccess>>} signed
122
- * @param {{account: string, backend: string}} held */
123
- const sameOwner = (signed, held) =>
124
- signed?.owner === held.account && signed?.heygenApi === held.backend;
125
- /** @typedef {{ binding: Binding, own: boolean, external: boolean, api: import("./sources/api.mjs").SourceApi,
126
- * store: import("./sources/adapterTypes.js").SourceStore, options: import("./sources/adapterTypes.js").SourceOptions,
127
- * signal: AbortSignal, fingerprint: (path: string) => Promise<string | null>, rendered: string }} Preparation */
128
- /** @param {Preparation} job @returns {Promise<SourceResult & {manifest?: import("./sources/types.js").Manifest}>} */
129
- async function prepareBinding(job) {
130
- const { binding, api, fingerprint, rendered } = job;
131
- const limits = await api.limits(binding.ownerSpaceId);
132
- if (!limits.syncAllowed) return unavailable("source_plan_required");
133
- const capture = await captureProject(binding.projectPath, { limits: limits.push });
134
- const bad = captureRefusal(capture);
135
- if (bad) return unavailable(bad);
136
- if (capture.at !== "captured") return unavailable("source_invalid");
137
- if ((await fingerprint(binding.projectPath)) !== rendered)
138
- return unavailable("source_render_changed");
139
- const result = await snapshotFor(job, capture.manifest, limits);
140
- return { ...result, manifest: capture.manifest };
141
- }
142
- /** @param {Preparation} job @param {import("./sources/types.js").Manifest} manifest
143
- * @param {Extract<import("./sources/types.js").SyncLimitsRead, {at: "read"}>} limits @returns {Promise<SourceResult>} */
144
- async function snapshotFor(job, manifest, limits) {
145
- const { binding, api, store, options, signal } = job;
146
- const blocked = await recoverEarlierCapture(job, manifest.sha256);
147
- if (blocked) return blocked;
148
- if (binding.base?.manifestSha256 === manifest.sha256 && !binding.inflight)
149
- return reuseSnapshot(api, binding, manifest.sha256);
150
- if (job.external) return unavailable("source_sync_required");
151
- if (!job.own && limits.compositions >= limits.maxCompositions)
152
- return unavailable("source_composition_limit");
153
- await store.write(binding);
154
- binding.composition = await api.create(
155
- binding.ownerSpaceId,
156
- binding.compositionId,
157
- basename(binding.projectPath),
158
- );
159
- await store.write(binding);
160
- return uploadSource({ binding, manifest, api, store, signal, ...uploadOptions(options) });
161
- }
162
- /** @param {Preparation} job @param {string} digest */
163
- function recoverEarlierCapture({ external, binding, api, store }, digest) {
164
- const inflight = binding.inflight;
165
- if (external || !inflight || inflight.manifestSha256 === digest) return null;
166
- return recoverSourceUpload({ binding, api, store });
167
- }
168
-
169
- /** @param {import("./sources/adapterTypes.js").SourceOptions} options */
170
- function uploadOptions(options) {
171
- return {
172
- fetchImpl: options.fetchImpl ?? fetch,
173
- wait: options.wait ?? realWait,
174
- timeoutMs: options.verificationTimeoutMs ?? 120_000,
175
- onProgress: options.onProgress ?? (() => {}),
176
- };
177
- }
178
-
179
- /** @param {import("./sources/types.js").BindingKey} key @param {import("./sources/adapterTypes.js").SourceOptions} options
180
- * @param {import("./sources/adapterTypes.js").SourceStore} store */
181
- async function selectBinding(key, options, store) {
182
- const own = await store.read(key);
183
- const existing = own ? null : await options.existingBinding?.(key);
184
- return { own, existing, binding: own ?? existing ?? newBinding(key, options.spaceId) };
185
- }
186
- /** @param {import("./sources/types.js").BindingKey} key @param {string} space @returns {Binding} */
187
- function newBinding(key, space) {
188
- return {
189
- ...key,
190
- version: 1,
191
- compositionId: randomUUID(),
192
- ownerSpaceId: space,
193
- boundAt: Date.now(),
194
- composition: null,
195
- etag: null,
196
- base: null,
197
- inflight: null,
198
- };
199
- }
200
- /** @param {import("./sources/api.mjs").SourceApi} api @param {Binding} binding @param {string} digest @returns {Promise<SourceResult>} */
201
- async function reuseSnapshot(api, binding, digest) {
202
- if (!binding.base) return unavailable("source_sync_required");
203
- const remote = await api.snapshot(
204
- binding.ownerSpaceId,
205
- binding.compositionId,
206
- binding.base.snapshotId,
207
- );
208
- if (
209
- remote.id !== binding.base.snapshotId ||
210
- remote.compositionId !== binding.compositionId ||
211
- remote.manifestSha256 !== digest ||
212
- remote.manifest?.sha256 !== digest ||
213
- remote.publicationOutcome !== "published"
214
- )
215
- return unavailable("source_snapshot_mismatch");
216
- return {
217
- at: "ready",
218
- snapshot: {
219
- space_id: binding.ownerSpaceId,
220
- composition_id: binding.compositionId,
221
- snapshot_id: remote.id,
222
- },
223
- };
224
- }
225
- /** @param {string} root @param {string} rendered @param {string} digest @param {(path: string) => Promise<string | null>} fingerprint */
226
- async function stillMatches(root, rendered, digest, fingerprint) {
227
- if ((await fingerprint(root)) !== rendered) return false;
228
- const capture = await captureProject(root);
229
- return (
230
- !captureRefusal(capture) &&
231
- capture.at === "captured" &&
232
- capture.manifest.sha256 === digest &&
233
- (await fingerprint(root)) === rendered
234
- );
235
- }
236
- export { createProjectShareSourceStore } from "./sources/store.mjs";
@@ -1,39 +0,0 @@
1
- import type { Binding, BindingKey, Manifest } from "./types.js";
2
- import type { SnapshotRef } from "../types/reviewWire.js";
3
- import type { SignedInAccess } from "../signedInRequest.mjs";
4
- import type { SourceApi } from "./api.mjs";
5
-
6
- export interface SourceStore {
7
- read(key: BindingKey): Promise<Binding | null>;
8
- write(binding: Binding): Promise<void>;
9
- }
10
- export type SourceResult =
11
- | { at: "ready"; snapshot: SnapshotRef }
12
- | { at: "unavailable"; reason: string };
13
- export interface SourceOptions {
14
- access: SignedInAccess;
15
- spaceId: string;
16
- store?: SourceStore;
17
- enabled?: () => boolean;
18
- fingerprint?: (path: string) => Promise<string | null>;
19
- /** Host-owned Desktop bindings may be read for exact reuse, never overwritten. */
20
- existingBinding?: (key: BindingKey) => Promise<Binding | null>;
21
- fetchImpl?: typeof fetch;
22
- signal?: AbortSignal;
23
- verificationTimeoutMs?: number;
24
- onProgress?: (bytes: number) => void;
25
- /** Test/host seams; the default is the authenticated v3 client. */
26
- api?: SourceApi;
27
- wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
28
- }
29
- export interface SourceRun {
30
- binding: Binding;
31
- manifest: Manifest;
32
- api: SourceApi;
33
- store: SourceStore;
34
- fetchImpl: typeof fetch;
35
- signal: AbortSignal;
36
- wait: (ms: number, signal?: AbortSignal) => Promise<void>;
37
- timeoutMs: number;
38
- onProgress: (bytes: number) => void;
39
- }
@@ -1,114 +0,0 @@
1
- // @ts-check
2
- import { signedInRequest } from "../signedInRequest.mjs";
3
- import { SyncApiError, refusalOf } from "./errors.mjs";
4
- import {
5
- beginOf,
6
- grantsOf,
7
- objectOf,
8
- uploadOf,
9
- syncLimitsOf,
10
- compositionOf,
11
- snapshotOf,
12
- } from "./wire.mjs";
13
-
14
- /** @param {{ access: import("../signedInRequest.mjs").SignedInAccess, fetchImpl?: typeof fetch }} deps */
15
- export function sourceApi({ access, fetchImpl = fetch }) {
16
- const request = signedInRequest({
17
- access,
18
- fetchImpl,
19
- timeoutMs: 120_000,
20
- tag: "share-source",
21
- host: "heygenApi",
22
- offline: () => new SyncApiError("offline"),
23
- signedOut: () => new SyncApiError("signed-out"),
24
- });
25
- /** @param {string} method @param {string} route @param {unknown} [body] @param {Record<string,string>} [headers] */
26
- const send = async (method, route, body, headers) => {
27
- const { reply } = await request(method, route, body, headers);
28
- if (!reply.ok) throw await refusalOf(reply);
29
- return objectOf(await reply.json(), "envelope").data;
30
- };
31
- /** @param {string} space @param {string} composition */
32
- const path = (space, composition) =>
33
- `/v3/hyperframes/workspaces/${encodeURIComponent(space)}/compositions/${encodeURIComponent(composition)}`;
34
- return {
35
- /** @param {string} space */
36
- limits: async (space) =>
37
- syncLimitsOf(
38
- await send(
39
- "GET",
40
- `/v3/hyperframes/workspaces/${encodeURIComponent(space)}/composition-sync/limits`,
41
- ),
42
- ),
43
- /** @param {string} space @param {string} composition @param {string} title */
44
- create: async (space, composition, title) =>
45
- compositionOf(await send("PUT", path(space, composition), { title })),
46
- /** @param {string} space @param {string} composition @param {string} snapshot */
47
- snapshot: async (space, composition, snapshot) =>
48
- snapshotOf(
49
- await send(
50
- "GET",
51
- `${path(space, composition)}/snapshots/${encodeURIComponent(snapshot)}?include=manifest`,
52
- ),
53
- ),
54
- /** @param {string} space @param {string} composition @param {import("./types.js").BeginRequest} body */
55
- begin: async (space, composition, body) =>
56
- beginOf(
57
- await send(
58
- "POST",
59
- `${path(space, composition)}/uploads`,
60
- {
61
- base_snapshot_id: body.baseSnapshotId,
62
- resolves_conflict_snapshot_ids: [],
63
- client_type: "cli",
64
- manifest_sha256: body.manifest.sha256,
65
- manifest: body.manifest.document,
66
- },
67
- { "Idempotency-Key": body.idempotencyKey },
68
- ),
69
- false,
70
- ),
71
- /** @param {string} space @param {string} composition @param {string} upload @param {import("./types.js").GrantRequest[]} files */
72
- uploadUrls: async (space, composition, upload, files) =>
73
- grantsOf(
74
- await send(
75
- "POST",
76
- `${path(space, composition)}/uploads/${encodeURIComponent(upload)}/upload-urls`,
77
- {
78
- files: files.map((file) => ({
79
- sha256: file.sha256,
80
- ...(file.multipartUploadId && { multipart_upload_id: file.multipartUploadId }),
81
- ...(file.parts && {
82
- parts: file.parts.map((part) => ({
83
- part_number: part.partNumber,
84
- sha256: part.sha256,
85
- })),
86
- }),
87
- })),
88
- },
89
- ),
90
- ),
91
- /** @param {string} space @param {string} composition @param {string} upload */
92
- status: async (space, composition, upload) =>
93
- uploadOf(
94
- await send("GET", `${path(space, composition)}/uploads/${encodeURIComponent(upload)}`),
95
- ),
96
- /** @param {string} space @param {string} composition @param {string} upload */
97
- cancel: async (space, composition, upload) =>
98
- uploadOf(
99
- await send(
100
- "POST",
101
- `${path(space, composition)}/uploads/${encodeURIComponent(upload)}/cancel`,
102
- ),
103
- ),
104
- /** @param {string} space @param {string} composition @param {string} upload */
105
- submit: async (space, composition, upload) =>
106
- uploadOf(
107
- await send(
108
- "POST",
109
- `${path(space, composition)}/uploads/${encodeURIComponent(upload)}/verification`,
110
- ),
111
- ),
112
- };
113
- }
114
- /** @typedef {ReturnType<typeof sourceApi>} SourceApi */
@@ -1,262 +0,0 @@
1
- // @ts-check
2
- // Captures a project folder as the canonical manifest the server accepts: every portable regular file with its
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
- // capture with a list of them; it never becomes a deletion. Limits are checked from sizes alone, before any hashing.
5
- import { createHash } from "node:crypto";
6
- import { constants } from "node:fs";
7
- import { lstat, open, readdir, realpath } from "node:fs/promises";
8
- import { dirname, join } from "node:path";
9
-
10
- import { ENTRYPOINT, ManifestInvalid, canonicalBytes, parseManifest } from "./manifest.mjs";
11
- import { V1_LIMITS, overLimit } from "./limits.mjs";
12
- import {
13
- PathViolation,
14
- checkInventoryPaths,
15
- classifyPath,
16
- excludedFolder,
17
- prefiltered,
18
- skippedSymlink,
19
- validatePath,
20
- } from "./pathPolicy.mjs";
21
- import { readRenderOutputs, settledState } from "./projectState.mjs";
22
-
23
- /** @typedef {import("./types.js").CaptureProblem} CaptureProblem @typedef {import("./types.js").Found} Found
24
- * @typedef {import("./types.js").Walk} Walk @typedef {import("./types.js").Capture} Capture */
25
-
26
- /** Files up to this size use one bounded buffer; larger files stream from the verified handle. */
27
- const INLINE_HASH_BYTES = 4 * 1024 * 1024;
28
- const HASHING_AT_ONCE = 4;
29
-
30
- /** @param {string} root @param {string} rel @param {import("node:fs").Dirent} entry @param {Walk} walk
31
- * @param {string[]} dirs */
32
- async function visit(root, rel, entry, walk, dirs) {
33
- if (entry.isDirectory()) {
34
- if (!excludedFolder(rel)) dirs.push(rel);
35
- return;
36
- }
37
- const action = classifyPath(rel).client_action;
38
- if (action === "exclude") return;
39
- if (action === "wait_then_exclude") return void walk.waiting.push(rel);
40
- if (entry.isSymbolicLink()) {
41
- if (skippedSymlink(rel)) walk.skippedLinks.push(rel);
42
- else walk.problems.push({ path: rel, problem: "symlink" });
43
- return;
44
- }
45
- if (!entry.isFile()) return void walk.problems.push({ path: rel, problem: "special-file" });
46
- await visitFile(root, rel, walk);
47
- }
48
- /** @param {string} root @param {string} rel @param {Walk} walk */
49
- async function visitFile(root, rel, walk) {
50
- try {
51
- validatePath(rel);
52
- } catch (error) {
53
- if (!(error instanceof PathViolation)) throw error;
54
- return void walk.problems.push({ path: rel, problem: "invalid-name", code: error.code });
55
- }
56
- const full = join(root, rel);
57
- try {
58
- const { size, mtimeMs, ino, dev } = await lstat(full);
59
- walk.files.push({ path: rel, full, size, mtimeMs, ino, dev });
60
- } catch {
61
- walk.problems.push({ path: rel, problem: "unreadable" });
62
- }
63
- }
64
-
65
- /** A folder that was a link, or another folder, by the time it was read. */
66
- class FolderMoved extends Error {}
67
-
68
- /** A folder's entries, read only while the same real folder stands at its path before and after: one swapped for a
69
- * link to somewhere else is never listed. @param {string} root @param {string} dir */
70
- async function listFolder(root, dir) {
71
- const full = join(root, dir);
72
- const before = await lstat(full);
73
- if (!before.isDirectory()) throw new FolderMoved();
74
- const entries = await readdir(full, { withFileTypes: true });
75
- const after = await lstat(full);
76
- if (!after.isDirectory() || after.ino !== before.ino || after.dev !== before.dev)
77
- throw new FolderMoved();
78
- return entries;
79
- }
80
-
81
- /** Every file a capture would list, and what keeps one from being listed. @param {string} root
82
- * @param {(rel: string) => boolean} renderOutput @returns {Promise<Walk>} */
83
- async function walkProject(root, renderOutput) {
84
- /** @type {Walk} */
85
- const walk = { files: [], problems: [], waiting: [], skippedLinks: [] };
86
- const dirs = [""];
87
- for (let dir = dirs.pop(); dir !== undefined; dir = dirs.pop()) {
88
- let entries;
89
- try {
90
- entries = await listFolder(root, dir);
91
- } catch (error) {
92
- const changed = error instanceof FolderMoved;
93
- walk.problems.push({ path: dir, problem: changed ? "changed" : "unreadable" });
94
- continue;
95
- }
96
- for (const entry of entries) {
97
- const rel = dir ? `${dir}/${entry.name}` : entry.name;
98
- if (prefiltered(entry.name) || renderOutput(rel)) continue;
99
- await visit(root, rel, entry, walk, dirs);
100
- }
101
- }
102
- return walk;
103
- }
104
-
105
- /** The manifest's canonical length, from paths and sizes alone: every SHA-256 is 64 characters.
106
- * @param {Found[]} files */
107
- const manifestBytes = (files) =>
108
- canonicalBytes({
109
- format_version: 1,
110
- entrypoint: ENTRYPOINT,
111
- files: files.map(({ path, size }) => ({ path, sha256: "0".repeat(64), size })),
112
- }).length;
113
-
114
- /** A file changed this recently can be written again within its time's granularity, keeping its size and time. */
115
- const SETTLED_MS = 2_000;
116
-
117
- /** A file's SHA-256 by path, size, time and inode, kept for the launch so a check after a focus reads no bytes.
118
- * Only a settled file's hash is kept. */
119
- export function hashMemo(now = Date.now) {
120
- /** @type {Map<string, { size: number, mtimeMs: number, ino: number, sha256: string }>} */
121
- const known = new Map();
122
- return {
123
- /** @param {Found} file */
124
- get: (file) => {
125
- const kept = known.get(file.full);
126
- const same =
127
- kept?.size === file.size && kept.mtimeMs === file.mtimeMs && kept.ino === file.ino;
128
- return same ? kept.sha256 : null;
129
- },
130
- /** @param {Found} file @param {string} sha256 */
131
- set: (file, sha256) => {
132
- if (now() - file.mtimeMs > SETTLED_MS) known.set(file.full, { ...file, sha256 });
133
- },
134
- };
135
- }
136
-
137
- /** The file the walk listed, as an open handle: never through a link at its own name (O_NOFOLLOW), and only when the
138
- * opened file is the same regular file (inode, device, size) found inside the project, so a file or folder swapped
139
- * for a link after the walk is refused rather than followed. Null when it is not. @param {string} root
140
- * @param {Found} file @param {typeof realpath} [resolve] */
141
- async function openListed(root, file, resolve = realpath) {
142
- const handle = await open(file.full, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch(
143
- (/** @type {NodeJS.ErrnoException} */ error) => {
144
- if (error.code === "ELOOP" || error.code === "EMLINK") return null;
145
- throw error;
146
- },
147
- );
148
- if (!handle) return null;
149
- try {
150
- const opened = await handle.stat();
151
- const same =
152
- opened.isFile() &&
153
- opened.ino === file.ino &&
154
- opened.dev === file.dev &&
155
- opened.size === file.size &&
156
- (await resolve(dirname(file.full))) === join(root, dirname(file.path));
157
- if (same) return handle;
158
- } catch (error) {
159
- await handle.close();
160
- throw error;
161
- }
162
- await handle.close();
163
- return null;
164
- }
165
-
166
- /** The file's SHA-256, read through the handle `openListed` checked; null when the file is not the one listed, or its
167
- * bytes changed while being read. `root` is the project's real path; `resolve` finds a folder's real path.
168
- * @param {string} root @param {Found} file @param {typeof realpath} [resolve] */
169
- export async function hashListedFile(root, file, resolve = realpath) {
170
- const handle = await openListed(root, file, resolve);
171
- if (!handle) return null;
172
- try {
173
- const sha256 =
174
- file.size <= INLINE_HASH_BYTES
175
- ? await handle
176
- .readFile()
177
- .then((bytes) =>
178
- bytes.length === file.size ? createHash("sha256").update(bytes).digest("hex") : null,
179
- )
180
- : await hashHandle(handle);
181
- const after = await handle.stat();
182
- return after.size === file.size && after.mtimeMs === file.mtimeMs ? sha256 : null;
183
- } finally {
184
- await handle.close();
185
- }
186
- }
187
-
188
- /** @param {string} root @param {Found[]} files @param {ReturnType<typeof hashMemo>} memo
189
- * @returns {Promise<{ hashed: import("./types.js").ManifestFile[], problems: CaptureProblem[] }>} */
190
- async function hashAll(root, files, memo) {
191
- /** @type {import("./types.js").ManifestFile[]} */
192
- const hashed = [];
193
- /** @type {CaptureProblem[]} */
194
- const problems = [];
195
- const queue = [...files];
196
- const worker = async () => {
197
- for (let file = queue.shift(); file; file = queue.shift()) {
198
- const sha256 = memo.get(file) ?? (await hashListedFile(root, file).catch(() => undefined));
199
- if (sha256 === undefined) problems.push({ path: file.path, problem: "unreadable" });
200
- else if (sha256 === null) problems.push({ path: file.path, problem: "changed" });
201
- else {
202
- memo.set(file, sha256);
203
- hashed.push({ path: file.path, sha256, size: file.size });
204
- }
205
- }
206
- };
207
- await Promise.all(Array.from({ length: HASHING_AT_ONCE }, worker));
208
- return { hashed, problems };
209
- }
210
-
211
- /** Problems that keep the files found from being one manifest: two names one disk stores as one, no index.html.
212
- * @param {Found[]} files @returns {CaptureProblem[]} */
213
- function inventoryProblems(files) {
214
- if (!files.some((file) => file.path === ENTRYPOINT))
215
- return [{ path: ENTRYPOINT, problem: "no-entrypoint" }];
216
- try {
217
- checkInventoryPaths(files.map((file) => file.path));
218
- return [];
219
- } catch (error) {
220
- if (!(error instanceof PathViolation)) throw error;
221
- return [{ path: error.path, problem: "invalid-name", code: error.code }];
222
- }
223
- }
224
-
225
- /** Captures `root` (a project's real path). `memo` reuses hashes of unchanged files; a send should pass a fresh one.
226
- * @param {string} root @param {{ memo?: ReturnType<typeof hashMemo>, limits?: import("./types.js").Limits }} [options]
227
- * @returns {Promise<Capture>} */
228
- export async function captureProject(root, { memo = hashMemo(), limits = V1_LIMITS } = {}) {
229
- const renderOutput = await readRenderOutputs(root);
230
- const walk = await walkProject(root, renderOutput);
231
- const problems = walk.problems.length ? walk.problems : inventoryProblems(walk.files);
232
- if (problems.length) return { at: "problems", problems };
233
- const over = overLimit(walk.files, manifestBytes(walk.files), limits);
234
- if (over) return { at: "too-big", over };
235
- const { hashed, problems: unhashed } = await hashAll(await realpath(root), walk.files, memo);
236
- if (unhashed.length) return { at: "problems", problems: unhashed };
237
- try {
238
- const manifest = parseManifest({ format_version: 1, entrypoint: ENTRYPOINT, files: hashed });
239
- const settled = await settledState(root);
240
- return {
241
- at: "captured",
242
- manifest,
243
- settled,
244
- waiting: walk.waiting,
245
- skippedLinks: walk.skippedLinks,
246
- };
247
- } catch (error) {
248
- if (!(error instanceof ManifestInvalid)) throw error;
249
- const first = error.violations[0];
250
- return {
251
- at: "problems",
252
- problems: [{ path: first?.path ?? "", problem: "invalid-name", code: error.code }],
253
- };
254
- }
255
- }
256
-
257
- /** @param {import("node:fs/promises").FileHandle} handle */
258
- async function hashHandle(handle) {
259
- const hash = createHash("sha256");
260
- for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk);
261
- return hash.digest("hex");
262
- }