@mieweb/pulsevault 0.4.1 → 0.5.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 +102 -1
- package/OPERATIONS.md +24 -10
- package/PROTOCOL.md +38 -1
- package/README.md +168 -29
- package/assets/README.md +33 -0
- package/assets/badge-app-store.svg +46 -0
- package/assets/badge-google-play.png +0 -0
- package/assets/pulse-logo-mono.svg +10 -0
- package/assets/pulse-logo.svg +14 -0
- package/dist/app.d.ts +24 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +20 -2
- 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 +89 -4
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +301 -40
- 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 +135 -0
- package/dist/lib/completion.d.ts.map +1 -0
- package/dist/lib/completion.js +288 -0
- package/dist/lib/completion.js.map +1 -0
- package/dist/lib/options.d.ts.map +1 -1
- package/dist/lib/options.js +2 -0
- 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 +114 -31
- package/dist/lib/pulsevaultTus.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 +173 -8
- 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 +236 -14
- package/dist/storage/s3.js.map +1 -1
- package/dist/storage/types.d.ts +73 -0
- package/dist/storage/types.d.ts.map +1 -1
- package/package.json +6 -4
- package/protocol/openapi.json +247 -1
- package/protocol/schemas/artifact-status.schema.json +49 -0
- package/protocol/schemas/capability-token.schema.json +3 -0
|
@@ -24,7 +24,18 @@ export type CapabilityTokenClaims = {
|
|
|
24
24
|
* capability token an upload uses.
|
|
25
25
|
*/
|
|
26
26
|
use?: 'view';
|
|
27
|
+
/**
|
|
28
|
+
* Opaque host data (`issueCapabilityToken`'s `context`): who the upload is for, where it goes.
|
|
29
|
+
* Signed with the rest, so it can't be changed by whoever holds the token; stored with the
|
|
30
|
+
* artifact at create and handed back to every hook. JSON, at most `MAX_CONTEXT_BYTES` encoded.
|
|
31
|
+
*/
|
|
32
|
+
ctx?: unknown;
|
|
27
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Cap on the encoded `context` claim. The token travels in a QR code and in every request's
|
|
36
|
+
* `Authorization` header, so a context is an owner and a destination, not a record.
|
|
37
|
+
*/
|
|
38
|
+
export declare const MAX_CONTEXT_BYTES = 1024;
|
|
28
39
|
export type IssueCapabilityTokenOptions = {
|
|
29
40
|
/** Key id this token is signed under. Required so rotation has an explicit overlap window. */
|
|
30
41
|
keyId: string;
|
|
@@ -32,6 +43,13 @@ export type IssueCapabilityTokenOptions = {
|
|
|
32
43
|
issuer: string;
|
|
33
44
|
/** Token lifetime in seconds. Defaults to 1800 (30 minutes). */
|
|
34
45
|
expirySeconds?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Opaque host data signed into the token (`ctx` claim): the owner, the destination — whatever
|
|
48
|
+
* the host would otherwise keep in a table keyed by artifactId. PulseVault stores it with the
|
|
49
|
+
* artifact at create and hands it to `authorize`, `onUploadComplete`, `getStatus` and
|
|
50
|
+
* `getPulse`, so most hosts need no lookup table of their own. JSON, at most 1 KiB encoded.
|
|
51
|
+
*/
|
|
52
|
+
context?: unknown;
|
|
35
53
|
};
|
|
36
54
|
/**
|
|
37
55
|
* Mint a stateless, HMAC-signed capability token scoped to one artifact (or
|
|
@@ -64,6 +82,13 @@ export type VerifyCapabilityTokenOptions = {
|
|
|
64
82
|
issuer: string;
|
|
65
83
|
/** Clock-skew tolerance in seconds applied to both `iat` and `exp` checks. Defaults to 30. */
|
|
66
84
|
clockToleranceSeconds?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Whether `createCapabilityAuthorize` holds a create to the shape of a pulse: a video only
|
|
87
|
+
* under the token's own `artifactId` (with no `relatedTo`), and a thumbnail, beat manifest or
|
|
88
|
+
* captions only under another id, `relatedTo` the token's. Defaults to `true`, matching the
|
|
89
|
+
* core's `pulseShape` option; set `false` there and here for non-Pulse uploads.
|
|
90
|
+
*/
|
|
91
|
+
pulseShape?: boolean;
|
|
67
92
|
};
|
|
68
93
|
/**
|
|
69
94
|
* Verify a token minted by `issueCapabilityToken`. Returns the authorized
|
|
@@ -75,6 +100,7 @@ export type VerifyCapabilityTokenOptions = {
|
|
|
75
100
|
*/
|
|
76
101
|
export declare function verifyCapabilityToken(token: string, lookupSecret: LookupSecret, opts: VerifyCapabilityTokenOptions): {
|
|
77
102
|
artifactId: string;
|
|
103
|
+
context?: unknown;
|
|
78
104
|
} | null;
|
|
79
105
|
/**
|
|
80
106
|
* Verify a view token minted by `issueViewToken`. Returns the `artifactId` it opens and its
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capability-token.d.ts","sourceRoot":"","sources":["../../src/lib/capability-token.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAA8B,MAAM,gBAAgB,CAAC;AAEtF;;;;;;;;GAQG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,yFAAyF;IACzF,UAAU,EAAE,MAAM,CAAC;IACnB,sCAAsC;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"capability-token.d.ts","sourceRoot":"","sources":["../../src/lib/capability-token.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,mBAAmB,EAA8B,MAAM,gBAAgB,CAAC;AAEtF;;;;;;;;GAQG;AACH,MAAM,MAAM,qBAAqB,GAAG;IAClC,yFAAyF;IACzF,UAAU,EAAE,MAAM,CAAC;IACnB,sCAAsC;IACtC,GAAG,EAAE,MAAM,CAAC;IACZ,mCAAmC;IACnC,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,GAAG,EAAE,MAAM,CAAC;IACZ,+EAA+E;IAC/E,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,GAAG,CAAC,EAAE,OAAO,CAAC;CACf,CAAC;AAIF;;;GAGG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAgDtC,MAAM,MAAM,2BAA2B,GAAG;IACxC,8FAA8F;IAC9F,KAAK,EAAE,MAAM,CAAC;IACd,qGAAqG;IACrG,MAAM,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,2BAA2B,GAChC,MAAM,CAaR;AAED,MAAM,MAAM,qBAAqB,GAAG;IAClC,0FAA0F;IAC1F,KAAK,EAAE,MAAM,CAAC;IACd,qCAAqC;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,wFAAwF;IACxF,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,qBAAqB,GAC1B,MAAM,CAgBR;AAED,0GAA0G;AAC1G,MAAM,MAAM,YAAY,GAAG,CAAC,KAAK,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;AAExE,MAAM,MAAM,4BAA4B,GAAG;IACzC,qDAAqD;IACrD,MAAM,EAAE,MAAM,CAAC;IACf,8FAA8F;IAC9F,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB,CAAC;AAiEF;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,4BAA4B,GACjC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAOlD;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,KAAK,EAAE,MAAM,EACb,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,4BAA4B,GACjC;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAE5C;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,yBAAyB,CACvC,YAAY,EAAE,YAAY,EAC1B,IAAI,EAAE,4BAA4B,GACjC,mBAAmB,CA2CrB"}
|
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
const DEFAULT_EXPIRY_SECONDS = 1800; // 30 minutes — long enough for one upload session.
|
|
3
3
|
const DEFAULT_CLOCK_TOLERANCE_SECONDS = 30;
|
|
4
|
+
/**
|
|
5
|
+
* Cap on the encoded `context` claim. The token travels in a QR code and in every request's
|
|
6
|
+
* `Authorization` header, so a context is an owner and a destination, not a record.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_CONTEXT_BYTES = 1024;
|
|
9
|
+
/** `JSON.stringify` a context, refusing anything that isn't plain JSON or is too large. */
|
|
10
|
+
function encodeContext(context) {
|
|
11
|
+
if (context === undefined) {
|
|
12
|
+
throw new TypeError('`context` must be JSON (an object, array, string, number, boolean or null)');
|
|
13
|
+
}
|
|
14
|
+
let encoded;
|
|
15
|
+
try {
|
|
16
|
+
encoded = JSON.stringify(context);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
throw new TypeError('`context` must be JSON-serializable');
|
|
20
|
+
}
|
|
21
|
+
if (encoded === undefined) {
|
|
22
|
+
throw new TypeError('`context` must be JSON (an object, array, string, number, boolean or null)');
|
|
23
|
+
}
|
|
24
|
+
if (Buffer.byteLength(encoded, 'utf8') > MAX_CONTEXT_BYTES) {
|
|
25
|
+
throw new TypeError(`\`context\` must encode to at most ${MAX_CONTEXT_BYTES} bytes of JSON`);
|
|
26
|
+
}
|
|
27
|
+
return encoded;
|
|
28
|
+
}
|
|
4
29
|
function base64urlEncode(input) {
|
|
5
30
|
return Buffer.from(input, 'utf8').toString('base64url');
|
|
6
31
|
}
|
|
@@ -40,6 +65,8 @@ export function issueCapabilityToken(artifactId, secret, opts) {
|
|
|
40
65
|
exp: now + (opts.expirySeconds ?? DEFAULT_EXPIRY_SECONDS),
|
|
41
66
|
kid: opts.keyId,
|
|
42
67
|
issuer: opts.issuer,
|
|
68
|
+
// Round-tripped through JSON so the claim is exactly what a verifier will read back.
|
|
69
|
+
...(opts.context !== undefined ? { ctx: JSON.parse(encodeContext(opts.context)) } : {}),
|
|
43
70
|
};
|
|
44
71
|
const payload = base64urlEncode(JSON.stringify(claims));
|
|
45
72
|
return `${payload}.${sign(payload, secret)}`;
|
|
@@ -113,7 +140,21 @@ function verifyToken(token, lookupSecret, opts, use) {
|
|
|
113
140
|
return null;
|
|
114
141
|
if (claims.issuer !== opts.issuer)
|
|
115
142
|
return null;
|
|
116
|
-
|
|
143
|
+
// The signature already covers `ctx`; the size cap is enforced on the way in, and checked
|
|
144
|
+
// again here so a token minted elsewhere with the same secret can't carry a huge context.
|
|
145
|
+
if (claims.ctx !== undefined) {
|
|
146
|
+
try {
|
|
147
|
+
encodeContext(claims.ctx);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
artifactId: claims.artifactId,
|
|
155
|
+
exp: claims.exp,
|
|
156
|
+
...(claims.ctx !== undefined ? { context: claims.ctx } : {}),
|
|
157
|
+
};
|
|
117
158
|
}
|
|
118
159
|
/**
|
|
119
160
|
* Verify a token minted by `issueCapabilityToken`. Returns the authorized
|
|
@@ -125,7 +166,12 @@ function verifyToken(token, lookupSecret, opts, use) {
|
|
|
125
166
|
*/
|
|
126
167
|
export function verifyCapabilityToken(token, lookupSecret, opts) {
|
|
127
168
|
const verified = verifyToken(token, lookupSecret, opts, 'upload');
|
|
128
|
-
|
|
169
|
+
if (!verified)
|
|
170
|
+
return null;
|
|
171
|
+
return {
|
|
172
|
+
artifactId: verified.artifactId,
|
|
173
|
+
...(verified.context !== undefined ? { context: verified.context } : {}),
|
|
174
|
+
};
|
|
129
175
|
}
|
|
130
176
|
/**
|
|
131
177
|
* Verify a view token minted by `issueViewToken`. Returns the `artifactId` it opens and its
|
|
@@ -171,19 +217,41 @@ function extractToken(request, ctx) {
|
|
|
171
217
|
* can't be used to change anything or to extend itself.
|
|
172
218
|
*/
|
|
173
219
|
export function createCapabilityAuthorize(lookupSecret, opts) {
|
|
220
|
+
const pulseShape = opts.pulseShape ?? true;
|
|
174
221
|
return async (request, ctx) => {
|
|
175
222
|
const token = extractToken(request, ctx);
|
|
176
223
|
if (!token) {
|
|
177
224
|
throw Object.assign(new Error('Missing capability token'), { statusCode: 401 });
|
|
178
225
|
}
|
|
179
|
-
const
|
|
180
|
-
|
|
226
|
+
const readOnly = ctx.phase === 'resolve' || ctx.phase === 'status';
|
|
227
|
+
const capability = verifyCapabilityToken(token, lookupSecret, opts);
|
|
228
|
+
const verified = capability ?? (readOnly ? verifyViewToken(token, lookupSecret, opts) : null);
|
|
181
229
|
if (!verified) {
|
|
182
230
|
throw Object.assign(new Error('Invalid or expired capability token'), { statusCode: 403 });
|
|
183
231
|
}
|
|
184
|
-
if (
|
|
232
|
+
if (pulseShape && ctx.phase === 'create') {
|
|
233
|
+
// The shape of a pulse: the token names the video, and only the video is created under
|
|
234
|
+
// that id. Everything else is created under its own id, `relatedTo` the token's. Without
|
|
235
|
+
// this, a token could create any number of videos under ids of the client's choosing, or
|
|
236
|
+
// take the video's own id with a thumbnail so the real video can never land.
|
|
237
|
+
const ok = ctx.kind === 'video'
|
|
238
|
+
? ctx.artifactId === verified.artifactId && ctx.relatedTo === undefined
|
|
239
|
+
: ctx.relatedTo === verified.artifactId && ctx.artifactId !== verified.artifactId;
|
|
240
|
+
if (!ok) {
|
|
241
|
+
throw Object.assign(new Error(ctx.kind === 'video'
|
|
242
|
+
? 'A video is uploaded under the token’s own artifactId, with no relatedTo'
|
|
243
|
+
: `A ${ctx.kind} is uploaded under its own artifactId, relatedTo the token’s`), { statusCode: 403 });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
else if (ctx.artifactId !== verified.artifactId && ctx.relatedTo !== verified.artifactId) {
|
|
185
247
|
throw Object.assign(new Error('Token does not authorize this artifact'), { statusCode: 403 });
|
|
186
248
|
}
|
|
249
|
+
// The token's context is stored with the artifact it creates, so every later hook, status
|
|
250
|
+
// read and replay gets it without a host lookup.
|
|
251
|
+
if (ctx.phase === 'create' && capability?.context !== undefined) {
|
|
252
|
+
return { context: capability.context };
|
|
253
|
+
}
|
|
254
|
+
return undefined;
|
|
187
255
|
};
|
|
188
256
|
}
|
|
189
257
|
//# sourceMappingURL=capability-token.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"capability-token.js","sourceRoot":"","sources":["../../src/lib/capability-token.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"capability-token.js","sourceRoot":"","sources":["../../src/lib/capability-token.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { type WebReadyOptions, type WebReadyResult } from './web-ready.js';
|
|
2
|
+
import { type PulseVaultLogger, type PulseVaultRequest } from './request.js';
|
|
3
|
+
import type { PulseVaultStorage, UploadKind } from '../storage/types.js';
|
|
4
|
+
/**
|
|
5
|
+
* What `onUploadComplete` is told about a finished upload. Everything the client sent in
|
|
6
|
+
* `Upload-Metadata` and everything the token carried, so a host can map the file to its own
|
|
7
|
+
* record without reading storage back.
|
|
8
|
+
*/
|
|
9
|
+
export type PulseVaultUploadCompleteContext = {
|
|
10
|
+
artifactId: string;
|
|
11
|
+
kind: UploadKind;
|
|
12
|
+
/** Bytes stored. `0` on a replay when the adapter can't tell. */
|
|
13
|
+
size: number;
|
|
14
|
+
/** tus's upload id (`<kind>/<artifactId><ext>`). */
|
|
15
|
+
uploadId: string;
|
|
16
|
+
/** Original filename from `Upload-Metadata.filename`. */
|
|
17
|
+
filename: string;
|
|
18
|
+
/** Lowercase extension of `filename`, with the leading dot. */
|
|
19
|
+
ext: string;
|
|
20
|
+
/** The video this artifact belongs to (`Upload-Metadata.relatedTo`), for a thumbnail, beat manifest or captions. */
|
|
21
|
+
relatedTo?: string;
|
|
22
|
+
/** The display name from `Upload-Metadata.name` (the draft's title), when sent. */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** The uploading app's version, when sent. */
|
|
25
|
+
appVersion?: string;
|
|
26
|
+
/** The host data the capability token carried (`issueCapabilityToken`'s `context`). */
|
|
27
|
+
context?: unknown;
|
|
28
|
+
/**
|
|
29
|
+
* `true` when PulseVault is firing this completion again: the hook threw, or the process
|
|
30
|
+
* stopped, before it recorded the first one. Hosts must treat completion as at-least-once —
|
|
31
|
+
* check their own record before writing it again.
|
|
32
|
+
*/
|
|
33
|
+
replay: boolean;
|
|
34
|
+
/** With `webReady.completeAfter`, what the conversion did — the hook runs once it has finished. */
|
|
35
|
+
webReady?: WebReadyResult;
|
|
36
|
+
};
|
|
37
|
+
export type PulseVaultOnUploadComplete = (request: PulseVaultRequest, ctx: PulseVaultUploadCompleteContext) => void | Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* Fired at low-frequency, audit-worthy moments — never per chunk — so an
|
|
40
|
+
* operator can wire one hook to get both ops metrics and a compliance audit
|
|
41
|
+
* trail without hand-rolling both from the lower-level hooks. `remove` fires
|
|
42
|
+
* after PulseVault removed an artifact: a `DELETE /artifacts/:id` or a TUS
|
|
43
|
+
* `DELETE` (`reason: "deleted"`), the `retention` sweep (`reason: "abandoned"`),
|
|
44
|
+
* or a create that reclaimed an idle, unfinished upload (`reason: "reclaimed"`)
|
|
45
|
+
* — so a host keeping its own index of artifacts can drop it. `processed`
|
|
46
|
+
* fires when a web-ready conversion has finished, with what it did.
|
|
47
|
+
*/
|
|
48
|
+
export type PulseVaultArtifactEvent = {
|
|
49
|
+
phase: 'authorize' | 'complete' | 'reject' | 'remove' | 'processed';
|
|
50
|
+
artifactId: string;
|
|
51
|
+
kind: UploadKind;
|
|
52
|
+
size?: number;
|
|
53
|
+
reason?: string;
|
|
54
|
+
/** The uploading app's version, from `Upload-Metadata.appVersion` (`complete`/`reject` only). */
|
|
55
|
+
appVersion?: string;
|
|
56
|
+
/** What the conversion did (`processed` only). */
|
|
57
|
+
webReady?: WebReadyResult;
|
|
58
|
+
};
|
|
59
|
+
export type PulseVaultOnArtifactEvent = (event: PulseVaultArtifactEvent) => void | Promise<void>;
|
|
60
|
+
/**
|
|
61
|
+
* Web-ready conversion of every finished video: a lossless faststart remux, or an H.264
|
|
62
|
+
* transcode for a codec browsers can't play (`ensureWebReady`), run after the final `PATCH` is
|
|
63
|
+
* answered so the client never waits on ffmpeg. Local storage only; on an adapter without
|
|
64
|
+
* `getLocalPath` the option is refused at boot.
|
|
65
|
+
*/
|
|
66
|
+
export type PulseVaultWebReadyOptions = WebReadyOptions & {
|
|
67
|
+
/** How many conversions run at once. A transcode is CPU-bound; defaults to 1. */
|
|
68
|
+
concurrency?: number;
|
|
69
|
+
/**
|
|
70
|
+
* Call `onUploadComplete` only once the conversion has finished, with `ctx.webReady` saying
|
|
71
|
+
* what it did — so a host that publishes from the hook never publishes a video that's still
|
|
72
|
+
* being rewritten. Defaults to `false`: the hook runs at once and the conversion follows.
|
|
73
|
+
*/
|
|
74
|
+
completeAfter?: boolean;
|
|
75
|
+
};
|
|
76
|
+
export type PulseVaultReplayOptions = {
|
|
77
|
+
/**
|
|
78
|
+
* How often PulseVault settles finished artifacts it isn't done with: the host's
|
|
79
|
+
* `onUploadComplete` never returned (it threw, or the process stopped first), or a conversion
|
|
80
|
+
* never finished. The first pass runs shortly after start. Defaults to 300 seconds.
|
|
81
|
+
*/
|
|
82
|
+
intervalSeconds?: number;
|
|
83
|
+
};
|
|
84
|
+
export type CompletionRunnerOptions = {
|
|
85
|
+
storage: PulseVaultStorage;
|
|
86
|
+
onUploadComplete?: PulseVaultOnUploadComplete;
|
|
87
|
+
onArtifactEvent?: PulseVaultOnArtifactEvent;
|
|
88
|
+
webReady?: PulseVaultWebReadyOptions | boolean;
|
|
89
|
+
replay?: PulseVaultReplayOptions | false;
|
|
90
|
+
logger?: PulseVaultLogger;
|
|
91
|
+
};
|
|
92
|
+
export type FinishedUpload = {
|
|
93
|
+
artifactId: string;
|
|
94
|
+
kind: UploadKind;
|
|
95
|
+
size: number;
|
|
96
|
+
uploadId: string;
|
|
97
|
+
};
|
|
98
|
+
export type CompletionRunner = {
|
|
99
|
+
/**
|
|
100
|
+
* Settle an upload whose bytes are stored and marked ready. Resolves once the final request
|
|
101
|
+
* may be answered: after `onUploadComplete` when it runs inline (and rejects when the hook
|
|
102
|
+
* threw, so the client gets a non-2xx), or at once when the hook waits for the conversion.
|
|
103
|
+
*/
|
|
104
|
+
complete(request: PulseVaultRequest, upload: FinishedUpload): Promise<void>;
|
|
105
|
+
/**
|
|
106
|
+
* One pass over storage: settle every finished artifact that still needs something — a hook
|
|
107
|
+
* that never returned, a conversion that never finished. Resolves the artifactIds it settled.
|
|
108
|
+
* Needs `storage.listArtifacts` and `describeArtifact`; resolves `[]` without them.
|
|
109
|
+
*/
|
|
110
|
+
replay(): Promise<string[]>;
|
|
111
|
+
/** Whether a web-ready conversion applies to an artifact of this kind: `webReady` is on and it's a video. */
|
|
112
|
+
converts(kind: UploadKind): boolean;
|
|
113
|
+
/** Start the periodic replay (no-op when replay is off). The timer never keeps the process alive. */
|
|
114
|
+
start(): void;
|
|
115
|
+
/** Stop the periodic replay and wait for conversions and hooks in progress. */
|
|
116
|
+
stop(): Promise<void>;
|
|
117
|
+
};
|
|
118
|
+
/** Fail at boot on a setting that can't work, rather than silently doing nothing later. */
|
|
119
|
+
export declare function validateCompletionOptions(opts: Pick<CompletionRunnerOptions, 'storage' | 'webReady' | 'replay'>): void;
|
|
120
|
+
/**
|
|
121
|
+
* The completion pipeline shared by the core and the Fastify plugin. Everything it knows is on
|
|
122
|
+
* the artifact's sidecar: `converted` (the web-ready pass finished; written `false` at reserve)
|
|
123
|
+
* and `acknowledged` (the host's `onUploadComplete` returned). One rule settles a finished
|
|
124
|
+
* artifact, whether its final chunk just landed, a retried request finished it a second time,
|
|
125
|
+
* or a replay found it after a throw or a restart:
|
|
126
|
+
*
|
|
127
|
+
* 1. if a conversion applies and it isn't converted, convert (idempotent), then record it;
|
|
128
|
+
* 2. if it isn't acknowledged, run the hook — after 1 with `completeAfter`, otherwise at
|
|
129
|
+
* once — then record it.
|
|
130
|
+
*
|
|
131
|
+
* The only thing kept in memory is which artifacts are being settled right now, so the same
|
|
132
|
+
* artifact isn't settled twice at once.
|
|
133
|
+
*/
|
|
134
|
+
export declare function createCompletionRunner(opts: CompletionRunnerOptions): CompletionRunner;
|
|
135
|
+
//# sourceMappingURL=completion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"completion.d.ts","sourceRoot":"","sources":["../../src/lib/completion.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { ensureWebReady } from './web-ready.js';
|
|
2
|
+
import { consoleLogger } from './request.js';
|
|
3
|
+
const DEFAULT_REPLAY_INTERVAL_SECONDS = 300;
|
|
4
|
+
/** The first replay after start: late enough for the host to finish booting. */
|
|
5
|
+
const FIRST_REPLAY_DELAY_MS = 5_000;
|
|
6
|
+
/** A request-shaped stand-in for replays, whose original request is long gone. */
|
|
7
|
+
const REPLAY_REQUEST = { headers: {} };
|
|
8
|
+
/** Fail at boot on a setting that can't work, rather than silently doing nothing later. */
|
|
9
|
+
export function validateCompletionOptions(opts) {
|
|
10
|
+
if (opts.webReady) {
|
|
11
|
+
if (typeof opts.storage.getLocalPath !== 'function') {
|
|
12
|
+
throw new TypeError('`webReady` needs a storage adapter with `getLocalPath` (the local adapter): ffmpeg rewrites the file in place');
|
|
13
|
+
}
|
|
14
|
+
if (typeof opts.storage.patchArtifact !== 'function') {
|
|
15
|
+
throw new TypeError('`webReady` needs a storage adapter with `patchArtifact`');
|
|
16
|
+
}
|
|
17
|
+
if (typeof opts.webReady === 'object') {
|
|
18
|
+
const { concurrency } = opts.webReady;
|
|
19
|
+
if (concurrency !== undefined && !(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
20
|
+
throw new TypeError('`webReady.concurrency` must be a positive integer');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if (opts.replay) {
|
|
25
|
+
const { intervalSeconds } = opts.replay;
|
|
26
|
+
if (intervalSeconds !== undefined && !(intervalSeconds > 0 && Number.isFinite(intervalSeconds))) {
|
|
27
|
+
throw new TypeError('`replayCompletions.intervalSeconds` must be a positive number of seconds');
|
|
28
|
+
}
|
|
29
|
+
// A replay that can find unacknowledged artifacts but can't record an acknowledgement would
|
|
30
|
+
// run the hook again on every pass, forever.
|
|
31
|
+
const { listArtifacts, describeArtifact, patchArtifact } = opts.storage;
|
|
32
|
+
if (typeof listArtifacts === 'function' && typeof describeArtifact === 'function' && typeof patchArtifact !== 'function') {
|
|
33
|
+
throw new TypeError('`replayCompletions` needs a storage adapter with `patchArtifact` to record what was replayed (or set `replayCompletions: false`)');
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** The hook context for an artifact, from its stored metadata. */
|
|
38
|
+
function contextFor(meta, upload, replay) {
|
|
39
|
+
return {
|
|
40
|
+
artifactId: upload.artifactId,
|
|
41
|
+
kind: upload.kind,
|
|
42
|
+
size: upload.size,
|
|
43
|
+
uploadId: upload.uploadId,
|
|
44
|
+
filename: meta?.filename ?? '',
|
|
45
|
+
ext: meta?.ext ?? '',
|
|
46
|
+
...(meta?.relatedTo ? { relatedTo: meta.relatedTo } : {}),
|
|
47
|
+
...(meta?.name ? { name: meta.name } : {}),
|
|
48
|
+
...(meta?.appVersion ? { appVersion: meta.appVersion } : {}),
|
|
49
|
+
...(meta?.context !== undefined ? { context: meta.context } : {}),
|
|
50
|
+
replay,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** A bounded set of concurrent jobs: `run` waits for a slot, `drain` waits for every job. */
|
|
54
|
+
function createQueue(concurrency) {
|
|
55
|
+
let active = 0;
|
|
56
|
+
const waiting = [];
|
|
57
|
+
const running = new Set();
|
|
58
|
+
const run = (job) => {
|
|
59
|
+
const task = (async () => {
|
|
60
|
+
if (active >= concurrency)
|
|
61
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
62
|
+
active++;
|
|
63
|
+
try {
|
|
64
|
+
await job();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
active--;
|
|
68
|
+
waiting.shift()?.();
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
running.add(task);
|
|
72
|
+
void task.finally(() => running.delete(task));
|
|
73
|
+
return task;
|
|
74
|
+
};
|
|
75
|
+
const drain = async () => {
|
|
76
|
+
while (running.size > 0)
|
|
77
|
+
await Promise.allSettled([...running]);
|
|
78
|
+
};
|
|
79
|
+
return { run, drain };
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The completion pipeline shared by the core and the Fastify plugin. Everything it knows is on
|
|
83
|
+
* the artifact's sidecar: `converted` (the web-ready pass finished; written `false` at reserve)
|
|
84
|
+
* and `acknowledged` (the host's `onUploadComplete` returned). One rule settles a finished
|
|
85
|
+
* artifact, whether its final chunk just landed, a retried request finished it a second time,
|
|
86
|
+
* or a replay found it after a throw or a restart:
|
|
87
|
+
*
|
|
88
|
+
* 1. if a conversion applies and it isn't converted, convert (idempotent), then record it;
|
|
89
|
+
* 2. if it isn't acknowledged, run the hook — after 1 with `completeAfter`, otherwise at
|
|
90
|
+
* once — then record it.
|
|
91
|
+
*
|
|
92
|
+
* The only thing kept in memory is which artifacts are being settled right now, so the same
|
|
93
|
+
* artifact isn't settled twice at once.
|
|
94
|
+
*/
|
|
95
|
+
export function createCompletionRunner(opts) {
|
|
96
|
+
const { storage, onUploadComplete, onArtifactEvent } = opts;
|
|
97
|
+
const logger = opts.logger ?? consoleLogger;
|
|
98
|
+
const webReady = opts.webReady
|
|
99
|
+
? typeof opts.webReady === 'object'
|
|
100
|
+
? opts.webReady
|
|
101
|
+
: {}
|
|
102
|
+
: undefined;
|
|
103
|
+
const completeAfter = webReady?.completeAfter === true;
|
|
104
|
+
const queue = createQueue(webReady?.concurrency ?? 1);
|
|
105
|
+
const replayEvery = opts.replay === false ? null : (opts.replay?.intervalSeconds ?? DEFAULT_REPLAY_INTERVAL_SECONDS) * 1000;
|
|
106
|
+
const converts = (kind) => webReady !== undefined && kind === 'video';
|
|
107
|
+
const describe = (artifactId) => storage.describeArtifact ? storage.describeArtifact(artifactId) : Promise.resolve(null);
|
|
108
|
+
const record = async (artifactId, patch) => {
|
|
109
|
+
try {
|
|
110
|
+
await storage.patchArtifact?.(artifactId, patch);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
// The work was done; a failed record means one extra pass over it, not a lost completion.
|
|
114
|
+
logger.error({ err, artifactId, patch }, 'pulsevault could not record a completion step');
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
/** Rule 1. Never throws: a failed conversion serves the original bytes and is tried again later. */
|
|
118
|
+
const convert = (ctx) => new Promise((resolve) => {
|
|
119
|
+
void queue.run(async () => {
|
|
120
|
+
try {
|
|
121
|
+
const localPath = await storage.getLocalPath?.(ctx.artifactId);
|
|
122
|
+
const result = typeof localPath === 'string'
|
|
123
|
+
? await ensureWebReady(localPath, { ...webReady, logger })
|
|
124
|
+
: { action: 'skipped', reason: 'no local path for this artifact' };
|
|
125
|
+
await record(ctx.artifactId, { converted: true });
|
|
126
|
+
await onArtifactEvent?.({
|
|
127
|
+
phase: 'processed',
|
|
128
|
+
artifactId: ctx.artifactId,
|
|
129
|
+
kind: ctx.kind,
|
|
130
|
+
size: ctx.size,
|
|
131
|
+
reason: result.reason,
|
|
132
|
+
webReady: result,
|
|
133
|
+
});
|
|
134
|
+
resolve(result);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
logger.error({ err, artifactId: ctx.artifactId }, 'pulsevault web-ready conversion failed');
|
|
138
|
+
resolve(undefined);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
/** Rule 2. Rethrows what the hook threw. */
|
|
143
|
+
const hook = async (request, ctx) => {
|
|
144
|
+
if (onUploadComplete)
|
|
145
|
+
await onUploadComplete(request, ctx);
|
|
146
|
+
await record(ctx.artifactId, { acknowledged: true });
|
|
147
|
+
};
|
|
148
|
+
/** One artifact is settled by one caller at a time; a second caller joins the first. */
|
|
149
|
+
const settling = new Map();
|
|
150
|
+
/**
|
|
151
|
+
* Apply the rule to one finished artifact. The request that finished the upload waits on the
|
|
152
|
+
* result: it rejects when the hook threw, so the client gets a non-2xx — except with
|
|
153
|
+
* `completeAfter`, where the hook follows the conversion in the background and the request
|
|
154
|
+
* is answered at once. In the background (a replay, a hook after a conversion) a throw is
|
|
155
|
+
* logged and left for the next pass.
|
|
156
|
+
*/
|
|
157
|
+
const settle = (request, meta, upload, replay) => {
|
|
158
|
+
const running = settling.get(upload.artifactId);
|
|
159
|
+
if (running)
|
|
160
|
+
return running;
|
|
161
|
+
const ctx = contextFor(meta, upload, replay);
|
|
162
|
+
// An adapter without `describeArtifact` can't say what was done: run everything (the hook
|
|
163
|
+
// is at-least-once anyway, and the conversion is idempotent).
|
|
164
|
+
const needsConversion = converts(upload.kind) && !(meta ? meta.converted : false);
|
|
165
|
+
const needsHook = meta ? !meta.acknowledged : true;
|
|
166
|
+
if (!needsConversion && !needsHook)
|
|
167
|
+
return Promise.resolve();
|
|
168
|
+
let run;
|
|
169
|
+
if (needsConversion && completeAfter) {
|
|
170
|
+
// Nothing for the request to wait on: the hook follows the conversion.
|
|
171
|
+
run = (async () => {
|
|
172
|
+
const result = await convert(ctx);
|
|
173
|
+
if (!needsHook)
|
|
174
|
+
return;
|
|
175
|
+
try {
|
|
176
|
+
await hook(request, { ...ctx, ...(result ? { webReady: result } : {}) });
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
logger.error({ err, artifactId: ctx.artifactId, kind: ctx.kind }, 'pulsevault onUploadComplete failed after web-ready; it will be tried again');
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
settling.set(upload.artifactId, run);
|
|
183
|
+
void run.finally(() => settling.delete(upload.artifactId));
|
|
184
|
+
return Promise.resolve();
|
|
185
|
+
}
|
|
186
|
+
run = (async () => {
|
|
187
|
+
const conversion = needsConversion ? convert(ctx) : Promise.resolve(undefined);
|
|
188
|
+
try {
|
|
189
|
+
if (needsHook)
|
|
190
|
+
await hook(request, ctx);
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
// The settle covers the conversion too, so a replay doesn't queue it again meanwhile.
|
|
194
|
+
void conversion.finally(() => settling.delete(upload.artifactId));
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
settling.set(upload.artifactId, run);
|
|
198
|
+
return run;
|
|
199
|
+
};
|
|
200
|
+
const complete = async (request, upload) => settle(request, await describe(upload.artifactId), upload, false);
|
|
201
|
+
/** The stored size of a finished upload, from tus's own record; `0` when it can't be read. */
|
|
202
|
+
const storedSize = async (uploadId) => {
|
|
203
|
+
try {
|
|
204
|
+
const upload = await storage.datastore.getUpload(uploadId);
|
|
205
|
+
return upload.size ?? upload.offset ?? 0;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return 0;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
let replaying = null;
|
|
212
|
+
const replay = () => {
|
|
213
|
+
if (replaying)
|
|
214
|
+
return replaying;
|
|
215
|
+
replaying = (async () => {
|
|
216
|
+
const replayed = [];
|
|
217
|
+
if (typeof storage.listArtifacts !== 'function' || typeof storage.describeArtifact !== 'function') {
|
|
218
|
+
return replayed;
|
|
219
|
+
}
|
|
220
|
+
const pending = [];
|
|
221
|
+
for await (const record of storage.listArtifacts()) {
|
|
222
|
+
if (!record.ready || settling.has(record.artifactId))
|
|
223
|
+
continue;
|
|
224
|
+
let meta;
|
|
225
|
+
try {
|
|
226
|
+
meta = await storage.describeArtifact(record.artifactId);
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
logger.error({ err, artifactId: record.artifactId }, 'pulsevault replay could not read an artifact');
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!meta || !meta.ready || settling.has(meta.artifactId))
|
|
233
|
+
continue;
|
|
234
|
+
if (meta.acknowledged && !(converts(meta.kind) && !meta.converted))
|
|
235
|
+
continue;
|
|
236
|
+
const uploadId = `${meta.kind}/${meta.artifactId}${meta.ext}`;
|
|
237
|
+
const upload = {
|
|
238
|
+
artifactId: meta.artifactId,
|
|
239
|
+
kind: meta.kind,
|
|
240
|
+
size: await storedSize(uploadId),
|
|
241
|
+
uploadId,
|
|
242
|
+
};
|
|
243
|
+
replayed.push(meta.artifactId);
|
|
244
|
+
pending.push(settle(REPLAY_REQUEST, meta, upload, true).catch((err) => {
|
|
245
|
+
logger.error({ err, artifactId: meta.artifactId, kind: meta.kind }, 'pulsevault onUploadComplete failed on replay; it will be tried again');
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
await Promise.all(pending);
|
|
249
|
+
return replayed;
|
|
250
|
+
})().finally(() => {
|
|
251
|
+
replaying = null;
|
|
252
|
+
});
|
|
253
|
+
return replaying;
|
|
254
|
+
};
|
|
255
|
+
let timer = null;
|
|
256
|
+
let stopped = false;
|
|
257
|
+
const schedule = (delayMs) => {
|
|
258
|
+
if (stopped || replayEvery === null)
|
|
259
|
+
return;
|
|
260
|
+
timer = setTimeout(() => {
|
|
261
|
+
timer = null;
|
|
262
|
+
void replay()
|
|
263
|
+
.catch((err) => logger.error({ err }, 'pulsevault completion replay failed'))
|
|
264
|
+
.finally(() => schedule(replayEvery));
|
|
265
|
+
}, delayMs);
|
|
266
|
+
timer.unref();
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
complete,
|
|
270
|
+
replay,
|
|
271
|
+
converts,
|
|
272
|
+
start: () => {
|
|
273
|
+
if (timer || stopped)
|
|
274
|
+
return;
|
|
275
|
+
schedule(Math.min(FIRST_REPLAY_DELAY_MS, replayEvery ?? FIRST_REPLAY_DELAY_MS));
|
|
276
|
+
},
|
|
277
|
+
stop: async () => {
|
|
278
|
+
stopped = true;
|
|
279
|
+
if (timer)
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
timer = null;
|
|
282
|
+
await replaying?.catch(() => []);
|
|
283
|
+
await queue.drain();
|
|
284
|
+
await Promise.allSettled([...settling.values()]);
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
//# sourceMappingURL=completion.js.map
|