1
15
16import {
17 BINARY_KIND,
18 ClientFrameSchema,
19 REALTIME_LIMITS,
20 type ClientFrame,
21 type ServerFrame,
22} from "@factory/shared/realtime/frames";
23
24
25
26export { WS_CLOSE } from "@factory/shared/realtime/frames";
27import { WS_CLOSE } from "@factory/shared/realtime/frames";
28
29export type CloseCode = (typeof WS_CLOSE)[keyof typeof WS_CLOSE];
30
31
32
33
34export type DecodedFrame =
35 | { readonly kind: "text"; readonly frame: ClientFrame }
36 | {
37 readonly kind: "binary";
38 readonly binary: "update" | "awareness";
39 readonly bytes: Uint8Array;
40 };
41
42
44export type FrameRejectCode =
45 | "invalid_json"
46 | "unknown_frame"
47 | "invalid_frame"
48 | "bad_binary_kind"
49 | "bad_update"
50 | "frame_too_large";
51
52
59export function decodeClientFrame(
60 data: string | Uint8Array,
61): { ok: true; frame: DecodedFrame } | { ok: false; code: FrameRejectCode } {
62 if (typeof data === "string") {
63 let parsed: unknown;
64 try {
65 parsed = JSON.parse(data);
66 } catch {
67 return { ok: false, code: "invalid_json" };
68 }
69 const result = ClientFrameSchema.safeParse(parsed);
70 if (!result.success) {
71
72
73
74 const t = (parsed as { type?: unknown } | null)?.type;
75 const known =
76 t === "sync.request" ||
77 t === "ping" ||
78 t === "outbox.pending" ||
79 t === "subscribe" ||
80 t === "unsubscribe" ||
81 t === "presence" ||
82 t === "presence-leave";
83 return { ok: false, code: known ? "invalid_frame" : "unknown_frame" };
84 }
85 return { ok: true, frame: { kind: "text", frame: result.data } };
86 }
87
88 if (data.byteLength < 1) {
89 return { ok: false, code: "bad_binary_kind" };
90 }
91 const kind = data[0];
92 if (kind === BINARY_KIND.Update) {
93 return { ok: true, frame: { kind: "binary", binary: "update", bytes: data.subarray(1) } };
94 }
95 if (kind === BINARY_KIND.Awareness) {
96 return { ok: true, frame: { kind: "binary", binary: "awareness", bytes: data.subarray(1) } };
97 }
98 return { ok: false, code: "bad_binary_kind" };
99}
100
101
104export function encodeServerFrame(frame: ServerFrame): string {
105 return JSON.stringify(frame);
106}
107
108
109export function encodeBinaryUpdate(bytes: Uint8Array): Uint8Array {
110 const out = new Uint8Array(bytes.byteLength + 1);
111 out[0] = BINARY_KIND.Update;
112 out.set(bytes, 1);
113 return out;
114}
115
116
117export function encodeBinaryAwareness(bytes: Uint8Array): Uint8Array {
118 const out = new Uint8Array(bytes.byteLength + 1);
119 out[0] = BINARY_KIND.Awareness;
120 out.set(bytes, 1);
121 return out;
122}
123
124
125
126
132export class StrikeTracker {
133 private readonly limit: number;
134 private readonly windowMs: number;
135 private readonly now: () => number;
136 private strikes: number[] = [];
137
138 constructor(opts?: { limit?: number; windowMs?: number; now?: () => number }) {
139 this.limit = opts?.limit ?? REALTIME_LIMITS.strikeLimit;
140 this.windowMs = opts?.windowMs ?? REALTIME_LIMITS.strikeWindowMs;
141 this.now = opts?.now ?? Date.now;
142 }
143
144
149 record(): { violated: boolean; strikes: number } {
150 const t = this.now();
151 const cutoff = t - this.windowMs;
152 this.strikes = this.strikes.filter((s) => s > cutoff);
153 this.strikes.push(t);
154 const violated = this.strikes.length >= this.limit;
155 return { violated, strikes: this.strikes.length };
156 }
157
158
159 count(): number {
160 const t = this.now();
161 const cutoff = t - this.windowMs;
162 return this.strikes.filter((s) => s > cutoff).length;
163 }
164
165 reset(): void {
166 this.strikes = [];
167 }
168}
169
170
171
172
173export const MAX_FRAME_BYTES = REALTIME_LIMITS.maxFrameBytes;
174