@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/README.md
CHANGED
|
@@ -188,13 +188,17 @@ Four runnable example servers live under [`examples/`](examples):
|
|
|
188
188
|
query instead of a sidecar crawl), uploads grouped by recording session
|
|
189
189
|
via `relatedTo`, WebVTT captions, Swagger UI, and an `onArtifactEvent`
|
|
190
190
|
live feed.
|
|
191
|
-
- [`examples/
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
191
|
+
- [`examples/meteor-demo`](examples/meteor-demo) — the **reference host
|
|
192
|
+
integration**, on `@mieweb/pulsevault/core` under Meteor: shaped like a
|
|
193
|
+
team app (people, destinations), it reserves an upload by signing the owner
|
|
194
|
+
and destination into the capability token, delivers the video from
|
|
195
|
+
`onUploadComplete` to where it was started, polls the status route from
|
|
196
|
+
the pairing popup, and shows every video as a card with its poster — with
|
|
197
|
+
no table of uploads, no shape check, no stale-upload cleanup and no
|
|
198
|
+
content-type fix-up of its own. Verified against a real `meteor create`
|
|
199
|
+
app, since Meteor's bundler needed the compatibility fixes described above.
|
|
200
|
+
- [`examples/express-demo`](examples/express-demo) — the fastify-demo on
|
|
201
|
+
`@mieweb/pulsevault/core` under Express.
|
|
198
202
|
|
|
199
203
|
## How a pairing + upload session flows
|
|
200
204
|
|
|
@@ -231,7 +235,9 @@ The plugin mounts the following routes under `prefix` (`@mieweb/pulsevault/core`
|
|
|
231
235
|
| `PATCH` / `HEAD` / `DELETE` \* | `/pulsevault/upload/:id` | Upload chunks, probe offset, delete the upload (TUS) |
|
|
232
236
|
| `GET` | `/pulsevault/artifacts/:artifactId` | Stream or redirect to the uploaded artifact (any kind) |
|
|
233
237
|
| `POST` | `/pulsevault/artifacts/:artifactId/view-link` | Mint a read-only view link to a finished artifact (only with [`issueViewLink`](#issueviewlink)) |
|
|
234
|
-
| `
|
|
238
|
+
| `GET` | `/pulsevault/artifacts/:artifactId/status` | Where an upload is: `uploading` (with progress), `processing`, `ready`, plus the host's recorded outcome (see [Status, outcome and pulses](#status-outcome-and-pulses)) |
|
|
239
|
+
| `GET` | `/pulsevault/artifacts/:artifactId/poster` | A video's poster frame: the finished thumbnail `relatedTo` it, served like the artifact route |
|
|
240
|
+
| `DELETE` | `/pulsevault/artifacts/:artifactId` | Delete a finalized upload (bytes + sidecar); refused once finished with [`lockWhenReady`](#lockwhenready) |
|
|
235
241
|
|
|
236
242
|
\* `DELETE /pulsevault/upload/:id` is TUS termination, addressed by the upload URL: it removes the artifact whether the upload is still in flight (a cancel) or finished, exactly as `DELETE /pulsevault/artifacts/:artifactId` does by artifactId.
|
|
237
243
|
|
|
@@ -250,12 +256,20 @@ type PulseVaultPluginOptions = {
|
|
|
250
256
|
allowedExtensions?:
|
|
251
257
|
| string[] // legacy — treated as video-only
|
|
252
258
|
| { video?: string[]; project?: string[]; captions?: string[]; thumbnail?: string[] }; // per-kind (recommended)
|
|
253
|
-
// defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".vtt"], thumbnail: [".jpg", ".jpeg", ".png"] }
|
|
259
|
+
// defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".vtt"], thumbnail: [".jpg", ".jpeg", ".png"] } — what Pulse sends
|
|
254
260
|
cache?: PulseVaultCacheOptions;
|
|
255
261
|
authorize?: PulseVaultAuthorize;
|
|
256
262
|
validatePayload?: PulseVaultValidatePayload; // runs for every kind; branch on ctx.kind
|
|
257
|
-
onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind
|
|
263
|
+
onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind; at-least-once
|
|
258
264
|
onArtifactEvent?: PulseVaultOnArtifactEvent; // low-frequency hook for metrics + audit logging
|
|
265
|
+
issueViewLink?: PulseVaultIssueViewLink; // read-only view links
|
|
266
|
+
retention?: { abandonedAfterSeconds: number; sweepIntervalSeconds?: number };
|
|
267
|
+
pulseShape?: boolean; // default true: creates must have the shape of a pulse
|
|
268
|
+
reclaim?: { idleSeconds?: number } | false; // default { idleSeconds: 300 }: a create takes over an idle unfinished upload
|
|
269
|
+
lockWhenReady?: boolean; // default false: a finished pulse can't be deleted through the routes
|
|
270
|
+
webReady?: PulseVaultWebReadyOptions | boolean; // default off: background faststart remux / H.264 transcode
|
|
271
|
+
replayCompletions?: { intervalSeconds?: number } | false; // default every 300 s: re-fire unacknowledged completions
|
|
272
|
+
coreDecoratorName?: string; // default "pulseVaultCore": getStatus, getPulse, recordOutcome, replayCompletions, conformAvailable
|
|
259
273
|
/** @deprecated use validatePayload + ctx.kind === "project" */
|
|
260
274
|
validateProjectPayload?: PulseVaultValidatePayload;
|
|
261
275
|
/** @deprecated use onUploadComplete + ctx.kind === "project" */
|
|
@@ -292,8 +306,9 @@ import "@mieweb/pulsevault/augment";
|
|
|
292
306
|
File extensions accepted per artifact kind. Three accepted forms:
|
|
293
307
|
|
|
294
308
|
```ts
|
|
295
|
-
// 1. Omit entirely — uses
|
|
296
|
-
// video: [".mp4"
|
|
309
|
+
// 1. Omit entirely — uses the defaults:
|
|
310
|
+
// video: [".mp4", ".mov", ".m4v", ".webm", ".mkv", ".3gp", ".avi"] project: [".pulse", ".zip"]
|
|
311
|
+
// captions: [".vtt"] thumbnail: [".jpg", ".jpeg", ".png"]
|
|
297
312
|
|
|
298
313
|
// 2. Flat array (legacy) — video-only; project/captions keep their defaults:
|
|
299
314
|
allowedExtensions: [".mp4"]
|
|
@@ -302,7 +317,7 @@ allowedExtensions: [".mp4"]
|
|
|
302
317
|
allowedExtensions: { video: [".mp4"], project: [".pulse"], captions: [".vtt"] }
|
|
303
318
|
```
|
|
304
319
|
|
|
305
|
-
All extensions must include the leading dot and are matched case-insensitively. The `kind` field in `Upload-Metadata` determines which list is checked.
|
|
320
|
+
All extensions must include the leading dot and are matched case-insensitively. The `kind` field in `Upload-Metadata` determines which list is checked. The default video list is the containers [`webReady`](#webready) conforms to MP4 (`CONFORM_VIDEO_EXTENSIONS`); the extension only decides what's accepted at `create`, so pair it with `createVideoValidator()` to check the bytes themselves.
|
|
306
321
|
|
|
307
322
|
### `cache`
|
|
308
323
|
|
|
@@ -322,7 +337,7 @@ Upload filenames are keyed by UUID, so `immutable: true` is safe when `maxAge` i
|
|
|
322
337
|
|
|
323
338
|
Optional async hook called before TUS create/patch, before GET resolve, before DELETE, and before minting a view link. Throw to reject — a `statusCode` or `status_code` number on the thrown error is used as the HTTP status (default `403`).
|
|
324
339
|
|
|
325
|
-
Phase mapping: `"create"` is the initial TUS `POST`; `"patch"` covers `PATCH` chunks and `HEAD` offset queries on the upload routes; `"resolve"` is `GET {prefix}/artifacts/<id
|
|
340
|
+
Phase mapping: `"create"` is the initial TUS `POST`; `"patch"` covers `PATCH` chunks and `HEAD` offset queries on the upload routes; `"resolve"` is `GET {prefix}/artifacts/<id>` and the poster route (authorized on the video); `"status"` is `GET {prefix}/artifacts/<id>/status`; `"delete"` is **both** ways of removing an artifact — `DELETE {prefix}/artifacts/<id>` and the TUS `DELETE {prefix}/upload/<id>` (a cancel of an in-flight upload, or removal of a finished one). Gating `phase === "delete"` gates every removal. `"share"` is `POST {prefix}/artifacts/<id>/view-link` — minting a read-only link — and only happens when [`issueViewLink`](#issueviewlink) is set.
|
|
326
341
|
|
|
327
342
|
```ts
|
|
328
343
|
type PulseVaultAuthorize = (
|
|
@@ -333,15 +348,22 @@ type PulseVaultAuthorize = (
|
|
|
333
348
|
// `http.IncomingMessage`, etc.) — the same hook works under either.
|
|
334
349
|
request: PulseVaultRequest,
|
|
335
350
|
ctx: {
|
|
336
|
-
phase: "create" | "patch" | "resolve" | "delete" | "share";
|
|
351
|
+
phase: "create" | "patch" | "resolve" | "status" | "delete" | "share";
|
|
337
352
|
artifactId: string;
|
|
338
|
-
kind: "video" | "project" | "captions"; // artifact kind; always present
|
|
339
|
-
token?: string; // only on "resolve"
|
|
340
|
-
relatedTo?: string; // the
|
|
353
|
+
kind: "video" | "project" | "captions" | "thumbnail"; // artifact kind; always present
|
|
354
|
+
token?: string; // only on the "resolve" and "status" phases
|
|
355
|
+
relatedTo?: string; // the video this artifact belongs to, if it declared one
|
|
356
|
+
name?: string; // Upload-Metadata.name, when sent
|
|
357
|
+
appVersion?: string; // Upload-Metadata.appVersion, when sent
|
|
358
|
+
filename?: string; // Upload-Metadata.filename
|
|
359
|
+
ext?: string; // its lowercase extension, with the dot
|
|
360
|
+
context?: unknown; // the token's context, read back from storage (absent on "create")
|
|
341
361
|
},
|
|
342
|
-
) => void | Promise<void>;
|
|
362
|
+
) => void | { context?: unknown } | Promise<void | { context?: unknown }>;
|
|
343
363
|
```
|
|
344
364
|
|
|
365
|
+
On `create` the hook may return `{ context }`: opaque host data (an owner, a destination) stored with the artifact and handed to every later `authorize`, to `onUploadComplete`, to `getStatus` and to `getPulse`. `createCapabilityAuthorize` returns the token's own `context` claim (see [Capability tokens](#capability-tokens)), so most hosts never touch this.
|
|
366
|
+
|
|
345
367
|
```ts
|
|
346
368
|
await app.register(pulseVault, {
|
|
347
369
|
// ...
|
|
@@ -395,6 +417,8 @@ await app.register(pulseVault, {
|
|
|
395
417
|
});
|
|
396
418
|
```
|
|
397
419
|
|
|
420
|
+
For videos from anywhere (a WebM from a browser recorder, an MKV, an AVI), check them by what they are with **`createVideoValidator({ maxDurationSeconds?, probeTimeoutSeconds? })`**: ffprobe must find a video stream with a duration above zero that isn't a picture (PNG, JPEG, GIF, HEIC, a one-frame clip), or the upload is refused with `422` and a message a host can show as it is (`That file isn't a video.`, `That video is longer than the limit of 10 minutes.`, or `That video couldn't be checked in time.` when ffprobe runs past `probeTimeoutSeconds`, default 60). It checks `kind: "video"` only and lets other kinds through; without ffprobe it falls back to `sniffVideo(path)` (the container's first bytes: `ftyp`, EBML or RIFF AVI) and logs once. Local storage only.
|
|
421
|
+
|
|
398
422
|
`createMp4Sniffer` reads the first 12 bytes and verifies the ISOBMFF `ftyp` header (MP4, MOV, M4V, 3GP). Uploads that pass the extension check but contain non-video bytes are rejected with 422 and the disk is cleaned up. The lower-level `sniffMp4(path)` is also exported if you want to drive your own validator.
|
|
399
423
|
|
|
400
424
|
`createChecksumValidator`/`createS3ChecksumValidator` parse `ctx.checksum` with the also-exported `parseChecksumMetadata(raw)`, which returns `{ algorithm, digest }` or `null` for a missing/malformed value — use it directly if you're writing your own checksum check instead of the ship-ready validators.
|
|
@@ -414,25 +438,42 @@ Optional async hook fired once the final byte is written, `validatePayload` has
|
|
|
414
438
|
|
|
415
439
|
```ts
|
|
416
440
|
type PulseVaultOnUploadComplete = (
|
|
417
|
-
request: PulseVaultRequest, // see the note under `authorize` above
|
|
418
|
-
ctx: {
|
|
441
|
+
request: PulseVaultRequest, // see the note under `authorize` above; `{ headers: {} }` on a replay
|
|
442
|
+
ctx: {
|
|
443
|
+
artifactId: string;
|
|
444
|
+
kind: "video" | "project" | "captions" | "thumbnail";
|
|
445
|
+
size: number;
|
|
446
|
+
uploadId: string;
|
|
447
|
+
filename: string; // Upload-Metadata.filename
|
|
448
|
+
ext: string; // its lowercase extension, with the dot
|
|
449
|
+
relatedTo?: string; // the video a thumbnail, beat manifest or captions belongs to
|
|
450
|
+
name?: string; // Upload-Metadata.name (the draft's title), when sent
|
|
451
|
+
appVersion?: string; // Upload-Metadata.appVersion, when sent
|
|
452
|
+
context?: unknown; // the capability token's context (see Capability tokens)
|
|
453
|
+
replay: boolean; // true when PulseVault is firing a completion it never recorded
|
|
454
|
+
webReady?: WebReadyResult; // with `webReady.completeAfter`: what the conversion did
|
|
455
|
+
},
|
|
419
456
|
) => void | Promise<void>;
|
|
420
457
|
```
|
|
421
458
|
|
|
459
|
+
**Completion is at-least-once.** PulseVault records that the hook returned (`acknowledged` in the artifact's sidecar). A finished artifact whose hook never returned — it threw, or the process stopped between the final byte and the hook — is fired again later with `ctx.replay: true` (see [`replayCompletions`](#replaycompletions)). So the hook must check its own record before writing it again: an upsert, or a claim that only one caller wins. A retried final `PATCH` for an acknowledged upload does not fire the hook a second time.
|
|
460
|
+
|
|
422
461
|
### `onArtifactEvent`
|
|
423
462
|
|
|
424
463
|
Optional low-frequency hook — fired on authorize rejection (every phase but per-chunk `patch`), upload completion, payload-validation rejection, and removal. One hook covers both ops metrics and a compliance audit trail instead of hand-wiring both from the lower-level hooks above, and `remove` is where a host keeping its own index of artifacts drops one:
|
|
425
464
|
|
|
426
465
|
```ts
|
|
427
466
|
type PulseVaultArtifactEvent = {
|
|
428
|
-
phase: "authorize" | "complete" | "reject" | "remove";
|
|
467
|
+
phase: "authorize" | "complete" | "reject" | "remove" | "processed";
|
|
429
468
|
artifactId: string;
|
|
430
469
|
kind: "video" | "project" | "captions" | "thumbnail";
|
|
431
470
|
size?: number;
|
|
432
471
|
// present for "authorize" and "reject"; on "remove", "deleted" (DELETE /artifacts/:id or a
|
|
433
|
-
// TUS DELETE)
|
|
472
|
+
// TUS DELETE), "abandoned" (the `retention` sweep) or "reclaimed" (a create took over an
|
|
473
|
+
// idle unfinished upload); on "processed", what the web-ready conversion did
|
|
434
474
|
reason?: string;
|
|
435
475
|
appVersion?: string; // the uploading app's version, on "complete"/"reject" (protocol 2.1)
|
|
476
|
+
webReady?: WebReadyResult; // on "processed": { action, reason }
|
|
436
477
|
};
|
|
437
478
|
|
|
438
479
|
onArtifactEvent: (event) => {
|
|
@@ -482,6 +523,65 @@ Finished videos, and anything whose video finished, are never touched; this is n
|
|
|
482
523
|
|
|
483
524
|
A sweep reads the metadata of every artifact older than the cutoff — one `GetObject` each on S3 — so on a large bucket sweep daily (or off-peak from a cron job) rather than hourly. Several instances may sweep the same storage (a removal that finds nothing is a no-op), but each instance caches metadata: on S3, an id another instance removed can still answer `409` on this one until the entry is evicted — the same as for `DELETE /artifacts/:id`. To schedule it yourself (a cron job, a queue), call `sweepAbandonedUploads(storage, { abandonedAfterSeconds, onRemoved? })` instead; it resolves the removed artifactIds.
|
|
484
525
|
|
|
526
|
+
### `pulseShape`
|
|
527
|
+
|
|
528
|
+
On by default. Every create must have the shape of a pulse (`PROTOCOL.md` §8): a video is created with no `relatedTo`, and a thumbnail, beat manifest or captions under its own id, `relatedTo` the video it belongs to. Anything else is refused at create with `403`, before any bytes move. With [`createCapabilityAuthorize`](#capability-tokens) the ids are also tied to the token: the video only under the token's own `artifactId`, the rest only `relatedTo` it. Without this, one token could create any number of videos under ids the client picks (never attached to anything, still converted, never removed), or take the video's own id with a thumbnail so the real video answers `409` for good.
|
|
529
|
+
|
|
530
|
+
The Pulse app has always uploaded in this shape. For uploads that aren't pulses, pass `pulseShape: false` here **and** to `createCapabilityAuthorize`.
|
|
531
|
+
|
|
532
|
+
### `reclaim`
|
|
533
|
+
|
|
534
|
+
`{ idleSeconds: 300 }` by default; `false` turns it off. An app killed mid-upload sends no TUS `DELETE`, so its unfinished upload keeps its artifactId and every later create of that id — the person scanning the same link again — answers `409` until `retention` removes it. With `reclaim`, a create takes over an unfinished upload of the same artifactId once it has been idle for `idleSeconds` — no bytes received: the bytes file's mtime on the local adapter, the newest multipart part (or the incomplete part parked between `PATCH`es) on S3: the old upload is removed (`onArtifactEvent` `remove` with `reason: "reclaimed"`) and the new one starts from byte 0. Only an upload of the same kind and `relatedTo` is taken over, so the token that authorized the new create authorized the old one; only one whose last activity the adapter knows; and creates for one artifactId run one at a time per instance, so two rescans can't both take it over.
|
|
535
|
+
|
|
536
|
+
### `lockWhenReady`
|
|
537
|
+
|
|
538
|
+
Off by default. Once an artifact is finished, `DELETE {prefix}/artifacts/<id>` and the TUS `DELETE` answer `403` for it, and for anything `relatedTo` a finished video: a pulse that landed stays, whatever token is presented. Removal then goes through `storage.remove` on the host's own terms (an admin action, a retention policy). The check reads storage (not the per-instance cache) just before the removal. For the TUS `DELETE` it runs inside tus's per-upload lock, so a cancel racing the final chunk of the same upload waits for it and then finds the artifact finished. `DELETE {prefix}/artifacts/<id>` takes no such lock — the Pulse app never uses it while uploading — so a delete and a final chunk arriving in the same instant can both succeed there.
|
|
539
|
+
|
|
540
|
+
### `webReady`
|
|
541
|
+
|
|
542
|
+
Off by default. `true`, or `ensureWebReady`'s options plus:
|
|
543
|
+
|
|
544
|
+
```ts
|
|
545
|
+
webReady: {
|
|
546
|
+
concurrency: 1, // conversions at once; a transcode is CPU-bound
|
|
547
|
+
completeAfter: false, // true: run onUploadComplete only once the conversion has finished
|
|
548
|
+
maxEdge: 1920, // longest edge of the served video
|
|
549
|
+
timeoutSeconds: undefined, // default 60 + 10 × duration in 1080p30 seconds (4K120 counts 16×), at most 1 h: a hang guard
|
|
550
|
+
probeTimeoutSeconds: 60, // the same guard for each ffprobe run over the upload
|
|
551
|
+
transcode: true, crf: 23, preset: "veryfast", ffmpegPath: "ffmpeg", ffprobePath: "ffprobe",
|
|
552
|
+
},
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Every finished video is conformed in the background, after the final `PATCH` is answered, to the one format the Pulse app records (`CONFORM_TARGET`: faststart MP4, H.264 8-bit SDR, longest edge at most `maxEdge`, rotation applied, AAC or silent; orientation kept). A file already in it — every Pulse upload — keeps its bytes (`none`); a `moov` at the end is a lossless remux (`remuxed`); an off-target stream is re-encoded (`transcoded`: HEVC, 10-bit HDR tone-mapped to SDR, 4K scaled, Opus/PCM to AAC); another container (`.mov`, `.webm`, `.mkv`, …) becomes a new `.mp4` served as `video/mp4` at the same artifact URL (`conformed`). See `OPERATIONS.md` for the table. While it runs the artifact's status is `processing` (the original bytes serve meanwhile, revalidated on every request instead of the configured `cache`; every rewrite is atomic), and when it's done the status reports `webReady: { action, reason }` — `skipped` with the reason when ffmpeg is missing, a run failed or timed out, and the original keeps serving — and `onArtifactEvent` fires `processed` with the same result. With `completeAfter: true`, `onUploadComplete` waits for the conversion and receives `ctx.webReady`, so a host that publishes from the hook never publishes a video that's still being rewritten. A conversion a restart interrupted is resumed by the replay below. Needs the local storage adapter and ffmpeg on the host; without ffmpeg it logs once and serves the original bytes.
|
|
556
|
+
|
|
557
|
+
### `replayCompletions`
|
|
558
|
+
|
|
559
|
+
On by default, every 300 seconds, with the first pass shortly after start; `{ intervalSeconds }` changes the interval and `false` turns it off. Each pass applies the one completion rule to every finished artifact that isn't settled: convert it if `webReady` applies and it isn't converted yet, then fire `onUploadComplete` again (`ctx.replay: true`) if the host hasn't acknowledged it. Both facts live on the artifact's sidecar (`converted`, `acknowledged`), written `false` at reserve, so nothing depends on what a process remembered. Turning `webReady` on later converts, on the first pass, the videos uploaded since this release that weren't. `fastify.pulseVaultCore.replayCompletions()` (or `core.replayCompletions()`) runs a pass on demand, for example at boot once the host's own services are up. Finished sidecars written before PulseVault recorded acknowledgements read as acknowledged, so an upgrade replays nothing (one still uploading at the upgrade completes normally).
|
|
560
|
+
|
|
561
|
+
### Status, outcome and pulses
|
|
562
|
+
|
|
563
|
+
The core behind the routes is `fastify.pulseVaultCore` (rename it with `coreDecoratorName`; non-Fastify hosts have it as the object `createPulseVaultCore` returns):
|
|
564
|
+
|
|
565
|
+
```ts
|
|
566
|
+
// Where an upload is — what GET /artifacts/:id/status reports — for a host that polls server-side.
|
|
567
|
+
await app.pulseVaultCore.getStatus(videoId);
|
|
568
|
+
// → { artifactId, state: "unknown" | "uploading" | "processing" | "ready", kind, relatedTo?, name?,
|
|
569
|
+
// bytesReceived?, size?, acknowledged, webReady?: { action, reason }, outcome?, context? }
|
|
570
|
+
// `context` (the token's) is for the host — an owner check is a comparison on it; the route leaves it out.
|
|
571
|
+
|
|
572
|
+
// Where it went, or why it didn't: any JSON, reported as `outcome` by the status route. `null` clears it.
|
|
573
|
+
await app.pulseVaultCore.recordOutcome(videoId, { state: "done", note: "Posted to Huddle" });
|
|
574
|
+
|
|
575
|
+
// Whether videos are conformed here (`webReady` on, ffmpeg and ffprobe installed) — for a health check.
|
|
576
|
+
await app.pulseVaultCore.conformAvailable();
|
|
577
|
+
|
|
578
|
+
// A video and the finished files relatedTo it, by kind — from the adapters' relation index, never a scan.
|
|
579
|
+
const { video, thumbnail, captions, manifest } = await app.pulseVaultCore.getPulse(videoId);
|
|
580
|
+
// One per kind; when a file was re-sent, the one that finished last (`readyAt`) wins.
|
|
581
|
+
```
|
|
582
|
+
|
|
583
|
+
A page waiting on an upload polls `GET {prefix}/artifacts/<id>/status` (never cached) with the pairing token or a view token, and reads `outcome` once the host recorded one — no table, SSE route or polling method of the host's own. `GET {prefix}/artifacts/<videoId>/poster` serves the video's thumbnail the way the artifact route serves it, authorized as `resolve` on the video, and `404`s until the poster has landed, so a card asks by the video's id and nothing is copied onto the host's records.
|
|
584
|
+
|
|
485
585
|
### `validateProjectPayload` / `onProjectUploadComplete` (deprecated)
|
|
486
586
|
|
|
487
587
|
Same lifecycle as `validatePayload`/`onUploadComplete`, but only fired for `kind=project` uploads. **Deprecated** — use the generic `validatePayload`/`onUploadComplete` with a `ctx.kind === "project"` branch instead. Still honored this release (passing either emits a one-time `DeprecationWarning` at registration); will be removed in a future major version.
|
|
@@ -492,7 +592,7 @@ When the final PATCH lands the plugin runs the following steps in order, for eve
|
|
|
492
592
|
|
|
493
593
|
1. **`validatePayload`** (optional, runs for every kind) — throws → `storage.remove(artifactId)`, HTTP 4xx (default 422).
|
|
494
594
|
2. **`storage.markReady(artifactId)`** — flips the sidecar so `resolve()` will serve the bytes.
|
|
495
|
-
3. **`onUploadComplete`** (optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them.
|
|
595
|
+
3. **`onUploadComplete`** (optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them, and the completion is replayed later (`ctx.replay: true`). Once it returns, the artifact is acknowledged. With `webReady`, the conversion is queued here; with `webReady.completeAfter`, the hook runs after it instead.
|
|
496
596
|
|
|
497
597
|
(`validateProjectPayload`/`onProjectUploadComplete`, if passed, run instead of the generic hooks specifically for `kind=project` — see the deprecation note above.)
|
|
498
598
|
|
|
@@ -507,7 +607,7 @@ When the final PATCH lands the plugin runs the following steps in order, for eve
|
|
|
507
607
|
"minSupportedVersion": 2,
|
|
508
608
|
"maxSupportedVersion": 2,
|
|
509
609
|
"kinds": ["video", "project", "captions", "thumbnail"],
|
|
510
|
-
"allowedExtensions": { "video": [".mp4"], "project": [".pulse", ".zip"], "captions": [".vtt"], "thumbnail": [".jpg", ".jpeg", ".png"] },
|
|
610
|
+
"allowedExtensions": { "video": [".mp4", ".mov", ".m4v", ".webm", ".mkv", ".3gp", ".avi"], "project": [".pulse", ".zip"], "captions": [".vtt"], "thumbnail": [".jpg", ".jpeg", ".png"] },
|
|
511
611
|
"maxUploadSize": 5368709120,
|
|
512
612
|
"checksum": { "algorithms": ["sha256", "sha1", "md5"] }
|
|
513
613
|
}
|
|
@@ -568,7 +668,19 @@ app.post("/pair", async (_req, reply) => {
|
|
|
568
668
|
});
|
|
569
669
|
```
|
|
570
670
|
|
|
571
|
-
A token authorizes either the artifact it names, or any artifact that declares that one as its `relatedTo` — so one token issued for a pulse's video also covers its captions, beat manifest and thumbnail in the same session, without minting a token per artifact. See `PROTOCOL.md` §5.4 for the full claim shape (`kid`/`iat`/`exp`/`issuer`/`artifactId`) and rationale.
|
|
671
|
+
A token authorizes either the artifact it names, or any artifact that declares that one as its `relatedTo` — so one token issued for a pulse's video also covers its captions, beat manifest and thumbnail in the same session, without minting a token per artifact. With [`pulseShape`](#pulseshape) (the default, on both the plugin and `createCapabilityAuthorize`), a create is held to that shape: the video only under the token's own `artifactId`, with no `relatedTo`; the rest only under other ids, `relatedTo` it. See `PROTOCOL.md` §5.4 for the full claim shape (`kid`/`iat`/`exp`/`issuer`/`artifactId`/`ctx`) and rationale.
|
|
672
|
+
|
|
673
|
+
**Context.** The token can carry the host's own data about the upload — who it's for, where it goes — signed with the rest, so whoever holds the token can't change it:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
const token = issueCapabilityToken(artifactId, keys["2026-06"], {
|
|
677
|
+
keyId: "2026-06",
|
|
678
|
+
issuer,
|
|
679
|
+
context: { userId: user.id, destination: { kind: "huddle", teamId } }, // any JSON, at most 1 KiB encoded
|
|
680
|
+
});
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
`createCapabilityAuthorize` returns it from the `create` phase, PulseVault stores it with the artifact (and with every file uploaded `relatedTo` it under the same token), and every later `authorize`, `onUploadComplete` (`ctx.context`, on replays too), `getStatus` and `getPulse` get it back. Most hosts then keep no table keyed by artifactId, and "is this the person who uploaded it?" is a comparison, not a lookup.
|
|
572
684
|
|
|
573
685
|
## Upload-Metadata protocol
|
|
574
686
|
|
|
@@ -612,7 +724,9 @@ The local adapter writes uploads into flat kind-scoped subdirectories. Downstrea
|
|
|
612
724
|
|
|
613
725
|
```text
|
|
614
726
|
<workspaceRoot>/
|
|
615
|
-
.pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum, name
|
|
727
|
+
.pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum, name,
|
|
728
|
+
# appVersion, context, acknowledged, converted, readyAt, outcome }
|
|
729
|
+
.pulsevault/related/<videoId>/<id> # relation index: an empty marker per artifact relatedTo the video
|
|
616
730
|
video/<id><ext> # video upload bytes (kind="video")
|
|
617
731
|
video/<id><ext>.json # @tus/file-store offset/metadata sidecar
|
|
618
732
|
project/<id><ext> # project bundle bytes (kind="project")
|
|
@@ -623,7 +737,7 @@ The local adapter writes uploads into flat kind-scoped subdirectories. Downstrea
|
|
|
623
737
|
|
|
624
738
|
`status` is `"uploading"` between `reserveUpload` and the successful final PATCH; `"ready"` thereafter. `GET /artifacts/:id` only serves `"ready"` uploads. `kind` defaults to `"video"` when absent (back-compat with pre-kind sidecars).
|
|
625
739
|
|
|
626
|
-
The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-resource paths without re-implementing the layout. `storage.
|
|
740
|
+
The adapter exposes `storage.workspaceRoot` (absolute, resolved from `workspaceDir`) so consumers can compute per-resource paths without re-implementing the layout. `storage.describeArtifact(id)` returns everything the sidecar holds plus `ready`, `acknowledged`, `processing` and `updatedAt` (`null` if unknown); `storage.listRelated(videoId)` walks the relation index; `storage.getKind(id)`, `storage.getRelatedTo(id)`, `storage.getChecksum(id)` and `storage.getName(id)` return single fields, each `null` if absent/unknown.
|
|
627
741
|
|
|
628
742
|
**Horizontal scaling**: this adapter requires sticky-session routing or a shared filesystem across instances — see `OPERATIONS.md`.
|
|
629
743
|
|
|
@@ -760,9 +874,13 @@ const storage: PulseVaultStorage = {
|
|
|
760
874
|
async shutdown() {
|
|
761
875
|
/* optional teardown */
|
|
762
876
|
},
|
|
763
|
-
async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum, name }) {
|
|
764
|
-
// Called by the TUS naming function. Return the file id for the datastore.
|
|
765
|
-
|
|
877
|
+
async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum, name, appVersion, context }) {
|
|
878
|
+
// Called by the TUS naming function. Return the file id for the datastore. `context` is what
|
|
879
|
+
// `authorize` returned for this create (the capability token's context); keep it with the row.
|
|
880
|
+
await db.createArtifact({
|
|
881
|
+
artifactId, filename, ext, kind, relatedTo, checksum, name, appVersion, context,
|
|
882
|
+
status: "uploading", acknowledged: false, converted: false,
|
|
883
|
+
});
|
|
766
884
|
return `${kind}/${artifactId}${ext}`;
|
|
767
885
|
},
|
|
768
886
|
async resolve(artifactId): Promise<PulseVaultResolution | null> {
|
|
@@ -793,19 +911,42 @@ const storage: PulseVaultStorage = {
|
|
|
793
911
|
async getRelatedTo(artifactId) { return (await db.findArtifact(artifactId))?.relatedTo ?? null; },
|
|
794
912
|
async getChecksum(artifactId) { return (await db.findArtifact(artifactId))?.checksum ?? null; },
|
|
795
913
|
async getName(artifactId) { return (await db.findArtifact(artifactId))?.name ?? null; },
|
|
796
|
-
// Optional — needed
|
|
797
|
-
// upload started while it's unfinished, and
|
|
914
|
+
// Optional — needed by `retention` / `sweepAbandonedUploads` and by the completion replay.
|
|
915
|
+
// `updatedAt` is when the upload last received bytes (or started) while it's unfinished, and
|
|
916
|
+
// when it finished once ready (ms since epoch).
|
|
798
917
|
async *listArtifacts({ changedBefore } = {}) {
|
|
799
918
|
for (const row of await db.listArtifacts({ changedBefore })) {
|
|
800
919
|
yield { artifactId: row.id, kind: row.kind, relatedTo: row.relatedTo, ready: row.ready, updatedAt: row.updatedAt };
|
|
801
920
|
}
|
|
802
921
|
},
|
|
922
|
+
// Optional — the richer hook context, `getStatus`, `getPulse`, `reclaim`, `webReady` and the
|
|
923
|
+
// completion replay need these three.
|
|
924
|
+
async describeArtifact(artifactId) {
|
|
925
|
+
const row = await db.findArtifact(artifactId);
|
|
926
|
+
if (!row) return null;
|
|
927
|
+
return {
|
|
928
|
+
artifactId, kind: row.kind, ext: row.ext, filename: row.filename, relatedTo: row.relatedTo,
|
|
929
|
+
checksum: row.checksum, name: row.name, appVersion: row.appVersion, context: row.context,
|
|
930
|
+
ready: row.status === "ready", acknowledged: row.acknowledged, converted: row.converted,
|
|
931
|
+
outcome: row.outcome, updatedAt: row.updatedAt, readyAt: row.readyAt,
|
|
932
|
+
};
|
|
933
|
+
},
|
|
934
|
+
async patchArtifact(artifactId, { acknowledged, converted, outcome }) {
|
|
935
|
+
// Each field is optional; `outcome: null` clears it. Return false for an unknown id.
|
|
936
|
+
return (await db.updateArtifact(artifactId, { acknowledged, converted, outcome })).matched;
|
|
937
|
+
},
|
|
938
|
+
async *listRelated(artifactId) {
|
|
939
|
+
// Every artifact whose `relatedTo` is this one — from an index, not a scan.
|
|
940
|
+
for (const row of await db.listArtifacts({ relatedTo: artifactId })) {
|
|
941
|
+
yield { artifactId: row.id, kind: row.kind, relatedTo: artifactId, ready: row.ready, updatedAt: row.updatedAt };
|
|
942
|
+
}
|
|
943
|
+
},
|
|
803
944
|
};
|
|
804
945
|
```
|
|
805
946
|
|
|
806
947
|
## Deep link helper
|
|
807
948
|
|
|
808
|
-
Use this to generate a
|
|
949
|
+
Use this to generate a pairing link for the Pulse mobile app and your server. Typically encoded as a QR code on a pairing page.
|
|
809
950
|
|
|
810
951
|
```ts
|
|
811
952
|
import { buildUploadLink } from "@mieweb/pulsevault";
|
|
@@ -820,8 +961,19 @@ const uploadLink = buildUploadLink({
|
|
|
820
961
|
token: "secret", // optional — forwarded to your authorize hook; see Capability tokens
|
|
821
962
|
});
|
|
822
963
|
// pulsecam://?v=1&artifactId=...&server=https%3A%2F%2Fexample.com%2Fpulsevault&token=secret
|
|
964
|
+
|
|
965
|
+
// The https form, a Universal Link / App Link (PROTOCOL.md §3):
|
|
966
|
+
const httpsLink = buildUploadLink({
|
|
967
|
+
server: "https://example.com/pulsevault",
|
|
968
|
+
artifactId: randomUUID(),
|
|
969
|
+
token: "secret",
|
|
970
|
+
form: "https",
|
|
971
|
+
});
|
|
972
|
+
// https://mieweb.github.io/pulse/open#v=1&artifactId=...&server=https%3A%2F%2Fexample.com%2Fpulsevault&token=secret
|
|
823
973
|
```
|
|
824
974
|
|
|
975
|
+
The `https` form is what a phone camera opens: Pulse opens straight from it when installed, and its page sends the person to the store when not. The parameters are after `#`, which browsers never send, so the link host never sees the token. Pulse releases from before the link host was claimed land on the page instead and open Pulse from its button, so `pulsecam://` stays the default.
|
|
976
|
+
|
|
825
977
|
## Pulse logo and store badges
|
|
826
978
|
|
|
827
979
|
The package ships the Pulse logo for the button that opens Pulse
|
package/dist/app.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { type PulseVaultAuthorize } from './routes/pulsevault.js';
|
|
|
3
3
|
import type { PulseVaultOnUploadComplete, PulseVaultOnArtifactEvent } from './lib/pulsevaultTus.js';
|
|
4
4
|
import type { PulseVaultValidatePayload } from './lib/magic.js';
|
|
5
5
|
import type { PulseVaultIssueViewLink } from './lib/view-links.js';
|
|
6
|
+
import type { PulseVaultReplayOptions, PulseVaultWebReadyOptions } from './lib/completion.js';
|
|
6
7
|
import { type PulseVaultRetentionOptions } from './lib/retention.js';
|
|
7
8
|
import type { PulseVaultStorage } from './storage/types.js';
|
|
8
9
|
import { type PulseVaultAllowedExtensionsInput } from './lib/options.js';
|
|
@@ -142,6 +143,23 @@ export type PulseVaultPluginOptions = {
|
|
|
142
143
|
* `sweepAbandonedUploads` from a cron job.
|
|
143
144
|
*/
|
|
144
145
|
retention?: PulseVaultRetentionOptions;
|
|
146
|
+
/** See the core's option of the same name: hold every create to the shape of a pulse. Defaults to `true`. */
|
|
147
|
+
pulseShape?: boolean;
|
|
148
|
+
/** See the core's option of the same name: a create takes over an idle unfinished upload of the same id. Defaults to `{ idleSeconds: 300 }`. */
|
|
149
|
+
reclaim?: {
|
|
150
|
+
idleSeconds?: number;
|
|
151
|
+
} | false;
|
|
152
|
+
/** See the core's option of the same name: a finished artifact can't be deleted through the routes. */
|
|
153
|
+
lockWhenReady?: boolean;
|
|
154
|
+
/** See the core's option of the same name: background web-ready conversion of finished videos. */
|
|
155
|
+
webReady?: PulseVaultWebReadyOptions | boolean;
|
|
156
|
+
/** See the core's option of the same name: re-fire `onUploadComplete` for completions the host never finished. */
|
|
157
|
+
replayCompletions?: PulseVaultReplayOptions | false;
|
|
158
|
+
/**
|
|
159
|
+
* Name of the decorator that exposes the core (`fastify.<name>.getStatus`, `.getPulse`,
|
|
160
|
+
* `.recordOutcome`, `.replayCompletions`, `.conformAvailable`). Defaults to `"pulseVaultCore"`.
|
|
161
|
+
*/
|
|
162
|
+
coreDecoratorName?: string;
|
|
145
163
|
/**
|
|
146
164
|
* @deprecated Use `validatePayload` instead — it now receives `ctx.kind`
|
|
147
165
|
* and runs for every artifact kind, including `"project"`. Still honored
|
|
@@ -162,17 +180,18 @@ export type PulseVaultPluginOptions = {
|
|
|
162
180
|
declare const _default: FastifyPluginAsync<PulseVaultPluginOptions>;
|
|
163
181
|
export default _default;
|
|
164
182
|
export { createLocalStorage } from './storage/local.js';
|
|
183
|
+
export { uploadIdOf } from './storage/types.js';
|
|
165
184
|
export type { LocalStorage, LocalStorageOptions } from './storage/local.js';
|
|
166
185
|
export { createS3Storage } from './storage/s3.js';
|
|
167
186
|
export type { S3Storage, S3StorageOptions } from './storage/s3.js';
|
|
168
187
|
export type { PulseVaultArtifactRecord, PulseVaultResolution, PulseVaultStorage, ReserveUploadParams, UploadKind, } from './storage/types.js';
|
|
169
188
|
export type { PulseVaultAuthorize, PulseVaultAuthorizeContext, PulseVaultAuthorizePhase, } from './routes/pulsevault.js';
|
|
170
189
|
export type { PulseVaultOnUploadComplete, PulseVaultOnArtifactEvent, PulseVaultArtifactEvent, } from './lib/pulsevaultTus.js';
|
|
171
|
-
export { sniffMp4, createMp4Sniffer, createS3Mp4Sniffer } from './lib/magic.js';
|
|
172
|
-
export type { PulseVaultValidatePayload } from './lib/magic.js';
|
|
173
|
-
export { ensureWebReady, scanMoovPosition } from './lib/web-ready.js';
|
|
174
|
-
export type { WebReadyAction, WebReadyOptions, WebReadyResult, MoovPosition } from './lib/web-ready.js';
|
|
175
|
-
export { buildUploadLink } from './lib/deeplinks.js';
|
|
190
|
+
export { sniffMp4, createMp4Sniffer, createS3Mp4Sniffer, sniffVideo, createVideoValidator } from './lib/magic.js';
|
|
191
|
+
export type { PulseVaultValidatePayload, VideoValidatorOptions } from './lib/magic.js';
|
|
192
|
+
export { ensureWebReady, scanMoovPosition, probeVideo, webReadyAvailable, CONFORM_TARGET, CONFORM_VIDEO_EXTENSIONS, } from './lib/web-ready.js';
|
|
193
|
+
export type { WebReadyAction, WebReadyOptions, WebReadyResult, MoovPosition, VideoProbe } from './lib/web-ready.js';
|
|
194
|
+
export { buildUploadLink, PULSE_OPEN_URL } from './lib/deeplinks.js';
|
|
176
195
|
export type { UploadLinkOptions } from './lib/deeplinks.js';
|
|
177
196
|
export { issueCapabilityToken, verifyCapabilityToken, issueViewToken, verifyViewToken, createCapabilityAuthorize, } from './lib/capability-token.js';
|
|
178
197
|
export type { CapabilityTokenClaims, IssueCapabilityTokenOptions, IssueViewTokenOptions, VerifyCapabilityTokenOptions, LookupSecret, } from './lib/capability-token.js';
|
|
@@ -183,4 +202,10 @@ export type { PulseVaultIssueViewLink, PulseVaultViewLink, PulseVaultViewLinkCon
|
|
|
183
202
|
export { createChecksumValidator, createS3ChecksumValidator, parseChecksumMetadata, } from './lib/checksum.js';
|
|
184
203
|
export type { ChecksumAlgorithm, ParsedChecksum } from './lib/checksum.js';
|
|
185
204
|
export type { PulseVaultRequest, PulseVaultLogger } from './lib/request.js';
|
|
205
|
+
export type { PulseVaultCore, PulseVaultArtifactStatus, PulseVaultPulse, } from './core.js';
|
|
206
|
+
export type { PulseVaultArtifactMeta, PulseVaultArtifactPatch, } from './storage/types.js';
|
|
207
|
+
export type { PulseVaultAuthorizeResult } from './lib/authorize.js';
|
|
208
|
+
export type { PulseVaultUploadCompleteContext } from './lib/pulsevaultTus.js';
|
|
209
|
+
export type { PulseVaultWebReadyOptions, PulseVaultReplayOptions } from './lib/completion.js';
|
|
210
|
+
export { MAX_CONTEXT_BYTES } from './lib/capability-token.js';
|
|
186
211
|
//# sourceMappingURL=app.d.ts.map
|
package/dist/app.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD,OAAyB,EAAE,KAAK,mBAAmB,
|
|
1
|
+
{"version":3,"file":"app.d.ts","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
package/dist/app.js
CHANGED
|
@@ -3,6 +3,7 @@ import pulseVaultRoutes, {} from './routes/pulsevault.js';
|
|
|
3
3
|
import { startRetentionSweep, validateRetentionOptions, } from './lib/retention.js';
|
|
4
4
|
import { normalizeAllowedExtensions, validateBasePath, validateMaxUploadSize, rejectRemovedOptions, validateAllowedExtensions, warnIfUsingDeprecatedProjectHooks, composeValidatePayload, composeOnUploadComplete, } from './lib/options.js';
|
|
5
5
|
const DEFAULT_DECORATOR_NAME = 'pulseVault';
|
|
6
|
+
const DEFAULT_CORE_DECORATOR_NAME = 'pulseVaultCore';
|
|
6
7
|
const app = async (fastify, opts) => {
|
|
7
8
|
rejectRemovedOptions(opts);
|
|
8
9
|
validateBasePath(opts.prefix, 'prefix');
|
|
@@ -14,10 +15,15 @@ const app = async (fastify, opts) => {
|
|
|
14
15
|
// state the adapter allocates mid-init still gets cleaned up if Fastify
|
|
15
16
|
// later tears the plugin down.
|
|
16
17
|
let retentionSweep = null;
|
|
18
|
+
let core = null;
|
|
17
19
|
fastify.addHook('onClose', async () => {
|
|
18
|
-
// Waits for a sweep in progress, so it never runs against shut-down storage.
|
|
20
|
+
// Waits for a sweep in progress, so it never runs against shut-down storage. The core's
|
|
21
|
+
// shutdown stops its completion queue and replay timer (and shuts storage down itself).
|
|
19
22
|
await retentionSweep?.stop();
|
|
20
|
-
|
|
23
|
+
if (core)
|
|
24
|
+
await core.shutdown();
|
|
25
|
+
else
|
|
26
|
+
await opts.storage.shutdown?.();
|
|
21
27
|
});
|
|
22
28
|
await opts.storage.initialize?.();
|
|
23
29
|
if (opts.retention) {
|
|
@@ -40,19 +46,32 @@ const app = async (fastify, opts) => {
|
|
|
40
46
|
onUploadComplete: composeOnUploadComplete(opts.onUploadComplete, opts.onProjectUploadComplete),
|
|
41
47
|
onArtifactEvent: opts.onArtifactEvent,
|
|
42
48
|
issueViewLink: opts.issueViewLink,
|
|
49
|
+
pulseShape: opts.pulseShape,
|
|
50
|
+
reclaim: opts.reclaim,
|
|
51
|
+
lockWhenReady: opts.lockWhenReady,
|
|
52
|
+
webReady: opts.webReady,
|
|
53
|
+
replayCompletions: opts.replayCompletions,
|
|
54
|
+
onCore: (built) => {
|
|
55
|
+
core = built;
|
|
56
|
+
},
|
|
43
57
|
});
|
|
58
|
+
if (!core)
|
|
59
|
+
throw new Error('pulsevault: the routes plugin did not hand back its core');
|
|
60
|
+
fastify.decorate(opts.coreDecoratorName ?? DEFAULT_CORE_DECORATOR_NAME, core);
|
|
44
61
|
};
|
|
45
62
|
export default fp(app, {
|
|
46
63
|
name: 'pulsevault',
|
|
47
64
|
fastify: '5.x',
|
|
48
65
|
});
|
|
49
66
|
export { createLocalStorage } from './storage/local.js';
|
|
67
|
+
export { uploadIdOf } from './storage/types.js';
|
|
50
68
|
export { createS3Storage } from './storage/s3.js';
|
|
51
|
-
export { sniffMp4, createMp4Sniffer, createS3Mp4Sniffer } from './lib/magic.js';
|
|
52
|
-
export { ensureWebReady, scanMoovPosition } from './lib/web-ready.js';
|
|
53
|
-
export { buildUploadLink } from './lib/deeplinks.js';
|
|
69
|
+
export { sniffMp4, createMp4Sniffer, createS3Mp4Sniffer, sniffVideo, createVideoValidator } from './lib/magic.js';
|
|
70
|
+
export { ensureWebReady, scanMoovPosition, probeVideo, webReadyAvailable, CONFORM_TARGET, CONFORM_VIDEO_EXTENSIONS, } from './lib/web-ready.js';
|
|
71
|
+
export { buildUploadLink, PULSE_OPEN_URL } from './lib/deeplinks.js';
|
|
54
72
|
export { issueCapabilityToken, verifyCapabilityToken, issueViewToken, verifyViewToken, createCapabilityAuthorize, } from './lib/capability-token.js';
|
|
55
73
|
export { createViewLinkIssuer } from './lib/view-links.js';
|
|
56
74
|
export { sweepAbandonedUploads } from './lib/retention.js';
|
|
57
75
|
export { createChecksumValidator, createS3ChecksumValidator, parseChecksumMetadata, } from './lib/checksum.js';
|
|
76
|
+
export { MAX_CONTEXT_BYTES } from './lib/capability-token.js';
|
|
58
77
|
//# sourceMappingURL=app.js.map
|
package/dist/app.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,gBAAgB,CAAC;AAChC,OAAO,gBAAgB,EAAE,
|
|
1
|
+
{"version":3,"file":"app.js","sourceRoot":"","sources":["../src/app.ts"],"names":[],"mappings":"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"}
|
package/dist/augment.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PulseVaultStorage, UploadKind } from './storage/types.js';
|
|
2
|
+
import type { PulseVaultCore } from './core.js';
|
|
2
3
|
/**
|
|
3
4
|
* Opt-in TypeScript augmentation for the default `pulseVault` decorator and
|
|
4
5
|
* the per-request `request.pulseVault` context that the plugin sets when it
|
|
@@ -32,6 +33,8 @@ import type { PulseVaultStorage, UploadKind } from './storage/types.js';
|
|
|
32
33
|
declare module 'fastify' {
|
|
33
34
|
interface FastifyInstance {
|
|
34
35
|
pulseVault: PulseVaultStorage;
|
|
36
|
+
/** The core behind the routes: `getStatus`, `getPulse`, `recordOutcome`, `replayCompletions`. */
|
|
37
|
+
pulseVaultCore: PulseVaultCore;
|
|
35
38
|
}
|
|
36
39
|
interface FastifyRequest {
|
|
37
40
|
/**
|
package/dist/augment.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"augment.d.ts","sourceRoot":"","sources":["../src/augment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"augment.d.ts","sourceRoot":"","sources":["../src/augment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AACxE,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,OAAO,QAAQ,SAAS,CAAC;IACvB,UAAU,eAAe;QACvB,UAAU,EAAE,iBAAiB,CAAC;QAC9B,iGAAiG;QACjG,cAAc,EAAE,cAAc,CAAC;KAChC;IACD,UAAU,cAAc;QACtB;;;;;;WAMG;QACH,UAAU,CAAC,EAAE;YAAE,UAAU,EAAE,MAAM,CAAC;YAAC,IAAI,EAAE,UAAU,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KAC3E;CACF;AAED,OAAO,EAAE,CAAC"}
|
package/dist/augment.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"augment.js","sourceRoot":"","sources":["../src/augment.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"augment.js","sourceRoot":"","sources":["../src/augment.ts"],"names":[],"mappings":"AAmDA,OAAO,EAAE,CAAC"}
|