Frameworks
Nuxt
Server routes for webhooks and uploads, and a page to upload a chapter from.
Three files in a Nuxt app: a server route that tinypica's webhooks are sent to, a server route that uploads a chapter, and a page to upload it from. They were run in a Nuxt 4 app, against the API, before they were put here; in Nuxt 3, the page goes in pages/.
The client
A module of plain fetch and Web Crypto, with nothing to install. In server/utils/, Nuxt imports it into every server route by itself, and never into the browser.
// tinypica's API from server code: Next.js, Nuxt, SvelteKit, or anything else
// with fetch and Web Crypto (Node 20+, Deno, Bun, Cloudflare Workers). Nothing
// to install. Server-side only, since it is handed your key.
const API = 'https://tinypica.com/api/v1'
export class TinypicaError extends Error {
constructor(readonly status: number, message: string) {
super(message)
}
}
// One request. A refusal throws, with the reason tinypica gave.
export async function tinypica<T>(key: string, path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(API + path, { ...init, headers: { ...init.headers, Authorization: `Bearer ${key}` } })
if (!response.ok) {
const { statusMessage } = await response.json().catch(() => ({}))
throw new TinypicaError(response.status, statusMessage ?? response.statusText)
}
return response.json() as Promise<T>
}
// A new chapter from its pages, in reading order, with its run started. Each
// page is a File or Blob whose type is an image's: a file from a form has one.
export async function uploadChapter(key: string, projectId: string, pages: Blob[]) {
const staged = new FormData()
staged.append('staged', '1')
const chapter = await tinypica<{ id: string, position: number }>(key, `/projects/${projectId}/chapters`, { method: 'POST', body: staged })
for (const [index, page] of pages.entries()) {
const form = new FormData()
form.append('pages', page)
form.append('hold', '1')
form.append('position', String(index + 1))
await tinypica(key, `/chapters/${chapter.id}/pages`, { method: 'POST', body: form })
}
await tinypica(key, `/chapters/${chapter.id}/start`, { method: 'POST' })
return chapter
}
// A file an export.ready announced. Your key goes to tinypica and nowhere else.
export async function downloadExport(key: string, downloadUrl: string): Promise<ArrayBuffer> {
if (!downloadUrl.startsWith(`${API}/`))
throw new Error(`not a tinypica download: ${downloadUrl}`)
const response = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${key}` } })
if (!response.ok)
throw new TinypicaError(response.status, `download failed: ${response.status}`)
return response.arrayBuffer()
}
// No return type written: inferred, it is the ArrayBuffer-backed kind that
// Web Crypto takes under every TypeScript version.
function bytes(base64: string) {
return Uint8Array.from(atob(base64), char => char.charCodeAt(0))
}
// Whether a delivery is tinypica's (Standard Webhooks): an HMAC-SHA256 of
// "id.timestamp.body", keyed with the endpoint's secret. `body` is the request
// body exactly as it arrived: parsed and serialised again, it would not match.
export async function verifyWebhook(secret: string, headers: Headers, body: string): Promise<boolean> {
const id = headers.get('webhook-id')
const timestamp = headers.get('webhook-timestamp')
// Five minutes either way, so an old delivery cannot be replayed.
if (!id || !timestamp || !(Math.abs(Date.now() / 1000 - Number(timestamp)) <= 300))
return false
const key = await crypto.subtle.importKey('raw', bytes(secret.slice('whsec_'.length)), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify'])
const signed = new TextEncoder().encode(`${id}.${timestamp}.${body}`)
for (const signature of (headers.get('webhook-signature') ?? '').split(' ')) {
try {
if (await crypto.subtle.verify('HMAC', key, bytes(signature.replace(/^v1,/, '')), signed))
return true
}
catch {
// Not base64: not a signature of ours.
}
}
return false
}
// What a delivery carries (docs: /docs/api/events).
export interface WebhookEvent {
type: 'chapter.finished' | 'chapter.failed' | 'export.ready' | 'ping'
timestamp: string
data: Record<string, any>
}
The key and the endpoint's signing secret go in the runtime config, set from the environment:
export default defineNuxtConfig({
runtimeConfig: {
// Server-only. Set by NUXT_TINYPICA_KEY and NUXT_TINYPICA_WEBHOOK_SECRET.
tinypicaKey: '',
tinypicaWebhookSecret: '',
},
})NUXT_TINYPICA_KEY=tp_…
NUXT_TINYPICA_WEBHOOK_SECRET=whsec_…Receive webhooks
readRawBody gives the body as it arrived, which is what the signature is checked against. The download runs in event.waitUntil, after the answer, and the file goes to Nitro's storage. Add https://your-site.example/api/tinypica as an endpoint on the API screen.
// tinypica's webhooks. The endpoint to add on the API screen is
// https://your-site.example/api/tinypica
// verifyWebhook and downloadExport come from server/utils/tinypica.ts,
// which Nuxt imports by itself.
export default defineEventHandler(async (event) => {
const { tinypicaKey, tinypicaWebhookSecret } = useRuntimeConfig(event)
// The body as it arrived: the signature is over these bytes.
const body = await readRawBody(event, 'utf8') ?? ''
if (!await verifyWebhook(tinypicaWebhookSecret, event.headers, body))
throw createError({ statusCode: 401, statusMessage: 'Not signed by tinypica' })
const payload: WebhookEvent = JSON.parse(body)
// After the answer, which tinypica waits 10 seconds for. An event can come
// twice: keep the webhook-ids you have handled in your database.
event.waitUntil((async () => {
if (payload.type === 'export.ready') {
const file = await downloadExport(tinypicaKey, payload.data.downloadUrl)
// Nitro's storage: .data/kv/ on a Node server, or any driver you mount
// there (S3, R2, a database…).
await useStorage('data').setItemRaw(`tinypica:${payload.data.filename}`, new Uint8Array(file))
}
})().catch(error => console.error('tinypica webhook', error)))
return sendNoContent(event)
})
Upload a chapter
Your app's own route takes the form, and hands its files on to tinypica:
// Your app's own upload route, for the page in pages/chapters/new.vue. It
// spends your credits: put it behind the sign-in your other admin routes have.
export default defineEventHandler(async (event) => {
const form = await readFormData(event)
// Pages in file-name order: 1.png, 2.png, …, 10.png.
const pages = (form.getAll('pages') as File[])
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
return uploadChapter(useRuntimeConfig(event).tinypicaKey, String(form.get('project')), pages)
})
<script setup lang="ts">
// A page for your team that uploads a chapter, through server/api/chapters.post.ts.
const route = useRoute()
const uploading = ref(false)
async function upload(event: Event) {
uploading.value = true
try {
const chapter = await $fetch('/api/chapters', { method: 'POST', body: new FormData(event.target as HTMLFormElement) })
await navigateTo({ query: { started: chapter.position } })
}
finally {
uploading.value = false
}
}
</script>
<template>
<form @submit.prevent="upload">
<p v-if="route.query.started">
Chapter {{ route.query.started }} is running.
</p>
<input name="project" placeholder="Project id" required>
<input name="pages" type="file" accept="image/png,image/jpeg,image/webp" multiple required>
<button type="submit" :disabled="uploading">
Upload
</button>
</form>
</template>
How deliveries are signed, retried and ordered is in webhooks.