Short links and their expiry
A short link stores one URL fragment. The service checks the fragment’s encoding and container, not the fields of the object inside it: the app that opens the link validates its own state (src/payload.mjs).
The payload format
Section titled “The payload format”A payload is PAYLOAD_PREFIX (default #state=v1.) followed by the unpadded base64url of one gzip stream whose content is a UTF-8 JSON object. validatePayload refuses anything else with a 400 and one of these codes:
| Code | Cause |
|---|---|
bad_prefix |
The body does not start with the prefix. |
bad_encoding |
Nothing after the prefix, a character outside A-Z a-z 0-9 - _, = padding, an impossible length, or a spelling that is not the canonical base64url of its bytes. |
bad_gzip |
Not a gzip header with deflate and no optional header fields, a wrong CRC or length, trailing bytes, or a second gzip member. |
bad_json |
The content is not valid UTF-8, not JSON, or JSON that is not an object (an array, null, a string). |
Only the canonical base64url and a single gzip member are accepted, so one state has one stored text and one id. Browsers refuse trailing bytes and second members too.
Size limits are fixed in code:
| Limit | Value | Answer past it |
|---|---|---|
| Body length | prefix length + 65,536 characters (65,546 with the default prefix) | 413 too_large |
| JSON after inflating | 256 KiB | 413 too_large |
Inflating stops at 256 KiB (maxOutputLength), so a gzip bomb costs at most that much memory before it is refused.
Encoding state in the browser
Section titled “Encoding state in the browser”This sketch is not part of the repository. It produces the format above with CompressionStream:
async function encodeState(state) { const json = new TextEncoder().encode(JSON.stringify(state)); const stream = new Blob([json]).stream().pipeThrough(new CompressionStream('gzip')); const bytes = new Uint8Array(await new Response(stream).arrayBuffer()); let binary = ''; for (const byte of bytes) binary += String.fromCharCode(byte); const base64url = btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); return `#state=v1.${base64url}`;}
const response = await fetch('/api/links', { method: 'POST', headers: { 'content-type': 'text/plain' }, body: await encodeState({ view: 'board', items: [1, 2, 3] }),});const { id, url } = await response.json();The request goes to the app’s own origin, so the browser sends Origin equal to PUBLIC_ORIGIN and Sec-Fetch-Site: same-origin. Any other Content-Type than text/plain (parameters such as charset are ignored) gets 415 unsupported_media_type.
Deduplication
Section titled “Deduplication”The links table has a unique index on the SHA-256 of the payload. Posting a payload that is already stored and not expired returns 200 with the existing id and url. It does not count against any quota except the sender’s attempts. A new payload returns 201.
The redirect
Section titled “The redirect”GET /s/<id> answers 302 with Location set to PUBLIC_ORIGIN + LINK_TARGET_PATH + payload, and Cache-Control: no-store, no-transform. The browser follows it and the app reads its state from location.hash.
Every other path under /s/, including a malformed or unknown id, redirects to PUBLIC_ORIGIN + LINK_MISSING_PATH. That page belongs to the app and should say the link is gone. Both redirects are built from configuration only, never from the Host header, so /s/ cannot redirect to another site.
GET /api/links/<id> returns {id, payload, createdAt} as JSON for tooling. It does not count as opening the link.
Expiry
Section titled “Expiry”A new link gets expires_at = now + UNOPENED_TTL_MS (30 days by default). Then:
- The first
GETorHEADof/s/<id>setsexpires_attoNULL. An opened link never expires. - Posting the same payload again while it is pending moves its expiry to now +
UNOPENED_TTL_MS. - Once the expiry passes, the link reads as missing right away:
/s/<id>goes to the missing-link page and/api/links/<id>answers404. - An expired payload posted again is stored as a new link with a new id.
- Expired rows are deleted by the sweep that runs with the backups, at start and every
BACKUP_INTERVAL_MS(one hour by default), and bynode src/admin.mjs sweep. - When the store is at its cap, an insert sweeps expired links first and refuses with
507 storage_fullonly if there is still no room.
The test a link nobody opens expires; opening it keeps it; posting it again moves its expiry walks through these cases with a fake clock.
An id is ten base62 characters (0-9 A-Z a-z) drawn with crypto.randomInt, about 59.5 bits. If a drawn id is already taken the store draws again, up to eight times. Reads of /s/ and /api/links/ are rate limited per client to slow down guessing.
A different prefix
Section titled “A different prefix”PAYLOAD_PREFIX sets the fragment prefix. It must be # followed by up to 32 characters from A-Z a-z 0-9 . _ ~ = -, so it needs no escaping in a URL. Payloads with another prefix get 400 bad_prefix. Changing it does not rewrite stored links.
Written from the code and tests of each repository. If a page and the code disagree, the code is right and the page has a bug. Open an issue.
© 2026 Jean Carlo Benites Porras. Code samples are MIT, like the projects they come from.