Tibi

Tether

The tether marketplace package (tibi plugin add tether) connects pages to a Tether project: window.Tibi.tether and reactive helpers in the browser, and a privileged ctx.tether in frontmatter and API routes. It follows @tthr/client 0.4.

Configure

# Browser (surfaced to pages) TIBI_TETHER_URL=https://tether.strands.gg TIBI_TETHER_PROJECT_ID=your-project-id TIBI_TETHER_PUBLISHABLE_KEY=tthr_pk_… # Server only (never sent to the browser): powers ctx.tether TIBI_TETHER_API_KEY=tthr_prod_…

The signed-in user's token comes from Tibi's built-in sign-in by default (Auth): the Strands Accounts id_token, which Tether verifies when the project has a Strands auth provider (issuer https://sessions.strands.gg, audience = your client id). Set TIBI_TETHER_AUTH_MODE to localstorage or cookie (with TIBI_TETHER_AUTH_KEY) to read it from somewhere else, or call Tibi.tether.configureAuth({ mode: 'callback', callback }). On a 401 the client asks for a fresh token once and retries.

In components

<script tibi> const handle = computed(() => $route.params.page); // Args may be a function or signal: the query refetches and // resubscribes when they change; null pauses it. const pages = Tibi.tether.useQuery('pages.byHandle', () => ({ handle: handle.value })); const save = Tibi.tether.useMutation('drafts.save', { invalidates: ['pages'] }); async function publish(draft) { try { await save.mutate({ id: draft.id, revision: draft.revision, content: draft.content }); } catch (e) { if (e instanceof Tibi.tether.TetherError && e.code === 'CONFLICT') reload(e.data.revision); } } </script>
  • useQuery(name, args, { live, enabled }) returns { data, loading, error, refetch } signals and stays live over the WebSocket unless live: false. Responses to superseded args are ignored.
  • useMutation(name, { invalidates }) returns { mutate, data, loading, error, reset }; useSubscription(name, args, callback) returns { data }. All three clean up when the component unmounts.
  • Imperative: query, mutation(name, args, { invalidates }), subscribe(name, args, callback, onError?) (the first result arrives over HTTP straight away, then changes over the socket), invalidateQueries, storage.upload / getUrl / getMetadata / delete / list.
  • Failures carrying a code ({ error, code, data }, such as a transactional mutation's CONFLICT) throw Tibi.tether.TetherError with .code and .data; instanceof also matches errors from @tthr/client.
  • The user token travels as a WebSocket subprotocol, never in the URL. Reconnects never give up (jittered backoff up to 30 seconds) and catch up on missed changes.

On the server

// pages/api/pages/[id].ts export async function PUT(ctx) { if (!ctx.user) ctx.error(401, 'Sign in first'); // Privileged (API key): scope by the user yourself. return ctx.tether.mutation('pages.update', { id: ctx.params.id, owner: ctx.user.id, ...ctx.body }); }

ctx.tether (query, mutation, bridge.query, bridge.mutate) is available in JS / TS frontmatter and API routes whenever TIBI_TETHER_URL and TIBI_TETHER_PROJECT_ID are set; it authenticates with TIBI_TETHER_API_KEY. An API route that lets a TetherError escape answers with a matching status (CONFLICT 409, NOT_FOUND 404, FORBIDDEN 403, …) and { error, code, data }.

Bridges

Calls into a bridged project need a secret API key, so they only happen server-side: ctx.tether.bridge.query(alias, fn, args) and ctx.tether.bridge.mutate(alias, fn, args) in frontmatter and API routes, or ctx.bridge inside your Tether functions. Tether has no actions, so there is no exec. In the browser, Tibi.tether.bridge.* rejects straight away with a FORBIDDEN TetherError saying so; call one of your own functions (or an API route) that makes the bridge call instead.

examples/tether in the Tibi repo runs against a small fake Tether (node fake-tether.mjs).