Skip to content

Repository files navigation

htmx-ext-tus

An HTMX extension for resumable file uploads via the tus protocol.

Installation

Script tag (IIFE)

Load htmx first, then htmx-ext-tus. The tus-js-client is bundled in.

<script src="https://unpkg.com/htmx.org@2"></script>
<script src="path/to/htmx-ext-tus.js"></script>

npm (ESM)

npm install htmx-ext-tus tus-js-client htmx.org
import 'htmx.org';
import 'htmx-ext-tus';

Usage

Add hx-ext="tus" to a form (or ancestor) and set data-tus-endpoint to your tus server URL.

<form hx-ext="tus" data-tus-endpoint="https://tusd.example.com/files/">
  <input type="file" name="upload" />
  <button type="submit">Upload</button>
</form>

When the form is submitted, htmx-ext-tus intercepts the request and uploads each file via tus instead. The normal htmx AJAX request is prevented.

Multi-file uploads

Multiple file inputs are supported — each file gets its own tus upload:

<form hx-ext="tus" data-tus-endpoint="https://tusd.example.com/files/">
  <input type="file" name="avatar" />
  <input type="file" name="document" />
  <button type="submit">Upload</button>
</form>

Completion callback

To notify your server after all uploads finish, add data-tus-complete-url or a standard hx-post/hx-put attribute. htmx-ext-tus will issue an AJAX request to that URL with the upload URLs as a JSON array in the tusUploadURLs parameter.

<form hx-ext="tus"
      data-tus-endpoint="https://tusd.example.com/files/"
      data-tus-complete-url="/api/uploads/done">
  <input type="file" name="upload" />
  <button type="submit">Upload</button>
</form>

Server requirements

You need a tus 1.0.0 server. tusd is the reference implementation; quarkus-tus is a Java one that additionally implements Checksum and Expiration.

CORS

If the tus server is on a different origin from your page — it usually is — the browser must be allowed to read the tus response headers. This is the most common cause of uploads that fail for no visible reason: the request succeeds, but the client cannot see the offset it needs to continue.

Expose at least:

Upload-Offset, Location, Upload-Length, Tus-Version, Tus-Resumable,
Tus-Extension, Tus-Max-Size, Upload-Expires, Upload-Metadata, Upload-Concat

With tusd:

tusd -cors-allow-origin 'https://your-site.example'      -cors-expose-headers 'Upload-Offset,Location,Upload-Length,Tus-Version,Tus-Resumable,Tus-Extension,Tus-Max-Size,Upload-Expires,Upload-Metadata,Upload-Concat'

Access-Control-Allow-Headers must also permit the request headers the client sends: Tus-Resumable, Upload-Length, Upload-Offset, Upload-Metadata, Upload-Concat, Upload-Checksum, Upload-Defer-Length, Content-Type and X-Request-ID if you enable data-tus-add-request-id.

Extension support varies by server

data-tus-discover reports what a server actually offers, which is worth checking before relying on an extension. Notably tusd implements neither Checksum nor Expiration, so data-tus-checksum will be sent and ignored, and tus:expires will never fire against it.

tus Protocol Coverage

Core tus 1.0.0 plus every extension reachable from a browser. All of it is built on tus-js-client's public API — the library is not forked, patched or vendored.

Core protocol 1.0.0

Requirement tus-js-client htmx-ext-tus How
POST creation Yes Yes data-tus-endpoint
HEAD offset retrieval Yes Yes automatic on resume and retry
PATCH with Upload-Offset Yes Yes data-tus-chunk-size
Tus-Resumable: 1.0.0 on every request Yes Yes automatic
X-HTTP-Method-Override Yes Yes data-tus-override-patch-method
OPTIONS capability discovery No Yes data-tus-discover
Tus-Max-Size enforcement No Yes file refused before the upload starts

Extensions

Extension tus-js-client htmx-ext-tus How
Creation Yes Yes data-tus-endpoint, data-tus-metadata
Creation With Upload Yes Yes data-tus-upload-data-during-creation
Creation Defer Length Yes Yes data-tus-upload-length-deferred
Expiration No Yes tus:expires, data-tus-restart-on-expiry
Checksum No Yes data-tus-checksum
Checksum Trailer No Not possible browsers cannot send HTTP request trailers — check for yourself
Termination Yes Yes data-tus-terminate, htmx.tus.terminate(url)
Concatenation — parallel parts of one file Yes Yes data-tus-parallel
Concatenation — across separate files No Yes data-tus-concatenate
Concatenation Unfinished n/a n/a a server capability; reported by discovery

Browser limitations

Two constraints come from the browser rather than from tus or this extension.

Checksum Trailer cannot be supported. No browser exposes an API for attaching trailers to an outgoing request — RequestInit has no trailers member and XMLHttpRequest.send() takes only a body. A trailers API was proposed for Fetch and removed in 2019; the tracking issue is still open with no implementer commitment. WebAssembly does not help, since it reaches the network only through the same JavaScript APIs.

This costs nothing in practice. The trailer form exists so a client can hash while transmitting a body it cannot inspect up front, but tus chunking already buffers each PATCH body before sending it, so the digest is always computable in advance for the plain Upload-Checksum header. Both forms give the same end-to-end verification, and a server advertising checksum-trailer also advertises checksum.

Resuming after a page reload needs the file re-selected. tus URL storage persists the upload URL, not the file. A File object dies with the page and browsers will not reopen a local path without user action, so after a refresh or crash the user must pick the same file again; tus-js-client then matches it by fingerprint (name, size, type, last modified) and continues from the stored offset. Resuming within a session is seamless — see Auto-resume.

Architecture

Diagrams follow the C4 model. Sources live in docs/diagrams/ as PlantUML and can be regenerated with:

java -jar plantuml.jar -tsvg -o . docs/diagrams/*.puml

Level 1 — System context

System context

Level 2 — Containers

How htmx, this extension, tus-js-client and browser storage fit together.

Container view

Level 3 — Components

The parts of the extension itself.

Component view

Dynamic views

A standard upload, from form submit to the completion request:

Upload sequence

Interruption, retry and resume — including why the retry decision must not return null:

Resume sequence

The Checksum extension, implemented by wrapping the HTTP stack because onBeforeRequest never sees the request body:

Checksum sequence

Concatenation across separate files:

Concatenation sequence

Capability discovery and expiration:

Discovery and expiration sequence

Attributes

All attributes are inherited — set them on a parent element to apply to all forms within.

Attribute tus-js-client option Type Default
data-tus-endpoint endpoint string (required)
data-tus-chunk-size chunkSize number tus-js-client default
data-tus-retry-delays retryDelays space/comma-separated ints 0 1000 3000 5000
data-tus-parallel parallelUploads number 1
data-tus-resume storeFingerprintForResuming boolean true
data-tus-metadata metadata key=value, ... or JSON
data-tus-headers headers key=value, ... or JSON
data-tus-upload-url uploadUrl string
data-tus-upload-size uploadSize number
data-tus-upload-data-during-creation uploadDataDuringCreation boolean false
data-tus-override-patch-method overridePatchMethod boolean false
data-tus-add-request-id addRequestId boolean false
data-tus-upload-length-deferred uploadLengthDeferred boolean false
data-tus-remove-fingerprint-on-success removeFingerprintOnSuccess boolean true
data-tus-protocol protocol string "tus-v1"
data-tus-parallel-upload-boundaries parallelUploadBoundaries JSON array
data-tus-metadata-for-partial-uploads metadataForPartialUploads key=value, ... or JSON
data-tus-checksum sha1 | sha256 | sha384 | sha512
data-tus-concatenate boolean false
data-tus-discover boolean false
data-tus-restart-on-expiry boolean false
data-tus-terminate boolean false
data-tus-auto-resume boolean false
data-tus-complete-url string

Attribute notes

  • data-tus-terminate — When true, cleanup (element removal) sends a DELETE request to the tus server to terminate the upload, rather than just aborting locally.
  • data-tus-auto-resume — When true, the extension calls findPreviousUploads() before starting and automatically resumes the most recent incomplete upload for the same file. Dispatches a tus:resume event when resuming.
  • data-tus-upload-url — Set this to resume a specific upload by URL (skips creation).
  • data-tus-protocol — Protocol version string, e.g. "tus-v1" or "ietf-draft-03".

Events

All events bubble and include a detail object.

Event Detail Cancelable Description
tus:start { file, upload } No Upload is starting
tus:progress { file, bytesUploaded, bytesTotal, progress, upload } No Upload progress (0–1)
tus:success { file, upload, uploadURL, lastResponse } No Upload completed
tus:error { file, error, upload } No Upload failed
tus:chunk-complete { file, chunkSize, bytesAccepted, bytesTotal, upload } No A chunk was uploaded
tus:upload-url-available { file, upload, uploadURL } No Upload URL assigned by server
tus:before-request { file, upload, request } No Before each HTTP request
tus:after-response { file, upload, request, response } No After each HTTP response
tus:should-retry { file, upload, error, retryAttempt } Yes Retry decision — preventDefault() to skip retry
tus:resume { file, upload, previousUpload } No Resuming from a previous upload (auto-resume)
tus:auto-resume-error { file, upload, error } No findPreviousUploads() failed (upload still starts)
tus:expires { file, upload, expires, expiresAt } No Server sent Upload-Expires
tus:restart { file, upload, error } No Expired upload is being restarted from zero
tus:capabilities { versions, extensions, maxSize, checksumAlgorithms, response } No OPTIONS discovery succeeded
tus:capabilities-error { error } No OPTIONS discovery failed (uploads still start)
tus:concatenated { uploadURL, partialURLs, response } No Final concatenated upload created
tus:concatenate-error { error, partialURLs } No Concatenation could not be completed

Progress bar example

<form hx-ext="tus" data-tus-endpoint="/upload">
  <input type="file" name="file" />
  <progress id="prog" value="0" max="1"></progress>
  <button type="submit">Upload</button>
</form>

<script>
  document.querySelector('form').addEventListener('tus:progress', (e) => {
    document.getElementById('prog').value = e.detail.progress;
  });
</script>

Chunk-level progress

For more reliable progress (especially with large chunk sizes), listen to tus:chunk-complete:

<script>
  document.querySelector('form').addEventListener('tus:chunk-complete', (e) => {
    const { bytesAccepted, bytesTotal } = e.detail;
    console.log(`${bytesAccepted} / ${bytesTotal} bytes accepted by server`);
  });
</script>

Error handling

<script>
  document.querySelector('form').addEventListener('tus:error', (e) => {
    const { file, error } = e.detail;
    console.error(`Upload of ${file.name} failed:`, error.message);
  });
</script>

Retry control

Use tus:should-retry to implement custom retry logic:

<script>
  document.querySelector('form').addEventListener('tus:should-retry', (e) => {
    // Don't retry on 403 Forbidden
    if (e.detail.error.originalResponse?.getStatus() === 403) {
      e.preventDefault();
    }
  });
</script>

Auto-resume

Enable automatic resumption of previous uploads for the same file:

<form hx-ext="tus"
      data-tus-endpoint="/upload"
      data-tus-auto-resume="true">
  <input type="file" name="file" />
  <button type="submit">Upload</button>
</form>

<script>
  document.querySelector('form').addEventListener('tus:resume', (e) => {
    console.log('Resuming previous upload:', e.detail.previousUpload);
  });
</script>

Programmatic upload control

Access the tus Upload instance via the tus:start event for full programmatic control:

<script>
  document.querySelector('form').addEventListener('tus:start', (e) => {
    const upload = e.detail.upload;
    // upload.abort(), upload.findPreviousUploads(), etc.
  });
</script>

Checksum verification

Set data-tus-checksum to have every chunk hashed with Web Crypto and sent as an Upload-Checksum header, implementing the tus Checksum extension. The server rejects a corrupted chunk with 460 Checksum Mismatch, and the normal retry path takes over.

<form hx-ext="tus" hx-post="/done"
      data-tus-endpoint="https://tus.example.com/files/"
      data-tus-chunk-size="5242880"
      data-tus-checksum="sha1">
  <input type="file" name="file" />
  <button type="submit">Upload</button>
</form>

Supported algorithms are sha1, sha256, sha384 and sha512 — whatever Web Crypto provides. md5 is not available in browsers. The checksum-trailer variant of the extension is not supported and cannot be: browsers expose no API for sending HTTP request trailers. It is also unnecessary here, since each chunk is fully buffered before it is sent and can always be hashed up front.

Capability discovery

data-tus-discover issues an OPTIONS request to the endpoint before uploading and reports what the server supports. If the server advertises a Tus-Max-Size smaller than a selected file, that file fails with tus:error instead of starting a doomed upload.

<form hx-ext="tus" data-tus-endpoint="https://tus.example.com/files/"
      data-tus-discover="true">
  <input type="file" name="file" />
</form>
<script>
  document.body.addEventListener('tus:capabilities', (e) => {
    console.log('server supports:', e.detail.extensions);
    console.log('max upload size:', e.detail.maxSize);
  });
</script>

Discovery never blocks an upload — if the OPTIONS request fails, tus:capabilities-error fires and uploads proceed anyway.

Expiration

Servers implementing the Expiration extension return an Upload-Expires header. Every response carrying one fires tus:expires, so you can warn the user or schedule a resume before the deadline.

document.body.addEventListener('tus:expires', (e) => {
  console.log(`${e.detail.file.name} expires at ${e.detail.expiresAt}`);
});

Add data-tus-restart-on-expiry="true" to automatically start a fresh upload when the server reports the old one is gone (404 or 410). A tus:restart event fires first. The restart happens at most once per file, so an upload the server always reports as gone cannot loop.

Concatenation

data-tus-concatenate="true" uploads every selected file as a partial upload, then creates a final upload joining them server-side once all parts succeed.

<form hx-ext="tus" hx-post="/done"
      data-tus-endpoint="https://tus.example.com/files/"
      data-tus-concatenate="true">
  <input type="file" name="parts" multiple />
  <button type="submit">Upload</button>
</form>

tus:concatenated fires with the final upload's URL, which is also sent to the completion request as tusFinalURL. If any part fails, no final upload is created and tus:concatenate-error fires instead.

For splitting a single file across parallel connections, use data-tus-parallel instead — tus-js-client drives partial/final itself in that mode, and this extension leaves the tagging to it.

JavaScript API

The extension exposes its API in two ways:

  • ESMimport { configure, resetConfig, activeUploads, tus } from 'htmx-ext-tus'
  • IIFE / script taghtmx.tus.configure(...), htmx.tus.activeUploads, etc.

Script tag (IIFE)

When loaded via <script>, the API is available on the htmx.tus namespace:

<script>
  // Configure function-valued options
  htmx.tus.configure({ httpStack: myCustomHttpStack });

  // Check tus support
  if (htmx.tus.isSupported) {
    console.log('tus uploads supported');
  }

  // Access active uploads
  const uploads = htmx.tus.activeUploads.get(formElement);
</script>

configure(options)

Set global defaults for function-valued tus options that cannot be expressed as attributes.

import { configure } from 'htmx-ext-tus';

configure({
  httpStack: myCustomHttpStack,
  fileReader: myCustomFileReader,
  urlStorage: myCustomUrlStorage,
  fingerprint: (file, options) => {
    return Promise.resolve(['tus', file.name, file.size].join('-'));
  },
});

Accepted keys: httpStack, fileReader, urlStorage, fingerprint, metadataForPartialUploads.

resetConfig()

Clear all global defaults previously set via configure().

import { resetConfig } from 'htmx-ext-tus';

resetConfig(); // removes all configure() options

activeUploads

A WeakMap<Element, Upload[]> tracking in-progress uploads per element. Useful for programmatic abort or inspection.

import { activeUploads } from 'htmx-ext-tus';

const uploads = activeUploads.get(formElement);
if (uploads) {
  uploads.forEach(u => u.abort());
}

terminate(url, options)

Terminate an upload on the server given its URL (tus Termination extension). Options set via configure() are merged in.

await htmx.tus.terminate('https://tus.example.com/files/abc123');

abort(element, shouldTerminate)

Abort every upload tracked for an element. Pass true to also send a DELETE to the server rather than only stopping locally.

htmx.tus.abort(document.querySelector('#upload-form'), true);

createChecksumHttpStack(algorithm, innerStack)

The HTTP stack that implements the Checksum extension, exported for advanced use — for example wrapping a custom stack of your own. data-tus-checksum uses this internally.

htmx.tus.configure({
  httpStack: htmx.tus.createChecksumHttpStack('sha256', myCustomStack),
});

tus re-export

The tus-js-client module is re-exported for convenience:

import { tus } from 'htmx-ext-tus';

if (tus.isSupported) {
  console.log('tus uploads supported');
}

if (tus.canStoreURLs) {
  console.log('URL storage available for resumable uploads');
}

Development

Integration tests

The unit suite mocks tus-js-client. The integration suite does not — it runs the real client against a real server:

./dev.sh test:integration

That starts tusd in a container on a free port and runs the suite against it, covering creation, chunked uploads, metadata, termination, capability discovery, Tus-Max-Size enforcement, and Concatenation end to end.

Checksum and Expiration are covered against a small server in test/integration/checksum-server.js, because tusd implements neither. It validates digests with node:crypto — a different code path from the client's Web Crypto — so agreement between them is evidence about the encoding rather than one implementation agreeing with itself.

All commands run inside a Podman container — no local Node.js needed.

./dev.sh install       # Install dependencies
./dev.sh build         # Build dist/ bundles
./dev.sh test          # Run tests
./dev.sh test:watch    # Run tests in watch mode
./dev.sh dev           # Build in watch mode
./dev.sh shell         # Open a shell in the container

License

0BSD — the same license as htmx and its official extensions.

About

htmx extension for resumable file uploads with the tus protocol. Full tus 1.0.0 support, including the Creation, Expiration, Checksum, Termination and Concatenation extensions.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages