TUS is resumable PATCH with Upload-Offset. The browser talks directly to the Go backend. Next.js should not proxy the entire file. After PostFinish, enqueue DASH: golang-video-dash.
Stack and scope
| Part | Role |
|---|---|
| github.com/tus/tusd/v2 | TUS handler (creation, HEAD, PATCH, concatenation if needed) |
| Gin | Mounting the handler, auth, CORS |
| tus-js-client | Client in App Router ("use client") |
| Hooks | Validate meta, move file, trigger job |
TUS 1.0.x: header Tus-Resumable: 1.0.0. Preserve the major of tusd already in go.mod.
Non-negotiable rules
- Upload directly to Go (or path rewrite without buffering the body in Node).
- Authn/authz at creation (
POST) and again at thePATCH/HEADof the same upload. - Limit
Upload-Lengthand MIME (video/mp4, …) in the hook — do not trust the client. - CORS: expose and allow TUS headers (
Tus-Resumable,Upload-Offset,Upload-Length,Upload-Metadata,Location). - Disk/S3: one upload = one opaque ID; original filename only in metadata.
PostFinish: transition status and enqueue processing; do not transcode in the hook.
Go backend (tusd + Gin)
composer, err := tusd.NewStoreComposer()
store := filestore.FileStore{Path: cfg.TusDir}
store.UseIn(composer)
handler, err := tusd.NewHandler(tusd.Config{
BasePath: "/files/",
StoreComposer: composer,
NotifyCompleteUploads: true,
PreUploadCreateCallback: func(hook tusd.HookEvent) error {
// authz já feita no middleware; valide length/MIME aqui
return nil
},
})
r.Use(tusCORS())
g := r.Group("/files")
g.Use(Authenticate())
g.Any("/*path", gin.WrapH(http.StripPrefix("/files/", handler)))
BasePathandStripPrefixequal to the public path (include trailing slash as tusd expects).- Consume
handler.CompleteUploads(orUnroutedHandler+ hooks) in a goroutine with an owner — seegolang-goroutines. - TUS metadata is Base64; decode and parse (
filename,contentType,videoId) with a limited size. - Production: S3 store (
s3store) +memorylocker/redislocker between instances; file store only in single-node/dev. MaxSizein the tusd config aligned with the product (e.g. 2–10 GiB).
CORS (browser → Go)
Allow POST, HEAD, PATCH, OPTIONS, DELETE and headers:
Authorization, Cookie, Content-Type, Tus-Resumable, Upload-Length, Upload-Offset, Upload-Metadata, Upload-Defer-Length, Upload-Concat
Expose: Location, Tus-Resumable, Tus-Version, Tus-Extension, Tus-Max-Size, Upload-Offset, Upload-Length.
Access-Control-Allow-Credentials only if the session is cookie-based; otherwise Bearer in the Authorization.
Next.js frontend
Do not use a Server Action for the binary. Client component + tus-js-client:
"use client";
import { Upload } from "tus-js-client";
export function startVideoUpload(file: File, token: string) {
return new Promise<string>((resolve, reject) => {
const upload = new Upload(file, {
endpoint: process.env.NEXT_PUBLIC_TUS_ENDPOINT!, // https://api.example.com/files/
chunkSize: 5 * 1024 * 1024,
retryDelays: [0, 1000, 3000, 5000],
headers: { Authorization: `Bearer ${token}` },
metadata: {
filename: file.name,
contentType: file.type || "video/mp4",
},
onError: reject,
onSuccess: () => resolve(upload.url!),
onProgress: (sent, total) => {
/* percent = sent / total */
},
});
upload.findPreviousUploads().then((prev) => {
if (prev[0]) upload.resumeFromPreviousUpload(prev[0]);
upload.start();
});
});
}
NEXT_PUBLIC_TUS_ENDPOINTpoints to the Go, not to/apiof Next, unless it is a streaming rewrite without a body limit.- Fingerprint/
removeFingerprintOnSuccess: resume after refresh on the same origin. - Pause/abort:
upload.abort(); do not leave an orphan PATCH without UX. - Validate
file.typeand size also in the UI; the server is the source of truth. - After success: call the API/action only to “register URL/id” — metadata, not the file.
Next.js: what to avoid
| Avoid | Do |
|---|---|
Route Handler that reads the File and does fetch to Go |
TUS Client → Go |
serverActions.bodySizeLimit huge |
Binary outside Next |
| Forget OPTIONS preflight | Full CORS TUS in Go |
Cookie SameSite=Strict + cross-site API |
Bearer or SameSite=None+Secure aware |
Rewrite (next.config rewrites to /files) only if not buffering; prefer API domain.
Connection to DASH
create video (API) → status=uploading
TUS POST/PATCH → bytes no store
PostFinish → status=processing → job FFmpeg DASH
ready → player usa /vod/{id}/manifest.mpd
- Tie
Upload-Metadatato an already authenticatedvideoId(created before TUS). - Do not publish the raw MP4 if the product is DASH only; treat the original as scratch and expire it.
Anti-patterns
- Classic multipart “one 4GB POST” through Next
- TUS without auth on PATCHes
- Accept any
Upload-Length - Heavy synchronous hook (FFmpeg in
PostFinish) - CORS
*with credentials - Trust
filenamefor disk path
Completion criteria
- POST/HEAD/PATCH TUS work and resume after network interruption
- Authz on creation and continuation
- Size/MIME limits on the server
- Correct CORS/TUS headers in the browser
- Next only orchestrates UI + metadata
PostFinishqueues DASH without blocking the upload