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.
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.
| Export | Purpose |
|---|---|
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, WriteHandler | Types 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), Phase | Derive upcoming | current | expired from the clock. |
windowFrom(startsAt, durationSeconds) | Build {startsAt, expiresAt} from a duration you own. |
PROTOCOL_VERSION, MAX_ITEM_SPAN_SECONDS | The wire protocol version, currently 1; the widest span items() accepts. |
Import only mod.ts; sdk/ is implementation, not a second author API.
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:
expiresAtis 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".- An instantaneous event publishes a one-second window, not a missing or
null bound.
now > nullisnow > 0in JS, andexpires_at > :nowis 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. phase()values are state, not words. The protocol sayscurrent; you decide whether that renders as "LIVE" or "PEAK". Never put a phase value inmainText,subTextorcaption.
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: nullis 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.
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.
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.
- Routes
/v1/descriptor,/v1/validate-settings,/v1/read,/v1/write,/v1/publish. - Validation-only requests call
parseSettingswithout callingreador returning the parsed internal value. KeepparseSettingsfree of mutations. settingsSchemamay be an object or an async function returning one.- Parses and shape-checks the request body; rejects a wrong
protocolVersionor asourceKeythat isn't yours. - Validates
context.utcOffsetSecondsbefore 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 502invalid_source_outputinstead of failing composition at the parent, where nothing would say which source produced the bad item. - Derives
capabilitiesfrom 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.
- Validating settings.
settingsSchemais published for the parent's UI; the SDK does not enforce it. The parent checks its supported form subset and posted values, then asksparseSettingsto decide before saving. Reads also callparseSettingsindependently. - 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
nullmeans.utcOffsetSeconds: nullmeans 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.
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.
- Milliseconds in
startsAtorexpiresAt. 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. - An
idthat 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. - Re-reading storage in
read.parseSettingsmay do I/O and hands its return value toreadverbatim — load the snapshot there once, and pass it along. As a bonus the request is then resolved against one consistent snapshot rather than two.