Current section
Files
Jump to
Current section
Files
packages/react/src/index.tsx
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useRef,
useState
} from "react"
import type { FC, ReactNode } from "react"
import { useSyncExternalStoreWithSelector } from "use-sync-external-store/shim/with-selector"
import {
connect as baseConnect,
MusubiCommandError,
type ConnectOptions,
type MountStoreOptions,
type MountedStore,
type MusubiConnection,
type SocketLike,
type StoreModule,
type StoreProxy,
type StoreSnapshot,
type CommandName,
type CommandPayload,
type CommandReply
} from "@musubi/client"
import { shallowEqual } from "./shallow"
export { shallowEqual } from "./shallow"
export { MusubiCommandError, keyOf } from "@musubi/client"
export type {
AsyncResult,
CommandName,
CommandPayload,
CommandReply,
ConnectionPatchEnvelope,
ExternalUploader,
ExternalUploaderArgs,
MountStoreOptions,
MountedStore,
MusubiCommandErrorKind,
MusubiCommandErrorOptions,
MusubiConnection,
PatchEnvelope,
StoreId,
StoreModule,
StoreProxy,
StoreSnapshot,
StreamEntry,
StreamOp,
UploadConfig,
UploadEntry,
UploadError,
UploadHandle,
UploadStatus
} from "@musubi/client"
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export type MusubiRootMount<M extends StoreModule<R>, R> =
| { status: "loading"; store: null; error: null }
| { status: "ready"; store: StoreProxy<M, R>; error: null }
| { status: "error"; store: null; error: Error }
export type UseMusubiRootOptions<
M extends StoreModule<R>,
R
> = MountStoreOptions<M, R> & {
unmountOnCleanup?: boolean
}
export type MusubiConnectionStatus<R> =
| { state: "connecting"; connection: null }
| { state: "ready"; connection: MusubiConnection<R> }
| { state: "error"; connection: null; error: Error }
type ConnectionProviderProps<R> = {
connection: MusubiConnection<R>
socket?: never
topic?: never
children: ReactNode
}
type SocketProviderProps = {
socket: SocketLike
topic?: string
uploaders?: Record<string, import("@musubi/client").ExternalUploader>
connection?: never
children: ReactNode
}
export type MusubiProviderProps<R> =
| ConnectionProviderProps<R>
| SocketProviderProps
export interface MusubiFactory<R> {
connect: (socket: SocketLike, options?: ConnectOptions) => Promise<MusubiConnection<R>>
MusubiProvider: FC<MusubiProviderProps<R>>
useMusubiConnection: () => MusubiConnection<R>
useMusubiConnectionStatus: () => MusubiConnectionStatus<R>
useMusubiRoot: <M extends StoreModule<R>>(
options: UseMusubiRootOptions<M, R>
) => MusubiRootMount<M, R>
useMusubiRootSuspense: <M extends StoreModule<R>>(
options: UseMusubiRootOptions<M, R>
) => StoreProxy<M, R>
useMusubiSnapshot: {
<M extends StoreModule<R>>(proxy: StoreProxy<M, R>): StoreSnapshot<M, R>
<M extends StoreModule<R>, Selected>(
proxy: StoreProxy<M, R>,
selector: (snapshot: StoreSnapshot<M, R>) => Selected,
equalityFn?: (a: Selected, b: Selected) => boolean
): Selected
}
useMusubiCommand: <M extends StoreModule<R>, K extends CommandName<M, R>>(
proxy: StoreProxy<M, R>,
name: K
) => MusubiCommandResult<M, K, R>
}
export interface MusubiCommandResult<
M extends StoreModule<R>,
K extends CommandName<M, R>,
R
> {
dispatch: (payload: CommandPayload<M, K, R>) => Promise<CommandReply<M, K, R>>
isPending: boolean
error: MusubiCommandError | null
data: CommandReply<M, K, R> | null
reset: () => void
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
/**
* Returns a Musubi API closed over the registry `R`.
*
* Usage:
*
* export const {
* connect,
* MusubiProvider,
* useMusubiRoot,
* useMusubiRootSuspense,
* useMusubiSnapshot,
* useMusubiCommand
* } = createMusubi<Musubi.Stores>()
*/
export function createMusubi<R>(): MusubiFactory<R> {
const StatusContext = createContext<MusubiConnectionStatus<R> | null>(null)
const MusubiProvider: FC<MusubiProviderProps<R>> = (props) => {
if (props.connection !== undefined && props.socket !== undefined) {
throw new Error(
"<MusubiProvider> accepts either `connection` or `socket`, not both"
)
}
if (props.connection === undefined && props.socket === undefined) {
throw new Error(
"<MusubiProvider> requires either `connection` or `socket`"
)
}
if (props.connection !== undefined) {
return (
<StatusContext.Provider value={{ state: "ready", connection: props.connection }}>
{props.children}
</StatusContext.Provider>
)
}
return <SocketProvider {...props}>{props.children}</SocketProvider>
}
const SocketProvider: FC<SocketProviderProps> = ({ socket, topic, uploaders, children }) => {
const [status, setStatus] = useState<MusubiConnectionStatus<R>>({
state: "connecting",
connection: null
})
useEffect(() => {
let cancelled = false
let liveConnection: MusubiConnection<R> | null = null
setStatus({ state: "connecting", connection: null })
const options: ConnectOptions = {}
if (topic !== undefined) options.topic = topic
if (uploaders !== undefined) options.uploaders = uploaders
baseConnect<R>(socket, options)
.then((connection) => {
if (cancelled) {
// Race: parent unmounted (or socket/topic changed) before connect
// resolved. Tear the freshly opened connection down immediately
// so we don't leak a live channel.
void connection.disconnect()
return
}
liveConnection = connection
setStatus({ state: "ready", connection })
})
.catch((cause: unknown) => {
if (cancelled) return
const error = cause instanceof Error ? cause : new Error(String(cause))
setStatus({ state: "error", connection: null, error })
})
return () => {
cancelled = true
if (liveConnection) {
const c = liveConnection
liveConnection = null
void c.disconnect()
}
}
}, [socket, topic, uploaders])
return <StatusContext.Provider value={status}>{children}</StatusContext.Provider>
}
function useMusubiConnectionStatus(): MusubiConnectionStatus<R> {
const status = useContext(StatusContext)
if (!status) {
throw new Error(
"useMusubiConnectionStatus must be used inside <MusubiProvider>"
)
}
return status
}
function useMusubiConnection(): MusubiConnection<R> {
const status = useContext(StatusContext)
if (!status) {
throw new Error(
"useMusubiConnection must be used inside <MusubiProvider> (or call useMusubiConnectionStatus() to observe connecting/error states)"
)
}
if (status.state !== "ready") {
throw new Error(
`useMusubiConnection requires a ready connection (current state: ${status.state}). Use useMusubiConnectionStatus() to observe connecting/error states.`
)
}
return status.connection
}
function connect(
socket: SocketLike,
options?: ConnectOptions
): Promise<MusubiConnection<R>> {
return baseConnect<R>(socket, options)
}
function useMusubiRoot<M extends StoreModule<R>>(
options: UseMusubiRootOptions<M, R>
): MusubiRootMount<M, R> {
const connection = useMusubiConnection()
const [state, setState] = useState<MusubiRootMount<M, R>>({
status: "loading",
store: null,
error: null
})
const paramsKey = canonicalStringify(options.params ?? null)
useEffect(() => {
let cancelled = false
const unmountOnCleanup = options.unmountOnCleanup ?? true
const mountOptions: MountStoreOptions<M, R> = {
module: options.module,
id: options.id,
...(options.params !== undefined ? { params: options.params } : {})
}
setState({ status: "loading", store: null, error: null })
const sharedMount = ensureRootMount<M, R>(connection, mountOptions)
bumpMountRef(connection, sharedMount.key)
sharedMount.promise
.then((mounted) => {
if (cancelled) return
setState({
status: "ready",
store: mounted.store as StoreProxy<M, R>,
error: null
})
})
.catch((error: unknown) => {
if (!cancelled) {
setState({
status: "error",
store: null,
error: error instanceof Error ? error : new Error(String(error))
})
}
})
return () => {
cancelled = true
releaseRootMount(connection, sharedMount.key, unmountOnCleanup)
}
// paramsKey collapses logically-equal params objects so that two
// callers passing {a:1,b:2} and {b:2,a:1} share a mount.
}, [connection, options.module, options.id, paramsKey, options.unmountOnCleanup])
return state
}
function useMusubiRootSuspense<M extends StoreModule<R>>(
options: UseMusubiRootOptions<M, R>
): StoreProxy<M, R> {
const connection = useMusubiConnection()
const unmountOnCleanup = options.unmountOnCleanup ?? true
const mountOptions: MountStoreOptions<M, R> = {
module: options.module,
id: options.id,
...(options.params !== undefined ? { params: options.params } : {})
}
// Render-phase: lookup-or-create. DO NOT bump refs here — that happens
// in the commit-phase effect below. Suspense may discard this render.
const sharedMount = ensureRootMount<M, R>(connection, mountOptions)
// Per-identity render-phase token registered with `suspenseSweepRegistry`.
// While React holds onto this fiber's hook state the token is reachable
// and the finalizer does not run. When React discards the render
// (parent unmounts, Suspense throws the subtree away) without ever
// committing, the fiber's hook state is released, the token becomes
// GC-eligible, and the finalizer eventually tears the orphaned mount
// down. This replaces the old timer-based `scheduleSuspenseOrphanSweep`
// (which raced React 19's MessageChannel commit and wedged Suspense in
// an infinite mount/unmount loop).
//
// Each identity change in render allocates a FRESH token (not a single
// fiber-stable token) so its registration can be unregistered
// independently. With a shared token, a render N+1 that supersedes a
// still-armed safety net from render N would also unregister N's
// registration when its own effect cleanup ran later — silently
// disarming the safety net on a render that hadn't committed yet.
const activeTokenRef = useRef<object | null>(null)
const activeHoldingsRef = useRef<SuspenseSweepHoldings | null>(null)
// Stable per-fiber claim id. `useId` survives StrictMode dev
// double-invoke (and Suspense retries) so all spurious
// re-registrations of the same logical mount add the SAME id to
// `shared.claimers`; `Set` deduplication keeps the bookkeeping
// honest. Different fibers (siblings using the same root) get
// distinct ids, so each owns its own claim and one's lifecycle
// doesn't poison the other's safety net.
const claimerId = useId()
if (suspenseSweepRegistry !== null) {
const previous = activeHoldingsRef.current
const currentConnection = connection as MusubiConnection<unknown>
const needsArm =
previous === null ||
previous.connection !== currentConnection ||
previous.key !== sharedMount.key ||
previous.unmountOnCleanup !== unmountOnCleanup ||
!sharedMount.claimers.has(claimerId)
if (needsArm) {
// Deliberately do NOT unregister the previous token here. A
// render-phase unregister is unsafe: a still-suspended earlier
// render (e.g. an in-flight transition for identity B) has its
// safety net armed *only* via the previous registration, and a
// later render swapping to identity C would disarm B's net
// before B ever gets a chance to commit — leaking the
// server-side root if B's mount eventually settles. Instead,
// just drop the strong reference (overwrite `activeTokenRef`
// below); GC reclaims the abandoned token and the finalizer
// sweeps the entry via its `claimerId` normally.
sharedMount.claimers.add(claimerId)
const nextToken: object = {}
const nextHoldings: SuspenseSweepHoldings = {
connection: currentConnection,
key: sharedMount.key,
unmountOnCleanup,
claimerId,
shared: sharedMount
}
suspenseSweepRegistry.register(nextToken, nextHoldings, nextToken)
activeTokenRef.current = nextToken
activeHoldingsRef.current = nextHoldings
}
}
useEffect(() => {
// Commit-phase: this render won; take a ref.
bumpMountRef(connection, sharedMount.key)
// Snapshot the token that was active when this effect set up so
// cleanup can unregister exactly this registration even if a later
// render swaps `activeTokenRef` out from under us.
const tokenAtCommit = activeTokenRef.current
const sharedAtCommit = sharedMount
return () => {
// Drop this fiber's render-phase claim — refs now owns the
// entry's lifecycle for the committed lifespan.
sharedAtCommit.claimers.delete(claimerId)
releaseRootMount(connection, sharedMount.key, unmountOnCleanup)
if (suspenseSweepRegistry !== null && tokenAtCommit !== null) {
suspenseSweepRegistry.unregister(tokenAtCommit)
if (activeTokenRef.current === tokenAtCommit) {
activeTokenRef.current = null
activeHoldingsRef.current = null
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connection, sharedMount.key, unmountOnCleanup])
if (sharedMount.failed) {
throw sharedMount.error
}
if (!sharedMount.settled) {
throw sharedMount.promise
}
return (sharedMount.value as unknown as MountedStore<M, R>).store
}
function useMusubiSnapshotImpl<M extends StoreModule<R>, Selected>(
proxy: StoreProxy<M, R>,
selector?: (snapshot: StoreSnapshot<M, R>) => Selected,
equalityFn?: (a: Selected, b: Selected) => boolean
): Selected | StoreSnapshot<M, R> {
const subscribe = useCallback((onChange: () => void) => proxy.subscribe(onChange), [proxy])
const getSnapshot = useCallback(() => proxy.snapshot(), [proxy])
const resolvedSelector =
selector ?? ((value: StoreSnapshot<M, R>) => value as unknown as Selected)
// Default to shallowEqual when a selector is supplied so callers that
// return fresh object/tuple literals don't re-render on every patch.
const resolvedEquality =
equalityFn ?? (selector ? (shallowEqual as (a: Selected, b: Selected) => boolean) : undefined)
return useSyncExternalStoreWithSelector(
subscribe,
getSnapshot,
getSnapshot,
resolvedSelector,
resolvedEquality
)
}
const useMusubiSnapshot = useMusubiSnapshotImpl as MusubiFactory<R>["useMusubiSnapshot"]
function useMusubiCommand<M extends StoreModule<R>, K extends CommandName<M, R>>(
proxy: StoreProxy<M, R>,
name: K
): MusubiCommandResult<M, K, R> {
type Reply = CommandReply<M, K, R>
const [state, setState] = useState<{
isPending: boolean
error: MusubiCommandError | null
data: Reply | null
}>({ isPending: false, error: null, data: null })
const requestIdRef = useRef(0)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => {
mountedRef.current = false
}
}, [])
const dispatch = useCallback(
async (payload: CommandPayload<M, K, R>): Promise<Reply> => {
const requestId = ++requestIdRef.current
if (mountedRef.current) {
setState({ isPending: true, error: null, data: null })
}
try {
const reply = (await proxy.dispatchCommand(name, payload)) as Reply
if (mountedRef.current && requestId === requestIdRef.current) {
setState({ isPending: false, error: null, data: reply })
}
return reply
} catch (cause) {
const error = MusubiCommandError.is(cause)
? cause
: new MusubiCommandError({
kind: "failed",
command: String(name),
storeId: [...proxy.__musubi_store_id__],
reply: { error: cause instanceof Error ? cause.message : String(cause) },
cause
})
if (mountedRef.current && requestId === requestIdRef.current) {
setState({ isPending: false, error, data: null })
}
throw error
}
},
[proxy, name]
)
const reset = useCallback(() => {
requestIdRef.current += 1
if (mountedRef.current) {
setState({ isPending: false, error: null, data: null })
}
}, [])
return { dispatch, isPending: state.isPending, error: state.error, data: state.data, reset }
}
return {
connect,
MusubiProvider,
useMusubiConnection,
useMusubiConnectionStatus,
useMusubiRoot,
useMusubiRootSuspense,
useMusubiSnapshot,
useMusubiCommand
}
}
// ---------------------------------------------------------------------------
// Shared mount ref-counting across hook callers
// ---------------------------------------------------------------------------
type SharedRootMount = {
refs: number
promise: Promise<MountedStore<never, unknown>>
settled: boolean
failed: boolean
value: MountedStore<never, unknown> | null
error: Error | null
cleanupTimer: ReturnType<typeof setTimeout> | null
// Set of `useId()` claim ids — one per fiber currently holding a
// render-phase safety net on this entry. The sweep is a no-op while
// the set is non-empty; the last live consumer (committed via
// `refs`, or a render-phase claim) is the one that ultimately
// tears the entry down. A `Set` rather than a monotonic generation
// makes this robust to React 19 StrictMode dev double-invoke and
// Suspense retries, which can register a single fiber's claim
// multiple times — `Set.add` deduplicates so a sibling consumer's
// claim isn't crowded out by spurious re-registrations.
claimers: Set<string>
}
const pendingRootMounts: WeakMap<
MusubiConnection<unknown>,
Map<string, SharedRootMount>
> = new WeakMap()
/** Internal: exposed for white-box tests, not part of the public API. */
export const __pendingRootMountsForTests = pendingRootMounts
type SuspenseSweepHoldings = {
connection: MusubiConnection<unknown>
key: string
unmountOnCleanup: boolean
// The `useId()` id of the fiber that armed this safety net. The
// sweep removes this id from `shared.claimers` first, then bails if
// anyone else (committed via `refs` or render-phase via remaining
// claimer ids) still holds the entry.
claimerId: string
// Strong reference to the exact `SharedRootMount` instance that was
// live at register time. The sweep bails when `mounts.get(key)` no
// longer matches this object — i.e. the original entry was torn
// down and replaced by a freshly-allocated one for the same key —
// so a stale finalizer can never mutate or unmount the new entry.
// Adds one extra pointer per pending registration; released when
// the finalizer fires (or sooner via `unregister` from the commit
// path).
shared: SharedRootMount
}
/**
* Core Suspense-orphan sweep. Exported for white-box unit tests because
* the host's GC schedule is not deterministic; tests drive this directly
* to cover the claimer-set and committed-claim cases without waiting on
* `globalThis.gc()`.
*/
export function __runSuspenseOrphanSweep(holdings: SuspenseSweepHoldings): void {
const { connection, key, unmountOnCleanup, claimerId, shared } = holdings
const mounts = pendingRootMounts.get(connection)
if (!mounts) return
// Stale finalizer: the entry alive at registration time is no
// longer the live one (the original was torn down and a fresh
// `SharedRootMount` was allocated for the same key). Bail before
// touching anything so the new entry's claimers/refs aren't
// disturbed.
if (mounts.get(key) !== shared) return
// Drop our render-phase claim. Idempotent — `Set.delete` on an
// already-removed id is a no-op, which covers re-arming and
// already-committed paths.
shared.claimers.delete(claimerId)
// A committed consumer claimed the entry: leave it alone, ref
// accounting owns the lifecycle now.
if (shared.refs > 0) return
// Other fibers' render-phase safety nets still hold the entry; one
// of those will eventually do the teardown.
if (shared.claimers.size > 0) return
// Wait for the in-flight mount to settle before deciding. If the GC
// fires before `shared.promise` resolves, `shared.value` is still
// null; deleting the entry now would orphan the eventual
// `MountedStore` with no one left to call `.unmount()` on it. Chain
// off the promise so the unmount tracks the settled value (mirrors
// the deferred unmount in `releaseRootMount`).
void shared.promise.then(
(mounted) => {
// Re-check between this callback being scheduled and running —
// a sibling consumer may have rearmed or committed in the gap.
if (mounts.get(key) !== shared) return
if (shared.refs > 0) return
if (shared.claimers.size > 0) return
mounts.delete(key)
if (unmountOnCleanup) void mounted.unmount()
},
() => {
if (mounts.get(key) !== shared) return
if (shared.refs > 0) return
if (shared.claimers.size > 0) return
// Failed mount: nothing to unmount, just drop the dead entry so
// a future render can retry.
mounts.delete(key)
}
)
}
/**
* FinalizationRegistry safety net for `useMusubiRootSuspense`. When a
* Suspense render starts a mount via `ensureRootMount` but never commits
* (parent unmounts before the promise settles, React discards the
* subtree, etc.), the commit-phase `useEffect` never runs so neither
* `bumpMountRef` nor `releaseRootMount` ever touch refs. The per-render
* token registered here keeps the entry's teardown reachable as long as
* the fiber's hook state is reachable; once the fiber is released the
* token becomes GC-eligible and the finalizer drops the orphaned entry
* and (when requested) unmounts the server-side root. GC timing is
* non-deterministic — the entry can linger until the next collection —
* but cleanup is guaranteed eventually, instead of waiting for the whole
* channel to terminate.
*
* `FinalizationRegistry` lands in Chrome 84 / Safari 14.1 / Node 14.6 —
* universal across the React 19 support matrix — but feature-detect at
* module load so an older host (kiosk WebViews, embedded shells) still
* imports without throwing `ReferenceError`. With no registry the
* orphaned mount lingers until the channel terminates (the pre-fix
* baseline behaviour), which is acceptable degradation; the committed
* path is unaffected because it owns its own ref-counted cleanup.
*/
const suspenseSweepRegistry: FinalizationRegistry<SuspenseSweepHoldings> | null =
typeof FinalizationRegistry !== "undefined"
? new FinalizationRegistry<SuspenseSweepHoldings>(__runSuspenseOrphanSweep)
: null
/**
* Render-phase safe: returns the existing shared mount entry or creates a new
* one. Does NOT bump refs (the caller does that on commit). Cancels any
* pending cleanup timer so a re-mount during the cleanup grace period reuses
* the existing mount.
*/
function ensureRootMount<M extends StoreModule<R>, R>(
connection: MusubiConnection<R>,
options: MountStoreOptions<M, R>
): SharedRootMount & { key: string } {
const key = rootMountKey(options)
const mounts = rootMountsFor(connection)
const existing = mounts.get(key)
if (existing) {
if (existing.cleanupTimer) {
clearTimeout(existing.cleanupTimer)
existing.cleanupTimer = null
}
return Object.assign(existing, { key })
}
const shared: SharedRootMount = {
refs: 0,
promise: Promise.resolve(null as never),
settled: false,
failed: false,
value: null,
error: null,
cleanupTimer: null,
claimers: new Set<string>()
}
shared.promise = connection
.mountStore(options)
.then((mounted) => {
shared.settled = true
shared.value = mounted as unknown as MountedStore<never, unknown>
return mounted as unknown as MountedStore<never, unknown>
})
.catch((cause: unknown) => {
shared.settled = true
shared.failed = true
shared.error = cause instanceof Error ? cause : new Error(String(cause))
// Don't delete here: the failed entry has to stay long enough for
// Suspense/effect consumers to observe it. releaseRootMount removes
// the entry once the last ref drops, so future mounts retry cleanly
// (no poison).
throw shared.error
})
// Swallow the unhandled rejection from the bare promise; callers that
// .then() / .catch() this still observe the error normally.
shared.promise.catch(() => undefined)
mounts.set(key, shared)
return Object.assign(shared, { key })
}
function bumpMountRef<R>(connection: MusubiConnection<R>, key: string): void {
const mounts = pendingRootMounts.get(connection as MusubiConnection<unknown>)
const shared = mounts?.get(key)
if (!shared) return
if (shared.cleanupTimer) {
clearTimeout(shared.cleanupTimer)
shared.cleanupTimer = null
}
shared.refs += 1
}
function releaseRootMount<R>(
connection: MusubiConnection<R>,
key: string,
unmountOnCleanup: boolean
): void {
const mounts = pendingRootMounts.get(connection as MusubiConnection<unknown>)
const shared = mounts?.get(key)
if (!mounts || !shared) {
return
}
shared.refs -= 1
if (shared.refs > 0) {
return
}
if (!unmountOnCleanup) {
mounts.delete(key)
return
}
shared.cleanupTimer = setTimeout(() => {
if (shared.refs > 0) {
return
}
mounts.delete(key)
if (!shared.failed && shared.value) {
void shared.value.unmount()
} else if (!shared.failed) {
void shared.promise.then((mounted) => mounted.unmount()).catch(() => undefined)
}
}, 0)
}
function rootMountsFor<R>(
connection: MusubiConnection<R>
): Map<string, SharedRootMount> {
const key = connection as MusubiConnection<unknown>
const existing = pendingRootMounts.get(key)
if (existing) {
return existing
}
const mounts = new Map<string, SharedRootMount>()
pendingRootMounts.set(key, mounts)
return mounts
}
function rootMountKey<M extends StoreModule<R>, R>(
options: MountStoreOptions<M, R>
): string {
return `${options.id}|${options.module}|${canonicalStringify(options.params ?? null)}`
}
function canonicalStringify(value: unknown): string {
// Mirror native JSON.stringify semantics for `undefined`:
// arrays render undefined slots as "null"; objects drop undefined-valued keys.
if (value === undefined) return "null"
if (value === null || typeof value !== "object") return JSON.stringify(value)
if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(",")}]`
const obj = value as Record<string, unknown>
const keys = Object.keys(obj)
.filter((k) => obj[k] !== undefined)
.sort()
return `{${keys
.map((k) => `${JSON.stringify(k)}:${canonicalStringify(obj[k])}`)
.join(",")}}`
}