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:
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".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.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.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:
[day start, next day start) — not a made-up instant plus a duration.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.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.
/v1/descriptor, /v1/validate-settings, /v1/read, /v1/write, /v1/publish.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.protocolVersion
or a sourceKey that isn't yours.context.utcOffsetSeconds before your code sees it.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.capabilities from the definition, so what a source advertises
cannot drift from what it implements.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.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.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.These belong to the parent, and a source that does them will conflict with it:
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.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.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.