Fastify plugin for resumable video uploads via the TUS protocol, with filesystem-first local storage and deep link helpers for the Pulse mobile app.
Pulse app Your Fastify app
───────── ────────────────
┌───────────┐ pair + ┌──────────────────────┐
│ iOS / web │ ─── TUS ───► │ Pulsevault plugin │
└───────────┘ │ POST /upload │
│ PATCH /upload/:id │
│ GET /:videoid │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ Your hooks │
│ • authorize │
│ • validatePayload │
│ • onUploadComplete │
└──┬─────────────────┬─┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Storage │ │ Your systems │
│ adapter │ │ DB · SSO · │
│ local / S3 / │ │ audit logs │
│ GCS / custom │ │ pipelines │
└──────────────┘ └──────────────┘
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 Fastify app 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 three routes; the rest stays yours.
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.
- Fastify
^5.x - Node.js
>=18
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: "",
storage: createLocalStorage({ workspaceDir: "./data" }),
maxUploadSize: 5 * 1024 * 1024 * 1024, // 5 GiB
});
// Your server owns videoid creation — attach auth, DB records, quotas here.
app.post("/reserve", async (_req, reply) => {
return reply.send({ videoid: randomUUID() });
});
await app.listen({ port: 3030 });The plugin mounts the following routes under prefix:
| Method | Path | Description |
|---|---|---|
POST |
/upload |
Create a TUS upload session |
PATCH / HEAD / DELETE * |
/upload/:id |
Upload chunks, probe offset, cancel upload (TUS) |
GET |
/:videoid |
Stream or redirect to the uploaded video |
DELETE |
/:videoid |
Delete a finalized upload (bytes + sidecar) |
* DELETE /upload/:id is TUS's own "cancel in-flight upload" — distinct from DELETE /:videoid, which removes a finalized video.
POST /reserveis not part of the plugin. Your server implements it so you control auth, ownership, and any business logic tied to video creation.
GET /:videoid 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.
type PulseVaultPluginOptions = {
storage: PulseVaultStorage;
prefix: string;
maxUploadSize: number;
decoratorName?: string; // default: "pulseVault"
allowedExtensions?: string[]; // default: [".mp4"]
cache?: PulseVaultCacheOptions;
authorize?: PulseVaultAuthorize;
validatePayload?: PulseVaultValidatePayload;
onUploadComplete?: PulseVaultOnUploadComplete;
};A PulseVaultStorage adapter. Use the built-in createLocalStorage for filesystem-backed deployments (the blessed default) or implement the interface for custom backends (S3, GCS, etc.).
URL prefix for all plugin routes. Use "" to mount at the root or "/pulsevault" to namespace. 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.
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 in Upload-Metadata.filename. Must include the leading dot. Defaults to [".mp4"].
Cache-control options for the GET /:videoid 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).
type PulseVaultAuthorize = (
request: FastifyRequest,
ctx: {
phase: "create" | "patch" | "resolve" | "delete";
videoid: string;
},
) => void | Promise<void>;await app.register(pulseVault, {
// ...
authorize: async (request, { phase, videoid }) => {
const token = request.headers.authorization?.replace("Bearer ", "");
if (!isValid(token, videoid)) {
throw Object.assign(new Error("Forbidden"), { statusCode: 403 });
}
},
});Optional async hook that runs after TUS writes the final byte but before the upload is marked ready or onUploadComplete fires. 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 video is never served.
type PulseVaultValidatePayload = (
request: FastifyRequest,
ctx: {
videoid: string;
size: number;
uploadId: string;
/** Absolute path to finalized bytes for adapters that expose `getLocalPath`. */
localPath: string | null;
},
) => void | Promise<void>;Use for magic-byte sniffing, virus scanning, size re-checks — anything that needs the final bytes. A ready-made helper ships with the package:
import pulseVault, {
createLocalStorage,
createMp4Sniffer,
} from "@mieweb/pulsevault";
const storage = createLocalStorage({ workspaceDir: "./data" });
await app.register(pulseVault, {
// ...
storage,
validatePayload: 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.
Optional async hook fired once the final byte is written, validatePayload has passed, and the sidecar has been marked ready. Use it to update a database row, enqueue a job, or write an audit log. Throwing returns a 500 to the client. The video is ready at this point — if you want all-or-nothing semantics, call storage.remove before throwing.
type PulseVaultOnUploadComplete = (
request: FastifyRequest,
ctx: { videoid: string; size: number; uploadId: string },
) => void | Promise<void>;When the final PATCH lands, the plugin runs the following in order. Any step failing short-circuits the rest.
validatePayload(optional) — throws →storage.remove(videoid), HTTP 4xx (default 422).storage.markReady(videoid)— flips the sidecar soresolve()will serve the bytes.onUploadComplete(optional) — throws → HTTP 500; bytes remain on disk and ready unless the consumer explicitly removes them.
import { createLocalStorage } from "@mieweb/pulsevault";
const storage = createLocalStorage({
workspaceDir: "./data", // directory for uploads; created if absent
});The local adapter writes each upload into a self-describing per-video directory. Downstream tools may rely on this layout across minor versions:
<workspaceRoot>/<videoid>/
.pulsevault.json # sidecar: { version, ext, filename, status }
video/<videoid><ext> # upload bytes (partial during upload, full when ready)
video/<videoid><ext>.json # @tus/file-store's offset/metadata sidecar
status is "uploading" between reserveUpload and the successful final PATCH; "ready" thereafter. GET /:videoid only serves "ready" uploads.
The adapter exposes storage.workspaceRoot (absolute, resolved from workspaceDir) so consumers can compute per-video paths without re-implementing the layout.
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: "",
storage,
maxUploadSize: 5 * 1024 * 1024 * 1024,
onUploadComplete: async (_req, { videoid }) => {
const root = path.join(storage.workspaceRoot, videoid);
const pod = new ArtiPod({ id: videoid, useMainMount: false });
pod.addMount(new ArtiMount("video", path.join(root, "video")));
// 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.
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({ videoid, filename, ext }) {
// Called by the TUS naming function. Return the file id for the datastore.
await db.createVideo({ videoid, filename, status: "uploading" });
return `${videoid}${ext}`;
},
async resolve(videoid): Promise<PulseVaultResolution | null> {
const video = await db.findVideo(videoid);
if (!video || video.status !== "ready") return null;
// Stream from local disk:
return { kind: "stream", root: "/uploads", filename: video.filename };
// Or redirect to a CDN / presigned URL:
// return { kind: "redirect", url: video.signedUrl, statusCode: 302 };
},
async markReady(videoid) {
// 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.updateVideo(videoid, { status: "ready" });
},
async remove(videoid) {
// Called from DELETE /:videoid and from the plugin's cleanup path when
// `validatePayload` rejects an upload. Return false if the videoid was
// already absent.
const result = await db.deleteVideo(videoid);
return result.deleted;
},
};Use these to generate pulsecam:// deep links for pairing the Pulse mobile app with your server. Typically encoded as QR codes on a pairing page.
import {
buildConfigureDestinationLink,
buildUploadLink,
} from "@mieweb/pulsevault";
import { randomUUID } from "node:crypto";
// Adds this server as a saved destination in the Pulse app.
const configureLink = buildConfigureDestinationLink({
server: "https://example.com",
name: "My Server", // optional — shown in the app's destination list
token: "secret", // optional — forwarded to your authorize hook
});
// Opens the app directly on the upload screen for a specific video.
const uploadLink = buildUploadLink({
server: "https://example.com",
videoid: randomUUID(), // generate server-side; skip POST /reserve on the app
token: "secret", // optional
});npm testRuns a Node --test suite against the built plugin: TUS create/HEAD/PATCH resume, collision handling, extension rejection, range GETs, the ready-gate (GET returns 404 while uploading), DELETE /:videoid, authorize rejection on every phase, validatePayload + createMp4Sniffer, onUploadComplete dispatch, and sidecar corruption recovery.
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/video/:id", async (req, reply) => {
const resolved = await app.pulseVault.resolve(req.params.id);
if (!resolved) return reply.code(404).send();
// custom logic...
});Source Available — free for non-commercial use under the terms in LICENSE, which also requires that redistributions be published under an OSI-approved open source license. Commercial use requires a separate license from Medical Informatics Engineering, LLC — contact helpdesk@mieweb.com or mieweb.com.
Copyright © 2026 Medical Informatics Engineering, LLC. All rights reserved.