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.
files:accessTwo ways to upload
Section titled “Two ways to upload”| 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.
Set up
Section titled “Set up”-
Install the library in your web app (GitHub Packages, see Install):
Terminal window npm install @central-code-station/ccs-files-user-client -
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:accessclientSecret: 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 APIresource, // https://files.centralcodestation.com/api in productionscope: '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;}} -
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.
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.
doneresolves with the file,availableat once. Processing (type check, malware scan, thumbnails, previews) runs after it; see Processing.
Resume after a reload
Section titled “Resume after a reload”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:
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.
Show the file
Section titled “Show the file”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.
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.
URLs for a feed
Section titled “URLs for a feed”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.
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, ]), );}The backend creates the ticket
Section titled “The backend creates the ticket”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.
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.
Angular and React
Section titled “Angular and React”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.
import type { FilesClient, TokenProvider } from '@central-code-station/ccs-files-user-client';import { useFiles, usePreview, useUpload } from '@central-code-station/ccs-files-user-client/react';
export function Uploader({ tokens, postId }: { tokens: TokenProvider; postId: string }) { const { client } = useFiles({ tokens }); // null until the token route answered const { start, uploads } = useUpload(client); return ( <> <input type="file" disabled={!client} onChange={(e) => start(e.target.files![0]!, { external_ref: `post:${postId}` })} /> {uploads.map((u) => ( <div key={u.id}> {u.name} <progress value={u.progress.loaded} max={u.progress.total} /> {u.state} {u.state === 'uploading' && <button onClick={() => u.handle.pause()}>Pause</button>} {(u.state === 'paused' || u.state === 'failed') && <button onClick={() => u.handle.resume()}>Resume</button>} </div> ))} </> );}
export function Thumbnail({ client, fileId }: { client: FilesClient | null; fileId: string }) { const { preview } = usePreview(client, fileId); // asks again shortly before the URLs expire return preview?.kind === 'image' && preview.url ? <img src={preview.url} alt="" /> : null;}useFiles({ tokens }) builds the client from the token route; useUpload keeps the uploads as state; usePreview asks again shortly before the URLs expire.
Verify
Section titled “Verify”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.
Next steps
Section titled “Next steps”- What to store in your database: the
file_id, and never a URL. - Events:
file.readythrough the Notification Service, to swap in the preview. - ccs-files-user-client: every option and behaviour.