From 152a57784cce2ff757cecdae1150a62d1bedb0c5 Mon Sep 17 00:00:00 2001 From: Filipe Cabaco Date: Tue, 15 Sep 2026 22:20:32 +0100 Subject: [PATCH] feat: realtime inspect command --- apps/cli/docs/inspect-realtime.md | 287 ++++++++++++ apps/cli/package.json | 2 + apps/cli/scripts/demo-inspect-realtime.ts | 378 ++++++++++++++++ apps/cli/src/auth/http-debug.layer.ts | 10 +- .../src/command-internal/db-target-flags.ts | 19 + .../src/commands/inspect/inspect.command.ts | 3 +- .../commands/inspect/realtime/SIDE_EFFECTS.md | 67 +++ .../realtime/broadcast/broadcast.command.ts | 58 +++ .../realtime/broadcast/broadcast.handler.ts | 92 ++++ .../broadcast/broadcast.integration.test.ts | 154 +++++++ .../inspect/realtime/check/check.command.ts | 56 +++ .../inspect/realtime/check/check.handler.ts | 170 +++++++ .../realtime/check/check.integration.test.ts | 202 +++++++++ .../inspect/realtime/listen/listen.command.ts | 106 +++++ .../inspect/realtime/listen/listen.handler.ts | 262 +++++++++++ .../listen/listen.integration.test.ts | 348 +++++++++++++++ .../realtime/presence/presence.command.ts | 49 ++ .../realtime/presence/presence.handler.ts | 140 ++++++ .../presence/presence.integration.test.ts | 121 +++++ .../realtime/realtime-session.service.ts | 27 ++ .../inspect/realtime/realtime.auth.ts | 112 +++++ .../inspect/realtime/realtime.command.ts | 17 + .../inspect/realtime/realtime.connection.ts | 237 ++++++++++ .../inspect/realtime/realtime.errors.ts | 176 ++++++++ .../inspect/realtime/realtime.events.ts | 184 ++++++++ .../realtime/realtime.events.unit.test.ts | 134 ++++++ .../inspect/realtime/realtime.flags.ts | 349 +++++++++++++++ .../realtime/realtime.flags.unit.test.ts | 208 +++++++++ .../inspect/realtime/realtime.format.ts | 122 +++++ .../inspect/realtime/realtime.layers.ts | 7 + .../inspect/realtime/realtime.prelude.ts | 223 ++++++++++ .../inspect/realtime/realtime.probe.ts | 87 ++++ .../inspect/realtime/realtime.session.ts | 417 ++++++++++++++++++ apps/cli/src/shared/output/output.layer.ts | 4 +- apps/cli/src/shared/output/types.ts | 11 + .../telemetry/__fixtures__/error-tags.txt | 16 + .../shared/telemetry/error-actionability.ts | 6 + apps/cli/tests/helpers/realtime.ts | 252 +++++++++++ pnpm-lock.yaml | 12 + 39 files changed, 5119 insertions(+), 6 deletions(-) create mode 100644 apps/cli/docs/inspect-realtime.md create mode 100644 apps/cli/scripts/demo-inspect-realtime.ts create mode 100644 apps/cli/src/commands/inspect/realtime/SIDE_EFFECTS.md create mode 100644 apps/cli/src/commands/inspect/realtime/broadcast/broadcast.command.ts create mode 100644 apps/cli/src/commands/inspect/realtime/broadcast/broadcast.handler.ts create mode 100644 apps/cli/src/commands/inspect/realtime/broadcast/broadcast.integration.test.ts create mode 100644 apps/cli/src/commands/inspect/realtime/check/check.command.ts create mode 100644 apps/cli/src/commands/inspect/realtime/check/check.handler.ts create mode 100644 apps/cli/src/commands/inspect/realtime/check/check.integration.test.ts create mode 100644 apps/cli/src/commands/inspect/realtime/listen/listen.command.ts create mode 100644 apps/cli/src/commands/inspect/realtime/listen/listen.handler.ts create mode 100644 apps/cli/src/commands/inspect/realtime/listen/listen.integration.test.ts create mode 100644 apps/cli/src/commands/inspect/realtime/presence/presence.command.ts create mode 100644 apps/cli/src/commands/inspect/realtime/presence/presence.handler.ts create mode 100644 apps/cli/src/commands/inspect/realtime/presence/presence.integration.test.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime-session.service.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.auth.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.command.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.connection.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.errors.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.events.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.events.unit.test.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.flags.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.flags.unit.test.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.format.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.layers.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.prelude.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.probe.ts create mode 100644 apps/cli/src/commands/inspect/realtime/realtime.session.ts create mode 100644 apps/cli/tests/helpers/realtime.ts diff --git a/apps/cli/docs/inspect-realtime.md b/apps/cli/docs/inspect-realtime.md new file mode 100644 index 0000000000..a08ebd1c44 --- /dev/null +++ b/apps/cli/docs/inspect-realtime.md @@ -0,0 +1,287 @@ +# `supabase inspect realtime` + +Debug a Realtime connection by joining a channel as a real client. + +Every other `inspect` family answers its question by querying Postgres. Realtime +cannot be inspected that way: whether a frame reaches a subscriber is a property +of the websocket, the API key, the channel's RLS and the replication stream +_together_. The only way to establish it is to connect and watch. + +| Command | The question it answers | +| --------------------------------------- | ---------------------------------------- | +| `check [channel]` | Why is Realtime not working? | +| `listen [channel]` | Is the frame I expect actually arriving? | +| `broadcast [payload]` | Did the server take my message? | +| `presence [channel]` | Who else is on this channel? | + +--- + +## Build and run it locally + +Running from source is the fast loop; the compiled binary is what ships. + +```sh +cd apps/cli + +# fast loop — no build step +bun src/main.ts inspect realtime check + +# the real artifact (bundles the Realtime SDK into a single binary) +pnpm run build:binary +./dist/supabase inspect realtime check +``` + +> `pnpm exec turbo run supabase#build` also builds the Go sidecar and +> `@supabase/config`. Use `build:binary` when you only want the CLI binary. + +Tests: + +```sh +cd apps/cli + +# everything for this family (61 unit + 57 integration, no network, no server) +bun --bun vitest run --project unit --project integration src/commands/inspect/realtime + +# the whole in-process suite +pnpm run test:unit && pnpm run test:integration +``` + +The handler tests run against a scripted session rather than a socket +(`RealtimeSessions`, see `realtime-session.service.ts`), so they need no +stack, no project and no credentials. + +## Point it at something + +Resolution order, per value, so a URL and a key can come from different places: + +1. `--url` / `--api-key` (`--publishable-key` is accepted as an alias) +2. `SUPABASE_URL` / `SUPABASE_PUBLISHABLE_KEY` / `SUPABASE_ANON_KEY` +3. the project the command is pointed at: `--local`, `--linked`, `--project-ref` +4. auto-detect — a local stack when one is _answering_ (it is probed, not + assumed), otherwise the linked project + +```sh +# in a project directory with a running stack: nothing to pass +supabase inspect realtime check + +# a hosted project +supabase inspect realtime check --project-ref abcdefghijklmnopqrst + +# anything, including self-hosted +supabase inspect realtime check --url https://abc.supabase.co --api-key sb_publishable_... +``` + +## For people + +```sh +# 1. Always start here. It names the step that broke. +supabase inspect realtime check + +# 2. Watch a channel. Ctrl-C to stop. +supabase inspect realtime listen room_a + +# 3. Make something arrive, from another terminal. +supabase inspect realtime broadcast room_a ping '{"n":1}' + +# 4. Watch database changes, with commit-to-receipt latency per row. +supabase inspect realtime listen --postgres public.messages --event INSERT + +# 5. See who is present; hold your own membership open with listen --as. +supabase inspect realtime presence room_a +supabase inspect realtime listen room_a --as me --duration 5m +``` + +A healthy `check` looks like this: + +``` +✔ resolve: using local stack at http://127.0.0.1:54321 +✔ reach: 127.0.0.1:54321 is serving Realtime (probe status 400). +✔ join: joined "room_a" (6ms) +``` + +A broken one names the cause instead of "transport error": + +``` +✔ resolve: using https://abc.supabase.co +✘ reach: abc.supabase.co rejected the API key. +``` + +## For agents + +The contract, in order of how much it matters: + +**Exit code is the verdict.** `0` means the command did what was asked — which +for `listen` includes receiving nothing, because an empty log is a finding, not +a failure. Non-zero means a step failed. + +**One JSON object on stdout, diagnostics on stderr.** Always parseable: + +```sh +supabase inspect realtime check --output-format json | jq '.steps' +# [{"name":"resolve","ok":true,...},{"name":"reach",...},{"name":"join",...,"durationMs":348}] +``` + +**Branch on `error.code`, never on message text.** + +| `error.code` | Meaning | Whose problem | +| ----------------------------------------- | ---------------------------------------------------------- | ------------------- | +| `RealtimeTargetNotResolvedError` | no URL/key could be found | the invocation | +| `RealtimeInvalidUrlError` | `--url` is not an http(s) URL | the invocation | +| `RealtimeInvalidOptionError` | a flag value is out of range | the invocation | +| `RealtimeKeyRejectedError` | the endpoint refused the key | the caller's config | +| `RealtimeEndpointUnhealthyError` | no server, or the gateway is down (`kind` narrows it) | the service | +| `RealtimeJoinFailedError` | the channel was refused (usually RLS on a private channel) | the policy | +| `RealtimePostgresSubscriptionFailedError` | the server refused the table subscription | the project config | +| `RealtimeBroadcastFailedError` | the send was not acknowledged | the service | + +**Tails must be bounded.** `--output-format json` emits a single object when the +tail ends, so an unbounded tail in that format could never emit anything — the +command refuses it and says so. Either bound it or stream it: + +```sh +# bounded: one object at the end +supabase inspect realtime listen room_a --duration 30s --output-format json + +# streamed: one NDJSON object per frame, as they arrive +supabase inspect realtime listen room_a --output-format stream-json \ + | jq -c 'select(.type=="realtime-frame") | {category, event, payload}' +``` + +Each `realtime-frame` carries the structured `payload` _and_ a rendered `line`, +so a consumer can inspect fields or echo what a human would have seen. + +**Choose what gets recorded** with `--categories`: the channel-side buckets +(`system`, `broadcast`, `presence`, `postgres`) are on by default, the client's +own internals (`transport`, `channel`) are opt-in, and `all` records everything. +`--server-log-level info|warning|error` sets the server's verbosity for the +connection. + +**Credentials never appear in output.** API keys, JWTs and `?apikey=` query +params are redacted before any frame is recorded, including in `stream-json` and +in `--debug` HTTP logs. + +## Debugging playbook + +What each symptom actually means, with the command that distinguishes the causes. + +### "Realtime isn't working" + +```sh +supabase inspect realtime check +``` + +`reach` fails → wrong URL/ref (404), rejected key (401/403), or an unreachable +host. `join` fails → the endpoint is fine and the server refused _this channel_, +which on a private channel means RLS. + +### "My subscription joins but no events arrive" + +The most common report, and the one with the most causes. `listen` now separates +them for you: + +```sh +supabase inspect realtime listen --postgres public.messages +``` + +- **"Subscription refused"** — the server rejected the subscription: the table is + not in the `supabase_realtime` publication, or the filter/columns are invalid. + The server's own reason is printed. +- **Confirmed, then silence** — the subscription is live. The command says so and + names the three remaining causes: nothing changed, the `--filter` excludes what + did change, or RLS does not grant the subscribing role. To rule RLS out: + +```sh +# as a real user +supabase inspect realtime listen --postgres public.messages --email you@example.com + +# ignoring RLS entirely — if this works and the above does not, it is your policy +supabase inspect realtime listen --postgres public.messages --service-role +``` + +> UPDATE and DELETE additionally need the table's replica identity to carry the +> old row. If INSERT arrives and UPDATE does not, check `REPLICA IDENTITY`. + +### "Presence is broken" + +Presence only reaches a client that asked for it, so this needs two sessions: + +```sh +# terminal 1 — holds a membership open +supabase inspect realtime listen room_a --as alice --duration 5m + +# terminal 2 — should see alice +supabase inspect realtime presence room_a +``` + +### "My private channel rejects everyone" + +Three runs isolate the layer: + +```sh +supabase inspect realtime check room_a --private # anon → expect refusal +supabase inspect realtime check room_a --private --email you@example.com # your user +supabase inspect realtime check room_a --private --service-role # ignores RLS +``` + +If only the last one joins, the problem is your `realtime.messages` policy, not +Realtime. Note that `check --service-role` says so in its output, because a pass +with an elevated key proves nothing about application users. + +### "Replay isn't replaying" + +`--replay-since` replays only messages that were **persisted** — sent through +`realtime.send()`, the REST broadcast endpoint, or a database trigger. A message +broadcast over a socket is not replayable. Replay also requires `--private`. + +```sh +supabase inspect realtime listen topic:orders --private --replay-since 5m --replay-limit 20 +``` + +Replayed frames carry `meta.replayed: true`. + +## The guided tour + +One command that runs the scenarios above in order, printing each command before +it runs it: + +```sh +cd apps/cli +bun scripts/demo-inspect-realtime.ts # uses whatever it can detect +bun scripts/demo-inspect-realtime.ts --url ... --api-key ... +bun scripts/demo-inspect-realtime.ts --table public.messages --email dev@example.com --password ... +``` + +Steps that need something it cannot provide (a table in the publication, a user +to sign in as) are skipped with an explanation rather than failed, so it is safe +to run against any target. + +## Implementation notes + +**The user token must be applied before the channel subscribes.** `RealtimeClient.connect()` +starts its own auth without awaiting it, and `RealtimeChannel.subscribe()` builds the join +payload from whatever `accessTokenValue` holds at that moment. Passing the SDK an `accessToken` +callback and subscribing immediately races that: the join goes out with no `access_token` and the +server treats the connection as anonymous, which silently defeats every RLS check — private +channels are refused and `postgres_changes` on an RLS-protected table confirms the subscription +then delivers nothing. The session awaits `setAuth(token)` before subscribing; a manually set +token also survives resubscribes. + +**A confirmed `postgres_changes` subscription is not a guarantee of delivery.** Some servers +honour `postgres_changes_options.wait` and reject the join; others answer `ok` and report the +refusal afterwards in a `system` frame. The session exposes both outcomes separately (`joined` +and `postgresSubscribed`) so a caller can tell them apart. + +**The API key travels as `?apikey=`.** That is the only place both a local Kong gateway and a +hosted deployment read it on the websocket route; an `apikey` header that works elsewhere in the +stack returns 401 here. `redactHttpUrl` keeps it out of `--debug` output. + +**A bare 500 from the probe means healthy.** This route legitimately answers 500 to a non-upgrade +request on hosted Supabase and 400 on a local stack, so only 502/503/504 are read as an outage. + +## Related + +- `SIDE_EFFECTS.md` in the command directory — files read/written, routes + called, environment variables, exit codes. +- `../../../realtime/test/e2e` in the Realtime repo — the server-side e2e suite. + It covers the same protocol from the other direction and is the better tool for + load and throughput work; this command is for debugging one connection. diff --git a/apps/cli/package.json b/apps/cli/package.json index 700d6050cf..f837a244a4 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "build:shim": "bun build src/shared/cli/bin.ts --outfile dist/supabase.js --target node", "docs:spec": "bun scripts/generate-docs-spec.ts", "dev": "pnpm exec bun src/main.ts", + "demo:realtime": "bun scripts/demo-inspect-realtime.ts", "gen:feedback-types": "pnpm dev -- gen types typescript --project-id xguihxuzqibwxjnimxev --schema public > src/shared/feedback/database.types.ts", "test": "pnpm run test:unit --coverage.enabled && pnpm run test:integration --coverage.enabled && pnpm run test:e2e", "test:unit": "pnpm exec turbo run supabase#test:unit:run --", @@ -59,6 +60,7 @@ "@supabase/config": "workspace:*", "@supabase/pg-delta": "1.0.0-alpha.52", "@supabase/pg-topo": "1.0.0-alpha.6", + "@supabase/realtime-js": "^2.116.0", "@supabase/stack": "workspace:*", "@supabase/supabase-js": "catalog:", "@tsconfig/bun": "catalog:", diff --git a/apps/cli/scripts/demo-inspect-realtime.ts b/apps/cli/scripts/demo-inspect-realtime.ts new file mode 100644 index 0000000000..7eb95f4294 --- /dev/null +++ b/apps/cli/scripts/demo-inspect-realtime.ts @@ -0,0 +1,378 @@ +const HELP = `Usage: bun scripts/demo-inspect-realtime.ts [options] + + --url Project URL. Omit to let the command detect a target. + --api-key Publishable/anon key. Omit to let the command resolve one. + --channel Channel to demo on. (default demo-room) + --table Also demo database changes against this table. + The table must be in the supabase_realtime publication. + --email Sign in, to demo RLS and private channels. + --password Password for --email. + --cli CLI to drive. (default: the built binary, else the source) + --help +`; + +interface Options { + readonly connection: ReadonlyArray; + readonly channel: string; + readonly table: string | undefined; + readonly credentials: ReadonlyArray; + readonly cli: ReadonlyArray; +} + +function parseArgs(argv: ReadonlyArray): Options | "help" { + const flags = new Map(); + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + if (token === "--help" || token === "-h") return "help"; + if (token?.startsWith("--")) { + const next = argv[i + 1]; + if (next === undefined || next.startsWith("--")) { + throw new Error(`${token} needs a value`); + } + flags.set(token.slice(2), next); + i += 1; + } + } + + const connection: Array = []; + const url = flags.get("url"); + const apiKey = flags.get("api-key"); + if (url !== undefined) connection.push("--url", url); + if (apiKey !== undefined) connection.push("--api-key", apiKey); + + const credentials: Array = []; + const email = flags.get("email"); + const password = flags.get("password"); + if (email !== undefined && password !== undefined) { + credentials.push("--email", email, "--password", password); + } + + const explicitCli = flags.get("cli"); + const builtBinary = new URL("../dist/supabase", import.meta.url).pathname; + const cli = + explicitCli !== undefined + ? [explicitCli] + : Bun.file(builtBinary).size > 0 + ? [builtBinary] + : ["bun", new URL("../src/main.ts", import.meta.url).pathname]; + + return { + connection, + channel: flags.get("channel") ?? "demo-room", + table: flags.get("table"), + credentials, + cli, + }; +} + +const DIM = ""; +const BOLD = ""; +const CYAN = ""; +const YELLOW = ""; +const RESET = ""; + +const TEXT = ["--output-format", "text"] as const; + +let step = 0; + +function heading(title: string, why: string): void { + step += 1; + console.log(`\n${BOLD}${CYAN}${step}. ${title}${RESET}`); + console.log(`${DIM} ${why}${RESET}\n`); +} + +function skip(reason: string): void { + console.log(`${YELLOW} skipped: ${reason}${RESET}`); +} + +function shown(args: ReadonlyArray): string { + const redacted = args.map((arg, index) => + index > 0 && /^(sb_(publishable|secret)_|eyJ)/.test(arg) + ? "" + : args[index - 1] === "--password" + ? "" + : arg, + ); + return `$ supabase ${redacted.join(" ")}`; +} + +async function run( + opts: Options, + args: ReadonlyArray, + render: (out: string) => void, +): Promise { + console.log(`${DIM}${shown(args)}${RESET}`); + const child = Bun.spawn([...opts.cli, ...args], { stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + const exitCode = await child.exited; + render(stripNoise(`${stdout}${stderr}`)); + console.log(`${DIM} exit ${exitCode}${RESET}`); + return exitCode; +} + +function stripNoise(text: string): string { + return text + .replaceAll(/\[[\d;?]*[A-Za-z]/g, "") + .split("\n") + .filter( + (line) => + line.trim().length > 0 && + !line.includes("A new version of Supabase CLI") && + !line.includes("We recommend updating regularly") && + !/^[│◒◐◓◑]/.test(line.trim()), + ) + .join("\n"); +} + +function indent(text: string): void { + for (const line of text.split("\n")) console.log(` ${line}`); +} + +async function startTail( + opts: Options, + args: ReadonlyArray, + ready: string, +): Promise<{ readonly output: () => Promise; readonly child: Bun.Subprocess }> { + console.log(`${DIM}${shown(args)}${DIM} ${DIM}(in another terminal)${RESET}`); + const child = Bun.spawn([...opts.cli, ...args], { stdout: "pipe", stderr: "pipe" }); + + let buffered = ""; + const reader = child.stdout.getReader(); + const decoder = new TextDecoder(); + const collected = (async () => { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffered += decoder.decode(value, { stream: true }); + } + return buffered; + })(); + + const deadline = Date.now() + 20_000; + while (!buffered.includes(ready) && Date.now() < deadline) { + await Bun.sleep(50); + } + if (!buffered.includes(ready)) { + console.log( + `${YELLOW} (the tail never reported being ready; the next step may miss it)${RESET}`, + ); + } + + return { output: () => collected, child }; +} + +async function main(): Promise { + const parsed = parseArgs(Bun.argv.slice(2)); + if (parsed === "help") { + console.log(HELP); + return 0; + } + const opts = parsed; + const target = [...opts.connection, ...opts.credentials]; + + console.log(`${BOLD}supabase inspect realtime — a guided tour${RESET}`); + console.log(`${DIM}driving: ${opts.cli.join(" ")}${RESET}`); + + heading( + "Is Realtime even reachable?", + "Walks resolve -> reach -> join and names the step that breaks. This is the one to run first, always.", + ); + const healthy = await run( + opts, + ["inspect", "realtime", "check", opts.channel, ...target, ...TEXT], + indent, + ); + if (healthy !== 0) { + console.log( + `\n${YELLOW}The target is not reachable, so the rest of the tour would fail too.${RESET}`, + ); + console.log( + `${DIM}Start a stack with \`supabase start\`, or pass --url and --api-key.${RESET}`, + ); + return 1; + } + + heading( + "A failure looks like this", + "The same command with a bad key. Notice it names the cause instead of saying 'transport error'.", + ); + await run( + opts, + [ + "inspect", + "realtime", + "check", + opts.channel, + ...opts.connection.slice(0, 2), + "--api-key", + "sb_publishable_deliberately_wrong", + ...TEXT, + ], + indent, + ); + + heading( + "Did my broadcast actually arrive?", + "One session tails the channel while another sends to it — the only way to tell a delivery problem from a publishing problem.", + ); + const tail = await startTail( + opts, + ["inspect", "realtime", "listen", opts.channel, ...target, "--duration", "12s", ...TEXT], + "TIME", + ); + await run( + opts, + [ + "inspect", + "realtime", + "broadcast", + opts.channel, + "demo-event", + JSON.stringify({ hello: "world" }), + ...target, + ...TEXT, + ], + indent, + ); + console.log(`${DIM} ...the tail received:${RESET}`); + indent(stripNoise(await tail.output())); + + heading( + "What an agent sees", + "The same tail as NDJSON: one structured frame per message on stdout, diagnostics on stderr.", + ); + const agentTail = await startTail( + opts, + [ + "inspect", + "realtime", + "listen", + opts.channel, + ...target, + "--duration", + "10s", + "--categories", + "all", + "--output-format", + "stream-json", + ], + '"type":', + ); + await run( + opts, + [ + "inspect", + "realtime", + "broadcast", + opts.channel, + "agent-event", + JSON.stringify({ n: 1 }), + ...target, + ], + () => {}, + ); + indent(stripNoise(await agentTail.output())); + console.log( + `${DIM} Pipe that to jq: ... --output-format stream-json | jq -c 'select(.type=="realtime-frame")'${RESET}`, + ); + + heading( + "Who else is on this channel?", + "One session holds a presence membership open; another reads the state as JSON.", + ); + const presenceTail = await startTail( + opts, + [ + "inspect", + "realtime", + "listen", + opts.channel, + ...target, + "--as", + "demo-watcher", + "--duration", + "12s", + ...TEXT, + ], + "TIME", + ); + await run( + opts, + ["inspect", "realtime", "presence", opts.channel, ...target, "--output-format", "json"], + indent, + ); + await presenceTail.output(); + + heading( + "Are database changes reaching subscribers?", + "The classic 'my subscription never fires'. The command separates 'the server refused the subscription' from 'the subscription is live but nothing is arriving'.", + ); + if (opts.table === undefined) { + skip("no --table given. Pass --table public.your_table to include this step."); + console.log(`${DIM} The table must be in the supabase_realtime publication, and the${RESET}`); + console.log(`${DIM} subscribing role must be able to read it under RLS.${RESET}`); + } else { + await run( + opts, + [ + "inspect", + "realtime", + "check", + opts.channel, + ...target, + "--postgres", + opts.table, + "--output-format", + "text", + ], + indent, + ); + console.log( + `${DIM} To watch changes live: supabase inspect realtime listen --postgres ${opts.table}${RESET}`, + ); + console.log( + `${DIM} ...then insert a row and watch the frame arrive with its commit lag.${RESET}`, + ); + } + + heading( + "Is it RLS, or is it broken?", + "A private channel enforces RLS. Comparing an anonymous join, a signed-in join and a service-role join tells you which layer is refusing you.", + ); + if (opts.credentials.length === 0) { + skip("no --email/--password given, so there is no user to join as."); + console.log( + `${DIM} With a user: ... check --private --email you@example.com --password ...${RESET}`, + ); + } else { + console.log(`${DIM} as an anonymous client:${RESET}`); + await run( + opts, + ["inspect", "realtime", "check", opts.channel, "--private", ...opts.connection, ...TEXT], + indent, + ); + console.log(`${DIM} as a signed-in user:${RESET}`); + await run( + opts, + ["inspect", "realtime", "check", opts.channel, "--private", ...target, ...TEXT], + indent, + ); + console.log( + `${DIM} If the user is refused too, add a realtime.messages policy for the topic.${RESET}`, + ); + console.log( + `${DIM} If --service-role joins but your user does not, the policy is the problem.${RESET}`, + ); + } + + console.log(`\n${BOLD}Where to go next${RESET}`); + console.log(`${DIM} supabase inspect realtime --help${RESET}`); + console.log(`${DIM} supabase inspect realtime listen --help # 25 flags, all optional${RESET}`); + console.log(`${DIM} apps/cli/docs/inspect-realtime.md # the written guide${RESET}`); + return 0; +} + +process.exit(await main()); diff --git a/apps/cli/src/auth/http-debug.layer.ts b/apps/cli/src/auth/http-debug.layer.ts index 437389ee38..f459b467e2 100644 --- a/apps/cli/src/auth/http-debug.layer.ts +++ b/apps/cli/src/auth/http-debug.layer.ts @@ -13,7 +13,7 @@ import { DebugLogger } from "../command-internal/debug-logger.service.ts"; * from. Logging one verbatim under `--debug` puts that in terminal scrollback * and in any CI log or bug report the output is pasted into. */ -const PRESIGNED_QUERY_KEYS = [ +const CREDENTIAL_QUERY_KEYS = [ // AWS SigV4 and SigV2 "x-amz-signature", "x-amz-credential", @@ -27,6 +27,8 @@ const PRESIGNED_QUERY_KEYS = [ "se", "signature", "token", + "apikey", + "access_token", ]; /** @@ -46,10 +48,10 @@ export function redactHttpUrl(url: string): string { if (parsed.search === "") { return url; } - const presigned = [...parsed.searchParams.keys()].some((key) => - PRESIGNED_QUERY_KEYS.includes(key.toLowerCase()), + const credentialed = [...parsed.searchParams.keys()].some((key) => + CREDENTIAL_QUERY_KEYS.includes(key.toLowerCase()), ); - if (!presigned) { + if (!credentialed) { return url; } return `${parsed.origin}${parsed.pathname}?`; diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index 74c8f86113..f652d5f842 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -57,6 +57,25 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // `--log-level` is not listed: an argv giving it a flag-shaped value fails the real parse // before any scanner here runs, so nothing can mis-consume around it. `--completions` prints // and exits before any handler runs, so these scans never see it either. + "api-key", + "publishable-key", + "secret-key", + "user-token", + "email", + "timeout", + "server-log-level", + "categories", + "postgres", + "event", + "filter", + "select", + "replay-since", + "replay-limit", + "duration", + "events", + "as", + "count", + "url", // Every other value-consuming flag declared directly across commands/ (see the doc comment // above). "add-domains", diff --git a/apps/cli/src/commands/inspect/inspect.command.ts b/apps/cli/src/commands/inspect/inspect.command.ts index 28f53c94e3..6f01b0fcb3 100644 --- a/apps/cli/src/commands/inspect/inspect.command.ts +++ b/apps/cli/src/commands/inspect/inspect.command.ts @@ -1,9 +1,10 @@ import { Command } from "effect/unstable/cli"; import { inspectDbCommand } from "./db/db.command.ts"; +import { inspectRealtimeCommand } from "./realtime/realtime.command.ts"; import { inspectReportCommand } from "./report/report.command.ts"; export const inspectCommand = Command.make("inspect").pipe( Command.withDescription("Tools to inspect your Supabase project."), Command.withShortDescription("Inspect project tools"), - Command.withSubcommands([inspectReportCommand, inspectDbCommand]), + Command.withSubcommands([inspectReportCommand, inspectDbCommand, inspectRealtimeCommand]), ); diff --git a/apps/cli/src/commands/inspect/realtime/SIDE_EFFECTS.md b/apps/cli/src/commands/inspect/realtime/SIDE_EFFECTS.md new file mode 100644 index 0000000000..4cf0718963 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/SIDE_EFFECTS.md @@ -0,0 +1,67 @@ +# `supabase inspect realtime` — side effects + +Covers `check`, `listen`, `broadcast` and `presence`. All four share one +connection-resolution path, so their reads and network calls are identical +apart from what they do once joined. + +## Files read + +| Path | Format | When | +| -------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | Local target only: resolving the API URL and the publishable/anon key. Skipped entirely when `--url` **and** a key are both supplied. | +| `/supabase/.env`, `.env.local` | dotenv | Loaded with the config, for `SUPABASE_*` overrides. `.env.local` is skipped when `SUPABASE_ENV=test`. | +| `/supabase/.temp/project-ref` | text | Linked target only, via `ProjectRefResolver`. | +| `~/.supabase/access-token` (or the native keyring) | text | Linked target only, to call the Management API. | + +## Files written + +| Path | Format | When | +| ---------------------------------------------- | ------ | --------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | Only when a project ref was resolved (the linked path). Written on success and failure alike. | +| `~/.supabase/telemetry.json` | JSON | Every invocation, on success and failure alike. | + +No credential obtained by these commands is persisted: a user token from +`--user-token` or `--email`/`--password` lives only for the duration of the +process. + +## API routes called + +| Method | Path | When | +| ------ | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/api-keys` | Linked target only. `reveal=true` is added only for `--service-role`/`--secret-key`-by-project, which needs the secret key. | +| `GET` | `/realtime/v1/websocket?apikey=…` | Every invocation of `check`; also the auto-detect probe when neither `--local` nor `--linked` was given. Plain HTTP, no upgrade — used only to classify the endpoint. | +| `POST` | `/auth/v1/token?grant_type=password` | `--email` only. Body `{email, password}`; the response's `access_token` is used as the user JWT. | +| `WSS` | `/realtime/v1/websocket` | Every command except a `check` that fails at the probe. | + +The `apikey` query param on the websocket route is the only place both a local +Kong gateway and a hosted deployment read the key, so it travels there rather +than in a header. `redactHttpUrl` redacts it from `--debug` output. + +## Environment variables consumed + +| Variable | Effect | +| --------------------------------------------------------------- | ----------------------------------------------------------- | +| `SUPABASE_URL` | Default for `--url`. | +| `SUPABASE_PUBLISHABLE_KEY`, `SUPABASE_ANON_KEY` | Default for `--api-key`, in that order. | +| `SUPABASE_SECRET_KEY`, `SUPABASE_SERVICE_ROLE_KEY` | Consulted for `--service-role`, in that order. | +| `SUPABASE_AUTH_*`, `SUPABASE_API_*` | Fold into the local config resolution, as for `status`. | +| `SUPABASE_ENV` | `test` excludes `supabase/.env.local` from the dotenv walk. | +| `SUPABASE_PROFILE`, `SUPABASE_ACCESS_TOKEN`, `SUPABASE_WORKDIR` | As for every command. | + +## Exit codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `0` | The command did what was asked. For `listen` this includes a tail that received nothing: an empty log is a finding, not a failure. | +| `1` | `check`: any step failed (endpoint unreachable, key rejected, no server, gateway 502/503/504, channel rejected, database subscription refused). Others: the channel never joined, a broadcast was not acknowledged, sign-in failed, a flag value was invalid, or `-o/--output` was passed. | + +## Notes + +- `-o/--output` is refused outright; machine output is `--output-format json|stream-json` + only (the `config diff` precedent, CLI-2156). +- In `json` and `stream-json` modes stdout carries only the payload; progress, + warnings and errors go to stderr. `listen` emits one `realtime-frame` event + per frame rather than one terminal `result`, since a tail has no natural end. +- `--service-role`/`--secret-key` bypass RLS, so a channel that joins with one + says nothing about whether an application user could join it. The text output + states this on the connection line. diff --git a/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.command.ts b/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.command.ts new file mode 100644 index 0000000000..ca9598b3b5 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.command.ts @@ -0,0 +1,58 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { inspectRealtimeCommandHandler } from "../realtime.prelude.ts"; +import { REALTIME_CONNECTION_FLAGS, REALTIME_LOG_FLAGS } from "../realtime.flags.ts"; +import { inspectRealtimeRuntimeLayer } from "../realtime.layers.ts"; +import { inspectRealtimeBroadcast } from "./broadcast.handler.ts"; + +const config = { + ...REALTIME_CONNECTION_FLAGS, + logLevel: REALTIME_LOG_FLAGS.logLevel, + ack: Flag.boolean("ack").pipe( + Flag.withDescription( + "Wait for the server to acknowledge each message before exiting. (default true)", + ), + Flag.withDefault(true), + ), + count: Flag.integer("count").pipe( + Flag.withDescription("How many copies of the message to send. (default 1)"), + Flag.withDefault(1), + ), + channel: Argument.string("channel").pipe(Argument.withDescription("Channel to broadcast on.")), + event: Argument.string("event").pipe(Argument.withDescription("Broadcast event name.")), + payload: Argument.string("payload").pipe( + Argument.withDescription("JSON payload to send. (default {})"), + Argument.withDefault("{}"), + ), +} as const; + +export type LegacyInspectRealtimeBroadcastFlags = CliCommand.Command.Config.Infer; + +export const inspectRealtimeBroadcastCommand = Command.make("broadcast", config).pipe( + Command.withDescription( + "Send a broadcast message to a Realtime channel and report whether the server took it.", + ), + Command.withShortDescription("Send a Realtime broadcast"), + Command.withExamples([ + { + command: "supabase inspect realtime broadcast room_a ping '{\"n\":1}'", + description: "Send one message and wait for the server to acknowledge it", + }, + { + command: "supabase inspect realtime broadcast room_a tick --count 10", + description: "Produce a stream of traffic for another session to observe", + }, + ]), + Command.withHandler( + inspectRealtimeCommandHandler({ + config, + telemetryFlags: (flags) => ({ + ack: flags.ack, + count: flags.count, + }), + handler: inspectRealtimeBroadcast, + }), + ), + Command.provide(inspectRealtimeRuntimeLayer(["inspect", "realtime", "broadcast"])), +); diff --git a/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.handler.ts b/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.handler.ts new file mode 100644 index 0000000000..9b3bceda6d --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.handler.ts @@ -0,0 +1,92 @@ +import { Effect } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { REALTIME_DEFAULT_CATEGORIES } from "../realtime.events.ts"; +import { parseRealtimePayload, requirePositive } from "../realtime.flags.ts"; +import { + describeRealtimeConnection, + realtimeSessionSpecOf, + runRealtimeCommand, + warnRealtimeChannelPrefix, +} from "../realtime.prelude.ts"; +import { RealtimeSessions } from "../realtime-session.service.ts"; +import type { LegacyInspectRealtimeBroadcastFlags } from "./broadcast.command.ts"; + +export const inspectRealtimeBroadcast = Effect.fn("inspect.realtime.broadcast")(function* ( + flags: LegacyInspectRealtimeBroadcastFlags, +) { + const output = yield* Output; + const sessions = yield* RealtimeSessions; + + return yield* runRealtimeCommand({ + flags, + prepare: Effect.all({ + payload: parseRealtimePayload(flags.payload), + count: requirePositive("count", flags.count), + timeout: requirePositive("timeout", flags.timeout), + }), + run: (prepared, connection) => + Effect.gen(function* () { + yield* warnRealtimeChannelPrefix(flags.channel); + + const spec = realtimeSessionSpecOf({ + connection, + flags, + channel: flags.channel, + categories: new Set(REALTIME_DEFAULT_CATEGORIES), + logLevel: flags.logLevel, + postgres: undefined, + presence: false, + broadcastSelf: false, + broadcastAck: flags.ack, + }); + + return yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* sessions.open(spec); + + const joining = + output.format === "text" + ? yield* output.task(`Joining ${flags.channel}...`) + : undefined; + yield* session.joined.pipe(Effect.tapError(() => joining?.fail() ?? Effect.void)); + yield* joining?.clear() ?? Effect.void; + + const sending = + output.format === "text" + ? yield* output.task( + flags.count === 1 + ? `Sending "${flags.event}"...` + : `Sending "${flags.event}" ${flags.count} times...`, + ) + : undefined; + + yield* Effect.forEach( + Array.from({ length: flags.count }, (_, index) => index), + () => session.broadcast(flags.event, prepared.payload), + { discard: true }, + ).pipe(Effect.tapError(() => sending?.fail() ?? Effect.void)); + + const delivered = flags.ack + ? `${flags.count === 1 ? "Message" : `${flags.count} messages`} acknowledged by the server.` + : `${flags.count === 1 ? "Message" : `${flags.count} messages`} sent (not waiting for acknowledgement).`; + + if (output.format === "text") { + yield* sending?.succeed(delivered) ?? Effect.void; + yield* output.outro(`${describeRealtimeConnection(connection)} ${delivered}`); + return; + } + + yield* output.success(delivered, { + channel: spec.channel, + url: spec.url, + source: connection.target.source, + event: flags.event, + count: flags.count, + acknowledged: flags.ack, + }); + }), + ); + }), + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.integration.test.ts b/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.integration.test.ts new file mode 100644 index 0000000000..65e240a934 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/broadcast/broadcast.integration.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { REALTIME_EXPLICIT_TARGET, setupRealtime } from "../../../../../tests/helpers/realtime.ts"; +import { inspectRealtimeBroadcast } from "./broadcast.handler.ts"; +import type { LegacyInspectRealtimeBroadcastFlags } from "./broadcast.command.ts"; + +function broadcastFlags( + overrides: Partial = {}, +): LegacyInspectRealtimeBroadcastFlags { + return { + ...REALTIME_EXPLICIT_TARGET, + logLevel: "info", + ack: true, + count: 1, + channel: "room_a", + event: "ping", + payload: '{"n":1}', + ...overrides, + }; +} + +describe("inspect realtime broadcast", () => { + it.live("sends the parsed payload once and reports the acknowledgement", () => { + const { layer, out, sessions } = setupRealtime(); + return Effect.gen(function* () { + yield* inspectRealtimeBroadcast(broadcastFlags()); + + expect(sessions.state.broadcasts).toEqual([{ event: "ping", payload: { n: 1 } }]); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: expect.stringContaining("Message acknowledged by the server."), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends --count copies", () => { + const { layer, sessions } = setupRealtime(); + return Effect.gen(function* () { + yield* inspectRealtimeBroadcast(broadcastFlags({ count: 3 })); + expect(sessions.state.broadcasts).toHaveLength(3); + }).pipe(Effect.provide(layer)); + }); + + it.live("says it is not waiting for an acknowledgement when --ack is off", () => { + const { layer, out, sessions } = setupRealtime(); + return Effect.gen(function* () { + yield* inspectRealtimeBroadcast(broadcastFlags({ ack: false })); + + expect(sessions.state.specs[0]?.broadcastAck).toBe(false); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: expect.stringContaining("not waiting for acknowledgement"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a payload that is not JSON without opening a connection", () => { + const { layer, sessions } = setupRealtime(); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeBroadcast(broadcastFlags({ payload: "{nope}" })).pipe( + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + expect(sessions.state.specs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the server does not acknowledge the message", () => { + const { layer } = setupRealtime({ broadcastFails: "was not acknowledged: timed out" }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeBroadcast(broadcastFlags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the channel cannot be joined", () => { + const { layer, sessions } = setupRealtime({ joinFails: "rejected" }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeBroadcast(broadcastFlags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(sessions.state.broadcasts).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports the send as structured data in machine format", () => { + const { layer, out } = setupRealtime({ format: "json" }); + return Effect.gen(function* () { + yield* inspectRealtimeBroadcast(broadcastFlags({ count: 2 })); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ + channel: "room_a", + event: "ping", + count: 2, + acknowledged: true, + }), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("refuses the -o machine-format flag", () => { + const { layer, sessions } = setupRealtime({ goOutput: "json" }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeBroadcast(broadcastFlags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + expect(sessions.state.specs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("signs in with --email and joins as that user", () => { + const { layer, sessions } = setupRealtime({ + httpBody: { + access_token: "header.eyJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.sig", + user: { email: "dev@example.com" }, + }, + }); + return Effect.gen(function* () { + yield* inspectRealtimeBroadcast( + broadcastFlags({ + email: Option.some("dev@example.com"), + password: Option.some("secret"), + }), + ); + + expect(sessions.state.specs[0]?.userToken).toBeDefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the sign in is rejected", () => { + const { layer } = setupRealtime({ + httpStatus: 400, + httpBody: { error_description: "Invalid login credentials" }, + }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeBroadcast( + broadcastFlags({ + email: Option.some("dev@example.com"), + password: Option.some("wrong"), + }), + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/check/check.command.ts b/apps/cli/src/commands/inspect/realtime/check/check.command.ts new file mode 100644 index 0000000000..75eaa70115 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/check/check.command.ts @@ -0,0 +1,56 @@ +import { Argument, Command } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { inspectRealtimeCommandHandler } from "../realtime.prelude.ts"; +import { + REALTIME_CONNECTION_FLAGS, + REALTIME_LOG_FLAGS, + REALTIME_POSTGRES_FLAGS, +} from "../realtime.flags.ts"; +import { inspectRealtimeRuntimeLayer } from "../realtime.layers.ts"; +import { inspectRealtimeCheck } from "./check.handler.ts"; + +const config = { + ...REALTIME_CONNECTION_FLAGS, + logLevel: REALTIME_LOG_FLAGS.logLevel, + ...REALTIME_POSTGRES_FLAGS, + channel: Argument.string("channel").pipe( + Argument.withDescription("Channel to verify a join against. (default room_a)"), + Argument.withDefault("room_a"), + ), +} as const; + +export type LegacyInspectRealtimeCheckFlags = CliCommand.Command.Config.Infer; + +export const inspectRealtimeCheckCommand = Command.make("check", config).pipe( + Command.withDescription( + "Diagnose a Realtime connection: resolve the endpoint, probe it, and join a channel.", + ), + Command.withShortDescription("Diagnose a Realtime connection"), + Command.withExamples([ + { + command: "supabase inspect realtime check", + description: "Verify the resolved project can be reached and joined", + }, + { + command: "supabase inspect realtime check --postgres public.messages", + description: "Also verify that a database-changes subscription can be established", + }, + { + command: "supabase inspect realtime check --output-format json", + description: "Machine-readable diagnosis, exiting non-zero when a step fails", + }, + ]), + Command.withHandler( + inspectRealtimeCommandHandler({ + config, + telemetryFlags: (flags) => ({ + postgres: flags.postgres, + filter: flags.filter, + select: flags.select, + }), + handler: inspectRealtimeCheck, + }), + ), + Command.provide(inspectRealtimeRuntimeLayer(["inspect", "realtime", "check"])), +); diff --git a/apps/cli/src/commands/inspect/realtime/check/check.handler.ts b/apps/cli/src/commands/inspect/realtime/check/check.handler.ts new file mode 100644 index 0000000000..91ff3a93aa --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/check/check.handler.ts @@ -0,0 +1,170 @@ +import { Duration, Effect } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { describeRealtimeTarget } from "../realtime.connection.ts"; +import { REALTIME_DEFAULT_CATEGORIES } from "../realtime.events.ts"; +import { + RealtimeEndpointUnhealthyError, + RealtimeKeyRejectedError, + RealtimePostgresSubscriptionFailedError, +} from "../realtime.errors.ts"; +import { requirePositive, resolveRealtimePostgresSpec } from "../realtime.flags.ts"; +import { + realtimeSessionSpecOf, + runRealtimeCommand, + warnRealtimeChannelPrefix, +} from "../realtime.prelude.ts"; +import { probeRealtimeEndpoint } from "../realtime.probe.ts"; +import { RealtimeSessions } from "../realtime-session.service.ts"; +import type { LegacyInspectRealtimeCheckFlags } from "./check.command.ts"; + +interface RealtimeCheckStep { + readonly name: string; + readonly ok: boolean; + readonly detail: string; + readonly durationMs?: number; +} + +export const inspectRealtimeCheck = Effect.fn("inspect.realtime.check")(function* ( + flags: LegacyInspectRealtimeCheckFlags, +) { + const output = yield* Output; + const sessions = yield* RealtimeSessions; + + return yield* runRealtimeCommand({ + flags, + prepare: Effect.all({ + timeout: requirePositive("timeout", flags.timeout), + postgres: resolveRealtimePostgresSpec({ + postgres: flags.postgres, + event: flags.event, + filter: flags.filter, + select: flags.select, + }), + }), + run: (prepared, connection) => + Effect.gen(function* () { + const steps: Array = []; + const report = (step: RealtimeCheckStep) => + Effect.gen(function* () { + steps.push(step); + if (output.format === "text") { + yield* output.raw( + `${step.ok ? "✔" : "✘"} ${step.name}: ${step.detail}${ + step.durationMs === undefined ? "" : ` (${step.durationMs}ms)` + }\n`, + ); + } + }); + + yield* report({ + name: "resolve", + ok: true, + detail: connection.target.elevated + ? `using ${describeRealtimeTarget(connection.target)} with the secret key, which bypasses RLS — this says nothing about whether an application user can join` + : `using ${describeRealtimeTarget(connection.target)}`, + }); + + const probe = yield* probeRealtimeEndpoint(connection.target); + yield* report({ + name: "reach", + ok: probe.kind === "reachable", + detail: probe.detail, + }); + + if (probe.kind !== "reachable") { + return yield* probe.kind === "unauthorized" + ? new RealtimeKeyRejectedError({ message: probe.detail }) + : new RealtimeEndpointUnhealthyError({ + kind: probe.kind, + message: probe.detail, + }); + } + + yield* warnRealtimeChannelPrefix(flags.channel); + + const spec = realtimeSessionSpecOf({ + connection, + flags, + channel: flags.channel, + categories: new Set(REALTIME_DEFAULT_CATEGORIES), + logLevel: flags.logLevel, + postgres: prepared.postgres, + presence: false, + broadcastSelf: false, + broadcastAck: false, + }); + + const attempt = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* sessions.open(spec); + + const joined = yield* Effect.timed(session.joined).pipe( + Effect.map(([elapsed]) => ({ ok: true as const, elapsed })), + Effect.catchTag("RealtimeJoinFailedError", (cause) => + Effect.succeed({ ok: false as const, message: cause.message }), + ), + ); + if (!joined.ok) return { joined } as const; + + const subscribed = yield* Effect.timed(session.postgresSubscribed).pipe( + Effect.map(([elapsed]) => ({ ok: true as const, elapsed })), + Effect.catchTag("RealtimePostgresSubscriptionFailedError", (cause) => + Effect.succeed({ ok: false as const, message: cause.message }), + ), + ); + return { joined, subscribed } as const; + }), + ); + + if (!attempt.joined.ok) { + yield* report({ name: "join", ok: false, detail: attempt.joined.message }); + return yield* new RealtimeEndpointUnhealthyError({ + kind: "handshake_refused", + message: attempt.joined.message, + }); + } + + yield* report({ + name: "join", + ok: true, + detail: `joined "${spec.channel}"`, + durationMs: Math.round(Duration.toMillis(attempt.joined.elapsed)), + }); + + const postgres = prepared.postgres; + if (postgres !== undefined) { + const subscribed = attempt.subscribed; + const what = `${postgres.event === "*" ? "all changes" : postgres.event} on ${postgres.schema}.${postgres.table}`; + + if (subscribed === undefined || !subscribed.ok) { + const detail = subscribed?.message ?? "the subscription was never confirmed"; + yield* report({ name: "subscribe", ok: false, detail }); + return yield* new RealtimePostgresSubscriptionFailedError({ message: detail }); + } + + yield* report({ + name: "subscribe", + ok: true, + detail: `server is streaming ${what}`, + durationMs: Math.round(Duration.toMillis(subscribed.elapsed)), + }); + } + + if (output.format === "text") { + yield* output.outro("Realtime is reachable and the channel joined."); + return; + } + + yield* output.success("Realtime is reachable and the channel joined.", { + channel: spec.channel, + url: spec.url, + source: connection.target.source, + ...(connection.target.projectRef === undefined + ? {} + : { projectRef: connection.target.projectRef }), + steps, + }); + }), + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/check/check.integration.test.ts b/apps/cli/src/commands/inspect/realtime/check/check.integration.test.ts new file mode 100644 index 0000000000..6426674be2 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/check/check.integration.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { REALTIME_EXPLICIT_TARGET, setupRealtime } from "../../../../../tests/helpers/realtime.ts"; +import { inspectRealtimeCheck } from "./check.handler.ts"; +import type { LegacyInspectRealtimeCheckFlags } from "./check.command.ts"; + +function checkFlags( + overrides: Partial = {}, +): LegacyInspectRealtimeCheckFlags { + return { + ...REALTIME_EXPLICIT_TARGET, + logLevel: "info", + postgres: Option.none(), + event: "*", + filter: Option.none(), + select: Option.none(), + channel: "room_a", + ...overrides, + }; +} + +describe("inspect realtime check", () => { + it.live("reports every step and succeeds when the channel joins", () => { + const { layer, out } = setupRealtime({ httpStatus: 400 }); + return Effect.gen(function* () { + yield* inspectRealtimeCheck(checkFlags()); + + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("✔ resolve"); + expect(printed).toContain("✔ reach"); + expect(printed).toContain("✔ join"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: "Realtime is reachable and the channel joined.", + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with the rejected-key diagnosis on a 401", () => { + const { layer, out } = setupRealtime({ httpStatus: 401 }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeCheck(checkFlags()).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("✘ reach"); + expect(printed).toContain("rejected the API key"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with the not-found diagnosis on a 404", () => { + const { layer, out } = setupRealtime({ httpStatus: 404 }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeCheck(checkFlags()).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.rawChunks.map((chunk) => chunk.text).join("")).toContain("No Realtime server at"); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats a bare 500 as reachable, since that is what the route answers", () => { + const { layer, out } = setupRealtime({ httpStatus: 500 }); + return Effect.gen(function* () { + yield* inspectRealtimeCheck(checkFlags()); + expect(out.rawChunks.map((chunk) => chunk.text).join("")).toContain("✔ reach"); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a gateway failure on a 503", () => { + const { layer, out } = setupRealtime({ httpStatus: 503 }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeCheck(checkFlags()).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.rawChunks.map((chunk) => chunk.text).join("")).toContain( + "not reachable through its gateway", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("never opens a connection when the endpoint is already broken", () => { + const { layer, sessions } = setupRealtime({ httpStatus: 401 }); + return Effect.gen(function* () { + yield* inspectRealtimeCheck(checkFlags()).pipe(Effect.exit); + expect(sessions.state.specs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports the join as failed when the server refuses the channel", () => { + const { layer, out } = setupRealtime({ httpStatus: 400, joinFails: "rejected" }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeCheck(checkFlags()).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(out.rawChunks.map((chunk) => chunk.text).join("")).toContain("✘ join"); + }).pipe(Effect.provide(layer)); + }); + + it.live("verifies the database subscription as its own step", () => { + const { layer, out, sessions } = setupRealtime({ httpStatus: 400 }); + return Effect.gen(function* () { + yield* inspectRealtimeCheck(checkFlags({ postgres: Option.some("public.messages") })); + + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("✔ subscribe"); + expect(printed).toContain("public.messages"); + expect(sessions.state.specs[0]?.postgres?.table).toBe("messages"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the join succeeds but the subscription is refused", () => { + const { layer, out } = setupRealtime({ + httpStatus: 400, + subscriptionFails: "Unable to subscribe to changes with given parameters", + }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeCheck( + checkFlags({ postgres: Option.some("public.nope") }), + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("✔ join"); + expect(printed).toContain("✘ subscribe"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits the per-step diagnosis in machine format on success", () => { + const { layer, out } = setupRealtime({ httpStatus: 400, format: "json" }); + return Effect.gen(function* () { + yield* inspectRealtimeCheck(checkFlags()); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ + channel: "room_a", + steps: expect.arrayContaining([expect.objectContaining({ name: "join", ok: true })]), + }), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live( + "gives a rejected key its own error, since the fix is the caller's not the service's", + () => { + const { layer, out } = setupRealtime({ httpStatus: 401, format: "json" }); + return Effect.gen(function* () { + const outcome = yield* inspectRealtimeCheck(checkFlags()).pipe( + Effect.as("joined" as const), + Effect.catchTag("RealtimeKeyRejectedError", () => Effect.succeed("key-rejected")), + Effect.catchTag("RealtimeEndpointUnhealthyError", (cause) => + Effect.succeed(`endpoint-${cause.kind}`), + ), + ); + + expect(outcome).toBe("key-rejected"); + expect(out.events).toEqual([]); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("keeps infrastructure failures under the endpoint error, with the kind", () => { + const { layer } = setupRealtime({ httpStatus: 503 }); + return Effect.gen(function* () { + const outcome = yield* inspectRealtimeCheck(checkFlags()).pipe( + Effect.as("joined" as const), + Effect.catchTag("RealtimeKeyRejectedError", () => Effect.succeed("key-rejected")), + Effect.catchTag("RealtimeEndpointUnhealthyError", (cause) => + Effect.succeed(`endpoint-${cause.kind}`), + ), + ); + + expect(outcome).toBe("endpoint-server_error"); + }).pipe(Effect.provide(layer)); + }); + + it.live("distinguishes a refused subscription from an unhealthy endpoint", () => { + const { layer } = setupRealtime({ + httpStatus: 400, + subscriptionFails: "Unable to subscribe to changes with given parameters", + }); + return Effect.gen(function* () { + const outcome = yield* inspectRealtimeCheck( + checkFlags({ postgres: Option.some("public.nope") }), + ).pipe( + Effect.as("subscribed" as const), + Effect.catchTag("RealtimePostgresSubscriptionFailedError", () => + Effect.succeed("subscription-refused" as const), + ), + Effect.catchTag("RealtimeEndpointUnhealthyError", () => + Effect.succeed("endpoint-unhealthy" as const), + ), + ); + expect(outcome).toBe("subscription-refused"); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/listen/listen.command.ts b/apps/cli/src/commands/inspect/realtime/listen/listen.command.ts new file mode 100644 index 0000000000..a720529eb7 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/listen/listen.command.ts @@ -0,0 +1,106 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { inspectRealtimeCommandHandler } from "../realtime.prelude.ts"; +import { + REALTIME_CONNECTION_FLAGS, + REALTIME_LOG_FLAGS, + REALTIME_POSTGRES_FLAGS, +} from "../realtime.flags.ts"; +import { inspectRealtimeRuntimeLayer } from "../realtime.layers.ts"; +import { inspectRealtimeListen } from "./listen.handler.ts"; + +const config = { + ...REALTIME_CONNECTION_FLAGS, + ...REALTIME_LOG_FLAGS, + presence: Flag.boolean("presence").pipe( + Flag.withDescription("Also receive presence state and changes for the channel."), + Flag.withDefault(false), + ), + as: Flag.string("as").pipe( + Flag.withDescription( + "Join the channel's presence under this name for as long as the tail runs, so other clients can see this session. Implies --presence.", + ), + Flag.optional, + ), + ...REALTIME_POSTGRES_FLAGS, + replaySince: Flag.string("replay-since").pipe( + Flag.withDescription( + "Replay persisted broadcasts from this point on join, as a timestamp or an age like 5m. Only messages persisted by realtime.send() or a database trigger replay, not ones broadcast over a socket. Requires --private.", + ), + Flag.optional, + ), + replayLimit: Flag.integer("replay-limit").pipe( + Flag.withDescription("Cap how many messages --replay-since replays."), + Flag.optional, + ), + replicationReady: Flag.boolean("replication-ready").pipe( + Flag.withDescription( + "Wait for the server to confirm the replication connection, for debugging broadcasts sent from the database.", + ), + Flag.withDefault(false), + ), + duration: Flag.string("duration").pipe( + Flag.withDescription( + "Stop after this long, e.g. 30s, 5m. Listens until interrupted when omitted.", + ), + Flag.optional, + ), + events: Flag.integer("events").pipe( + Flag.withDescription("Stop after this many frames have been recorded."), + Flag.optional, + ), + channel: Argument.string("channel").pipe( + Argument.withDescription("Channel to join. (default room_a)"), + Argument.withDefault("room_a"), + ), +} as const; + +export type LegacyInspectRealtimeListenFlags = CliCommand.Command.Config.Infer; + +export const inspectRealtimeListenCommand = Command.make("listen", config).pipe( + Command.withDescription( + "Join a Realtime channel and print every frame it receives until interrupted.", + ), + Command.withShortDescription("Tail a Realtime channel"), + Command.withExamples([ + { + command: "supabase inspect realtime listen room_a", + description: "Tail broadcast, presence and system frames on a channel", + }, + { + command: "supabase inspect realtime listen --postgres public.messages --event INSERT", + description: "Watch inserts on a table, waiting for replication to be ready", + }, + { + command: "supabase inspect realtime listen room_a --duration 30s --output-format stream-json", + description: "Record 30 seconds of frames as NDJSON for a script or an agent", + }, + { + command: + "supabase inspect realtime listen room_a --private --email dev@example.com --categories all", + description: "Join a private channel as a signed-in user and record everything", + }, + ]), + Command.withHandler( + inspectRealtimeCommandHandler({ + config, + telemetryFlags: (flags) => ({ + presence: flags.presence, + as: flags.as, + postgres: flags.postgres, + filter: flags.filter, + select: flags.select, + duration: flags.duration, + events: flags.events, + "replay-since": flags.replaySince, + "replay-limit": flags.replayLimit, + "replication-ready": flags.replicationReady, + "full-payload": flags.fullPayload, + categories: flags.categories, + }), + handler: inspectRealtimeListen, + }), + ), + Command.provide(inspectRealtimeRuntimeLayer(["inspect", "realtime", "listen"])), +); diff --git a/apps/cli/src/commands/inspect/realtime/listen/listen.handler.ts b/apps/cli/src/commands/inspect/realtime/listen/listen.handler.ts new file mode 100644 index 0000000000..3fc0ff7f7a --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/listen/listen.handler.ts @@ -0,0 +1,262 @@ +import { Duration, Effect, Option, Ref, Stream } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import type { RealtimeEvent } from "../realtime.events.ts"; +import { + formatRealtimeHeader, + formatRealtimeLine, + realtimeFrameEvent, + realtimeNoChangesHint, + realtimeSummaryLine, + type RealtimeRenderOptions, +} from "../realtime.format.ts"; +import { + parseRealtimeCategories, + parseRealtimeDuration, + parseRealtimeReplaySince, + requirePositive, + resolveRealtimePostgresSpec, +} from "../realtime.flags.ts"; +import { RealtimeInvalidOptionError } from "../realtime.errors.ts"; +import { + describeRealtimeConnection, + realtimeSessionSpecOf, + runRealtimeCommand, + warnRealtimeChannelPrefix, +} from "../realtime.prelude.ts"; +import { RealtimeSessions } from "../realtime-session.service.ts"; +import type { RealtimePostgresSpec } from "../realtime.session.ts"; +import type { LegacyInspectRealtimeListenFlags } from "./listen.command.ts"; + +type LegacyListenStop = "interrupted" | "duration" | "events"; + +const SUBSCRIPTION_VERDICT_WAIT = Duration.seconds(5); + +export const inspectRealtimeListen = Effect.fn("inspect.realtime.listen")(function* ( + flags: LegacyInspectRealtimeListenFlags, +) { + const output = yield* Output; + const sessions = yield* RealtimeSessions; + const processControl = yield* ProcessControl; + + return yield* runRealtimeCommand({ + flags, + prepare: Effect.all({ + timeout: requirePositive("timeout", flags.timeout), + events: Option.match(flags.events, { + onNone: () => Effect.succeed(Option.none()), + onSome: (limit) => + Effect.map(requirePositive("events", limit), (value) => Option.some(value)), + }), + categories: parseRealtimeCategories(flags.categories), + duration: parseRealtimeDuration(flags.duration), + replaySince: parseRealtimeReplaySince(flags.replaySince), + postgres: resolveRealtimePostgresSpec({ + postgres: flags.postgres, + event: flags.event, + filter: flags.filter, + select: flags.select, + }), + }), + run: (prepared, connection) => + Effect.gen(function* () { + if (output.format === "json" && Option.isNone(prepared.duration)) { + return yield* new RealtimeInvalidOptionError({ + message: + "listen needs --duration with --output-format json, which emits one object once the tail ends; use --output-format stream-json to stream frames as they arrive.", + }); + } + + yield* warnRealtimeChannelPrefix(flags.channel); + + const spec = realtimeSessionSpecOf({ + connection, + flags, + channel: flags.channel, + categories: prepared.categories, + logLevel: flags.logLevel, + postgres: prepared.postgres, + presence: flags.presence || Option.isSome(flags.as), + ...Option.match(flags.as, { + onNone: () => ({}), + onSome: (name) => ({ presenceKey: name }), + }), + broadcastSelf: true, + broadcastAck: false, + broadcastReplay: + prepared.replaySince === undefined + ? undefined + : { since: prepared.replaySince, limit: Option.getOrUndefined(flags.replayLimit) }, + replicationReady: flags.replicationReady, + }); + + const render: RealtimeRenderOptions = { fullPayload: flags.fullPayload }; + + return yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* sessions.open(spec); + + const joining = + output.format === "text" + ? yield* output.task(`Joining ${flags.channel}...`) + : undefined; + + yield* session.joined.pipe(Effect.tapError(() => joining?.fail() ?? Effect.void)); + yield* joining?.clear() ?? Effect.void; + + if (output.format === "text") { + yield* output.info(describeRealtimeConnection(connection)); + yield* output.info( + describeRealtimeSubscription(spec.channel, flags, prepared.postgres), + ); + yield* output.raw(`${formatRealtimeHeader()}\n`); + } + + if (prepared.postgres !== undefined) { + const subscribed = yield* session.postgresSubscribed.pipe( + Effect.as("ok" as const), + Effect.catchTag("RealtimePostgresSubscriptionFailedError", (cause) => + Effect.succeed(cause.message), + ), + Effect.timeoutOrElse({ + duration: SUBSCRIPTION_VERDICT_WAIT, + orElse: () => Effect.succeed("unconfirmed" as const), + }), + ); + + if (subscribed === "unconfirmed") { + yield* output.warn( + "The server has not confirmed the database subscription; changes may not arrive.", + ); + } else if (subscribed !== "ok") { + yield* output.warn( + `No database changes will arrive: ${subscribed}. Check that the table is added to the supabase_realtime publication.`, + ); + } + } + + if (flags.replicationReady) { + const established = yield* session.replicationEstablished.pipe( + Effect.as("ok" as const), + Effect.catchTag("RealtimePostgresSubscriptionFailedError", (cause) => + Effect.succeed(cause.message), + ), + Effect.timeoutOrElse({ + duration: SUBSCRIPTION_VERDICT_WAIT, + orElse: () => Effect.succeed("unconfirmed" as const), + }), + ); + + if (established !== "ok") { + yield* output.warn( + established === "unconfirmed" + ? "The server has not confirmed the replication connection; broadcasts sent from the database may not arrive." + : `The replication connection was not established: ${established}.`, + ); + } + } + + if (Option.isSome(flags.as)) { + yield* session.track({ + name: flags.as.value, + online_at: new Date().toISOString(), + }); + } + + const byCategory = yield* Ref.make>({}); + + const emit = (event: RealtimeEvent) => + Effect.gen(function* () { + yield* Ref.update(byCategory, (counts) => ({ + ...counts, + [event.category]: (counts[event.category] ?? 0) + 1, + })); + + if (output.format === "text") { + yield* output.raw(`${formatRealtimeLine(event, render)}\n`); + return; + } + yield* output.event(realtimeFrameEvent(event, render)); + }); + + const tail = Option.match(prepared.events, { + onNone: () => session.events, + onSome: (limit) => Stream.take(session.events, limit), + }).pipe(Stream.runForEach(emit)); + + const stop = yield* Effect.raceAll([ + tail.pipe(Effect.as("events")), + processControl + .awaitSignal(["SIGINT", "SIGTERM"]) + .pipe(Effect.as("interrupted")), + ...Option.match(prepared.duration, { + onNone: () => [], + onSome: (limit) => [ + Effect.sleep(limit).pipe(Effect.as("duration")), + ], + }), + ]); + + const counts = yield* session.counts; + const summary = { + emitted: counts.emitted, + suppressed: counts.suppressed, + byCategory: yield* Ref.get(byCategory), + }; + + if (output.format === "text") { + if (prepared.postgres !== undefined && (summary.byCategory["postgres"] ?? 0) === 0) { + yield* output.warn( + realtimeNoChangesHint({ + table: `${prepared.postgres.schema}.${prepared.postgres.table}`, + filtered: prepared.postgres.filter !== undefined, + elevated: connection.target.elevated, + asUser: connection.userToken !== undefined, + }), + ); + } + yield* output.outro(`${realtimeSummaryLine(summary)} ${describeRealtimeStop(stop)}`); + return; + } + + yield* output.success("Realtime tail complete.", { + channel: spec.channel, + url: spec.url, + source: connection.target.source, + stoppedBy: stop, + frames: summary.emitted, + suppressed: summary.suppressed, + byCategory: summary.byCategory, + }); + }), + ); + }), + }); +}); + +function describeRealtimeStop(stop: LegacyListenStop): string { + switch (stop) { + case "duration": + return "Stopped at the requested duration."; + case "events": + return "Stopped after the requested number of frames."; + default: + return "Stopped on interrupt."; + } +} + +function describeRealtimeSubscription( + channel: string, + flags: LegacyInspectRealtimeListenFlags, + postgres: RealtimePostgresSpec | undefined, +): string { + const parts = ["broadcast"]; + if (flags.presence || Option.isSome(flags.as)) parts.push("presence"); + if (postgres !== undefined) { + const what = postgres.event === "*" ? "all changes" : postgres.event; + parts.push(`${what} on ${postgres.schema}.${postgres.table}`); + } + const visibility = flags.private ? "private" : "public"; + return `Listening on ${visibility} channel "${channel}" for ${parts.join(", ")}.`; +} diff --git a/apps/cli/src/commands/inspect/realtime/listen/listen.integration.test.ts b/apps/cli/src/commands/inspect/realtime/listen/listen.integration.test.ts new file mode 100644 index 0000000000..ae74cf02cd --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/listen/listen.integration.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { + REALTIME_EXPLICIT_TARGET, + setupRealtime, + type SetupRealtimeOptions, +} from "../../../../../tests/helpers/realtime.ts"; +import { inspectRealtimeListen } from "./listen.handler.ts"; +import type { LegacyInspectRealtimeListenFlags } from "./listen.command.ts"; + +function listenFlags( + overrides: Partial = {}, +): LegacyInspectRealtimeListenFlags { + return { + ...REALTIME_EXPLICIT_TARGET, + logLevel: "info", + categories: Option.none(), + fullPayload: false, + presence: false, + as: Option.none(), + postgres: Option.none(), + event: "*", + filter: Option.none(), + select: Option.none(), + duration: Option.none(), + events: Option.none(), + replaySince: Option.none(), + replayLimit: Option.none(), + replicationReady: false, + channel: "room_a", + ...overrides, + }; +} + +const BROADCAST_FRAME = { + category: "broadcast" as const, + event: "ping", + payload: { type: "broadcast", event: "ping", payload: { n: 1 } }, +}; + +function setup(opts: SetupRealtimeOptions = {}) { + return setupRealtime({ frames: [BROADCAST_FRAME], ...opts }); +} + +describe("inspect realtime listen", () => { + it.live("prints the frames it received and a summary of what arrived", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags()); + + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("CATEGORY"); + expect(printed).toContain("broadcast"); + expect(printed).toContain("ping"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: expect.stringContaining("1 frame: broadcast 1"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("says what it subscribed to before any frame arrives", () => { + const { layer, out } = setup({ + frames: [], + subscriptionFails: undefined, + }); + return Effect.gen(function* () { + yield* inspectRealtimeListen( + listenFlags({ presence: true, postgres: Option.some("public.messages") }), + ); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining( + 'Listening on public channel "room_a" for broadcast, presence, all changes on public.messages', + ), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports a channel that never joined as a failure", () => { + const { layer } = setup({ joinFails: "rejected" }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeListen(listenFlags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("warns that no database changes will arrive when the subscription is refused", () => { + const { layer, out } = setup({ + frames: [], + subscriptionFails: "Unable to subscribe to changes with given parameters", + }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags({ postgres: Option.some("public.messages") })); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("No database changes will arrive"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("treats an empty tail as a finding rather than a failure", () => { + const { layer, out } = setup({ frames: [], suppressed: 4 }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags()); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: expect.stringContaining("No frames received (4 filtered out by --categories)"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("stops after --events frames", () => { + const { layer, out } = setup({ + frames: [BROADCAST_FRAME, { ...BROADCAST_FRAME, event: "pong" }], + }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags({ events: Option.some(1) })); + + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("ping"); + expect(printed).not.toContain("pong"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits one structured frame per event in machine format", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags()); + + expect(out.events).toContainEqual( + expect.objectContaining({ + type: "realtime-frame", + category: "broadcast", + event: "ping", + payload: BROADCAST_FRAME.payload, + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "Realtime tail complete.", + data: expect.objectContaining({ frames: 1, stoppedBy: "events" }), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects an unknown --categories value before opening a connection", () => { + const { layer, sessions } = setup(); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeListen( + listenFlags({ categories: Option.some("bogus") }), + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(sessions.state.specs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects a malformed --postgres target before opening a connection", () => { + const { layer, sessions } = setup(); + return Effect.gen(function* () { + const exit = yield* inspectRealtimeListen( + listenFlags({ postgres: Option.some("a.b.c") }), + ).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(sessions.state.specs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes the resolved subscription and identity through to the session", () => { + const { layer, sessions } = setup(); + return Effect.gen(function* () { + yield* inspectRealtimeListen( + listenFlags({ + postgres: Option.some("public.messages"), + event: "INSERT", + filter: Option.some("id=eq.1"), + select: Option.some("id, title"), + private: true, + categories: Option.some("broadcast"), + }), + ); + + const spec = sessions.state.specs[0]; + expect(spec?.privateChannel).toBe(true); + expect(spec?.postgres).toEqual({ + schema: "public", + table: "messages", + event: "INSERT", + filter: "id=eq.1", + select: ["id", "title"], + }); + expect([...(spec?.categories ?? [])]).toEqual(["broadcast"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("asks the server to replay broadcasts from the requested point", () => { + const { layer, sessions } = setup(); + return Effect.gen(function* () { + yield* inspectRealtimeListen( + listenFlags({ replaySince: Option.some("5m"), replayLimit: Option.some(20) }), + ); + + const replay = sessions.state.specs[0]?.broadcastReplay; + expect(replay?.limit).toBe(20); + expect(replay?.since).toBeLessThanOrEqual(Date.now()); + expect(replay?.since).toBeGreaterThan(Date.now() - 6 * 60_000); + }).pipe(Effect.provide(layer)); + }); + + it.live("waits for the replication connection when asked, and warns when it never comes", () => { + const { layer, out, sessions } = setup({ + frames: [], + replicationFails: "the replication connection was refused", + }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags({ replicationReady: true })); + + expect(sessions.state.specs[0]?.replicationReady).toBe(true); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("replication connection was not established"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("says when the connection bypasses RLS", () => { + const { layer, out, sessions } = setup(); + return Effect.gen(function* () { + yield* inspectRealtimeListen( + listenFlags({ secretKey: Option.some("sb_secret_testtesttest") }), + ); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "info", + message: expect.stringContaining("bypasses RLS"), + }), + ); + expect(sessions.state.specs).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("refuses an unbounded tail in json mode, which could never emit anything", () => { + const { layer, sessions } = setup({ format: "json" }); + return Effect.gen(function* () { + const outcome = yield* inspectRealtimeListen(listenFlags()).pipe( + Effect.as("ran" as const), + Effect.catchTag("RealtimeInvalidOptionError", (cause) => Effect.succeed(cause.message)), + ); + + expect(outcome).toContain("needs --duration with --output-format json"); + expect(sessions.state.specs).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("allows an unbounded tail when frames stream as they arrive", () => { + const { layer, sessions } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags()); + expect(sessions.state.specs).toHaveLength(1); + }).pipe(Effect.provide(layer)); + }); + + it.live("holds a tracked presence for the life of the tail with --as", () => { + const { layer, sessions } = setup(); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags({ as: Option.some("watcher") })); + + expect(sessions.state.specs[0]?.presence).toBe(true); + expect(sessions.state.specs[0]?.presenceKey).toBe("watcher"); + expect(sessions.state.tracked[0]).toEqual( + expect.objectContaining({ name: "watcher", online_at: expect.any(String) }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("explains a confirmed database subscription that delivered nothing", () => { + const { layer, out } = setup({ frames: [] }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags({ postgres: Option.some("public.messages") })); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("confirmed but no database changes arrived"), + }), + ); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("RLS on the table does not grant the anon role"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("does not blame RLS when already connected as a user", () => { + const { layer, out } = setup({ + frames: [], + httpBody: { + access_token: "h.eyJyb2xlIjoiYXV0aGVudGljYXRlZCJ9.s", + user: { email: "d@e.com" }, + }, + }); + return Effect.gen(function* () { + yield* inspectRealtimeListen( + listenFlags({ + postgres: Option.some("public.messages"), + email: Option.some("d@e.com"), + password: Option.some("pw"), + }), + ); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("RLS does not grant this user"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even when the join fails", () => { + const { layer, telemetry } = setup({ joinFails: "timed_out" }); + return Effect.gen(function* () { + yield* inspectRealtimeListen(listenFlags()).pipe(Effect.exit); + expect(telemetry.flushCount).toBeGreaterThan(0); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/presence/presence.command.ts b/apps/cli/src/commands/inspect/realtime/presence/presence.command.ts new file mode 100644 index 0000000000..c5107e5811 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/presence/presence.command.ts @@ -0,0 +1,49 @@ +import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { inspectRealtimeCommandHandler } from "../realtime.prelude.ts"; +import { REALTIME_CONNECTION_FLAGS, REALTIME_LOG_FLAGS } from "../realtime.flags.ts"; +import { inspectRealtimeRuntimeLayer } from "../realtime.layers.ts"; +import { inspectRealtimePresence } from "./presence.handler.ts"; + +const config = { + ...REALTIME_CONNECTION_FLAGS, + logLevel: REALTIME_LOG_FLAGS.logLevel, + as: Flag.string("as").pipe( + Flag.withDescription( + "Join the channel's presence as this name, so other subscribers can see this session.", + ), + Flag.optional, + ), + channel: Argument.string("channel").pipe( + Argument.withDescription("Channel to read presence for. (default room_a)"), + Argument.withDefault("room_a"), + ), +} as const; + +export type LegacyInspectRealtimePresenceFlags = CliCommand.Command.Config.Infer; + +export const inspectRealtimePresenceCommand = Command.make("presence", config).pipe( + Command.withDescription("Show who is present on a Realtime channel, and optionally join them."), + Command.withShortDescription("Inspect Realtime presence"), + Command.withExamples([ + { + command: "supabase inspect realtime presence room_a", + description: "Print the current presence state of a channel and exit", + }, + { + command: "supabase inspect realtime presence room_a --as debugger", + description: "Join presence under a name, so other subscribers can see this session", + }, + ]), + Command.withHandler( + inspectRealtimeCommandHandler({ + config, + telemetryFlags: (flags) => ({ + as: flags.as, + }), + handler: inspectRealtimePresence, + }), + ), + Command.provide(inspectRealtimeRuntimeLayer(["inspect", "realtime", "presence"])), +); diff --git a/apps/cli/src/commands/inspect/realtime/presence/presence.handler.ts b/apps/cli/src/commands/inspect/realtime/presence/presence.handler.ts new file mode 100644 index 0000000000..f3d73400da --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/presence/presence.handler.ts @@ -0,0 +1,140 @@ +import { Effect, Option, Stream } from "effect"; + +import { Output } from "../../../../shared/output/output.service.ts"; +import { requirePositive } from "../realtime.flags.ts"; +import type { RealtimeCategory } from "../realtime.events.ts"; +import { + describeRealtimeConnection, + realtimeSessionSpecOf, + runRealtimeCommand, + warnRealtimeChannelPrefix, +} from "../realtime.prelude.ts"; +import { RealtimeSessions } from "../realtime-session.service.ts"; +import type { LegacyInspectRealtimePresenceFlags } from "./presence.command.ts"; + +const PRESENCE_CATEGORIES: ReadonlySet = new Set([ + "presence", + "system", + "error", +]); + +export const inspectRealtimePresence = Effect.fn("inspect.realtime.presence")(function* ( + flags: LegacyInspectRealtimePresenceFlags, +) { + const output = yield* Output; + const sessions = yield* RealtimeSessions; + + return yield* runRealtimeCommand({ + flags, + prepare: Effect.all({ + timeout: requirePositive("timeout", flags.timeout), + state: Effect.succeed( + Option.map(flags.as, (name) => ({ name, online_at: new Date().toISOString() })), + ), + }), + run: (prepared, connection) => + Effect.gen(function* () { + const presenceKey = Option.getOrElse(flags.as, () => "supabase-cli"); + + yield* warnRealtimeChannelPrefix(flags.channel); + + const spec = realtimeSessionSpecOf({ + connection, + flags, + channel: flags.channel, + categories: PRESENCE_CATEGORIES, + logLevel: flags.logLevel, + postgres: undefined, + presence: true, + ...(Option.isNone(prepared.state) ? {} : { presenceKey }), + broadcastSelf: false, + broadcastAck: false, + }); + + return yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* sessions.open(spec); + + const joining = + output.format === "text" + ? yield* output.task(`Joining ${flags.channel}...`) + : undefined; + yield* session.joined.pipe(Effect.tapError(() => joining?.fail() ?? Effect.void)); + yield* joining?.clear() ?? Effect.void; + + const synced = yield* Effect.gen(function* () { + if (Option.isNone(prepared.state)) { + return yield* Stream.runHead( + session.events.pipe( + Stream.filter( + (event) => event.category === "presence" && event.event === "sync", + ), + ), + ).pipe(Effect.map(Option.isSome)); + } + + yield* session.track(prepared.state.value); + yield* session.events.pipe( + Stream.filter((event) => event.category === "presence"), + Stream.runForEachWhile(() => + Effect.map(session.presenceState, (state) => state[presenceKey] === undefined), + ), + ); + return true; + }).pipe( + Effect.timeoutOrElse({ + duration: spec.joinTimeout, + orElse: () => Effect.succeed(false), + }), + ); + + const state = yield* session.presenceState; + const members = Object.entries(state).map(([key, entries]) => ({ + key, + count: entries.length, + entries, + })); + + if (output.format === "text") { + yield* output.info(describeRealtimeConnection(connection)); + if (!synced) { + yield* output.warn( + Option.isNone(prepared.state) + ? "The server sent no presence state; the channel may not have presence enabled." + : `This session's presence did not appear under "${presenceKey}" within ${flags.timeout}s; the state below may be incomplete.`, + ); + } + yield* output.raw(`${legacyFormatPresenceState(members)}\n`); + } + + if (output.format !== "text") { + yield* output.success("Presence read.", { + channel: spec.channel, + url: spec.url, + source: connection.target.source, + tracked: Option.isSome(prepared.state), + members: members.length, + presence: state, + }); + return; + } + + yield* output.outro( + members.length === 0 + ? `Nobody is present on "${spec.channel}".` + : `${members.length} member${members.length === 1 ? "" : "s"} present on "${spec.channel}".`, + ); + }), + ); + }), + }); +}); + +function legacyFormatPresenceState( + members: ReadonlyArray<{ readonly key: string; readonly count: number }>, +): string { + if (members.length === 0) return "(nobody present)"; + return members + .map((member) => `${member.key} ${member.count} connection${member.count === 1 ? "" : "s"}`) + .join("\n"); +} diff --git a/apps/cli/src/commands/inspect/realtime/presence/presence.integration.test.ts b/apps/cli/src/commands/inspect/realtime/presence/presence.integration.test.ts new file mode 100644 index 0000000000..1fd9fa6bbf --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/presence/presence.integration.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { REALTIME_EXPLICIT_TARGET, setupRealtime } from "../../../../../tests/helpers/realtime.ts"; +import { inspectRealtimePresence } from "./presence.handler.ts"; +import type { LegacyInspectRealtimePresenceFlags } from "./presence.command.ts"; + +function presenceFlags( + overrides: Partial = {}, +): LegacyInspectRealtimePresenceFlags { + return { + ...REALTIME_EXPLICIT_TARGET, + logLevel: "info", + as: Option.none(), + channel: "room_a", + ...overrides, + }; +} + +const SYNC_FRAME = { category: "presence" as const, event: "sync", payload: { state: {} } }; + +describe("inspect realtime presence", () => { + it.live("prints an empty channel as nobody present", () => { + const { layer, out } = setupRealtime({ frames: [SYNC_FRAME] }); + return Effect.gen(function* () { + yield* inspectRealtimePresence(presenceFlags()); + + expect(out.rawChunks.map((chunk) => chunk.text).join("")).toContain("(nobody present)"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: 'Nobody is present on "room_a".', + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("lists each member and how many connections it has", () => { + const { layer, out } = setupRealtime({ + frames: [SYNC_FRAME], + presence: { + debugger: [{ presence_ref: "r1", name: "debugger" }], + app: [{ presence_ref: "r2" }, { presence_ref: "r3" }], + }, + }); + return Effect.gen(function* () { + yield* inspectRealtimePresence(presenceFlags()); + + const printed = out.rawChunks.map((chunk) => chunk.text).join(""); + expect(printed).toContain("debugger 1 connection"); + expect(printed).toContain("app 2 connections"); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "outro", + message: '2 members present on "room_a".', + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("tracks a named state with --as and enables presence for the session", () => { + const { layer, sessions } = setupRealtime({ + frames: [SYNC_FRAME], + presence: { debugger: [{ presence_ref: "r1", name: "debugger" }] }, + }); + return Effect.gen(function* () { + yield* inspectRealtimePresence(presenceFlags({ as: Option.some("debugger") })); + + expect(sessions.state.tracked[0]).toEqual( + expect.objectContaining({ name: "debugger", online_at: expect.any(String) }), + ); + expect(sessions.state.specs[0]?.presence).toBe(true); + expect(sessions.state.specs[0]?.presenceKey).toBe("debugger"); + }).pipe(Effect.provide(layer)); + }); + + it.live("warns when the server sends no presence state at all", () => { + const { layer, out } = setupRealtime({ frames: [] }); + return Effect.gen(function* () { + yield* inspectRealtimePresence(presenceFlags()); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "warn", + message: expect.stringContaining("no presence state"), + }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when the channel cannot be joined", () => { + const { layer } = setupRealtime({ joinFails: "rejected" }); + return Effect.gen(function* () { + const exit = yield* inspectRealtimePresence(presenceFlags()).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("reports the presence state as structured data in machine format", () => { + const { layer, out } = setupRealtime({ + frames: [SYNC_FRAME], + presence: { debugger: [{ presence_ref: "r1", name: "debugger" }] }, + format: "json", + }); + return Effect.gen(function* () { + yield* inspectRealtimePresence(presenceFlags()); + + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: expect.objectContaining({ + channel: "room_a", + members: 1, + tracked: false, + presence: { debugger: [{ presence_ref: "r1", name: "debugger" }] }, + }), + }), + ); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/realtime-session.service.ts b/apps/cli/src/commands/inspect/realtime/realtime-session.service.ts new file mode 100644 index 0000000000..0fa5e47a64 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime-session.service.ts @@ -0,0 +1,27 @@ +import { Context, Effect, Layer, type Scope } from "effect"; + +import type { RealtimeInvalidUrlError, RealtimeJoinFailedError } from "./realtime.errors.ts"; +import { + realtimeSession, + type RealtimeSession, + type RealtimeSessionSpec, +} from "./realtime.session.ts"; + +interface RealtimeSessionsShape { + readonly open: ( + spec: RealtimeSessionSpec, + ) => Effect.Effect< + RealtimeSession, + RealtimeInvalidUrlError | RealtimeJoinFailedError, + Scope.Scope + >; +} + +export class RealtimeSessions extends Context.Service()( + "supabase/realtime/RealtimeSessions", +) {} + +export const realtimeSessionsLayer = Layer.succeed( + RealtimeSessions, + RealtimeSessions.of({ open: realtimeSession }), +); diff --git a/apps/cli/src/commands/inspect/realtime/realtime.auth.ts b/apps/cli/src/commands/inspect/realtime/realtime.auth.ts new file mode 100644 index 0000000000..077dfeab09 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.auth.ts @@ -0,0 +1,112 @@ +import { Effect, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import { redactRealtimeText } from "./realtime.events.ts"; +import { RealtimeSignInFailedError } from "./realtime.errors.ts"; + +interface RealtimeIdentity { + readonly token: Redacted.Redacted; + readonly subject: string; + readonly role: string | undefined; +} + +export const realtimeSignIn = Effect.fnUntraced(function* (opts: { + readonly url: string; + readonly apiKey: Redacted.Redacted; + readonly email: string; + readonly password: Redacted.Redacted; +}) { + const client = yield* HttpClient.HttpClient; + const endpoint = `${opts.url.replace(/\/+$/, "")}/auth/v1/token?grant_type=password`; + + const request = HttpClientRequest.post(endpoint).pipe( + HttpClientRequest.setHeader("apikey", Redacted.value(opts.apiKey)), + HttpClientRequest.bodyJsonUnsafe({ + email: opts.email, + password: Redacted.value(opts.password), + }), + ); + + const response = yield* client.execute(request).pipe( + Effect.catch((cause) => + Effect.fail( + new RealtimeSignInFailedError({ + message: `could not reach the Auth service at ${opts.url}: ${redactRealtimeText(String(cause))}`, + }), + ), + ), + ); + + const body = yield* response.json.pipe(Effect.catch(() => Effect.succeed({}))); + + if (response.status !== 200) { + return yield* new RealtimeSignInFailedError({ + message: `sign in failed (${response.status}): ${legacyAuthErrorText(body) ?? "no reason given"}`, + }); + } + + const token = legacyStringField(body, "access_token"); + if (token === undefined) { + return yield* new RealtimeSignInFailedError({ + message: "the Auth service returned no access token", + }); + } + + const claims = legacyDecodeJwtClaims(token); + return { + token: Redacted.make(token), + subject: legacyUserEmail(body) ?? legacyStringField(claims, "sub") ?? "user", + role: legacyStringField(claims, "role"), + } satisfies RealtimeIdentity; +}); + +export function realtimeTokenIdentity(token: Redacted.Redacted): { + readonly subject: string; + readonly role: string | undefined; + readonly expired: boolean; +} { + const claims = legacyDecodeJwtClaims(Redacted.value(token)); + const expiry = claims?.["exp"]; + return { + subject: + legacyStringField(claims, "email") ?? legacyStringField(claims, "sub") ?? "unreadable token", + role: legacyStringField(claims, "role"), + expired: typeof expiry === "number" && expiry * 1000 < Date.now(), + }; +} + +function legacyDecodeJwtClaims(token: string): Record | undefined { + const segments = token.split("."); + if (segments.length < 2) return undefined; + const payload = segments[1]; + if (payload === undefined) return undefined; + try { + const decoded = Buffer.from(payload, "base64url").toString("utf8"); + const parsed: unknown = JSON.parse(decoded); + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function legacyStringField(source: unknown, key: string): string | undefined { + if (typeof source !== "object" || source === null) return undefined; + const value = (source as Record)[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function legacyUserEmail(body: unknown): string | undefined { + if (typeof body !== "object" || body === null) return undefined; + return legacyStringField((body as Record)["user"], "email"); +} + +function legacyAuthErrorText(body: unknown): string | undefined { + return ( + legacyStringField(body, "error_description") ?? + legacyStringField(body, "msg") ?? + legacyStringField(body, "error") + ); +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.command.ts b/apps/cli/src/commands/inspect/realtime/realtime.command.ts new file mode 100644 index 0000000000..c629e3d14c --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.command.ts @@ -0,0 +1,17 @@ +import { Command } from "effect/unstable/cli"; + +import { inspectRealtimeBroadcastCommand } from "./broadcast/broadcast.command.ts"; +import { inspectRealtimeCheckCommand } from "./check/check.command.ts"; +import { inspectRealtimeListenCommand } from "./listen/listen.command.ts"; +import { inspectRealtimePresenceCommand } from "./presence/presence.command.ts"; + +export const inspectRealtimeCommand = Command.make("realtime").pipe( + Command.withDescription("Debug Realtime connections by joining a channel as a client."), + Command.withShortDescription("Debug Realtime connections"), + Command.withSubcommands([ + inspectRealtimeCheckCommand, + inspectRealtimeListenCommand, + inspectRealtimeBroadcastCommand, + inspectRealtimePresenceCommand, + ]), +); diff --git a/apps/cli/src/commands/inspect/realtime/realtime.connection.ts b/apps/cli/src/commands/inspect/realtime/realtime.connection.ts new file mode 100644 index 0000000000..d7f3c4bc7f --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.connection.ts @@ -0,0 +1,237 @@ +import { Effect, Option, Redacted } from "effect"; + +import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; +import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; +import { mapTenantApiKeysError } from "../../../command-internal/get-tenant-api-keys.ts"; +import { loadLocalProjectContext } from "../../../command-internal/local-project-context.ts"; +import { resolveLocalConfigValues } from "../../../command-internal/local-config-values.ts"; +import { extractServiceKeys } from "../../../command-internal/tenant-keys.ts"; +import { probeRealtimeEndpoint } from "./realtime.probe.ts"; +import { + RealtimeApiKeysNetworkError, + RealtimeInvalidUrlError, + RealtimeApiKeysStatusError, + RealtimeConfigError, + RealtimeMissingApiKeyError, + RealtimeTargetNotResolvedError, +} from "./realtime.errors.ts"; + +type RealtimeTargetSource = "flag" | "env" | "local" | "linked"; + +export interface RealtimeTarget { + readonly url: string; + readonly apiKey: Redacted.Redacted; + readonly source: RealtimeTargetSource; + readonly projectRef: string | undefined; + readonly elevated: boolean; +} + +export type RealtimeTargetChoice = "local" | "linked"; + +interface RealtimeTargetRequest { + readonly url: Option.Option; + readonly apiKey: Option.Option; + readonly secretKey: Option.Option; + readonly serviceRole: boolean; + readonly projectRef: Option.Option; + readonly choice: RealtimeTargetChoice | undefined; +} + +const URL_ENV_KEY = "SUPABASE_URL"; +const KEY_ENV_KEYS = ["SUPABASE_PUBLISHABLE_KEY", "SUPABASE_ANON_KEY"] as const; +const SECRET_KEY_ENV_KEYS = ["SUPABASE_SECRET_KEY", "SUPABASE_SERVICE_ROLE_KEY"] as const; + +const legacyValidateRealtimeUrl = (url: string) => + Effect.try({ + try: () => { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`expected an http:// or https:// URL, got "${parsed.protocol}"`); + } + return url; + }, + catch: (cause) => + new RealtimeInvalidUrlError({ + message: `invalid Realtime URL "${url}": ${cause instanceof Error ? cause.message : String(cause)}. Pass the project URL, e.g. https://abc.supabase.co or http://127.0.0.1:54321.`, + }), + }); + +function legacyEnvValue(key: string): string | undefined { + const value = process.env[key]; + return value !== undefined && value.length > 0 ? value : undefined; +} + +export const resolveRealtimeTarget = Effect.fnUntraced(function* (request: RealtimeTargetRequest) { + const flagUrl = Option.getOrUndefined(request.url); + const flagSecret = + Option.getOrUndefined(request.secretKey) ?? + (request.serviceRole + ? SECRET_KEY_ENV_KEYS.map(legacyEnvValue).find((value) => value !== undefined) + : undefined); + const flagKey = flagSecret ?? Option.getOrUndefined(request.apiKey); + const envUrl = legacyEnvValue(URL_ENV_KEY); + const envKey = KEY_ENV_KEYS.map(legacyEnvValue).find((value) => value !== undefined); + + const url = flagUrl ?? envUrl; + const apiKey = flagKey ?? envKey; + const elevated = flagSecret !== undefined; + + if (url !== undefined && apiKey !== undefined) { + yield* legacyValidateRealtimeUrl(url); + return { + url, + apiKey: Redacted.make(apiKey), + source: flagUrl !== undefined && flagKey !== undefined ? "flag" : "env", + projectRef: undefined, + elevated, + } satisfies RealtimeTarget; + } + + const resolved = yield* resolveRealtimeProject(request); + const effectiveUrl = url ?? resolved.url; + yield* legacyValidateRealtimeUrl(effectiveUrl); + + return { + url: effectiveUrl, + apiKey: apiKey === undefined ? resolved.apiKey : Redacted.make(apiKey), + source: resolved.source, + projectRef: resolved.projectRef, + elevated: elevated || resolved.elevated, + } satisfies RealtimeTarget; +}); + +const resolveRealtimeProject = Effect.fnUntraced(function* (request: RealtimeTargetRequest) { + if (request.choice === "local") return yield* legacyLocalRealtimeTarget(request.serviceRole); + if (request.choice === "linked" || Option.isSome(request.projectRef)) { + return yield* legacyLinkedRealtimeTarget(request.projectRef, request.serviceRole); + } + + const local = yield* legacyLocalRealtimeTarget(request.serviceRole).pipe( + Effect.catchTags({ + RealtimeConfigError: () => Effect.succeed(undefined), + RealtimeTargetNotResolvedError: () => Effect.succeed(undefined), + }), + ); + + if (local !== undefined) { + const probe = yield* probeRealtimeEndpoint(local); + if (probe.kind === "reachable") return local; + + return yield* legacyLinkedRealtimeTarget(request.projectRef, request.serviceRole).pipe( + Effect.catch((cause) => + Effect.fail( + new RealtimeTargetNotResolvedError({ + message: [ + `the local stack at ${local.url} is not serving Realtime (${probe.detail})`, + `and no linked project could be used (${cause.message})`, + "start the stack with `supabase start`, or pass --url and --api-key", + ].join("; "), + }), + ), + ), + ); + } + + return yield* legacyLinkedRealtimeTarget(request.projectRef, request.serviceRole); +}); + +const legacyLocalRealtimeTarget = Effect.fnUntraced(function* (serviceRole: boolean) { + const cliSettings = yield* CommandSettings; + const context = yield* loadLocalProjectContext( + cliSettings.workdir, + (message) => new RealtimeConfigError({ message }), + ); + + if (context.loaded === null) { + return yield* new RealtimeTargetNotResolvedError({ + message: `no supabase/config.toml under ${cliSettings.workdir}, so there is no local stack to inspect`, + }); + } + + const values = yield* Effect.try({ + try: () => + resolveLocalConfigValues( + context.config, + context.hostname, + cliSettings.workdir, + context.projectEnvValues, + context.loaded?.document, + ), + catch: (cause) => + new RealtimeConfigError({ + message: cause instanceof Error ? cause.message : String(cause), + }), + }); + + const publishable = values.publishableKey.length > 0 ? values.publishableKey : values.anonKey; + const secret = values.secretKey.length > 0 ? values.secretKey : values.serviceRoleKey; + const key = serviceRole ? secret : publishable; + + if (key.length === 0) { + return yield* new RealtimeMissingApiKeyError({ + message: serviceRole + ? "the local config produced neither a secret key nor a service-role key" + : "the local config produced neither a publishable key nor an anon key", + }); + } + + return { + url: values.apiUrl, + apiKey: Redacted.make(key), + source: "local", + projectRef: undefined, + elevated: serviceRole, + } satisfies RealtimeTarget; +}); + +const legacyLinkedRealtimeTarget = Effect.fnUntraced(function* ( + projectRef: Option.Option, + serviceRole: boolean, +) { + const cliSettings = yield* CommandSettings; + const resolver = yield* ProjectRefResolver; + const ref = yield* resolver.resolve(projectRef); + + const api = yield* (yield* CommandPlatformApiFactory).make; + const keys = extractServiceKeys( + yield* api.v1.getProjectApiKeys(serviceRole ? { ref, reveal: true } : { ref }).pipe( + Effect.catch( + mapTenantApiKeysError({ + networkError: RealtimeApiKeysNetworkError, + statusError: RealtimeApiKeysStatusError, + }), + ), + ), + ); + + const key = serviceRole ? keys.serviceRole : keys.anon; + if (key.length === 0) { + return yield* new RealtimeMissingApiKeyError({ + message: serviceRole + ? `project ${ref} exposes no secret or service-role key to connect with` + : `project ${ref} exposes no publishable or anon key to connect with`, + }); + } + + return { + url: `https://${ref}.${cliSettings.projectHost}`, + apiKey: Redacted.make(key), + source: "linked", + projectRef: ref, + elevated: serviceRole, + } satisfies RealtimeTarget; +}); + +export function describeRealtimeTarget(target: RealtimeTarget): string { + switch (target.source) { + case "local": + return `local stack at ${target.url}`; + case "linked": + return `project ${target.projectRef ?? "?"} at ${target.url}`; + case "env": + return `${target.url} (from the environment)`; + default: + return target.url; + } +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.errors.ts b/apps/cli/src/commands/inspect/realtime/realtime.errors.ts new file mode 100644 index 0000000000..e3255cc9c5 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.errors.ts @@ -0,0 +1,176 @@ +import { Data } from "effect"; + +import { + actionability, + type CliErrorActionabilityDeclaration, + ErrorActionabilityId, + statusCodeActionability, +} from "../../../shared/telemetry/error-actionability.ts"; + +export type RealtimeJoinFailureReason = "rejected" | "timed_out" | "closed"; + +export type RealtimeEndpointFailureKind = + | "not_found" + | "unauthorized" + | "server_error" + | "unreachable" + | "handshake_refused"; + +export class RealtimeTargetNotResolvedError extends Data.TaggedError( + "RealtimeTargetNotResolvedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class RealtimeMutuallyExclusiveFlagsError extends Data.TaggedError( + "RealtimeMutuallyExclusiveFlagsError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class RealtimeOutputFlagUnsupportedError extends Data.TaggedError( + "RealtimeOutputFlagUnsupportedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class RealtimeInvalidUrlError extends Data.TaggedError("RealtimeInvalidUrlError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.invalidInput, fingerprint_suffix: "invalid_url" }; + } +} + +export class RealtimeInvalidPayloadError extends Data.TaggedError("RealtimeInvalidPayloadError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.invalidInput, fingerprint_suffix: "invalid_content" }; + } +} + +export class RealtimeInvalidOptionError extends Data.TaggedError("RealtimeInvalidOptionError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.invalidInput, fingerprint_suffix: "bad_argument" }; + } +} + +export class RealtimeConfigError extends Data.TaggedError("RealtimeConfigError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.invalidConfig; + } +} + +export class RealtimeApiKeysNetworkError extends Data.TaggedError("RealtimeApiKeysNetworkError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.externalNetwork; + } +} + +export class RealtimeApiKeysStatusError extends Data.TaggedError("RealtimeApiKeysStatusError")<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return statusCodeActionability(this.status); + } +} + +export class RealtimeMissingApiKeyError extends Data.TaggedError("RealtimeMissingApiKeyError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return actionability.provideFlags; + } +} + +export class RealtimeJoinFailedError extends Data.TaggedError("RealtimeJoinFailedError")<{ + readonly reason: RealtimeJoinFailureReason; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + if (this.reason === "rejected") { + return { ...actionability.invalidInput, fingerprint_suffix: "realtime_join_rejected" }; + } + if (this.reason === "closed") { + return { ...actionability.externalNetwork, fingerprint_suffix: "realtime_channel_closed" }; + } + return { ...actionability.externalNetwork, fingerprint_suffix: "realtime_join_timeout" }; + } +} + +export class RealtimePostgresSubscriptionFailedError extends Data.TaggedError( + "RealtimePostgresSubscriptionFailedError", +)<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.invalidConfig, fingerprint_suffix: "realtime_subscription_refused" }; + } +} + +export class RealtimeBroadcastFailedError extends Data.TaggedError("RealtimeBroadcastFailedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.apiStatus, fingerprint_suffix: "realtime_broadcast_unacked" }; + } +} + +export class RealtimeSignInFailedError extends Data.TaggedError("RealtimeSignInFailedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.authToken, fingerprint_suffix: "auth" }; + } +} + +export class RealtimeKeyRejectedError extends Data.TaggedError("RealtimeKeyRejectedError")<{ + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + return { ...actionability.authToken, fingerprint_suffix: "gateway_auth" }; + } +} + +export class RealtimeEndpointUnhealthyError extends Data.TaggedError( + "RealtimeEndpointUnhealthyError", +)<{ + readonly kind: RealtimeEndpointFailureKind; + readonly message: string; +}> { + get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { + switch (this.kind) { + case "not_found": + return { ...actionability.provideFlags, fingerprint_suffix: "not_found" }; + case "unauthorized": + return { ...actionability.authToken, fingerprint_suffix: "gateway_auth" }; + case "server_error": + return { ...actionability.apiStatus, fingerprint_suffix: "api_status" }; + case "handshake_refused": + return { ...actionability.provideFlags, fingerprint_suffix: "realtime_handshake_refused" }; + default: + return { ...actionability.externalNetwork, fingerprint_suffix: "connect" }; + } + } +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.events.ts b/apps/cli/src/commands/inspect/realtime/realtime.events.ts new file mode 100644 index 0000000000..f20d3723f7 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.events.ts @@ -0,0 +1,184 @@ +const REALTIME_CHANNEL_CATEGORIES = ["system", "broadcast", "presence", "postgres"] as const; + +const REALTIME_CLIENT_CATEGORIES = ["transport", "channel", "error"] as const; + +export const REALTIME_CATEGORIES = [ + ...REALTIME_CHANNEL_CATEGORIES, + ...REALTIME_CLIENT_CATEGORIES, +] as const; + +export const REALTIME_DEFAULT_CATEGORIES = [...REALTIME_CHANNEL_CATEGORIES, "error"] as const; + +export type RealtimeCategory = (typeof REALTIME_CATEGORIES)[number]; + +export const REALTIME_LOG_LEVELS = ["info", "warning", "error"] as const; + +export type RealtimeLogLevel = (typeof REALTIME_LOG_LEVELS)[number]; + +export interface RealtimeEvent { + readonly seq: number; + readonly at: string; + readonly category: RealtimeCategory; + readonly event: string; + readonly label?: string; + readonly payload: unknown; + readonly latencyMs?: number; +} + +export function isRealtimeCategory(value: string): value is RealtimeCategory { + return (REALTIME_CATEGORIES as ReadonlyArray).includes(value); +} + +export function realtimeCategoryOfLogKind(kind: string): RealtimeCategory { + if (kind === "error") return "error"; + if (kind === "transport") return "transport"; + return "channel"; +} + +const MAX_STRING = 2048; +const MAX_DEPTH = 8; + +const CREDENTIAL_PARAM = /([?&](?:apikey|token|access_token)=)[^&\s]+/gi; +const JWT = /\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/g; +const SUPABASE_KEY = /\bsb_(?:publishable|secret)_[A-Za-z0-9_-]{8,}\b/g; + +const REDACTED = "[redacted]"; + +export function redactRealtimeText(text: string): string { + return text + .replace(CREDENTIAL_PARAM, `$1${REDACTED}`) + .replace(JWT, REDACTED) + .replace(SUPABASE_KEY, REDACTED); +} + +function realtimeEventFields(value: object): Record | null { + if (typeof Event === "undefined" || !(value instanceof Event)) return null; + + const event: Record = { type: value.type }; + if (value instanceof CloseEvent) { + event["code"] = value.code; + if (value.reason.length > 0) event["reason"] = value.reason; + event["was_clean"] = value.wasClean; + } + if (value instanceof MessageEvent && typeof value.data === "string") { + event["data"] = value.data; + } + return event; +} + +export function cleanRealtimePayload(value: unknown, depth = 0): unknown { + if (value === null || value === undefined) return undefined; + if (depth > MAX_DEPTH) return "[nested]"; + + if (typeof value === "string") { + const trimmed = redactRealtimeText(value.trim()); + if (trimmed === "") return undefined; + return trimmed.length > MAX_STRING + ? `${trimmed.slice(0, MAX_STRING)}… (${trimmed.length} chars)` + : trimmed; + } + + if (typeof value !== "object") return value; + + if (value instanceof Error) { + return { name: value.name, message: redactRealtimeText(value.message) }; + } + + const eventFields = realtimeEventFields(value); + if (eventFields !== null) return cleanRealtimePayload(eventFields, depth); + + if (Array.isArray(value)) { + const items = value + .map((item) => cleanRealtimePayload(item, depth + 1)) + .filter((item) => item !== undefined); + return items.length > 0 ? items : undefined; + } + + const cleaned: Record = {}; + for (const [key, item] of Object.entries(value)) { + const next = cleanRealtimePayload(item, depth + 1); + if (next !== undefined) cleaned[key] = next; + } + return Object.keys(cleaned).length > 0 ? cleaned : undefined; +} + +export function unwrapRealtimePayload(payload: unknown): unknown { + const cleaned = cleanRealtimePayload(payload); + if (cleaned !== null && typeof cleaned === "object" && !Array.isArray(cleaned)) { + const keys = Object.keys(cleaned); + if (keys.length === 1 && keys[0] === "data") { + return (cleaned as { data: unknown }).data; + } + } + return cleaned; +} + +export function isRealtimeHeartbeat(message: string): boolean { + return message.includes("heartbeat") || /(^|\s)phoenix\s/.test(message); +} + +export function realtimeEventLabel(event: string): string { + if (event.startsWith("connected to ")) return "Transport connected"; + if (event.startsWith("connecting to ")) return "Transport connecting"; + + const { status, topic, verb } = parseRealtimeEvent(event); + const channel = realtimeChannelName(topic); + + switch (verb) { + case "phx_join": + return `Joining ${channel}`; + case "phx_reply": + if (status === "ok") return `Joined ${channel}`; + if (status === "error") return "Join rejected"; + return "Reply"; + case "phx_leave": + case "leave": + return `Leaving ${channel}`; + case "phx_close": + case "close": + return "Channel closed"; + case "phx_error": + case "error": + return "Channel error"; + case "broadcast": + return "Broadcast"; + case "postgres_changes": + return "Database change"; + case "presence_state": + return "Presence sync"; + case "presence_diff": + return "Presence change"; + case "system": + return "Subscription confirmed"; + case undefined: + return event; + default: + return verb; + } +} + +function parseRealtimeEvent(event: string): { + readonly status: string | undefined; + readonly topic: string | undefined; + readonly verb: string | undefined; +} { + const tokens = event + .replace(/\s*\([^)]*\)\s*$/, "") + .split(" ") + .filter((token) => token.length > 0); + + const topic = tokens.find((token) => token.includes(":")); + + const rest = tokens.filter((token) => !token.includes(":")); + if (rest.length === 2) return { status: rest[0], topic, verb: rest[1] }; + if (rest.length === 1) return { status: undefined, topic, verb: rest[0] }; + return { status: undefined, topic, verb: undefined }; +} + +function realtimeChannelName(topic: string | undefined): string { + if (topic === undefined) return "channel"; + const separator = topic.indexOf(":"); + if (separator === -1) return topic; + const name = topic.slice(separator + 1); + return name.length > 0 ? name : topic; +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.events.unit.test.ts b/apps/cli/src/commands/inspect/realtime/realtime.events.unit.test.ts new file mode 100644 index 0000000000..6f97293f98 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.events.unit.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; + +import { + cleanRealtimePayload, + isRealtimeHeartbeat, + realtimeCategoryOfLogKind, + realtimeEventLabel, + redactRealtimeText, + unwrapRealtimePayload, +} from "./realtime.events.ts"; + +const JWT = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"; + +describe("redactRealtimeText", () => { + it("redacts the apikey query param the SDK logs with the socket URL", () => { + expect( + redactRealtimeText("connecting to wss://abc.supabase.co/realtime/v1?apikey=secret1234"), + ).toBe("connecting to wss://abc.supabase.co/realtime/v1?apikey=[redacted]"); + }); + + it("redacts token and access_token params", () => { + expect(redactRealtimeText("?token=abc&access_token=def")).toBe( + "?token=[redacted]&access_token=[redacted]", + ); + }); + + it("redacts a bare JWT anywhere in the text", () => { + expect(redactRealtimeText(`joined with ${JWT} ok`)).toBe("joined with [redacted] ok"); + }); + + it("redacts publishable and secret keys, which are not JWTs", () => { + expect(redactRealtimeText("key sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH")).toBe( + "key [redacted]", + ); + expect(redactRealtimeText("key sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz")).toBe( + "key [redacted]", + ); + }); + + it("leaves text without credentials untouched", () => { + expect(redactRealtimeText("realtime:room_a phx_join (6, 6)")).toBe( + "realtime:room_a phx_join (6, 6)", + ); + }); +}); + +describe("cleanRealtimePayload", () => { + it("drops null, undefined and empty members", () => { + expect(cleanRealtimePayload({ a: 1, b: null, c: undefined, d: "" })).toEqual({ a: 1 }); + }); + + it("returns undefined for a payload with nothing left in it", () => { + expect(cleanRealtimePayload({ b: null })).toBeUndefined(); + expect(cleanRealtimePayload([])).toBeUndefined(); + }); + + it("truncates a long string and reports its real length", () => { + const cleaned = cleanRealtimePayload("x".repeat(2100)); + expect(cleaned).toBe(`${"x".repeat(2048)}… (2100 chars)`); + }); + + it("stops descending past the depth limit", () => { + let nested: unknown = "leaf"; + for (let i = 0; i < 12; i += 1) nested = { nested }; + expect(JSON.stringify(cleanRealtimePayload(nested))).toContain("[nested]"); + }); + + it("redacts credentials inside nested values", () => { + expect(cleanRealtimePayload({ url: `wss://x/socket?apikey=${JWT}` })).toEqual({ + url: "wss://x/socket?apikey=[redacted]", + }); + }); + + it("reduces an Error to its name and message", () => { + expect(cleanRealtimePayload(new TypeError("boom"))).toEqual({ + name: "TypeError", + message: "boom", + }); + }); +}); + +describe("unwrapRealtimePayload", () => { + it("unwraps the single-key data envelope the SDK logger adds", () => { + expect(unwrapRealtimePayload({ data: { status: "ok" } })).toEqual({ status: "ok" }); + }); + + it("leaves a payload with other keys alone", () => { + expect(unwrapRealtimePayload({ data: 1, other: 2 })).toEqual({ data: 1, other: 2 }); + }); +}); + +describe("isRealtimeHeartbeat", () => { + it("matches both the heartbeat push and its reply on the phoenix topic", () => { + expect(isRealtimeHeartbeat("push: phoenix heartbeat (7)")).toBe(true); + expect(isRealtimeHeartbeat("receive: ok phoenix phx_reply (7)")).toBe(true); + }); + + it("does not match ordinary channel traffic", () => { + expect(isRealtimeHeartbeat("receive: ok realtime:room_a phx_reply (6)")).toBe(false); + }); +}); + +describe("realtimeEventLabel", () => { + it.each([ + ["connected to wss://abc/realtime/v1", "Transport connected"], + ["connecting to wss://abc/realtime/v1", "Transport connecting"], + ["realtime:room_a phx_join (6, 6)", "Joining room_a"], + ["ok realtime:room_a phx_reply (6, 6)", "Joined room_a"], + ["error realtime:room_a phx_reply (6, 6)", "Join rejected"], + ["realtime:room_a phx_leave (7)", "Leaving room_a"], + ["realtime:room_a phx_close", "Channel closed"], + ["realtime:room_a phx_error", "Channel error"], + ["postgres_changes", "Database change"], + ["presence_state", "Presence sync"], + ["presence_diff", "Presence change"], + ])("labels %s as %s", (event, expected) => { + expect(realtimeEventLabel(event)).toBe(expected); + }); + + it("falls back to the raw event name when there is nothing to parse", () => { + expect(realtimeEventLabel("ping")).toBe("ping"); + expect(realtimeEventLabel("")).toBe(""); + }); +}); + +describe("realtimeCategoryOfLogKind", () => { + it("maps the SDK's log kinds onto categories, defaulting to channel", () => { + expect(realtimeCategoryOfLogKind("error")).toBe("error"); + expect(realtimeCategoryOfLogKind("transport")).toBe("transport"); + expect(realtimeCategoryOfLogKind("channel")).toBe("channel"); + expect(realtimeCategoryOfLogKind("something-new")).toBe("channel"); + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/realtime.flags.ts b/apps/cli/src/commands/inspect/realtime/realtime.flags.ts new file mode 100644 index 0000000000..257ab2fccc --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.flags.ts @@ -0,0 +1,349 @@ +import { Duration, Effect, Option } from "effect"; +import { Flag } from "effect/unstable/cli"; + +import { changedLinkedLocalFlags } from "../../../command-internal/db-target-flags.ts"; +import { OutputFlag } from "../../../command-internal/global-flags.ts"; +import { + REALTIME_CATEGORIES, + REALTIME_DEFAULT_CATEGORIES, + REALTIME_LOG_LEVELS, + isRealtimeCategory, + type RealtimeCategory, +} from "./realtime.events.ts"; +import { + RealtimeInvalidOptionError, + RealtimeInvalidPayloadError, + RealtimeMutuallyExclusiveFlagsError, + RealtimeOutputFlagUnsupportedError, +} from "./realtime.errors.ts"; +import type { RealtimePostgresEvent, RealtimePostgresSpec } from "./realtime.session.ts"; +import type { RealtimeTargetChoice } from "./realtime.connection.ts"; + +export const REALTIME_CONNECTION_FLAGS = { + url: Flag.string("url").pipe( + Flag.withDescription( + "Project URL to connect to, e.g. https://abc.supabase.co. (default $SUPABASE_URL, else the resolved project)", + ), + Flag.optional, + ), + apiKey: Flag.string("api-key").pipe( + Flag.withAlias("publishable-key"), + Flag.withDescription( + "Publishable or anon key to connect with. (default $SUPABASE_PUBLISHABLE_KEY, $SUPABASE_ANON_KEY, else the resolved project's)", + ), + Flag.optional, + ), + secretKey: Flag.string("secret-key").pipe( + Flag.withDescription("Secret or service-role key to connect with, bypassing RLS."), + Flag.optional, + ), + serviceRole: Flag.boolean("service-role").pipe( + Flag.withDescription( + "Connect with the resolved project's secret key instead of its publishable key, bypassing RLS.", + ), + Flag.withDefault(false), + ), + projectRef: Flag.string("project-ref").pipe( + Flag.withDescription("Project ref of the Supabase project to connect to."), + Flag.optional, + ), + local: Flag.boolean("local").pipe( + Flag.withDescription("Connect to the local stack's Realtime."), + Flag.withDefault(false), + ), + linked: Flag.boolean("linked").pipe( + Flag.withDescription("Connect to the linked project's Realtime."), + Flag.withDefault(false), + ), + userToken: Flag.string("user-token").pipe( + Flag.withDescription("User JWT to join as, for RLS and private channels."), + Flag.optional, + ), + email: Flag.string("email").pipe( + Flag.withDescription("Sign in with this email to obtain a user JWT."), + Flag.optional, + ), + password: Flag.string("password").pipe( + Flag.withDescription("Password for --email. Prompted for when omitted on a TTY."), + Flag.optional, + ), + private: Flag.boolean("private").pipe( + Flag.withDescription("Join the channel as private, so RLS policies are enforced."), + Flag.withDefault(false), + ), + timeout: Flag.integer("timeout").pipe( + Flag.withDescription("Seconds to wait for the channel to join. (default 15)"), + Flag.withDefault(15), + ), +} as const; + +export const REALTIME_LOG_FLAGS = { + logLevel: Flag.choice("server-log-level", REALTIME_LOG_LEVELS).pipe( + Flag.withDescription( + "Server-side log verbosity for this connection. Realtime accepts info, warning and error only. (default info)", + ), + Flag.withDefault("info" as const), + ), + categories: Flag.string("categories").pipe( + Flag.withDescription( + `Comma-separated frame categories to record: ${REALTIME_CATEGORIES.join(", ")}, or "all". (default ${REALTIME_DEFAULT_CATEGORIES.join(",")})`, + ), + Flag.optional, + ), + fullPayload: Flag.boolean("full-payload").pipe( + Flag.withDescription("Print each payload in full instead of a single clamped line."), + Flag.withDefault(false), + ), +} as const; + +export const REALTIME_POSTGRES_FLAGS = { + postgres: Flag.string("postgres").pipe( + Flag.withDescription( + "Subscribe to database changes for a table, as schema.table (e.g. public.messages) or a bare schema.", + ), + Flag.optional, + ), + event: Flag.choice("event", ["*", "INSERT", "UPDATE", "DELETE"]).pipe( + Flag.withDescription("Which database change to subscribe to. (default *)"), + Flag.withDefault("*" as const), + ), + filter: Flag.string("filter").pipe( + Flag.withDescription( + "Row filter for database changes, e.g. id=eq.1. Combine conditions with commas (AND).", + ), + Flag.optional, + ), + select: Flag.string("select").pipe( + Flag.withDescription("Comma-separated columns to return for database changes."), + Flag.optional, + ), +} as const; + +export function realtimeChannelName(channel: string): { + readonly channel: string; + readonly strippedTopicPrefix: boolean; +} { + const trimmed = channel.trim(); + return trimmed.startsWith("realtime:") + ? { channel: trimmed.slice("realtime:".length), strippedTopicPrefix: true } + : { channel: trimmed, strippedTopicPrefix: false }; +} + +export function requirePositive( + name: string, + value: number, +): Effect.Effect { + return value >= 1 + ? Effect.succeed(value) + : Effect.fail( + new RealtimeInvalidOptionError({ + message: `--${name} must be at least 1, got ${value}`, + }), + ); +} + +export function parseRealtimeCategories( + value: Option.Option, +): Effect.Effect, RealtimeInvalidOptionError> { + const raw = Option.getOrUndefined(value); + if (raw === undefined) { + return Effect.succeed(new Set(REALTIME_DEFAULT_CATEGORIES)); + } + + const tokens = raw + .split(",") + .map((token) => token.trim().toLowerCase()) + .filter((token) => token.length > 0); + + if (tokens.length === 0) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: "--categories was empty; pass at least one category, or omit the flag", + }), + ); + } + + if (tokens.includes("all")) { + return Effect.succeed(new Set(REALTIME_CATEGORIES)); + } + + const unknown = tokens.filter((token) => !isRealtimeCategory(token)); + if (unknown.length > 0) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: `unknown --categories value${unknown.length === 1 ? "" : "s"} ${unknown.join(", ")}; valid values are ${REALTIME_CATEGORIES.join(", ")} or all`, + }), + ); + } + + return Effect.succeed(new Set(tokens.filter(isRealtimeCategory))); +} + +export function parseRealtimeDuration( + value: Option.Option, +): Effect.Effect, RealtimeInvalidOptionError> { + const raw = Option.getOrUndefined(value); + if (raw === undefined) return Effect.succeed(Option.none()); + + const match = /^(\d+)(ms|s|m|h)?$/.exec(raw.trim()); + if (match === null) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: `invalid --duration "${raw}"; expected a number of seconds or a value like 30s, 5m, 1h`, + }), + ); + } + + const amount = Number(match[1]); + if (amount === 0) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: "--duration must be greater than zero", + }), + ); + } + + switch (match[2]) { + case "ms": + return Effect.succeed(Option.some(Duration.millis(amount))); + case "m": + return Effect.succeed(Option.some(Duration.minutes(amount))); + case "h": + return Effect.succeed(Option.some(Duration.hours(amount))); + default: + return Effect.succeed(Option.some(Duration.seconds(amount))); + } +} + +export function parseRealtimePostgresTarget( + value: string, +): Effect.Effect<{ readonly schema: string; readonly table: string }, RealtimeInvalidOptionError> { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: "--postgres was empty; pass a schema, or schema.table", + }), + ); + } + + const parts = trimmed.split("."); + if (parts.length > 2) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: `invalid --postgres "${value}"; expected schema or schema.table`, + }), + ); + } + + const schema = parts[0] ?? ""; + const table = parts.length === 2 ? (parts[1] ?? "") : "*"; + if (schema.length === 0 || table.length === 0) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: `invalid --postgres "${value}"; expected schema or schema.table`, + }), + ); + } + + return Effect.succeed({ schema, table }); +} + +export function parseRealtimeReplaySince( + value: Option.Option, + now: number = Date.now(), +): Effect.Effect { + const raw = Option.getOrUndefined(value); + if (raw === undefined) return Effect.succeed(undefined); + + const relative = /^(\d+)(ms|s|m|h)?$/.exec(raw.trim()); + if (relative !== null) { + return Effect.map( + parseRealtimeDuration(Option.some(raw)), + (duration) => now - Duration.toMillis(Option.getOrThrow(duration)), + ); + } + + const absolute = Date.parse(raw.trim()); + if (Number.isNaN(absolute)) { + return Effect.fail( + new RealtimeInvalidOptionError({ + message: `invalid --replay-since "${raw}"; expected a timestamp or an age like 5m, 1h`, + }), + ); + } + return Effect.succeed(absolute); +} + +export function parseRealtimeSelect(value: Option.Option): ReadonlyArray { + const raw = Option.getOrUndefined(value); + if (raw === undefined) return []; + return [ + ...new Set( + raw + .split(",") + .map((column) => column.trim()) + .filter((column) => column.length > 0), + ), + ]; +} + +export const resolveRealtimePostgresSpec = Effect.fnUntraced(function* (flags: { + readonly postgres: Option.Option; + readonly event: RealtimePostgresEvent; + readonly filter: Option.Option; + readonly select: Option.Option; +}) { + const target = Option.getOrUndefined(flags.postgres); + if (target === undefined) return undefined; + + const { schema, table } = yield* parseRealtimePostgresTarget(target); + return { + schema, + table, + event: flags.event, + filter: Option.getOrUndefined(flags.filter), + select: parseRealtimeSelect(flags.select), + } satisfies RealtimePostgresSpec; +}); + +export function parseRealtimePayload( + raw: string, +): Effect.Effect { + return Effect.try({ + try: (): unknown => JSON.parse(raw), + catch: (cause) => + new RealtimeInvalidPayloadError({ + message: `payload is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +} + +export const assertRealtimeTargetsExclusive = Effect.fnUntraced(function* ( + args: ReadonlyArray, +) { + const setFlags = changedLinkedLocalFlags(args); + if (setFlags.length > 1) { + return yield* new RealtimeMutuallyExclusiveFlagsError({ + message: `if any flags in the group [linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }); + } +}); + +export function realtimeTargetChoice(flags: { + readonly local: boolean; + readonly linked: boolean; +}): RealtimeTargetChoice | undefined { + if (flags.local) return "local"; + if (flags.linked) return "linked"; + return undefined; +} + +export const rejectRealtimeOutputFlag = Effect.fnUntraced(function* () { + if (Option.isSome(yield* OutputFlag)) { + return yield* new RealtimeOutputFlagUnsupportedError({ + message: + "the -o/--output flag is not supported by inspect realtime; use --output-format json|stream-json instead.", + }); + } +}); diff --git a/apps/cli/src/commands/inspect/realtime/realtime.flags.unit.test.ts b/apps/cli/src/commands/inspect/realtime/realtime.flags.unit.test.ts new file mode 100644 index 0000000000..381426fda0 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.flags.unit.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Duration, Effect, Exit, Option } from "effect"; + +import { + realtimeChannelName, + requirePositive, + parseRealtimeCategories, + parseRealtimeDuration, + parseRealtimePayload, + parseRealtimePostgresTarget, + parseRealtimeReplaySince, + parseRealtimeSelect, + realtimeTargetChoice, + resolveRealtimePostgresSpec, +} from "./realtime.flags.ts"; + +const run = (effect: Effect.Effect) => Effect.runSync(effect); +const runExit = (effect: Effect.Effect) => Effect.runSyncExit(effect); + +describe("parseRealtimeCategories", () => { + it("defaults to the channel categories plus error", () => { + expect([...run(parseRealtimeCategories(Option.none()))].sort()).toEqual([ + "broadcast", + "error", + "postgres", + "presence", + "system", + ]); + }); + + it("accepts a comma separated list, trimming and lowercasing", () => { + expect([...run(parseRealtimeCategories(Option.some(" Broadcast , postgres ")))]).toEqual([ + "broadcast", + "postgres", + ]); + }); + + it("expands all to every category, including the client internals", () => { + expect([...run(parseRealtimeCategories(Option.some("all")))]).toContain("transport"); + expect([...run(parseRealtimeCategories(Option.some("all")))]).toContain("channel"); + }); + + it("rejects an unknown category", () => { + const exit = runExit(parseRealtimeCategories(Option.some("broadcast,bogus"))); + expect(Exit.isFailure(exit)).toBe(true); + }); + + it("rejects a value that selects nothing", () => { + expect(Exit.isFailure(runExit(parseRealtimeCategories(Option.some(" , "))))).toBe(true); + }); +}); + +describe("parseRealtimeDuration", () => { + it("returns none when the flag is absent", () => { + expect(Option.isNone(run(parseRealtimeDuration(Option.none())))).toBe(true); + }); + + it.each([ + ["30", Duration.seconds(30)], + ["30s", Duration.seconds(30)], + ["500ms", Duration.millis(500)], + ["5m", Duration.minutes(5)], + ["2h", Duration.hours(2)], + ])("parses %s", (input, expected) => { + const parsed = run(parseRealtimeDuration(Option.some(input))); + expect(Option.getOrThrow(parsed)).toStrictEqual(expected); + }); + + it.each(["abc", "-5", "5x", "", "0"])("rejects %s", (input) => { + expect(Exit.isFailure(runExit(parseRealtimeDuration(Option.some(input))))).toBe(true); + }); +}); + +describe("parseRealtimePostgresTarget", () => { + it("reads schema.table", () => { + expect(run(parseRealtimePostgresTarget("public.messages"))).toEqual({ + schema: "public", + table: "messages", + }); + }); + + it("treats a bare schema as every table in it", () => { + expect(run(parseRealtimePostgresTarget(" public "))).toEqual({ + schema: "public", + table: "*", + }); + }); + + it.each(["", " ", "a.b.c", "public."])("rejects %s", (input) => { + expect(Exit.isFailure(runExit(parseRealtimePostgresTarget(input)))).toBe(true); + }); +}); + +describe("parseRealtimeSelect", () => { + it("splits, trims and de-duplicates the column list", () => { + expect(parseRealtimeSelect(Option.some(" id , title ,id, "))).toEqual(["id", "title"]); + }); + + it("returns no columns when the flag is absent, meaning every column", () => { + expect(parseRealtimeSelect(Option.none())).toEqual([]); + }); +}); + +describe("resolveRealtimePostgresSpec", () => { + it("returns undefined when --postgres was not passed", () => { + const spec = run( + resolveRealtimePostgresSpec({ + postgres: Option.none(), + event: "*", + filter: Option.none(), + select: Option.none(), + }), + ); + expect(spec).toBeUndefined(); + }); + + it("builds the full binding from the related flags", () => { + expect( + run( + resolveRealtimePostgresSpec({ + postgres: Option.some("public.messages"), + event: "INSERT", + filter: Option.some("id=eq.1"), + select: Option.some("id,title"), + }), + ), + ).toEqual({ + schema: "public", + table: "messages", + event: "INSERT", + filter: "id=eq.1", + select: ["id", "title"], + }); + }); +}); + +describe("parseRealtimePayload", () => { + it("parses a JSON payload", () => { + expect(run(parseRealtimePayload('{"n":1}'))).toEqual({ n: 1 }); + }); + + it("rejects text that is not JSON", () => { + expect(Exit.isFailure(runExit(parseRealtimePayload("{nope}")))).toBe(true); + }); +}); + +describe("realtimeTargetChoice", () => { + it("reports the named target, and nothing when neither flag is set", () => { + expect(realtimeTargetChoice({ local: true, linked: false })).toBe("local"); + expect(realtimeTargetChoice({ local: false, linked: true })).toBe("linked"); + expect(realtimeTargetChoice({ local: false, linked: false })).toBeUndefined(); + }); +}); + +describe("parseRealtimeReplaySince", () => { + const now = Date.UTC(2026, 0, 2, 12, 0, 0); + + it("returns undefined when the flag is absent", () => { + expect(run(parseRealtimeReplaySince(Option.none(), now))).toBeUndefined(); + }); + + it("reads an age as a point that far in the past", () => { + expect(run(parseRealtimeReplaySince(Option.some("5m"), now))).toBe(now - 5 * 60_000); + expect(run(parseRealtimeReplaySince(Option.some("30"), now))).toBe(now - 30_000); + }); + + it("reads an absolute timestamp", () => { + expect(run(parseRealtimeReplaySince(Option.some("2026-01-02T11:00:00Z"), now))).toBe( + Date.UTC(2026, 0, 2, 11, 0, 0), + ); + }); + + it("rejects text that is neither", () => { + expect(Exit.isFailure(runExit(parseRealtimeReplaySince(Option.some("yesterday"), now)))).toBe( + true, + ); + }); +}); + +describe("realtimeChannelName", () => { + it("strips the wire protocol's reserved topic prefix", () => { + expect(realtimeChannelName("realtime:room_a")).toEqual({ + channel: "room_a", + strippedTopicPrefix: true, + }); + }); + + it("leaves a channel whose own name contains a colon alone", () => { + expect(realtimeChannelName("room:42")).toEqual({ + channel: "room:42", + strippedTopicPrefix: false, + }); + }); + + it("trims surrounding whitespace", () => { + expect(realtimeChannelName(" room_a ").channel).toBe("room_a"); + }); +}); + +describe("requirePositive", () => { + it("accepts one and above", () => { + expect(run(requirePositive("count", 1))).toBe(1); + }); + + it.each([0, -1])("rejects %s", (value) => { + expect(Exit.isFailure(runExit(requirePositive("count", value)))).toBe(true); + }); +}); diff --git a/apps/cli/src/commands/inspect/realtime/realtime.format.ts b/apps/cli/src/commands/inspect/realtime/realtime.format.ts new file mode 100644 index 0000000000..0b3cf3470b --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.format.ts @@ -0,0 +1,122 @@ +import type { StreamEvent } from "../../../shared/output/types.ts"; +import { realtimeEventLabel, type RealtimeEvent } from "./realtime.events.ts"; + +const CATEGORY_WIDTH = 9; +const LABEL_WIDTH = 22; +const SUMMARY_LIMIT = 200; + +export interface RealtimeRenderOptions { + readonly fullPayload: boolean; +} + +function formatRealtimeTime(iso: string): string { + const at = new Date(iso); + if (Number.isNaN(at.getTime())) return iso; + const time = at.toISOString(); + return time.slice(11, 23); +} + +function formatRealtimePayload(payload: unknown, limit = SUMMARY_LIMIT): string { + if (payload === undefined || payload === null) return ""; + const json = JSON.stringify(payload); + if (json === undefined) return ""; + if (json === "{}" || json === "[]") return ""; + return json.length > limit ? `${json.slice(0, limit)}…` : json; +} + +export function formatRealtimeLine( + event: RealtimeEvent, + options: RealtimeRenderOptions = { fullPayload: false }, +): string { + const label = event.label ?? realtimeEventLabel(event.event); + const latency = event.latencyMs === undefined ? "" : ` +${Math.round(event.latencyMs)}ms`; + + const head = [ + formatRealtimeTime(event.at), + event.category.padEnd(CATEGORY_WIDTH), + `${label}${latency}`.padEnd(LABEL_WIDTH), + ].join(" "); + + if (options.fullPayload) { + const pretty = JSON.stringify(event.payload, null, 2); + if (pretty === undefined || pretty === "{}") return head.trimEnd(); + return `${head.trimEnd()}\n${legacyIndent(pretty, 2)}`; + } + + const summary = formatRealtimePayload(event.payload); + return summary.length === 0 ? head.trimEnd() : `${head} ${summary}`; +} + +function legacyIndent(text: string, spaces: number): string { + const pad = " ".repeat(spaces); + return text + .split("\n") + .map((line) => `${pad}${line}`) + .join("\n"); +} + +export function realtimeFrameEvent( + event: RealtimeEvent, + options: RealtimeRenderOptions = { fullPayload: false }, +): StreamEvent { + return { + type: "realtime-frame", + timestamp: event.at, + seq: event.seq, + category: event.category, + event: event.event, + label: event.label ?? realtimeEventLabel(event.event), + line: formatRealtimeLine(event, options), + payload: event.payload, + ...(event.latencyMs === undefined ? {} : { latencyMs: event.latencyMs }), + }; +} + +export function formatRealtimeHeader(): string { + return [ + "TIME".padEnd(12), + "CATEGORY".padEnd(CATEGORY_WIDTH), + "EVENT".padEnd(LABEL_WIDTH), + "PAYLOAD", + ].join(" "); +} + +interface RealtimeSummary { + readonly emitted: number; + readonly suppressed: number; + readonly byCategory: Readonly>; +} + +export function realtimeNoChangesHint(opts: { + readonly table: string; + readonly filtered: boolean; + readonly elevated: boolean; + readonly asUser: boolean; +}): string { + const causes = [`nothing changed in ${opts.table}`]; + if (opts.filtered) causes.push("the --filter excludes the rows that did change"); + if (!opts.elevated && !opts.asUser) { + causes.push( + "or RLS on the table does not grant the anon role — retry with --email/--user-token, or --service-role to rule RLS out", + ); + } else if (opts.asUser) { + causes.push("or RLS does not grant this user — retry with --service-role to rule RLS out"); + } + return `The subscription was confirmed but no database changes arrived: either ${causes.join(", ")}.`; +} + +export function realtimeSummaryLine(summary: RealtimeSummary): string { + const breakdown = Object.entries(summary.byCategory) + .filter(([, count]) => count > 0) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, count]) => `${category} ${count}`) + .join(", "); + + const suppressed = + summary.suppressed > 0 ? ` (${summary.suppressed} filtered out by --categories)` : ""; + + if (summary.emitted === 0) { + return `No frames received${suppressed}.`; + } + return `${summary.emitted} frame${summary.emitted === 1 ? "" : "s"}: ${breakdown}${suppressed}.`; +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.layers.ts b/apps/cli/src/commands/inspect/realtime/realtime.layers.ts new file mode 100644 index 0000000000..7a9a4361f8 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.layers.ts @@ -0,0 +1,7 @@ +import { Layer } from "effect"; + +import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; +import { realtimeSessionsLayer } from "./realtime-session.service.ts"; + +export const inspectRealtimeRuntimeLayer = (subcommand: ReadonlyArray) => + Layer.mergeAll(storageGatewayRuntimeLayer(subcommand), realtimeSessionsLayer); diff --git a/apps/cli/src/commands/inspect/realtime/realtime.prelude.ts b/apps/cli/src/commands/inspect/realtime/realtime.prelude.ts new file mode 100644 index 0000000000..4868e48179 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.prelude.ts @@ -0,0 +1,223 @@ +import { Duration, Effect, Option, Redacted } from "effect"; + +import type { Param } from "effect/unstable/cli"; + +import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; +import { Output } from "../../../shared/output/output.service.ts"; +import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; +import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; +import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; +import { realtimeSignIn, realtimeTokenIdentity } from "./realtime.auth.ts"; +import { + describeRealtimeTarget, + resolveRealtimeTarget, + type RealtimeTarget, +} from "./realtime.connection.ts"; +import type { RealtimeCategory, RealtimeLogLevel } from "./realtime.events.ts"; +import { + assertRealtimeTargetsExclusive, + realtimeChannelName, + realtimeTargetChoice, + rejectRealtimeOutputFlag, +} from "./realtime.flags.ts"; +import type { RealtimePostgresSpec, RealtimeSessionSpec } from "./realtime.session.ts"; + +export interface RealtimeConnectionFlags { + readonly url: Option.Option; + readonly apiKey: Option.Option; + readonly secretKey: Option.Option; + readonly serviceRole: boolean; + readonly projectRef: Option.Option; + readonly local: boolean; + readonly linked: boolean; + readonly userToken: Option.Option; + readonly email: Option.Option; + readonly password: Option.Option; + readonly private: boolean; + readonly timeout: number; +} + +export interface RealtimeConnection { + readonly target: RealtimeTarget; + readonly userToken: Redacted.Redacted | undefined; + readonly identity: + | { readonly subject: string; readonly role: string | undefined; readonly expired: boolean } + | undefined; +} + +const resolveRealtimeConnection = Effect.fnUntraced(function* (flags: RealtimeConnectionFlags) { + yield* rejectRealtimeOutputFlag(); + + const target = yield* resolveRealtimeTarget({ + url: flags.url, + apiKey: flags.apiKey, + secretKey: flags.secretKey, + serviceRole: flags.serviceRole, + projectRef: flags.projectRef, + choice: realtimeTargetChoice(flags), + }); + + const { userToken, identity } = yield* resolveRealtimeIdentity(target, flags); + + return { target, userToken, identity } satisfies RealtimeConnection; +}); + +const resolveRealtimeIdentity = Effect.fnUntraced(function* ( + target: RealtimeTarget, + flags: RealtimeConnectionFlags, +) { + const explicit = Option.getOrUndefined(flags.userToken); + if (explicit !== undefined) { + const token = Redacted.make(explicit); + return { userToken: token, identity: realtimeTokenIdentity(token) }; + } + + const email = Option.getOrUndefined(flags.email); + if (email === undefined) { + return { userToken: undefined, identity: undefined }; + } + + const output = yield* Output; + const password = Option.isSome(flags.password) + ? flags.password.value + : yield* output.promptPassword(`Password for ${email}`); + + const signedIn = yield* realtimeSignIn({ + url: target.url, + apiKey: target.apiKey, + email, + password: Redacted.make(password), + }); + + return { + userToken: signedIn.token, + identity: { subject: signedIn.subject, role: signedIn.role, expired: false }, + }; +}); + +export function runRealtimeCommand(opts: { + readonly flags: RealtimeConnectionFlags; + readonly prepare: Effect.Effect; + readonly run: (prepared: P, connection: RealtimeConnection) => Effect.Effect; +}) { + return Effect.gen(function* () { + const telemetryState = yield* TelemetryState; + const linkedProjectCache = yield* LinkedProjectCache; + + let linkedRef = ""; + + return yield* Effect.gen(function* () { + const prepared = yield* opts.prepare; + const connection = yield* resolveRealtimeConnection(opts.flags); + linkedRef = connection.target.projectRef ?? ""; + return yield* opts.run(prepared, connection); + }).pipe( + Effect.ensuring( + Effect.suspend(() => + linkedRef === "" ? Effect.void : linkedProjectCache.cache(linkedRef), + ), + ), + Effect.ensuring(telemetryState.flush), + ); + }); +} + +export const warnRealtimeChannelPrefix = Effect.fnUntraced(function* (channel: string) { + const normalized = realtimeChannelName(channel); + if (!normalized.strippedTopicPrefix) return normalized.channel; + const output = yield* Output; + yield* output.warn( + `"${channel}" is the wire protocol's topic name; using channel "${normalized.channel}". Pass the channel as your application names it, without the "realtime:" prefix.`, + ); + return normalized.channel; +}); + +export function describeRealtimeConnection(connection: RealtimeConnection): string { + const where = describeRealtimeTarget(connection.target); + const identity = connection.identity; + if (identity === undefined) { + return connection.target.elevated + ? `Connected to ${where} with the secret key, which bypasses RLS.` + : `Connected to ${where} with the publishable key.`; + } + + const role = identity.role === undefined ? "" : ` (${identity.role})`; + const expired = identity.expired ? " — note: this token is expired" : ""; + return `Connected to ${where} as ${identity.subject}${role}${expired}.`; +} + +const BUFFER_SIZE = 8192; + +export function realtimeSessionSpecOf(opts: { + readonly connection: RealtimeConnection; + readonly flags: RealtimeConnectionFlags; + readonly channel: string; + readonly categories: ReadonlySet; + readonly logLevel: RealtimeLogLevel; + readonly postgres: RealtimePostgresSpec | undefined; + readonly presence: boolean; + readonly presenceKey?: string | undefined; + readonly broadcastSelf: boolean; + readonly broadcastAck: boolean; + readonly broadcastReplay?: + | { readonly since: number; readonly limit: number | undefined } + | undefined; + readonly replicationReady?: boolean; +}): RealtimeSessionSpec { + return { + url: opts.connection.target.url, + apiKey: opts.connection.target.apiKey, + userToken: opts.connection.userToken, + channel: realtimeChannelName(opts.channel).channel, + privateChannel: opts.flags.private, + broadcastSelf: opts.broadcastSelf, + broadcastAck: opts.broadcastAck, + broadcastReplay: opts.broadcastReplay, + replicationReady: opts.replicationReady ?? false, + presence: opts.presence, + presenceKey: opts.presenceKey, + postgres: opts.postgres, + logLevel: opts.logLevel, + categories: opts.categories, + bufferSize: BUFFER_SIZE, + joinTimeout: Duration.seconds(opts.flags.timeout), + }; +} + +function realtimeConnectionTelemetryFlags(flags: RealtimeConnectionFlags): Record { + return { + url: flags.url, + "api-key": flags.apiKey, + "project-ref": flags.projectRef, + local: flags.local, + linked: flags.linked, + "user-token": flags.userToken, + email: flags.email, + password: flags.password, + private: flags.private, + timeout: flags.timeout, + }; +} + +export function inspectRealtimeCommandHandler(opts: { + readonly config: Record; + readonly telemetryFlags?: (flags: Flags) => Record; + readonly handler: (flags: Flags) => Effect.Effect; +}) { + return (flags: Flags) => + Effect.gen(function* () { + const cliArgs = yield* CliArgs; + yield* assertRealtimeTargetsExclusive(cliArgs.args); + + return yield* opts.handler(flags).pipe( + withCommandTelemetry({ + flags: { + ...realtimeConnectionTelemetryFlags(flags), + ...opts.telemetryFlags?.(flags), + }, + config: opts.config, + }), + ); + }).pipe(withJsonErrorHandling); +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.probe.ts b/apps/cli/src/commands/inspect/realtime/realtime.probe.ts new file mode 100644 index 0000000000..258ff247d7 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.probe.ts @@ -0,0 +1,87 @@ +import { Duration, Effect, Redacted } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; + +import type { RealtimeEndpointFailureKind } from "./realtime.errors.ts"; + +export type RealtimeProbeOutcome = + | { + readonly kind: "reachable"; + readonly status: number; + readonly detail: string; + } + | { + readonly kind: RealtimeEndpointFailureKind; + readonly status: number | undefined; + readonly detail: string; + }; + +const PROBE_TIMEOUT = Duration.seconds(10); + +export const probeRealtimeEndpoint = Effect.fnUntraced(function* (opts: { + readonly url: string; + readonly apiKey: Redacted.Redacted; +}) { + const endpoint = `${opts.url.replace(/\/+$/, "")}/realtime/v1/websocket`; + const host = realtimeHostLabel(endpoint); + const client = yield* HttpClient.HttpClient; + + const request = HttpClientRequest.get(endpoint).pipe( + HttpClientRequest.setUrlParam("apikey", Redacted.value(opts.apiKey)), + ); + + return yield* client.execute(request).pipe( + Effect.map((response): RealtimeProbeOutcome => { + if (response.status === 404) { + return { + kind: "not_found", + status: 404, + detail: `No Realtime server at ${host}. Check the project ref or the URL.`, + }; + } + if (response.status === 401 || response.status === 403) { + return { + kind: "unauthorized", + status: response.status, + detail: `${host} rejected the API key.`, + }; + } + if (response.status === 502 || response.status === 503 || response.status === 504) { + return { + kind: "server_error", + status: response.status, + detail: `${host} answered ${response.status}; Realtime is not reachable through its gateway.`, + }; + } + return { + kind: "reachable", + status: response.status, + detail: `${host} is serving Realtime (probe status ${response.status}).`, + }; + }), + Effect.timeoutOrElse({ + duration: PROBE_TIMEOUT, + orElse: () => + Effect.succeed({ + kind: "unreachable", + status: undefined, + detail: `${host} did not answer within ${Duration.toSeconds(PROBE_TIMEOUT)}s.`, + }), + }), + Effect.catch(() => + Effect.succeed({ + kind: "unreachable", + status: undefined, + detail: `Could not reach ${host}. Check the URL, your network, or whether the server is running.`, + }), + ), + ); +}); + +function realtimeHostLabel(endpoint: string): string { + try { + return new URL(endpoint).host; + } catch { + return endpoint; + } +} diff --git a/apps/cli/src/commands/inspect/realtime/realtime.session.ts b/apps/cli/src/commands/inspect/realtime/realtime.session.ts new file mode 100644 index 0000000000..e503920984 --- /dev/null +++ b/apps/cli/src/commands/inspect/realtime/realtime.session.ts @@ -0,0 +1,417 @@ +import { + REALTIME_SUBSCRIBE_STATES, + RealtimeClient, + type RealtimeChannel, + type RealtimePresenceState, +} from "@supabase/realtime-js"; +import { Cause, Deferred, Duration, Effect, Queue, Redacted, Stream } from "effect"; + +import { + RealtimeBroadcastFailedError, + RealtimeInvalidUrlError, + RealtimeJoinFailedError, + RealtimePostgresSubscriptionFailedError, +} from "./realtime.errors.ts"; +import { + isRealtimeHeartbeat, + realtimeCategoryOfLogKind, + redactRealtimeText, + unwrapRealtimePayload, + type RealtimeCategory, + type RealtimeEvent, + type RealtimeLogLevel, +} from "./realtime.events.ts"; + +export type RealtimePostgresEvent = "*" | "INSERT" | "UPDATE" | "DELETE"; + +export interface RealtimePostgresSpec { + readonly schema: string; + readonly table: string; + readonly event: RealtimePostgresEvent; + readonly filter: string | undefined; + readonly select: ReadonlyArray; +} + +export interface RealtimeSessionSpec { + readonly url: string; + readonly apiKey: Redacted.Redacted; + readonly userToken: Redacted.Redacted | undefined; + readonly channel: string; + readonly privateChannel: boolean; + readonly broadcastSelf: boolean; + readonly broadcastAck: boolean; + readonly broadcastReplay: + | { readonly since: number; readonly limit: number | undefined } + | undefined; + readonly replicationReady: boolean; + readonly presence: boolean; + readonly presenceKey: string | undefined; + readonly postgres: RealtimePostgresSpec | undefined; + readonly logLevel: RealtimeLogLevel | undefined; + readonly categories: ReadonlySet; + readonly bufferSize: number; + readonly joinTimeout: Duration.Duration; +} + +interface RealtimeSessionCounts { + readonly emitted: number; + readonly suppressed: number; +} + +export interface RealtimeSession { + readonly events: Stream.Stream; + readonly joined: Effect.Effect; + readonly postgresSubscribed: Effect.Effect; + readonly replicationEstablished: Effect.Effect; + readonly broadcast: ( + event: string, + payload: unknown, + ) => Effect.Effect; + readonly track: ( + state: Record, + ) => Effect.Effect; + readonly presenceState: Effect.Effect; + readonly counts: Effect.Effect; +} + +function realtimeSocketEndpoint(url: string): Effect.Effect { + return Effect.try({ + try: () => { + const parsed = new URL(url); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error(`unsupported scheme "${parsed.protocol}"`); + } + return `${url.replace(/\/+$/, "")}/realtime/v1`; + }, + catch: (cause) => + new RealtimeInvalidUrlError({ + message: `invalid Realtime URL "${url}": ${cause instanceof Error ? cause.message : String(cause)}`, + }), + }); +} + +export const realtimeSession = Effect.fnUntraced(function* (spec: RealtimeSessionSpec) { + const endpoint = yield* realtimeSocketEndpoint(spec.url); + const queue = yield* Queue.sliding(spec.bufferSize); + const joinOutcome = yield* Deferred.make(); + const postgresOutcome = yield* Deferred.make(); + const replicationOutcome = yield* Deferred.make(); + + const counts = { emitted: 0, suppressed: 0 }; + + const record = ( + category: RealtimeCategory, + event: string, + payload: unknown, + options?: { readonly latencyMs?: number; readonly label?: string }, + ): void => { + if (!spec.categories.has(category)) { + counts.suppressed += 1; + return; + } + counts.emitted += 1; + Queue.offerUnsafe(queue, { + seq: counts.emitted, + at: new Date().toISOString(), + category, + event: redactRealtimeText(event), + payload: unwrapRealtimePayload(payload) ?? {}, + ...(options?.latencyMs === undefined ? {} : { latencyMs: options.latencyMs }), + ...(options?.label === undefined ? {} : { label: options.label }), + }); + }; + + const joinTimeoutMs = Duration.toMillis(spec.joinTimeout); + + const handle = yield* Effect.acquireRelease( + Effect.gen(function* () { + const client = new RealtimeClient(endpoint, { + params: { apikey: Redacted.value(spec.apiKey) }, + ...(spec.logLevel === undefined ? {} : { log_level: spec.logLevel }), + heartbeatCallback: (status, latency) => { + record("transport", `heartbeat ${status}`, {}, { latencyMs: latency }); + }, + logger: (kind, message, data) => { + if (isRealtimeHeartbeat(message)) return; + record(realtimeCategoryOfLogKind(kind), message, { data }); + }, + }); + + if (spec.userToken !== undefined) { + const token = Redacted.value(spec.userToken); + yield* Effect.tryPromise({ + try: () => client.setAuth(token), + catch: (cause) => + new RealtimeJoinFailedError({ + reason: "rejected", + message: `the user token could not be applied: ${realtimeReasonText(cause)}`, + }), + }); + } + + const channel = client.channel(spec.channel, { + config: { + broadcast: { + self: spec.broadcastSelf, + ack: spec.broadcastAck, + ...(spec.replicationReady ? { replication_ready: true } : {}), + ...(spec.broadcastReplay === undefined + ? {} + : { + replay: { + since: spec.broadcastReplay.since, + ...(spec.broadcastReplay.limit === undefined + ? {} + : { limit: spec.broadcastReplay.limit }), + }, + }), + }, + presence: { + enabled: spec.presence, + ...(spec.presenceKey === undefined ? {} : { key: spec.presenceKey }), + }, + private: spec.privateChannel, + ...(spec.postgres === undefined ? {} : { postgres_changes_options: { wait: true } }), + }, + }); + + legacyRegisterRealtimeListeners(channel, spec, record, (extension, status, message) => { + const outcome = + status === "ok" + ? Effect.void + : Effect.fail( + new RealtimePostgresSubscriptionFailedError({ + message: + message ?? + `the server refused the ${extension} subscription without giving a reason`, + }), + ); + Deferred.doneUnsafe( + extension === "postgres_changes" ? postgresOutcome : replicationOutcome, + outcome, + ); + }); + + channel.subscribe((status, error) => { + const reason = error === undefined ? undefined : realtimeReasonText(error); + record("channel", `subscribe ${status}`, reason === undefined ? {} : { reason }); + + switch (status) { + case REALTIME_SUBSCRIBE_STATES.SUBSCRIBED: + Deferred.doneUnsafe(joinOutcome, Effect.void); + return; + case REALTIME_SUBSCRIBE_STATES.CHANNEL_ERROR: + Deferred.doneUnsafe( + joinOutcome, + Effect.fail( + new RealtimeJoinFailedError({ + reason: "rejected", + message: `channel "${spec.channel}" was rejected: ${reason ?? "no reason given"}`, + }), + ), + ); + return; + case REALTIME_SUBSCRIBE_STATES.TIMED_OUT: + Deferred.doneUnsafe( + joinOutcome, + Effect.fail( + new RealtimeJoinFailedError({ + reason: "timed_out", + message: `joining channel "${spec.channel}" timed out after ${joinTimeoutMs}ms`, + }), + ), + ); + return; + default: + Deferred.doneUnsafe( + joinOutcome, + Effect.fail( + new RealtimeJoinFailedError({ + reason: "closed", + message: `the connection closed before channel "${spec.channel}" joined`, + }), + ), + ); + } + }, joinTimeoutMs); + + return { client, channel }; + }), + ({ client }) => + Effect.tryPromise(() => client.removeAllChannels()).pipe( + Effect.ignore, + Effect.andThen( + Effect.sync(() => { + Queue.endUnsafe(queue); + }), + ), + ), + ); + + const session: RealtimeSession = { + events: Stream.fromQueue(queue), + joined: Deferred.await(joinOutcome).pipe( + Effect.timeoutOrElse({ + duration: Duration.sum(spec.joinTimeout, Duration.seconds(10)), + orElse: () => + Effect.fail( + new RealtimeJoinFailedError({ + reason: "timed_out", + message: `no join result for channel "${spec.channel}" within ${joinTimeoutMs}ms`, + }), + ), + }), + ), + postgresSubscribed: + spec.postgres === undefined + ? Effect.void + : Deferred.await(postgresOutcome).pipe( + Effect.timeoutOrElse({ + duration: Duration.sum(spec.joinTimeout, Duration.seconds(10)), + orElse: () => + Effect.fail( + new RealtimePostgresSubscriptionFailedError({ + message: `the server never confirmed the subscription to ${spec.postgres?.schema}.${spec.postgres?.table}`, + }), + ), + }), + ), + replicationEstablished: !spec.replicationReady + ? Effect.void + : Deferred.await(replicationOutcome).pipe( + Effect.timeoutOrElse({ + duration: Duration.sum(spec.joinTimeout, Duration.seconds(10)), + orElse: () => + Effect.fail( + new RealtimePostgresSubscriptionFailedError({ + message: + "the server never confirmed that the replication connection was established", + }), + ), + }), + ), + broadcast: (event, payload) => + realtimeSendResult( + () => handle.channel.send({ type: "broadcast", event, payload }), + `broadcast "${event}"`, + ), + track: (state) => realtimeSendResult(() => handle.channel.track(state), "presence track"), + presenceState: Effect.sync(() => handle.channel.presenceState()), + counts: Effect.sync(() => ({ emitted: counts.emitted, suppressed: counts.suppressed })), + }; + + return session; +}); + +function realtimeSendResult( + send: () => Promise, + what: string, +): Effect.Effect { + return Effect.tryPromise({ + try: send, + catch: (cause) => + new RealtimeBroadcastFailedError({ + message: `${what} failed: ${realtimeReasonText(cause)}`, + }), + }).pipe( + Effect.flatMap((status) => + status === "ok" + ? Effect.void + : Effect.fail( + new RealtimeBroadcastFailedError({ + message: `${what} was not acknowledged: ${status}`, + }), + ), + ), + ); +} + +function realtimeReasonText(cause: unknown): string { + const text = cause instanceof Error ? cause.message : String(cause); + return redactRealtimeText(text.replace(/^Error:\s*/i, "").trim()); +} + +function legacyRegisterRealtimeListeners( + channel: RealtimeChannel, + spec: RealtimeSessionSpec, + record: ( + category: RealtimeCategory, + event: string, + payload: unknown, + options?: { readonly latencyMs?: number; readonly label?: string }, + ) => void, + onSubscriptionStatus: ( + extension: string, + status: "ok" | "error", + message: string | undefined, + ) => void, +): void { + channel.on("system", {}, (payload) => { + const extension = typeof payload["extension"] === "string" ? payload["extension"] : "system"; + const status = typeof payload["status"] === "string" ? payload["status"] : undefined; + const message = typeof payload["message"] === "string" ? payload["message"] : undefined; + + if (status === "ok" || status === "error") { + onSubscriptionStatus(extension, status, message); + } + + record( + "system", + extension, + payload, + status === "error" + ? { label: "Subscription refused" } + : status === "ok" + ? { label: "Subscription confirmed" } + : undefined, + ); + }); + + channel.on("broadcast", { event: "*" }, (payload) => { + const event = typeof payload.event === "string" ? payload.event : "broadcast"; + record("broadcast", event, payload); + }); + + if (spec.presence) { + channel.on("presence", { event: "sync" }, () => { + record("presence", "sync", { state: channel.presenceState() }); + }); + channel.on("presence", { event: "join" }, (payload) => { + record("presence", "join", payload); + }); + channel.on("presence", { event: "leave" }, (payload) => { + record("presence", "leave", payload); + }); + } + + const postgres = spec.postgres; + if (postgres !== undefined) { + channel.on( + "postgres_changes", + { + event: postgres.event, + schema: postgres.schema, + table: postgres.table, + ...(postgres.filter === undefined ? {} : { filter: postgres.filter }), + ...(postgres.select.length === 0 ? {} : { select: [...postgres.select] }), + }, + (payload) => { + const lag = realtimeCommitLagMs(payload.commit_timestamp); + record( + "postgres", + typeof payload.eventType === "string" ? payload.eventType : "postgres_changes", + payload, + lag === undefined ? undefined : { latencyMs: lag }, + ); + }, + ); + } +} + +function realtimeCommitLagMs(commitTimestamp: unknown): number | undefined { + if (typeof commitTimestamp !== "string") return undefined; + const committedAt = Date.parse(commitTimestamp); + if (Number.isNaN(committedAt)) return undefined; + const lag = Date.now() - committedAt; + return lag < 0 ? undefined : lag; +} diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index 6054c9a3a7..53c68cf20c 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -209,7 +209,9 @@ export const textOutputLayer = Layer.effect( event: (event: StreamEvent) => event.type === "log-entry" ? Effect.sync(() => log.info(`[${event.service}] ${event.line}`)) - : Effect.sync(() => log.info(JSON.stringify(event))), + : event.type === "realtime-frame" + ? Effect.sync(() => log.info(event.line)) + : Effect.sync(() => log.info(JSON.stringify(event))), task: (message: string) => Effect.sync(() => { let shown = false; diff --git a/apps/cli/src/shared/output/types.ts b/apps/cli/src/shared/output/types.ts index 6c935aa289..4c6663a136 100644 --- a/apps/cli/src/shared/output/types.ts +++ b/apps/cli/src/shared/output/types.ts @@ -15,6 +15,17 @@ export type StreamEvent = readonly line: string; readonly source: "history" | "live"; } + | { + readonly type: "realtime-frame"; + readonly timestamp: string; + readonly seq: number; + readonly category: string; + readonly event: string; + readonly label: string; + readonly line: string; + readonly payload: unknown; + readonly latencyMs?: number; + } | { readonly type: "result"; readonly data: unknown; diff --git a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt index 602fe52097..aa627d42da 100644 --- a/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt +++ b/apps/cli/src/shared/telemetry/__fixtures__/error-tags.txt @@ -428,6 +428,22 @@ PullOutputFlagUnsupportedError PullParentRefInvalidError PullUncommittedChangesError PullWorkdirError +RealtimeApiKeysNetworkError +RealtimeApiKeysStatusError +RealtimeBroadcastFailedError +RealtimeConfigError +RealtimeEndpointUnhealthyError +RealtimeInvalidOptionError +RealtimeInvalidPayloadError +RealtimeInvalidUrlError +RealtimeJoinFailedError +RealtimeKeyRejectedError +RealtimeMissingApiKeyError +RealtimeMutuallyExclusiveFlagsError +RealtimeOutputFlagUnsupportedError +RealtimePostgresSubscriptionFailedError +RealtimeSignInFailedError +RealtimeTargetNotResolvedError ResetLocalDbNotRunningError ResetReplicationSlotsError RestartServicesError diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 725c351284..48cdf5ea08 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -111,6 +111,12 @@ const CLI_ERROR_FINGERPRINT_SUFFIXES = [ "port_allocation", "port_conflict", "query", + "realtime_broadcast_unacked", + "realtime_channel_closed", + "realtime_handshake_refused", + "realtime_join_rejected", + "realtime_join_timeout", + "realtime_subscription_refused", "registry_pull", "replication_slots_active", "replication_slots_query", diff --git a/apps/cli/tests/helpers/realtime.ts b/apps/cli/tests/helpers/realtime.ts new file mode 100644 index 0000000000..92964dae43 --- /dev/null +++ b/apps/cli/tests/helpers/realtime.ts @@ -0,0 +1,252 @@ +import { Effect, Layer, Option, Stream } from "effect"; +import { BunServices } from "@effect/platform-bun"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; +import { CommandPlatformApiFactory } from "../../src/auth/command-platform-api-factory.service.ts"; +import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; +import { OutputFlag } from "../../src/command-internal/global-flags.ts"; +import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; +import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; +import { RealtimeSessions } from "../../src/commands/inspect/realtime/realtime-session.service.ts"; +import { + RealtimeBroadcastFailedError, + RealtimeJoinFailedError, + RealtimePostgresSubscriptionFailedError, + type RealtimeJoinFailureReason, +} from "../../src/commands/inspect/realtime/realtime.errors.ts"; +import type { RealtimeEvent } from "../../src/commands/inspect/realtime/realtime.events.ts"; +import type { RealtimePresenceState } from "@supabase/realtime-js"; +import type { + RealtimeSession, + RealtimeSessionSpec, +} from "../../src/commands/inspect/realtime/realtime.session.ts"; +import { + mockCommandSettings, + mockLinkedProjectCacheTracked, + mockCommandPlatformApiService, + mockTelemetryStateTracked, +} from "./command-mocks.ts"; +import { mockOutput, mockProcessControl } from "./mocks.ts"; +import type { OutputFormat } from "../../src/shared/output/types.ts"; + +export interface RealtimeFrame { + readonly category: RealtimeEvent["category"]; + readonly event: string; + readonly payload?: unknown; + readonly label?: string; + readonly latencyMs?: number; +} + +export interface MockRealtimeSessionOptions { + readonly frames?: ReadonlyArray; + readonly joinFails?: RealtimeJoinFailureReason; + readonly subscriptionFails?: string; + readonly replicationFails?: string; + readonly broadcastFails?: string; + readonly presence?: RealtimePresenceState<{ presence_ref: string } & Record>; + readonly suppressed?: number; +} + +export interface MockRealtimeSessionState { + readonly specs: ReadonlyArray; + readonly broadcasts: ReadonlyArray<{ readonly event: string; readonly payload: unknown }>; + readonly tracked: ReadonlyArray>; + readonly closed: number; +} + +export function mockRealtimeSession(opts: MockRealtimeSessionOptions = {}) { + const specs: Array = []; + const broadcasts: Array<{ event: string; payload: unknown }> = []; + const tracked: Array> = []; + let closed = 0; + + const frames: ReadonlyArray = (opts.frames ?? []).map((frame, index) => ({ + seq: index + 1, + at: new Date(Date.UTC(2026, 0, 1, 12, 0, index)).toISOString(), + category: frame.category, + event: frame.event, + payload: frame.payload ?? {}, + ...(frame.label === undefined ? {} : { label: frame.label }), + ...(frame.latencyMs === undefined ? {} : { latencyMs: frame.latencyMs }), + })); + + const layer = Layer.succeed( + RealtimeSessions, + RealtimeSessions.of({ + open: (spec) => + Effect.gen(function* () { + specs.push(spec); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + closed += 1; + }), + ); + + const session: RealtimeSession = { + events: Stream.fromIterable(frames), + joined: + opts.joinFails === undefined + ? Effect.void + : Effect.fail( + new RealtimeJoinFailedError({ + reason: opts.joinFails, + message: `channel "${spec.channel}" did not join (${opts.joinFails})`, + }), + ), + replicationEstablished: + opts.replicationFails === undefined + ? Effect.void + : Effect.fail( + new RealtimePostgresSubscriptionFailedError({ + message: opts.replicationFails, + }), + ), + postgresSubscribed: + opts.subscriptionFails === undefined + ? Effect.void + : Effect.fail( + new RealtimePostgresSubscriptionFailedError({ + message: opts.subscriptionFails, + }), + ), + broadcast: (event, payload) => + opts.broadcastFails === undefined + ? Effect.sync(() => { + broadcasts.push({ event, payload }); + }) + : Effect.fail(new RealtimeBroadcastFailedError({ message: opts.broadcastFails })), + track: (state) => + Effect.sync(() => { + tracked.push(state); + }), + presenceState: Effect.succeed(opts.presence ?? {}), + counts: Effect.succeed({ + emitted: frames.length, + suppressed: opts.suppressed ?? 0, + }), + }; + + return session; + }), + }), + ); + + return { + layer, + get state(): MockRealtimeSessionState { + return { specs, broadcasts, tracked, closed }; + }, + }; +} + +export interface SetupRealtimeOptions extends MockRealtimeSessionOptions { + readonly format?: OutputFormat; + readonly workdir?: string; + readonly projectRef?: string; + readonly linkedFails?: boolean; + readonly apiKeys?: ReadonlyArray; + readonly passwords?: ReadonlyArray; + readonly httpStatus?: number; + readonly httpBody?: unknown; + readonly signal?: "SIGINT" | "SIGTERM"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; + readonly cliArgs?: ReadonlyArray; +} + +export function setupRealtime(opts: SetupRealtimeOptions = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptPasswordResponses: opts.passwords, + }); + const processControl = mockProcessControl( + opts.signal === undefined ? {} : { signal: opts.signal }, + ); + const telemetry = mockTelemetryStateTracked(); + const linkedCache = mockLinkedProjectCacheTracked(); + const sessions = mockRealtimeSession(opts); + + const requests: Array<{ readonly method: string; readonly url: string }> = []; + const httpLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push({ method: request.method, url: request.url }); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(opts.httpBody ?? {}), { + status: opts.httpStatus ?? 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ); + + const projectRef = opts.projectRef ?? "abcdefghijklmnopqrst"; + const notLinked = () => + new ProjectRefNotLinkedError({ + message: "Cannot find project ref. Have you run supabase link?", + }); + const projectRefLayer = Layer.succeed(ProjectRefResolver, { + resolve: () => + opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRef), + resolveForLink: () => + opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRef), + resolveOptional: () => Effect.succeed(Option.some(projectRef)), + loadProjectRef: (flagValue: Option.Option) => + Option.isSome(flagValue) && flagValue.value.length > 0 + ? Effect.succeed(flagValue.value) + : opts.linkedFails === true + ? Effect.fail(notLinked()) + : Effect.succeed(projectRef), + promptProjectRef: () => Effect.succeed(projectRef), + }); + + const managementApi = mockCommandPlatformApiService({ + v1: { + getProjectApiKeys: () => + Effect.succeed( + opts.apiKeys ?? [{ name: "anon", api_key: "test-publishable-key", type: "publishable" }], + ), + }, + }); + + const layer = Layer.mergeAll( + out.layer, + processControl.layer, + telemetry.layer, + linkedCache.layer, + sessions.layer, + httpLayer, + projectRefLayer, + BunServices.layer, + Layer.succeed( + OutputFlag, + opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + ), + Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), + mockCommandSettings({ workdir: opts.workdir ?? "/tmp/legacy-realtime" }), + Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), + }), + ); + + return { layer, out, sessions, telemetry, linkedCache, requests, processControl }; +} + +export const REALTIME_EXPLICIT_TARGET = { + url: Option.some("http://127.0.0.1:54321"), + apiKey: Option.some("sb_publishable_testtesttesttest"), + secretKey: Option.none(), + serviceRole: false, + projectRef: Option.none(), + local: false, + linked: false, + userToken: Option.none(), + email: Option.none(), + password: Option.none(), + private: false, + timeout: 15, +} as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 720aa2a3fb..0736c39f27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -334,6 +334,9 @@ importers: '@supabase/pg-topo': specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6(supports-color@7.2.0) + '@supabase/realtime-js': + specifier: ^2.116.0 + version: 2.116.0 '@supabase/stack': specifier: workspace:* version: link:../../packages/stack @@ -2780,6 +2783,10 @@ packages: resolution: {integrity: sha512-vZ+j079SKrM0Xiq7MJCvQKLDpaH2kfKfLY68xuQE1sqsCsMmx1CyrDBJHsxZ3cX01VOs5SI9igmoZAF3BmdZxw==} engines: {node: '>=22.0.0'} + '@supabase/realtime-js@2.116.0': + resolution: {integrity: sha512-MHAnlXxi2s6yiJsZsQMfs2B3RFxeVfQWxerqYhIMqcCQV/FuY3LIeouPEkXw/ah7wUWMLYwempF9MOCUScyddg==} + engines: {node: '>=22.0.0'} + '@supabase/storage-js@2.112.4': resolution: {integrity: sha512-lQ0JemuTlMIXVKgSci1qez8yPnM5hyDngeAfEBjZS2Om4D+Cus0EE5BE6glFobrxdyii1OF4UzWfF0zcQgDq5A==} engines: {node: '>=22.0.0'} @@ -8466,6 +8473,11 @@ snapshots: '@supabase/phoenix': 0.4.5 tslib: 2.8.1 + '@supabase/realtime-js@2.116.0': + dependencies: + '@supabase/phoenix': 0.4.5 + tslib: 2.8.1 + '@supabase/storage-js@2.112.4': dependencies: iceberg-js: 0.8.1