@hyperframes/share 0.9.27 → 0.9.28-alpha.0
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/README.md +58 -1
- package/dist/index.d.mts +1 -0
- package/dist/projectLock.d.mts +2 -2
- package/dist/projectSnapshot.d.mts +3 -1
- package/dist/reviewExport.d.mts +11 -1
- package/dist/reviewUpload.d.mts +2 -0
- package/dist/shareSources.d.mts +24 -0
- package/dist/sources/adapterTypes.d.ts +39 -0
- package/dist/sources/api.d.mts +29 -0
- package/dist/sources/capture.d.mts +24 -0
- package/dist/sources/contract.d.mts +2 -0
- package/dist/sources/errors.d.mts +24 -0
- package/dist/sources/limits.d.mts +12 -0
- package/dist/sources/manifest.d.mts +23 -0
- package/dist/sources/pathPolicy.d.mts +25 -0
- package/dist/sources/projectState.d.mts +9 -0
- package/dist/sources/store.d.mts +3 -0
- package/dist/sources/transfer.d.mts +45 -0
- package/dist/sources/types.d.ts +353 -0
- package/dist/sources/unicode.d.mts +15 -0
- package/dist/sources/upload.d.mts +9 -0
- package/dist/sources/uploadFiles.d.mts +31 -0
- package/dist/sources/wire.d.mts +21 -0
- package/package.json +1 -1
- package/scripts/copy-types.mjs +3 -0
- package/src/index.mjs +6 -0
- package/src/projectLock.mjs +9 -2
- package/src/projectSnapshot.mjs +11 -4
- package/src/reviewExport.mjs +145 -37
- package/src/reviewUpload.mjs +18 -16
- package/src/shareSources.mjs +236 -0
- package/src/sources/adapterTypes.d.ts +39 -0
- package/src/sources/api.mjs +114 -0
- package/src/sources/capture.mjs +262 -0
- package/src/sources/contract/LICENSE-Unicode.txt +39 -0
- package/src/sources/contract/path-policy.json +311 -0
- package/src/sources/contract/unicode-15.1.0.json +6179 -0
- package/src/sources/contract.mjs +13 -0
- package/src/sources/errors.mjs +77 -0
- package/src/sources/limits.mjs +48 -0
- package/src/sources/manifest.mjs +154 -0
- package/src/sources/pathPolicy.mjs +197 -0
- package/src/sources/projectState.mjs +59 -0
- package/src/sources/store.mjs +82 -0
- package/src/sources/transfer.mjs +196 -0
- package/src/sources/types.d.ts +353 -0
- package/src/sources/unicode.mjs +163 -0
- package/src/sources/upload.mjs +201 -0
- package/src/sources/uploadFiles.mjs +263 -0
- package/src/sources/wire.mjs +205 -0
- package/LICENSE +0 -190
package/src/reviewExport.mjs
CHANGED
|
@@ -39,9 +39,10 @@ import { versionSentence } from "./reviewVersionSentences.mjs";
|
|
|
39
39
|
* definitive result; `reviewSent`: cleanup cutoff, with attribution only when the accepted attempt is
|
|
40
40
|
* known; `last`: the newest version.
|
|
41
41
|
* @typedef {{ pending: { body: ExportRequest, videoId: string | null, projectId?: string | null,
|
|
42
|
-
* checkingSince?: number, snapshotRefused?: boolean, unanswered?: boolean, reviewSent?: ReviewSent } | null,
|
|
43
|
-
* last: { fingerprint: string, versionId: string, exportedAt: string, projectId: string, files?: boolean,
|
|
44
|
-
*
|
|
42
|
+
* renderKey?: string, checkingSince?: number, snapshotRefused?: boolean, unanswered?: boolean, reviewSent?: ReviewSent } | null,
|
|
43
|
+
* last: { fingerprint: string, versionId: string, exportedAt: string, projectId: string, files?: boolean,
|
|
44
|
+
* snapshot?: ExportRequest["snapshot"], checksum?: string }
|
|
45
|
+
* | null, failure?: (Failure & { projectId: string }) | null }} VersionRecord */
|
|
45
46
|
|
|
46
47
|
/** `onAdded`: a version went ready, with whether it carries files and whether it is the page's first from here.
|
|
47
48
|
* @typedef {{ onProgress?: (progress: ReviewExportProgress | null) => void, onUploaded?: () => Promise<void>,
|
|
@@ -49,10 +50,11 @@ import { versionSentence } from "./reviewVersionSentences.mjs";
|
|
|
49
50
|
|
|
50
51
|
/** `projectId` overrides the recorded review project; `open`: a held video is watched only while the project is open.
|
|
51
52
|
* `filesForExport`: the synced snapshot a new version carries, null for
|
|
52
|
-
* none.
|
|
53
|
+
* none. `allowPending` lets a host treat accepted processing or screening as a successful return.
|
|
53
54
|
* @typedef {VersionHooks & { projectPath: string, title: string, client: ReviewClient, put?: typeof putFile,
|
|
54
55
|
* sleep?: (ms: number) => Promise<void>, probe?: (file: string) => Promise<VideoFacts | null>, now?: () => number,
|
|
55
|
-
* projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null
|
|
56
|
+
* projectId?: string | null, filesForExport?: FilesForRender, fromReview?: FromReview | null,
|
|
57
|
+
* allowPending?: boolean, requireSource?: boolean, omitSource?: boolean }} VersionRequest */
|
|
56
58
|
|
|
57
59
|
/** The snapshot of `rendered`, the project as the export was rendered; null when the folder is not that render.
|
|
58
60
|
* @typedef {(projectPath: string, rendered: string | null) => Promise<import("./types/reviewWire.js").SnapshotRef | null>}
|
|
@@ -177,18 +179,36 @@ function isLast(value) {
|
|
|
177
179
|
isSavedId(value.versionId) &&
|
|
178
180
|
isSavedId(value.exportedAt) &&
|
|
179
181
|
isSavedId(value.projectId) &&
|
|
182
|
+
isLastMetadata(value)
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** @param {Record<string, unknown>} value */
|
|
187
|
+
function isLastMetadata(value) {
|
|
188
|
+
return (
|
|
180
189
|
isOptionalBoolean(value.files) &&
|
|
190
|
+
isOptionalSnapshot(value.snapshot) &&
|
|
181
191
|
(value.checksum === undefined || isSavedId(value.checksum))
|
|
182
192
|
);
|
|
183
193
|
}
|
|
184
194
|
|
|
185
195
|
/** @param {Record<string, unknown>} saved
|
|
186
|
-
* @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null}} */
|
|
196
|
+
* @returns {saved is {pending?: VersionRecord["pending"], last?: SavedLast | null, failure?: unknown}} */
|
|
187
197
|
const isExportState = (saved) =>
|
|
188
198
|
("pending" in saved || "last" in saved) &&
|
|
189
199
|
(saved.pending == null || isPending(saved.pending)) &&
|
|
190
200
|
(saved.last == null || isLast(saved.last));
|
|
191
201
|
|
|
202
|
+
/** @param {unknown} value @returns {value is Failure & { projectId: string }} */
|
|
203
|
+
const isSavedFailure = (value) =>
|
|
204
|
+
isJsonObject(value) &&
|
|
205
|
+
isSavedId(value.projectId) &&
|
|
206
|
+
typeof value.exportKey === "string" &&
|
|
207
|
+
typeof value.sentence === "string" &&
|
|
208
|
+
typeof value.final === "boolean" &&
|
|
209
|
+
typeof value.exportHelps === "boolean" &&
|
|
210
|
+
isOptionalBoolean(value.held);
|
|
211
|
+
|
|
192
212
|
/** @param {string} dir @returns {Promise<VersionRecord>} */
|
|
193
213
|
async function readState(dir) {
|
|
194
214
|
const file = statePath(dir);
|
|
@@ -198,6 +218,7 @@ async function readState(dir) {
|
|
|
198
218
|
const { last, pending } = saved;
|
|
199
219
|
return {
|
|
200
220
|
pending: pending ?? null,
|
|
221
|
+
failure: isSavedFailure(saved.failure) ? saved.failure : null,
|
|
201
222
|
last: typeof last?.fingerprint === "string" ? { ...last, fingerprint: last.fingerprint } : null,
|
|
202
223
|
};
|
|
203
224
|
}
|
|
@@ -234,7 +255,7 @@ const failures = new Map();
|
|
|
234
255
|
/** Which export a failure was about: the newest export file and the project as it was rendered. @param {string} dir */
|
|
235
256
|
const exportKeyOf = (dir) => {
|
|
236
257
|
const latest = latestExportFile(dir);
|
|
237
|
-
return latest ? `${latest.path}\n${latest.fingerprint ?? ""}` : "";
|
|
258
|
+
return latest ? `${latest.path}\n${latest.fingerprint ?? ""}\n${latest.id}` : "";
|
|
238
259
|
};
|
|
239
260
|
|
|
240
261
|
/** Whether the newest export can go up now: there is one, it is MP4, and the project has not moved past it.
|
|
@@ -259,7 +280,8 @@ export async function reviewVersion(projectPath, server = {}) {
|
|
|
259
280
|
const last = state.last?.projectId === projectId ? state.last : null;
|
|
260
281
|
const onServer =
|
|
261
282
|
server.latestReadyVersionId === undefined || server.latestReadyVersionId === last?.versionId;
|
|
262
|
-
const
|
|
283
|
+
const savedFailure = state.failure?.projectId === projectId ? state.failure : null;
|
|
284
|
+
const failed = savedFailure ?? failures.get(key);
|
|
263
285
|
return {
|
|
264
286
|
last: last
|
|
265
287
|
? { exportedAt: last.exportedAt, current: last.fingerprint === now && onServer }
|
|
@@ -279,8 +301,8 @@ export async function reviewVersion(projectPath, server = {}) {
|
|
|
279
301
|
|
|
280
302
|
/** The newest MP4 and the body that adds it; a stale, non-MP4, too big or too long export never sends anything.
|
|
281
303
|
* @param {string} dir @param {string} title @param {(file: string) => Promise<VideoFacts | null>} probe
|
|
282
|
-
* @param {FilesForRender} filesForExport */
|
|
283
|
-
async function exportSource(dir, title, probe, filesForExport) {
|
|
304
|
+
* @param {FilesForRender} filesForExport @param {boolean} requireSource */
|
|
305
|
+
async function exportSource(dir, title, probe, filesForExport, requireSource) {
|
|
284
306
|
const latest = latestExportFile(dir);
|
|
285
307
|
if (!latest || sourceOf(dir, await projectFingerprint(dir)) !== "ready")
|
|
286
308
|
return { failure: local("Export an MP4 of the project as it is now first.") };
|
|
@@ -307,18 +329,34 @@ async function exportSource(dir, title, probe, filesForExport) {
|
|
|
307
329
|
content_type: "video/mp4",
|
|
308
330
|
},
|
|
309
331
|
};
|
|
310
|
-
const snapshot = await
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
332
|
+
const snapshot = await sourceSnapshot(
|
|
333
|
+
dir,
|
|
334
|
+
body.source_fingerprint,
|
|
335
|
+
filesForExport,
|
|
336
|
+
requireSource,
|
|
337
|
+
);
|
|
338
|
+
if (missingRequiredSource(requireSource, snapshot))
|
|
339
|
+
return {
|
|
340
|
+
failure: local(
|
|
341
|
+
"Source changed after preparation. Retry sharing, or choose video-only explicitly.",
|
|
342
|
+
),
|
|
343
|
+
};
|
|
318
344
|
if (snapshot) body.snapshot = snapshot;
|
|
319
345
|
return { file: latest.path, body };
|
|
320
346
|
}
|
|
321
347
|
|
|
348
|
+
/** @param {boolean} required @param {import("./types/reviewWire.js").SnapshotRef | null} snapshot */
|
|
349
|
+
const missingRequiredSource = (required, snapshot) => required && !snapshot;
|
|
350
|
+
|
|
351
|
+
/** @param {string} dir @param {string | null} rendered @param {FilesForRender} filesForExport @param {boolean} requireSource */
|
|
352
|
+
async function sourceSnapshot(dir, rendered, filesForExport, requireSource) {
|
|
353
|
+
return filesForExport(dir, rendered).catch((error) => {
|
|
354
|
+
if (requireSource) throw error;
|
|
355
|
+
console.error("[review files] could not read source; adding video only:", error);
|
|
356
|
+
return null;
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
322
360
|
/** @param {ExportRequest["snapshot"]} a @param {ExportRequest["snapshot"]} b */
|
|
323
361
|
const sameSnapshot = (a, b) =>
|
|
324
362
|
a?.space_id === b?.space_id &&
|
|
@@ -368,7 +406,7 @@ async function createdExport(r, id, pending, kept) {
|
|
|
368
406
|
return await sendCreate(r, id, pending, kept);
|
|
369
407
|
} catch (error) {
|
|
370
408
|
const refused = error instanceof ReviewFailed && error.reason === "snapshot_mismatch";
|
|
371
|
-
if (!refused || !pending.body.snapshot || pending.unanswered) throw error;
|
|
409
|
+
if (!refused || !pending.body.snapshot || pending.unanswered || r.requireSource) throw error;
|
|
372
410
|
console.error("[review] snapshot refused; the version goes up without files:", error);
|
|
373
411
|
const body = { ...pending.body, client_operation_id: `export-${randomUUID()}` };
|
|
374
412
|
delete body.snapshot;
|
|
@@ -564,17 +602,31 @@ const spent = (error, pending) =>
|
|
|
564
602
|
* @param {string} id @param {ExportDTO} done @param {FromReview | null} fromReview @returns {Promise<Said | null>} */
|
|
565
603
|
async function finish(dir, state, pending, id, done, fromReview) {
|
|
566
604
|
const ready = done.status === "ready";
|
|
567
|
-
const last =
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
605
|
+
const last = readyRecord(pending, id, done);
|
|
606
|
+
const failure = ready ? null : said(new ReviewFailed(200, done.error?.reason ?? ""));
|
|
607
|
+
await writeState(dir, {
|
|
608
|
+
pending: null,
|
|
609
|
+
last: ready ? last : state.last,
|
|
610
|
+
...(failure && {
|
|
611
|
+
failure: { ...failure, projectId: id, exportKey: await failedExportKey(dir, pending) },
|
|
612
|
+
}),
|
|
613
|
+
});
|
|
576
614
|
if (ready) await sharedFromReview(fromReview, dir, pending.reviewSent?.since);
|
|
577
|
-
return
|
|
615
|
+
return failure;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** @param {string} dir @param {NonNullable<VersionRecord["pending"]>} pending */
|
|
619
|
+
async function failedExportKey(dir, pending) {
|
|
620
|
+
if (typeof pending.renderKey === "string") return pending.renderKey;
|
|
621
|
+
const latest = latestExportFile(dir);
|
|
622
|
+
if (!latest) return "";
|
|
623
|
+
const checksum =
|
|
624
|
+
latest.fingerprint === pending.body.source_fingerprint
|
|
625
|
+
? await fileSha256Base64(latest.path).catch(() => null)
|
|
626
|
+
: null;
|
|
627
|
+
return checksum === pending.body.local_upload.checksum_sha256
|
|
628
|
+
? exportKeyOf(dir)
|
|
629
|
+
: `pending:${pending.body.client_operation_id}`;
|
|
578
630
|
}
|
|
579
631
|
|
|
580
632
|
/** @param {Required<VersionRequest>} r @param {(progress: ReviewExportProgress) => void} report
|
|
@@ -582,17 +634,37 @@ async function finish(dir, state, pending, id, done, fromReview) {
|
|
|
582
634
|
async function run(r, report) {
|
|
583
635
|
report({ step: "preparing", sent: 0, total: 0 });
|
|
584
636
|
const state = await readState(r.projectPath);
|
|
585
|
-
const source = await exportSource(
|
|
637
|
+
const source = await exportSource(
|
|
638
|
+
r.projectPath,
|
|
639
|
+
r.title,
|
|
640
|
+
r.probe,
|
|
641
|
+
r.omitSource ? noSource : r.filesForExport,
|
|
642
|
+
r.requireSource,
|
|
643
|
+
);
|
|
586
644
|
if ("failure" in source) return source.failure ?? null;
|
|
587
645
|
const id = r.projectId ?? (await reviewProjectId(r.projectPath));
|
|
588
646
|
if (!id) return local("Share the project first, then add a version.");
|
|
589
|
-
if (
|
|
647
|
+
if (
|
|
648
|
+
sameSourceMode(r, state.last, source.body.snapshot) &&
|
|
649
|
+
(await alreadyUploaded(r.client, id, state, source.body))
|
|
650
|
+
)
|
|
651
|
+
return null;
|
|
590
652
|
return sendVersion(r, report, source, id, state);
|
|
591
653
|
}
|
|
654
|
+
const noSource = async () => null;
|
|
655
|
+
|
|
656
|
+
/** @param {Required<VersionRequest>} r @param {VersionRecord["last"]} last @param {ExportRequest["snapshot"]} snapshot */
|
|
657
|
+
function sameSourceMode(r, last, snapshot) {
|
|
658
|
+
if (r.requireSource) return sameSnapshot(last?.snapshot, snapshot);
|
|
659
|
+
if (r.omitSource) return last?.files !== true;
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
|
|
592
663
|
/** @param {ReviewClient} client @param {string} id @param {VersionRecord} state @param {ExportRequest} body */
|
|
593
664
|
async function alreadyUploaded(client, id, state, body) {
|
|
594
665
|
const last = state.last;
|
|
595
|
-
if (state.pending || !last || last.projectId !== id)
|
|
666
|
+
if (state.pending || state.failure?.projectId === id || !last || last.projectId !== id)
|
|
667
|
+
return false;
|
|
596
668
|
if (
|
|
597
669
|
last.fingerprint !== body.source_fingerprint ||
|
|
598
670
|
last.checksum !== body.local_upload.checksum_sha256
|
|
@@ -604,8 +676,10 @@ async function alreadyUploaded(client, id, state, body) {
|
|
|
604
676
|
* @param {{file: string, body: ExportRequest}} source @param {string} id @param {VersionRecord} state */
|
|
605
677
|
async function sendVersion(r, report, source, id, state) {
|
|
606
678
|
const first = state.last?.projectId !== id;
|
|
607
|
-
let pending = bodyToSend(state, source.body, id);
|
|
608
|
-
|
|
679
|
+
let pending = bindRenderKey(bodyToSend(state, source.body, id), r.projectPath);
|
|
680
|
+
const sourceConflict = pendingSourceConflict(r, pending, source.body);
|
|
681
|
+
if (sourceConflict) return local(sourceConflict);
|
|
682
|
+
await writeState(r.projectPath, (state = { ...state, pending, failure: null }));
|
|
609
683
|
try {
|
|
610
684
|
const answer = await createdExport(r, id, pending, (next) =>
|
|
611
685
|
writeState(r.projectPath, (state = { ...state, pending: next })),
|
|
@@ -637,10 +711,21 @@ async function sendVersion(r, report, source, id, state) {
|
|
|
637
711
|
}
|
|
638
712
|
}
|
|
639
713
|
|
|
714
|
+
/** @param {Required<VersionRequest>} r @param {NonNullable<VersionRecord["pending"]>} pending @param {ExportRequest} fresh */
|
|
715
|
+
function pendingSourceConflict(r, pending, fresh) {
|
|
716
|
+
if (r.requireSource && !pending.body.snapshot)
|
|
717
|
+
return "A pending video-only upload must finish before attaching source. Retry with --video-only, then share source again.";
|
|
718
|
+
if (r.omitSource && pending.body.snapshot)
|
|
719
|
+
return "A pending upload includes source. Check its status or retry with source before creating a video-only version.";
|
|
720
|
+
if (r.requireSource && !sameSnapshot(pending.body.snapshot, fresh.snapshot))
|
|
721
|
+
return "A pending upload includes a different source snapshot. Check its status before sharing again.";
|
|
722
|
+
return null;
|
|
723
|
+
}
|
|
724
|
+
|
|
640
725
|
/** @param {Required<VersionRequest>} r @param {VersionRecord} state
|
|
641
726
|
* @param {NonNullable<VersionRecord["pending"]>} pending @param {string} id @param {ExportDTO} done @param {boolean} first */
|
|
642
727
|
async function finishRun(r, state, pending, id, done, first) {
|
|
643
|
-
if (going(done)) return stillGoing(done);
|
|
728
|
+
if (going(done)) return r.allowPending ? null : stillGoing(done);
|
|
644
729
|
const failed = await finish(r.projectPath, state, pending, id, done, r.fromReview);
|
|
645
730
|
if (!failed) {
|
|
646
731
|
const fromReview = pending.reviewSent?.value;
|
|
@@ -663,8 +748,11 @@ export async function addVersion(request) {
|
|
|
663
748
|
now: () => Date.now(),
|
|
664
749
|
onProgress: () => {},
|
|
665
750
|
open: () => true,
|
|
666
|
-
|
|
751
|
+
allowPending: false,
|
|
752
|
+
filesForExport: noSource,
|
|
667
753
|
fromReview: null,
|
|
754
|
+
requireSource: false,
|
|
755
|
+
omitSource: false,
|
|
668
756
|
onAdded: () => {},
|
|
669
757
|
...request,
|
|
670
758
|
projectId: request.projectId ?? null,
|
|
@@ -755,7 +843,7 @@ async function settleSavedExport(dir, client, fromReview) {
|
|
|
755
843
|
const id = await reviewProjectId(dir);
|
|
756
844
|
if (!pending?.videoId || !id || pending.projectId !== id) return;
|
|
757
845
|
const done = await client.readExport(id, pending.videoId);
|
|
758
|
-
if (going(done)) return;
|
|
846
|
+
if (done.status === "upload_pending" || going(done)) return;
|
|
759
847
|
const failure = await finish(dir, state, pending, id, done, fromReview);
|
|
760
848
|
const key = normalize(dir);
|
|
761
849
|
if (failure) failures.set(key, { ...failure, exportKey: exportKeyOf(dir) });
|
|
@@ -825,3 +913,23 @@ export async function pendingReviewExport(projectPath) {
|
|
|
825
913
|
checksum: pending.body.local_upload.checksum_sha256,
|
|
826
914
|
};
|
|
827
915
|
}
|
|
916
|
+
|
|
917
|
+
/** @param {NonNullable<VersionRecord["pending"]>} pending @param {string} id @param {ExportDTO} done */
|
|
918
|
+
function readyRecord(pending, id, done) {
|
|
919
|
+
return {
|
|
920
|
+
fingerprint: pending.body.source_fingerprint ?? "",
|
|
921
|
+
versionId: done.version_id,
|
|
922
|
+
exportedAt: done.exported_at,
|
|
923
|
+
projectId: id,
|
|
924
|
+
files: Boolean(pending.body.snapshot),
|
|
925
|
+
...(pending.body.snapshot && { snapshot: pending.body.snapshot }),
|
|
926
|
+
checksum: pending.body.local_upload.checksum_sha256,
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** @param {NonNullable<VersionRecord["pending"]>} pending @param {string} dir */
|
|
931
|
+
function bindRenderKey(pending, dir) {
|
|
932
|
+
return typeof pending.renderKey === "string"
|
|
933
|
+
? pending
|
|
934
|
+
: { ...pending, renderKey: exportKeyOf(dir) };
|
|
935
|
+
}
|
package/src/reviewUpload.mjs
CHANGED
|
@@ -18,6 +18,22 @@ export function fileSha256Base64(file) {
|
|
|
18
18
|
|
|
19
19
|
/** @typedef {"sent" | "expired" | "refused" | "offline"} PutOutcome */
|
|
20
20
|
|
|
21
|
+
/** @param {string} url */
|
|
22
|
+
export function trustedUploadUrl(url) {
|
|
23
|
+
if (!URL.canParse(url)) return null;
|
|
24
|
+
const target = new URL(url);
|
|
25
|
+
const loopback = target.protocol === "http:" && target.hostname === "127.0.0.1";
|
|
26
|
+
if (
|
|
27
|
+
(target.protocol !== "https:" && !loopback) ||
|
|
28
|
+
target.username ||
|
|
29
|
+
target.password ||
|
|
30
|
+
target.hash ||
|
|
31
|
+
!reviewUploadOrigins().includes(target.origin)
|
|
32
|
+
)
|
|
33
|
+
return null;
|
|
34
|
+
return target;
|
|
35
|
+
}
|
|
36
|
+
|
|
21
37
|
/** Streams the file to the signed URL through main's fetch (the app's proxy), the server's headers as given plus
|
|
22
38
|
* the length, and no bearer. Nothing sent or answered for `stallMs` ends it as offline, never "Uploading" forever.
|
|
23
39
|
* @param {{ put_url: string, headers: Record<string, string> }} upload @param {string} file @param {number} size
|
|
@@ -29,22 +45,8 @@ export async function putFile(
|
|
|
29
45
|
onSent,
|
|
30
46
|
{ stallMs = 60_000, fetchImpl = fetch } = {},
|
|
31
47
|
) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
target = new URL(upload.put_url);
|
|
35
|
-
} catch {
|
|
36
|
-
return "refused";
|
|
37
|
-
}
|
|
38
|
-
const loopback = target.protocol === "http:" && target.hostname === "127.0.0.1";
|
|
39
|
-
if (
|
|
40
|
-
(target.protocol !== "https:" && !loopback) ||
|
|
41
|
-
target.username ||
|
|
42
|
-
target.password ||
|
|
43
|
-
target.hash ||
|
|
44
|
-
!reviewUploadOrigins().includes(target.origin)
|
|
45
|
-
) {
|
|
46
|
-
return "refused";
|
|
47
|
-
}
|
|
48
|
+
const target = trustedUploadUrl(upload.put_url);
|
|
49
|
+
if (!target) return "refused";
|
|
48
50
|
const stall = new AbortController();
|
|
49
51
|
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
50
52
|
let timer;
|
|
@@ -0,0 +1,236 @@
|
|
|
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";
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
}
|