Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,44 @@
import type { LookupFn } from "openclaw/plugin-sdk";
import { SsrFBlockedError } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { authenticate } from "./auth.js";
describe("tlon urbit auth ssrf", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("blocks private IPs by default", async () => {
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
await expect(authenticate("http://127.0.0.1:8080", "code")).rejects.toBeInstanceOf(
SsrFBlockedError,
);
expect(mockFetch).not.toHaveBeenCalled();
});
it("allows private IPs when allowPrivateNetwork is enabled", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
text: async () => "ok",
headers: new Headers({
"set-cookie": "urbauth-~zod=123; Path=/; HttpOnly",
}),
});
vi.stubGlobal("fetch", mockFetch);
const lookupFn = (async () => [{ address: "127.0.0.1", family: 4 }]) as unknown as LookupFn;
const cookie = await authenticate("http://127.0.0.1:8080", "code", {
ssrfPolicy: { allowPrivateNetwork: true },
lookupFn,
});
expect(cookie).toContain("urbauth-~zod=123");
expect(mockFetch).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,48 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { UrbitAuthError } from "./errors.js";
import { urbitFetch } from "./fetch.js";
export type UrbitAuthenticateOptions = {
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
timeoutMs?: number;
};
export async function authenticate(
url: string,
code: string,
options: UrbitAuthenticateOptions = {},
): Promise<string> {
const { response, release } = await urbitFetch({
baseUrl: url,
path: "/~/login",
init: {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ password: code }).toString(),
},
ssrfPolicy: options.ssrfPolicy,
lookupFn: options.lookupFn,
fetchImpl: options.fetchImpl,
timeoutMs: options.timeoutMs ?? 15_000,
maxRedirects: 3,
auditContext: "tlon-urbit-login",
});
try {
if (!response.ok) {
throw new UrbitAuthError("auth_failed", `Login failed with status ${response.status}`);
}
// Some Urbit setups require the response body to be read before cookie headers finalize.
await response.text().catch(() => {});
const cookie = response.headers.get("set-cookie");
if (!cookie) {
throw new UrbitAuthError("missing_cookie", "No authentication cookie received");
}
return cookie;
} finally {
await release();
}
}

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { validateUrbitBaseUrl } from "./base-url.js";
describe("validateUrbitBaseUrl", () => {
it("adds https:// when scheme is missing and strips path/query fragments", () => {
const result = validateUrbitBaseUrl("example.com/foo?bar=baz");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.baseUrl).toBe("https://example.com");
expect(result.hostname).toBe("example.com");
});
it("rejects non-http schemes", () => {
const result = validateUrbitBaseUrl("file:///etc/passwd");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain("http:// or https://");
});
it("rejects embedded credentials", () => {
const result = validateUrbitBaseUrl("https://user:pass@example.com");
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toContain("credentials");
});
it("normalizes a trailing dot in the hostname for origin construction", () => {
const result = validateUrbitBaseUrl("https://example.com./foo");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.baseUrl).toBe("https://example.com");
expect(result.hostname).toBe("example.com");
});
it("preserves port in the normalized origin", () => {
const result = validateUrbitBaseUrl("http://example.com:8080/~/login");
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.baseUrl).toBe("http://example.com:8080");
});
});

View File

@@ -0,0 +1,57 @@
import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk";
export type UrbitBaseUrlValidation =
| { ok: true; baseUrl: string; hostname: string }
| { ok: false; error: string };
function hasScheme(value: string): boolean {
return /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(value);
}
export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation {
const trimmed = String(raw ?? "").trim();
if (!trimmed) {
return { ok: false, error: "Required" };
}
const candidate = hasScheme(trimmed) ? trimmed : `https://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return { ok: false, error: "Invalid URL" };
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return { ok: false, error: "URL must use http:// or https://" };
}
if (parsed.username || parsed.password) {
return { ok: false, error: "URL must not include credentials" };
}
const hostname = parsed.hostname.trim().toLowerCase().replace(/\.$/, "");
if (!hostname) {
return { ok: false, error: "Invalid hostname" };
}
// Normalize to origin so callers can't smuggle paths/query fragments into the base URL,
// and strip a trailing dot from the hostname (DNS root label).
const isIpv6 = hostname.includes(":");
const host = parsed.port
? `${isIpv6 ? `[${hostname}]` : hostname}:${parsed.port}`
: isIpv6
? `[${hostname}]`
: hostname;
return { ok: true, baseUrl: `${parsed.protocol}//${host}`, hostname };
}
export function isBlockedUrbitHostname(hostname: string): boolean {
const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
if (!normalized) {
return false;
}
return isBlockedHostnameOrIp(normalized);
}

View File

@@ -0,0 +1,158 @@
import { randomUUID } from "node:crypto";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { urbitFetch } from "./fetch.js";
export type UrbitChannelClientOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export class UrbitChannelClient {
readonly baseUrl: string;
readonly cookie: string;
readonly ship: string;
readonly ssrfPolicy?: SsrFPolicy;
readonly lookupFn?: LookupFn;
readonly fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
private channelId: string | null = null;
constructor(url: string, cookie: string, options: UrbitChannelClientOptions = {}) {
const ctx = getUrbitContext(url, options.ship);
this.baseUrl = ctx.baseUrl;
this.cookie = normalizeUrbitCookie(cookie);
this.ship = ctx.ship;
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
private get channelPath(): string {
const id = this.channelId;
if (!id) {
throw new Error("Channel not opened");
}
return `/~/channel/${id}`;
}
async open(): Promise<void> {
if (this.channelId) {
return;
}
const channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelId = channelId;
try {
await ensureUrbitChannelOpen(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ship: this.ship,
channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{
createBody: [],
createAuditContext: "tlon-urbit-channel-open",
},
);
} catch (error) {
this.channelId = null;
throw error;
}
}
async poke(params: { app: string; mark: string; json: unknown }): Promise<number> {
await this.open();
const channelId = this.channelId;
if (!channelId) {
throw new Error("Channel not opened");
}
return await pokeUrbitChannel(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ship: this.ship,
channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ ...params, auditContext: "tlon-urbit-poke" },
);
}
async scry(path: string): Promise<unknown> {
return await scryUrbitPath(
{
baseUrl: this.baseUrl,
cookie: this.cookie,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ path, auditContext: "tlon-urbit-scry" },
);
}
async getOurName(): Promise<string> {
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: "/~/name",
init: {
method: "GET",
headers: { Cookie: this.cookie },
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-name",
});
try {
if (!response.ok) {
throw new Error(`Name request failed: ${response.status}`);
}
const text = await response.text();
return text.trim();
} finally {
await release();
}
}
async close(): Promise<void> {
if (!this.channelId) {
return;
}
const channelPath = this.channelPath;
this.channelId = null;
try {
const { response, release } = await urbitFetch({
baseUrl: this.baseUrl,
path: channelPath,
init: { method: "DELETE", headers: { Cookie: this.cookie } },
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
} catch {
// ignore cleanup errors
}
}
}

View File

@@ -0,0 +1,164 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { UrbitHttpError } from "./errors.js";
import { urbitFetch } from "./fetch.js";
export type UrbitChannelDeps = {
baseUrl: string;
cookie: string;
ship: string;
channelId: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
};
export async function pokeUrbitChannel(
deps: UrbitChannelDeps,
params: { app: string; mark: string; json: unknown; auditContext: string },
): Promise<number> {
const pokeId = Date.now();
const pokeData = {
id: pokeId,
action: "poke",
ship: deps.ship,
app: params.app,
mark: params.mark,
json: params.json,
};
const { response, release } = await urbitFetch({
baseUrl: deps.baseUrl,
path: `/~/channel/${deps.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: deps.cookie,
},
body: JSON.stringify([pokeData]),
},
ssrfPolicy: deps.ssrfPolicy,
lookupFn: deps.lookupFn,
fetchImpl: deps.fetchImpl,
timeoutMs: 30_000,
auditContext: params.auditContext,
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text().catch(() => "");
throw new Error(`Poke failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`);
}
return pokeId;
} finally {
await release();
}
}
export async function scryUrbitPath(
deps: Pick<UrbitChannelDeps, "baseUrl" | "cookie" | "ssrfPolicy" | "lookupFn" | "fetchImpl">,
params: { path: string; auditContext: string },
): Promise<unknown> {
const scryPath = `/~/scry${params.path}`;
const { response, release } = await urbitFetch({
baseUrl: deps.baseUrl,
path: scryPath,
init: {
method: "GET",
headers: { Cookie: deps.cookie },
},
ssrfPolicy: deps.ssrfPolicy,
lookupFn: deps.lookupFn,
fetchImpl: deps.fetchImpl,
timeoutMs: 30_000,
auditContext: params.auditContext,
});
try {
if (!response.ok) {
throw new Error(`Scry failed: ${response.status} for path ${params.path}`);
}
return await response.json();
} finally {
await release();
}
}
export async function createUrbitChannel(
deps: UrbitChannelDeps,
params: { body: unknown; auditContext: string },
): Promise<void> {
const { response, release } = await urbitFetch({
baseUrl: deps.baseUrl,
path: `/~/channel/${deps.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: deps.cookie,
},
body: JSON.stringify(params.body),
},
ssrfPolicy: deps.ssrfPolicy,
lookupFn: deps.lookupFn,
fetchImpl: deps.fetchImpl,
timeoutMs: 30_000,
auditContext: params.auditContext,
});
try {
if (!response.ok && response.status !== 204) {
throw new UrbitHttpError({ operation: "Channel creation", status: response.status });
}
} finally {
await release();
}
}
export async function wakeUrbitChannel(deps: UrbitChannelDeps): Promise<void> {
const { response, release } = await urbitFetch({
baseUrl: deps.baseUrl,
path: `/~/channel/${deps.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: deps.cookie,
},
body: JSON.stringify([
{
id: Date.now(),
action: "poke",
ship: deps.ship,
app: "hood",
mark: "helm-hi",
json: "Opening API channel",
},
]),
},
ssrfPolicy: deps.ssrfPolicy,
lookupFn: deps.lookupFn,
fetchImpl: deps.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-wake",
});
try {
if (!response.ok && response.status !== 204) {
throw new UrbitHttpError({ operation: "Channel activation", status: response.status });
}
} finally {
await release();
}
}
export async function ensureUrbitChannelOpen(
deps: UrbitChannelDeps,
params: { createBody: unknown; createAuditContext: string },
): Promise<void> {
await createUrbitChannel(deps, {
body: params.createBody,
auditContext: params.createAuditContext,
});
await wakeUrbitChannel(deps);
}

View File

@@ -0,0 +1,47 @@
import type { SsrFPolicy } from "openclaw/plugin-sdk";
import { validateUrbitBaseUrl } from "./base-url.js";
import { UrbitUrlError } from "./errors.js";
export type UrbitContext = {
baseUrl: string;
hostname: string;
ship: string;
};
export function resolveShipFromHostname(hostname: string): string {
const trimmed = hostname.trim().toLowerCase().replace(/\.$/, "");
if (!trimmed) {
return "";
}
if (trimmed.includes(".")) {
return trimmed.split(".")[0] ?? trimmed;
}
return trimmed;
}
export function normalizeUrbitShip(ship: string | undefined, hostname: string): string {
const raw = ship?.replace(/^~/, "") ?? resolveShipFromHostname(hostname);
return raw.trim();
}
export function normalizeUrbitCookie(cookie: string): string {
return cookie.split(";")[0] ?? cookie;
}
export function getUrbitContext(url: string, ship?: string): UrbitContext {
const validated = validateUrbitBaseUrl(url);
if (!validated.ok) {
throw new UrbitUrlError(validated.error);
}
return {
baseUrl: validated.baseUrl,
hostname: validated.hostname,
ship: normalizeUrbitShip(ship, validated.hostname),
};
}
export function ssrfPolicyFromAllowPrivateNetwork(
allowPrivateNetwork: boolean | null | undefined,
): SsrFPolicy | undefined {
return allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined;
}

View File

@@ -0,0 +1,51 @@
export type UrbitErrorCode =
| "invalid_url"
| "http_error"
| "auth_failed"
| "missing_cookie"
| "channel_not_open";
export class UrbitError extends Error {
readonly code: UrbitErrorCode;
constructor(code: UrbitErrorCode, message: string, options?: { cause?: unknown }) {
super(message, options);
this.name = "UrbitError";
this.code = code;
}
}
export class UrbitUrlError extends UrbitError {
constructor(message: string, options?: { cause?: unknown }) {
super("invalid_url", message, options);
this.name = "UrbitUrlError";
}
}
export class UrbitHttpError extends UrbitError {
readonly status: number;
readonly operation: string;
readonly bodyText?: string;
constructor(params: { operation: string; status: number; bodyText?: string; cause?: unknown }) {
const suffix = params.bodyText ? ` - ${params.bodyText}` : "";
super("http_error", `${params.operation} failed: ${params.status}${suffix}`, {
cause: params.cause,
});
this.name = "UrbitHttpError";
this.status = params.status;
this.operation = params.operation;
this.bodyText = params.bodyText;
}
}
export class UrbitAuthError extends UrbitError {
constructor(
code: "auth_failed" | "missing_cookie",
message: string,
options?: { cause?: unknown },
) {
super(code, message, options);
this.name = "UrbitAuthError";
}
}

View File

@@ -0,0 +1,39 @@
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
import { validateUrbitBaseUrl } from "./base-url.js";
import { UrbitUrlError } from "./errors.js";
export type UrbitFetchOptions = {
baseUrl: string;
path: string;
init?: RequestInit;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
timeoutMs?: number;
maxRedirects?: number;
signal?: AbortSignal;
auditContext?: string;
pinDns?: boolean;
};
export async function urbitFetch(params: UrbitFetchOptions) {
const validated = validateUrbitBaseUrl(params.baseUrl);
if (!validated.ok) {
throw new UrbitUrlError(validated.error);
}
const url = new URL(params.path, validated.baseUrl).toString();
return await fetchWithSsrFGuard({
url,
fetchImpl: params.fetchImpl,
init: params.init,
timeoutMs: params.timeoutMs,
maxRedirects: params.maxRedirects,
signal: params.signal,
policy: params.ssrfPolicy,
lookupFn: params.lookupFn,
auditContext: params.auditContext,
pinDns: params.pinDns,
});
}

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@urbit/aura", () => ({
scot: vi.fn(() => "mocked-ud"),
da: {
fromUnix: vi.fn(() => 123n),
},
}));
describe("sendDm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses aura v3 helpers for the DM id", async () => {
const { sendDm } = await import("./send.js");
const aura = await import("@urbit/aura");
const scot = vi.mocked(aura.scot);
const fromUnix = vi.mocked(aura.da.fromUnix);
const sentAt = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(sentAt);
const poke = vi.fn(async () => ({}));
const result = await sendDm({
api: { poke },
fromShip: "~zod",
toShip: "~nec",
text: "hi",
});
expect(fromUnix).toHaveBeenCalledWith(sentAt);
expect(scot).toHaveBeenCalledWith("ud", 123n);
expect(poke).toHaveBeenCalledTimes(1);
expect(result.messageId).toBe("~zod/mocked-ud");
});
});

View File

@@ -0,0 +1,131 @@
import { scot, da } from "@urbit/aura";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
};
type SendTextParams = {
api: TlonPokeApi;
fromShip: string;
toShip: string;
text: string;
};
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const idUd = scot("ud", da.fromUnix(sentAt));
const id = `${fromShip}/${idUd}`;
const delta = {
add: {
memo: {
content: story,
author: fromShip,
sent: sentAt,
},
kind: null,
time: null,
},
};
const action = {
ship: toShip,
diff: { id, delta },
};
await api.poke({
app: "chat",
mark: "chat-dm-action",
json: action,
});
return { channel: "tlon", messageId: id };
}
type SendGroupParams = {
api: TlonPokeApi;
fromShip: string;
hostShip: string;
channelName: string;
text: string;
replyToId?: string | null;
};
export async function sendGroupMessage({
api,
fromShip,
hostShip,
channelName,
text,
replyToId,
}: SendGroupParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
// Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
let formattedReplyId = replyToId;
if (replyToId && /^\d+$/.test(replyToId)) {
try {
formattedReplyId = scot("ud", BigInt(replyToId));
} catch {
// Fall back to raw ID if formatting fails
}
}
const action = {
channel: {
nest: `chat/${hostShip}/${channelName}`,
action: formattedReplyId
? {
// Thread reply - needs post wrapper around reply action
// ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
post: {
reply: {
id: formattedReplyId,
action: {
add: {
content: story,
author: fromShip,
sent: sentAt,
},
},
},
},
}
: {
// Regular post
post: {
add: {
content: story,
author: fromShip,
sent: sentAt,
kind: "/chat",
blob: null,
meta: null,
},
},
},
},
};
await api.poke({
app: "channels",
mark: "channel-action-1",
json: action,
});
return { channel: "tlon", messageId: `${fromShip}/${sentAt}` };
}
export function buildMediaText(text: string | undefined, mediaUrl: string | undefined): string {
const cleanText = text?.trim() ?? "";
const cleanUrl = mediaUrl?.trim() ?? "";
if (cleanText && cleanUrl) {
return `${cleanText}\n${cleanUrl}`;
}
if (cleanUrl) {
return cleanUrl;
}
return cleanText;
}

View File

@@ -0,0 +1,44 @@
import type { LookupFn } from "openclaw/plugin-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { UrbitSSEClient } from "./sse-client.js";
const mockFetch = vi.fn();
describe("UrbitSSEClient", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("sends subscriptions added after connect", async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200, text: async () => "" });
const lookupFn = (async () => [{ address: "1.1.1.1", family: 4 }]) as unknown as LookupFn;
const client = new UrbitSSEClient("https://example.com", "urbauth-~zod=123", {
lookupFn,
});
(client as { isConnected: boolean }).isConnected = true;
await client.subscribe({
app: "chat",
path: "/dm/~zod",
event: () => {},
});
expect(mockFetch).toHaveBeenCalledTimes(1);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe(client.channelUrl);
expect(init.method).toBe("PUT");
const body = JSON.parse(init.body as string);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({
action: "subscribe",
app: "chat",
path: "/dm/~zod",
});
});
});

View File

@@ -0,0 +1,431 @@
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import type { LookupFn, SsrFPolicy } from "openclaw/plugin-sdk";
import { ensureUrbitChannelOpen, pokeUrbitChannel, scryUrbitPath } from "./channel-ops.js";
import { getUrbitContext, normalizeUrbitCookie } from "./context.js";
import { urbitFetch } from "./fetch.js";
export type UrbitSseLogger = {
log?: (message: string) => void;
error?: (message: string) => void;
};
type UrbitSseOptions = {
ship?: string;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
onReconnect?: (client: UrbitSSEClient) => Promise<void> | void;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
reconnectDelay?: number;
maxReconnectDelay?: number;
logger?: UrbitSseLogger;
};
export class UrbitSSEClient {
url: string;
cookie: string;
ship: string;
channelId: string;
channelUrl: string;
subscriptions: Array<{
id: number;
action: "subscribe";
ship: string;
app: string;
path: string;
}> = [];
eventHandlers = new Map<
number,
{ event?: (data: unknown) => void; err?: (error: unknown) => void; quit?: () => void }
>();
aborted = false;
streamController: AbortController | null = null;
onReconnect: UrbitSseOptions["onReconnect"] | null;
autoReconnect: boolean;
reconnectAttempts = 0;
maxReconnectAttempts: number;
reconnectDelay: number;
maxReconnectDelay: number;
isConnected = false;
logger: UrbitSseLogger;
ssrfPolicy?: SsrFPolicy;
lookupFn?: LookupFn;
fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
streamRelease: (() => Promise<void>) | null = null;
constructor(url: string, cookie: string, options: UrbitSseOptions = {}) {
const ctx = getUrbitContext(url, options.ship);
this.url = ctx.baseUrl;
this.cookie = normalizeUrbitCookie(cookie);
this.ship = ctx.ship;
this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
this.onReconnect = options.onReconnect ?? null;
this.autoReconnect = options.autoReconnect !== false;
this.maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
this.reconnectDelay = options.reconnectDelay ?? 1000;
this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
this.logger = options.logger ?? {};
this.ssrfPolicy = options.ssrfPolicy;
this.lookupFn = options.lookupFn;
this.fetchImpl = options.fetchImpl;
}
async subscribe(params: {
app: string;
path: string;
event?: (data: unknown) => void;
err?: (error: unknown) => void;
quit?: () => void;
}) {
const subId = this.subscriptions.length + 1;
const subscription = {
id: subId,
action: "subscribe",
ship: this.ship,
app: params.app,
path: params.path,
} as const;
this.subscriptions.push(subscription);
this.eventHandlers.set(subId, { event: params.event, err: params.err, quit: params.quit });
if (this.isConnected) {
try {
await this.sendSubscription(subscription);
} catch (error) {
const handler = this.eventHandlers.get(subId);
handler?.err?.(error);
}
}
return subId;
}
private async sendSubscription(subscription: {
id: number;
action: "subscribe";
ship: string;
app: string;
path: string;
}) {
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify([subscription]),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-subscribe",
});
try {
if (!response.ok && response.status !== 204) {
const errorText = await response.text().catch(() => "");
throw new Error(
`Subscribe failed: ${response.status}${errorText ? ` - ${errorText}` : ""}`,
);
}
} finally {
await release();
}
}
async connect() {
await ensureUrbitChannelOpen(
{
baseUrl: this.url,
cookie: this.cookie,
ship: this.ship,
channelId: this.channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{
createBody: this.subscriptions,
createAuditContext: "tlon-urbit-channel-create",
},
);
await this.openStream();
this.isConnected = true;
this.reconnectAttempts = 0;
}
async openStream() {
// Use AbortController with manual timeout so we only abort during initial connection,
// not after the SSE stream is established and actively streaming.
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 60_000);
this.streamController = controller;
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "GET",
headers: {
Accept: "text/event-stream",
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
signal: controller.signal,
auditContext: "tlon-urbit-sse-stream",
});
this.streamRelease = release;
// Clear timeout once connection established (headers received).
clearTimeout(timeoutId);
if (!response.ok) {
await release();
this.streamRelease = null;
throw new Error(`Stream connection failed: ${response.status}`);
}
this.processStream(response.body).catch((error) => {
if (!this.aborted) {
this.logger.error?.(`Stream error: ${String(error)}`);
for (const { err } of this.eventHandlers.values()) {
if (err) {
err(error);
}
}
}
});
}
async processStream(body: ReadableStream<Uint8Array> | Readable | null) {
if (!body) {
return;
}
// oxlint-disable-next-line typescript/no-explicit-any
const stream = body instanceof ReadableStream ? Readable.fromWeb(body as any) : body;
let buffer = "";
try {
for await (const chunk of stream) {
if (this.aborted) {
break;
}
buffer += chunk.toString();
let eventEnd;
while ((eventEnd = buffer.indexOf("\n\n")) !== -1) {
const eventData = buffer.substring(0, eventEnd);
buffer = buffer.substring(eventEnd + 2);
this.processEvent(eventData);
}
}
} finally {
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
this.streamController = null;
if (!this.aborted && this.autoReconnect) {
this.isConnected = false;
this.logger.log?.("[SSE] Stream ended, attempting reconnection...");
await this.attemptReconnect();
}
}
}
processEvent(eventData: string) {
const lines = eventData.split("\n");
let data: string | null = null;
for (const line of lines) {
if (line.startsWith("data: ")) {
data = line.substring(6);
}
}
if (!data) {
return;
}
try {
const parsed = JSON.parse(data) as { id?: number; json?: unknown; response?: string };
if (parsed.response === "quit") {
if (parsed.id) {
const handlers = this.eventHandlers.get(parsed.id);
if (handlers?.quit) {
handlers.quit();
}
}
return;
}
if (parsed.id && this.eventHandlers.has(parsed.id)) {
const { event } = this.eventHandlers.get(parsed.id) ?? {};
if (event && parsed.json) {
event(parsed.json);
}
} else if (parsed.json) {
for (const { event } of this.eventHandlers.values()) {
if (event) {
event(parsed.json);
}
}
}
} catch (error) {
this.logger.error?.(`Error parsing SSE event: ${String(error)}`);
}
}
async poke(params: { app: string; mark: string; json: unknown }) {
return await pokeUrbitChannel(
{
baseUrl: this.url,
cookie: this.cookie,
ship: this.ship,
channelId: this.channelId,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ ...params, auditContext: "tlon-urbit-poke" },
);
}
async scry(path: string) {
return await scryUrbitPath(
{
baseUrl: this.url,
cookie: this.cookie,
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
},
{ path, auditContext: "tlon-urbit-scry" },
);
}
async attemptReconnect() {
if (this.aborted || !this.autoReconnect) {
this.logger.log?.("[SSE] Reconnection aborted or disabled");
return;
}
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
this.logger.error?.(
`[SSE] Max reconnection attempts (${this.maxReconnectAttempts}) reached. Giving up.`,
);
return;
}
this.reconnectAttempts += 1;
const delay = Math.min(
this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1),
this.maxReconnectDelay,
);
this.logger.log?.(
`[SSE] Reconnection attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts} in ${delay}ms...`,
);
await new Promise((resolve) => setTimeout(resolve, delay));
try {
this.channelId = `${Math.floor(Date.now() / 1000)}-${randomUUID()}`;
this.channelUrl = new URL(`/~/channel/${this.channelId}`, this.url).toString();
if (this.onReconnect) {
await this.onReconnect(this);
}
await this.connect();
this.logger.log?.("[SSE] Reconnection successful!");
} catch (error) {
this.logger.error?.(`[SSE] Reconnection failed: ${String(error)}`);
await this.attemptReconnect();
}
}
async close() {
this.aborted = true;
this.isConnected = false;
this.streamController?.abort();
try {
const unsubscribes = this.subscriptions.map((sub) => ({
id: sub.id,
action: "unsubscribe",
subscription: sub.id,
}));
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "PUT",
headers: {
"Content-Type": "application/json",
Cookie: this.cookie,
},
body: JSON.stringify(unsubscribes),
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-unsubscribe",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
{
const { response, release } = await urbitFetch({
baseUrl: this.url,
path: `/~/channel/${this.channelId}`,
init: {
method: "DELETE",
headers: {
Cookie: this.cookie,
},
},
ssrfPolicy: this.ssrfPolicy,
lookupFn: this.lookupFn,
fetchImpl: this.fetchImpl,
timeoutMs: 30_000,
auditContext: "tlon-urbit-channel-close",
});
try {
void response.body?.cancel();
} finally {
await release();
}
}
} catch (error) {
this.logger.error?.(`Error closing channel: ${String(error)}`);
}
if (this.streamRelease) {
const release = this.streamRelease;
this.streamRelease = null;
await release();
}
}
}