Browse documentation

Scope & realtime

Tenant scoping, the publisher contract, and the rate limiter.

Three small contracts that sit under the rest of the framework: how a request is narrowed to a tenant, how realtime messages travel between instances, and how throttling counts.

Scope

A scope is resolved once per request and applied by the adapter to every query.

type Scope = Record<string, unknown> | null;
type Session = Record<string, unknown>;

AdminConfig.scope receives:

Prop

Type

defineAdmin({
  scope: async ({ session }) => ({ orgId: session?.user?.orgId }),
  // …
});

The returned object reaches every context as ctx.scope, and the adapter turns it into a predicate on ctx.applyScope.

The opt-out is mandatory

Once an admin declares a global scope, every resource must say what it does with it — use it, or opt out with scope: "bypass". A resource that says nothing throws FlowpanelAccessError when it is first queried, rather than serving another tenant's rows. That is a per-request failure, not a startup one: defineAdmin is pure and runs no such check.

export function assertResourceScope({ hasGlobal, resourceScope }: ScopeCheckInput): void;

Prop

Type

That check runs for you inside the request prologue. You only call it directly when writing an adapter or a route of your own.

See Multi-tenant scope for the full setup.

Publisher

The realtime transport. createPublisher builds one from AdminConfig.realtime; implement the interface yourself to sit on a bus FlowPanel does not ship.

Prop

Type

Build an SSE realtime publisher. No connection opens until the first publish.

export function createPublisher(opts: PublisherOptions): Publisher;
type PublisherOptions =
  | { driver: "memory" }
  | { driver: "redis"; url?: string; client?: RedisLike; keyPrefix?: string };

The memory driver only reaches the process that published, so a second instance never sees the message. That is fine locally and wrong in production — use the Redis driver whenever more than one instance serves the admin. The Redis driver loads ioredis on first use and throws if it is not installed; it is an optional peer dependency.

The redis driver needs one of url or client, and says which is missing if it gets neither. client takes an ioredis-compatible instance you already configured — TLS, a sentinel, a retry strategy — and FlowPanel then opens no connection of its own for publishing; it calls duplicate() only when something subscribes, because a subscribed Redis connection can run no other command. Whichever you pass, nothing connects until the first publish or subscribe: defineAdmin stays pure.

Every connection gets an error listener, because an unhandled error on a Redis connection is what takes a Node process down. It logs one line per error kind rather than one per reconnect attempt, and never rethrows.

keyPrefix namespaces channels when several admins share a Redis, and it defaults to "flowpanel" — the wire channel for resource.orders is flowpanel:resource.orders unless you set it. It never changes the logical channel name application code sees. The rate limiter has its own, unrelated default prefix: "fp:rl:".

Routing is by channel: one channel carries one payload shape, so use a distinct channel per event kind rather than multiplexing shapes onto one. On the Redis hop the message body is the payload as JSON — an empty string when there is no payload — and the channel is the Redis channel name; the { channel, payload } envelope is added by stream() on the way to the browser, and only there. A payload is serialized in every driver, so the memory driver delivers what Redis would: a Date arrives as an ISO string in development too, and a payload that cannot be JSON-encoded is rejected with the channel and the reason named, whether or not this process has a subscriber.

close() releases the connections a publisher opened; a client you passed in stays yours, and the error listener FlowPanel added to it is removed. A closed publisher stays closed: subscribe opens no connection and hands back a no-op disposer, and publish throws rather than returning quietly.

Publishing from a worker

A process that only publishes — a worker, a cron job, a script — imports @flowpanel/kit/publish and nothing else. It needs no admin config, no adapter and no Next.js, and it opens exactly one connection.

A realtime publisher for a process that only publishes — a worker, a cron job, a script. It opens one connection, or reuses the one the host passes, and it imports nothing but `ioredis` (lazily, and not at all when given a client).

export function createPublisher(opts: { redis: RedisLike | string; keyPrefix?: string; }): PublisherClient;

Prop

Type

Prop

Type

import { createPublisher } from "@flowpanel/kit/publish";

const events = createPublisher({ redis: process.env.REDIS_URL! });
await events.publish(events.channelFor("orders"), { action: "update", id });

The SSE envelope is exported next to it, for a consumer that reads the stream without going through FlowPanel's own client:

The SSE frame body, `{channel, payload}` (ADR 0014), with `payload` omitted when there is none.

export function encodeEnvelope(channel: string, payload: unknown): string;

Read an SSE frame body back. Returns `null` for anything that is not an envelope.

export function decodeEnvelope(raw: string): { channel: string; payload: unknown; } | null;

decodeEnvelope returns null for a frame that is not a channelled envelope, which is how the browser bus drops a foreign frame instead of handing a subscriber something it cannot read.

See Realtime with Redis.

The memory driver in a Redis deployment

The publisher is bound per process from config.realtime, and rebinds when those options change, including a client that is a different instance than the bound one — an HMR edit to realtime takes effect without restarting the dev server, while the same config arriving as a different object from another route bundle does not disturb subscribers that are already connected.

Binding also warns, once per process, when the driver resolves to memory while REDIS_URL or FLOWPANEL_REDIS_URL is set in the environment:

[flowpanel] realtime driver is memory while REDIS_URL is set — other instances
and workers will not receive events; set realtime: { driver: 'redis', url:
process.env.REDIS_URL } in defineAdmin

That is the one misconfiguration that looks entirely healthy: every publish succeeds, the tab that made the change refreshes, and no other instance or tab ever hears about it.

See Realtime in production.

RateLimiter

Prop

Type

function createRateLimiter(opts: RateLimitOptions): RateLimiter;

check returns false when the caller is over the limit, and the request prologue turns that into a 429. The key is derived from AuthConfig.userId for per: "user", falling back to the IP when the session carries no id.

Like the publisher, the memory driver counts per process — a limit of 100 across four instances is really 400 until you move to Redis.