@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.
- package/CHANGELOG.md +216 -1
- package/OPERATIONS.md +135 -44
- package/PROTOCOL.md +63 -4
- package/README.md +186 -34
- package/dist/app.d.ts +30 -5
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +24 -5
- package/dist/app.js.map +1 -1
- package/dist/augment.d.ts +3 -0
- package/dist/augment.d.ts.map +1 -1
- package/dist/augment.js.map +1 -1
- package/dist/core.d.ts +107 -8
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +381 -47
- package/dist/core.js.map +1 -1
- package/dist/lib/authorize.d.ts +28 -6
- package/dist/lib/authorize.d.ts.map +1 -1
- package/dist/lib/capability-token.d.ts +26 -0
- package/dist/lib/capability-token.d.ts.map +1 -1
- package/dist/lib/capability-token.js +73 -5
- package/dist/lib/capability-token.js.map +1 -1
- package/dist/lib/completion.d.ts +138 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +375 -0
- package/dist/lib/completion.js.map +1 -0
- package/dist/lib/deeplinks.d.ts +18 -2
- package/dist/lib/deeplinks.d.ts.map +1 -1
- package/dist/lib/deeplinks.js +13 -2
- package/dist/lib/deeplinks.js.map +1 -1
- package/dist/lib/magic.d.ts +41 -1
- package/dist/lib/magic.d.ts.map +1 -1
- package/dist/lib/magic.js +104 -0
- package/dist/lib/magic.js.map +1 -1
- package/dist/lib/options.d.ts +2 -0
- package/dist/lib/options.d.ts.map +1 -1
- package/dist/lib/options.js +8 -2
- package/dist/lib/options.js.map +1 -1
- package/dist/lib/protocol-version.gen.js +1 -1
- package/dist/lib/pulsevaultTus.d.ts +28 -26
- package/dist/lib/pulsevaultTus.d.ts.map +1 -1
- package/dist/lib/pulsevaultTus.js +149 -32
- package/dist/lib/pulsevaultTus.js.map +1 -1
- package/dist/lib/web-ready.d.ts +138 -18
- package/dist/lib/web-ready.d.ts.map +1 -1
- package/dist/lib/web-ready.js +453 -91
- package/dist/lib/web-ready.js.map +1 -1
- package/dist/routes/pulsevault.d.ts +12 -1
- package/dist/routes/pulsevault.d.ts.map +1 -1
- package/dist/routes/pulsevault.js +92 -1
- package/dist/routes/pulsevault.js.map +1 -1
- package/dist/storage/local.d.ts +7 -1
- package/dist/storage/local.d.ts.map +1 -1
- package/dist/storage/local.js +408 -52
- package/dist/storage/local.js.map +1 -1
- package/dist/storage/s3.d.ts +7 -1
- package/dist/storage/s3.d.ts.map +1 -1
- package/dist/storage/s3.js +256 -14
- package/dist/storage/s3.js.map +1 -1
- package/dist/storage/types.d.ts +126 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/dist/storage/types.js +7 -0
- package/dist/storage/types.js.map +1 -1
- package/package.json +2 -2
- package/protocol/openapi.json +270 -1
- package/protocol/schemas/artifact-status.schema.json +61 -0
- package/protocol/schemas/capability-token.schema.json +3 -0
- package/protocol/schemas/deep-link.schema.json +2 -2
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
341
|
-
const
|
|
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, {
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
|
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';
|