
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
@encody/vue
Advanced tools
TUS-based resumable upload queue with a Vue 3 composable. Handles concurrency, retries, SSE status updates, file validation, and error codes out of the box.
npm install @encody/vue
import { useEncody } from '@encody/vue'
const { add, clear, files, isActive } = useEncody({
token: () => getTokenFromBackend()
})
All options accept a raw value or a Vue ref — the composable stays reactive either way.
Live demo and playground: demo.encody.io
| Option | Default | Description |
|---|---|---|
baseUrl | 'https://app.encody.io' | API origin. Override for on-prem or enterprise installs. |
endpoint | {baseUrl}/media/upload | TUS upload endpoint. Derived from baseUrl if omitted. |
sseUrl | {baseUrl}/api/upload-events | SSE endpoint for real-time status updates. Derived from baseUrl if omitted. |
token | — | async () => string — called before each upload and on SSE reconnect. Must return a short-lived JWT. Never expose your API key to the browser — request the token from your own backend, which exchanges the API key for a JWT against the Encody API. |
concurrency | 3 | Max parallel uploads. |
retryDelays | [0, 3000, 10000, 30000] | Backoff delays in ms between TUS retries. Pass [] to disable. |
maxSize | null | Max file size in bytes. null = unlimited. |
allowedTypes | [] | Allowed MIME types. Supports wildcards (image/*). Empty = all types allowed. A project's allowed types (configured in the Encody dashboard) are inherited automatically and override this — see Allowed file types. |
storage | MemoryAdapter | Queue storage adapter. Pass a persistent adapter to survive page reloads — see Persistent offline queue. |
queueTTL | 86400000 (24 h) | Max age in ms of a queued/interrupted record before startup recovery discards it. Only relevant with persistent storage. null = keep forever. |
keepCompleted | false | Keep completed (ready/cancelled) records in persistent storage across reloads. By default they are purged during startup recovery. |
pollInterval | 5000 | Watchdog: while files are processing and no SSE event arrived within this window (ms), the SDK polls /api/upload-status to catch missed events. null/0 disables. |
scanTimeout | Infinity | Max time in ms a file may sit in processing waiting for the virus-scan result. After the deadline the file resolves to ready anyway; 0 means the scan never gates readiness. Infinity (default) keeps the current behavior: ready only once the scan reported. Note: a late infected verdict still flips an already-ready file to failed, and with pollInterval disabled an elapsed deadline only resolves on the next SSE event. |
By default the queue lives in memory and is lost on reload. Plug in
@encody/storage-idb to
persist queued files — including the file bytes — in IndexedDB:
import { IndexedDBAdapter } from '@encody/storage-idb'
const { add, files, on } = useEncody({
baseUrl: 'https://...',
token: async () => getJwt(),
storage: new IndexedDBAdapter()
})
On startup the SDK scans the persisted queue: interrupted uploads restart from
byte 0, and a queue:recovered event reports what was resumed. Coming back
online or returning to a visible tab re-drains anything still queued —
this works on every browser including iOS Safari (no Background Sync needed).
If a record's file blob did not survive (storage eviction), a file:reprompt
event asks your app to let the user re-select the file:
on('file:reprompt', async ({ fileId, name, size }) => {
// show your own UI, then:
await resolveReprompt(fileId, newlySelectedFile)
})
Records queued longer than queueTTL (default 24 h) are discarded during
recovery. A storage:quota-exceeded event fires when the browser rejects a
write for lack of space (add() also rejects).
Multiple tabs work out of the box: the adapter broadcasts queue changes
via BroadcastChannel, so every tab sees live status and progress. A leader
tab (elected via the Web Locks API) owns recovery and drain, preventing
duplicate uploads. Each active upload additionally holds a per-file Web Lock
as an exact liveness signal — when a tab closes or crashes mid-upload, the
browser releases its locks instantly and another tab takes the upload over
immediately (a ~10 s staleness heuristic only applies on browsers without
Web Locks).
The token function is called by the SDK before every upload and on SSE reconnect. It must return a short-lived JWT issued by the Encody API.
Your API key must never leave your server. The recommended flow is:
Browser → POST /token → Your backend → POST /api/token → Encody API
(Authorization: Bearer ek_...)
Minimal Node.js token endpoint (no dependencies):
// token-server.mjs
import { createServer } from 'node:http'
const {
PORT = 3000,
ENCODY_API_KEY,
ENCODY_BASE_URL = 'https://app.encody.io'
} = process.env
createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/token') {
res.writeHead(404).end()
return
}
const { token } = await fetch(`${ENCODY_BASE_URL}/api/token`, {
method: 'POST',
headers: { Authorization: `Bearer ${ENCODY_API_KEY}` }
}).then(r => r.json())
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ token }))
}).listen(PORT)
ENCODY_API_KEY=ek_... node token-server.mjs
Then pass it to useEncody:
const { add, files } = useEncody({
token: async () => {
const res = await fetch('/token', { method: 'POST' })
return (await res.json()).token
}
})
A project can define which MIME types it accepts in the Encody dashboard. The SDK inherits that list automatically — you don't have to re-declare it in the browser:
allowedTypes return value.allowedTypes you pass to
useEncody(...); if the project defines none, your init allowedTypes applies; if that is
also empty, all types are allowed.Every add() is validated against the effective list — disallowed files are rejected with
ErrorCode.FILE_TYPE_NOT_ALLOWED (see Error codes).
<input type="file">The effective list maps directly onto the input's accept attribute — MIME wildcards like
image/* are valid accept tokens, so a comma-joined string works as-is. This filters the
operating-system file dialog so users only see allowed types:
<script setup>
import { useEncody } from '@encody/vue'
import { computed } from 'vue'
const { add, allowedTypes } = useEncody({ token: getTokenFromBackend })
// undefined when the list is empty → attribute omitted → all types allowed
const accept = computed(() => allowedTypes.value.length ? allowedTypes.value.join(',') : undefined)
</script>
<template>
<input type="file" multiple :accept="accept" @change="e => add([...e.target.files])">
</template>
allowedTypes is reactive, so the accept filter updates live when the project's types
change in the dashboard. Note that accept is a UX hint only — it doesn't enforce
anything; the SDK's add() validation (and the Encody server) still reject disallowed files.
const {
add, // add files to the queue
clear, // clear the queue
files, // ComputedRef<FileRecord[]> — reactive queue state
isActive, // ComputedRef<boolean> — true while any upload is in progress
allowedTypes, // Ref<string[]> — effective allowed MIME types (project override, else init), reactive
file, // (id) => { retry, cancel, pause, resume, updateMeta }
useFile, // (id | Ref<id>) => ComputedRef<FileRecord> — reactive single file
useBatch, // (batchId) => { files, progress, isComplete, hasFailed }
on, // (event, handler) => void
off, // (event, handler) => void
initSession // () => Promise<void> — establish /media/* browser session cookie
} = useEncody(options)
add(input, options?)// single file
add(file)
// multiple files — auto-grouped as a batch
add([file1, file2])
// with custom meta (stored to DB, accessible server-side)
add(files, { meta: { folderId: '123', tag: 'avatar' } })
// per-file meta
add([
{ file: fileA, meta: { tag: 'cover' } },
{ file: fileB, meta: { tag: 'thumb' } }
])
file(id)file(id).retry()
file(id).cancel()
file(id).pause()
file(id).resume()
file(id).updateMeta({ tag: 'updated' })
on('file:uploading', ({ fileId, filename, size, meta }) => {})
on('file:ready', ({ fileId, url, meta }) => {})
on('file:failed', ({ fileId, code, error, meta }) => {})
on('file:progress', ({ fileId, progress, meta }) => {})
on('batch:complete', ({ batchId, files }) => {})
on('batch:failed', ({ batchId, failed, succeeded }) => {})
on('sse:error', () => {})
on('allowedTypes:change', ({ allowedTypes }) => {}) // project's allowed types changed (JWT claim or live SSE)
file(id).raw()Returns the file as a File object. Accepts an optional options object to control the source:
| Option | Default | Description |
|---|---|---|
| (none) | auto | In-memory File if available in this session, fetches from API otherwise |
{ remote: true } | — | Always fetch from API — bypasses the in-memory cache |
{ local: true } | — | Only return in-memory File; returns null if not in queue |
// Auto — local if available, remote fallback
const f = await file(record.id).raw()
// Force remote fetch (e.g. to measure download time or get a fresh copy)
const f = await file(record.id).raw({ remote: true })
// Local only — null if the file isn't in the current session queue
const f = await file(record.id).raw({ local: true })
// Local image preview
img.src = URL.createObjectURL(f)
// Pass to another API (e.g. canvas, custom processor)
processLocally(f)
file(id).downloadUrl()Returns a Promise resolving to the authenticated download URL. Works with queue IDs (resolves the server file ID automatically) or raw server file IDs fetched from a REST listing — the caller doesn't need to know which kind of ID it has.
// from the upload queue
const url = await file(record.id).downloadUrl()
// from a REST listing — works identically
const url = await file(upload.fileId).downloadUrl()
const url = await file(upload.id).downloadUrl()
// in a Vue template
<a :href="await file(record.id).downloadUrl()">Download</a>
file(id).download(filename?)Fetches the file with auth and triggers a browser download dialog — no signed URLs needed. Uses the JWT from your token getter if configured, otherwise falls back to credentials: 'include' for session auth.
// from the upload queue
await file(record.id).download()
await file(record.id).download('custom-name.jpg')
// from a REST listing — works identically
await file(upload.fileId).download()
// in a template
<button @click="file(record.id).download(record.name)">Download</button>
initSession()Exchanges the current JWT for a browser session cookie covering all /media/* routes (/media/image, /media/download, etc.). Once established, cross-origin <img src> and <video src> requests authenticate via the cookie automatically — no Authorization header needed.
Called automatically on each add() call. Invoke explicitly at startup for image-only use cases (no uploads):
const { initSession, file } = useEncody({ token: getTokenFromBackend })
await initSession()
// <img :src="file(id).imageUrl(400, 300)"> now works cross-origin
The session is valid for 15 minutes (matching the JWT TTL). The SDK tracks expiry via a companion readable cookie (encody_media_exp) set by the server, so redundant re-init calls are skipped across page reloads.
file(id).imageUrl(width?, height?, format?, { version? })Returns an image URL string — sync, safe to use inline in templates. Passes id through as-is (accepts a fileId or any server-side id). Browser HTTP caching (1 year, public + ETag) is fully preserved since no blob fetch is involved.
// Auto-detects WebP support and scales for device pixel ratio.
// On a 2× Retina display with WebP support:
file(upload.fileId).imageUrl(400, 300) // → '/media/image/{id}/800-600/image.webp'
// On a 1× display without WebP:
file(upload.fileId).imageUrl(400, 300) // → '/media/image/{id}/400-300/image.jpg'
// Override format explicitly (DPR still applied)
file(upload.fileId).imageUrl(400, 300, 'jpeg') // → '/media/image/{id}/800-600/image.jpeg' on 2× display
// Pass 0 for either dimension to preserve aspect ratio (no letterboxing, no cropping):
file(upload.fileId).imageUrl(400, 0) // → '/media/image/{id}/800-0/image.webp' — fit to width
file(upload.fileId).imageUrl(0, 300) // → '/media/image/{id}/0-600/image.webp' — fit to height
// Original file, no transformation
file(upload.fileId).imageUrl() // → '/media/image/{id}'
// Directly in a Vue template — no computed or await needed
// <img :src="file(upload.fileId).imageUrl(400, 300)">
Cache busting (?v=): file contents can change under the same URL (server-side
replace). If your page knows a content version — the upload's md5 from your own
backend or webhook data — pass it as version; the URL gets a ?v= query
parameter, so a new version bypasses the year-long browser cache immediately.
The server ignores the parameter. Without version the URL is unchanged.
file(upload.fileId).imageUrl(400, 300, undefined, { version: upload.md5 })
// → '/media/image/{id}/800-600/image.webp?v=<md5>'
Both methods accept any server-side id — the server resolves either format automatically.
FileRecord shape{
id: string,
status: 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled',
progress: number, // 0–99 during upload, 99 during processing, 100 when ready
name: string,
size: number,
mimeType: string,
meta: object,
batchId: string | null,
error: string | null,
errorCode: ErrorCode | null,
url: string | null, // populated when status === 'ready'
serverFileId: string | null, // server-side file ID, populated when status === 'ready' — use for download()
}
import { defaultMessages, EncodyError, ErrorCode } from '@encody/vue'
ErrorCode.TOKEN_EMPTY // token() returned falsy
ErrorCode.UPLOAD_FAILED // network or server error
ErrorCode.VIRUS_DETECTED // antivirus flagged the file
ErrorCode.SSE_ERROR // SSE connection failed repeatedly
ErrorCode.FILE_TOO_LARGE // exceeds maxSize
ErrorCode.FILE_TYPE_NOT_ALLOWED // not in allowedTypes
Use defaultMessages[code] as fallbacks, override per code for i18n:
const messages = {
[ErrorCode.FILE_TOO_LARGE]: 'Die Datei ist zu groß'
}
function errorMessage(file) {
return messages[file.errorCode] ?? file.error
}
import { createEncody } from '@encody/vue/core'
const instance = createEncody({ token, baseUrl })
instance.add(files)
instance.on('file:ready', handler)
instance.destroy()
MIT
Marcus Spiegel spiegel@uscreen.de — published and supported by u|screen
FAQs
Encody client SDK — resumable upload queue with Vue composables
The npm package @encody/vue receives a total of 17 weekly downloads. As such, @encody/vue popularity was classified as not popular.
We found that @encody/vue demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.