@mieweb/pulsevault 0.4.2 → 0.6.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +216 -1
  2. package/OPERATIONS.md +135 -44
  3. package/PROTOCOL.md +63 -4
  4. package/README.md +186 -34
  5. package/dist/app.d.ts +30 -5
  6. package/dist/app.d.ts.map +1 -1
  7. package/dist/app.js +24 -5
  8. package/dist/app.js.map +1 -1
  9. package/dist/augment.d.ts +3 -0
  10. package/dist/augment.d.ts.map +1 -1
  11. package/dist/augment.js.map +1 -1
  12. package/dist/core.d.ts +107 -8
  13. package/dist/core.d.ts.map +1 -1
  14. package/dist/core.js +381 -47
  15. package/dist/core.js.map +1 -1
  16. package/dist/lib/authorize.d.ts +28 -6
  17. package/dist/lib/authorize.d.ts.map +1 -1
  18. package/dist/lib/capability-token.d.ts +26 -0
  19. package/dist/lib/capability-token.d.ts.map +1 -1
  20. package/dist/lib/capability-token.js +73 -5
  21. package/dist/lib/capability-token.js.map +1 -1
  22. package/dist/lib/completion.d.ts +138 -0
  23. package/dist/lib/completion.d.ts.map +1 -0
  24. package/dist/lib/completion.js +375 -0
  25. package/dist/lib/completion.js.map +1 -0
  26. package/dist/lib/deeplinks.d.ts +18 -2
  27. package/dist/lib/deeplinks.d.ts.map +1 -1
  28. package/dist/lib/deeplinks.js +13 -2
  29. package/dist/lib/deeplinks.js.map +1 -1
  30. package/dist/lib/magic.d.ts +41 -1
  31. package/dist/lib/magic.d.ts.map +1 -1
  32. package/dist/lib/magic.js +104 -0
  33. package/dist/lib/magic.js.map +1 -1
  34. package/dist/lib/options.d.ts +2 -0
  35. package/dist/lib/options.d.ts.map +1 -1
  36. package/dist/lib/options.js +8 -2
  37. package/dist/lib/options.js.map +1 -1
  38. package/dist/lib/protocol-version.gen.js +1 -1
  39. package/dist/lib/pulsevaultTus.d.ts +28 -26
  40. package/dist/lib/pulsevaultTus.d.ts.map +1 -1
  41. package/dist/lib/pulsevaultTus.js +149 -32
  42. package/dist/lib/pulsevaultTus.js.map +1 -1
  43. package/dist/lib/web-ready.d.ts +138 -18
  44. package/dist/lib/web-ready.d.ts.map +1 -1
  45. package/dist/lib/web-ready.js +453 -91
  46. package/dist/lib/web-ready.js.map +1 -1
  47. package/dist/routes/pulsevault.d.ts +12 -1
  48. package/dist/routes/pulsevault.d.ts.map +1 -1
  49. package/dist/routes/pulsevault.js +92 -1
  50. package/dist/routes/pulsevault.js.map +1 -1
  51. package/dist/storage/local.d.ts +7 -1
  52. package/dist/storage/local.d.ts.map +1 -1
  53. package/dist/storage/local.js +408 -52
  54. package/dist/storage/local.js.map +1 -1
  55. package/dist/storage/s3.d.ts +7 -1
  56. package/dist/storage/s3.d.ts.map +1 -1
  57. package/dist/storage/s3.js +256 -14
  58. package/dist/storage/s3.js.map +1 -1
  59. package/dist/storage/types.d.ts +126 -0
  60. package/dist/storage/types.d.ts.map +1 -1
  61. package/dist/storage/types.js +7 -0
  62. package/dist/storage/types.js.map +1 -1
  63. package/package.json +2 -2
  64. package/protocol/openapi.json +270 -1
  65. package/protocol/schemas/artifact-status.schema.json +61 -0
  66. package/protocol/schemas/capability-token.schema.json +3 -0
  67. package/protocol/schemas/deep-link.schema.json +2 -2
  68. package/scripts/web-ready-migrate.mjs +35 -11
package/dist/core.js CHANGED
@@ -1,10 +1,14 @@
1
1
  import send from '@fastify/send';
2
- import { createPulsevaultTusServer, pulseVaultTusContext, artifactIdFromUploadId, } from './lib/pulsevaultTus.js';
2
+ import { fstat, ReadStream } from 'node:fs';
3
+ import { createPulsevaultTusServer, pulseVaultTusContext, artifactIdFromUploadId, normalizeArtifactName, } from './lib/pulsevaultTus.js';
4
+ import { createCompletionRunner, validateCompletionOptions, } from './lib/completion.js';
3
5
  import { startRetentionSweep, validateRetentionOptions, } from './lib/retention.js';
4
6
  import { pulseVaultError, statusCodeOf } from './lib/errors.js';
5
7
  import { isUuid } from './lib/uuid.js';
8
+ import { webReadyAvailable } from './lib/web-ready.js';
6
9
  import { consoleLogger } from './lib/request.js';
7
- import { parseUploadKind } from './storage/types.js';
10
+ import { parseUploadKind, uploadIdOf } from './storage/types.js';
11
+ import { normalizeAppVersion } from './lib/protocol.js';
8
12
  import { normalizeAllowedExtensions, validateBasePath, validateMaxUploadSize, rejectRemovedOptions, validateAllowedExtensions, warnIfUsingDeprecatedProjectHooks, composeValidatePayload, composeOnUploadComplete, } from './lib/options.js';
9
13
  import { buildCapabilities, PROTOCOL_VERSION } from './lib/capabilities.js';
10
14
  import { outdatedClientRejection } from './lib/protocol.js';
@@ -28,6 +32,9 @@ function parseUploadMetadata(header) {
28
32
  let projectidRaw;
29
33
  let kind = 'video';
30
34
  let relatedTo;
35
+ let filename;
36
+ let name;
37
+ let appVersion;
31
38
  for (const pair of header.split(',')) {
32
39
  const trimmed = pair.trim();
33
40
  if (!trimmed)
@@ -56,6 +63,15 @@ function parseUploadMetadata(header) {
56
63
  else if (key === 'relatedTo' && !relatedTo) {
57
64
  relatedTo = isUuid(decoded) ? decoded : undefined;
58
65
  }
66
+ else if (key === 'filename') {
67
+ filename ??= decoded.trim() || undefined;
68
+ }
69
+ else if (key === 'name') {
70
+ name ??= normalizeArtifactName(decoded);
71
+ }
72
+ else if (key === 'appVersion') {
73
+ appVersion ??= normalizeAppVersion(decoded);
74
+ }
59
75
  }
60
76
  catch {
61
77
  // ignore malformed base64
@@ -63,7 +79,65 @@ function parseUploadMetadata(header) {
63
79
  }
64
80
  const candidate = (artifactIdRaw ?? videoidRaw ?? projectidRaw ?? '').trim();
65
81
  const artifactId = isUuid(candidate) ? candidate : undefined;
66
- return { artifactId, kind, relatedTo };
82
+ return { artifactId, kind, relatedTo, filename, name, appVersion };
83
+ }
84
+ /** Lowercase extension of a filename, with the leading dot; `undefined` when it has none. */
85
+ function extensionOf(filename) {
86
+ if (!filename)
87
+ return undefined;
88
+ const dot = filename.lastIndexOf('.');
89
+ return dot > 0 ? filename.slice(dot).toLowerCase() : undefined;
90
+ }
91
+ /**
92
+ * Whether a file stream from `@fastify/send` opened something other than the file `send`
93
+ * described in its headers (`stat`): the file is gone, or another file was renamed onto the
94
+ * path in between (a conversion replacing the bytes). Then the stream is closed and nothing has
95
+ * been written. Streams that aren't file streams (an empty body for a `304`) resolve `false`.
96
+ */
97
+ async function fileChanged(stream, stat) {
98
+ if (!(stream instanceof ReadStream))
99
+ return false;
100
+ if (stream.pending) {
101
+ const gone = await new Promise((resolve, reject) => {
102
+ const onReady = () => {
103
+ stream.off('error', onError);
104
+ resolve(false);
105
+ };
106
+ const onError = (err) => {
107
+ stream.off('ready', onReady);
108
+ // Any other failure to open is the server's, before anything was written: a 500.
109
+ if (err.code === 'ENOENT')
110
+ resolve(true);
111
+ else
112
+ reject(err);
113
+ };
114
+ stream.once('ready', onReady);
115
+ stream.once('error', onError);
116
+ });
117
+ if (gone)
118
+ return true;
119
+ }
120
+ // The descriptor the stream opened (set once it's `ready`; not in the type definitions).
121
+ const fd = stream.fd;
122
+ if (!stat || typeof fd !== 'number')
123
+ return false;
124
+ const opened = await new Promise((resolve, reject) => fstat(fd, (err, st) => (err ? reject(err) : resolve(st))));
125
+ const same = opened.ino === stat.ino && opened.dev === stat.dev && opened.size === stat.size && opened.mtimeMs === stat.mtimeMs;
126
+ if (!same)
127
+ stream.destroy();
128
+ return !same;
129
+ }
130
+ /** The hook-context fields that come from stored metadata, for every phase after `create`. */
131
+ function describedFields(meta) {
132
+ if (!meta)
133
+ return {};
134
+ return {
135
+ ...(meta.name ? { name: meta.name } : {}),
136
+ ...(meta.appVersion ? { appVersion: meta.appVersion } : {}),
137
+ filename: meta.filename,
138
+ ext: meta.sourceExt ?? meta.ext,
139
+ ...(meta.context !== undefined ? { context: meta.context } : {}),
140
+ };
67
141
  }
68
142
  /**
69
143
  * `@tus/server`'s `BaseHandler.getFileIdFromRequest` — the function that
@@ -203,6 +277,29 @@ export function createPulseVaultCore(options) {
203
277
  await onArtifactEvent?.({ phase: 'remove', artifactId, kind, reason: 'abandoned' });
204
278
  })
205
279
  : null;
280
+ const pulseShape = options.pulseShape ?? true;
281
+ const lockWhenReady = options.lockWhenReady === true;
282
+ const describe = (artifactId) => typeof storage.describeArtifact === 'function'
283
+ ? storage.describeArtifact(artifactId)
284
+ : Promise.resolve(null);
285
+ if (options.reclaim !== undefined && options.reclaim !== false) {
286
+ const { idleSeconds } = options.reclaim;
287
+ if (idleSeconds !== undefined && !(idleSeconds >= 0 && Number.isFinite(idleSeconds))) {
288
+ throw new TypeError('`reclaim.idleSeconds` must be a number of seconds, at least 0');
289
+ }
290
+ }
291
+ const reclaim = options.reclaim === false ? false : { idleSeconds: options.reclaim?.idleSeconds ?? 300 };
292
+ const replay = options.replayCompletions === false ? false : (options.replayCompletions ?? {});
293
+ validateCompletionOptions({ storage, webReady: options.webReady, replay });
294
+ const completion = createCompletionRunner({
295
+ storage,
296
+ onUploadComplete,
297
+ onArtifactEvent,
298
+ webReady: options.webReady,
299
+ replay,
300
+ logger,
301
+ });
302
+ completion.start();
206
303
  const tusPath = `${basePath}/upload`;
207
304
  const tusServer = createPulsevaultTusServer({
208
305
  storage,
@@ -210,10 +307,36 @@ export function createPulseVaultCore(options) {
210
307
  maxSize: maxUploadSize,
211
308
  allowedExtensions,
212
309
  validatePayload,
213
- onUploadComplete,
310
+ completion,
214
311
  onArtifactEvent,
312
+ pulseShape,
313
+ reclaim,
314
+ ...(lockWhenReady ? { isLocked: (artifactId) => isLocked(artifactId) } : {}),
215
315
  logger,
216
316
  });
317
+ /**
318
+ * With `lockWhenReady`, whether a delete must be refused: the artifact is finished, or it
319
+ * belongs to a finished video. The check reads storage just before the removal; the TUS
320
+ * `DELETE` additionally runs under tus's per-upload lock, so it can't interleave with the
321
+ * final chunk of the same upload.
322
+ */
323
+ const isFinished = async (artifactId) => {
324
+ // `describeArtifact` reads storage itself; `resolve` may answer from a per-instance cache
325
+ // that hasn't seen another instance finish the upload.
326
+ if (typeof storage.describeArtifact === 'function') {
327
+ return (await storage.describeArtifact(artifactId))?.ready ?? false;
328
+ }
329
+ return (await storage.resolve(artifactId)) !== null;
330
+ };
331
+ const isLocked = async (artifactId, relatedTo) => {
332
+ if (!lockWhenReady)
333
+ return false;
334
+ const meta = await describe(artifactId);
335
+ if (meta ? meta.ready : await isFinished(artifactId))
336
+ return true;
337
+ const parent = relatedTo ?? meta?.relatedTo ?? (await resolveStorageRelatedTo(storage, artifactId));
338
+ return parent !== undefined && (await isFinished(parent));
339
+ };
217
340
  /**
218
341
  * Run the consumer's `authorize` hook (if any) for a TUS request. Returns
219
342
  * `true` iff the request may proceed; on rejection, this function already
@@ -223,17 +346,27 @@ export function createPulseVaultCore(options) {
223
346
  let artifactId;
224
347
  let kind = 'video';
225
348
  let relatedTo;
349
+ let fields = {};
226
350
  if (phase === 'create') {
227
351
  const meta = req.headers['upload-metadata'];
228
352
  if (typeof meta === 'string') {
229
- ({ artifactId, kind, relatedTo } = parseUploadMetadata(meta));
353
+ const parsed = parseUploadMetadata(meta);
354
+ ({ artifactId, kind, relatedTo } = parsed);
355
+ fields = {
356
+ ...(parsed.name ? { name: parsed.name } : {}),
357
+ ...(parsed.appVersion ? { appVersion: parsed.appVersion } : {}),
358
+ ...(parsed.filename ? { filename: parsed.filename } : {}),
359
+ ...(extensionOf(parsed.filename) ? { ext: extensionOf(parsed.filename) } : {}),
360
+ };
230
361
  }
231
362
  }
232
363
  else {
233
364
  artifactId = artifactIdFromTusUrl(req.url ?? '');
234
365
  if (artifactId) {
235
- kind = await resolveStorageKind(storage, artifactId);
236
- relatedTo = await resolveStorageRelatedTo(storage, artifactId);
366
+ const described = await describe(artifactId);
367
+ kind = described?.kind ?? (await resolveStorageKind(storage, artifactId));
368
+ relatedTo = described?.relatedTo ?? (await resolveStorageRelatedTo(storage, artifactId));
369
+ fields = describedFields(described);
237
370
  }
238
371
  }
239
372
  if (artifactId) {
@@ -254,8 +387,9 @@ export function createPulseVaultCore(options) {
254
387
  return { ok: true, artifactId, kind, relatedTo };
255
388
  }
256
389
  try {
257
- await authorize(req, { phase, artifactId, kind, relatedTo });
258
- return { ok: true, artifactId, kind, relatedTo };
390
+ const result = await authorize(req, { phase, artifactId, kind, relatedTo, ...fields });
391
+ const context = phase === 'create' && result ? result.context : undefined;
392
+ return { ok: true, artifactId, kind, relatedTo, ...(context !== undefined ? { context } : {}) };
259
393
  }
260
394
  catch (err) {
261
395
  const statusCode = statusCodeOf(err, 403);
@@ -311,7 +445,13 @@ export function createPulseVaultCore(options) {
311
445
  const authz = await runAuthorize(req, res, phase);
312
446
  if (!authz.ok)
313
447
  return;
314
- await pulseVaultTusContext.run({ request: req, artifactId: authz.artifactId }, () => tusServer.handle(req, res));
448
+ // `lockWhenReady` for a TUS DELETE is checked inside the datastore's removal, under tus's
449
+ // per-upload lock (see `withArtifactRemoval`).
450
+ await pulseVaultTusContext.run({
451
+ request: req,
452
+ artifactId: authz.artifactId,
453
+ ...(authz.context !== undefined ? { context: authz.context } : {}),
454
+ }, () => tusServer.handle(req, res));
315
455
  }
316
456
  catch (err) {
317
457
  failClosed(res, err, 'tus handler');
@@ -337,13 +477,21 @@ export function createPulseVaultCore(options) {
337
477
  writeJson(res, 400, pulseVaultError('`artifactId` must be a valid UUID'));
338
478
  return undefined;
339
479
  }
340
- const kind = await resolveStorageKind(storage, artifactId);
341
- const relatedTo = await resolveStorageRelatedTo(storage, artifactId);
480
+ const described = await describe(artifactId);
481
+ const kind = described?.kind ?? (await resolveStorageKind(storage, artifactId));
482
+ const relatedTo = described?.relatedTo ?? (await resolveStorageRelatedTo(storage, artifactId));
342
483
  stashPulseVaultContext(req, { artifactId, kind, relatedTo });
343
484
  if (!authorize)
344
485
  return { kind, relatedTo };
345
486
  try {
346
- await authorize(req, { phase, artifactId, kind, relatedTo, token });
487
+ await authorize(req, {
488
+ phase,
489
+ artifactId,
490
+ kind,
491
+ relatedTo,
492
+ token,
493
+ ...describedFields(described),
494
+ });
347
495
  return { kind, relatedTo };
348
496
  }
349
497
  catch (err) {
@@ -367,6 +515,10 @@ export function createPulseVaultCore(options) {
367
515
  writeJson(res, 501, pulseVaultError('Storage adapter does not support delete'));
368
516
  return;
369
517
  }
518
+ if (await isLocked(artifactId, prepared.relatedTo)) {
519
+ writeJson(res, 403, pulseVaultError('A finished artifact is locked'));
520
+ return;
521
+ }
370
522
  const removed = await storage.remove(artifactId);
371
523
  if (!removed) {
372
524
  writeJson(res, 404, pulseVaultError('Artifact not found'));
@@ -427,44 +579,205 @@ export function createPulseVaultCore(options) {
427
579
  const prepared = await prepareArtifactRequest(req, res, artifactId, 'resolve', token);
428
580
  if (!prepared)
429
581
  return;
430
- const resolved = await storage.resolve(artifactId);
431
- if (!resolved) {
432
- writeJson(res, 404, pulseVaultError('Artifact not found'));
433
- return;
582
+ // Twice at most: a conversion can switch the artifact to its new file between the
583
+ // resolution and the open, and then the second resolution finds the new one.
584
+ for (let attempt = 0; attempt < 2; attempt++) {
585
+ const resolved = await storage.resolve(artifactId);
586
+ if (!resolved)
587
+ break;
588
+ // While a video is being converted its URL serves the original bytes, which the
589
+ // conversion replaces: revalidate instead of the configured (possibly `immutable`) cache,
590
+ // so nobody keeps the original once the converted file is in place. Also when this
591
+ // instance resolved a file other than the one storage now names (another instance
592
+ // switched it): those bytes are about to go.
593
+ const meta = completion.converts('video') ? await describe(artifactId) : null;
594
+ const converting = meta !== null &&
595
+ completion.converts(meta.kind) &&
596
+ (!meta.converted || (resolved.kind === 'stream' && !resolved.filename.endsWith(meta.ext)));
597
+ if (await serveResolution(req, res, resolved, converting))
598
+ return;
434
599
  }
435
- if (resolved.kind === 'redirect') {
436
- res.writeHead(resolved.statusCode ?? 302, { Location: resolved.url });
437
- res.end();
600
+ writeJson(res, 404, pulseVaultError('Artifact not found'));
601
+ }
602
+ catch (err) {
603
+ failClosed(res, err, 'artifact get');
604
+ }
605
+ };
606
+ /**
607
+ * Stream or redirect to a resolved artifact — the tail of the GET route, shared with the poster
608
+ * route. An artifact URL names immutable bytes once finished, so it takes the configured
609
+ * `cache`; the poster URL is a lookup whose answer changes when a newer thumbnail lands, and a
610
+ * video still being converted is about to change, so those are revalidated on every request
611
+ * (`mustRevalidate`). Resolves `false`, having written nothing, when the file opened isn't the
612
+ * one `send` described (a conversion removed or replaced it in between): the caller resolves
613
+ * again.
614
+ */
615
+ const serveResolution = async (req, res, resolved, mustRevalidate = false) => {
616
+ if (resolved.kind === 'redirect') {
617
+ res.writeHead(resolved.statusCode ?? 302, {
618
+ Location: resolved.url,
619
+ ...(mustRevalidate ? { 'cache-control': 'no-store' } : {}),
620
+ });
621
+ res.end();
622
+ return true;
623
+ }
624
+ const cacheOptions = mustRevalidate
625
+ ? { cacheControl: true, maxAge: 0, immutable: false }
626
+ : cache;
627
+ const result = await send(req, resolved.filename, { root: resolved.root, ...cacheOptions });
628
+ if (result.type === 'error') {
629
+ if (result.statusCode === 404)
630
+ return false;
631
+ writeJson(res, result.statusCode, pulseVaultError(result.metadata.error.message));
632
+ return true;
633
+ }
634
+ // The file stream opens it after `send` returned: wait for that before the headers go out,
635
+ // and check it opened the file the headers describe. Once open, the file being replaced or
636
+ // removed no longer matters to this response.
637
+ if (await fileChanged(result.stream, result.metadata.stat))
638
+ return false;
639
+ const headers = { ...result.headers };
640
+ // If the storage adapter provided an explicit content type (e.g. for
641
+ // non-standard extensions like `.pulse`), override what @fastify/send
642
+ // would otherwise infer from the filename.
643
+ if (resolved.contentType) {
644
+ headers['content-type'] = resolved.contentType;
645
+ }
646
+ res.writeHead(result.statusCode, headers);
647
+ // Headers are on the wire now, so a mid-stream read error (file removed
648
+ // after stat, disk error) can't become a 500 — destroy the socket instead
649
+ // of letting an unhandled 'error' crash the process. Destroy the source on
650
+ // client disconnect so the file descriptor is never leaked.
651
+ result.stream.on('error', (err) => failClosed(res, err, 'artifact stream'));
652
+ // A client that aborts mid-download makes the *response* stream emit its own
653
+ // 'error' (ECONNRESET / EPIPE). `.pipe` doesn't forward that, and an unhandled
654
+ // 'error' on `res` is fatal to the process — so swallow it (the 'close' handler
655
+ // above already tears down the source and reclaims the fd).
656
+ res.on('error', () => { });
657
+ res.on('close', () => result.stream.destroy());
658
+ result.stream.pipe(res);
659
+ return true;
660
+ };
661
+ /** The declared length and bytes received of an upload, from tus's own record. */
662
+ const uploadProgress = async (meta) => {
663
+ try {
664
+ const upload = await storage.datastore.getUpload(uploadIdOf(meta));
665
+ return {
666
+ ...(typeof upload.size === 'number' ? { size: upload.size } : {}),
667
+ ...(typeof upload.offset === 'number' ? { bytesReceived: upload.offset } : {}),
668
+ };
669
+ }
670
+ catch {
671
+ return {};
672
+ }
673
+ };
674
+ const getStatus = async (artifactId) => {
675
+ const meta = isUuid(artifactId) ? await describe(artifactId) : null;
676
+ if (!meta)
677
+ return { artifactId, state: 'unknown' };
678
+ const state = !meta.ready
679
+ ? 'uploading'
680
+ : completion.converts(meta.kind) && !meta.converted
681
+ ? 'processing'
682
+ : 'ready';
683
+ return {
684
+ artifactId,
685
+ state,
686
+ kind: meta.kind,
687
+ ...(meta.relatedTo ? { relatedTo: meta.relatedTo } : {}),
688
+ ...(meta.name ? { name: meta.name } : {}),
689
+ ...(await uploadProgress(meta)),
690
+ acknowledged: meta.acknowledged,
691
+ ...(meta.webReady ? { webReady: meta.webReady } : {}),
692
+ ...(meta.outcome !== undefined ? { outcome: meta.outcome } : {}),
693
+ ...(meta.context !== undefined ? { context: meta.context } : {}),
694
+ };
695
+ };
696
+ const getPulse = async (artifactId) => {
697
+ const video = isUuid(artifactId) ? await describe(artifactId) : null;
698
+ const pulse = { video };
699
+ if (!video || typeof storage.listRelated !== 'function')
700
+ return pulse;
701
+ for await (const record of storage.listRelated(artifactId)) {
702
+ if (!record.ready)
703
+ continue;
704
+ const slot = record.kind === 'thumbnail'
705
+ ? 'thumbnail'
706
+ : record.kind === 'captions'
707
+ ? 'captions'
708
+ : record.kind === 'project'
709
+ ? 'manifest'
710
+ : null;
711
+ if (!slot)
712
+ continue;
713
+ // One per kind: a pulse uploads each once, and a retry that re-sent one is the newer —
714
+ // by when it finished (`readyAt`), which later bookkeeping never moves.
715
+ const meta = await describe(record.artifactId);
716
+ if (!meta)
717
+ continue;
718
+ const current = pulse[slot];
719
+ if (current && (current.readyAt ?? current.updatedAt) >= (meta.readyAt ?? meta.updatedAt))
720
+ continue;
721
+ pulse[slot] = meta;
722
+ }
723
+ return pulse;
724
+ };
725
+ const recordOutcome = async (artifactId, outcome) => {
726
+ if (typeof storage.patchArtifact !== 'function') {
727
+ throw new TypeError('recordOutcome needs a storage adapter with `patchArtifact`');
728
+ }
729
+ if (!isUuid(artifactId))
730
+ return false;
731
+ return storage.patchArtifact(artifactId, { outcome: outcome === undefined ? null : outcome });
732
+ };
733
+ /**
734
+ * `GET /artifacts/:artifactId/status`: where an upload is, for a page waiting on it. Authorized
735
+ * as `status`, which `createCapabilityAuthorize` grants to the pairing token and to a view token.
736
+ */
737
+ const handleStatus = async (req, res, artifactId, token) => {
738
+ stampProtocolVersion(res);
739
+ if (rejectOutdatedClient(req, res))
740
+ return;
741
+ try {
742
+ const prepared = await prepareArtifactRequest(req, res, artifactId, 'status', token);
743
+ if (!prepared)
438
744
  return;
439
- }
440
- const result = await send(req, resolved.filename, { root: resolved.root, ...cache });
441
- if (result.type === 'error') {
442
- writeJson(res, result.statusCode, pulseVaultError(result.metadata.error.message));
745
+ res.setHeader('cache-control', 'no-store');
746
+ // The token's context is the host's data about the upload, not the viewer's business.
747
+ const { context: _context, ...status } = await getStatus(artifactId);
748
+ writeJson(res, 200, status);
749
+ }
750
+ catch (err) {
751
+ failClosed(res, err, 'artifact status');
752
+ }
753
+ };
754
+ /**
755
+ * `GET /artifacts/:artifactId/poster`: the finished thumbnail `relatedTo` the video, served as
756
+ * the artifact route would serve it. Authorized as `resolve` on the video, so whoever may watch
757
+ * it may see its poster. 404 until the poster has landed.
758
+ */
759
+ const handlePoster = async (req, res, artifactId, token) => {
760
+ stampProtocolVersion(res);
761
+ if (rejectOutdatedClient(req, res))
762
+ return;
763
+ try {
764
+ const prepared = await prepareArtifactRequest(req, res, artifactId, 'resolve', token);
765
+ if (!prepared)
766
+ return;
767
+ const { thumbnail } = await getPulse(artifactId);
768
+ const resolved = thumbnail ? await storage.resolve(thumbnail.artifactId) : null;
769
+ if (!resolved) {
770
+ res.setHeader('cache-control', 'no-store');
771
+ writeJson(res, 404, pulseVaultError('No poster for this artifact'));
443
772
  return;
444
773
  }
445
- const headers = { ...result.headers };
446
- // If the storage adapter provided an explicit content type (e.g. for
447
- // non-standard extensions like `.pulse`), override what @fastify/send
448
- // would otherwise infer from the filename.
449
- if (resolved.contentType) {
450
- headers['content-type'] = resolved.contentType;
774
+ if (!(await serveResolution(req, res, resolved, true))) {
775
+ res.setHeader('cache-control', 'no-store');
776
+ writeJson(res, 404, pulseVaultError('No poster for this artifact'));
451
777
  }
452
- res.writeHead(result.statusCode, headers);
453
- // Headers are on the wire now, so a mid-stream read error (file removed
454
- // after stat, disk error) can't become a 500 — destroy the socket instead
455
- // of letting an unhandled 'error' crash the process. Destroy the source on
456
- // client disconnect so the file descriptor is never leaked.
457
- result.stream.on('error', (err) => failClosed(res, err, 'artifact stream'));
458
- // A client that aborts mid-download makes the *response* stream emit its own
459
- // 'error' (ECONNRESET / EPIPE). `.pipe` doesn't forward that, and an unhandled
460
- // 'error' on `res` is fatal to the process — so swallow it (the 'close' handler
461
- // above already tears down the source and reclaims the fd).
462
- res.on('error', () => { });
463
- res.on('close', () => result.stream.destroy());
464
- result.stream.pipe(res);
465
778
  }
466
779
  catch (err) {
467
- failClosed(res, err, 'artifact get');
780
+ failClosed(res, err, 'artifact poster');
468
781
  }
469
782
  };
470
783
  const handler = async (req, res, next) => {
@@ -520,6 +833,16 @@ export function createPulseVaultCore(options) {
520
833
  await handleViewLink(req, res, viewLinkMatch[1]);
521
834
  return;
522
835
  }
836
+ const statusMatch = pathname.match(/^\/artifacts\/([^/]+)\/status$/);
837
+ if (statusMatch?.[1] && req.method === 'GET') {
838
+ await handleStatus(req, res, statusMatch[1], url.searchParams.get('token') ?? undefined);
839
+ return;
840
+ }
841
+ const posterMatch = pathname.match(/^\/artifacts\/([^/]+)\/poster$/);
842
+ if (posterMatch?.[1] && req.method === 'GET') {
843
+ await handlePoster(req, res, posterMatch[1], url.searchParams.get('token') ?? undefined);
844
+ return;
845
+ }
523
846
  const artifactMatch = pathname.match(/^\/artifacts\/([^/]+)$/);
524
847
  if (artifactMatch?.[1]) {
525
848
  const artifactId = artifactMatch[1];
@@ -538,6 +861,7 @@ export function createPulseVaultCore(options) {
538
861
  handler,
539
862
  shutdown: async () => {
540
863
  await retentionSweep?.stop();
864
+ await completion.stop();
541
865
  await storage.shutdown?.();
542
866
  },
543
867
  handleTus,
@@ -545,16 +869,26 @@ export function createPulseVaultCore(options) {
545
869
  handleArtifactGet,
546
870
  handleViewLink,
547
871
  handleArtifactDelete,
872
+ handleStatus,
873
+ handlePoster,
874
+ getStatus,
875
+ getPulse,
876
+ recordOutcome,
877
+ replayCompletions: () => completion.replay(),
878
+ conformAvailable: () => options.webReady
879
+ ? webReadyAvailable(typeof options.webReady === 'object' ? options.webReady : {})
880
+ : Promise.resolve(false),
548
881
  };
549
882
  }
550
883
  // Re-exported so a non-Fastify consumer never needs to import from both `.`
551
884
  // and `./core` for a normal setup — these are already framework-agnostic
552
885
  // and identical to what the `.` (Fastify) entry point re-exports.
553
886
  export { createLocalStorage } from './storage/local.js';
887
+ export { uploadIdOf } from './storage/types.js';
554
888
  export { createS3Storage } from './storage/s3.js';
555
- export { sniffMp4, createMp4Sniffer, createS3Mp4Sniffer } from './lib/magic.js';
556
- export { ensureWebReady, scanMoovPosition } from './lib/web-ready.js';
557
- export { buildUploadLink } from './lib/deeplinks.js';
889
+ export { sniffMp4, createMp4Sniffer, createS3Mp4Sniffer, sniffVideo, createVideoValidator } from './lib/magic.js';
890
+ export { ensureWebReady, scanMoovPosition, probeVideo, webReadyAvailable, CONFORM_TARGET, CONFORM_VIDEO_EXTENSIONS, } from './lib/web-ready.js';
891
+ export { buildUploadLink, PULSE_OPEN_URL } from './lib/deeplinks.js';
558
892
  export { issueCapabilityToken, verifyCapabilityToken, issueViewToken, verifyViewToken, createCapabilityAuthorize, } from './lib/capability-token.js';
559
893
  export { createViewLinkIssuer } from './lib/view-links.js';
560
894
  export { sweepAbandonedUploads } from './lib/retention.js';