Skip to content

SDK reference

<script>
document.addEventListener("DOMContentLoaded", async () => {
await homespun.ready;
const row = await homespun.collections.create("todos", { text: "buy milk" });
console.log("created", row.key);
homespun.collections.on("todos", (delta) => {
console.log(delta.kind, delta.row);
});
});
</script>

Homespun injects this API into every deployed app automatically, there’s nothing to import. Read “Script ordering” below before writing anything that touches window.homespun at the top level of your own script.

The SDK is injected as <script src="/_hs/sdk.<hash>.js" defer>. A defer script runs after the page has parsed but before DOMContentLoaded, and, importantly, after any plain, non-deferred inline <script> on the page. If your own script references homespun.* at its top level (not inside a callback), it runs before window.homespun is defined and throws ReferenceError: homespun is not defined, silently, with no visible error unless you check the console.

Fix it one of two ways:

  • Wrap anything touching window.homespun in a DOMContentLoaded listener (what every example on this page does). This is the safer default regardless of where your <script> tag sits.
  • Or mark your own script defer too and place it after the SDK’s tag; deferred scripts run in document order, so a later defer script always sees a defined window.homespun.

Never reference window.homespun at the top level of a plain inline <script>, it isn’t defined yet when that line runs.

A Promise<void>. Resolves once the session is resolved and every declared collection has been snapshotted into the local, in-browser mirror. await it before your first synchronous read. Rejects after a 20-second connection timeout if the realtime connection never establishes.

Read-only, manifest-derived facts about the app itself, kept live:

homespun.app.slug; // string
homespun.app.name; // string
homespun.app.description; // string | null
homespun.app.icon; // string | null
homespun.app.visibility; // "private" | "link" | "public"
homespun.app.collections; // { name: string; appendOnly: boolean }[]

Who’s looking at the page right now:

homespun.session.kind; // "owner" | "member" | "anonymous"
homespun.session.humanId; // string | null, null when anonymous
homespun.session.login(); // full-page redirect to sign in
homespun.session.logout(); // clears the local session and reloads as anonymous

login() sends the browser to homespun.dev to sign in and back; see Opening a shared app for what a person experiences during that redirect.

Every method operates on an in-memory mirror, kept in sync by the realtime connection.

homespun.collections.snapshot(name); // HomespunRow[], synchronous, [] before ready
homespun.collections.get(name, key); // HomespunRow | undefined, synchronous
homespun.collections.on(name, handler); // returns an unsubscribe function
homespun.collections.create(name, data); // Promise<HomespunRow>, server-generated key
homespun.collections.upsert(name, key, data); // Promise<HomespunRow>, create-or-return-existing
homespun.collections.update(name, key, data, { ifMatch }); // Promise<HomespunRow>
homespun.collections.delete(name, key, { ifMatch }); // Promise<void>

A HomespunRow looks like:

{
key: string,
data: unknown,
version: number,
author: { kind: "agent" | "human", id: string },
createdAt: string,
updatedAt: string,
}
  • snapshot / get are synchronous reads against the local mirror; call them any time after homespun.ready resolves.
  • on(name, handler) delivers { kind: "upsert", collection, row } or { kind: "delete", collection, row: { key, deletedAt } }. Call the returned function to unsubscribe.
  • create always generates the key server-side. upsert is the one call that accepts a caller-chosen key and never errors on a collision, it just returns the row that’s already there.
  • update and delete both accept an optional ifMatch for optimistic locking. A stale ifMatch rejects with a conflict error whose details.current is the winning row, already folded into your local mirror by the time the promise rejects, so your next read is already correct without waiting on a realtime message.

The raw, unfiltered (or single-collection) change feed, every create/update/delete across the app, in order:

const unsubscribe = homespun.feed.on(
(entry) => {
console.log(entry.op, entry.collection, entry.key);
},
{ collection: "todos" }, // optional
);
homespun.feed.cursor; // highest feed sequence number applied locally so far

A FeedEntry carries { seq, op, collection, key, data, author, ts }. Note the field is op, not kind, homespun.feed.on and homespun.collections.on intentionally carry different shapes: feed is the raw log, collections.on is already folded into row deltas.

homespun.uploadBlob(file, { filename, mime }); // Promise<AttachmentRef>
homespun.downloadBlob(attachmentId); // Promise<Blob>
homespun.saveBlob(attachmentId, filename); // Promise<void>, triggers a browser save

AttachmentRef is { id, mime, size, filename }. These three names are kept from an earlier version of the SDK for continuity; they aren’t renamed to collections-style verbs.

Every rejected promise is one shape:

{ code: string, message: string, status?: number, details?: unknown, retryable: boolean }

SDK-local codes: network_error, invalid_args, timeout, ws_unavailable. Anything else (like conflict from a stale ifMatch) passes the relay’s own error code straight through.