Networking
fetch sends HTTP requests to any server and WebSocket keeps a live text connection open;
fetchLocal reads a file the host has by name. Calls to the project's own backend are
server functions, and multiplayer game networking is Net.
They read like the web APIs, but res.json() / res.text() are synchronous — no second await.
- call a REST API and read the JSON it returns
- send a POST request with a JSON body and an auth header
- upload a photo or a picked file to a server (
FormData) - show the progress of a large download
- keep a live connection to a server and receive its messages (
WebSocket) - read a file next to the app by name, synchronously (
fetchLocal)
Platforms: iOS, Android, desktop, the web and the web player; on the web and in the web player
the browser's CORS rules apply and no cookies are sent. Desktop reports no download progress and
opens only ws:// sockets; fetchLocal finds nothing on the web.
At a glance
const load = async () => {
const res = await fetch('https://api.example.com/items')
if (res.status === 200) {
const items = res.json<{ id: number, name: string }[]>() // synchronous — no await
console.log(items.length)
}
}
load()Send an HTTP request: fetch
fetch calls an HTTP API — any method, with headers and a body — and resolves once the host has
the whole response: the status and the body.
fetch(url: string, options?: {
method?: string // 'GET' (default), 'POST', 'PUT', 'DELETE', …
headers?: Record<string, string>
body?: string | FormData
onProgress?: (p: { loaded: number, total?: number }) => void // download progress, bytes (below)
}): Promise<FetchResponse>An HTTP error status (404, 500) still resolves — check res.status. The promise rejects only
when the request itself fails: no connection, a bad URL, a server the browser's CORS rules refuse.
body is a string or a FormData: send JSON as
JSON.stringify(data) with its Content-Type. An object passed as it is does not become JSON —
iOS, Android, desktop and the web player send the text [object Object], the web rejects.
const addItem = async (name: string) => {
try {
const res = await fetch('https://api.example.com/items', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer <token>' },
body: JSON.stringify({ name }),
})
if (res.status < 200 || res.status >= 300) console.log('HTTP error', res.status, res.text())
else console.log('created', res.json<{ id: number }>().id)
} catch (e) {
console.log('request failed', e)
}
}
addItem('Milk')use https:// — a built iOS or Android app refuses plain http:// (App Transport
Security, Android's cleartext policy). Send credentials in a header: the web sends no cookies.
in headless runs (lecodes test / lecodes render with no host flag such as --sim
or --lite) a request reaches the network only with --wait-network or when it goes to the
project's own backend; any other resolves at once with status 0 and an empty body. Give a flow
canned answers with its fixtures ({ "<url pattern>": { "status": 200, "json": … } }).
useOnce: true (in the options type, passed by the SDK's own loaders) is
read by no host — it changes nothing.
Read the response: FetchResponse
A response holds the HTTP status and the body, and the body is read synchronously — as JSON or as text.
res.status // HTTP status code (read-only)
res.json<T>(): T // the body parsed as JSON — SYNCHRONOUS (a syntax error throws)
res.text(): string // the body as UTF-8 text — SYNCHRONOUS (stops at a zero byte)
res.bytes(): Uint8Array // the body's bytes, a copy — a binary format of your own, read with a DataViewThere is no ok, no headers and no blob(): test status yourself, and hand a binary body on
as the response itself where a file is wanted. A FetchResponse goes where a File does — an
image source (UIImage(res)), a FormData part, an AudioPlayer source, a
files.write payload, a
share() item, but not a server
function argument — and its bytes never pass through JS.
await res.json() runs (it awaits a plain value) but reads as if the body were still
on its way — write res.json().
ownership — the body lives as long as you hold the response. On iOS, Android and the
web, dropping it frees the host's copy; the desktop host keeps every body until the app exits.
There is nothing to free by hand: res.dispose() is a deprecated no-op — remove the call.
Download progress and large files
onProgress follows a download as it arrives: the bytes received so far, and the total when the
server sends a Content-Length.
const progress = signal(0) // 0..1
const download = async () => {
const res = await fetch('https://cdn.example.com/maps/city.bin', {
onProgress: ({ loaded, total }) => { if (total) progress.value = loaded / total },
})
progress.value = 1
console.log('done', res.status)
}
UIScreen(
UIText(() => `${Math.round(progress.value * 100)}%`).style({ color: 'white' }),
UIBox().style({ height: 6, bgColor: '#22c55e', width: () => `${progress.value * 100}%` }),
UIButton(UIText('Download').style({ color: 'white' })).style({ bgColor: '#2563eb', p: 12 }).onClick(download),
).style({ bgColor: 'black', p: 20, gap: 12 }).open()- Memory. The whole body lands in memory, in one host buffer, and stays there while you hold
the response. Android rejects a body over 50 MB (
Response too large). - Foreground only. There is no background download: on iOS and Android the app's code pauses when the app leaves the foreground (app.md), and the progress and the result wait until it is back.
- To keep the file, write the response with
files.write(desktop and iOS).
onProgress is called on iOS (at most about 20 times a second, then once at the end),
Android, the web and the web player. On desktop and in headless runs it is never called — the
request still completes.
Upload a photo or a file: FormData
To upload a photo or a picked file to an HTTP API, put it in a FormData and pass that as
fetch's body — a multipart/form-data request, like an HTML form's. To keep files on the
project's own backend, pass the File to a server function instead (Stored files).
const form = new FormData()
form.append(name: string, value: string | number | boolean | File | FetchResponse,
filename?: string) // filename defaults to a File's own name
form.delete(name: string) // removes the first field with that nameconst upload = async () => {
const photo = await openFilePicker({ accept: 'image/*' })
if (!photo) return // the picker was cancelled
const form = new FormData()
form.append('avatar', photo) // a file part, named photo.name
form.append('userId', 123) // a text field
const res = await fetch('https://api.example.com/upload', { method: 'POST', body: form })
console.log('upload', res.status)
}
UIScreen(
UIButton(UIText('Upload a photo').style({ color: 'white' })).style({ bgColor: '#2563eb', p: 12 }).onClick(upload),
).style({ bgColor: 'black', p: 20 }).open()The host sets the request's Content-Type and its boundary — don't set one yourself. A file part
carries the file's name and is typed application/octet-stream on every host. FormData is a
minimal subset: no get, has, set or iteration.
File
To save a file on the device, write it with files.write;
this section is the File value itself.
file.name // file name (read-only)
file.size // bytes (read-only)A File is a handle to bytes the host holds; you never construct one. It comes from
openFilePicker, a camera's
takePhoto(), an AudioRecorder
recording, Canvas.toFile() and Scene.capture(). Its bytes never pass through JS: hand the
File itself to what takes one — a FormData part, an
image source (UIImage(file)), an AudioPlayer source, a
server function argument, a share() item. A
FetchResponse goes in all of them but a server function argument, which uploads only a File.
Connect to a server, send and receive messages: WebSocket
A WebSocket connects to a server and keeps the connection open: the server's messages arrive the
moment it sends them, and send writes back — a chat, live prices, a feed. For the project's own
backend use its channels; for a multiplayer game, Net.
const ws = new WebSocket(url: string) // connects at once
ws.send(text: string) // one text frame — after 'open'
ws.close()
ws.addEventListener('open' | 'message' | 'close' | 'error', handler) // and removeEventListenerlet ws: WebSocket | null = null
const messages = signal<string[]>([])
UIScreen(
UIText(() => messages.value.join('\n')).style({ color: 'white' }),
).style({ bgColor: 'black', p: 16 })
.onOpen(() => {
ws = new WebSocket('wss://chat.example.com/room/1')
ws.addEventListener('open', () => ws?.send('hello'))
ws.addEventListener('message', (text) => { messages.value = [...messages.value, text] })
ws.addEventListener('close', (code) => console.log('closed', code))
ws.addEventListener('error', () => console.log('socket error'))
})
.onClose(() => { ws?.close(); ws = null })
.open()Listeners follow the shared emitter pattern. message carries the
frame's text: there is no binary send, and a binary frame arrives as UTF-8 text (as the browser's
Blob on the web). Send only after open — the web drops what is sent before it.
A connection that fails never throws, and each host reports it its own way — listen to both
error and close: iOS and Android send close (iOS with its error code, Android with -1),
desktop error, the web error then close. close carries the close code (the desktop host
passes ""). After close(), send() is a silent no-op. Close the socket in the screen's
onClose, or it keeps running behind other screens — only a restart of the app closes it for you.
host-gated — the desktop host speaks plain ws:// only (a wss:// URL fires error),
while a built Android app refuses plain ws://. In headless runs a socket opens only to the
project's own backend; any other never opens.
Read a file the host has by name: fetchLocal {#fetchlocal}
fetchLocal reads a file the host has next to the app, by name — synchronously, with no network
and no promise.
fetchLocal(name: string): FetchResponse // synchronous, status always 200The host searches its own places: on desktop the path as given,
the app's assets/ folder, the folder of the launched bundle and the host's runtime folder; on
iOS the app, the SDK's resources and the folder files
writes to; on Android the APK's assets/. On desktop and iOS it is the read half of files: a
relative path files.write saved is what fetchLocal reads back.
const main = async () => {
await files.write('saves/slot1.json', JSON.stringify({ level: 3 }))
console.log(fetchLocal('saves/slot1.json').json<{ level: number }>().level) // 3
}
if (files.supported) main()a name the host doesn't have still reads status 200 — with an empty body (text() is
"", json() throws). Status is no existence check. The web and the web player have no local
files, so every name reads empty there; in the le.codes viewer app only the viewer's own files
(and, on iOS, what files.write saved) are found.
for a data file you ship with the project, use asset() instead: a .json file
is the parsed data itself (const config = asset('./config.json')), and any other resource is
read with await fetch(asset('./levels.bin')) — see Host globals.
Pitfalls
// ✗ web-fetch habits — body readers are not async here
const data = await (await fetch(url)).json()
// ✓ await the fetch once, then read synchronously
const res = await fetch(url); const data = res.json()
// ✗ treating a 404 as a rejection
try { await fetch(url) } catch { /* expecting 404 here — it won't arrive */ }
// ✓ HTTP statuses resolve; check status yourself
const res = await fetch(url); if (res.status !== 200) console.log('HTTP', res.status)
// ✗ an object body — it is not serialized for you
fetch(url, { method: 'POST', body: { name: 'x' } })
// ✓ a string (or a FormData)
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'x' }) })See also
- Files & share — where
Filehandles come from, thefilesstore - Stored files — uploading a
Fileto the project's own backend - Storage — persist small state locally instead of a server
- Server functions — call your own
*.server.tscode instead of hand-written endpoints - Multiplayer — real-time game networking (
Net), not HTTP - Conventions — events,
asset()macro