@flaredev/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import { R as RealtimePresenceData, a as RealtimeReconnectPolicy, b as RealtimeStatus, c as RealtimeMember } from './types-B6Y73BgH.js';
2
+ export { RealtimeClient } from './realtime/client.js';
3
+
4
+ interface UseRealtimeOptions {
5
+ /** Presence announced on join, and re-sent whenever its value changes. `null` clears it. */
6
+ presence?: RealtimePresenceData;
7
+ /** Reconnect/keepalive tuning (see RealtimeReconnectPolicy). */
8
+ reconnect?: RealtimeReconnectPolicy;
9
+ /** Channel endpoint path if the app mounted realtime elsewhere (default /realtime). */
10
+ path?: string;
11
+ /** Connect immediately on mount (default true). Set false to defer. */
12
+ autoConnect?: boolean;
13
+ /** Called for every application event received on the channel. */
14
+ onEvent?: (e: string, d: unknown, from?: string) => void;
15
+ }
16
+ interface UseRealtimeResult {
17
+ /** Lifecycle: connecting | open | reconnecting | dead | closed. */
18
+ status: RealtimeStatus;
19
+ /** Currently connected members (id + presence). */
20
+ members: RealtimeMember[];
21
+ /** Broadcast an app event to every other member (needs a `send` grant from the server). */
22
+ publish: (e: string, d: unknown) => boolean;
23
+ /** True when the underlying socket is open. */
24
+ connected: boolean;
25
+ /** Connect (with autoConnect: false), or resume after close(). */
26
+ connect: () => void;
27
+ /** Close the channel's shared socket for every component using it. */
28
+ close: () => void;
29
+ /** Update this connection's presence. */
30
+ setPresence: (presence: RealtimePresenceData) => void;
31
+ }
32
+ declare function useRealtime(channel: string, opts?: UseRealtimeOptions): UseRealtimeResult;
33
+
34
+ export { RealtimeMember, RealtimePresenceData, RealtimeReconnectPolicy, RealtimeStatus, type UseRealtimeOptions, type UseRealtimeResult, useRealtime };
package/dist/react.js ADDED
@@ -0,0 +1,95 @@
1
+ "use client";
2
+ import {
3
+ RealtimeClient
4
+ } from "./chunk-LYQT4S47.js";
5
+ import "./chunk-ZJPJGME5.js";
6
+
7
+ // src/realtime/react.ts
8
+ import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
9
+ var registry = /* @__PURE__ */ new Map();
10
+ function acquire(key, channel, opts) {
11
+ const existing = registry.get(key);
12
+ if (existing) {
13
+ existing.refs += 1;
14
+ return existing.client;
15
+ }
16
+ const client = new RealtimeClient(channel, { reconnect: opts.reconnect, path: opts.path });
17
+ registry.set(key, { client, refs: 1 });
18
+ return client;
19
+ }
20
+ function release(key) {
21
+ const entry = registry.get(key);
22
+ if (!entry) return;
23
+ entry.refs -= 1;
24
+ if (entry.refs <= 0) {
25
+ entry.client.close();
26
+ registry.delete(key);
27
+ }
28
+ }
29
+ var SERVER_SNAPSHOT = { status: "connecting", members: [] };
30
+ var noopUnsubscribe = () => {
31
+ };
32
+ function useRealtime(channel, opts = {}) {
33
+ const { presence, reconnect, path, autoConnect = true, onEvent } = opts;
34
+ const key = `${path ?? "/realtime"}|${channel}`;
35
+ const [client, setClient] = useState(null);
36
+ const reconnectRef = useRef(reconnect);
37
+ reconnectRef.current = reconnect;
38
+ useEffect(() => {
39
+ const acquired = acquire(key, channel, { reconnect: reconnectRef.current, path });
40
+ setClient(acquired);
41
+ if (autoConnect) acquired.connect();
42
+ return () => {
43
+ release(key);
44
+ setClient(null);
45
+ };
46
+ }, [key, channel, path, autoConnect]);
47
+ const onEventRef = useRef(onEvent);
48
+ onEventRef.current = onEvent;
49
+ useEffect(() => client?.onAny((e, d, from) => onEventRef.current?.(e, d, from)), [client]);
50
+ const presenceKey = presence === void 0 ? void 0 : JSON.stringify(presence);
51
+ useEffect(() => {
52
+ if (!client || presenceKey === void 0) return;
53
+ client.setPresence(JSON.parse(presenceKey));
54
+ }, [client, presenceKey]);
55
+ const cache = useRef(null);
56
+ const getSnapshot = () => {
57
+ if (!client) return SERVER_SNAPSHOT;
58
+ const cached = cache.current;
59
+ if (cached && cached.client === client && cached.value.status === client.status && cached.value.members === client.members) {
60
+ return cached.value;
61
+ }
62
+ const value = { status: client.status, members: client.members };
63
+ cache.current = { client, value };
64
+ return value;
65
+ };
66
+ const subscribe = useMemo(
67
+ () => (notify) => {
68
+ if (!client) return noopUnsubscribe;
69
+ const offStatus = client.onStatus(notify);
70
+ const offMembers = client.onMembers(notify);
71
+ return () => {
72
+ offStatus();
73
+ offMembers();
74
+ };
75
+ },
76
+ [client]
77
+ );
78
+ const { status, members } = useSyncExternalStore(subscribe, getSnapshot, () => SERVER_SNAPSHOT);
79
+ return useMemo(
80
+ () => ({
81
+ status,
82
+ members,
83
+ connected: status === "open",
84
+ publish: (e, d) => client?.publish(e, d) ?? false,
85
+ connect: () => void client?.connect(),
86
+ close: () => client?.close(),
87
+ setPresence: (next) => client?.setPresence(next)
88
+ }),
89
+ [client, status, members]
90
+ );
91
+ }
92
+ export {
93
+ RealtimeClient,
94
+ useRealtime
95
+ };
@@ -0,0 +1,82 @@
1
+ import { c as RealtimeMember, b as RealtimeStatus, a as RealtimeReconnectPolicy, R as RealtimePresenceData } from '../types-B6Y73BgH.js';
2
+ export { d as RealtimeEvent } from '../types-B6Y73BgH.js';
3
+
4
+ /**
5
+ * Flare Realtime client transport. A framework-agnostic reconnecting WebSocket
6
+ * that speaks the `types.ts` wire protocol. `useRealtime` (react.ts) wraps it
7
+ * for React; this class is usable directly in plain JS/TS and tests.
8
+ */
9
+
10
+ interface RealtimeClientOptions {
11
+ /** Reconnect/keepalive tuning (see RealtimeReconnectPolicy). */
12
+ reconnect?: RealtimeReconnectPolicy;
13
+ /** Initial presence announced on `join`. */
14
+ presence?: RealtimePresenceData;
15
+ }
16
+ type EventHandler = (payload: unknown, from?: string) => void;
17
+ type AnyHandler = (e: string, d: unknown, from?: string) => void;
18
+ type MembersHandler = (members: RealtimeMember[]) => void;
19
+ type StatusHandler = (status: RealtimeStatus) => void;
20
+ declare class RealtimeClient {
21
+ readonly channel: string;
22
+ members: RealtimeMember[];
23
+ status: RealtimeStatus;
24
+ /** This connection's id, from the server's `hello` (new on every reconnect). */
25
+ connectionId: string | null;
26
+ private readonly url;
27
+ private readonly policy;
28
+ private presence;
29
+ private ws;
30
+ private attempt;
31
+ private explicitClose;
32
+ private reconnectTimer;
33
+ private deadTimer;
34
+ private stableTimer;
35
+ private heartbeatInterval;
36
+ private lastPongAt;
37
+ private readonly listeners;
38
+ private readonly anyHandlers;
39
+ private readonly memberHandlers;
40
+ private readonly statusHandlers;
41
+ /**
42
+ * @param channel Channel name to join, e.g. `"deals"`.
43
+ * @param opts.path Websocket endpoint (default `/realtime`); the URL is
44
+ * resolved against `location` in the browser, or the given
45
+ * `baseUrl`. Scheme is upgraded to `wss:` / `ws:` automatically.
46
+ */
47
+ constructor(channel: string, opts?: RealtimeClientOptions & {
48
+ path?: string;
49
+ baseUrl?: string;
50
+ });
51
+ /** Subscribe to one application event type. Returns an unsubscribe fn. */
52
+ on(e: string, handler: EventHandler): () => void;
53
+ /** Subscribe to every application event on the channel. */
54
+ onAny(handler: AnyHandler): () => void;
55
+ /** Presence change notifications (full member list on every change). */
56
+ onMembers(handler: MembersHandler): () => void;
57
+ onStatus(handler: StatusHandler): () => void;
58
+ /** Set presence for this connection; sends a `join` frame if already connected. */
59
+ setPresence(presence: RealtimePresenceData): void;
60
+ /** Broadcast an application event to every other member (needs a `send` grant). */
61
+ publish(e: string, d: unknown): boolean;
62
+ connected(): boolean;
63
+ /** Connect and join; no-op if already connected or connecting. Resumes after close(). */
64
+ connect(): this;
65
+ /** Close and stop reconnecting and keepalive, until connect() is called again. */
66
+ close(): void;
67
+ private send;
68
+ private handleOpen;
69
+ private handleMessage;
70
+ private setMembers;
71
+ private dispatch;
72
+ private handleClose;
73
+ private scheduleReconnect;
74
+ /** Exponential backoff with jitter, never above maxDelayMs. */
75
+ private nextDelay;
76
+ private startHeartbeat;
77
+ private clearTimers;
78
+ private clearHeartbeat;
79
+ private setStatus;
80
+ }
81
+
82
+ export { RealtimeClient, type RealtimeClientOptions, RealtimeMember, RealtimePresenceData, RealtimeStatus };
@@ -0,0 +1,7 @@
1
+ import {
2
+ RealtimeClient
3
+ } from "../chunk-LYQT4S47.js";
4
+ import "../chunk-ZJPJGME5.js";
5
+ export {
6
+ RealtimeClient
7
+ };
@@ -0,0 +1,109 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import { c as RealtimeMember, R as RealtimePresenceData } from '../types-B6Y73BgH.js';
3
+
4
+ /**
5
+ * Flare Realtime server primitive: one WebSocket-hibernatable Durable Object
6
+ * per channel, providing broadcast (rooms) and presence.
7
+ *
8
+ * - One DO instance per channel name (`hubs.get(hubs.idFromName(channel))`).
9
+ * - WebSocket Hibernation API: sockets, their tags, and per-socket attachment
10
+ * data survive hibernation; presence rebuilds from `state.getWebSockets()`
11
+ * with no durable storage dependency.
12
+ * - Connectivity: the app's `worker/index.ts` intercepts `/realtime/<channel>/ws`
13
+ * upgrades and hands them to `handleRealtimeUpgrade`, which checks the Origin,
14
+ * asks the app's `authorize` hook, and routes to this DO.
15
+ * - Server-driven publishes call `hub.publish(event, data)` on the DO stub
16
+ * from any app code that has access to the `FLARE_REALTIME` binding.
17
+ *
18
+ * Wire protocol lives in `types.ts`.
19
+ *
20
+ * @see https://developers.cloudflare.com/durable-objects/api/websockets/
21
+ */
22
+
23
+ /** What an authorized connection may do beyond receiving events and presence. */
24
+ interface RealtimeGrant {
25
+ /** Allow `send` frames (client-to-client broadcast). Off unless granted. */
26
+ send?: boolean;
27
+ }
28
+ /**
29
+ * Decides whether a WebSocket upgrade may join `channel`. Return a grant to allow
30
+ * it, or `false`/`null` to refuse (401). A Response is returned to the client as-is.
31
+ */
32
+ type RealtimeAuthorize = (request: Request, channel: string) => RealtimeGrant | false | null | Response | Promise<RealtimeGrant | false | null | Response>;
33
+ interface RealtimeUpgradeOptions {
34
+ /** Required: there is no default, so every app decides who may connect. */
35
+ authorize: RealtimeAuthorize;
36
+ /**
37
+ * Origins allowed to open sockets besides the app's own. Browsers always send
38
+ * Origin on WebSocket upgrades, so this is what blocks cross-site WebSocket hijacking.
39
+ */
40
+ allowedOrigins?: string[];
41
+ }
42
+ /**
43
+ * The subset of a `DurableObjectNamespace` the realtime primitive needs.
44
+ * Structural so core's published d.ts stays independent of
45
+ * `@cloudflare/workers-types`; any `DurableObjectNamespace<RealtimeChannel>`
46
+ * binding (e.g. `env.FLARE_REALTIME`) satisfies it.
47
+ */
48
+ interface RealtimeChannelNamespace {
49
+ idFromName(name: string): {
50
+ toString(): string;
51
+ equals(other: unknown): boolean;
52
+ readonly name?: string;
53
+ readonly jurisdiction?: string;
54
+ };
55
+ get(id: {
56
+ toString(): string;
57
+ equals(other: unknown): boolean;
58
+ readonly name?: string;
59
+ readonly jurisdiction?: string;
60
+ }): unknown;
61
+ }
62
+ /**
63
+ * The Durable Object backing one channel. Extend it only via composition in
64
+ * the app's `worker/index.ts`; the class is exported for wrangler's `migrations`
65
+ * block and app-side RPC typing.
66
+ */
67
+ declare class RealtimeChannel extends DurableObject {
68
+ /** Upgrade path for `/realtime/<channel>/ws`, reached only through handleRealtimeUpgrade. */
69
+ fetch(request: Request): Response;
70
+ /**
71
+ * App-facing RPC: broadcast an application event to every live member.
72
+ * Returns the number of sockets that accepted the frame.
73
+ */
74
+ publish(e: string, d: unknown): number;
75
+ /** App-facing RPC: the channel's currently connected members. */
76
+ presence(): RealtimeMember[];
77
+ /** RPC for server logic that knows a connectionId. Returns false if not found or too large. */
78
+ setPresence(connectionId: string, presence: RealtimePresenceData): boolean;
79
+ webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void>;
80
+ webSocketClose(ws: WebSocket, code: number, reason: string): void;
81
+ webSocketError(ws: WebSocket): void;
82
+ private members;
83
+ private announcePresence;
84
+ private frame;
85
+ }
86
+ /**
87
+ * Route a WebSocket upgrade for `/realtime/<channel>/ws` to the channel's DO,
88
+ * after checking the Origin and asking `options.authorize` whether this request
89
+ * may join. Returns `undefined` for any other path so the caller falls through
90
+ * to the app handler.
91
+ *
92
+ * ```ts
93
+ * // worker/index.ts
94
+ * const realtime = await handleRealtimeUpgrade(request, env.FLARE_REALTIME, { authorize: authorizeRealtime });
95
+ * if (realtime) return realtime;
96
+ * return app.fetch(request, env, ctx);
97
+ * ```
98
+ */
99
+ declare function handleRealtimeUpgrade(request: Request, namespace: RealtimeChannelNamespace, options: RealtimeUpgradeOptions): Promise<Response | undefined>;
100
+ /**
101
+ * Resolve a channel hub (DO stub) for app-driven publish/presence calls.
102
+ *
103
+ * ```ts
104
+ * await realtimeHub(env.FLARE_REALTIME, "deals").publish("deal.stage", { id, stage });
105
+ * ```
106
+ */
107
+ declare function realtimeHub(namespace: RealtimeChannelNamespace, channel: string): RealtimeChannel;
108
+
109
+ export { type RealtimeAuthorize, RealtimeChannel, type RealtimeChannelNamespace, type RealtimeGrant, RealtimeMember, RealtimePresenceData, type RealtimeUpgradeOptions, handleRealtimeUpgrade, realtimeHub };
@@ -0,0 +1,200 @@
1
+ import {
2
+ parseClientFrame
3
+ } from "../chunk-ZJPJGME5.js";
4
+
5
+ // src/realtime/server.ts
6
+ import { DurableObject } from "cloudflare:workers";
7
+ var MEMBER_TAG = "member";
8
+ var MAX_FRAME_CHARS = 1024 * 1024;
9
+ var MAX_SEND_CHARS = 64 * 1024;
10
+ var MAX_PRESENCE_CHARS = 1024;
11
+ var MAX_PRESENCE = 256;
12
+ var MAX_CONNECTIONS = 1e3;
13
+ var GRANT_HEADER = "x-flare-realtime-grant";
14
+ function makeWebSocketPair() {
15
+ const Ctor = globalThis.WebSocketPair;
16
+ if (!Ctor) throw new Error("The WebSocketPair global is unavailable in this runtime.");
17
+ return new Ctor();
18
+ }
19
+ function readGrant(request) {
20
+ try {
21
+ const grant = JSON.parse(request.headers.get(GRANT_HEADER) ?? "null");
22
+ return { send: grant?.send === true };
23
+ } catch {
24
+ return { send: false };
25
+ }
26
+ }
27
+ function presenceFits(presence) {
28
+ try {
29
+ return JSON.stringify(presence ?? null).length <= MAX_PRESENCE_CHARS;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ function asHibernatable(ws) {
35
+ return ws;
36
+ }
37
+ var RealtimeChannel = class extends DurableObject {
38
+ /** Upgrade path for `/realtime/<channel>/ws`, reached only through handleRealtimeUpgrade. */
39
+ fetch(request) {
40
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
41
+ return new Response("This endpoint is for WebSocket upgrades.", { status: 426 });
42
+ }
43
+ if (this.ctx.getWebSockets(MEMBER_TAG).length >= MAX_CONNECTIONS) {
44
+ return new Response("This channel is full.", { status: 503 });
45
+ }
46
+ const pair = makeWebSocketPair();
47
+ const client = pair[0];
48
+ const server = pair[1];
49
+ const connectionId = crypto.randomUUID();
50
+ const grant = readGrant(request);
51
+ server.serializeAttachment({ connectionId, joinedAt: Date.now(), presence: null, send: grant.send === true });
52
+ this.ctx.acceptWebSocket(server, [MEMBER_TAG]);
53
+ server.send(this.frame({ t: "hello", connectionId, members: this.members() }));
54
+ this.announcePresence({ joined: { connectionId, presence: null } });
55
+ return new Response(null, { status: 101, webSocket: client });
56
+ }
57
+ /**
58
+ * App-facing RPC: broadcast an application event to every live member.
59
+ * Returns the number of sockets that accepted the frame.
60
+ */
61
+ publish(e, d) {
62
+ const frame = this.frame({ t: "event", e, d });
63
+ let sent = 0;
64
+ for (const ws of this.ctx.getWebSockets(MEMBER_TAG)) {
65
+ try {
66
+ ws.send(frame);
67
+ sent += 1;
68
+ } catch {
69
+ }
70
+ }
71
+ return sent;
72
+ }
73
+ /** App-facing RPC: the channel's currently connected members. */
74
+ presence() {
75
+ return this.members();
76
+ }
77
+ /** RPC for server logic that knows a connectionId. Returns false if not found or too large. */
78
+ setPresence(connectionId, presence) {
79
+ if (!presenceFits(presence)) return false;
80
+ for (const ws of this.ctx.getWebSockets(MEMBER_TAG)) {
81
+ const attachment = ws.deserializeAttachment();
82
+ if (attachment.connectionId === connectionId) {
83
+ ws.serializeAttachment({ ...attachment, presence });
84
+ this.announcePresence({ joined: { connectionId, presence } });
85
+ return true;
86
+ }
87
+ }
88
+ return false;
89
+ }
90
+ async webSocketMessage(ws, message) {
91
+ if (typeof message !== "string") return;
92
+ const hij = asHibernatable(ws);
93
+ if (message.length > MAX_FRAME_CHARS) {
94
+ hij.send(this.frame({ t: "error", code: "too_large", message: "Frame exceeds 1 MiB." }));
95
+ return;
96
+ }
97
+ const attachment = hij.deserializeAttachment();
98
+ const parsed = parseClientFrame(message);
99
+ if (!parsed.ok) {
100
+ hij.send(this.frame({ t: "error", code: "bad_frame", message: parsed.reason }));
101
+ return;
102
+ }
103
+ const frame = parsed.frame;
104
+ if (frame.t === "join") {
105
+ const presence = frame.presence ?? null;
106
+ if (!presenceFits(presence)) {
107
+ hij.send(this.frame({ t: "error", code: "presence_too_large", message: `Presence must serialize to at most ${MAX_PRESENCE_CHARS} characters.` }));
108
+ return;
109
+ }
110
+ hij.serializeAttachment({ ...attachment, presence });
111
+ this.announcePresence({ joined: { connectionId: attachment.connectionId, presence } });
112
+ } else if (frame.t === "ping") {
113
+ hij.send(this.frame({ t: "pong" }));
114
+ } else if (frame.t === "send") {
115
+ if (!attachment.send) {
116
+ hij.send(this.frame({ t: "error", code: "forbidden", message: "This connection may not broadcast on this channel." }));
117
+ return;
118
+ }
119
+ if (message.length > MAX_SEND_CHARS) {
120
+ hij.send(this.frame({ t: "error", code: "too_large", message: `Broadcasts are limited to ${MAX_SEND_CHARS / 1024} KiB.` }));
121
+ return;
122
+ }
123
+ const eventFrame = this.frame({ t: "event", e: frame.e, d: frame.d, from: attachment.connectionId });
124
+ for (const other of this.ctx.getWebSockets(MEMBER_TAG)) {
125
+ if (other === hij) continue;
126
+ try {
127
+ other.send(eventFrame);
128
+ } catch {
129
+ }
130
+ }
131
+ }
132
+ }
133
+ webSocketClose(ws, code, reason) {
134
+ const attachment = asHibernatable(ws).deserializeAttachment();
135
+ if (attachment?.connectionId) {
136
+ this.announcePresence({ left: attachment.connectionId }, ws);
137
+ }
138
+ try {
139
+ ws.close(code === 1005 || code === 1006 ? 1e3 : code, reason);
140
+ } catch {
141
+ }
142
+ }
143
+ webSocketError(ws) {
144
+ void ws;
145
+ }
146
+ members(exclude) {
147
+ return this.ctx.getWebSockets(MEMBER_TAG).filter((ws) => ws !== exclude).map((ws) => {
148
+ const attachment = ws.deserializeAttachment();
149
+ return { connectionId: attachment.connectionId, presence: attachment.presence };
150
+ }).slice(0, MAX_PRESENCE);
151
+ }
152
+ announcePresence(change, exclude) {
153
+ const frame = this.frame({ t: "presence", members: this.members(exclude), ...change });
154
+ for (const ws of this.ctx.getWebSockets(MEMBER_TAG)) {
155
+ if (ws === exclude) continue;
156
+ try {
157
+ ws.send(frame);
158
+ } catch {
159
+ }
160
+ }
161
+ }
162
+ frame(frame) {
163
+ try {
164
+ return JSON.stringify(frame);
165
+ } catch {
166
+ return '{"t":"error","code":"serialize","message":"Serialization failure"}';
167
+ }
168
+ }
169
+ };
170
+ async function handleRealtimeUpgrade(request, namespace, options) {
171
+ const url = new URL(request.url);
172
+ const match = /^\/realtime\/([^/]+)\/ws\/?$/.exec(url.pathname);
173
+ if (!match) return void 0;
174
+ const channel = match[1] ?? "";
175
+ if (!/^[A-Za-z0-9._-]{1,128}$/.test(channel)) {
176
+ return new Response("Invalid channel name.", { status: 400 });
177
+ }
178
+ if (request.headers.get("upgrade")?.toLowerCase() !== "websocket") {
179
+ return new Response("This endpoint expects a WebSocket upgrade.", { status: 426 });
180
+ }
181
+ const origin = request.headers.get("origin");
182
+ if (origin !== null && origin !== url.origin && !(options.allowedOrigins ?? []).includes(origin)) {
183
+ return new Response("Cross-origin WebSocket upgrade refused.", { status: 403 });
184
+ }
185
+ const decision = await options.authorize(request, channel);
186
+ if (decision instanceof Response) return decision;
187
+ if (!decision) return new Response("Not allowed to join this channel.", { status: 401 });
188
+ const forwarded = new Request(request);
189
+ forwarded.headers.delete(GRANT_HEADER);
190
+ forwarded.headers.set(GRANT_HEADER, JSON.stringify({ send: decision.send === true }));
191
+ return namespace.get(namespace.idFromName(channel)).fetch(forwarded);
192
+ }
193
+ function realtimeHub(namespace, channel) {
194
+ return namespace.get(namespace.idFromName(channel));
195
+ }
196
+ export {
197
+ RealtimeChannel,
198
+ handleRealtimeUpgrade,
199
+ realtimeHub
200
+ };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Flare security: what an app's `security.config.ts` declares, and the pure rules
3
+ * that apply it. Nothing here touches Cloudflare or storage, so it is unit-tested.
4
+ *
5
+ * Two layers enforce the config:
6
+ *
7
+ * - **Worker layer** (always on, any plan, works on workers.dev): before the app runs,
8
+ * the Worker refuses banned IPs and applies the Workers rate-limit binding; after it
9
+ * runs, detectors count matching responses per IP and ban an IP that trips one.
10
+ * - **Zone layer** (optional): when the app has a zone and a scoped API token, the same
11
+ * bans become zone IP Access Rules and `flare deploy` provisions the zone's WAF
12
+ * custom rules and rate-limiting rules. Flare only ever touches rules it tagged.
13
+ */
14
+ type Severity = "low" | "medium" | "high";
15
+ /** A pattern of responses that signals abuse when one IP produces too many of them. */
16
+ interface Detector {
17
+ /** Stable id, e.g. "login-brute-force". Used as the SecurityEvent kind. */
18
+ name: string;
19
+ /** Human description for the dashboard. */
20
+ description?: string;
21
+ match: {
22
+ /** HTTP method, e.g. "POST". Any method when omitted. */
23
+ method?: string;
24
+ /** Exact path ("/api/auth/sign-in/email") or a RegExp. */
25
+ path: string | RegExp;
26
+ /** Response statuses that count, e.g. [401]. Any status when omitted. */
27
+ status?: number | number[];
28
+ };
29
+ /** How many matches within `windowSeconds` trip the detector. */
30
+ threshold: number;
31
+ windowSeconds: number;
32
+ /** Ban the IP when tripped. Omit to only log a SecurityEvent. */
33
+ ban?: {
34
+ seconds: number;
35
+ };
36
+ severity?: Severity;
37
+ }
38
+ /** A zone rate-limiting rule (Cloudflare's http_ratelimit phase). */
39
+ interface ZoneRateLimit {
40
+ name: string;
41
+ /** Cloudflare rules-language expression, e.g. `http.request.uri.path eq "/api/auth/sign-in/email"`. */
42
+ expression: string;
43
+ /** Requests allowed per `periodSeconds` per IP before blocking. */
44
+ requests: number;
45
+ /** 10 on the Free plan; 60, 120, 300, 600, 3600 on paid plans. */
46
+ periodSeconds: number;
47
+ /** How long to block once the limit is hit. 10 on the Free plan. */
48
+ mitigationSeconds: number;
49
+ }
50
+ /** A zone WAF custom rule (Cloudflare's http_request_firewall_custom phase). */
51
+ interface ZoneCustomRule {
52
+ name: string;
53
+ expression: string;
54
+ action: "block" | "managed_challenge" | "js_challenge" | "log";
55
+ }
56
+ interface SecurityConfig {
57
+ /** IPs that are never banned or rate-limited (your office, uptime monitors). */
58
+ allow?: string[];
59
+ /** Response patterns that indicate abuse. */
60
+ detectors?: Detector[];
61
+ /**
62
+ * Worker-level rate limit through the Workers rate-limit binding. Its limit and
63
+ * period are set on the binding in wrangler.jsonc; this chooses which paths use it.
64
+ */
65
+ rateLimit?: {
66
+ binding: string;
67
+ paths: RegExp;
68
+ };
69
+ /** Zone rules `flare deploy` provisions when a zone and token are configured. */
70
+ zone?: {
71
+ rateLimits?: ZoneRateLimit[];
72
+ customRules?: ZoneCustomRule[];
73
+ };
74
+ }
75
+ /** Validate and return a security config (the value `security.config.ts` default-exports). */
76
+ declare function defineSecurity(config: SecurityConfig): SecurityConfig;
77
+ /** Whether a response to this request counts for the detector. */
78
+ declare function detectorMatches(detector: Detector, request: {
79
+ method: string;
80
+ url: string;
81
+ }, status: number): boolean;
82
+ /**
83
+ * Sliding-window count: keep the hits still inside the window, add this one, and
84
+ * say whether it reaches the threshold. `tripped` is true only on the hit that
85
+ * crosses it, so one burst produces one event and one ban, not one per request.
86
+ */
87
+ declare function recordHit(previous: number[], now: number, windowSeconds: number, threshold: number): {
88
+ hits: number[];
89
+ tripped: boolean;
90
+ };
91
+ /**
92
+ * The client's IP. On Cloudflare, CF-Connecting-IP is set by the edge and can't be
93
+ * forged by the client; anything else (X-Forwarded-For) could be, so it's ignored.
94
+ */
95
+ declare function clientIp(request: Request): string | null;
96
+ /** IPv4, or an IPv6 address in any of its textual forms. */
97
+ declare function isIp(value: string): boolean;
98
+ /** Tag Flare writes on every zone rule it owns, so re-provisioning never touches anyone else's. */
99
+ declare const zoneRuleTag: (app: string, name: string) => string;
100
+ declare const isFlareRule: (app: string, description: string | undefined | null) => boolean;
101
+ /** The default detectors every app starts with. */
102
+ declare const DEFAULT_DETECTORS: Detector[];
103
+
104
+ /**
105
+ * The zone layer: a small client over Cloudflare's API for the three things Flare
106
+ * manages on an app's zone. IP Access Rules (bans), the rate-limiting phase and the
107
+ * custom WAF phase, plus firewall analytics for the dashboard.
108
+ *
109
+ * Every rule Flare writes is tagged `flare:<app>:<name>` (see zoneRuleTag), and every
110
+ * write keeps whatever rules the zone already had that aren't Flare's. Use an API
111
+ * token scoped to this one zone with Zone WAF Edit, Firewall Services Edit,
112
+ * Analytics Read and Zone Read.
113
+ */
114
+
115
+ interface ZoneOptions {
116
+ zoneId: string;
117
+ apiToken: string;
118
+ /** Tag prefix for this app's rules, usually the Worker's name. */
119
+ app: string;
120
+ fetch?: typeof fetch;
121
+ }
122
+ interface ZoneBan {
123
+ id: string;
124
+ ip: string;
125
+ notes: string;
126
+ createdAt: string;
127
+ }
128
+ declare class CloudflareApiError extends Error {
129
+ readonly status: number;
130
+ constructor(status: number, message: string);
131
+ }
132
+ declare function createZoneClient(options: ZoneOptions): {
133
+ /** Provision this app's rate-limiting rules, leaving the zone's other rules alone. */
134
+ syncRateLimits(rules: ZoneRateLimit[]): Promise<{
135
+ kept: number;
136
+ written: number;
137
+ }>;
138
+ /** Provision this app's WAF custom rules, leaving the zone's other rules alone. */
139
+ syncCustomRules(rules: ZoneCustomRule[]): Promise<{
140
+ kept: number;
141
+ written: number;
142
+ }>;
143
+ /** Block an IP at the edge for the whole zone. Idempotent. */
144
+ ban(ip: string, reason: string): Promise<ZoneBan>;
145
+ /** Remove this app's ban on an IP (never a ban someone else added). */
146
+ unban(ip: string): Promise<boolean>;
147
+ /** This app's active zone bans. */
148
+ bans(): Promise<ZoneBan[]>;
149
+ /** Firewall events on the zone since `since`, grouped by action and source (for the dashboard). */
150
+ firewallEvents(since: Date): Promise<Array<{
151
+ count: number;
152
+ action: string;
153
+ source: string;
154
+ description: string | null;
155
+ }>>;
156
+ };
157
+ type ZoneClient = ReturnType<typeof createZoneClient>;
158
+
159
+ export { CloudflareApiError, DEFAULT_DETECTORS, type Detector, type SecurityConfig, type Severity, type ZoneBan, type ZoneClient, type ZoneCustomRule, type ZoneOptions, type ZoneRateLimit, clientIp, createZoneClient, defineSecurity, detectorMatches, isFlareRule, isIp, recordHit, zoneRuleTag };