lazypock 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/README.md +226 -0
- package/dist/index.cjs +971 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +520 -0
- package/dist/index.d.ts +520 -0
- package/dist/index.global.js +963 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +938 -0
- package/dist/index.js.map +1 -0
- package/package.json +32 -0
- package/src/auth.ts +185 -0
- package/src/collection.ts +185 -0
- package/src/files.ts +96 -0
- package/src/http.ts +195 -0
- package/src/index.ts +423 -0
- package/src/realtime.ts +264 -0
- package/src/types.ts +46 -0
package/src/realtime.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// ── Phoenix Channel WebSocket Client ──────────────────
|
|
2
|
+
//
|
|
3
|
+
// Implements the Phoenix Channels protocol over WebSocket
|
|
4
|
+
// to subscribe to collection realtime updates.
|
|
5
|
+
//
|
|
6
|
+
// Protocol: Phoenix V1 JSON Serializer (object-based messages).
|
|
7
|
+
// Sends messages as JSON objects with {topic, event, payload, ref, join_ref}.
|
|
8
|
+
// Receives messages as JSON objects with {topic, event, payload, ref}.
|
|
9
|
+
|
|
10
|
+
interface RealtimeEvent {
|
|
11
|
+
event: string;
|
|
12
|
+
topic: string;
|
|
13
|
+
payload: Record<string, unknown>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface SubEntry {
|
|
17
|
+
topic: string;
|
|
18
|
+
callback: (e: RealtimeEvent) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type RealtimeConnectOpts = {
|
|
22
|
+
/** WebSocket URL (e.g. ws://localhost:4000/socket/websocket) */
|
|
23
|
+
url: string;
|
|
24
|
+
/** Auth token to pass as query param */
|
|
25
|
+
token?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Derive a WebSocket URL from an HTTP base URL.
|
|
30
|
+
* http://localhost:4000/api → ws://localhost:4000/socket/websocket
|
|
31
|
+
*/
|
|
32
|
+
export function wsUrlFromBaseUrl(baseUrl: string): string {
|
|
33
|
+
try {
|
|
34
|
+
const url = new URL(baseUrl);
|
|
35
|
+
const protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
36
|
+
return `${protocol}//${url.host}/socket/websocket`;
|
|
37
|
+
} catch {
|
|
38
|
+
return `${baseUrl.replace(/^http/, "ws").replace(/\/api$/, "")}/socket/websocket`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Phoenix Channel client for real-time collection subscriptions.
|
|
44
|
+
*
|
|
45
|
+
* Connects via WebSocket and subscribes to collection topics.
|
|
46
|
+
* Includes automatic reconnection with exponential backoff.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```ts
|
|
50
|
+
* const rt = new RealtimeService();
|
|
51
|
+
* rt.connect({ url: wsUrlFromBaseUrl('http://localhost:4000/api') });
|
|
52
|
+
* rt.subscribe('collection:posts', (e) => console.log(e));
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
export class RealtimeService {
|
|
56
|
+
private ws: WebSocket | null = null;
|
|
57
|
+
private refCounter = 0;
|
|
58
|
+
private subscriptions = new Map<string, SubEntry[]>();
|
|
59
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
60
|
+
private reconnectAttempt = 0;
|
|
61
|
+
private maxReconnectDelay = 5000;
|
|
62
|
+
|
|
63
|
+
// Callbacks for connection state
|
|
64
|
+
onReconnect?: () => void;
|
|
65
|
+
onDisconnect?: () => void;
|
|
66
|
+
onError?: (err: Event) => void;
|
|
67
|
+
|
|
68
|
+
private url: string = "";
|
|
69
|
+
private token: string | undefined;
|
|
70
|
+
|
|
71
|
+
connect(opts: RealtimeConnectOpts): void {
|
|
72
|
+
this.url = opts.url;
|
|
73
|
+
this.token = opts.token;
|
|
74
|
+
this.reconnectAttempt = 0;
|
|
75
|
+
this.doConnect();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
disconnect(): void {
|
|
79
|
+
this.clearReconnectTimer();
|
|
80
|
+
this.ws?.close();
|
|
81
|
+
this.ws = null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Subscribe to a topic (e.g. "collection:posts" or "collection:posts:*").
|
|
86
|
+
* The backend Channel authorizes via listRule on join.
|
|
87
|
+
*/
|
|
88
|
+
subscribe(topic: string, callback: (e: RealtimeEvent) => void): void {
|
|
89
|
+
const subs = this.subscriptions.get(topic) || [];
|
|
90
|
+
subs.push({ topic, callback });
|
|
91
|
+
this.subscriptions.set(topic, subs);
|
|
92
|
+
|
|
93
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
94
|
+
this.joinTopic(topic);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Unsubscribe a specific callback from a topic.
|
|
100
|
+
*/
|
|
101
|
+
unsubscribe(topic: string, callback?: (e: RealtimeEvent) => void): void {
|
|
102
|
+
if (!callback) {
|
|
103
|
+
this.subscriptions.delete(topic);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const subs = this.subscriptions
|
|
107
|
+
.get(topic)
|
|
108
|
+
?.filter((s) => s.callback !== callback);
|
|
109
|
+
if (subs && subs.length > 0) {
|
|
110
|
+
this.subscriptions.set(topic, subs);
|
|
111
|
+
} else {
|
|
112
|
+
this.subscriptions.delete(topic);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private resubscribeAll(): void {
|
|
117
|
+
for (const topic of this.subscriptions.keys()) {
|
|
118
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
119
|
+
this.joinTopic(topic);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private doConnect(): void {
|
|
125
|
+
if (typeof WebSocket === "undefined") {
|
|
126
|
+
console.warn(
|
|
127
|
+
"[lazypock] WebSocket not available — realtime subscriptions disabled",
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let url = this.url;
|
|
133
|
+
if (this.token) {
|
|
134
|
+
url +=
|
|
135
|
+
(url.includes("?") ? "&" : "?") +
|
|
136
|
+
"token=" +
|
|
137
|
+
encodeURIComponent(this.token);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this.ws = new WebSocket(url);
|
|
141
|
+
|
|
142
|
+
this.ws.onopen = () => {
|
|
143
|
+
this.reconnectAttempt = 0;
|
|
144
|
+
this.resubscribeAll();
|
|
145
|
+
this.startHeartbeat();
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
this.ws.onmessage = (msg: MessageEvent) => {
|
|
149
|
+
this.handleMessage(msg.data);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
this.ws.onclose = () => {
|
|
153
|
+
this.stopHeartbeat();
|
|
154
|
+
this.onDisconnect?.();
|
|
155
|
+
this.scheduleReconnect();
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
this.ws.onerror = (err: Event) => {
|
|
159
|
+
this.onError?.(err);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private handleMessage(data: string): void {
|
|
164
|
+
let parsed: Record<string, unknown>;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(data) as Record<string, unknown>;
|
|
167
|
+
} catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (typeof parsed !== "object" || !parsed.topic || !parsed.event) return;
|
|
171
|
+
|
|
172
|
+
const topic = parsed.topic as string;
|
|
173
|
+
const event = parsed.event as string;
|
|
174
|
+
const payload = (parsed.payload as Record<string, unknown>) || {};
|
|
175
|
+
|
|
176
|
+
// Handle phx_reply (join/heartbeat responses)
|
|
177
|
+
if (event === "phx_reply") return;
|
|
178
|
+
|
|
179
|
+
// Relay incoming events to all subscribers of this topic
|
|
180
|
+
const subs = this.subscriptions.get(topic);
|
|
181
|
+
if (subs) {
|
|
182
|
+
const e: RealtimeEvent = {
|
|
183
|
+
event,
|
|
184
|
+
topic,
|
|
185
|
+
payload: payload as Record<string, unknown>,
|
|
186
|
+
};
|
|
187
|
+
for (const s of subs) {
|
|
188
|
+
try {
|
|
189
|
+
s.callback(e);
|
|
190
|
+
} catch {
|
|
191
|
+
// swallow callback errors
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private joinTopic(topic: string): void {
|
|
198
|
+
const ref = this.nextRef();
|
|
199
|
+
// Phoenix V1 JSON Serializer expects a JSON object, not an array
|
|
200
|
+
const msg = JSON.stringify({
|
|
201
|
+
topic: topic,
|
|
202
|
+
event: "phx_join",
|
|
203
|
+
payload: {},
|
|
204
|
+
ref: ref,
|
|
205
|
+
});
|
|
206
|
+
this.ws?.send(msg);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private nextRef(): string {
|
|
210
|
+
this.refCounter++;
|
|
211
|
+
return this.refCounter.toString();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ── Heartbeat ──
|
|
215
|
+
|
|
216
|
+
private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
|
217
|
+
|
|
218
|
+
private startHeartbeat(): void {
|
|
219
|
+
this.stopHeartbeat();
|
|
220
|
+
this.heartbeatInterval = setInterval(() => {
|
|
221
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
222
|
+
const ref = this.nextRef();
|
|
223
|
+
// Phoenix V1 JSON Serializer expects a JSON object
|
|
224
|
+
const msg = JSON.stringify({
|
|
225
|
+
topic: "phoenix",
|
|
226
|
+
event: "heartbeat",
|
|
227
|
+
payload: {},
|
|
228
|
+
ref: ref,
|
|
229
|
+
});
|
|
230
|
+
this.ws.send(msg);
|
|
231
|
+
}
|
|
232
|
+
}, 30_000);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private stopHeartbeat(): void {
|
|
236
|
+
if (this.heartbeatInterval) {
|
|
237
|
+
clearInterval(this.heartbeatInterval);
|
|
238
|
+
this.heartbeatInterval = null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── Reconnect ──
|
|
243
|
+
|
|
244
|
+
private scheduleReconnect(): void {
|
|
245
|
+
this.clearReconnectTimer();
|
|
246
|
+
const delay = Math.min(
|
|
247
|
+
1000 * 2 ** this.reconnectAttempt,
|
|
248
|
+
this.maxReconnectDelay,
|
|
249
|
+
);
|
|
250
|
+
this.reconnectAttempt++;
|
|
251
|
+
this.reconnectTimer = setTimeout(() => {
|
|
252
|
+
this.reconnectTimer = null;
|
|
253
|
+
this.onReconnect?.();
|
|
254
|
+
this.doConnect();
|
|
255
|
+
}, delay);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private clearReconnectTimer(): void {
|
|
259
|
+
if (this.reconnectTimer) {
|
|
260
|
+
clearTimeout(this.reconnectTimer);
|
|
261
|
+
this.reconnectTimer = null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ── Record & Collection types ───────────────────────────
|
|
2
|
+
|
|
3
|
+
/** Shape of a record returned from any collection. */
|
|
4
|
+
export interface ApiRecord {
|
|
5
|
+
id: string;
|
|
6
|
+
collectionId: string;
|
|
7
|
+
collectionName: string;
|
|
8
|
+
created: string;
|
|
9
|
+
updated: string;
|
|
10
|
+
[key: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Paginated list response matching PocketBase format. */
|
|
14
|
+
export interface ListResult<T = ApiRecord> {
|
|
15
|
+
items: T[];
|
|
16
|
+
page: number;
|
|
17
|
+
perPage: number;
|
|
18
|
+
totalItems: number;
|
|
19
|
+
totalPages: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** HTTP method supported by the client. */
|
|
23
|
+
export type Method = "GET" | "POST" | "PATCH" | "DELETE";
|
|
24
|
+
|
|
25
|
+
export interface RequestOptions {
|
|
26
|
+
/** Search/filter params */
|
|
27
|
+
params?: Record<string, string>;
|
|
28
|
+
/** Raw request headers to merge */
|
|
29
|
+
headers?: Record<string, string>;
|
|
30
|
+
/** Abort signal */
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
/** Custom fetch implementation (for RN or test mocking) */
|
|
33
|
+
fetch?: typeof globalThis.fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class ApiError extends Error {
|
|
37
|
+
readonly data: unknown;
|
|
38
|
+
readonly status: number;
|
|
39
|
+
|
|
40
|
+
constructor(message: string, data: unknown, status: number) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "ApiError";
|
|
43
|
+
this.data = data;
|
|
44
|
+
this.status = status;
|
|
45
|
+
}
|
|
46
|
+
}
|