You are working on the FlipDot Content Server, a Val Town project that serves pre-rendered content to a FlipDot display driver running on a Raspberry Pi.
This server implements the FlipDot Content Server API v2.0 specified in CONTENT_SERVER_SPEC.md. The driver polls this server for content, and the server is responsible for:
Key Architecture Principle: The server does ALL rendering. The driver is minimal and only handles display queue management, timing, and hardware communication.
[[1], [2]] (stacked vertically)BEFORE implementing any features, read:
CONTENT_SERVER_SPEC.md - Complete API specification
Key Spec Sections:
status, content, poll_interval_ms)data_b64 encodingRequired Endpoint:
// GET /api/flipdot/content
// Returns: ContentResponse JSON
export default async function (req: Request) {
// 1. Validate authentication
// 2. Determine what content to show
// 3. Render content to frames
// 4. Return ContentResponse
}
Authentication:
X-API-Key header (or Authorization: Bearer token)Deno.env.get('FLIPDOT_API_KEY')401 Unauthorized if missing/invalid403 Forbidden if insufficient permissionsResponse Structure:
interface ContentResponse {
status: "updated" | "no_change" | "clear";
content?: Content; // Required when status="updated"
poll_interval_ms: number; // >= 1000
}
Critical: Frames must use packed bit format (see spec Section 4).
Reference Implementation Pattern:
// Helper function to pack bits (little-endian)
function packBitsLittleEndian(bits: number[]): Uint8Array {
const byteArray = new Uint8Array(Math.ceil(bits.length / 8));
for (let i = 0; i < bits.length; i++) {
if (bits[i]) {
byteArray[Math.floor(i / 8)] |= 1 << i % 8;
}
}
return byteArray;
}
// Convert packed bytes to base64
function bitsToBase64(bits: number[]): string {
const packed = packBitsLittleEndian(bits);
return btoa(String.fromCharCode(...packed));
}
// Create a frame
function createFrame(
width: number,
height: number,
bits: number[],
durationMs?: number,
) {
return {
data_b64: bitsToBase64(bits),
width,
height,
duration_ms: durationMs ?? null,
};
}
Bitmap Font Rendering:
Phase 1 (MVP):
Phase 2:
Phase 3:
Important: The server should be mostly stateless. The driver manages:
Server MAY maintain:
no_change responses)Storage:
import { blob } from "https://esm.town/v/std/blob";
// Cache rendered content
await blob.setJSON("flipdot:last_content", contentResponse);
// Get cached content
const cached = await blob.getJSON("flipdot:last_content");
Time-Based Content:
// Use Deno's Date API
const now = new Date();
const hour = now.getHours();
const minute = now.getMinutes();
// Generate clock content
const clockText = `${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}`;
Cron for Updates: If you need to pre-compute content, use a cron val:
// Update cache every minute
export default async function () {
const content = await generateClockContent();
await blob.setJSON("flipdot:current_content", content);
}
Test Responses: Use these patterns to test incrementally:
// Minimal valid response (no content change)
return Response.json({
status: "no_change",
poll_interval_ms: 30000,
});
// Static test frame (all pixels on)
const bits = new Array(28 * 14).fill(1);
return Response.json({
status: "updated",
content: {
content_id: "test-pattern",
frames: [
{
data_b64: bitsToBase64(bits),
width: 28,
height: 14,
duration_ms: null,
},
],
playback: {
priority: 0,
interruptible: true,
},
},
poll_interval_ms: 30000,
});
Validation:
ceil(28 * 14 / 8) = 49 bytespoll_interval_ms must be >= 1000content required when status="updated"Local Testing:
# Test the endpoint curl -H "X-API-Key: your-key" https://your-val.val.run/api/flipdot/content
val-server/
├── backend/
│ ├── index.ts # Main HTTP handler (polling endpoint)
│ ├── auth.ts # Authentication logic
│ ├── content/
│ │ ├── clock.ts # Clock content generator
│ │ ├── text.ts # Static text renderer
│ │ ├── weather.ts # Weather content generator
│ │ └── router.ts # Content routing logic
│ └── rendering/
│ ├── frame.ts # Frame creation utilities
│ ├── font.ts # Bitmap font rendering
│ └── bits.ts # Bit packing utilities
├── shared/
│ └── types.ts # Shared TypeScript types (Content, Frame, etc.)
├── CONTENT_SERVER_SPEC.md # API specification
└── CLAUDE.md # This file
Copy these from the spec into shared/types.ts:
export interface Frame {
data_b64: string;
width: number;
height: number;
duration_ms?: number | null;
metadata?: Record<string, unknown>;
}
export interface PlaybackMode {
loop?: boolean;
loop_count?: number | null;
priority?: number;
interruptible?: boolean;
}
export interface Content {
content_id: string;
frames: Frame[];
playback?: PlaybackMode;
metadata?: Record<string, unknown>;
}
export type ResponseStatus = "updated" | "no_change" | "clear";
export interface ContentResponse {
status: ResponseStatus;
content?: Content;
poll_interval_ms: number;
}
❌ Don't:
poll_interval_ms < 1000content when status is "no_change" or "clear"✅ Do:
content_id for the same logical contentpoll_interval_ms (longer for static content)The content below is standard Val Town guidance. See above for project-specific requirements.
Files that are HTTP triggers have http in their name like foobar.http.tsx
Files that are Cron triggers have cron in their name like foobar.cron.tsx
Files that are Email triggers have email in their name like foobar.email.tsx
Val Town provides several hosted services and utility functions.
import { blob } from "https://esm.town/v/std/blob";
await blob.setJSON("myKey", { hello: "world" });
let blobDemo = await blob.getJSON("myKey");
let appKeys = await blob.list("app_");
await blob.delete("myKey");
import { sqlite } from "https://esm.town/v/stevekrouse/sqlite";
const TABLE_NAME = "todo_app_users_2";
// Create table - do this before usage and change table name when modifying schema
await sqlite.execute(`CREATE TABLE IF NOT EXISTS ${TABLE_NAME} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
)`);
// Query data
const result = await sqlite.execute(
`SELECT * FROM ${TABLE_NAME} WHERE id = ?`,
[1],
);
Note: When changing a SQLite table's schema, change the table's name (e.g., add _2 or _3) to create a fresh table.
import { OpenAI } from "https://esm.town/v/std/openai";
const openai = new OpenAI();
const completion = await openai.chat.completions.create({
messages: [{ role: "user", content: "Say hello in a creative way" }],
model: "gpt-4o-mini",
max_tokens: 30,
});
import { email } from "https://esm.town/v/std/email";
// By default emails the owner of the val
await email({
subject: "Hi",
text: "Hi",
html: "<h1>Hi</h1>",
});
Val Town provides several utility functions to help with common project tasks.
Always import utilities with version pins to avoid breaking changes:
import {
parseProject,
readFile,
serveFile,
} from "https://esm.town/v/std/utils@85-main/index.ts";
For example, in Hono:
// serve all files in frontend/ and shared/
app.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url));
app.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url));
// Read a file from the project
const fileContent = await readFile("/frontend/index.html", import.meta.url);
This is useful for including info for linking back to a val, ie in "view source" urls:
const projectVal = parseProject(import.meta.url);
console.log(projectVal.username); // Owner of the project
console.log(projectVal.name); // Project name
console.log(projectVal.version); // Version number
console.log(projectVal.branch); // Branch name
console.log(projectVal.links.self.project); // URL to the project page
However, it's extremely importing to note that parseProject and other Standard Library utilities ONLY RUN ON THE SERVER.
If you need access to this data on the client, run it in the server and pass it to the client by splicing it into the HTML page
or by making an API request for it.
return new Response(null, { status: 302, headers: { Location: "/place/to/redirect" }}) instead of Response.redirect which is broken<img src="https://maxm-imggenurl.web.val.run/the-description-of-your-image" />alert(), prompt(), or confirm() methodsimport.meta.url.replace("ems.sh", "val.town)" (or passing this data to the client) and include target="_top" attribute<script src="https://esm.town/v/std/catch"></script> to HTML to capture client-side errorsDeno.env.get('keyname') when you need to, but generally prefer APIs that don't require keyshttps://esm.sh for npm and Deno dependencies to ensure compatibility on server and browser?deps=react@18.2.0,react-dom@18.2.0 and start the file with /** @jsxImportSource https://esm.sh/react@18.2.0 */<script src="https://cdn.twind.style" crossorigin></script> unless otherwise specifiedEnvironment Limitations:
shared/ must work in both frontend and backend environmentsDeno keyword in shared codehttps://esm.sh for imports that work in both environmentsSQLite Peculiarities:
React Configuration:
@jsxImportSource https://esm.sh/react@18.2.0 at the top of React filesFile Handling:
readFile helpersAPI Design:
fetch handler is the entry point for HTTP valsexport default app.fetch // This is the entry point for HTTP vals