ReactJunjoProvider + useJunjo

JunjoProvider + useJunjo

Wires a Junjo client through React Context. Construct the client once at app startup and hand it to <JunjoProvider client={client}> near the root of your tree; every descendant then reaches it via useJunjo().

How you construct that client depends on where the tree runs. The per-game API key (jk_<prefix>.<secret>) is a full-control secret: it authorizes every read and every mutation on your game. It can live in a server process; it can never live in a browser bundle. Server-rendered trees construct with the key directly (below); browser apps use proxy mode.

JunjoProvider

import { Junjo } from "@junjo.io/sdk";
import { JunjoProvider } from "@junjo.io/react";
 
// JUNJO_API_KEY is a server-only environment variable.
// NOT NEXT_PUBLIC_; see the warning below.
const junjo = new Junjo({
  apiKey: process.env.JUNJO_API_KEY!,
});
 
export function App({ children }: { children: React.ReactNode }) {
  return <JunjoProvider client={junjo}>{children}</JunjoProvider>;
}

This construction is for trees that run in a trusted server context: server-only rendering, internal tools on a private network, tests. If the components calling Junjo hydrate in a visitor’s browser, the bundler would have to embed the key for this code to run there, which leaks it. In that case construct with proxy mode instead.

Never put the API key in a NEXT_PUBLIC_ (or VITE_, or any other client-exposed) environment variable. Next.js inlines every NEXT_PUBLIC_ value into the JavaScript bundle at build time, so the key ships to every visitor and is readable in view-source, devtools, and the network tab. The jk_ key is not a publishable key: anyone holding it has full control of your game. The SDK detects a jk_ key being constructed while window exists and logs a console warning; treat that warning as a live leak: rotate the key and switch to proxy mode.

Props

PropTypeRequiredNotes
clientJunjoyesThe instance every descendant receives via useJunjo().
childrenReactNodeyesRendered verbatim.

The provider is a thin wrapper around Context.Provider; passing a different client to JunjoProvider re-renders consumers with the new instance. Nested providers shadow outer ones inside their subtree.

Construct the client once and reuse it across renders. A fresh new Junjo(...) on every render allocates a new HTTP helper each time and defeats any future caching layer in @junjo.io/react.

Browser apps: proxy mode

A browser client must hold no credential at all. Construct with proxy: true and point baseUrl at a route on your own backend:

import { Junjo } from "@junjo.io/sdk";
import { JunjoProvider } from "@junjo.io/react";
 
const junjo = new Junjo({
  proxy: true,
  baseUrl: "/api/junjo",
});
 
export function App({ children }: { children: React.ReactNode }) {
  return <JunjoProvider client={junjo}>{children}</JunjoProvider>;
}

In proxy mode the SDK sends every request to baseUrl with no authorization header. The constructor enforces the contract: passing an apiKey together with proxy: true throws invalid_config (the key would still ship to the browser), and omitting baseUrl throws as well (the default cloud endpoint would reject the unauthenticated requests).

Your backend forwards /api/junjo/* to the Junjo API and injects the real key server-side. A minimal Next.js App Router handler:

// app/api/junjo/[...path]/route.ts
const JUNJO_API = "https://api.junjo.io";
 
async function forward(
  req: Request,
  { params }: { params: Promise<{ path: string[] }> },
) {
  const { path } = await params;
 
  // The jk_ key is full-control, so per-user authorization belongs HERE.
  // Authenticate the visitor and forward only the routes (and user ids)
  // they are allowed to touch; a proxy that forwards everything hands
  // full control of the game to every visitor.
  // if (!(await isAllowed(req, path))) return new Response(null, { status: 403 });
 
  const search = new URL(req.url).search;
  const upstream = await fetch(`${JUNJO_API}/${path.join("/")}${search}`, {
    method: req.method,
    headers: {
      authorization: `Bearer ${process.env.JUNJO_API_KEY}`,
      "content-type": req.headers.get("content-type") ?? "application/json",
      accept: req.headers.get("accept") ?? "application/json",
    },
    body:
      req.method === "GET" || req.method === "HEAD"
        ? undefined
        : await req.text(),
  });
 
  // Mirror the upstream response headers (content-type, cache-control,
  // content-encoding...). Dropping cache-control in particular lets an
  // intermediary cache the SSE response and break streaming. Strip only
  // the hop-by-hop headers the runtime recomputes for the new response.
  const headers = new Headers(upstream.headers);
  for (const h of ["content-length", "connection", "keep-alive", "transfer-encoding"]) {
    headers.delete(h);
  }
 
  return new Response(upstream.body, {
    status: upstream.status,
    headers,
  });
}
 
export { forward as GET, forward as POST, forward as PUT, forward as PATCH, forward as DELETE };

Notes on the handler:

  • The SDK requests paths like /v1/groups, so with baseUrl: "/api/junjo" the handler receives path = ["v1", "groups"] and forwards to https://api.junjo.io/v1/groups. Query strings pass through via search.
  • Forwarding the accept header, mirroring the upstream response headers (especially cache-control: no-cache), and returning upstream.body unbuffered keeps groups.subscribe (SSE) working through the proxy, as long as your host supports streaming responses.
  • The await req.text() branch covers every non-GET method, including DELETE bodies (bans.remove attributes the unban through one).
  • The same shape works on any backend (Express, Fastify, a worker runtime). The contract is only: forward method, path, query, and body; inject authorization: Bearer <key>; stream the response back.

The authorization comment is not optional homework. Because the jk_ key bypasses per-user permission checks, the proxy is the layer that decides what the signed-in visitor may do: pin user-id path segments (and the viewer query param on friends routes) to the session’s user, allowlist the routes your UI actually needs, and reject everything else.

useJunjo

Returns the Junjo instance provided by the nearest ancestor JunjoProvider.

import { useJunjo } from "@junjo.io/react";
 
export function CreateGroupButton({ name }: { name: string }) {
  const junjo = useJunjo();
  return (
    <button
      type="button"
      onClick={async () => {
        await junjo.groups.create({ kind: "guild", name });
      }}
    >
      Create
    </button>
  );
}

Errors

Calling useJunjo() outside a <JunjoProvider> throws:

useJunjo must be used inside a <JunjoProvider>

Wrap your tree (or your test) in a provider. The error is intentional: returning null would push the same null check into every consumer.

Shared group event streams

The live hooks in the group family (useGroup, useMembers, useInvitations, useRoles) do not each open their own SSE connection. The provider owns a subscription hub that keeps at most one stream per (client, groupId) pair:

  • The first hook that mounts for a groupId opens a single groups.subscribe stream.
  • Every other hook for the same groupId under the same provider attaches to that stream. A group page rendering all four hooks costs one server connection, not four.
  • The stream is refcounted: the last attached hook to unmount closes it.

The hub shares the client’s lifecycle: passing a different client to JunjoProvider rebuilds the hub, and the hooks resubscribe through the new one. Two providers (or two clients) never share streams.

Stream teardown and JunjoStreamClosedError

Two signals end a shared stream: a stream error (a rejected subscribe handshake or a mid-stream failure) and a server-initiated clean close (a deploy, a proxy idle timeout). Either one tears the shared stream down and notifies every attached hook. Hooks are never silently re-attached to a future stream; the next subscriber for that groupId opens a fresh one.

Each hook surfaces the signal on its error field:

  • A stream error passes through unchanged (typically a JunjoError or a network error).
  • A clean close surfaces as JunjoStreamClosedError, exported from @junjo.io/react.

JunjoStreamClosedError means something different from a real failure, and its contract is:

  • The loaded snapshot is still valid; only the live feed has stopped.
  • There is no automatic reconnect. The server has no event replay, so a silently reopened stream would hide a gap the consumer cannot detect.
  • Recover by remounting the hook (or changing groupId), which refetches a fresh snapshot and opens a new stream. Calling refetch alone refreshes the snapshot but does NOT resubscribe.

Use the exported isStreamClosedError guard to branch:

import { isStreamClosedError } from "@junjo.io/react";
 
function StreamStatus({ error }: { error: Error | null }) {
  if (error === null) return null;
  if (isStreamClosedError(error)) {
    return <Banner kind="info">Live updates paused. Reload the panel to resubscribe.</Banner>;
  }
  return <Banner kind="error">{error.message}</Banner>;
}

The guard matches on the error’s name as well as its prototype, so it keeps working when a bundler ends up with duplicate copies of the package.

Testing

For component-level tests, render the component inside a provider with a stub client. The Junjo constructor accepts a fetch override, so tests can swap in a mock without touching the network.

import { Junjo } from "@junjo.io/sdk";
import { JunjoProvider } from "@junjo.io/react";
import { render } from "@testing-library/react";
import { vi } from "vitest";
 
const stub = new Junjo({
  apiKey: "test_prefix.test_secret",
  fetch: vi.fn() as unknown as typeof fetch,
});
 
render(
  <JunjoProvider client={stub}>
    <ComponentUnderTest />
  </JunjoProvider>,
);