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

TypeScript
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.

TypeScript
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.

TypeScript
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')
Note

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.

Note

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": … } }).

Not implemented

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.

TypeScript
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 DataView

There 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.

Note

await res.json() runs (it awaits a plain value) but reads as if the body were still on its way — write res.json().

Note

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.

TypeScript
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).
Note

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).

TypeScript
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 name
TypeScript
const 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.

TypeScript
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.

TypeScript
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 removeEventListener
TypeScript
let 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.

Note

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.

TypeScript
fetchLocal(name: string): FetchResponse    // synchronous, status always 200

The 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.

TypeScript
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()
Note

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.

Note

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

TypeScript
// ✗ 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 File handles come from, the files store
  • Stored files — uploading a File to the project's own backend
  • Storage — persist small state locally instead of a server
  • Server functions — call your own *.server.ts code instead of hand-written endpoints
  • Multiplayer — real-time game networking (Net), not HTTP
  • Conventions — events, asset() macro