radio-faf 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wolfe James
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # mcpaas
2
+
3
+ **Your AI forgets you every session. MCPaaS remembers.**
4
+
5
+ You re-explain your stack to Claude. Switch to Gemini — re-explain everything. Open Cursor — again. Every session. Every tool. Every time.
6
+
7
+ That's the drift tax. [$49M/day](https://fafdev.tools/value) burned industry-wide.
8
+
9
+ This SDK connects you to [MCPaaS](https://mcpaas.live) — persistent AI context that follows you across tools, sessions, and teams.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install mcpaas
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```typescript
20
+ import { Radio } from 'mcpaas';
21
+
22
+ const radio = new Radio('wss://mcpaas.live/beacon/radio');
23
+ await radio.connect();
24
+
25
+ // Tune to your project's frequency
26
+ await radio.tune('91.0');
27
+
28
+ // Your context arrives — from any AI, any session
29
+ radio.onBroadcast((frequency, context) => {
30
+ console.log(`Context update on ${frequency} FM:`, context);
31
+ });
32
+ ```
33
+
34
+ That's it. Your context persists. Zero drift.
35
+
36
+ ## How It Works
37
+
38
+ MCPaaS uses the **Radio Protocol** — broadcast once, every AI receives.
39
+
40
+ ```
41
+ Traditional (the tax):
42
+ You → Claude (send 50KB context)
43
+ You → Grok (send 50KB again)
44
+ You → Gemini (send 50KB again)
45
+ = 3x cost, 3x latency, context drift
46
+
47
+ Radio Protocol (the fix):
48
+ You → Broadcast to 91.0 FM (send once)
49
+ Claude ← tuned to 91.0
50
+ Grok ← tuned to 91.0
51
+ Gemini ← tuned to 91.0
52
+ = 1x cost, instant, zero drift
53
+ ```
54
+
55
+ ## Multi-AI Example
56
+
57
+ ```typescript
58
+ import { Radio } from 'mcpaas';
59
+
60
+ const claude = new Radio('wss://mcpaas.live/beacon/radio');
61
+ const grok = new Radio('wss://mcpaas.live/beacon/radio');
62
+ const gemini = new Radio('wss://mcpaas.live/beacon/radio');
63
+
64
+ await Promise.all([claude.connect(), grok.connect(), gemini.connect()]);
65
+ await Promise.all([claude.tune('91.0'), grok.tune('91.0'), gemini.tune('91.0')]);
66
+
67
+ // All three AIs now share the same context, in real time
68
+ claude.onBroadcast((f, ctx) => console.log('Claude:', ctx));
69
+ grok.onBroadcast((f, ctx) => console.log('Grok:', ctx));
70
+ gemini.onBroadcast((f, ctx) => console.log('Gemini:', ctx));
71
+ ```
72
+
73
+ ## API
74
+
75
+ ### Constructor
76
+
77
+ ```typescript
78
+ const radio = new Radio(url: string, options?: {
79
+ reconnect?: boolean; // Auto-reconnect (default: true)
80
+ maxReconnectAttempts?: number; // Max attempts (default: 5)
81
+ reconnectDelay?: number; // Delay in ms (default: 1000)
82
+ heartbeatInterval?: number; // Ping interval (default: 30000)
83
+ debug?: boolean; // Debug logging (default: false)
84
+ });
85
+ ```
86
+
87
+ ### Methods
88
+
89
+ | Method | Description |
90
+ |--------|-------------|
91
+ | `connect()` | Connect to MCPaaS |
92
+ | `tune(frequency)` | Subscribe to a frequency |
93
+ | `untune(frequency)` | Unsubscribe from a frequency |
94
+ | `disconnect()` | Disconnect |
95
+ | `getState()` | `'DISCONNECTED'` \| `'CONNECTING'` \| `'CONNECTED'` \| `'RECONNECTING'` \| `'CLOSED'` |
96
+ | `getTunedFrequencies()` | Set of tuned frequencies |
97
+
98
+ ### Events
99
+
100
+ | Event | Callback |
101
+ |-------|----------|
102
+ | `onBroadcast(fn)` | `(frequency, context) => void` |
103
+ | `onConnect(fn)` | `() => void` |
104
+ | `onDisconnect(fn)` | `(code?, reason?) => void` |
105
+ | `onError(fn)` | `(error) => void` |
106
+ | `onTuned(fn)` | `(frequency) => void` |
107
+ | `onUntuned(fn)` | `(frequency) => void` |
108
+
109
+ ## Why Bun?
110
+
111
+ Anthropic acquired Bun in December 2025. Bun now powers Claude Code. This library is built for Bun first — native TypeScript, 7x faster WebSockets, zero config.
112
+
113
+ ## Namepoints
114
+
115
+ Every frequency maps to a **namepoint** on MCPaaS — your permanent AI identity.
116
+
117
+ Free namepoints work. But `yourname.mcpaas.live` hits different than `user-38291.mcpaas.live`.
118
+
119
+ **Claim yours before someone else does:** [mcpaas.live/claim](https://mcpaas.live/claim)
120
+
121
+ If `mcpaas` has been useful, consider starring the repo — it helps others find it.
122
+
123
+ ## License
124
+
125
+ MIT
126
+
127
+ ---
128
+
129
+ Built by [Wolfe James](https://github.com/Wolfe-Jam) | Powered by [MCPaaS](https://mcpaas.live) | Format: [FAF](https://faf.one)
package/dist/index.js ADDED
@@ -0,0 +1,223 @@
1
+ // @bun
2
+ // src/radio.ts
3
+ class Radio {
4
+ url;
5
+ ws = null;
6
+ state = "DISCONNECTED";
7
+ options;
8
+ handlers = {};
9
+ reconnectAttempts = 0;
10
+ heartbeatTimer = null;
11
+ tunedFrequencies = new Set;
12
+ constructor(url, options = {}) {
13
+ this.url = url;
14
+ this.options = {
15
+ reconnect: options.reconnect ?? true,
16
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
17
+ reconnectDelay: options.reconnectDelay ?? 1000,
18
+ heartbeatInterval: options.heartbeatInterval ?? 30000,
19
+ debug: options.debug ?? false
20
+ };
21
+ }
22
+ async connect() {
23
+ if (this.state === "CONNECTED" || this.state === "CONNECTING") {
24
+ this.log("Already connected or connecting");
25
+ return;
26
+ }
27
+ return new Promise((resolve, reject) => {
28
+ this.state = "CONNECTING";
29
+ this.log(`Connecting to ${this.url}...`);
30
+ try {
31
+ this.ws = new WebSocket(this.url);
32
+ this.ws.onopen = () => {
33
+ this.state = "CONNECTED";
34
+ this.reconnectAttempts = 0;
35
+ this.log("Connected to Radio Protocol");
36
+ this.startHeartbeat();
37
+ this.handlers.onConnect?.();
38
+ resolve();
39
+ };
40
+ this.ws.onmessage = (event) => {
41
+ this.handleMessage(event.data);
42
+ };
43
+ this.ws.onerror = (event) => {
44
+ const error = new Error("WebSocket error");
45
+ this.log("WebSocket error:", event);
46
+ this.handlers.onError?.(error);
47
+ reject(error);
48
+ };
49
+ this.ws.onclose = (event) => {
50
+ this.handleClose(event.code, event.reason);
51
+ };
52
+ } catch (error) {
53
+ this.state = "DISCONNECTED";
54
+ reject(error);
55
+ }
56
+ });
57
+ }
58
+ async tune(frequency) {
59
+ this.validateFrequency(frequency);
60
+ this.ensureConnected();
61
+ const message = {
62
+ action: "tune",
63
+ frequencies: [frequency]
64
+ };
65
+ this.send(message);
66
+ this.tunedFrequencies.add(frequency);
67
+ this.log(`Tuning to ${frequency} FM...`);
68
+ }
69
+ async untune(frequency) {
70
+ this.validateFrequency(frequency);
71
+ this.ensureConnected();
72
+ const message = {
73
+ action: "untune",
74
+ frequencies: [frequency]
75
+ };
76
+ this.send(message);
77
+ this.tunedFrequencies.delete(frequency);
78
+ this.log(`Untuning from ${frequency} FM...`);
79
+ }
80
+ disconnect() {
81
+ this.stopHeartbeat();
82
+ if (this.ws) {
83
+ this.state = "CLOSED";
84
+ this.ws.close();
85
+ this.ws = null;
86
+ }
87
+ this.tunedFrequencies.clear();
88
+ this.log("Disconnected");
89
+ }
90
+ onBroadcast(handler) {
91
+ this.handlers.onBroadcast = handler;
92
+ }
93
+ onConnect(handler) {
94
+ this.handlers.onConnect = handler;
95
+ }
96
+ onDisconnect(handler) {
97
+ this.handlers.onDisconnect = handler;
98
+ }
99
+ onError(handler) {
100
+ this.handlers.onError = handler;
101
+ }
102
+ onTuned(handler) {
103
+ this.handlers.onTuned = handler;
104
+ }
105
+ onUntuned(handler) {
106
+ this.handlers.onUntuned = handler;
107
+ }
108
+ getState() {
109
+ return this.state;
110
+ }
111
+ getTunedFrequencies() {
112
+ return new Set(this.tunedFrequencies);
113
+ }
114
+ handleMessage(data) {
115
+ try {
116
+ const text = typeof data === "string" ? data : new TextDecoder().decode(data);
117
+ const message = JSON.parse(text);
118
+ this.log("Received:", message.type);
119
+ switch (message.type) {
120
+ case "connected":
121
+ this.log(`\u2705 ${message.message} (Client ID: ${message.clientId})`);
122
+ this.log("Available frequencies:", message.availableFrequencies);
123
+ break;
124
+ case "tuned":
125
+ message.frequencies.forEach((freq) => {
126
+ this.handlers.onTuned?.(freq);
127
+ this.log(`\u2705 Tuned to ${freq} FM`);
128
+ });
129
+ if (message.invalid.length > 0) {
130
+ this.log(`\u26A0\uFE0F Invalid frequencies: ${message.invalid.join(", ")}`);
131
+ }
132
+ break;
133
+ case "untuned":
134
+ message.frequencies.forEach((freq) => {
135
+ this.handlers.onUntuned?.(freq);
136
+ this.log(`\u2705 Untuned from ${freq} FM`);
137
+ });
138
+ break;
139
+ case "broadcast":
140
+ this.handlers.onBroadcast?.(message.frequency, message.event);
141
+ this.log(`\uD83D\uDCFB Broadcast from ${message.frequency} FM`);
142
+ break;
143
+ case "pong":
144
+ this.log("\uD83D\uDC93 Heartbeat");
145
+ break;
146
+ case "error":
147
+ const error = new Error(message.message);
148
+ this.handlers.onError?.(error);
149
+ this.log(`\u274C Error: ${message.message}`);
150
+ break;
151
+ }
152
+ } catch (error) {
153
+ this.log("Failed to parse message:", error);
154
+ }
155
+ }
156
+ handleClose(code, reason) {
157
+ this.stopHeartbeat();
158
+ this.state = "DISCONNECTED";
159
+ this.log(`Connection closed (${code}): ${reason}`);
160
+ this.handlers.onDisconnect?.(code, reason);
161
+ if (this.options.reconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
162
+ this.reconnect();
163
+ }
164
+ }
165
+ async reconnect() {
166
+ this.reconnectAttempts++;
167
+ this.state = "RECONNECTING";
168
+ const delay = this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
169
+ this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.maxReconnectAttempts})...`);
170
+ await Bun.sleep(delay);
171
+ try {
172
+ await this.connect();
173
+ for (const freq of this.tunedFrequencies) {
174
+ await this.tune(freq);
175
+ }
176
+ } catch (error) {
177
+ this.log("Reconnection failed:", error);
178
+ }
179
+ }
180
+ send(message) {
181
+ if (!this.ws || this.state !== "CONNECTED") {
182
+ throw new Error("Not connected to Radio Protocol server");
183
+ }
184
+ this.ws.send(JSON.stringify(message));
185
+ }
186
+ startHeartbeat() {
187
+ this.stopHeartbeat();
188
+ this.heartbeatTimer = setInterval(() => {
189
+ if (this.state === "CONNECTED") {
190
+ this.send({ action: "ping" });
191
+ }
192
+ }, this.options.heartbeatInterval);
193
+ }
194
+ stopHeartbeat() {
195
+ if (this.heartbeatTimer) {
196
+ clearInterval(this.heartbeatTimer);
197
+ this.heartbeatTimer = null;
198
+ }
199
+ }
200
+ validateFrequency(frequency) {
201
+ const pattern = /^[0-9]{1,3}\.[0-9]$/;
202
+ if (!pattern.test(frequency)) {
203
+ throw new Error(`Invalid frequency format: ${frequency}. Expected XX.X (e.g., "91.5")`);
204
+ }
205
+ const num = parseFloat(frequency);
206
+ if (num < 40 || num > 108) {
207
+ throw new Error(`Frequency out of range: ${frequency}. Must be between 40.0 and 108.0`);
208
+ }
209
+ }
210
+ ensureConnected() {
211
+ if (this.state !== "CONNECTED") {
212
+ throw new Error("Not connected. Call connect() first.");
213
+ }
214
+ }
215
+ log(...args) {
216
+ if (this.options.debug) {
217
+ console.log("[Radio]", ...args);
218
+ }
219
+ }
220
+ }
221
+ export {
222
+ Radio
223
+ };
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "radio-faf",
3
+ "version": "0.1.0",
4
+ "description": "Radio Protocol client for RadioFAF — AI Context Broadcasting via WebSocket. Built for Bun.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "bun": "./src/index.ts",
11
+ "default": "./dist/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "src",
16
+ "dist",
17
+ "README.md",
18
+ "LICENSE",
19
+ "project.faf"
20
+ ],
21
+ "scripts": {
22
+ "dev": "bun --hot src/index.ts",
23
+ "test": "bun test",
24
+ "build": "bun build src/index.ts --outdir dist --target bun",
25
+ "prepublishOnly": "bun run build",
26
+ "example": "bun run examples/basic.ts"
27
+ },
28
+ "keywords": [
29
+ "ai-context",
30
+ "persistent-context",
31
+ "mcpaas",
32
+ "websocket",
33
+ "bun",
34
+ "claude",
35
+ "grok",
36
+ "gemini",
37
+ "faf"
38
+ ],
39
+ "author": "Wolfe James",
40
+ "license": "MIT",
41
+ "peerDependencies": {
42
+ "bun-types": "^1.3.0"
43
+ },
44
+ "devDependencies": {
45
+ "bun-types": "^1.3.0"
46
+ },
47
+ "engines": {
48
+ "bun": ">=1.3.0"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/Wolfe-Jam/faf-radio-bun.git"
53
+ },
54
+ "bugs": {
55
+ "url": "https://github.com/Wolfe-Jam/faf-radio-bun/issues"
56
+ },
57
+ "homepage": "https://radiofaf.com"
58
+ }
package/project.faf ADDED
@@ -0,0 +1,116 @@
1
+ faf_version: 2.5.0
2
+ generated: 2026-03-04T01:30:00.000Z
3
+ ai_scoring_system: 2025-09-20
4
+ ai_score: 85%
5
+ ai_confidence: HIGH
6
+ ai_value: 30_seconds_replaces_20_minutes_of_questions
7
+ ai_tldr:
8
+ project: 'mcpaas - "Persistent AI context SDK — your AI remembers across tools, sessions, and teams"'
9
+ stack: TypeScript, Bun, WebSocket
10
+ quality_bar: production_ready
11
+ current_focus: npm publish as mcpaas@0.1.0
12
+ your_role: Build features with perfect context
13
+ instant_context:
14
+ what_building: MCPaaS SDK — persistent AI context via Radio Protocol
15
+ tech_stack: TypeScript, Bun, WebSocket
16
+ main_language: TypeScript
17
+ key_files:
18
+ - src/radio.ts
19
+ - src/types.ts
20
+ - src/index.ts
21
+ - package.json
22
+ context_quality:
23
+ slots_filled: 18/21 (86%)
24
+ ai_confidence: HIGH
25
+ handoff_ready: true
26
+ missing_context:
27
+ - CI/CD pipeline
28
+ - Database
29
+ - Authentication
30
+ project:
31
+ name: mcpaas
32
+ goal: Persistent AI context SDK that connects to mcpaas.live via Radio Protocol
33
+ main_language: TypeScript
34
+ type: sdk
35
+ ai_instructions:
36
+ priority_order:
37
+ - 1. Read THIS .faf file first
38
+ - 2. Check CLAUDE.md for session context
39
+ - 3. Review package.json for dependencies
40
+ working_style:
41
+ code_first: true
42
+ explanations: minimal
43
+ quality_bar: zero_errors
44
+ testing: required
45
+ warnings:
46
+ - All TypeScript must pass strict mode
47
+ - Test coverage required for new features
48
+ - Package name is mcpaas (not faf-radio-bun)
49
+ stack:
50
+ package_manager: bun
51
+ targetUser: developers building AI applications
52
+ coreProblem: AI forgets context between sessions, tools, and teams
53
+ missionPurpose: WebSocket SDK for Radio Protocol — broadcast once, every AI receives
54
+ mainLanguage: TypeScript
55
+ runtime: Bun
56
+ frontend: None
57
+ backend: None
58
+ database: None
59
+ build: Bun
60
+ hosting: npm registry
61
+ cicd: None
62
+ testing: Bun Test + WJTTC (19/19 passing)
63
+ ui_library: None
64
+ preferences:
65
+ quality_bar: zero_errors
66
+ commit_style: conventional_emoji
67
+ response_style: concise_code_first
68
+ explanation_level: minimal
69
+ communication: direct
70
+ testing: required
71
+ documentation: as_needed
72
+ state:
73
+ phase: publish
74
+ version: 0.1.0
75
+ focus: npm_publish
76
+ status: green_flag
77
+ next_milestone: mcpaas@0.1.0 live on npm
78
+ blockers: null
79
+ scores:
80
+ faf_score: 85
81
+ slot_based_percentage: 86
82
+ total_slots: 21
83
+ tags:
84
+ auto_generated:
85
+ - mcpaas
86
+ - typescript
87
+ - bun
88
+ - websocket
89
+ smart_defaults:
90
+ - .faf
91
+ - ai-ready
92
+ - "2026"
93
+ - sdk
94
+ - open-source
95
+ user_defined: null
96
+ human_context:
97
+ who: Developers using Claude, Grok, Gemini, Cursor — anyone tired of re-explaining context
98
+ what: MCPaaS SDK — persistent AI context via Radio Protocol (WebSocket broadcast)
99
+ why: Your AI forgets you every session. MCPaaS remembers. Broadcast once, every AI receives.
100
+ where: npm (mcpaas) + GitHub (Wolfe-Jam/faf-radio-bun) + mcpaas.live (hosted service)
101
+ when: v0.1.0 ready to publish — 19/19 tests passing, 6.6KB tarball
102
+ how: npm install mcpaas → connect to mcpaas.live → tune to frequency → context persists
103
+ additional_context: Part of MCPaaS ecosystem. SDK connects to mcpaas.live (Cloudflare Workers, 300+ edge locations). IANA-registered format (application/vnd.faf+yaml).
104
+ context_score: 100
105
+ total_prd_score: 100
106
+ success_rate: 100%
107
+ faf_dna:
108
+ birth_dna: 43
109
+ birth_certificate: FAF-2026-FAFRADIO-7PMX
110
+ birth_date: 2026-02-13T03:38:41.451Z
111
+ current_score: 85
112
+ stack_signature: typescript-bun-websocket
113
+ detected_frameworks:
114
+ - Bun
115
+ - TypeScript
116
+ - WebSocket
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @faf/radio-bun
3
+ *
4
+ * Radio Protocol client for Bun - AI Context Broadcasting
5
+ * Built for Bun, Claude's Runtime
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { Radio } from '@faf/radio-bun';
10
+ *
11
+ * const radio = new Radio('wss://mcpaas.live/beacon/radio');
12
+ * await radio.connect();
13
+ * await radio.tune('91.5');
14
+ * radio.onBroadcast((freq, msg) => console.log(freq, msg));
15
+ * ```
16
+ */
17
+
18
+ export { Radio } from './radio';
19
+ export type * from './types';
package/src/radio.ts ADDED
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Radio Protocol Client for Bun
3
+ * Built for Bun - Claude's Runtime
4
+ *
5
+ * @example
6
+ * ```typescript
7
+ * const radio = new Radio('wss://mcpaas.live/beacon/radio');
8
+ * await radio.connect();
9
+ * await radio.tune('91.5');
10
+ * radio.onBroadcast((freq, msg) => console.log(freq, msg));
11
+ * await radio.broadcast('91.5', { hello: 'world' });
12
+ * ```
13
+ */
14
+
15
+ import type {
16
+ Frequency,
17
+ RadioOptions,
18
+ RadioEventHandlers,
19
+ ConnectionState,
20
+ ServerMessage,
21
+ ClientMessage,
22
+ BroadcastReceivedMessage,
23
+ } from './types';
24
+
25
+ export class Radio {
26
+ private url: string;
27
+ private ws: WebSocket | null = null;
28
+ private state: ConnectionState = 'DISCONNECTED';
29
+ private options: Required<RadioOptions>;
30
+ private handlers: RadioEventHandlers = {};
31
+ private reconnectAttempts = 0;
32
+ private heartbeatTimer: Timer | null = null;
33
+ private tunedFrequencies = new Set<Frequency>();
34
+
35
+ constructor(url: string, options: RadioOptions = {}) {
36
+ this.url = url;
37
+ this.options = {
38
+ reconnect: options.reconnect ?? true,
39
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 5,
40
+ reconnectDelay: options.reconnectDelay ?? 1000,
41
+ heartbeatInterval: options.heartbeatInterval ?? 30000,
42
+ debug: options.debug ?? false,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Connect to Radio Protocol server
48
+ */
49
+ async connect(): Promise<void> {
50
+ if (this.state === 'CONNECTED' || this.state === 'CONNECTING') {
51
+ this.log('Already connected or connecting');
52
+ return;
53
+ }
54
+
55
+ return new Promise((resolve, reject) => {
56
+ this.state = 'CONNECTING';
57
+ this.log(`Connecting to ${this.url}...`);
58
+
59
+ try {
60
+ this.ws = new WebSocket(this.url);
61
+
62
+ this.ws.onopen = () => {
63
+ this.state = 'CONNECTED';
64
+ this.reconnectAttempts = 0;
65
+ this.log('Connected to Radio Protocol');
66
+ this.startHeartbeat();
67
+ this.handlers.onConnect?.();
68
+ resolve();
69
+ };
70
+
71
+ this.ws.onmessage = (event) => {
72
+ this.handleMessage(event.data);
73
+ };
74
+
75
+ this.ws.onerror = (event) => {
76
+ const error = new Error('WebSocket error');
77
+ this.log('WebSocket error:', event);
78
+ this.handlers.onError?.(error);
79
+ reject(error);
80
+ };
81
+
82
+ this.ws.onclose = (event) => {
83
+ this.handleClose(event.code, event.reason);
84
+ };
85
+ } catch (error) {
86
+ this.state = 'DISCONNECTED';
87
+ reject(error);
88
+ }
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Tune to a frequency (subscribe)
94
+ */
95
+ async tune(frequency: Frequency): Promise<void> {
96
+ this.validateFrequency(frequency);
97
+ this.ensureConnected();
98
+
99
+ const message: ClientMessage = {
100
+ action: 'tune',
101
+ frequencies: [frequency],
102
+ };
103
+
104
+ this.send(message);
105
+ this.tunedFrequencies.add(frequency);
106
+ this.log(`Tuning to ${frequency} FM...`);
107
+ }
108
+
109
+ /**
110
+ * Untune from a frequency (unsubscribe)
111
+ */
112
+ async untune(frequency: Frequency): Promise<void> {
113
+ this.validateFrequency(frequency);
114
+ this.ensureConnected();
115
+
116
+ const message: ClientMessage = {
117
+ action: 'untune',
118
+ frequencies: [frequency],
119
+ };
120
+
121
+ this.send(message);
122
+ this.tunedFrequencies.delete(frequency);
123
+ this.log(`Untuning from ${frequency} FM...`);
124
+ }
125
+
126
+ /**
127
+ * Disconnect from server
128
+ */
129
+ disconnect(): void {
130
+ this.stopHeartbeat();
131
+ if (this.ws) {
132
+ this.state = 'CLOSED';
133
+ this.ws.close();
134
+ this.ws = null;
135
+ }
136
+ this.tunedFrequencies.clear();
137
+ this.log('Disconnected');
138
+ }
139
+
140
+ /**
141
+ * Event: On broadcast received
142
+ */
143
+ onBroadcast(handler: (frequency: Frequency, message: any) => void): void {
144
+ this.handlers.onBroadcast = handler;
145
+ }
146
+
147
+ /**
148
+ * Event: On connection established
149
+ */
150
+ onConnect(handler: () => void): void {
151
+ this.handlers.onConnect = handler;
152
+ }
153
+
154
+ /**
155
+ * Event: On disconnection
156
+ */
157
+ onDisconnect(handler: (code?: number, reason?: string) => void): void {
158
+ this.handlers.onDisconnect = handler;
159
+ }
160
+
161
+ /**
162
+ * Event: On error
163
+ */
164
+ onError(handler: (error: Error) => void): void {
165
+ this.handlers.onError = handler;
166
+ }
167
+
168
+ /**
169
+ * Event: On tuned confirmation
170
+ */
171
+ onTuned(handler: (frequency: Frequency) => void): void {
172
+ this.handlers.onTuned = handler;
173
+ }
174
+
175
+ /**
176
+ * Event: On untuned confirmation
177
+ */
178
+ onUntuned(handler: (frequency: Frequency) => void): void {
179
+ this.handlers.onUntuned = handler;
180
+ }
181
+
182
+ /**
183
+ * Get current connection state
184
+ */
185
+ getState(): ConnectionState {
186
+ return this.state;
187
+ }
188
+
189
+ /**
190
+ * Get list of tuned frequencies
191
+ */
192
+ getTunedFrequencies(): Set<Frequency> {
193
+ return new Set(this.tunedFrequencies);
194
+ }
195
+
196
+ /**
197
+ * Handle incoming server message
198
+ */
199
+ private handleMessage(data: string | Blob | ArrayBuffer): void {
200
+ try {
201
+ const text = typeof data === 'string' ? data : new TextDecoder().decode(data as ArrayBuffer);
202
+ const message: ServerMessage = JSON.parse(text);
203
+
204
+ this.log('Received:', message.type);
205
+
206
+ switch (message.type) {
207
+ case 'connected':
208
+ this.log(`✅ ${message.message} (Client ID: ${message.clientId})`);
209
+ this.log('Available frequencies:', message.availableFrequencies);
210
+ break;
211
+
212
+ case 'tuned':
213
+ // Call handler for each tuned frequency
214
+ message.frequencies.forEach(freq => {
215
+ this.handlers.onTuned?.(freq);
216
+ this.log(`✅ Tuned to ${freq} FM`);
217
+ });
218
+ if (message.invalid.length > 0) {
219
+ this.log(`⚠️ Invalid frequencies: ${message.invalid.join(', ')}`);
220
+ }
221
+ break;
222
+
223
+ case 'untuned':
224
+ // Call handler for each untuned frequency
225
+ message.frequencies.forEach(freq => {
226
+ this.handlers.onUntuned?.(freq);
227
+ this.log(`✅ Untuned from ${freq} FM`);
228
+ });
229
+ break;
230
+
231
+ case 'broadcast':
232
+ this.handlers.onBroadcast?.(message.frequency, message.event);
233
+ this.log(`📻 Broadcast from ${message.frequency} FM`);
234
+ break;
235
+
236
+ case 'pong':
237
+ this.log('💓 Heartbeat');
238
+ break;
239
+
240
+ case 'error':
241
+ const error = new Error(message.message);
242
+ this.handlers.onError?.(error);
243
+ this.log(`❌ Error: ${message.message}`);
244
+ break;
245
+ }
246
+ } catch (error) {
247
+ this.log('Failed to parse message:', error);
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Handle connection close
253
+ */
254
+ private handleClose(code?: number, reason?: string): void {
255
+ this.stopHeartbeat();
256
+ this.state = 'DISCONNECTED';
257
+ this.log(`Connection closed (${code}): ${reason}`);
258
+ this.handlers.onDisconnect?.(code, reason);
259
+
260
+ // Auto-reconnect if enabled
261
+ if (this.options.reconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
262
+ this.reconnect();
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Reconnect with exponential backoff
268
+ */
269
+ private async reconnect(): Promise<void> {
270
+ this.reconnectAttempts++;
271
+ this.state = 'RECONNECTING';
272
+
273
+ const delay = this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
274
+ this.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.options.maxReconnectAttempts})...`);
275
+
276
+ await Bun.sleep(delay);
277
+
278
+ try {
279
+ await this.connect();
280
+
281
+ // Re-tune to all previously tuned frequencies
282
+ for (const freq of this.tunedFrequencies) {
283
+ await this.tune(freq);
284
+ }
285
+ } catch (error) {
286
+ this.log('Reconnection failed:', error);
287
+ }
288
+ }
289
+
290
+ /**
291
+ * Send message to server
292
+ */
293
+ private send(message: ClientMessage): void {
294
+ if (!this.ws || this.state !== 'CONNECTED') {
295
+ throw new Error('Not connected to Radio Protocol server');
296
+ }
297
+
298
+ this.ws.send(JSON.stringify(message));
299
+ }
300
+
301
+ /**
302
+ * Start heartbeat timer
303
+ */
304
+ private startHeartbeat(): void {
305
+ this.stopHeartbeat();
306
+
307
+ this.heartbeatTimer = setInterval(() => {
308
+ if (this.state === 'CONNECTED') {
309
+ this.send({ action: 'ping' });
310
+ }
311
+ }, this.options.heartbeatInterval);
312
+ }
313
+
314
+ /**
315
+ * Stop heartbeat timer
316
+ */
317
+ private stopHeartbeat(): void {
318
+ if (this.heartbeatTimer) {
319
+ clearInterval(this.heartbeatTimer);
320
+ this.heartbeatTimer = null;
321
+ }
322
+ }
323
+
324
+ /**
325
+ * Validate frequency format
326
+ */
327
+ private validateFrequency(frequency: Frequency): void {
328
+ const pattern = /^[0-9]{1,3}\.[0-9]$/;
329
+ if (!pattern.test(frequency)) {
330
+ throw new Error(`Invalid frequency format: ${frequency}. Expected XX.X (e.g., "91.5")`);
331
+ }
332
+
333
+ const num = parseFloat(frequency);
334
+ if (num < 40.0 || num > 108.0) {
335
+ throw new Error(`Frequency out of range: ${frequency}. Must be between 40.0 and 108.0`);
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Ensure client is connected
341
+ */
342
+ private ensureConnected(): void {
343
+ if (this.state !== 'CONNECTED') {
344
+ throw new Error('Not connected. Call connect() first.');
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Debug logging
350
+ */
351
+ private log(...args: any[]): void {
352
+ if (this.options.debug) {
353
+ console.log('[Radio]', ...args);
354
+ }
355
+ }
356
+ }
package/src/types.ts ADDED
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Radio Protocol Types for Bun
3
+ * Built for Bun - Claude's Runtime
4
+ */
5
+
6
+ /**
7
+ * Radio Protocol message types (from protocol spec)
8
+ */
9
+ export type MessageType =
10
+ | 'TUNE' // Subscribe to frequency
11
+ | 'UNTUNE' // Unsubscribe from frequency
12
+ | 'BROADCAST' // Send message to frequency
13
+ | 'PING' // Heartbeat
14
+ | 'TUNED' // Confirmation of tune
15
+ | 'UNTUNED' // Confirmation of untune
16
+ | 'PONG' // Heartbeat response
17
+ | 'ERROR'; // Error response
18
+
19
+ /**
20
+ * Frequency format: XX.X (e.g., "91.5")
21
+ * Range: 40.0 - 108.0 FM
22
+ */
23
+ export type Frequency = string;
24
+
25
+ /**
26
+ * Client → Server messages
27
+ * Note: Server expects "action" field (not "type") and "frequencies" array
28
+ */
29
+ export interface TuneMessage {
30
+ action: 'tune';
31
+ frequencies: Frequency[];
32
+ }
33
+
34
+ export interface UntuneMessage {
35
+ action: 'untune';
36
+ frequencies: Frequency[];
37
+ }
38
+
39
+ export interface PingMessage {
40
+ action: 'ping';
41
+ }
42
+
43
+ export type ClientMessage =
44
+ | TuneMessage
45
+ | UntuneMessage
46
+ | PingMessage;
47
+
48
+ /**
49
+ * Server → Client messages
50
+ * Note: Server sends lowercase "type" values
51
+ */
52
+ export interface ConnectedMessage {
53
+ type: 'connected';
54
+ clientId: string;
55
+ message: string;
56
+ availableFrequencies: Record<string, string>;
57
+ }
58
+
59
+ export interface TunedMessage {
60
+ type: 'tuned';
61
+ frequencies: Frequency[];
62
+ invalid: Frequency[];
63
+ souls: Array<{ frequency: Frequency; soul: string }>;
64
+ }
65
+
66
+ export interface UntunedMessage {
67
+ type: 'untuned';
68
+ frequencies: Frequency[];
69
+ }
70
+
71
+ export interface BroadcastReceivedMessage {
72
+ type: 'broadcast';
73
+ frequency: Frequency;
74
+ event: any;
75
+ timestamp?: number;
76
+ }
77
+
78
+ export interface PongMessage {
79
+ type: 'pong';
80
+ timestamp: number;
81
+ }
82
+
83
+ export interface ErrorMessage {
84
+ type: 'error';
85
+ message: string;
86
+ }
87
+
88
+ export type ServerMessage =
89
+ | ConnectedMessage
90
+ | TunedMessage
91
+ | UntunedMessage
92
+ | BroadcastReceivedMessage
93
+ | PongMessage
94
+ | ErrorMessage;
95
+
96
+ /**
97
+ * Radio client options
98
+ */
99
+ export interface RadioOptions {
100
+ /** Enable automatic reconnection (default: true) */
101
+ reconnect?: boolean;
102
+
103
+ /** Max reconnection attempts (default: 5) */
104
+ maxReconnectAttempts?: number;
105
+
106
+ /** Reconnection delay in ms (default: 1000) */
107
+ reconnectDelay?: number;
108
+
109
+ /** Heartbeat interval in ms (default: 30000) */
110
+ heartbeatInterval?: number;
111
+
112
+ /** Enable debug logging (default: false) */
113
+ debug?: boolean;
114
+ }
115
+
116
+ /**
117
+ * Radio connection state
118
+ */
119
+ export type ConnectionState =
120
+ | 'DISCONNECTED'
121
+ | 'CONNECTING'
122
+ | 'CONNECTED'
123
+ | 'RECONNECTING'
124
+ | 'CLOSED';
125
+
126
+ /**
127
+ * Event handlers
128
+ */
129
+ export interface RadioEventHandlers {
130
+ onConnect?: () => void;
131
+ onDisconnect?: (code?: number, reason?: string) => void;
132
+ onError?: (error: Error) => void;
133
+ onBroadcast?: (frequency: Frequency, message: any) => void;
134
+ onTuned?: (frequency: Frequency) => void;
135
+ onUntuned?: (frequency: Frequency) => void;
136
+ }