Skip to content

Web quickstart: uploads and previews in the browser

This quickstart adds photo and video uploads to the web app of a social app, with a progress bar, pause, resume (also after the page was reloaded) and cancel, and shows the files back: images, PDFs, text and playable video. It uses ccs-files-user-client, which runs the upload protocol for you. It is for frontend developers; your backend needs one small route.

User scope: files:access
Flow The app holds Your backend Use it when
The app uploads as the user (most of this page) A user token from your token route Hands out tokens The app browses, searches or uploads under the user’s own permissions
Your backend creates the ticket (below) Nothing Creates the ticket, completes it, links the file to its record Your API decides who may attach what to which record, as in the backend quickstart

Both send the bytes straight from the browser to the store with signed URLs; neither sends file bytes through your backend or the File Service API.

  1. Install the library in your web app (GitHub Packages, see Install):

    Terminal window
    npm install @central-code-station/ccs-files-user-client
  2. Add the token route to your backend. It exchanges the user’s token for a File Service token and answers it with the service’s address and your context. The details are on User tokens:

    files-token.ts (your backend)
    import { CcsIdentityClient, CcsOAuthError } from '@central-code-station/ccs-identity-client';
    import { filesEndpoints } from '@central-code-station/ccs-files-client';
    import type { CurrentUser } from '../shared/app';
    // The Files API and its resource identifier (never the audience), as FilesClient computes them: production unless
    // FILES_API_URL / FILES_API_RESOURCE or CCS_ENVIRONMENT say otherwise.
    const { apiUrl, resource } = filesEndpoints({ env: process.env });
    const ccsIdentity = new CcsIdentityClient({
    issuerUrl: process.env.CCS_ISSUER_URL!,
    clientId: process.env.CCS_FILES_CLIENT_ID!, // the backend client that holds the token-exchange permission files:access
    clientSecret: process.env.CCS_FILES_CLIENT_SECRET!,
    });
    // GET /files-token, behind your normal access-token check: the user's own token is already verified.
    export async function filesToken(user: CurrentUser): Promise<{ status: number; headers: Record<string, string>; body: unknown }> {
    try {
    const { access_token, expires_in } = await ccsIdentity.exchangeToken({
    subjectToken: user.accessToken, // the token your app sent to your API
    resource, // https://files.centralcodestation.com/api in production
    scope: 'files:access',
    });
    return {
    status: 200,
    headers: { 'Cache-Control': 'no-store' },
    body: { access_token, expires_in, api_url: apiUrl, tenant_id: process.env.CCS_TENANT_ID, application_id: process.env.CCS_APPLICATION_ID },
    };
    } catch (error) {
    // invalid_grant (the user's token expired), unauthorized_client or invalid_target (setup): never retry blindly.
    if (error instanceof CcsOAuthError) return { status: 401, headers: {}, body: { error: error.error } };
    throw error;
    }
    }
  3. Create the client in your app, from the route:

    files.ts
    import { createTokenProvider, FilesClient } from '@central-code-station/ccs-files-user-client';
    import { getAppAccessToken } from '../shared/web-app';
    // Your backend's token route (see "User tokens"), called with your app's own session.
    export const tokens = createTokenProvider({
    url: '/api/files-token',
    headers: async () => ({ Authorization: `Bearer ${await getAppAccessToken()}` }),
    // credentials: 'include', // instead of headers, for a cookie session
    });
    // The route answers api_url, tenant_id and application_id, so the client configures itself. The token is cached until
    // a minute before it expires, renewed with one request at a time, and asked for again once after a 401.
    export const files = await FilesClient.fromTokenRoute(tokens);
    // Or configure it yourself:
    // new FilesClient({ apiUrl, tenantId: 'fitness-co', applicationId: 'fitness-app', getAccessToken: tokens.getAccessToken });
    // (apiUrl is optional: omitted, the production File Service.)
    // On sign-out: cancels the uploads in progress, clears the URL cache and the token.
    export const signOut = () => files.signOut();

The token lives at most 10 minutes. The token provider renews it a minute before it expires, with one request at a time, and asks again once when the service answers 401.

Upload with progress, pause, resume and cancel

Section titled “Upload with progress, pause, resume and cancel”

Read the application’s public settings first (GET P/settings/public): the allowed types, the largest file and the upload policies. A file outside them is refused at the ticket anyway (type_not_allowed, file_too_large, quota_exceeded), before any byte is sent, but checking first gives the user an answer at once.

upload.ts
import { FilesPayloadTooLargeError, FilesUnprocessableError, type FileRecord } from '@central-code-station/ccs-files-user-client';
import { files } from './files-client';
// Check the picked file against the application's rules before anything is sent.
const settings = await files.settings.public();
export function allowed(file: File): boolean {
const typeOk = settings.allowed_types.some((t) => t === '*' || t === file.type || (t.endsWith('/*') && file.type.startsWith(t.slice(0, -1))));
return typeOk && file.size <= settings.max_file_size_bytes;
}
export async function uploadPhoto(file: File, postId: string, render: (fraction: number) => void): Promise<FileRecord> {
const upload = files.upload(file, { external_ref: `post:${postId}`, visibility: 'application', on_name_conflict: 'rename' });
upload.on('progress', ({ loaded, total }) => render(loaded / total)); // bytes in flight included
upload.on('state', (state) => console.log(upload.name, state)); // creating, uploading, paused, completing, done, failed, cancelled
// Wire these to your buttons: pause() stops the PUTs, resume() continues (or retries a failed upload), cancel() releases the ticket.
pauseButton.onclick = () => upload.pause();
resumeButton.onclick = () => upload.resume();
cancelButton.onclick = () => void upload.cancel();
try {
return await upload.done; // the File: available, processing still pending
} catch (error) {
if (error instanceof FilesPayloadTooLargeError) throw new Error(`Too large (${error.code})`); // file_too_large, quota_exceeded
if (error instanceof FilesUnprocessableError) throw new Error('This type is not allowed here'); // type_not_allowed
throw error;
}
}
declare const pauseButton: HTMLButtonElement, resumeButton: HTMLButtonElement, cancelButton: HTMLButtonElement;

What the library does:

  • Small files (up to 64 MiB) go in one PUT with exactly the ticket’s headers. Larger ones go in parts, 4 at a time, each retried with backoff, and the part URLs are fetched as needed.
  • Progress counts the bytes the store confirmed and the bytes in flight, so the bar moves smoothly.
  • Pause stops the PUTs in flight. Resume continues, keeping every part the store confirmed; it also retries a failed upload. Cancel stops for good and releases the ticket and its quota reservation.
  • done resolves with the file, available at once. Processing (type check, malware scan, thumbnails, previews) runs after it; see Processing.

A large upload can outlive the page. Keep each upload’s snapshot in localStorage; it holds no bytes and no signed URL. After a reload the browser cannot reopen the file by itself, so ask the user to pick it again and resume:

resume.ts
import type { UploadHandle, UploadSnapshot, FileRecord } from '@central-code-station/ccs-files-user-client';
import { UploadFileMismatchError } from '@central-code-station/ccs-files-user-client';
import { files } from './files-client';
// Keep a snapshot of each upload while it runs: no bytes and no signed URL, only what identifies the ticket and the file.
export function remember(upload: UploadHandle<FileRecord>): void {
const save = () => localStorage.setItem(`upload:${upload.id}`, JSON.stringify(upload.serialize()));
upload.on('progress', save);
upload.on('state', (state) => (state === 'done' || state === 'cancelled' ? localStorage.removeItem(`upload:${upload.id}`) : save()));
}
// After a reload: the browser cannot reopen the file by itself, so ask the user to pick it again.
export function pendingUploads(): UploadSnapshot[] {
return Object.keys(localStorage)
.filter((key) => key.startsWith('upload:'))
.map((key) => JSON.parse(localStorage.getItem(key)!) as UploadSnapshot);
}
export function resume(snapshot: UploadSnapshot, pickedAgain: File): UploadHandle<FileRecord> | null {
try {
// An open multipart ticket continues from the parts the store already has; a completed one resolves with its file;
// anything else starts over with a new ticket.
return files.resumeUpload(snapshot, pickedAgain);
} catch (error) {
if (error instanceof UploadFileMismatchError) return null; // another file (size or lastModified differ)
throw error;
}
}

An open multipart ticket continues from the parts the store already has. A ticket that was completed meanwhile resolves with its file. Anything else (a single PUT, an expired ticket) starts over with a new ticket. A different file (another size or modification time) is refused with UploadFileMismatchError before anything is sent.

GET P/files/:file_id/preview answers what to show and a signed URL for it. playbackSources lists a video’s playable URLs, best first: the H.264 MP4 renditions when they are ready, then the original.

preview.ts
import { playbackSources } from '@central-code-station/ccs-files-user-client';
import { files } from './files-client';
// One call says how to show a file. URLs are cached until a minute before they expire.
export async function show(fileId: string, container: HTMLElement): Promise<void> {
const preview = await files.preview(fileId);
switch (preview.kind) {
case 'image':
container.replaceChildren(Object.assign(document.createElement('img'), { src: preview.url! }));
break;
case 'video': {
// The mp4 renditions when they are ready (1080p, then 720p), then the original.
const video = Object.assign(document.createElement('video'), { controls: true, preload: 'metadata' });
for (const source of playbackSources(preview)) {
video.append(Object.assign(document.createElement('source'), { src: source.url, type: source.mime_type ?? '' }));
}
container.replaceChildren(video);
break;
}
case 'audio':
container.replaceChildren(Object.assign(document.createElement('audio'), { controls: true, src: preview.url! }));
break;
case 'pdf':
container.replaceChildren(Object.assign(document.createElement('iframe'), { src: preview.url!, title: 'PDF' }));
break;
case 'text':
container.textContent = await (await fetch(preview.text!.url)).text(); // cut after 1 MiB when text.truncated
break;
default: // 'none': Office documents, archives, a file still processing. Offer a download.
container.replaceChildren(Object.assign(document.createElement('button'), { textContent: 'Download', onclick: () => files.files.download(fileId) }));
}
}
kind Show
image The preview rendition (WebP, up to 1280 pixels), or the original while it is being made
video mp4_1080p or mp4_720p when ready, otherwise the original
audio, pdf The original, in the browser’s own player or viewer
text The text, cut after 1 MiB (text.truncated)
none Nothing can be shown: Office documents, archives, a file whose preview is not ready. Offer a download

The URLs live 15 minutes by default; the client caches them until a minute before they expire. Never store them: store the file_id. See Previews and renditions.

A page of a feed shows many files. Mint their thumbnails in one request (POST P/files/urls, up to 200 ids): the answer has one entry per id, in order, and an entry that cannot be served carries an error instead of failing the request.

feed.ts
import { files } from './files-client';
// A feed page: one request for every thumbnail, answered in the order of the ids, cached per file.
export async function thumbnails(fileIds: string[]): Promise<Map<string, string | null>> {
const { items } = await files.urls(fileIds, { rendition: 'thumbnail' });
return new Map(
items.map((item) => [
item.file_id,
// error per item: rendition_not_available (still processing), scan_pending (not scanned yet), missing_action, ...
item.url ?? null,
]),
);
}

In this flow the app holds no File Service token. Your API checks its own rule (the user wrote the post), creates the ticket with the Node client and hands it to the app; the app sends the bytes with FilesClient.uploadFromTicket, which has the same progress, retries, pause and resume; your API completes the ticket and links the file to the post in the same request. The backend side is the backend quickstart: files.uploads.create returns the ticket to hand out, files.uploads.parts gives more part URLs, files.uploads.complete takes the parts.

attach-to-post.ts
import { FilesClient } from '@central-code-station/ccs-files-user-client';
import { myApi } from '../shared/web-app';
// The backend-mediated flow: the app holds no File Service token. Your API creates the ticket with ccs-files-client,
// the app sends the bytes with the library's engine (progress, retries, pause, resume), your API completes it.
export async function attachToPost(postId: string, file: File, render: (fraction: number) => void): Promise<string> {
const ticket = await myApi.createUpload(postId, { name: file.name, size: file.size, mime_type: file.type });
const upload = FilesClient.uploadFromTicket(ticket, file, {
onProgress: ({ loaded, total }) => render(loaded / total),
// More part URLs (above 100 parts, or when one expired after an hour): your API calls uploads.parts.
partUrls: ({ from, count }) => myApi.uploadParts(ticket.upload_id, from, count),
});
// Keep upload.serialize() with the user's session data to continue after a reload:
// FilesClient.uploadFromTicket(snapshot, file, options). It holds the ticket's signed URLs.
const { upload_id, parts } = await upload.done; // parts: [{ part_number, etag }], [] for a single PUT
const { file_id } = await myApi.completeUpload(postId, upload_id, parts);
return file_id;
}

done resolves with { upload_id, file_id, method, parts }, and parts ([{ part_number, etag }], empty for a single PUT) is exactly what files.uploads.complete needs. Cancelling only stops the PUTs: your backend releases the ticket with files.uploads.abort, or lets it expire. A snapshot of a ticket upload keeps the ticket’s signed URLs, because nothing else can mint them again: keep it with the user’s session data, not in shared storage.

files.service.ts
import { DestroyRef, Injectable, inject } from '@angular/core';
import { createFiles } from '@central-code-station/ccs-files-user-client/angular';
import { tokens } from './files-client';
// createFiles is a plain factory: call it from your own service. The client is disposed with the injector.
@Injectable({ providedIn: 'root' })
export class FilesService {
private readonly files = createFiles(
{ apiUrl: 'https://files.centralcodestation.com/api/v1', tenantId: 'fitness-co', applicationId: 'fitness-app', getAccessToken: tokens.getAccessToken },
inject(DestroyRef),
);
readonly client = this.files.client;
/** Signal<readonly UploadView[]>: { id, name, size, fileId, state, progress, error, handle }, updated on every progress event. */
readonly uploads = this.files.uploads;
upload(file: File, postId: string) {
return this.files.upload(file, { external_ref: `post:${postId}`, visibility: 'application' });
}
}
// In a template: @for (u of files.uploads(); track u.id) { <progress [value]="u.progress.loaded" [max]="u.progress.total"></progress> }

createFiles returns the client and a Signal of the uploads, updated on every progress event. Pass a ready client (from fromTokenRoute) instead of options when you build it elsewhere.

Upload a photo, then open it in the Files console: it is in the root folder (or the folder you passed), owned by the user, with external_ref set. Pause a large video halfway, reload the page, pick the file again and resume: only the missing parts are sent.