yaks.app

Files and pictures

← Documentation

Files are the one thing an app's store cannot hold directly: you cannot write raw bytes into a component the way you write a title or a number. This page covers files: how you write the app's own files with app_files, then what upload accepts and returns for the files its visitors send, where those are served from, the two rows one upload writes, who may send and who may look, how to delete one, how the app says which files it takes and the downscale that makes a phone photo quick to send — and a gallery that does not show the same picture twice.

app_files, and what a write returns

The app's own files — index.html, the css and js beside it, vocab.json — are written with app_files. Writing a file needs no op: give it a path and a content, or a list of them as files: [{path, content}, …]. The files stay in a private draft until app_deploy releases them. A refused deploy leaves the previous files, vocabulary and worker serving together.

app_files(app, op: 'list') lists their paths, each with the SHA-256 of its bytes, then the live version and whether the draft differs from it: the number app_deploy takes as base.

Coming back to an app, read it in one call: app_files(app, op: 'read') answers every text file in it, each under its path and SHA-256, up to 64 KB in all, and names the live version app_deploy takes as base. paths: [...] reads just those files. The answer names each file it left out and why: not text, no such file, or past the 64 KB. paths reads those in another call, and path reads one whole, as its text alone.

Every write reports what was stored, so a file transcribed by hand is checked in the call that made it before the app serves it:

wrote index.html → https://yourname.yaks.app/recipes/index.html
  — 4213 bytes, sha256 9f2a…

A .json file is parsed as well, and the reply reports parsed — or reports that it is not valid JSON, and where: NOT valid JSON — Expected ',' or '}' after property value in JSON at position 45971 names the bracket that is wrong. That is a note in the reply, not a refusal: the file is still stored.

op: patch edits one file in place — path, find, replace. find is exact text, not a pattern, and must match exactly once; anything else refuses saying how many matches there were, so lengthen find until it names one place. An empty replace removes the text. It is the cheap fix for the one line that came out wrong, where the alternative is re-sending the whole file.

op: fetch writes a URL's body to a path — path and url, https only. It is how a third-party library is vendored into the app rather than transcribed:

app_files(app, op: 'fetch', path: 'chess.js',
          url: 'https://cdnjs.cloudflare.com/…/chess.min.js')
→ fetched … → https://yourname.yaks.app/chess/chess.js — 15234 bytes,
  sha256 3c1f…, text/javascript, integrity sha256-PB8…=

The integrity is the same digest written the way a <script integrity> attribute expects, so a page that goes on loading the file from a CDN can be pinned to the bytes this fetch got. The content type the app serves it as comes from the path's extension, not from the response — name it .js and it is javascript.

After app_deploy, local scripts, stylesheets, images, audio and video named by the page use a URL under ./api/assets/<release>/. Local import-map targets and scopes, and relative URLs inside stylesheets and module scripts, stay in that release too, except one that names ./api/: a module's import … from './api/client.js' still reaches the app's API. Those URLs keep the same bytes even after another deploy or a rollback, so a browser may keep them for a year. The page itself, app data, and the ordinary file addresses still revalidate; they show the current release. A page with its own <base> keeps its chosen URL behavior. App access is checked whenever a request reaches the server, including a request for an older release.

Pages and their addresses

Every HTML file is a page, at the address a static host gives it:

index.html          /<app>/
about.html          /<app>/about, and /<app>/about.html
docs/index.html     /<app>/docs/ (and /<app>/docs is sent there)
404.html            any page that is not there, answered 404

A path with no file behind it and no extension, /<app>/recipes/42, is a route: without a 404.html the app's index.html answers it, so a page that draws itself at a place opens there. A missing stylesheet, script or picture is a 404, never a page.

Choosing what a missing page answers. The same wrangler.jsonc can say it, in Cloudflare's own key, assets.not_found_handling:

{ "assets": { "not_found_handling": "single-page-application" } }
  • "single-page-application": a route answers index.html with 200, even when a 404.html exists.
  • "404-page": the nearest 404.html answers 404, looking in the missing page's folder and then each folder above it; with none, a plain 404.
  • "none": a plain 404.

Unset, the rule above holds: the root's 404.html, else index.html for a route. A missing file with an extension is a 404 in every mode.

Each page resolves its relative URLs from its own folder, as anywhere on the web: docs/index.html names ../style.css, and reaches the client at ../api/client.js.

Serving a folder. A wrangler.jsonc beside index.html can name a folder of the app for its addresses to serve, in Cloudflare's own words:

{ "assets": { "directory": "dist" } }

Then dist/index.html is /<app>/, and nothing outside dist/ is on the web. That is how a build sits beside the source that made it, and the code page shows one from the sandbox to the address: Code of your own. app_deploy refuses a folder with no files in it.

Releases and open pages

app_deploy makes the draft a new release. An already-open page keeps its release's file URLs, but its data is live: a newer release may change the contract that page uses. The platform checks on a release push through the app's own socket, when that socket opens or reconnects, when the page becomes visible again, and on a persisted pageshow (returning from the browser's back-forward cache). The return checks run at most once a minute. A page with no socket that never hides does not hear a release until one of those moments.

The level is derived from the page's version to the newest version, not just from the last deploy:

  • Identical file manifests mean no notice, even after a rollback or an explicit required mark.
  • Dropping or retyping a declared component, tool or property, or crossing any release marked reload: 'required', means required.
  • Other file changes, including additive vocabulary changes, mean optional.

A deployer can raise the level for a contract the vocabulary does not describe:

app_deploy(app: 'recipes', reload: 'required')

Use this when, for example, a worker route or the meaning of a value sent to a peer changes incompatibly. The only explicit value is 'required'. Leaving reload out means derive from the releases, not "force optional"; a deployer cannot lower a required change. app_rollback compares the page with the kept version now live, including backward moves; dropping words or crossing a required mark requires reload. app_update carries required marks from the source releases it crosses. The identical-files rule still takes precedence. Neither operation rolls back saved data; plan any store migration with the release.

The default notice

The platform emits a cancelable yak-release event on window, once for each newly noticed version. Its detail includes {version, reload}: version is the latest release number and reload is 'optional' or 'required'. More fields may be added; read the ones you use rather than assuming that is the whole object.

Without a takeover, an optional notice says "A new version of this app is out", with Reload and Dismiss. Dismiss holds for that version in that tab. A required notice says "This app has changed. Reload to keep using it", with Reload only, and stays until the person reloads. Neither notice covers the page's input. There is no automatic reload, even for required changes: let the person save or copy unfinished work before choosing Reload.

Drawing your own notice

Call event.preventDefault() synchronously in the listener to suppress the platform notice, then draw your own. Canceling is a takeover, not an acknowledgment: your app now owes the person a notice and a Reload action. Keep a required notice visible, and let only an optional notice be dismissed. Do not clear or replace the form while drawing it.

For example, put this notice in normal page flow, beside (not over) your form:

<aside id="release-notice" hidden aria-live="polite">
  <p id="release-message"></p>
  <button id="release-reload" type="button">Reload</button>
  <button id="release-dismiss" type="button">Dismiss</button>
</aside>

Run this script after those elements exist. The form and its unsaved values are untouched; reload happens only on the button click. For a required change, help the person copy unfinished work before clicking it.

let notice = document.querySelector('#release-notice')
let message = document.querySelector('#release-message')
let reload = document.querySelector('#release-reload')
let dismiss = document.querySelector('#release-dismiss')

window.addEventListener('yak-release', (event) => {
  event.preventDefault()
  let { reload: level } = event.detail
  message.textContent = level == 'required'
    ? 'This app has changed. Copy unfinished work, then reload to keep using it'
    : 'A new version of this app is out'
  dismiss.hidden = level == 'required'
  dismiss.onclick = () => {
    notice.hidden = true
  }
  reload.onclick = () => {
    window.location.reload()
  }
  notice.hidden = false
})

This event is for the app's own releases, not for platform releases or a borrowed app's releases. The script is supplied by the platform; there is no client library upgrade or app rebuild needed to receive it.

Every write keeps what it replaced

No write and no delete throws bytes away. Before anything lands at a path, whatever was there is kept — addressed by its own sha256, beside the app — and noted in that path's history. So the file you just overwrote is one call away, and you never have to remember what it used to contain.

app_files(app, op: 'history', path: 'index.html')
→ index.html in yourname/recipes:
  now — 4213 bytes, sha256 9f2a…
  - until 2026-09-06T14:20:11Z — 3980 bytes, sha256 c41d…, by the owner
  - until 2026-09-04T09:02:47Z — 1204 bytes, sha256 7b19…, by the owner

An entry reads "the file was these bytes until then" — the time and the name are the write that took them away. So the top entry is one step back from now, and now is what the app is serving this second.

op: restore puts one back. With just a path it is the newest entry — undo the last write:

app_files(app, op: 'restore', path: 'index.html')

sha names one exactly, off the history. at asks for a moment — at: '2026-09-05T00:00:00Z' puts the file back to what it was serving then, which is the useful form when what you know is when it was still right and not what it contained.

A restore is itself a write, so the bytes it replaces are kept in turn and a restore can be undone by another. Nothing rewrites the history.

The bytes are kept for 30 days, and always at least the last 20 versions of a file, whichever reaches further back. After that a version is let go — and only ever a version nothing else needs: bytes a kept deploy names are never removed, so app_rollback keeps working for every version app_versions still lists.

This is the file-sized way back. Beside it: app_rollback puts back every file of a whole deploy at once, store_restore puts back everything the app has saved, and app_restore brings back an app that was deleted.

An icon for the home screen

Write an icon.svg beside index.html and the app has an icon everywhere. It is text, so it goes in as content like a page does:

app_files(app, files: [{path: 'icon.svg', content:
  '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">' +
  '<rect width="24" height="24" fill="#4c773e"/>' +
  '<circle cx="12" cy="12" r="6" fill="#fdf7ee"/></svg>'}])

The platform draws it as icon.png, 512×512, which is the address a home screen reads. The SVG is drawn when it is written, and refused with the reason if it cannot be an icon:

  • its <svg> has xmlns="http://www.w3.org/2000/svg", as any SVG file needs;
  • its viewBox is square;
  • it has no <text>: there is no font to draw letters with, so a letter is a <path>;
  • it parses.

Fill the square with a background of your own, a <rect> the size of the viewBox. Where the picture leaves the square bare it is drawn on white, because iOS fills a transparent icon with solid black.

The page is served with the two links an installed app needs already in its head, at the app's own root:

<link rel="apple-touch-icon" href="/recipes/icon.png">
<link rel="manifest" href="/recipes/manifest.webmanifest">

iOS reads the first and nothing else — an icon named only in a manifest is not the one it uses — while everything else reads the second, which is generated from the app: its title as the name, its own address as start_url and scope, display: standalone, the icon at 512 and 192, and the colour the page sets in <meta name="theme-color"> where it sets one.

What the app writes is kept. Each link is added only where the page has none, so an app that writes its own apple-touch-icon keeps it and still gets the manifest link, and an app that ships its own manifest.webmanifest is served that file rather than the generated one. An icon.png of the app's own is served in place of the drawing: a picture fetched with op: fetch, or one sandbox_ship copies in. A picture is read the way a browser would read it when it is written, and one that does not decode is refused: a PNG's bytes cannot be written out by hand, so an icon an assistant makes is an SVG.

Until an app writes an icon, icon.png serves the platform's own default tile, so an app somebody keeps on their phone is never a blank square.

upload, and what it returns

upload comes from the client library every app is served:

import { upload } from './api/client.js'
let file = await upload(input.files[0])

It accepts a File from an <input type=file>, or any Blob — one a canvas made, one a fetch returned. A second argument names it: upload(blob, { name: 'cake.jpg' }). A File carries its own name; a bare Blob has none unless you give it one.

It returns one object:

{ eid:   '9f2a…',                      // the bytes' own address
  url:   '/photos/api/blob/9f2a…',     // where this app serves them
  mime:  'image/jpeg',                 // the type the browser reported
  bytes: 51234,                        // how many there are
  w: 1600, h: 1200 }                   // pictures only

mime is the blob's own type — the browser fills it in for a file off an input. When the type is empty or application/octet-stream, a recognized filename supplies it instead (video/mp4 for .mp4, audio/mpeg for .mp3). An unknown name gets application/octet-stream. w and h are there only when the bytes are a picture that declares its size; otherwise both keys are simply absent.

Content addressing, and what follows from it

eid is the SHA-256 of the bytes, in hex. Nothing else decides it: not the name, not the mime, not who sent it. Three things follow.

The same file twice is one upload. Send the same photo from two pages, two devices, or twice from one form, and the second send lands on the same eid, the same object, and the same row — so a page need not remember what it has sent; it may send again and read what comes back.

The bytes at an address can never change. The mime and filename can change if the file is uploaded again, so GET ./api/blob/<eid> redirects to a URL that also names that representation. The alias revalidates, and the versioned URL keeps its MIME and filename. An app checks access before serving either URL; its browser responses are private to that browser's cache.

The address is not a secret. Anyone who may read the app may read any blob in it, and someone holding the identical file can compute its eid without asking. So never treat a blob address as a permission to hold something private. What content addressing does not save is the sending: the upload endpoint reads every byte to hash them, so re-uploading a 4 MB photo costs 4 MB every time. A file big enough to go up in parts is the exception (below): the page names it first, and bytes the app already holds are not sent again.

The two rows one upload writes

The upload endpoint writes two entities through the app's own /apply endpoint, recorded as written by the person who uploaded — so .created names them the way it names any other row. The content row sits at the bytes' own eid and records what is true of the bytes; the use row records the name this app gives them:

{ entity: { eid: '9f2a…' },
  blob:  { bytes: 51234 },
  image: { w: 1600, h: 1200 } }        // pictures only

{ entity: { eid: '…derived…' },
  attachment: { blob: '9f2a…', mime: 'image/jpeg', name: 'cake.jpg' } }

The use row's eid is derived from the content's, as sha256("attachment:" + sha), so it is stable without being the same eid. That is what makes a second upload of the same bytes rename the row that already exists rather than write a second one holding the same facts. A component may not point at its own entity, which is the other reason the two are apart.

The rename is a patch: an upload that names nothing keeps the name it had. Send cake.png, then the same bytes as the cake.png, then a third time off a canvas with no name at all, and the row's name is the cake.png.

List the use, not the content. query('.attachment') is every file the app holds:

for (let f of await query('.attachment')) {
  draw(`./api/blob/${f.attachment.blob}`, f.attachment.name)
}

.blob is not that list — a long doc.body is a blob row too.

A picture's width and height

image sits on the content row — at the very eid your own row points at — because a size is a fact about the bytes, however many attachments name them. The platform reads it out of the file's own header and never by decoding the image: png, jpeg, gif and webp each declare their size in the first few dozen bytes. Anything else gets no image at all — a guess would be worse than nothing, since a page can measure the bitmap itself but cannot tell that a stored number is wrong. A header declaring a zero is a broken file, and gets none either.

So a wall holds each photo's space open before a single byte of it arrives:

let size = new Map((await query('.image'))
  .map((i) => [i.entity.eid, i.image]))
let box = size.get(p.photo.blob)              // the eid the row holds
if (box) { img.width = box.w; img.height = box.h }

Two attributes and the browser reserves the right rectangle, so the page does not jump as the pictures land; aspect-ratio: ${box.w} / ${box.h} does the same for a fluid layout. The header is all the platform reads — no orientation, no capture date, no camera.

Drawing a picture from a row

Point your own row at the bytes by their eid:

await apply({ photo: { caption: 'the cake', blob: file.eid } })

photo is the app's own component, declared in its vocab.json:

{ "$defs": {
    "photo": { "properties": {
      "caption": { "type": "string" },
      "blob":    { "type": "string" } } } } }

An app's own properties are text, number, bool, time or url, so a blob's address is held as text. Then build the address from the eid, relative: img.src = './api/blob/' + p.photo.blob.

file.url is fine to drop straight into an <img src> the moment the upload returns — it is the picture the person just picked, on the page they picked it on. It is the wrong thing to save in a row: it is path-absolute and carries this app's slug (/photos/api/blob/9f2a…), so a row holding it breaks the day someone installs a copy of the app at another address.

The HTTP endpoints underneath

upload is one POST, and the bytes come back from one GET — ordinary same-origin HTTP, which curl or a worker can call too.

POST ./api/blob
content-type: image/jpeg     ← the blob's own type, minus parameters
x-yak-name: cake.jpg         ← optional, percent-encoded, then the bytes
→ {"eid": "9f2a…", "url": "/photos/api/blob/9f2a…",
   "mime": "image/jpeg", "bytes": 51234, "w": 1600, "h": 1200}

GET ./api/blob/9f2a…    → the bytes
Range: bytes=100-199    → 206, those 100 bytes

A header is ASCII and a file's name is not, so the name is sent percent-encoded and the endpoint decodes it; the mime is kept to 120 characters and the name to 200. GET and HEAD return the mime and name from the attachment row. A single Range: bytes=… request gets 206 and Content-Range; a range beyond the end gets 416. Responses include a revalidating cache header, content-security-policy: sandbox; script-src 'none' and x-content-type-options: nosniff — an uploaded HTML page or SVG opened in a tab cannot run anything. A path that is not 64 hex characters, or bytes this app never received, is 404 no_such_file.

Big files go up in parts, and resume

upload sends a file of 32 MiB or more in parts, four at a time, and an upload that is cut short carries on instead of starting over. A part that fails because the connection dropped or the phone went to sleep is sent again once the browser is back online. If the tab closed, uploading the same file again, from any tab or device, sends only the parts that had not arrived. The page changes nothing: the same upload(file), the same answer.

An upload in parts is named by the file's SHA-256 before it begins, so the page reads the file once to compute it first. digest(file) from the client library is that hash, the eid the upload will answer.

An upload nobody finishes is forgotten after seven days, and its parts never count toward the space's photos and files.

The endpoints, for a client of your own:

POST ./api/blob/9f2a…          ← the file's SHA-256, and what it is
{"bytes": 41943040, "name": "talk.mp4", "mime": "video/mp4"}
→ {"part": 8388608, "need": [1, 2, 3, 4, 5]}

PUT ./api/blob/9f2a…/3         ← part 3: from byte 2 × part, part bytes
x-yak-state: 1f83d9ab…         ← the hash state after bytes 0 to 2 × part
→ {"n": 3}

POST ./api/blob/9f2a…          ← again, once every part is in
→ {"eid": "9f2a…", "url": "/photos/api/blob/9f2a…/…",
   "mime": "video/mp4", "bytes": 41943040}

need lists the parts not received yet. Every part but the last is part bytes, a whole number of MiB. Every call may be sent again. A POST when nothing is missing assembles the parts and answers what POST ./api/blob answers.

Each part is hashed as it arrives, on from the SHA-256 state its x-yak-state names: the eight 32-bit words of the hash after every byte before the part, as 64 hex characters, which a SHA-256 written to be fed in pieces holds between them. The first part starts from SHA-256's own initial value (6a09e667bb67ae85…5be0cd19). The parts are assembled only when each one's state follows on from the one before it and the last ends at the address, so the bytes kept are the bytes the address names; parts that do not are dropped with a 400, as is a part of the wrong length. 409 upload_gone says the upload is no longer under way (seven days passed): POST again to begin it anew. The space's room and the most bytes the app takes are checked against bytes when it begins, with the same space_full and too_large, and the type the app takes once it is whole, with the same 415.

Deployed audio and video files use media types from their extensions: .mp4, .m4v, .mov, .webm, .mp3, .m4a, .aac, .wav, .ogg, .flac, and HLS .m3u8 and .ts are recognized. File responses also support HEAD and byte ranges, so a browser can start playback and seek without downloading the whole file again.

Who may upload, who may read

Uploading is a write and reading the bytes is a read, so the app's access decides both, exactly as it decides apply and query: on public anyone with the link reads and an owner or editor uploads; on open anyone with the link does both, so a guest at the party adds their own photo without signing in; on private members read and an owner or editor uploads.

Ask before they pick, not after they wait:

let who = await me()
if (!who.writes) {
  show(who.signIn ? `<a href="${who.signIn}">Sign in to add one</a>`
                  : 'Only members of this app can add one.')
}

A refused upload throws an error carrying the platform's own message: sign in to change this app to a stranger, with signIn on it, and, to a member who is only a viewer, you can read this app but not change it — its owner can make you an editor:

try { await upload(file) }
catch (e) { e.signIn ? location = e.signIn : show(e.message) }

On an open app a signed-out guest's upload has no created.by at all. If the wall wants a byline, ask them for a name and save it in your own row.

Deleting an upload

Delete the content row, at the upload's eid, and the upload is gone:

await apply({ entity: { eid: file.eid }, $delete: true })

Its attachment row goes with it, and the platform deletes the bytes right after, so ./api/blob/<eid> and every file.url it handed out answer 404 no_such_file. There is no undo. A page listing .attachment deletes f.attachment.blob, not the attachment row's own eid: deleting the attachment row alone removes the name and keeps the bytes.

Delete your own row that points at the upload in the same batch, so the page never draws a picture that is gone:

await apply([
  { entity: { eid: p.entity.eid }, $delete: true },   // your photo row
  { entity: { eid: p.photo.blob }, $delete: true },   // the upload
])

The bytes stay while another attachment still points at them, by blob or by artifact (a picture a model made is one). Nothing else keeps them: your own properties holding the eid are text, and the platform does not read your files for ./api/blob/ addresses, so delete or change what still uses an upload before you delete it.

Deleted bytes stop counting toward the space's photos-and-files ceiling. An app that replaces its uploads (a video rendered again, a photo cropped) deletes the ones it no longer uses the same way: list .blob, keep the eids its files and rows still use, and delete the rest, from a page with apply or from the assistant with graph_apply.

Who may delete an upload is who may delete its row: an owner or editor any upload, and on an open app, anyone else only what they uploaded. A refused delete throws like any refused apply. The same bytes sent twice are one upload, so deleting it deletes it for everyone who sent it.

Deleting the app (app_delete) puts it in the trash and keeps every byte of it for 30 days, so app_restore brings the uploads back with everything else. After that — or straight away, with forever: true — the bytes go: every object under the app's own prefix, uploads included, and there is no undo.

Which files the app takes, and the downscale

yaks.app puts no size or type of its own on an upload; the one bound is the space's room for photos and files. Which files an app takes is for its author to decide, in its vocab.json, in JSON Schema's own words for a file's bytes:

{
  "blob": { "maxLength": 10485760, "contentMediaType": "image/*" },
  "$defs": { … }
}
  • maxLength is the most bytes one file may hold.
  • contentMediaType is what it may be: one media type, or a list of them (["image/*", "application/pdf"]), where image/* takes every kind of picture, the way an <input accept> does.

Leave either out and anything is taken. The type is read off the bytes, never off the name or what the upload says it is, so a web page called cake.png is not a picture. A file too big is refused 413, code too_large, before any of it is read; one of another type 415, code unsupported_media_type. Each carries a message a guest can act on: "this app takes image/*, and this file is application/pdf". In parts, the size is checked when it begins and the type once it is whole. A letter's attachments are held to the same words, and one the app does not take is left off the letter. An empty body is a 400, code no_bytes.

A phone photo is several megabytes, so a page that shows pictures downscales them before it sends them, to be quick to send and to show:

// A picture, at most 1600px on its long side, as a jpeg.
let smaller = async (file, max = 1600) => {
  if (!file.type.startsWith('image/')) return file
  let bmp = await createImageBitmap(file)
  let scale = Math.min(1, max / Math.max(bmp.width, bmp.height))
  let cv = new OffscreenCanvas(
    Math.round(bmp.width * scale),
    Math.round(bmp.height * scale),
  )
  cv.getContext('2d').drawImage(bmp, 0, 0, cv.width, cv.height)
  bmp.close()
  return await cv.convertToBlob({ type: 'image/jpeg', quality: 0.85 })
}

await upload(await smaller(file), { name: file.name })

Three things worth keeping in that shape. Math.min(1, …) never blows a small picture up. A Blob off a canvas has no name, so pass the original's, or the attachment row will have none. And resized bytes are new bytes: a downscale that picks a different max or quality next time hashes differently and uploads again, so pick your numbers once and leave them alone.

A 413 with code space_full says the space is out of room, with a message naming the space; nothing about the file will fix that one. There is no server-side resizing, so the page's downscale is the whole of it.

Dropping files, and more than one

A file input is one way in; a drag-and-drop is the other. Both hand you a FileList:

let took = async (files) => {                 // one at a time
  for (let file of files) await add(file)
}
drop.addEventListener('dragover', (e) => e.preventDefault())
drop.addEventListener('drop', (e) => {
  e.preventDefault()
  took(e.dataTransfer.files)
})

dragover must call preventDefault or the browser opens the file instead of giving it to you, and uploading one at a time keeps a dropped folder of twenty photos from making twenty requests at once.

Pick, downscale, upload, look before writing, then draw the wall with each picture's space held open — the entire page.

<!doctype html>
<meta charset="utf-8" />
<title>The wall</title>
<input type="file" id="pick" accept="image/*" multiple />
<p id="say"></p>
<div id="wall"></div>
<script type="module">
  import { apply, me, query, subscribe, upload }
    from './api/client.js'

  let pick = document.getElementById('pick')
  let wall = document.getElementById('wall')
  let say = (words) => document.getElementById('say').textContent = words
  // smaller() is the downscale from the section above.

  // Only offer the input to someone this app accepts a write from.
  let who = await me()
  if (!who.writes) {
    pick.remove()
    say(who.signIn ? 'Sign in to add a photo.' : 'Look only.')
  }

  let add = async (file) => {
    try {
      say(`sending ${file.name}…`)
      let sent = await upload(await smaller(file), { name: file.name })
      // The bytes dedupe themselves; a row of ours does not. Look
      // before writing one, or the photo hangs on the wall twice.
      let [seen] = await query(`.photo.blob=${sent.eid}`)
      if (!seen) {
        await apply({ photo: { caption: file.name, blob: sent.eid } })
      }
      say('')
    } catch (e) {
      e.signIn ? location = e.signIn : say(e.message)
    }
  }

  pick.addEventListener('change', async () => {
    for (let file of pick.files) await add(file)
    pick.value = ''
  })

  // Each picture's size, by the eid the rows already hold.
  let size = new Map((await query('.image'))
    .map((i) => [i.entity.eid, i.image]))

  // And the wall, redrawn on every change — including one made on
  // their phone while this page is open.
  subscribe('.photo', (photos) => {
    wall.replaceChildren(...photos.map((p) => {
      let img = document.createElement('img')
      img.src = `./api/blob/${p.photo.blob}`
      img.alt = p.photo.caption || ''
      let box = size.get(p.photo.blob)
      if (box) { img.width = box.w; img.height = box.h }
      return img
    }))
  })
</script>

The line worth keeping is the check before the write: two visitors sending the identical picture are one blob and one attachment row for free, but a photo row of your own is a separate row, and two of them are two pictures on the wall. On a wall left open for hours, read .image inside the subscription, so a photo added later gets its box too.

What files do not do

  • No server-side resizing and no thumbnails. What you upload is what is served, at one size. The page downscales.
  • No listing of the bucket and no deleting bytes short of deleting the app. .attachment is the listing; bytes nobody wrote a row about are reachable only by their address.
  • No image for anything but png, jpeg, gif and webp — a pdf, an svg or a heic uploads fine and simply carries no size.
  • No progress events. upload returns when it is done, so a page shows progress per file, not within one.

The whole guide, everything else an app can do, is at The guide.