mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
- POST /projects/:id/events accepts client-authored events restricted to
the custom.* namespace — system types can't be spoofed. They flow
through the same publishEvent fan-out: realtime, webhooks, and
event_pattern functions all fire.
- Presence: subscribe/unsubscribe bumps a per-project count and
broadcasts presence.update {online} to the realtime stream only —
deliberately not through publishEvent so webhooks and "*" functions
don't fire on connect noise. GET …/realtime/presence reads the count.
- @primora/client gains publish() and presence(); CLI gains events:send.
Verified live: presence 0->1 on subscribe, presence.update frame
delivered, custom event delivered to SSE + fired a custom.* function,
spoofed document.created rejected 400.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
// Realtime subscriptions over the project's SSE domain-event stream
|
|
// (GET /api/v1/projects/:id/realtime/stream). EventSource can't send auth
|
|
// headers, so the stream is consumed via fetch + ReadableStream.
|
|
//
|
|
// Presence: the server broadcasts `presence.update` {online: n} on every
|
|
// subscriber join/leave — subscribe via channel("presence").on("update").
|
|
// Client-broadcast: publish() emits a custom.* event through the server
|
|
// fan-out (webhooks and event_pattern functions fire too).
|
|
|
|
export interface RealtimeEvent {
|
|
/** e.g. "document.created", "object.deleted", "issue.created", "deploy.marker" */
|
|
type: string;
|
|
occurred_at: string;
|
|
data: Record<string, unknown>;
|
|
}
|
|
|
|
export type RealtimeState = "connecting" | "open" | "reconnecting" | "closed";
|
|
|
|
export interface RealtimeClientOptions {
|
|
/** Primora deployment origin, e.g. http://localhost:8090 */
|
|
endpoint: string;
|
|
/** project API key (prm_…) — sent as X-API-Key */
|
|
key: string;
|
|
projectId: string;
|
|
onStateChange?: (state: RealtimeState) => void;
|
|
}
|
|
|
|
export type RealtimeHandler = (event: RealtimeEvent) => void;
|
|
|
|
export interface RealtimeChannel {
|
|
/**
|
|
* `on("created", cb)` matches `<channel>.created`; `on("*")` every event in
|
|
* the channel; a full type like `"document.created"` also works.
|
|
* Returns an unsubscribe function.
|
|
*/
|
|
on(event: string, handler: RealtimeHandler): () => void;
|
|
}
|
|
|
|
export interface RealtimeClient {
|
|
/**
|
|
* Channel names are event namespaces — "document", "object", "issue",
|
|
* "deploy", or "*" for everything. A trailing "s" is dropped so
|
|
* `channel("documents")` and `channel("document")` are equivalent.
|
|
*/
|
|
channel(name: string): RealtimeChannel;
|
|
/**
|
|
* Publishes a `custom.*` event to the project — realtime subscribers,
|
|
* matching webhooks, and event_pattern functions all receive it.
|
|
*/
|
|
publish(type: string, data?: Record<string, unknown>): Promise<void>;
|
|
/** Current count of clients subscribed to the project's realtime stream. */
|
|
presence(): Promise<number>;
|
|
close(): void;
|
|
readonly state: RealtimeState;
|
|
}
|
|
|
|
interface Listener {
|
|
prefix: string;
|
|
pattern: string;
|
|
handler: RealtimeHandler;
|
|
}
|
|
|
|
export function createRealtimeClient(opts: RealtimeClientOptions): RealtimeClient {
|
|
const url =
|
|
opts.endpoint.replace(/\/$/, "") +
|
|
`/api/v1/projects/${encodeURIComponent(opts.projectId)}/realtime/stream`;
|
|
|
|
const listeners = new Set<Listener>();
|
|
const abort = new AbortController();
|
|
let state: RealtimeState = "connecting";
|
|
|
|
const setState = (s: RealtimeState) => {
|
|
state = s;
|
|
opts.onStateChange?.(s);
|
|
};
|
|
|
|
const dispatch = (evt: RealtimeEvent) => {
|
|
for (const l of listeners) {
|
|
const match =
|
|
l.pattern === "*"
|
|
? evt.type.startsWith(l.prefix)
|
|
: l.pattern.includes(".")
|
|
? evt.type === l.pattern
|
|
: evt.type === l.prefix + l.pattern;
|
|
if (match) {
|
|
try {
|
|
l.handler(evt);
|
|
} catch {
|
|
// a throwing listener must not kill the stream
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// Minimal SSE frame parser — we only need `data:` lines.
|
|
async function readStream(res: Response): Promise<void> {
|
|
if (!res.body) throw new Error("no response body");
|
|
const reader = res.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = "";
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) return;
|
|
buf += dec.decode(value, { stream: true });
|
|
let idx;
|
|
while ((idx = buf.indexOf("\n\n")) >= 0) {
|
|
const frame = buf.slice(0, idx);
|
|
buf = buf.slice(idx + 2);
|
|
for (const line of frame.split("\n")) {
|
|
if (!line.startsWith("data:")) continue;
|
|
try {
|
|
dispatch(JSON.parse(line.slice(5).trim()) as RealtimeEvent);
|
|
} catch {
|
|
// keepalive pings and malformed frames are ignored
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
let attempt = 0;
|
|
while (!abort.signal.aborted) {
|
|
try {
|
|
setState(attempt === 0 ? "connecting" : "reconnecting");
|
|
const res = await fetch(url, {
|
|
headers: { "X-API-Key": opts.key, Accept: "text/event-stream" },
|
|
signal: abort.signal,
|
|
});
|
|
if (!res.ok) throw new Error(`realtime stream: HTTP ${res.status}`);
|
|
setState("open");
|
|
attempt = 0;
|
|
await readStream(res);
|
|
} catch (err) {
|
|
if (abort.signal.aborted) break;
|
|
}
|
|
// stream ended or failed — back off, capped at 30 s
|
|
const delay = Math.min(1000 * 2 ** attempt++, 30000);
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
}
|
|
})().finally(() => setState("closed"));
|
|
|
|
return {
|
|
channel(name) {
|
|
const prefix = name === "*" ? "" : name.replace(/s$/, "") + ".";
|
|
return {
|
|
on(event, handler) {
|
|
const l: Listener = { prefix, pattern: event, handler };
|
|
listeners.add(l);
|
|
return () => listeners.delete(l);
|
|
},
|
|
};
|
|
},
|
|
async publish(type, data = {}) {
|
|
const res = await fetch(
|
|
opts.endpoint.replace(/\/$/, "") +
|
|
`/api/v1/projects/${encodeURIComponent(opts.projectId)}/events`,
|
|
{
|
|
method: "POST",
|
|
headers: { "X-API-Key": opts.key, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ type, data }),
|
|
},
|
|
);
|
|
if (!res.ok) throw new Error(`publish event: HTTP ${res.status}`);
|
|
},
|
|
async presence() {
|
|
const res = await fetch(
|
|
opts.endpoint.replace(/\/$/, "") +
|
|
`/api/v1/projects/${encodeURIComponent(opts.projectId)}/realtime/presence`,
|
|
{ headers: { "X-API-Key": opts.key } },
|
|
);
|
|
if (!res.ok) throw new Error(`presence: HTTP ${res.status}`);
|
|
return ((await res.json()) as { online: number }).online;
|
|
},
|
|
close: () => abort.abort(),
|
|
get state() {
|
|
return state;
|
|
},
|
|
};
|
|
}
|