Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "bool-sdk",
"version": "0.3.3",
"description": "Client SDK for apps built on Bool \u2014 gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).",
"version": "0.4.0-next.7",
"description": "Client SDK for apps built on Bool gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand All @@ -18,7 +18,7 @@
"default": "./dist/react.js"
}
},
"_sideEffectsNote": "dist/react.js MUST stay listed below. It registers the live-query implementation into the React-free core at module scope, and apps load it via a bare `import \"bool-sdk/react\"` with no bindings used \u2014 so a blanket `\"sideEffects\": false` licenses bundlers to delete that import outright. Verified: with `false`, a real Vite production build drops it and every published app throws on first render (shipped as 0.3.0, fixed in 0.3.1).",
"_sideEffectsNote": "dist/react.js MUST stay listed below. It registers the live-query implementation into the React-free core at module scope, and apps load it via a bare `import \"bool-sdk/react\"` with no bindings used so a blanket `\"sideEffects\": false` licenses bundlers to delete that import outright. Verified: with `false`, a real Vite production build drops it and every published app throws on first render (shipped as 0.3.0, fixed in 0.3.1).",
"sideEffects": [
"./dist/react.js"
],
Expand Down
108 changes: 100 additions & 8 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { createEntitiesModule, type EntitiesModule } from "./entities.js";
import { createDoorbell, type MintResult, type RealtimeMint } from "./realtime.js";
import {
createBoolRoom,
createBoolRoomApi,
createRoomStore,
type BoolRoomApi,
type RoomStore,
} from "./room.js";
import { createSession, type SessionChannel } from "./session.js";

/** Matches the server's append-only gateway path version. */
const GATEWAY_API = "v1";
Expand Down Expand Up @@ -197,6 +205,11 @@ export type BoolClient = {
/** The AI battery: `ai.generate(prompt)` / `ai.generate({prompt, schema})` /
* `ai.stream(prompt)`. Server-side AI with no API key in the bundle. */
ai: BoolAi;
/** The ephemeral lane: live cursors, presence, one-shot events — data that
* flies between the people currently in the app and is never stored.
* `bool.room.useOthers()` is the app-wide room; `bool.room("game:4")` is a
* scoped one with the identical surface. Durable data stays on `entities`. */
room: BoolRoomApi;
/** This app's private Postgres schema name. */
schema: string;
/** Subscribe to the app's realtime "doorbell": fires whenever any row in the
Expand Down Expand Up @@ -330,6 +343,13 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
// Cast: Bun's `typeof fetch` demands a `preconnect` property that a plain
// fetch-shaped function doesn't have; supabase-js only ever calls it.
global: { fetch: proxyFetch as unknown as typeof fetch },
// supabase-js defaults to 10 client events/sec, which a bool.room cursor
// stream (~40/s after the SDK's own throttle) would trip — the limiter
// silently drops the excess and cursors freeze. The room store is the only
// high-frequency sender and it throttles itself; this cap is the backstop.
// Declared per-connection send budget. 60Hz room state + events + margin;
// the server-side ceiling is the tenant config, not this.
realtime: { params: { eventsPerSecond: 500 } },
});

const authListeners = new Set<AuthChangeListener>();
Expand Down Expand Up @@ -666,20 +686,40 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
}
};

const doorbell = createDoorbell({
mint: mintRealtimeToken,
// ONE session for everything realtime — the doorbell and every room share
// its mint, its wristband refresh, and the underlying socket. Two machines
// used to run here with duplicate lifecycles; both of the July-2026
// production fires had to be fixed twice because of it (see session.ts).
const session = createSession({
mint: async () => {
const res = await mintRealtimeToken();
if (!res.ok) return res;
return {
ok: true as const,
token: res.mint.token,
expiresIn: res.mint.expiresIn,
topics: {
app: res.mint.topics.app,
user: res.mint.topics.user ?? null,
room: res.mint.topics.room ?? null,
},
};
},
setAuth: async (token) => {
await db.realtime.setAuth(token);
},
channel: (topic, opts) => {
// Adapt one supabase RealtimeChannel to the doorbell's minimal shape.
// The channel is created lazily-configured and joined in join(), so
// onBroadcast registration always precedes the subscribe.
const ch = db.channel(topic, { config: { private: opts.private } });
});

const doorbell = createDoorbell({
session,
makeChannel: (topic) => {
// The channel is created lazily-configured and joined by the session,
// so onBroadcast registration always precedes the subscribe.
const ch = db.channel(topic, { config: { private: true } });
return {
onBroadcast(cb) {
ch.on("broadcast", { event: "*" }, (msg) =>
cb((msg as { payload?: BoolChangePayload }).payload ?? {}),
cb(msg as unknown as { event: string; payload: unknown }),
);
},
join(status) {
Expand All @@ -696,11 +736,63 @@ export function createBoolClient(config: BoolClientConfig): BoolClient {
listener: (payload: BoolChangePayload) => void,
): (() => void) => doorbell.subscribe(listener);

const makeRoomChannel = (topic: string, key: string): SessionChannel => {
const ch = db.channel(topic, {
config: {
private: true,
presence: { key },
// No server echo: the store already delivered the sender's copy
// locally and synchronously; a wire echo would double-fire.
broadcast: { self: false, ack: false },
},
});
return {
track(state, done) {
// supabase-js RESOLVES with "ok" | "timed out" | "error" — it does not
// throw — so the result must be inspected or every failure is silent.
void ch.track(state).then(
(r) => done?.(typeof r === "string" ? r : "ok"),
() => done?.("error"),
);
},
onPresence(cb) {
ch.on("presence", { event: "sync" }, () => {
cb(ch.presenceState() as Record<string, Array<Record<string, unknown>>>);
});
},
onBroadcast(cb) {
ch.on("broadcast", { event: "*" }, (msg) =>
cb(msg as unknown as { event: string; payload: unknown }),
);
},
send(event, payload, done) {
void ch.send({ type: "broadcast", event, payload }).then(
(r) => done?.(typeof r === "string" ? r : "ok"),
() => done?.("error"),
);
},
join(status) {
ch.subscribe((state) => status(state));
},
leave() {
void db.removeChannel(ch);
},
};
};

// The default room + memoized scoped rooms, all channel groups on the one
// session: `bool.room.useOthers()` is the app-wide room, `bool.room("id")`
// is a scoped one with the identical surface.
const roomStoreFor = (roomId: string | null): RoomStore<Record<string, unknown>> =>
createRoomStore({ session, roomId, makeChannel: makeRoomChannel });
const room = createBoolRoomApi(createBoolRoom(roomStoreFor(null)), (id) => roomStoreFor(id));

const client: BoolClient = {
db,
entities: createEntitiesModule(db, subscribeToChanges),
auth,
ai,
room,
schema,
subscribeToChanges,
};
Expand Down
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,10 @@ export {
type LiveQueryOptions,
type LiveSnapshot,
} from "./live.js";
export {
colorForId,
type BoolRoom,
type RoomEvent,
type RoomPeer,
type RoomStatus,
} from "./room.js";
49 changes: 49 additions & 0 deletions src/react.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
useEntity,
} from "./react";
import { __registerEntityUseQuery } from "./entities";
import { __registerRoomHooks } from "./room";

// SSR smoke tests: effects don't run in renderToString, so the provider is in
// its initial loading state — enough to pin the gate/hook contract without a
Expand Down Expand Up @@ -193,3 +194,51 @@ describe("bool.entities.<table>.useQuery", () => {
expect(typeof Probe).toBe("function");
});
});

// bool.room hooks — same contract pins as useQuery: SSR-renderable initial
// state, and a clear thrown error when the React entry isn't loaded.
describe("bool.room hooks", () => {
test("useOthers/useStatus/useSetMe render on the server with honest initials", () => {
const client = createBoolClient(CONFIG);
function Probe() {
const others = client.room.useOthers<{ cursor: { x: number } }>();
const setMe = client.room.useSetMe();
const status = client.room.useStatus();
return (
<span>
{others.length}:{status}:{typeof setMe === "function" ? "fn" : "bad"}
</span>
);
}
// SSR interleaves comment markers between expressions, so assert pieces.
const html = renderToString(<Probe />);
expect(html).toContain("connecting");
expect(html).toContain("fn");
expect(html).not.toContain("bad");
});

test("self is available without React and colors are deterministic", () => {
const client = createBoolClient(CONFIG);
expect(client.room.self.id.length).toBeGreaterThan(6);
expect(client.room.self.color).toMatch(/^hsl\(/);
});

test("without the React entry loaded, hooks throw instructions naming the fix", () => {
const prev = __registerRoomHooks(null);
try {
const client = createBoolClient(CONFIG);
expect(() => client.room.useOthers()).toThrow(/bool-sdk\/react/);
expect(() => client.room.useSetMe()).toThrow(/React entry/);
} finally {
__registerRoomHooks(prev);
}
});

test("hallucinated members are type errors, not runtime undefined", () => {
const client = createBoolClient(CONFIG);
// @ts-expect-error — no useMyPresence: BoolRoom has NO index signature, so
// Liveblocks-reflex members fail the build instead of failing users.
const bad = client.room.useMyPresence;
expect(bad).toBeUndefined();
});
});
87 changes: 87 additions & 0 deletions src/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ import {
type EntityHandler,
type EntityQueryResult,
} from "./entities.js";
import {
__registerRoomHooks,
type RoomEvent,
type RoomPeer,
type RoomStatus,
type RoomStore,
} from "./room.js";

// Re-exported on purpose, and it is load-bearing. A React app creates its client
// by importing createBoolClient FROM HERE:
Expand Down Expand Up @@ -376,3 +383,83 @@ function useEntityHandler<T extends EntityRow = EntityRow>(
// this import via src/lib/supabase.ts, so the hook Just Works everywhere the
// data client does.
__registerEntityUseQuery(useEntityHandler as Parameters<typeof __registerEntityUseQuery>[0]);

// ---------------------------------------------------------------------------
// bool.room hooks. Same registration pattern as useQuery above: core is
// React-free, importing this entry arms the hooks. Every hook ACQUIRES the
// room machinery on mount and releases on unmount — the connection exists
// exactly while something on screen cares about it.

type AnyRoomStore = RoomStore<Record<string, unknown>>;

function useRoomAcquire(store: AnyRoomStore): void {
// StrictMode mounts, unmounts, and remounts: acquire/release must be exactly
// paired, and the store's ref-count (with its generation counter) makes the
// double-cycle safe.
useEffect(() => store.acquire(), [store]);
}

function useRoomOthers(store: AnyRoomStore): ReadonlyArray<RoomPeer<Record<string, unknown>>> {
useRoomAcquire(store);
return useSyncExternalStore(
(cb) => store.onOthers(cb),
() => store.getOthers(),
() => store.getOthers(),
);
}

function useRoomSetMe(store: AnyRoomStore): (patch: Record<string, unknown>) => void {
useRoomAcquire(store);
// Track which keys THIS component wrote, and clear exactly those on unmount:
// a cursor must not outlive the screen that was publishing it, and two
// components publishing different keys must not clobber each other.
const keysRef = useRef<Set<string>>(new Set());
useEffect(() => {
const keys = keysRef.current;
return () => {
if (keys.size > 0) store.clearMe([...keys]);
};
}, [store]);
const [setter] = useState(() => (patch: Record<string, unknown>) => {
for (const k of Object.keys(patch)) keysRef.current.add(k);
store.setMe(patch);
});
return setter;
}

function useRoomEventListener(store: AnyRoomStore, event: string, cb: (e: RoomEvent) => void): void {
useRoomAcquire(store);
// Latest-ref: the handler closes over fresh props/state on every render,
// while the subscription itself is stable — no stale closures, no resubscribe
// churn, nothing for StrictMode to double.
const cbRef = useRef(cb);
cbRef.current = cb;
const eventRef = useRef(event);
eventRef.current = event;
useEffect(
() =>
store.onEvent((name, e) => {
if (name === eventRef.current) cbRef.current(e);
}),
[store],
);
}

function useRoomStatus(store: AnyRoomStore): RoomStatus {
useRoomAcquire(store);
return useSyncExternalStore(
(cb) => {
const off = store.onStatus(() => cb());
return () => off();
},
() => store.status(),
() => store.status(),
);
}

__registerRoomHooks({
useOthers: useRoomOthers,
useSetMe: useRoomSetMe,
useEventListener: useRoomEventListener,
useStatus: useRoomStatus,
});
Loading
Loading