Resumable video uploads via the TUS protocol, with filesystem-first local storage and deep link helpers for the Pulse mobile app. Ships as a Fastify plugin (@mieweb/pulsevault) and as a framework-agnostic core (@mieweb/pulsevault/core) for Express, Meteor, or plain http.createServer — see Non-Fastify hosts.
See also: PROTOCOL.md (the wire contract, independent of this implementation — read this if you're building a Pulse-compatible server without this package) and OPERATIONS.md (scaling, secrets, retention, monitoring).
Self-hosted video capture for places that can't ship recordings to a vendor. Pulse records the walkthrough on the phone; Pulsevault receives it inside the backend you already run, behind your auth, on your storage. Pair a device by QR code and upload over TUS so two-minute captures from the floor survive signal drops and device restarts.
Hook in at authorize, validatePayload, or onUploadComplete to bolt on whatever your institution already runs — SSO, audit logs, transcoding queues, AI pipelines. The plugin mounts a handful of routes; the rest stays yours. Every deployment of this plugin is fully independent — there's no central Pulse-run service anywhere in this picture:
graph LR
subgraph "Org A — fully independent"
A1["Org A's Fastify server"] -->|registers| A2["@mieweb/pulsevault"]
A1 -->|owns| A3["artifactId minting, token issuance,\nTTL policy, secret rotation, revocation"]
end
subgraph "Org B — fully independent, no pulsevault"
B1["Org B's own server"] -->|implements from spec only| B2["PROTOCOL.md"]
B1 -->|owns| B3["its own auth scheme entirely"]
end
P["Pulse mobile app\nmulti-tenant client"] -->|pairs + uploads via open contract| A1
P -->|pairs + uploads via open contract| B1
P -.->|no dependency on either| X[("No central Pulse service")]
The local storage adapter writes to a stable on-disk layout (see Local storage) so you can layer post-processing — transcription, thumbnails, AI analysis — directly against the files from an onUploadComplete hook.
- Node.js
>=18 - Fastify
^5.x— only if you use the default@mieweb/pulsevault(Fastify plugin) entry point. The framework-agnostic@mieweb/pulsevault/coreentry point (Express, Meteor, or plainhttp.createServer) has no Fastify dependency at all — see Non-Fastify hosts.
npm install @mieweb/pulsevaultimport Fastify from "fastify";
import { randomUUID } from "node:crypto";
import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
const app = Fastify();
await app.register(pulseVault, {
prefix: "/pulsevault",
storage: createLocalStorage({ workspaceDir: "./data" }),
maxUploadSize: 5 * 1024 * 1024 * 1024, // 5 GiB
});
// Your server owns artifactId creation — attach auth, DB records, quotas here.
app.post("/reserve", async (_req, reply) => {
return reply.send({ artifactId: randomUUID() });
});
await app.listen({ port: 3030 });This is the minimal setup with no authentication — fine for local development,
not for production. See authorize and Capability tokens
for a secure-by-default option, and OPERATIONS.md for the full production checklist.
Everything above the Fastify-specific route/hook wiring — the authorize/
validatePayload/onUploadComplete orchestration, the TUS glue, the
capabilities payload, artifact GET/DELETE — lives in a framework-agnostic
core that the Fastify plugin itself is a thin adapter over. That core is
published as a separate entry point, @mieweb/pulsevault/core, with no
Fastify dependency, so a non-Fastify backend gets full protocol parity for
about the same amount of code as the Fastify quick-start above.
createPulseVaultCore(...) returns a connect-style handler(req, res, next?)
you can mount directly:
// Express
import express from "express";
import { randomUUID } from "node:crypto";
import { createPulseVaultCore, createLocalStorage } from "@mieweb/pulsevault/core";
const app = express();
const pulseVault = createPulseVaultCore({
basePath: "/pulsevault",
// Express's app.use(prefix, ...) already strips the mount prefix from
// req.url before calling the middleware, so tell the core not to also
// match/strip it — basePath is still used for the tus Location header.
stripBasePath: false,
storage: createLocalStorage({ workspaceDir: "./data" }),
maxUploadSize: 5 * 1024 * 1024 * 1024, // 5 GiB
});
app.use("/pulsevault", (req, res, next) => pulseVault.handler(req, res, next).catch(next));
app.post("/reserve", express.json(), (_req, res) => {
res.json({ artifactId: randomUUID() });
});
app.listen(3030);// Meteor (server-only module)
import { WebApp } from "meteor/webapp";
import { createPulseVaultCore, createLocalStorage } from "@mieweb/pulsevault/core";
const pulseVault = createPulseVaultCore({
basePath: "/pulsevault",
stripBasePath: false, // WebApp.connectHandlers strips the mount prefix too
storage: createLocalStorage({ workspaceDir: process.env.PULSEVAULT_DIR }),
maxUploadSize: 5 * 1024 * 1024 * 1024,
});
WebApp.connectHandlers.use("/pulsevault", (req, res, next) => {
pulseVault.handler(req, res, next).catch(next);
});Meteor compatibility note. WebApp.connectHandlers.use(prefix, handler)
is the right integration point (it's connect, the same shape as Express),
but Meteor's bundler (tested: Meteor 3.4, modules@0.20.3) doesn't resolve
package.json "exports" subpath maps — neither this package's own
"./core" entry, nor (transitively, until this release) @tus/server's
own dependency on srvx, which shipped only subpath exports with no
legacy main fallback. Both are worked around: this package pins
@tus/server/@tus/file-store to 2.0.0 (the last release before the
srvx migration — see CHANGELOG.md) and ships plain root-level
core.js/augment.js fallback files alongside the "exports" map, so
resolvers that ignore "exports" (Meteor's included) still find them via
ordinary relative-path resolution. Verified against a real
meteor create app: WebApp.connectHandlers.use("/pulsevault", ...)
resolves cleanly and a full TUS create → PATCH → GET round-trip works.
For a bare http.createServer (or any host that hands handler the
request's full, unmodified URL instead of pre-stripping a mount prefix),
leave stripBasePath at its default (true):
import http from "node:http";
import { createPulseVaultCore, createLocalStorage } from "@mieweb/pulsevault/core";
const pulseVault = createPulseVaultCore({
basePath: "/pulsevault",
storage: createLocalStorage({ workspaceDir: "./data" }),
maxUploadSize: 5 * 1024 * 1024 * 1024,
});
http.createServer((req, res) => {
pulseVault.handler(req, res).catch((err) => {
res.writeHead(500);
res.end(String(err));
});
}).listen(3030);@mieweb/pulsevault/core re-exports everything the Fastify entry point does
(createLocalStorage, createS3Storage, createMp4Sniffer,
createChecksumValidator, createCapabilityAuthorize, etc.), so a
non-Fastify integration only ever imports from this one path — every option
documented under Plugin options below (authorize,
validatePayload, onUploadComplete, allowedExtensions, cache, and so
on) applies identically. createPulseVaultCore differs from the Fastify
plugin's options in a few small ways: it takes basePath where the plugin
takes prefix; it has no decoratorName (there's no Fastify decorator to
name — keep your own reference to storage instead); it adds stripBasePath
(see above — false for hosts that already strip their own mount prefix,
default true otherwise); and it adds an optional logger ({ info(obj, msg?), error(obj, msg?) }, Pino-shaped) for the core's own internal
diagnostics — the Fastify plugin always uses request.log for these
automatically, but a non-Fastify host has no equivalent to infer one from,
so it falls back to console when omitted.
Four runnable example servers live under examples/:
examples/fastify-demo— the smallest runnable server: Fastify plugin mount, QR pairing, flat upload listing, no auth, local storage. Start here.examples/fastify-auth-demo— production-shaped Fastify reference, run through Docker Compose (server + Postgres, onedocker compose up --build): capability tokens always on (fails fast at boot withoutPULSEVAULT_SECRET), a Better Auth-protected React dashboard (email+password), a Prisma schema holding both the auth tables and an artifact index (onUploadCompletewrites each finished upload once;/pulsesis one query instead of a sidecar crawl), uploads grouped by recording session viarelatedTo, WebVTT captions, Swagger UI, and anonArtifactEventlive feed.examples/express-demoandexamples/meteor-demo— the same demo on@mieweb/pulsevault/coreinstead of the plugin, proving the core needs about the same amount of glue code under a different framework. Both are verified against the real frameworks, not just the test suite —meteor-demoin particular against a realmeteor createapp, since Meteor's bundler needed the compatibility fixes described above.
sequenceDiagram
participant Org as Your server
participant User
participant Pulse as Pulse app
Org->>Org: mint artifactId + issue a token
Org->>User: show QR / deep link
User->>Pulse: open link
Pulse->>Pulse: validate link, show pairing screen
User->>Pulse: confirm and choose draft
Pulse->>Org: POST upload, Bearer token, kind video, checksum
Org->>Pulse: 201 Location
Pulse->>Org: PATCH chunks, HEAD before resume
Org->>Org: validatePayload, markReady, onUploadComplete
Pulse->>Org: POST upload, kind captions, same session
Org->>Org: validatePayload, markReady, onUploadComplete
Org->>Pulse: ready for playback and captions fetch
The plugin mounts the following routes under prefix (@mieweb/pulsevault/core mounts the identical set under basePath — see Non-Fastify hosts):
| Method | Path | Description |
|---|---|---|
GET |
/pulsevault/capabilities |
Unauthenticated discovery: protocol version, uploadUnit, allowed extensions, limits |
POST |
/pulsevault/upload |
Create a TUS upload session |
PATCH / HEAD / DELETE * |
/pulsevault/upload/:id |
Upload chunks, probe offset, cancel upload (TUS) |
GET |
/pulsevault/artifacts/:artifactId |
Stream or redirect to the uploaded artifact (any kind) |
DELETE |
/pulsevault/artifacts/:artifactId |
Delete a finalized upload (bytes + sidecar) |
* DELETE /pulsevault/upload/:id is TUS's own "cancel in-flight upload" — distinct from DELETE /pulsevault/artifacts/:artifactId, which removes a finalized artifact.
POST /reserveis not part of the plugin. Your server implements it so you control auth, ownership, and any business logic tied to artifact creation.
GET /pulsevault/artifacts/:artifactId only serves uploads whose adapter has been told to mark them ready. With the built-in local adapter, that means the final PATCH has landed and validatePayload (if configured) accepted the bytes. In-progress uploads return 404. The artifact's kind (video/project/captions) is resolved from storage, not the URL — there's one route for every kind.
type PulseVaultPluginOptions = {
storage: PulseVaultStorage;
prefix: string;
maxUploadSize: number;
uploadUnit?: "beat" | "merged"; // default: "beat" — see "Upload unit"
decoratorName?: string; // default: "pulseVault"
allowedExtensions?:
| string[] // legacy — treated as video-only
| { video?: string[]; project?: string[]; captions?: string[] }; // per-kind (recommended)
// defaults: { video: [".mp4"], project: [".pulse", ".zip"], captions: [".vtt"] }
cache?: PulseVaultCacheOptions;
authorize?: PulseVaultAuthorize;
validatePayload?: PulseVaultValidatePayload; // runs for every kind; branch on ctx.kind
onUploadComplete?: PulseVaultOnUploadComplete; // runs for every kind; branch on ctx.kind
onArtifactEvent?: PulseVaultOnArtifactEvent; // low-frequency hook for metrics + audit logging
/** @deprecated use validatePayload + ctx.kind === "project" */
validateProjectPayload?: PulseVaultValidatePayload;
/** @deprecated use onUploadComplete + ctx.kind === "project" */
onProjectUploadComplete?: PulseVaultOnUploadComplete;
};A PulseVaultStorage adapter. Use the built-in createLocalStorage for filesystem-backed deployments (the blessed default), createS3Storage for Cloudflare R2 / AWS S3 (see Object storage), or implement the interface for custom backends (GCS, database, etc.).
URL prefix for all plugin routes. Set to "/pulsevault" for the standard namespaced mount. Use "" to mount at the root. Must start with / (no trailing slash) or be "".
Because the plugin uses
fastify-pluginto escape encapsulation, Fastify's nativeregister(..., { prefix })is a no-op — always passprefixthrough this option.
Maximum upload size in bytes. Use Infinity for no cap.
uploadUnit: "beat" | "merged" — purely advertised via GET /pulsevault/capabilities; the plugin doesn't enforce either. It tells the client which upload strategy this deployment expects:
"beat"(default): the client uploads each clip individually (no merge/re-encode pass), plus akind=projectmanifest artifact for ordering. Lower client-side cost, finer-grained resumability."merged": the client pre-merges a pulse's clips into one video before uploading. Simpler server-side mental model (one artifact per pulse), more client-side work.
A client reads this at pairing time, before doing any merge or upload work, and branches accordingly. See PROTOCOL.md §8 for the full contract.
Need both strategies live at once instead of one fixed value for the whole deployment? Pass uploadUnit to buildUploadLink per session — it overrides /capabilities for that pairing only, with no server-side option to change.
Name of the Fastify decorator that exposes the storage adapter on the instance. Defaults to "pulseVault". Override when registering the plugin more than once in the same process.
For TypeScript access to the default decorator, add a side-effect import once in your app:
import "@mieweb/pulsevault/augment";File extensions accepted per artifact kind. Three accepted forms:
// 1. Omit entirely — uses all three defaults:
// video: [".mp4"] project: [".pulse", ".zip"] captions: [".vtt"]
// 2. Flat array (legacy) — video-only; project/captions keep their defaults:
allowedExtensions: [".mp4"]
// 3. Per-kind object — unset keys fall back to their defaults:
allowedExtensions: { video: [".mp4"], project: [".pulse"], captions: [".vtt"] }All extensions must include the leading dot and are matched case-insensitively. The kind field in Upload-Metadata determines which list is checked.
Cache-control options for the GET /artifacts/:artifactId route, forwarded to @fastify/send:
type PulseVaultCacheOptions = {
cacheControl?: boolean; // default: true
maxAge?: string | number; // ms or ms-style string e.g. "1y". default: 0
immutable?: boolean; // requires maxAge > 0. default: false
};Upload filenames are keyed by UUID, so immutable: true is safe when maxAge is non-zero.
Optional async hook called before TUS create/patch, before GET resolve, and before DELETE. Throw to reject — a statusCode or status_code number on the thrown error is used as the HTTP status (default 403).
Phase mapping: "create" is the initial TUS POST; "patch" covers every other request on the upload routes — PATCH chunks, HEAD offset queries, and the in-flight cancel DELETE {prefix}/upload/<id>; "resolve" is GET {prefix}/artifacts/<id>; "delete" fires only for the finalized-artifact route DELETE {prefix}/artifacts/<id>. If you gate deletion on phase === "delete" alone, you are not gating upload cancellation — that arrives as "patch".
type PulseVaultAuthorize = (
// PulseVaultRequest only requires `.headers` — under the Fastify plugin
// this is always the real `FastifyRequest` object (cast if you need
// Fastify-specific fields); under @mieweb/pulsevault/core it's whatever
// the host framework hands the handler (Express's `req`, a raw
// `http.IncomingMessage`, etc.) — the same hook works under either.
request: PulseVaultRequest,
ctx: {
phase: "create" | "patch" | "resolve" | "delete";
artifactId: string;
kind: "video" | "project" | "captions"; // artifact kind; always present
token?: string; // only on "resolve" phase
relatedTo?: string; // the session-anchor artifact this one belongs to, if any
},
) => void | Promise<void>;await app.register(pulseVault, {
// ...
authorize: async (request, { phase, artifactId, kind }) => {
const token = request.headers.authorization?.replace("Bearer ", "");
if (!isValid(token, artifactId)) {
throw Object.assign(new Error("Forbidden"), { statusCode: 403 });
}
},
});Don't want to write your own auth from scratch? See Capability tokens for a secure-by-default option.
Optional async hook that runs after TUS writes the final byte but before the upload is marked ready or onUploadComplete fires — for every artifact kind, with ctx.kind telling you which. Throw to reject — the plugin calls storage.remove to free the bytes and returns a 4xx (default 422) to the client. The sidecar never flips to "ready", so the artifact is never served.
type PulseVaultValidatePayload = (
request: PulseVaultRequest, // see the note under `authorize` above
ctx: {
artifactId: string;
size: number;
uploadId: string;
kind: "video" | "project" | "captions";
/** Absolute path to finalized bytes for adapters that expose `getLocalPath`. */
localPath: string | null;
/** Client-supplied `<algorithm>:<hex>` digest, if `Upload-Metadata.checksum` was sent. */
checksum?: string;
},
) => void | Promise<void>;Use for magic-byte sniffing, checksum verification, virus scanning, size re-checks — anything that needs the final bytes. Ready-made helpers ship with the package:
import pulseVault, {
createLocalStorage,
createMp4Sniffer,
createChecksumValidator,
} from "@mieweb/pulsevault";
const storage = createLocalStorage({ workspaceDir: "./data" });
await app.register(pulseVault, {
// ...
storage,
// Chain validators: checksum runs first, then the MP4 sniff.
validatePayload: createChecksumValidator(createMp4Sniffer(storage)),
});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.
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.
createMp4Sniffer/createChecksumValidator only make sense for kind=video/general payloads — they run unconditionally for every kind your hook receives, so branch on ctx.kind yourself if you only want them applied to one kind, or compose differently per kind:
validatePayload: async (request, ctx) => {
if (ctx.kind === "video") return createMp4Sniffer(storage)(request, ctx);
// project/captions: no byte-level check, or your own.
},Optional async hook fired once the final byte is written, validatePayload has passed, and the sidecar has been marked ready — for every artifact kind, with ctx.kind telling you which. Use it to update a database row, enqueue a job, or write an audit log. Throwing returns a 500 to the client. The artifact is ready at this point — if you want all-or-nothing semantics, call storage.remove before throwing.
type PulseVaultOnUploadComplete = (
request: PulseVaultRequest, // see the note under `authorize` above
ctx: { artifactId: string; kind: "video" | "project" | "captions"; size: number; uploadId: string },
) => void | Promise<void>;Optional low-frequency hook — fired on authorize rejection (create/delete/resolve phases only, never per-chunk patch), upload completion, and payload-validation rejection. One hook covers both ops metrics and a compliance audit trail instead of hand-wiring both from the lower-level hooks above:
type PulseVaultArtifactEvent = {
phase: "authorize" | "complete" | "reject";
artifactId: string;
kind: "video" | "project" | "captions";
size?: number;
reason?: string; // present for "authorize" and "reject"
};
onArtifactEvent: (event) => {
app.log.info(event, "pulsevault artifact event"); // or push to a metrics/audit sink
},See OPERATIONS.md for a Prometheus-counter example and what to alert on.
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.
When the final PATCH lands the plugin runs the following steps in order, for every kind. Any step failing short-circuits the rest.
validatePayload(optional, runs for every kind) — throws →storage.remove(artifactId), HTTP 4xx (default 422).storage.markReady(artifactId)— flips the sidecar soresolve()will serve the bytes.onUploadComplete(optional, runs for every kind) — throws → HTTP 500; bytes remain ready unless the consumer removes them.
(validateProjectPayload/onProjectUploadComplete, if passed, run instead of the generic hooks specifically for kind=project — see the deprecation note above.)
GET {prefix}/capabilities is unauthenticated (no secrets in the response) and lets a client detect protocol compatibility and this deployment's configuration before pairing:
{
"protocolVersion": 1,
"minSupportedVersion": 1,
"maxSupportedVersion": 1,
"uploadUnit": "beat",
"kinds": ["video", "project", "captions"],
"allowedExtensions": { "video": [".mp4"], "project": [".pulse", ".zip"], "captions": [".vtt"] },
"maxUploadSize": 5368709120,
"checksum": { "algorithms": ["sha256", "sha1", "md5"] }
}checksum.algorithms always lists what createChecksumValidator/createS3ChecksumValidator are capable of verifying — it does not detect whether this deployment's validatePayload actually calls one of them. A client sending a checksum metadata value is only verified if the operator wired in one of these helpers (or an equivalent check of their own).
See PROTOCOL.md §2 for the full normative shape.
Don't want to write your own auth scheme? issueCapabilityToken/verifyCapabilityToken/createCapabilityAuthorize implement a stateless, HMAC-signed token — no server-side session table, so you can rotate secrets, change TTL policy, or revoke at the artifact level entirely on your own schedule.
import pulseVault, {
createLocalStorage,
createCapabilityAuthorize,
issueCapabilityToken,
buildUploadLink,
} from "@mieweb/pulsevault";
import { randomUUID } from "node:crypto";
const keys = { "2026-06": process.env.PULSEVAULT_KEY_2026_06! }; // add the previous key during rotation
const issuer = "https://vault.example.org"; // identity claim — no path, just who issued the token
const prefix = "/pulsevault";
await app.register(pulseVault, {
prefix,
storage: createLocalStorage({ workspaceDir: "./data" }),
maxUploadSize: 5 * 1024 * 1024 * 1024,
authorize: createCapabilityAuthorize((kid) => keys[kid] ?? null, { issuer }),
});
app.post("/pair", async (_req, reply) => {
const artifactId = randomUUID();
const token = issueCapabilityToken(artifactId, keys["2026-06"], {
keyId: "2026-06",
issuer,
expirySeconds: 1800, // 30 minutes — long enough for one upload session
});
// `server` is the full base URL the client uploads to — origin *plus* the
// plugin's prefix — not just `issuer`'s bare origin.
return reply.send({ link: buildUploadLink({ server: `${issuer}${prefix}`, artifactId, token }) });
});A token authorizes either the artifact it names, or any artifact that declares that one as its relatedTo — so one token issued for a video also covers its captions and (under uploadUnit: "beat") every beat and the manifest 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.
The TUS Upload-Metadata header is a comma-separated list of <key> <base64> pairs. PulseVault reads the following keys on POST /upload:
| Key | Required | Description |
|---|---|---|
artifactId |
Yes (or videoid/projectid) |
Server-generated UUID for this upload. |
videoid / projectid |
Legacy aliases for artifactId |
Accepted as synonyms indefinitely (protocol v1). Use artifactId for new code. |
filename |
Yes | Original filename. The extension is validated against the kind's allowed list. |
kind |
No | video (default), project, or captions. Determines the storage subdir and which hooks fire. |
relatedTo |
No | UUID of another artifact this one belongs to (e.g. a video's captions). Lets one capability token authorize a whole session. |
checksum |
No | <algorithm>:<hex digest> of the finished file, verified by createChecksumValidator/createS3ChecksumValidator if configured. |
Example (kind=captions, linked to a video):
Upload-Metadata: artifactId <base64(uuid)>, filename <base64("clip.vtt")>, kind <base64("captions")>, relatedTo <base64(videoArtifactId)>
import { createLocalStorage } from "@mieweb/pulsevault";
const storage = createLocalStorage({
workspaceDir: "./data", // directory for uploads; created if absent
metaCacheLimit: 10_000, // optional — bounds the in-memory metadata cache
});The local adapter writes uploads into flat kind-scoped subdirectories. Downstream tools may rely on this layout across minor versions:
<workspaceRoot>/
.pulsevault/<id>.json # sidecar: { version, ext, filename, status, kind, relatedTo, checksum }
video/<id><ext> # video upload bytes (kind="video")
video/<id><ext>.json # @tus/file-store offset/metadata sidecar
project/<id><ext> # project bundle bytes (kind="project")
project/<id><ext>.json # @tus/file-store offset/metadata sidecar
captions/<id><ext> # captions bytes (kind="captions")
captions/<id><ext>.json # @tus/file-store offset/metadata sidecar
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).
The adapter exposes storage.workspaceRoot (absolute, resolved from workspaceDir) so consumers can compute per-resource paths without re-implementing the layout. storage.getKind(id) returns "video" | "project" | "captions" | null; storage.getRelatedTo(id) and storage.getChecksum(id) return the corresponding sidecar fields, each null if absent/unknown.
Horizontal scaling: this adapter requires sticky-session routing or a shared filesystem across instances — see OPERATIONS.md.
The filesystem layout is the integration surface. Use the onUploadComplete hook as your trigger. For example, to hydrate an ArtiPod with the video plus sibling artifact directories:
import path from "node:path";
import { ArtiPod, ArtiMount } from "@mieweb/artipod";
import pulseVault, { createLocalStorage } from "@mieweb/pulsevault";
const storage = createLocalStorage({ workspaceDir: "./data" });
await app.register(pulseVault, {
prefix: "/pulsevault",
storage,
maxUploadSize: 5 * 1024 * 1024 * 1024,
onUploadComplete: async (_req, { artifactId, kind }) => {
if (kind !== "video") return;
const videoDir = path.join(storage.workspaceRoot, "video");
const pod = new ArtiPod({ id: artifactId, useMainMount: false });
pod.addMount(new ArtiMount("video", videoDir));
// Create these lazily as your pipeline produces artifacts:
// pod.addMount(new ArtiMount("transcripts", path.join(root, "transcripts")));
// pod.addMount(new ArtiMount("frames", path.join(root, "frames")));
await pod.initialize();
// Run a containerized transcription step, build an LLM prompt from
// collected artifacts, etc. See the @mieweb/artipod docs for details.
},
});@mieweb/artipod is not a dependency of this plugin — install it in your app only if you want it. Any filesystem-native pipeline (ffmpeg, whisper, rsync) works equally well.
createS3Storage is a built-in adapter that streams uploads into an S3-compatible bucket via S3 multipart upload and serves playback by redirecting the client to a short-lived presigned URL (so bytes never flow back through your server). Because Cloudflare R2 speaks the S3 API, the same adapter covers both R2 and AWS S3 — they differ only by endpoint and credentials. It has no horizontal-scaling caveat — every instance talks to the same bucket.
It's opt-in: the AWS SDK and @tus/s3-store are optionalDependencies, loaded lazily only when you call createS3Storage. Install them in your app:
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner @tus/s3-storecreateS3Storage is async (it lazily imports those packages), so await it before registering the plugin.
Cloudflare R2:
import pulseVault, { createS3Storage, createS3Mp4Sniffer } from "@mieweb/pulsevault";
const storage = await createS3Storage({
bucket: "pulse-videos",
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
accessKeyId: process.env.R2_ACCESS_KEY,
secretAccessKey: process.env.R2_SECRET_KEY,
});
await app.register(pulseVault, {
prefix: "/pulsevault",
storage,
maxUploadSize: 5 * 1024 * 1024 * 1024,
validatePayload: createS3Mp4Sniffer(storage), // ranged-read MP4 sniff
});AWS S3 — drop the custom endpoint and set region:
const storage = await createS3Storage({
bucket: "pulse-videos",
region: "us-east-1",
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
});Credentials are optional — omit accessKeyId/secretAccessKey to use the AWS SDK's default credential chain (env vars, IAM role, etc.). Never hard-code keys; read them from the environment — see OPERATIONS.md for a secrets-manager example.
<bucket>/
.pulsevault/<id>.json # metadata sidecar: { version, ext, filename, status, kind, relatedTo, checksum }
video/<id><ext> # finalized video object (kind="video")
project/<id><ext> # finalized project object (kind="project")
captions/<id><ext> # finalized captions object (kind="captions")
<key>.info # @tus/s3-store multipart bookkeeping (transient)
resolve() only returns a presigned URL once the sidecar status is "ready" (after the final byte lands and validatePayload passes), so in-progress or rejected uploads are never served. getKind(id) returns the kind without a full resolve; getRelatedTo(id)/getChecksum(id) mirror the local adapter.
| Option | Default | Notes |
|---|---|---|
bucket |
— | Required. Must already exist. |
endpoint |
— | Custom S3 endpoint (set for R2). Omit for AWS S3. |
region |
"auto" when endpoint is set |
Required for AWS S3. |
accessKeyId / secretAccessKey |
SDK default chain | Optional; omit both to use env/IAM credentials. |
sessionToken |
— | Optional STS session token for temporary credentials. |
forcePathStyle |
true when endpoint is set |
R2 and most S3-compatible stores need path-style. |
presignTtlSeconds |
900 |
Lifetime of the playback presigned URL. |
partSize |
computed | Preferred multipart part size (≥ 5 MiB), forwarded to @tus/s3-store. |
metaCacheLimit |
10000 |
Caps the in-memory metadata cache before evicting the oldest entry. |
clientConfig |
— | Advanced: extra S3ClientConfig merged into the client. |
A typical deployment wires these to environment variables (e.g.
S3_BUCKET,S3_ENDPOINT,AWS_REGION,S3_ACCESS_KEY_ID,S3_SECRET_ACCESS_KEY) and picks the storage adapter with aSTORAGE=local|s3switch — the options table above maps one-to-one ontocreateS3Storage(...).
The default createMp4Sniffer/createChecksumValidator read a local file path, which doesn't exist for a bucket object. Use createS3Mp4Sniffer(storage)/createS3ChecksumValidator(storage) instead: they fetch the bytes they need via the adapter (a small ranged GET for the MP4 sniff; a full object download for the checksum, since a digest needs every byte — streamed through the hash via storage.digestAll rather than buffered into memory at once) rather than assuming a local path.
Because playback is a 302 redirect to a presigned URL, a browser fetching it goes directly to R2/S3. If you play back from a web origin, add a bucket CORS rule allowing your origin (GET, and Range for seeking). Native clients that follow redirects need no CORS.
Implement PulseVaultStorage to back uploads with any system (S3, GCS, database, etc.):
import type {
PulseVaultStorage,
PulseVaultResolution,
} from "@mieweb/pulsevault";
const storage: PulseVaultStorage = {
datastore, // @tus/server DataStore instance
async initialize() {
/* optional setup */
},
async shutdown() {
/* optional teardown */
},
async reserveUpload({ artifactId, filename, ext, kind, relatedTo, checksum }) {
// Called by the TUS naming function. Return the file id for the datastore.
await db.createArtifact({ artifactId, filename, kind, relatedTo, checksum, status: "uploading" });
return `${kind}/${artifactId}${ext}`;
},
async resolve(artifactId): Promise<PulseVaultResolution | null> {
const artifact = await db.findArtifact(artifactId);
if (!artifact || artifact.status !== "ready") return null;
// Stream from local disk:
return { kind: "stream", root: "/uploads", filename: artifact.filename };
// Or redirect to a CDN / presigned URL:
// return { kind: "redirect", url: artifact.signedUrl, statusCode: 302 };
},
async markReady(artifactId) {
// Called after `validatePayload` (if any) accepts the bytes. Flip your
// state so `resolve` starts returning non-null. Omit this method if
// your backend can't distinguish in-progress from finalized uploads.
await db.updateArtifact(artifactId, { status: "ready" });
},
async remove(artifactId) {
// Called from DELETE /artifacts/:artifactId and from the plugin's
// cleanup path when `validatePayload` rejects an upload. Return false if
// the artifactId was already absent.
const result = await db.deleteArtifact(artifactId);
return result.deleted;
},
// Optional — only needed if you use relatedTo/checksum-aware hooks/validators:
async getKind(artifactId) { return (await db.findArtifact(artifactId))?.kind ?? null; },
async getRelatedTo(artifactId) { return (await db.findArtifact(artifactId))?.relatedTo ?? null; },
async getChecksum(artifactId) { return (await db.findArtifact(artifactId))?.checksum ?? null; },
};Use this to generate a pulsecam:// deep link for pairing the Pulse mobile app with your server. Typically encoded as a QR code on a pairing page.
import { buildUploadLink } from "@mieweb/pulsevault";
import { randomUUID } from "node:crypto";
// Opens the app directly on the upload screen for a specific artifact.
// `server` must include your `prefix` — e.g. "https://example.com/pulsevault",
// not just the bare origin — the client has no separate notion of a prefix.
const uploadLink = buildUploadLink({
server: "https://example.com/pulsevault",
artifactId: randomUUID(), // generate server-side; skip POST /reserve on the app
token: "secret", // optional — forwarded to your authorize hook; see Capability tokens
uploadUnit: "merged", // optional — see "Upload unit" below
});
// pulsecam://?v=1&artifactId=...&server=https%3A%2F%2Fexample.com%2Fpulsevault&token=secret&uploadUnit=mergeduploadUnit on the link is a per-session override of whatever GET /capabilities currently reports (PROTOCOL.md §3, §8). Omit it and nothing changes — the client falls back to /capabilities exactly as before. Set it when you want "beat" and "merged" sessions live at the same time (staged rollout, A/B test, per-tenant policy) instead of one fixed value for the whole deployment — /capabilities can only ever report one current value, and a client reading it separately from opening the link is racing whatever the server happened to be serving at that moment, not the value this specific session was paired under.
npm testRuns a Node --test suite against the built plugin. Coverage includes:
- TUS create/HEAD/PATCH resume, collision handling, extension rejection, range GETs
- Ready-gate (
GETreturns 404 while uploading) - The generic
GET/DELETE /artifacts/:artifactIdroute, and that the old per-kind routes are gone (not kept as a parallel API) authorizerejection on every phase;kind/relatedToin the authorize contextvalidatePayload/onUploadCompleterunning for every kind viactx.kind, plus the deprecatedvalidateProjectPayload/onProjectUploadCompletealiaseskind=projectandkind=captionshappy paths — correct subdir,Content-Type, sidecar,relatedTolinking- Extension mismatch rejections in both directions
artifactId/videoid/projectidmetadata aliasesgetKind()/getRelatedTo()/getChecksum()storage methods- Legacy sidecars (no
kindfield) default to"video", readable through the new generic route with no data migration - Sidecar corruption recovery
allowedExtensionsobject formcreateChecksumValidator/createS3ChecksumValidator: matching digest accepted, mismatch rejected with cleanupissueCapabilityToken/verifyCapabilityToken/createCapabilityAuthorize: round-trip, tampered signature/payload, expiry + clock tolerance, unknownkid, issuer mismatch, key-rotation overlap,relatedTo-based session authorization — both as fast unit tests (no server) and wired into real HTTP requestsGET /capabilities- S3/R2 backend (
createS3Storage): full resumable upload → presigned-redirect playback,createS3Mp4Sniffer,createS3ChecksumValidator,DELETE,kind=project/captions, run against an in-process s3rver mock (no cloud credentials needed) @mieweb/pulsevault/core(the framework-agnostic entry point): the same protocol suite re-run against a barehttp.createServerwrappingcore.handler, plus an Express-specific smoke test provingapp.use(prefix, handler)composition
The storage adapter is exposed as a Fastify decorator, so you can use it in your own routes:
import "@mieweb/pulsevault/augment"; // once, for TypeScript types
app.get("/admin/artifact/:id", async (req, reply) => {
const resolved = await app.pulseVault.resolve(req.params.id);
if (!resolved) return reply.code(404).send();
// custom logic...
});Under @mieweb/pulsevault/core there's no decorator (no Fastify instance to hang one off of) — just keep the storage object you passed into createPulseVaultCore(...) and call it directly in your own routes.
MIT — see LICENSE.
Copyright © 2026 Medical Informatics Engineering, LLC.