Build apps
Data and the network
Fetch data from the hosts you declare, and check what comes back.
Your app fetches data with the global fetch, from the hosts it names in ledable.json. Check
everything that comes back before you use it.

import * as z from "zod/mini";
import {
cacheTag,
defineApp,
location,
select,
type LocationValue,
} from "@ledable/sdk/app";
import {
Frame,
Text,
measureText,
renderFrame,
type BitmapFont,
} from "@ledable/sdk/graphics";
import { PIXOLLETTA, TINY, loadFonts } from "@ledable/sdk/fonts";
const FONT_REFS = { place: TINY, figure: PIXOLLETTA } as const;
type Fonts = { readonly [K in keyof typeof FONT_REFS]: BitmapFont };
const MINUTE_MS = 60 * 1000;
// Only the fields the app reads, under the names Open-Meteo gives them.
const forecastSchema = z.object({
current: z.object({
time: z.string(),
temperature_2m: z.number(),
}),
});
async function currentCelsius(place: LocationValue): Promise<number> {
const query = new URLSearchParams({
latitude: String(place.lat),
longitude: String(place.lng),
current: "temperature_2m",
});
const url = `https://api.open-meteo.com/v1/forecast?${query}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Open-Meteo answered ${response.status}`);
}
return z.parse(forecastSchema, await response.json()).current.temperature_2m;
}
/** `text`, shortened until it fits in `maxWidth` pixels. */
function fit(font: BitmapFont, text: string, maxWidth: number): string {
// Whole characters, so a character made of two UTF-16 units stays whole.
const characters = Array.from(text);
const width = () => measureText(font, characters.join("")).width;
while (characters.length > 1 && width() > maxWidth) {
characters.pop();
}
return characters.join("");
}
function face(fonts: Fonts, place: string, degrees: string) {
return renderFrame(
<Frame width={64} height={32} background="#000000">
<Text
x={2}
y={2}
text={fit(fonts.place, place, 60)}
font={fonts.place}
color="#8a96a3"
/>
<Text
x={32}
y={20}
anchor="mm"
text={degrees}
font={fonts.figure}
color="#ffffff"
/>
</Frame>,
);
}
export default defineApp({
settings: () => ({
place: location({
name: "Place",
description: "Where to read the temperature",
default: { lat: 52.52, lng: 13.405, desc: "Berlin" },
}),
unit: select({
name: "Unit",
description: "Degrees Celsius or Fahrenheit",
options: ["Celsius", "Fahrenheit"],
default: "Celsius",
}),
}),
async render(ctx, params) {
let celsius: number;
try {
// One request per place for every display that shows it: the unit is
// applied when drawing, so it is left out of the cache key.
celsius = await ctx.cache.get(
"current",
async () => ({
value: await currentCelsius(params.place),
ttlMs: 15 * MINUTE_MS,
}),
{ vary: ["place"] },
);
} catch (error) {
// A failed fetch is not cached; the display keeps its last image.
const reason = error instanceof Error ? error.message : String(error);
return { ok: false, msg: `No temperature: ${reason}` };
}
const fahrenheit = params.unit === "Fahrenheit";
const value = fahrenheit ? celsius * 1.8 + 32 : celsius;
const degrees = `${Math.round(value)}°${fahrenheit ? "F" : "C"}`;
const freshness = {
softTtlMs: 15 * MINUTE_MS,
hardTtlMs: 120 * MINUTE_MS,
};
// The text decides the picture, so it names it: the same id, the same
// frames. A display that already shows it is told to keep it. The id
// travels in an HTTP header, which cannot hold every character of a
// place name, so it is a hash.
const cacheId = await cacheTag(params.place.desc, degrees);
if (ctx.cacheId === cacheId) {
return { image: null, cacheId, ...freshness };
}
const fonts = await loadFonts(ctx.assets, FONT_REFS);
const image = [face(fonts, params.place.desc, degrees)];
return { image, cacheId, ...freshness };
},
async preview(ctx) {
const fonts = await loadFonts(ctx.assets, FONT_REFS);
return { image: [face(fonts, "Berlin", "13°C")] };
},
});{
"app_id": "current-temperature",
"version": "1.0.0",
"entry": "./index.tsx",
"name": "Current Temperature",
"description": "The temperature outside a place you pick, from Open-Meteo.",
"category": "weather",
"network": { "allowed_hosts": ["api.open-meteo.com"] }
}The example reads the current temperature from Open-Meteo, which needs no key. Its preview draws fixed values, because a preview may not use the network.
Declare the hosts
List every host your app requests in
network.allowed_hosts:
api.example.comallows exactly that host.*.example.comallows every host underexample.com, at any depth, but notexample.comitself; list both if you need both.- A wildcard may not cover a public suffix, a name that unrelated owners share:
*.comand*.github.ioare refused. - Write host names in lower case, without a scheme, port or path. IP addresses are not allowed.
An app that uses no network declares an empty list. People see the hosts in the store before they add your app.
Every request must be HTTPS to the default port, to a host on the list. Anything else fails in your app, and the error says why, for example:
api.example.org is not among the app's network.allowed_hosts in ledable.jsonThe same rule applies in settings, in render, and in the local preview of
ledable developer dev and ledable developer render, so a missing host shows up while you
develop. fetch is the only way out: other connections, such as raw sockets, are refused.
Check what comes back
Treat every response as untrusted. Check the status, then parse the body with a schema, as the
example does with zod and its small zod/mini build. Add zod to your project:
npm install zodDescribe only the fields you read, under the names the API gives them, and parse with z.parse,
which throws when the data does not match, so a changed or broken API fails the render instead of
drawing nonsense. numericSchema from @ledable/sdk/app accepts a number that an API sends either
as a JSON number or as a string, and gives you a number.
When a render fails
A render fails when it throws, or when it returns { ok: false, msg } with a message of your own,
as the example does when the request fails. Either way, no new image is sent:
- A display that already has an image from your app keeps showing it, and asks again every 15 seconds, until the image's hard TTL runs out (see Caching and refresh).
- A display with no image from your app, or whose image has expired, shows its loading indicator while the entry is on screen, until a render succeeds.
So a failure costs nothing visible for as long as the last image is still good. Choose the hard TTL by how long old data is still worth showing.
ledable developer dev and ledable developer render show your message, or the error with the
line of your code it came from. Values of your secrets and of people's
credentials are replaced with *** in the messages and errors a render
reports.
When you upload a version, the store renders it once with its default settings, unless a setting is required. If that render fails, for example because the API is down, the upload fails, and you can upload the same version again later (see Publishing checks).
Time
Keep a render short. When you upload, the store gives the preview and the render with default settings 10 seconds each, and refuses the upload if one takes longer. A display waits 15 seconds for the answer before it gives up and asks again later.
Ask other servers as little as you can: cache what they answer, and share it between displays where it is the same for everyone, as the example shares the temperature of a place.
What the platform answers
Some things your app needs are in its context and need no request:
ctx.nowis the time of the request. Read the time only from it, never fromDate.now()ornew Date(), so that tests can fix it.ctx.timezoneis the display's time zone, an IANA name such asEurope/Berlin, andctx.languageits language.ctx.locations.timezoneAt(lat, lng)returns the IANA time zone at a place, such as one a person picked in a location setting, ornullwhen it finds none.ctx.images.decode(bytes)decodes a PNG or JPEG you downloaded into a framebuffer; see Images and assets.
In the local preview, the answers of ctx.locations come from ledable.dev.json; see
Local preview. Keys of your own go in secrets, and
people's own accounts and tokens are covered in Accounts and credentials.