Public
Shared temporal source protocol and authoring SDK
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.

Source SDK

One definition and one HTTP mount. Import the public API from:

import { accept, reject, defineSource, serveSource, type Item } from "https://esm.town/v/plusjade/source-sdk@12-main/mod.ts";

This is shared code executed inside your source val, not another HTTP service. Storage, credentials, ingest, schedules and domain policy stay in your source. The SDK runtime has no dependencies or load-time side effects.

Create a source

Copy examples/rpc.ts into a new val as an HTTP file. Set that val's SOURCE_TOKEN environment variable, choose a unique source key, and replace the example item with your data. No install, build, import map or SDK directory is needed. This complete starter uses empty settings, supported by the parent's browser form:

// Starter mount: create this as an HTTP file in your own source val. import { accept, reject, defineSource, serveSource, windowFrom } from "https://esm.town/v/plusjade/source-sdk@12-main/mod.ts"; // Your domain's typical duration, named and justified. The SDK will never // supply one — it has no idea what your events are. const EXAMPLE_TYPICAL_EVENT_SECONDS = 2 * 60 * 60; const source = defineSource<null>({ key: "example", settingsSchema: { type: "object", properties: {}, additionalProperties: false }, parseSettings: (raw) => Object.keys(raw).length === 0 ? accept(null) : reject("invalid_settings"), read: async () => [{ id: "example-event", mainText: "Example event", subText: "Replace this with your source's data", caption: null, emphasized: false, ...windowFrom(1798761600, EXAMPLE_TYPICAL_EVENT_SECONDS), }], }); export default function handler(req: Request): Promise<Response> | Response { const token = Deno.env.get("SOURCE_TOKEN"); if (!token) return Response.json({ error: "RPC token not configured" }, { status: 503 }); if (req.headers.get("authorization") !== `Bearer ${token}`) { return Response.json({ error: "unauthorized" }, { status: 401 }); } return serveSource(source, req); }

The example is stored here as a script so the SDK val exposes no HTTP endpoint. A larger source can put the definition in mySource.ts and keep authentication and serveSource(mySource, req) in rpc.ts; import from the same URL in both. See Moon for that small split and Women's FIBA for storage-backed settings and ingestion.

To connect it to Clark View, register the HTTP endpoint obtained from Val Town, the matching source key, protocol version 1, and a credential reference in the parent's source registry; configure the same bearer value independently in the parent and source. Assign that registered source to a device through the parent. Do not register this SDK val as a source.

For selectable settings, follow the parent's supported form contract. The SDK publishes your schema; your parseSettings enforces it.

Public API

ExportPurpose
defineSource, SourceDefinition<Settings>Describe a source; infer/check parsed settings against its read handler.
serveSource(definition, request)Serve protocol v1 with request and item validation.
accept(value), reject(code, status?), Result<T>Return validated input or an expected rejection.
Item, ReadContext, WriteHandlerTypes for items, caller context and optional writes.
record(value), items(value)Guards for object payloads and item arrays; output guarding is automatic.
phase(item, now), PhaseDerive upcoming | current | expired from the clock.
windowFrom(startsAt, durationSeconds)Build {startsAt, expiresAt} from a duration you own.
PROTOCOL_VERSION, MAX_ITEM_SPAN_SECONDSThe wire protocol version, currently 1; the widest span items() accepts.

Import only mod.ts; sdk/ is implementation, not a second author API.

An item is a window, not an instant

Every item carries startsAt and expiresAt, both Unix seconds, both required, with expiresAt strictly greater. Together they let any consumer derive lifecycle from the clock alone — no re-read, no ingest, no polling:

phase(item, Math.floor(Date.now() / 1000)); // "upcoming" | "current" | "expired"

Three rules that are easy to get wrong:

  1. expiresAt is an estimate, and never displayed. Publish your domain's typical duration; a longer-than-typical event reading as expired while it is still running is the accepted cost of needing no live ingest. It exists for phase and refresh scheduling. Never render it as "ends at 4:15 PM".
  2. An instantaneous event publishes a one-second window, not a missing or null bound. now > null is now > 0 in JS, and expires_at > :now is never true for NULL in SQL — a nullable expiry classifies a moment as permanently expired, silently, in both languages. One second is a fiction too, but it degrades predictably instead of corrupting. If such an event should later read as current for an hour, publish that hour.
  3. phase() values are state, not words. The protocol says current; you decide whether that renders as "LIVE" or "PEAK". Never put a phase value in mainText, subText or caption.

Day-granular events

Some events are only precise to a day ("pay this bill"). Any clock time you invent for one is fabricated, and rule 1 above applies to it too:

  • Span the real unit. The window is the whole local day — [day start, next day start) — not a made-up instant plus a duration.
  • Send a non-null caption ("DUE", "TODAY"). caption: null is a protocol instruction meaning "client, format the start time yourself", and the client will render a clock time. The day label it derives is honest at day granularity; the clock time is not.

SDK changes and source upgrades

The import above pins the entire val to main snapshot 12, including relative dependencies. This is an implementation revision, not a protocol version. Snapshot 12 is the first with the startsAt/expiresAt window; snapshot 3 is the last single-timestamp runtime. Later documentation and example edits do not require sources to change their runtime pin.

Keep a source's SDK imports on the same snapshot. To upgrade, test an SDK branch, merge once, obtain its new immutable module URL, replace the old URL in a source branch, run that source's checks, then merge. Unversioned source-sdk/mod.ts tracks main; avoid it in deployed sources while the SDK is evolving. There is no separate release registry or compatibility adapter.

Maintainer map and checks

  • mod.ts: explicit public exports.
  • sdk/contract.ts: item guard and results.
  • sdk/defineSource.ts: source author types.
  • sdk/serve.ts: protocol implementation.
  • tools/sdk-check.ts: shared boundary and protocol conformance checks.
  • examples/rpc.ts: complete creator starting point.

Run tools/sdk-check.ts in this val after runtime edits. It imports the public entrypoint, uses a storage-free fixture, and checks the implementation imports stay within sdk/. Source-specific storage and HTTP checks stay in each source. The runtime must not import a source, Val Town storage, environment helpers or third-party packages. Authentication belongs in the mount.

What the SDK does, so you don't

  • Routes /v1/descriptor, /v1/validate-settings, /v1/read, /v1/write, /v1/publish.
  • Validation-only requests call parseSettings without calling read or returning the parsed internal value. Keep parseSettings free of mutations.
  • settingsSchema may be an object or an async function returning one.
  • Parses and shape-checks the request body; rejects a wrong protocolVersion or a sourceKey that isn't yours.
  • Validates context.utcOffsetSeconds before your code sees it.
  • Validates your output. Items that break the contract — a duplicate id, an inverted or implausibly long window — never leave the val; the request fails 502 invalid_source_output instead of failing composition at the parent, where nothing would say which source produced the bad item.
  • Derives capabilities from the definition, so what a source advertises cannot drift from what it implements.
  • Writes every response envelope, so no route can invent a new one.

What is yours

  • Validating settings. settingsSchema is published for the parent's UI; the SDK does not enforce it. The parent checks its supported form subset and posted values, then asks parseSettings to decide before saving. Reads also call parseSettings independently.
  • Being total. Reject unknown keys. A setting you silently ignore is a setting a caller thinks is working.
  • Failing by returning, not throwing. reject("code") is this source correctly refusing, and the code reaches the parent verbatim — make it a stable snake_case token it can branch on, not a sentence. A throw is a bug and surfaces as a 500.
  • Deciding what null means. utcOffsetSeconds: null means the parent had no offset to forward. It does not mean UTC. Fall back to a zone you pick; treating it as 0 quietly moves every day boundary to Greenwich.

What is nobody's job here

These belong to the parent, and a source that does them will conflict with it:

  • Composition across sources, and namespacing ids (the parent prefixes yours).
  • Presentation: envelopes, eyebrows, section headers, empty-state copy.
  • Device or user identity — a source never learns who is asking. Authentication belongs to each source's HTTP mount.

Three things that go wrong

  1. Milliseconds in startsAt or expiresAt. They are Unix seconds. Sending ms decodes on the client as a date ~57,000 years out, and nothing catches it: both are numbers, and a real ms value is not large enough to trip the magnitude check. The span rule catches only the mixed case, one bound in each unit — two consistently-wrong bounds pass. Pin it in a test.
  2. An id that isn't stable. It must be the same across polls of the same underlying thing, and unique within one response. An id containing "now" makes every poll look like new content.
  3. Re-reading storage in read. parseSettings may do I/O and hands its return value to read verbatim — load the snapshot there once, and pass it along. As a bonus the request is then resolved against one consistent snapshot rather than two.