hotpipe 0.0.1 → 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/dist/client/connection.d.ts +36 -0
- package/dist/client/connection.d.ts.map +1 -0
- package/dist/client/connection.js +158 -0
- package/dist/client/connection.js.map +1 -0
- package/dist/client/index.d.ts +52 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +124 -0
- package/dist/client/index.js.map +1 -0
- package/dist/server/index.d.ts +71 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +103 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +19 -9
- package/src/client/connection.ts +0 -204
- package/src/client/index.tsx +0 -215
- package/src/server/index.ts +0 -145
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
|
2
|
+
export type ChannelListener = (event: string, data: unknown) => void;
|
|
3
|
+
interface ConnectionConfig {
|
|
4
|
+
brokerUrl: string;
|
|
5
|
+
authEndpoint: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class ConnectionManager {
|
|
8
|
+
private config;
|
|
9
|
+
private ws;
|
|
10
|
+
private status;
|
|
11
|
+
private statusListeners;
|
|
12
|
+
private channelListeners;
|
|
13
|
+
private channelRefs;
|
|
14
|
+
private reconnectAttempts;
|
|
15
|
+
private maxReconnectDelay;
|
|
16
|
+
private pingInterval;
|
|
17
|
+
private reconnectTimer;
|
|
18
|
+
private intentionalClose;
|
|
19
|
+
constructor(config: ConnectionConfig);
|
|
20
|
+
connect(): Promise<void>;
|
|
21
|
+
disconnect(): void;
|
|
22
|
+
subscribe(channel: string): void;
|
|
23
|
+
unsubscribe(channel: string): void;
|
|
24
|
+
publish(channel: string, event: string, data: unknown): void;
|
|
25
|
+
addChannelListener(channel: string, listener: ChannelListener): void;
|
|
26
|
+
removeChannelListener(channel: string, listener: ChannelListener): void;
|
|
27
|
+
getStatus(): ConnectionStatus;
|
|
28
|
+
onStatusChange(listener: (status: ConnectionStatus) => void): () => void;
|
|
29
|
+
private dispatch;
|
|
30
|
+
private setStatus;
|
|
31
|
+
private wsSend;
|
|
32
|
+
private cleanup;
|
|
33
|
+
private scheduleReconnect;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
|
36
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../../src/client/connection.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,cAAc,GAAG,YAAY,GAAG,WAAW,GAAG,cAAc,CAAC;AAE5F,MAAM,MAAM,eAAe,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,KAAK,IAAI,CAAC;AAErE,UAAU,gBAAgB;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,qBAAa,iBAAiB;IAYhB,OAAO,CAAC,MAAM;IAX1B,OAAO,CAAC,EAAE,CAA0B;IACpC,OAAO,CAAC,MAAM,CAAoC;IAClD,OAAO,CAAC,eAAe,CAAiD;IACxE,OAAO,CAAC,gBAAgB,CAA2C;IACnE,OAAO,CAAC,WAAW,CAA6B;IAChD,OAAO,CAAC,iBAAiB,CAAK;IAC9B,OAAO,CAAC,iBAAiB,CAAU;IACnC,OAAO,CAAC,YAAY,CAA+C;IACnE,OAAO,CAAC,cAAc,CAA8C;IACpE,OAAO,CAAC,gBAAgB,CAAS;gBAEb,MAAM,EAAE,gBAAgB;IAEtC,OAAO;IAqEb,UAAU;IAcV,SAAS,CAAC,OAAO,EAAE,MAAM;IASzB,WAAW,CAAC,OAAO,EAAE,MAAM;IAc3B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAMrD,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe;IAQ7D,qBAAqB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,eAAe;IAQhE,SAAS,IAAI,gBAAgB;IAI7B,cAAc,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,gBAAgB,KAAK,IAAI,GAAG,MAAM,IAAI;IAKxE,OAAO,CAAC,QAAQ;IAShB,OAAO,CAAC,SAAS;IAQjB,OAAO,CAAC,MAAM;IAId,OAAO,CAAC,OAAO;IAOf,OAAO,CAAC,iBAAiB;CAW1B"}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export class ConnectionManager {
|
|
2
|
+
config;
|
|
3
|
+
ws = null;
|
|
4
|
+
status = 'disconnected';
|
|
5
|
+
statusListeners = new Set();
|
|
6
|
+
channelListeners = new Map();
|
|
7
|
+
channelRefs = new Map();
|
|
8
|
+
reconnectAttempts = 0;
|
|
9
|
+
maxReconnectDelay = 30_000;
|
|
10
|
+
pingInterval = null;
|
|
11
|
+
reconnectTimer = null;
|
|
12
|
+
intentionalClose = false;
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
}
|
|
16
|
+
async connect() {
|
|
17
|
+
if (this.status === 'connecting' || this.status === 'connected')
|
|
18
|
+
return;
|
|
19
|
+
this.intentionalClose = false;
|
|
20
|
+
this.setStatus(this.reconnectAttempts > 0 ? 'reconnecting' : 'connecting');
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch(this.config.authEndpoint, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
credentials: 'include',
|
|
25
|
+
});
|
|
26
|
+
if (!res.ok) {
|
|
27
|
+
throw new Error(`Auth failed: ${res.status}`);
|
|
28
|
+
}
|
|
29
|
+
const { token } = await res.json();
|
|
30
|
+
const wsUrl = `${this.config.brokerUrl}/ws?token=${encodeURIComponent(token)}`;
|
|
31
|
+
this.ws = new WebSocket(wsUrl);
|
|
32
|
+
this.ws.onopen = () => {
|
|
33
|
+
this.setStatus('connected');
|
|
34
|
+
this.reconnectAttempts = 0;
|
|
35
|
+
// Resubscribe to all active channels
|
|
36
|
+
for (const channel of this.channelRefs.keys()) {
|
|
37
|
+
this.wsSend({ type: 'subscribe', channel });
|
|
38
|
+
}
|
|
39
|
+
// Keepalive ping every 30s
|
|
40
|
+
this.pingInterval = setInterval(() => {
|
|
41
|
+
this.wsSend({ type: 'ping' });
|
|
42
|
+
}, 30_000);
|
|
43
|
+
};
|
|
44
|
+
this.ws.onmessage = (event) => {
|
|
45
|
+
try {
|
|
46
|
+
const msg = JSON.parse(event.data);
|
|
47
|
+
if (msg.type === 'event') {
|
|
48
|
+
this.dispatch(msg.channel, msg.event, msg.data);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Ignore malformed messages
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
this.ws.onclose = () => {
|
|
56
|
+
this.cleanup();
|
|
57
|
+
this.setStatus('disconnected');
|
|
58
|
+
if (!this.intentionalClose) {
|
|
59
|
+
this.scheduleReconnect();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
this.ws.onerror = () => {
|
|
63
|
+
// onclose fires after onerror — reconnect handled there
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
this.setStatus('disconnected');
|
|
68
|
+
if (!this.intentionalClose) {
|
|
69
|
+
this.scheduleReconnect();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
disconnect() {
|
|
74
|
+
this.intentionalClose = true;
|
|
75
|
+
this.cleanup();
|
|
76
|
+
if (this.reconnectTimer) {
|
|
77
|
+
clearTimeout(this.reconnectTimer);
|
|
78
|
+
this.reconnectTimer = null;
|
|
79
|
+
}
|
|
80
|
+
this.ws?.close();
|
|
81
|
+
this.ws = null;
|
|
82
|
+
this.setStatus('disconnected');
|
|
83
|
+
}
|
|
84
|
+
subscribe(channel) {
|
|
85
|
+
const count = this.channelRefs.get(channel) || 0;
|
|
86
|
+
this.channelRefs.set(channel, count + 1);
|
|
87
|
+
if (count === 0 && this.ws?.readyState === WebSocket.OPEN) {
|
|
88
|
+
this.wsSend({ type: 'subscribe', channel });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
unsubscribe(channel) {
|
|
92
|
+
const count = this.channelRefs.get(channel) || 0;
|
|
93
|
+
if (count <= 1) {
|
|
94
|
+
this.channelRefs.delete(channel);
|
|
95
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
96
|
+
this.wsSend({ type: 'unsubscribe', channel });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
this.channelRefs.set(channel, count - 1);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
publish(channel, event, data) {
|
|
104
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
105
|
+
this.wsSend({ type: 'publish', channel, event, data });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
addChannelListener(channel, listener) {
|
|
109
|
+
if (!this.channelListeners.has(channel)) {
|
|
110
|
+
this.channelListeners.set(channel, new Set());
|
|
111
|
+
}
|
|
112
|
+
this.channelListeners.get(channel).add(listener);
|
|
113
|
+
}
|
|
114
|
+
removeChannelListener(channel, listener) {
|
|
115
|
+
this.channelListeners.get(channel)?.delete(listener);
|
|
116
|
+
if (this.channelListeners.get(channel)?.size === 0) {
|
|
117
|
+
this.channelListeners.delete(channel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
getStatus() {
|
|
121
|
+
return this.status;
|
|
122
|
+
}
|
|
123
|
+
onStatusChange(listener) {
|
|
124
|
+
this.statusListeners.add(listener);
|
|
125
|
+
return () => this.statusListeners.delete(listener);
|
|
126
|
+
}
|
|
127
|
+
dispatch(channel, event, data) {
|
|
128
|
+
const listeners = this.channelListeners.get(channel);
|
|
129
|
+
if (!listeners)
|
|
130
|
+
return;
|
|
131
|
+
for (const listener of listeners) {
|
|
132
|
+
listener(event, data);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
setStatus(status) {
|
|
136
|
+
this.status = status;
|
|
137
|
+
for (const listener of this.statusListeners) {
|
|
138
|
+
listener(status);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
wsSend(msg) {
|
|
142
|
+
this.ws?.send(JSON.stringify(msg));
|
|
143
|
+
}
|
|
144
|
+
cleanup() {
|
|
145
|
+
if (this.pingInterval) {
|
|
146
|
+
clearInterval(this.pingInterval);
|
|
147
|
+
this.pingInterval = null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
scheduleReconnect() {
|
|
151
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000, this.maxReconnectDelay);
|
|
152
|
+
this.reconnectTimer = setTimeout(() => {
|
|
153
|
+
this.reconnectAttempts++;
|
|
154
|
+
this.connect();
|
|
155
|
+
}, delay);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
//# sourceMappingURL=connection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.js","sourceRoot":"","sources":["../../src/client/connection.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
import { type ConnectionStatus } from './connection';
|
|
3
|
+
/**
|
|
4
|
+
* A record mapping event names to Zod schemas.
|
|
5
|
+
*/
|
|
6
|
+
type EventMap = Record<string, z.ZodType>;
|
|
7
|
+
/**
|
|
8
|
+
* Extract event names from an EventMap.
|
|
9
|
+
*/
|
|
10
|
+
type EventName<T extends EventMap> = keyof T & string;
|
|
11
|
+
/**
|
|
12
|
+
* Infer the data type for a given event.
|
|
13
|
+
*/
|
|
14
|
+
type EventData<T extends EventMap, E extends EventName<T>> = z.infer<T[E]>;
|
|
15
|
+
/**
|
|
16
|
+
* Handler map for useChannel — a partial record of event handlers.
|
|
17
|
+
*/
|
|
18
|
+
type ChannelHandlers<T extends EventMap> = {
|
|
19
|
+
[E in EventName<T>]?: (data: EventData<T, E>) => void;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Creates a typed real-time client bound to your event schemas.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* ```ts
|
|
26
|
+
* const { RealtimeProvider, useChannel, useEvent } = createRealtimeClient({
|
|
27
|
+
* events: realtimeEvents,
|
|
28
|
+
* auth: { endpoint: '/api/realtime/auth' },
|
|
29
|
+
* });
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export declare function createRealtimeClient<T extends EventMap>(config: {
|
|
33
|
+
events: T;
|
|
34
|
+
auth: {
|
|
35
|
+
endpoint: string;
|
|
36
|
+
};
|
|
37
|
+
/** Override the broker URL. Defaults to `wss://api.hotpipe.dev`. */
|
|
38
|
+
brokerUrl?: string;
|
|
39
|
+
}): {
|
|
40
|
+
RealtimeProvider: ({ children }: {
|
|
41
|
+
children: React.ReactNode;
|
|
42
|
+
}) => import("react/jsx-runtime").JSX.Element;
|
|
43
|
+
useChannel: (channel: string, handlers: Partial<ChannelHandlers<T>>) => {
|
|
44
|
+
status: ConnectionStatus;
|
|
45
|
+
publish: <E extends EventName<T>>(event: E, data: EventData<T, E>) => void;
|
|
46
|
+
};
|
|
47
|
+
useEvent: <E extends EventName<T>>(channel: string, event: E, handler: (data: EventData<T, E>) => void) => {
|
|
48
|
+
status: ConnectionStatus;
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
export type { ConnectionStatus, EventMap };
|
|
52
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/client/index.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,OAAO,EAAqB,KAAK,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAExE;;GAEG;AACH,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAE1C;;GAEG;AACH,KAAK,SAAS,CAAC,CAAC,SAAS,QAAQ,IAAI,MAAM,CAAC,GAAG,MAAM,CAAC;AAEtD;;GAEG;AACH,KAAK,SAAS,CAAC,CAAC,SAAS,QAAQ,EAAE,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;AAE3E;;GAEG;AACH,KAAK,eAAe,CAAC,CAAC,SAAS,QAAQ,IAAI;KACxC,CAAC,IAAI,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI;CACtD,CAAC;AAIF;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,QAAQ,EAAE,MAAM,EAAE;IAC/D,MAAM,EAAE,CAAC,CAAC;IACV,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IAC3B,oEAAoE;IACpE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;qCAiByC;QAAE,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;KAAE;0BA+B1D,MAAM,YACL,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,CAAC,KACpC;QACD,MAAM,EAAE,gBAAgB,CAAC;QACzB,OAAO,EAAE,CAAC,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,IAAI,EAAE,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC;KAC5E;eA6DiB,CAAC,SAAS,SAAS,CAAC,CAAC,CAAC,WAC7B,MAAM,SACR,CAAC,WACC,CAAC,IAAI,EAAE,SAAS,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,KACvC;QAAE,MAAM,EAAE,gBAAgB,CAAA;KAAE;EA+BhC;AAED,YAAY,EAAE,gBAAgB,EAAE,QAAQ,EAAE,CAAC"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { ConnectionManager } from './connection';
|
|
5
|
+
const DEFAULT_BROKER_URL = 'wss://api.hotpipe.dev';
|
|
6
|
+
/**
|
|
7
|
+
* Creates a typed real-time client bound to your event schemas.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* ```ts
|
|
11
|
+
* const { RealtimeProvider, useChannel, useEvent } = createRealtimeClient({
|
|
12
|
+
* events: realtimeEvents,
|
|
13
|
+
* auth: { endpoint: '/api/realtime/auth' },
|
|
14
|
+
* });
|
|
15
|
+
* ```
|
|
16
|
+
*/
|
|
17
|
+
export function createRealtimeClient(config) {
|
|
18
|
+
const RealtimeContext = createContext(null);
|
|
19
|
+
function useManager() {
|
|
20
|
+
const manager = useContext(RealtimeContext);
|
|
21
|
+
if (!manager) {
|
|
22
|
+
throw new Error('hotpipe: useChannel/useEvent must be used within <RealtimeProvider>');
|
|
23
|
+
}
|
|
24
|
+
return manager;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Wrap your app (or a subtree) with this provider to establish the
|
|
28
|
+
* WebSocket connection to the broker.
|
|
29
|
+
*/
|
|
30
|
+
function RealtimeProvider({ children }) {
|
|
31
|
+
const managerRef = useRef(null);
|
|
32
|
+
if (!managerRef.current) {
|
|
33
|
+
managerRef.current = new ConnectionManager({
|
|
34
|
+
brokerUrl: config.brokerUrl ?? DEFAULT_BROKER_URL,
|
|
35
|
+
authEndpoint: config.auth.endpoint,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
managerRef.current?.connect();
|
|
40
|
+
return () => managerRef.current?.disconnect();
|
|
41
|
+
}, []);
|
|
42
|
+
return (_jsx(RealtimeContext.Provider, { value: managerRef.current, children: children }));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Subscribe to events on a channel. Returns connection status and a
|
|
46
|
+
* typed publish function.
|
|
47
|
+
*
|
|
48
|
+
* ```tsx
|
|
49
|
+
* const { status, publish } = useChannel('general', {
|
|
50
|
+
* 'message.created': (data) => addMessage(data),
|
|
51
|
+
* });
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
function useChannel(channel, handlers) {
|
|
55
|
+
const manager = useManager();
|
|
56
|
+
const handlersRef = useRef(handlers);
|
|
57
|
+
handlersRef.current = handlers;
|
|
58
|
+
const [status, setStatus] = useState(manager.getStatus());
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
return manager.onStatusChange(setStatus);
|
|
61
|
+
}, [manager]);
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
const listener = (event, data) => {
|
|
64
|
+
const handler = handlersRef.current[event];
|
|
65
|
+
if (handler) {
|
|
66
|
+
handler(data);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
manager.addChannelListener(channel, listener);
|
|
70
|
+
manager.subscribe(channel);
|
|
71
|
+
return () => {
|
|
72
|
+
manager.unsubscribe(channel);
|
|
73
|
+
manager.removeChannelListener(channel, listener);
|
|
74
|
+
};
|
|
75
|
+
}, [channel, manager]);
|
|
76
|
+
const publish = useCallback((event, data) => {
|
|
77
|
+
// Validate against schema before sending
|
|
78
|
+
const schema = config.events[event];
|
|
79
|
+
if (schema) {
|
|
80
|
+
const result = schema.safeParse(data);
|
|
81
|
+
if (!result.success) {
|
|
82
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
83
|
+
console.error(`[hotpipe] Invalid data for "${event}":`, result.error.format());
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
manager.publish(channel, event, data);
|
|
89
|
+
}, [channel, manager]);
|
|
90
|
+
return { status, publish };
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Subscribe to a single event type on a channel.
|
|
94
|
+
*
|
|
95
|
+
* ```tsx
|
|
96
|
+
* useEvent('general', 'message.created', (data) => addMessage(data));
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
function useEvent(channel, event, handler) {
|
|
100
|
+
const manager = useManager();
|
|
101
|
+
const handlerRef = useRef(handler);
|
|
102
|
+
handlerRef.current = handler;
|
|
103
|
+
const [status, setStatus] = useState(manager.getStatus());
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
return manager.onStatusChange(setStatus);
|
|
106
|
+
}, [manager]);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
const listener = (evt, data) => {
|
|
109
|
+
if (evt === event) {
|
|
110
|
+
handlerRef.current(data);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
manager.addChannelListener(channel, listener);
|
|
114
|
+
manager.subscribe(channel);
|
|
115
|
+
return () => {
|
|
116
|
+
manager.unsubscribe(channel);
|
|
117
|
+
manager.removeChannelListener(channel, listener);
|
|
118
|
+
};
|
|
119
|
+
}, [channel, event, manager]);
|
|
120
|
+
return { status };
|
|
121
|
+
}
|
|
122
|
+
return { RealtimeProvider, useChannel, useEvent };
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/client/index.tsx"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { z } from 'zod';
|
|
2
|
+
type EventMap = Record<string, z.ZodType>;
|
|
3
|
+
/**
|
|
4
|
+
* The shape returned by your authorize function.
|
|
5
|
+
*/
|
|
6
|
+
interface AuthResult {
|
|
7
|
+
userId: string;
|
|
8
|
+
channels: Record<string, ('subscribe' | 'publish')[]>;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Creates a typed server-side publisher for emitting events to the broker
|
|
12
|
+
* from API routes, server actions, webhooks, cron jobs, etc.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* const realtime = createRealtimePublisher({
|
|
16
|
+
* apiKey: process.env.HOTPIPE_API_KEY!,
|
|
17
|
+
* events: realtimeEvents,
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* await realtime.publish('general', 'message.created', { ... });
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
export declare function createRealtimePublisher<T extends EventMap>(config: {
|
|
24
|
+
apiKey: string;
|
|
25
|
+
events: T;
|
|
26
|
+
/** Override the broker URL. Defaults to `https://api.hotpipe.dev`. */
|
|
27
|
+
brokerUrl?: string;
|
|
28
|
+
}): {
|
|
29
|
+
/**
|
|
30
|
+
* Publish a single event to a channel.
|
|
31
|
+
*/
|
|
32
|
+
publish<E extends keyof T & string>(channel: string, event: E, data: z.infer<T[E]>): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Publish multiple events in a single HTTP request.
|
|
35
|
+
*/
|
|
36
|
+
publishBatch(events: Array<{
|
|
37
|
+
channel: string;
|
|
38
|
+
event: keyof T & string;
|
|
39
|
+
data: unknown;
|
|
40
|
+
}>): Promise<void>;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Creates a Next.js-compatible route handler for client auth.
|
|
44
|
+
* The client SDK calls this endpoint to get a signed token before
|
|
45
|
+
* opening the WebSocket connection to the broker.
|
|
46
|
+
*
|
|
47
|
+
* ```ts
|
|
48
|
+
* // app/api/realtime/auth/route.ts
|
|
49
|
+
* export const POST = createAuthHandler({
|
|
50
|
+
* secret: process.env.BROKER_SIGNING_SECRET!,
|
|
51
|
+
* authorize: async (req) => {
|
|
52
|
+
* const session = await getServerSession();
|
|
53
|
+
* if (!session) return null;
|
|
54
|
+
* return {
|
|
55
|
+
* userId: session.user.id,
|
|
56
|
+
* channels: {
|
|
57
|
+
* general: ['subscribe', 'publish'],
|
|
58
|
+
* [`user-${session.user.id}`]: ['subscribe'],
|
|
59
|
+
* },
|
|
60
|
+
* };
|
|
61
|
+
* },
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export declare function createAuthHandler(handlerConfig: {
|
|
66
|
+
secret: string;
|
|
67
|
+
authorize: (req: Request) => Promise<AuthResult | null>;
|
|
68
|
+
tokenExpiry?: number;
|
|
69
|
+
}): (req: Request) => Promise<Response>;
|
|
70
|
+
export type { AuthResult, EventMap };
|
|
71
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;AAE1C;;GAEG;AACH,UAAU,UAAU;IAClB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,WAAW,GAAG,SAAS,CAAC,EAAE,CAAC,CAAC;CACvD;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,uBAAuB,CAAC,CAAC,SAAS,QAAQ,EAAE,MAAM,EAAE;IAClE,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,CAAC,CAAC;IACV,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;IASG;;OAEG;YACW,CAAC,SAAS,MAAM,CAAC,GAAG,MAAM,WAC7B,MAAM,SACR,CAAC,QACF,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAClB,OAAO,CAAC,IAAI,CAAC;IAkBhB;;OAEG;yBAEO,KAAK,CAAC;QACZ,OAAO,EAAE,MAAM,CAAC;QAChB,KAAK,EAAE,MAAM,CAAC,GAAG,MAAM,CAAC;QACxB,IAAI,EAAE,OAAO,CAAC;KACf,CAAC,GACD,OAAO,CAAC,IAAI,CAAC;EAYnB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,iBAAiB,CAAC,aAAa,EAAE;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAAC;IACxD,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,IAIe,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAuB/C;AAED,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,CAAC"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { SignJWT } from 'jose';
|
|
2
|
+
const DEFAULT_BROKER_URL = 'https://api.hotpipe.dev';
|
|
3
|
+
/**
|
|
4
|
+
* Creates a typed server-side publisher for emitting events to the broker
|
|
5
|
+
* from API routes, server actions, webhooks, cron jobs, etc.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* const realtime = createRealtimePublisher({
|
|
9
|
+
* apiKey: process.env.HOTPIPE_API_KEY!,
|
|
10
|
+
* events: realtimeEvents,
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* await realtime.publish('general', 'message.created', { ... });
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export function createRealtimePublisher(config) {
|
|
17
|
+
const brokerUrl = config.brokerUrl ?? DEFAULT_BROKER_URL;
|
|
18
|
+
const headers = {
|
|
19
|
+
'Content-Type': 'application/json',
|
|
20
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
21
|
+
};
|
|
22
|
+
return {
|
|
23
|
+
/**
|
|
24
|
+
* Publish a single event to a channel.
|
|
25
|
+
*/
|
|
26
|
+
async publish(channel, event, data) {
|
|
27
|
+
const schema = config.events[event];
|
|
28
|
+
if (schema) {
|
|
29
|
+
schema.parse(data);
|
|
30
|
+
}
|
|
31
|
+
const res = await fetch(`${brokerUrl}/publish`, {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
headers,
|
|
34
|
+
body: JSON.stringify({ channel, event, data }),
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
throw new Error(`hotpipe publish failed: ${res.status} ${await res.text()}`);
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
/**
|
|
41
|
+
* Publish multiple events in a single HTTP request.
|
|
42
|
+
*/
|
|
43
|
+
async publishBatch(events) {
|
|
44
|
+
const res = await fetch(`${brokerUrl}/publish/batch`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers,
|
|
47
|
+
body: JSON.stringify({ events }),
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
throw new Error(`hotpipe batch publish failed: ${res.status} ${await res.text()}`);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Creates a Next.js-compatible route handler for client auth.
|
|
57
|
+
* The client SDK calls this endpoint to get a signed token before
|
|
58
|
+
* opening the WebSocket connection to the broker.
|
|
59
|
+
*
|
|
60
|
+
* ```ts
|
|
61
|
+
* // app/api/realtime/auth/route.ts
|
|
62
|
+
* export const POST = createAuthHandler({
|
|
63
|
+
* secret: process.env.BROKER_SIGNING_SECRET!,
|
|
64
|
+
* authorize: async (req) => {
|
|
65
|
+
* const session = await getServerSession();
|
|
66
|
+
* if (!session) return null;
|
|
67
|
+
* return {
|
|
68
|
+
* userId: session.user.id,
|
|
69
|
+
* channels: {
|
|
70
|
+
* general: ['subscribe', 'publish'],
|
|
71
|
+
* [`user-${session.user.id}`]: ['subscribe'],
|
|
72
|
+
* },
|
|
73
|
+
* };
|
|
74
|
+
* },
|
|
75
|
+
* });
|
|
76
|
+
* ```
|
|
77
|
+
*/
|
|
78
|
+
export function createAuthHandler(handlerConfig) {
|
|
79
|
+
const encodedSecret = new TextEncoder().encode(handlerConfig.secret);
|
|
80
|
+
const expiry = handlerConfig.tokenExpiry || 3600;
|
|
81
|
+
return async (req) => {
|
|
82
|
+
try {
|
|
83
|
+
const result = await handlerConfig.authorize(req);
|
|
84
|
+
if (!result) {
|
|
85
|
+
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
86
|
+
}
|
|
87
|
+
const token = await new SignJWT({
|
|
88
|
+
channels: Object.keys(result.channels),
|
|
89
|
+
permissions: result.channels,
|
|
90
|
+
})
|
|
91
|
+
.setSubject(result.userId)
|
|
92
|
+
.setIssuedAt()
|
|
93
|
+
.setExpirationTime(`${expiry}s`)
|
|
94
|
+
.setProtectedHeader({ alg: 'HS256' })
|
|
95
|
+
.sign(encodedSecret);
|
|
96
|
+
return Response.json({ token });
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return Response.json({ error: 'Internal error' }, { status: 500 });
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hotpipe",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Type-safe real-time event broker SDK for React and Next.js",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
6
7
|
"keywords": [
|
|
7
8
|
"realtime",
|
|
8
9
|
"websocket",
|
|
@@ -15,20 +16,28 @@
|
|
|
15
16
|
"zod"
|
|
16
17
|
],
|
|
17
18
|
"exports": {
|
|
18
|
-
"./client": "./
|
|
19
|
-
"./server": "./
|
|
19
|
+
"./client": "./dist/client/index.js",
|
|
20
|
+
"./server": "./dist/server/index.js"
|
|
20
21
|
},
|
|
21
22
|
"typesVersions": {
|
|
22
23
|
"*": {
|
|
23
|
-
"client": [
|
|
24
|
-
|
|
24
|
+
"client": [
|
|
25
|
+
"./dist/client/index.d.ts"
|
|
26
|
+
],
|
|
27
|
+
"server": [
|
|
28
|
+
"./dist/server/index.d.ts"
|
|
29
|
+
]
|
|
25
30
|
}
|
|
26
31
|
},
|
|
27
32
|
"files": [
|
|
28
|
-
"
|
|
33
|
+
"dist/"
|
|
29
34
|
],
|
|
30
35
|
"scripts": {
|
|
31
|
-
"
|
|
36
|
+
"dev": "tsc --project tsconfig.build.json --watch --preserveWatchOutput",
|
|
37
|
+
"build": "tsc --project tsconfig.build.json",
|
|
38
|
+
"type-check": "tsc --noEmit",
|
|
39
|
+
"clean": "rm -rf .turbo dist tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo",
|
|
40
|
+
"reset": "npm run clean && rm -rf node_modules"
|
|
32
41
|
},
|
|
33
42
|
"dependencies": {
|
|
34
43
|
"jose": "^5.0.0"
|
|
@@ -38,7 +47,8 @@
|
|
|
38
47
|
"zod": ">=3"
|
|
39
48
|
},
|
|
40
49
|
"devDependencies": {
|
|
41
|
-
"
|
|
42
|
-
"@types/react": ">=18"
|
|
50
|
+
"@pepper/tsconfig": "1.0.0",
|
|
51
|
+
"@types/react": ">=18",
|
|
52
|
+
"typescript": "^5.0.0"
|
|
43
53
|
}
|
|
44
54
|
}
|
package/src/client/connection.ts
DELETED
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
export type ConnectionStatus =
|
|
2
|
-
| 'disconnected'
|
|
3
|
-
| 'connecting'
|
|
4
|
-
| 'connected'
|
|
5
|
-
| 'reconnecting';
|
|
6
|
-
|
|
7
|
-
export type ChannelListener = (event: string, data: unknown) => void;
|
|
8
|
-
|
|
9
|
-
interface ConnectionConfig {
|
|
10
|
-
brokerUrl: string;
|
|
11
|
-
authEndpoint: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export class ConnectionManager {
|
|
15
|
-
private ws: WebSocket | null = null;
|
|
16
|
-
private status: ConnectionStatus = 'disconnected';
|
|
17
|
-
private statusListeners = new Set<(status: ConnectionStatus) => void>();
|
|
18
|
-
private channelListeners = new Map<string, Set<ChannelListener>>();
|
|
19
|
-
private channelRefs = new Map<string, number>();
|
|
20
|
-
private reconnectAttempts = 0;
|
|
21
|
-
private maxReconnectDelay = 30_000;
|
|
22
|
-
private pingInterval: ReturnType<typeof setInterval> | null = null;
|
|
23
|
-
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
|
-
private intentionalClose = false;
|
|
25
|
-
|
|
26
|
-
constructor(private config: ConnectionConfig) {}
|
|
27
|
-
|
|
28
|
-
async connect() {
|
|
29
|
-
if (this.status === 'connecting' || this.status === 'connected') return;
|
|
30
|
-
|
|
31
|
-
this.intentionalClose = false;
|
|
32
|
-
this.setStatus(this.reconnectAttempts > 0 ? 'reconnecting' : 'connecting');
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const res = await fetch(this.config.authEndpoint, {
|
|
36
|
-
method: 'POST',
|
|
37
|
-
credentials: 'include',
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
if (!res.ok) {
|
|
41
|
-
throw new Error(`Auth failed: ${res.status}`);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const { token } = await res.json();
|
|
45
|
-
const wsUrl = `${this.config.brokerUrl}/ws?token=${encodeURIComponent(token)}`;
|
|
46
|
-
|
|
47
|
-
this.ws = new WebSocket(wsUrl);
|
|
48
|
-
|
|
49
|
-
this.ws.onopen = () => {
|
|
50
|
-
this.setStatus('connected');
|
|
51
|
-
this.reconnectAttempts = 0;
|
|
52
|
-
|
|
53
|
-
// Resubscribe to all active channels
|
|
54
|
-
for (const channel of this.channelRefs.keys()) {
|
|
55
|
-
this.wsSend({ type: 'subscribe', channel });
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// Keepalive ping every 30s
|
|
59
|
-
this.pingInterval = setInterval(() => {
|
|
60
|
-
this.wsSend({ type: 'ping' });
|
|
61
|
-
}, 30_000);
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
this.ws.onmessage = (event) => {
|
|
65
|
-
try {
|
|
66
|
-
const msg = JSON.parse(event.data);
|
|
67
|
-
|
|
68
|
-
if (msg.type === 'event') {
|
|
69
|
-
this.dispatch(msg.channel, msg.event, msg.data);
|
|
70
|
-
}
|
|
71
|
-
} catch {
|
|
72
|
-
// Ignore malformed messages
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
this.ws.onclose = () => {
|
|
77
|
-
this.cleanup();
|
|
78
|
-
this.setStatus('disconnected');
|
|
79
|
-
|
|
80
|
-
if (!this.intentionalClose) {
|
|
81
|
-
this.scheduleReconnect();
|
|
82
|
-
}
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
this.ws.onerror = () => {
|
|
86
|
-
// onclose fires after onerror — reconnect handled there
|
|
87
|
-
};
|
|
88
|
-
} catch {
|
|
89
|
-
this.setStatus('disconnected');
|
|
90
|
-
|
|
91
|
-
if (!this.intentionalClose) {
|
|
92
|
-
this.scheduleReconnect();
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
disconnect() {
|
|
98
|
-
this.intentionalClose = true;
|
|
99
|
-
this.cleanup();
|
|
100
|
-
|
|
101
|
-
if (this.reconnectTimer) {
|
|
102
|
-
clearTimeout(this.reconnectTimer);
|
|
103
|
-
this.reconnectTimer = null;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
this.ws?.close();
|
|
107
|
-
this.ws = null;
|
|
108
|
-
this.setStatus('disconnected');
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
subscribe(channel: string) {
|
|
112
|
-
const count = this.channelRefs.get(channel) || 0;
|
|
113
|
-
this.channelRefs.set(channel, count + 1);
|
|
114
|
-
|
|
115
|
-
if (count === 0 && this.ws?.readyState === WebSocket.OPEN) {
|
|
116
|
-
this.wsSend({ type: 'subscribe', channel });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
unsubscribe(channel: string) {
|
|
121
|
-
const count = this.channelRefs.get(channel) || 0;
|
|
122
|
-
|
|
123
|
-
if (count <= 1) {
|
|
124
|
-
this.channelRefs.delete(channel);
|
|
125
|
-
|
|
126
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
127
|
-
this.wsSend({ type: 'unsubscribe', channel });
|
|
128
|
-
}
|
|
129
|
-
} else {
|
|
130
|
-
this.channelRefs.set(channel, count - 1);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
publish(channel: string, event: string, data: unknown) {
|
|
135
|
-
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
136
|
-
this.wsSend({ type: 'publish', channel, event, data });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
addChannelListener(channel: string, listener: ChannelListener) {
|
|
141
|
-
if (!this.channelListeners.has(channel)) {
|
|
142
|
-
this.channelListeners.set(channel, new Set());
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
this.channelListeners.get(channel)!.add(listener);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
removeChannelListener(channel: string, listener: ChannelListener) {
|
|
149
|
-
this.channelListeners.get(channel)?.delete(listener);
|
|
150
|
-
|
|
151
|
-
if (this.channelListeners.get(channel)?.size === 0) {
|
|
152
|
-
this.channelListeners.delete(channel);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
getStatus(): ConnectionStatus {
|
|
157
|
-
return this.status;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
onStatusChange(listener: (status: ConnectionStatus) => void): () => void {
|
|
161
|
-
this.statusListeners.add(listener);
|
|
162
|
-
return () => this.statusListeners.delete(listener);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
private dispatch(channel: string, event: string, data: unknown) {
|
|
166
|
-
const listeners = this.channelListeners.get(channel);
|
|
167
|
-
if (!listeners) return;
|
|
168
|
-
|
|
169
|
-
for (const listener of listeners) {
|
|
170
|
-
listener(event, data);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
private setStatus(status: ConnectionStatus) {
|
|
175
|
-
this.status = status;
|
|
176
|
-
|
|
177
|
-
for (const listener of this.statusListeners) {
|
|
178
|
-
listener(status);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private wsSend(msg: Record<string, unknown>) {
|
|
183
|
-
this.ws?.send(JSON.stringify(msg));
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
private cleanup() {
|
|
187
|
-
if (this.pingInterval) {
|
|
188
|
-
clearInterval(this.pingInterval);
|
|
189
|
-
this.pingInterval = null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
private scheduleReconnect() {
|
|
194
|
-
const delay = Math.min(
|
|
195
|
-
1000 * Math.pow(2, this.reconnectAttempts) + Math.random() * 1000,
|
|
196
|
-
this.maxReconnectDelay
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
this.reconnectTimer = setTimeout(() => {
|
|
200
|
-
this.reconnectAttempts++;
|
|
201
|
-
this.connect();
|
|
202
|
-
}, delay);
|
|
203
|
-
}
|
|
204
|
-
}
|
package/src/client/index.tsx
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
createContext,
|
|
5
|
-
useCallback,
|
|
6
|
-
useContext,
|
|
7
|
-
useEffect,
|
|
8
|
-
useRef,
|
|
9
|
-
useState,
|
|
10
|
-
} from 'react';
|
|
11
|
-
import type { z } from 'zod';
|
|
12
|
-
|
|
13
|
-
import { ConnectionManager, type ConnectionStatus } from './connection';
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* A record mapping event names to Zod schemas.
|
|
17
|
-
*/
|
|
18
|
-
type EventMap = Record<string, z.ZodType>;
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* Extract event names from an EventMap.
|
|
22
|
-
*/
|
|
23
|
-
type EventName<T extends EventMap> = keyof T & string;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Infer the data type for a given event.
|
|
27
|
-
*/
|
|
28
|
-
type EventData<T extends EventMap, E extends EventName<T>> = z.infer<T[E]>;
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Handler map for useChannel — a partial record of event handlers.
|
|
32
|
-
*/
|
|
33
|
-
type ChannelHandlers<T extends EventMap> = {
|
|
34
|
-
[E in EventName<T>]?: (data: EventData<T, E>) => void;
|
|
35
|
-
};
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Creates a typed real-time client bound to your event schemas.
|
|
39
|
-
*
|
|
40
|
-
* Usage:
|
|
41
|
-
* ```ts
|
|
42
|
-
* const { RealtimeProvider, useChannel, useEvent } = createRealtimeClient({
|
|
43
|
-
* brokerUrl: 'wss://broker.fly.dev',
|
|
44
|
-
* events: realtimeEvents,
|
|
45
|
-
* auth: { endpoint: '/api/realtime/auth' },
|
|
46
|
-
* });
|
|
47
|
-
* ```
|
|
48
|
-
*/
|
|
49
|
-
export function createRealtimeClient<T extends EventMap>(config: {
|
|
50
|
-
brokerUrl: string;
|
|
51
|
-
events: T;
|
|
52
|
-
auth: { endpoint: string };
|
|
53
|
-
}) {
|
|
54
|
-
const RealtimeContext = createContext<ConnectionManager | null>(null);
|
|
55
|
-
|
|
56
|
-
function useManager(): ConnectionManager {
|
|
57
|
-
const manager = useContext(RealtimeContext);
|
|
58
|
-
|
|
59
|
-
if (!manager) {
|
|
60
|
-
throw new Error(
|
|
61
|
-
'hotpipe: useChannel/useEvent must be used within <RealtimeProvider>'
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return manager;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Wrap your app (or a subtree) with this provider to establish the
|
|
70
|
-
* WebSocket connection to the broker.
|
|
71
|
-
*/
|
|
72
|
-
function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
|
73
|
-
const managerRef = useRef<ConnectionManager | null>(null);
|
|
74
|
-
|
|
75
|
-
if (!managerRef.current) {
|
|
76
|
-
managerRef.current = new ConnectionManager({
|
|
77
|
-
brokerUrl: config.brokerUrl,
|
|
78
|
-
authEndpoint: config.auth.endpoint,
|
|
79
|
-
});
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
managerRef.current?.connect();
|
|
84
|
-
return () => managerRef.current?.disconnect();
|
|
85
|
-
}, []);
|
|
86
|
-
|
|
87
|
-
return (
|
|
88
|
-
<RealtimeContext.Provider value={managerRef.current}>
|
|
89
|
-
{children}
|
|
90
|
-
</RealtimeContext.Provider>
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Subscribe to events on a channel. Returns connection status and a
|
|
96
|
-
* typed publish function.
|
|
97
|
-
*
|
|
98
|
-
* ```tsx
|
|
99
|
-
* const { status, publish } = useChannel('general', {
|
|
100
|
-
* 'message.created': (data) => addMessage(data),
|
|
101
|
-
* });
|
|
102
|
-
* ```
|
|
103
|
-
*/
|
|
104
|
-
function useChannel(
|
|
105
|
-
channel: string,
|
|
106
|
-
handlers: Partial<ChannelHandlers<T>>
|
|
107
|
-
): {
|
|
108
|
-
status: ConnectionStatus;
|
|
109
|
-
publish: <E extends EventName<T>>(event: E, data: EventData<T, E>) => void;
|
|
110
|
-
} {
|
|
111
|
-
const manager = useManager();
|
|
112
|
-
const handlersRef = useRef(handlers);
|
|
113
|
-
handlersRef.current = handlers;
|
|
114
|
-
|
|
115
|
-
const [status, setStatus] = useState<ConnectionStatus>(
|
|
116
|
-
manager.getStatus()
|
|
117
|
-
);
|
|
118
|
-
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
return manager.onStatusChange(setStatus);
|
|
121
|
-
}, [manager]);
|
|
122
|
-
|
|
123
|
-
useEffect(() => {
|
|
124
|
-
const listener = (event: string, data: unknown) => {
|
|
125
|
-
const handler = handlersRef.current[event as EventName<T>];
|
|
126
|
-
|
|
127
|
-
if (handler) {
|
|
128
|
-
(handler as (d: unknown) => void)(data);
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
manager.addChannelListener(channel, listener);
|
|
133
|
-
manager.subscribe(channel);
|
|
134
|
-
|
|
135
|
-
return () => {
|
|
136
|
-
manager.unsubscribe(channel);
|
|
137
|
-
manager.removeChannelListener(channel, listener);
|
|
138
|
-
};
|
|
139
|
-
}, [channel, manager]);
|
|
140
|
-
|
|
141
|
-
const publish = useCallback(
|
|
142
|
-
<E extends EventName<T>>(event: E, data: EventData<T, E>) => {
|
|
143
|
-
// Validate against schema before sending
|
|
144
|
-
const schema = config.events[event];
|
|
145
|
-
|
|
146
|
-
if (schema) {
|
|
147
|
-
const result = schema.safeParse(data);
|
|
148
|
-
|
|
149
|
-
if (!result.success) {
|
|
150
|
-
if (process.env.NODE_ENV !== 'production') {
|
|
151
|
-
console.error(
|
|
152
|
-
`[hotpipe] Invalid data for "${event}":`,
|
|
153
|
-
result.error.format()
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
manager.publish(channel, event, data);
|
|
162
|
-
},
|
|
163
|
-
[channel, manager]
|
|
164
|
-
);
|
|
165
|
-
|
|
166
|
-
return { status, publish };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Subscribe to a single event type on a channel.
|
|
171
|
-
*
|
|
172
|
-
* ```tsx
|
|
173
|
-
* useEvent('general', 'message.created', (data) => addMessage(data));
|
|
174
|
-
* ```
|
|
175
|
-
*/
|
|
176
|
-
function useEvent<E extends EventName<T>>(
|
|
177
|
-
channel: string,
|
|
178
|
-
event: E,
|
|
179
|
-
handler: (data: EventData<T, E>) => void
|
|
180
|
-
): { status: ConnectionStatus } {
|
|
181
|
-
const manager = useManager();
|
|
182
|
-
const handlerRef = useRef(handler);
|
|
183
|
-
handlerRef.current = handler;
|
|
184
|
-
|
|
185
|
-
const [status, setStatus] = useState<ConnectionStatus>(
|
|
186
|
-
manager.getStatus()
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
useEffect(() => {
|
|
190
|
-
return manager.onStatusChange(setStatus);
|
|
191
|
-
}, [manager]);
|
|
192
|
-
|
|
193
|
-
useEffect(() => {
|
|
194
|
-
const listener = (evt: string, data: unknown) => {
|
|
195
|
-
if (evt === event) {
|
|
196
|
-
handlerRef.current(data as EventData<T, E>);
|
|
197
|
-
}
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
manager.addChannelListener(channel, listener);
|
|
201
|
-
manager.subscribe(channel);
|
|
202
|
-
|
|
203
|
-
return () => {
|
|
204
|
-
manager.unsubscribe(channel);
|
|
205
|
-
manager.removeChannelListener(channel, listener);
|
|
206
|
-
};
|
|
207
|
-
}, [channel, event, manager]);
|
|
208
|
-
|
|
209
|
-
return { status };
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
return { RealtimeProvider, useChannel, useEvent };
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
export type { ConnectionStatus, EventMap };
|
package/src/server/index.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { SignJWT } from 'jose';
|
|
2
|
-
import type { z } from 'zod';
|
|
3
|
-
|
|
4
|
-
type EventMap = Record<string, z.ZodType>;
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The shape returned by your authorize function.
|
|
8
|
-
*/
|
|
9
|
-
interface AuthResult {
|
|
10
|
-
userId: string;
|
|
11
|
-
channels: Record<string, ('subscribe' | 'publish')[]>;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Creates a typed server-side publisher for emitting events to the broker
|
|
16
|
-
* from API routes, server actions, webhooks, cron jobs, etc.
|
|
17
|
-
*
|
|
18
|
-
* ```ts
|
|
19
|
-
* const realtime = createRealtimePublisher({
|
|
20
|
-
* brokerUrl: process.env.BROKER_URL!,
|
|
21
|
-
* apiKey: process.env.BROKER_API_KEY!,
|
|
22
|
-
* events: realtimeEvents,
|
|
23
|
-
* });
|
|
24
|
-
*
|
|
25
|
-
* await realtime.publish('general', 'message.created', { ... });
|
|
26
|
-
* ```
|
|
27
|
-
*/
|
|
28
|
-
export function createRealtimePublisher<T extends EventMap>(config: {
|
|
29
|
-
brokerUrl: string;
|
|
30
|
-
apiKey: string;
|
|
31
|
-
events: T;
|
|
32
|
-
}) {
|
|
33
|
-
const headers = {
|
|
34
|
-
'Content-Type': 'application/json',
|
|
35
|
-
Authorization: `Bearer ${config.apiKey}`,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
/**
|
|
40
|
-
* Publish a single event to a channel.
|
|
41
|
-
*/
|
|
42
|
-
async publish<E extends keyof T & string>(
|
|
43
|
-
channel: string,
|
|
44
|
-
event: E,
|
|
45
|
-
data: z.infer<T[E]>
|
|
46
|
-
): Promise<void> {
|
|
47
|
-
const schema = config.events[event];
|
|
48
|
-
|
|
49
|
-
if (schema) {
|
|
50
|
-
schema.parse(data);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const res = await fetch(`${config.brokerUrl}/publish`, {
|
|
54
|
-
method: 'POST',
|
|
55
|
-
headers,
|
|
56
|
-
body: JSON.stringify({ channel, event, data }),
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
if (!res.ok) {
|
|
60
|
-
throw new Error(`hotpipe publish failed: ${res.status} ${await res.text()}`);
|
|
61
|
-
}
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Publish multiple events in a single HTTP request.
|
|
66
|
-
*/
|
|
67
|
-
async publishBatch(
|
|
68
|
-
events: Array<{
|
|
69
|
-
channel: string;
|
|
70
|
-
event: keyof T & string;
|
|
71
|
-
data: unknown;
|
|
72
|
-
}>
|
|
73
|
-
): Promise<void> {
|
|
74
|
-
const res = await fetch(`${config.brokerUrl}/publish/batch`, {
|
|
75
|
-
method: 'POST',
|
|
76
|
-
headers,
|
|
77
|
-
body: JSON.stringify({ events }),
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
if (!res.ok) {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`hotpipe batch publish failed: ${res.status} ${await res.text()}`
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Creates a Next.js-compatible route handler for client auth.
|
|
91
|
-
* The client SDK calls this endpoint to get a signed token before
|
|
92
|
-
* opening the WebSocket connection to the broker.
|
|
93
|
-
*
|
|
94
|
-
* ```ts
|
|
95
|
-
* // app/api/realtime/auth/route.ts
|
|
96
|
-
* export const POST = createAuthHandler({
|
|
97
|
-
* secret: process.env.BROKER_SIGNING_SECRET!,
|
|
98
|
-
* authorize: async (req) => {
|
|
99
|
-
* const session = await getServerSession();
|
|
100
|
-
* if (!session) return null;
|
|
101
|
-
* return {
|
|
102
|
-
* userId: session.user.id,
|
|
103
|
-
* channels: {
|
|
104
|
-
* general: ['subscribe', 'publish'],
|
|
105
|
-
* [`user-${session.user.id}`]: ['subscribe'],
|
|
106
|
-
* },
|
|
107
|
-
* };
|
|
108
|
-
* },
|
|
109
|
-
* });
|
|
110
|
-
* ```
|
|
111
|
-
*/
|
|
112
|
-
export function createAuthHandler(handlerConfig: {
|
|
113
|
-
secret: string;
|
|
114
|
-
authorize: (req: Request) => Promise<AuthResult | null>;
|
|
115
|
-
tokenExpiry?: number;
|
|
116
|
-
}) {
|
|
117
|
-
const encodedSecret = new TextEncoder().encode(handlerConfig.secret);
|
|
118
|
-
const expiry = handlerConfig.tokenExpiry || 3600;
|
|
119
|
-
|
|
120
|
-
return async (req: Request): Promise<Response> => {
|
|
121
|
-
try {
|
|
122
|
-
const result = await handlerConfig.authorize(req);
|
|
123
|
-
|
|
124
|
-
if (!result) {
|
|
125
|
-
return Response.json({ error: 'Unauthorized' }, { status: 401 });
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const token = await new SignJWT({
|
|
129
|
-
channels: Object.keys(result.channels),
|
|
130
|
-
permissions: result.channels,
|
|
131
|
-
})
|
|
132
|
-
.setSubject(result.userId)
|
|
133
|
-
.setIssuedAt()
|
|
134
|
-
.setExpirationTime(`${expiry}s`)
|
|
135
|
-
.setProtectedHeader({ alg: 'HS256' })
|
|
136
|
-
.sign(encodedSecret);
|
|
137
|
-
|
|
138
|
-
return Response.json({ token });
|
|
139
|
-
} catch {
|
|
140
|
-
return Response.json({ error: 'Internal error' }, { status: 500 });
|
|
141
|
-
}
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
export type { AuthResult, EventMap };
|