lazypock 0.8.4 → 0.9.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/src/collection.ts CHANGED
@@ -60,6 +60,23 @@ export interface RealtimeMessage {
60
60
  /** Subscription callback for a collection's realtime events. */
61
61
  export type RealtimeCallback = (e: RealtimeMessage) => void;
62
62
 
63
+ /**
64
+ * Options for {@link CollectionService.subscribe} (PocketBase-compatible).
65
+ *
66
+ * `expand` and any extra keys are forwarded to the server channel join
67
+ * payload (available to `onRealtimeSubscribeRequest` hooks). `headers` is
68
+ * accepted for PocketBase signature compatibility but has no effect over
69
+ * WebSocket — it is not forwarded.
70
+ */
71
+ export interface RealtimeSubscribeOptions {
72
+ /** Fields to expand on the subscribed records (server join payload). */
73
+ expand?: string;
74
+ /** Accepted for PocketBase parity; not sent over the WebSocket. */
75
+ headers?: Record<string, string>;
76
+ /** Any extra keys are forwarded verbatim to the server join payload. */
77
+ [key: string]: unknown;
78
+ }
79
+
63
80
  /** Raw event passed by the low-level realtime service. */
64
81
  interface RealtimeEventLike {
65
82
  event: string;
@@ -505,23 +522,64 @@ export class CollectionService<T = ApiRecord, TData = never> {
505
522
 
506
523
  /**
507
524
  * Subscribe to realtime changes for this collection.
508
- * The event's `action` is one of `"create" | "update" | "delete"`.
525
+ * The event's `action` is one of `"create" | "update" | "delete"` and the
526
+ * callback always receives the **full record** (all fields, regardless of
527
+ * any `select()` projection on the service).
509
528
  *
510
529
  * Access is governed by the collection's `listRule` (PocketBase semantics):
511
530
  * public collections allow anonymous subscriptions; other collections
512
531
  * require a matching logged-in user or superuser.
513
532
  *
514
- * @param callback Received on every record change.
515
- * @param recordId Optional — subscribe to a single record instead of `*`.
533
+ * PocketBase-compatible argument forms:
534
+ * - `subscribe(cb)` — all records of the collection
535
+ * - `subscribe('*', cb)` — all records (explicit wildcard)
536
+ * - `subscribe('RECORD_ID', cb)` — a single record
537
+ * - `subscribe('*' | 'RECORD_ID', cb, options)` — with join options
538
+ *
539
+ * Legacy form (callback first) is still accepted: `subscribe(cb, recordId)`.
540
+ *
516
541
  * @returns A function that unsubscribes this callback.
517
542
  */
518
- subscribe(callback: RealtimeCallback, recordId?: string): () => void {
543
+ subscribe(callback: RealtimeCallback): () => void;
544
+ subscribe(
545
+ recordId: string,
546
+ callback: RealtimeCallback,
547
+ options?: RealtimeSubscribeOptions,
548
+ ): () => void;
549
+ /** @deprecated Use `subscribe(recordId, callback)` — PocketBase order. */
550
+ subscribe(callback: RealtimeCallback, recordId?: string): () => void;
551
+ subscribe(
552
+ topicOrCallback: string | RealtimeCallback,
553
+ maybeCallback?: RealtimeCallback | string,
554
+ options?: RealtimeSubscribeOptions,
555
+ ): () => void {
519
556
  if (!this.realtime) {
520
557
  console.warn("[lazypock] No realtime service configured.");
521
558
  return () => {};
522
559
  }
560
+
561
+ // Normalise PocketBase-style args; also accept the legacy callback-first
562
+ // form (subscribe(cb, recordId)) for backward compatibility.
563
+ let recordId: string | undefined;
564
+ let callback: RealtimeCallback;
565
+ let joinPayload: Record<string, unknown> | undefined;
566
+
567
+ if (typeof topicOrCallback === "function") {
568
+ callback = topicOrCallback;
569
+ if (typeof maybeCallback === "string") recordId = maybeCallback;
570
+ } else {
571
+ callback = maybeCallback as RealtimeCallback;
572
+ recordId = topicOrCallback === "*" ? undefined : topicOrCallback;
573
+ // Forward subscribe options (minus HTTP-only headers) to the server
574
+ // channel join payload.
575
+ const { headers: _ignored, ...rest } = options ?? {};
576
+ if (Object.keys(rest).length > 0) joinPayload = rest;
577
+ }
578
+
523
579
  const topic =
524
- "collection:" + this.collectionName + (recordId ? ":" + recordId : "");
580
+ "collection:" +
581
+ this.collectionName +
582
+ (recordId ? ":" + recordId : "");
525
583
  const handler = (raw: RealtimeEventLike) => {
526
584
  const record = (raw.payload?.["record"] ?? {}) as Record<string, unknown>;
527
585
  callback({
@@ -531,17 +589,27 @@ export class CollectionService<T = ApiRecord, TData = never> {
531
589
  });
532
590
  };
533
591
  this.realtime.ensureConnected();
534
- this.realtime.subscribe(topic, handler as never);
592
+ this.realtime.subscribe(topic, handler as never, joinPayload);
535
593
  return () => this.realtime?.unsubscribe(topic, handler as never);
536
594
  }
537
595
 
538
596
  /**
539
- * Unsubscribe all callbacks from this collection (or a specific record).
540
- * @param recordId Optional record id; omitting it unsubs everything.
597
+ * Unsubscribe from realtime changes (PocketBase-compatible):
598
+ * - `unsubscribe()` — remove **all** subscriptions of this collection
599
+ * - `unsubscribe('*')` — remove wildcard subscriptions
600
+ * - `unsubscribe('RECORD_ID')` — remove that record's subscriptions
541
601
  */
542
602
  unsubscribe(recordId?: string): void {
603
+ if (recordId === undefined) {
604
+ this.realtime?.unsubscribeByPrefix(
605
+ "collection:" + this.collectionName,
606
+ );
607
+ return;
608
+ }
543
609
  const topic =
544
- "collection:" + this.collectionName + (recordId ? ":" + recordId : "");
610
+ "collection:" +
611
+ this.collectionName +
612
+ (recordId === "*" ? "" : ":" + recordId);
545
613
  this.realtime?.unsubscribe(topic);
546
614
  }
547
615
 
package/src/index.ts CHANGED
@@ -56,5 +56,5 @@ export type {
56
56
  LazypockClientOptions,
57
57
  } from "./lazypock";
58
58
  export type { LazypockCollections } from "./client";
59
- export type { RealtimeMessage, RealtimeCallback } from "./collection";
59
+ export type { RealtimeMessage, RealtimeCallback, RealtimeSubscribeOptions } from "./collection";
60
60
  export type { CollectionsMessage } from "./collections";
package/src/lazypock.ts CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  type FieldKey,
29
29
  } from "./types";
30
30
  import { RealtimeService, wsUrlFromBaseUrl } from "./realtime";
31
+ import type { RealtimeConnectOpts, RealtimeTokenProvider } from "./realtime";
31
32
  import {
32
33
  FilesService,
33
34
  getFileUrl,
@@ -69,6 +70,9 @@ export type {
69
70
  SystemFields,
70
71
  RequestOptions,
71
72
  FileRecord,
73
+ // realtime
74
+ RealtimeConnectOpts,
75
+ RealtimeTokenProvider,
72
76
  CollectionSchema,
73
77
  SchemaField,
74
78
  // schema-driven query typing
@@ -154,6 +158,12 @@ export class LazypockClient {
154
158
  if (!options.realtime) {
155
159
  this.realtime.setUrl(wsUrlFromBaseUrl(baseUrl));
156
160
  }
161
+ // Keep the realtime socket authenticated with the current auth token
162
+ // and reconnect whenever auth changes (login/logout/token refresh) —
163
+ // PocketBase parity. Reconnect is a no-op until something subscribes.
164
+ this.realtime.setTokenProvider(() => this.authStore.token);
165
+ this.authStore.onChange(() => this.realtime.refresh());
166
+ this.authReady.then(() => this.realtime.refresh());
157
167
  this.files = new FilesService(this.http);
158
168
  this.collections = new CollectionsService(this.http, this.realtime);
159
169
  if (options.types?.schemas) {
package/src/realtime.ts CHANGED
@@ -16,6 +16,8 @@ interface RealtimeEvent {
16
16
  interface SubEntry {
17
17
  topic: string;
18
18
  callback: (e: RealtimeEvent) => void;
19
+ /** Optional payload forwarded with the channel join (e.g. expand). */
20
+ joinPayload?: Record<string, unknown>;
19
21
  }
20
22
 
21
23
  export type RealtimeConnectOpts = {
@@ -25,6 +27,13 @@ export type RealtimeConnectOpts = {
25
27
  token?: string;
26
28
  };
27
29
 
30
+ /**
31
+ * Provides the current auth token at connect/reconnect time.
32
+ * Wired by {@link LazypockClient} from its `authStore` so the socket is
33
+ * always authenticated with the latest token (PocketBase parity).
34
+ */
35
+ export type RealtimeTokenProvider = () => string | undefined;
36
+
28
37
  /**
29
38
  * Derive a WebSocket URL from an HTTP base URL.
30
39
  * http://localhost:4000/api → ws://localhost:4000/socket/websocket
@@ -67,6 +76,7 @@ export class RealtimeService {
67
76
 
68
77
  private url: string = "";
69
78
  private token: string | undefined;
79
+ private tokenProvider: RealtimeTokenProvider | null = null;
70
80
 
71
81
  /** Whether the WebSocket is currently open. */
72
82
  get isOpen(): boolean {
@@ -94,6 +104,35 @@ export class RealtimeService {
94
104
  this.url = url;
95
105
  }
96
106
 
107
+ /**
108
+ * Register a token provider consulted at every (re)connect.
109
+ * When set, it takes precedence over the token passed to {@link connect}.
110
+ */
111
+ setTokenProvider(provider: RealtimeTokenProvider): void {
112
+ this.tokenProvider = provider;
113
+ }
114
+
115
+ /**
116
+ * Reconnect the socket immediately with the current token.
117
+ * Called by the SDK when auth changes (login/logout/token refresh) so
118
+ * private-channel joins are authorized with the new credentials. No-op
119
+ * when the socket has never been opened and nothing is subscribed.
120
+ */
121
+ refresh(): void {
122
+ if (typeof WebSocket === "undefined") return;
123
+ if (!this.ws && this.subscriptions.size === 0) return;
124
+ this.clearReconnectTimer();
125
+ const ws = this.ws;
126
+ this.ws = null;
127
+ if (ws) {
128
+ // Suppress the auto-reconnect path for this intentional close.
129
+ ws.onclose = null;
130
+ ws.close();
131
+ }
132
+ this.reconnectAttempt = 0;
133
+ this.doConnect();
134
+ }
135
+
97
136
  /*
98
137
  * Ensure the socket is connected, then subscribe.
99
138
  * Used by collection-level convenience wrappers so a connection is opened
@@ -120,16 +159,36 @@ export class RealtimeService {
120
159
  }
121
160
 
122
161
  /**
123
- * Subscribe to a topic (e.g. "collection:posts" or "collection:posts:*").
162
+ * Subscribe to a topic (e.g. "collection:posts" or "custom:chat-room").
124
163
  * The backend Channel authorizes via listRule on join.
164
+ *
165
+ * @param joinPayload Optional payload forwarded with the channel join
166
+ * (available to the server's join callback / hooks, e.g. `expand`).
125
167
  */
126
- subscribe(topic: string, callback: (e: RealtimeEvent) => void): void {
168
+ subscribe(
169
+ topic: string,
170
+ callback: (e: RealtimeEvent) => void,
171
+ joinPayload?: Record<string, unknown>,
172
+ ): void {
127
173
  const subs = this.subscriptions.get(topic) || [];
128
- subs.push({ topic, callback });
174
+ subs.push({ topic, callback, joinPayload });
129
175
  this.subscriptions.set(topic, subs);
130
176
 
131
177
  if (this.ws?.readyState === WebSocket.OPEN) {
132
- this.joinTopic(topic);
178
+ this.joinTopic(topic, joinPayload);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Remove all subscriptions for topics under `prefix` (the topic itself or
184
+ * any topic starting with `prefix + ":"`). Used by collection-level
185
+ * `unsubscribe()` to drop every subscription of a collection.
186
+ */
187
+ unsubscribeByPrefix(prefix: string): void {
188
+ for (const topic of [...this.subscriptions.keys()]) {
189
+ if (topic === prefix || topic.startsWith(prefix + ":")) {
190
+ this.subscriptions.delete(topic);
191
+ }
133
192
  }
134
193
  }
135
194
 
@@ -152,9 +211,14 @@ export class RealtimeService {
152
211
  }
153
212
 
154
213
  private resubscribeAll(): void {
155
- for (const topic of this.subscriptions.keys()) {
156
- if (this.ws?.readyState === WebSocket.OPEN) {
157
- this.joinTopic(topic);
214
+ const seen = new Set<string>();
215
+ for (const entries of this.subscriptions.values()) {
216
+ for (const entry of entries) {
217
+ if (seen.has(entry.topic)) continue;
218
+ seen.add(entry.topic);
219
+ if (this.ws?.readyState === WebSocket.OPEN) {
220
+ this.joinTopic(entry.topic, entry.joinPayload);
221
+ }
158
222
  }
159
223
  }
160
224
  }
@@ -168,11 +232,12 @@ export class RealtimeService {
168
232
  }
169
233
 
170
234
  let url = this.url;
171
- if (this.token) {
235
+ const token = this.tokenProvider ? this.tokenProvider() : this.token;
236
+ if (token) {
172
237
  url +=
173
238
  (url.includes("?") ? "&" : "?") +
174
239
  "token=" +
175
- encodeURIComponent(this.token);
240
+ encodeURIComponent(token);
176
241
  }
177
242
 
178
243
  this.ws = new WebSocket(url);
@@ -232,13 +297,16 @@ export class RealtimeService {
232
297
  }
233
298
  }
234
299
 
235
- private joinTopic(topic: string): void {
300
+ private joinTopic(
301
+ topic: string,
302
+ joinPayload?: Record<string, unknown>,
303
+ ): void {
236
304
  const ref = this.nextRef();
237
305
  // Phoenix V1 JSON Serializer expects a JSON object, not an array
238
306
  const msg = JSON.stringify({
239
307
  topic: topic,
240
308
  event: "phx_join",
241
- payload: {},
309
+ payload: joinPayload ?? {},
242
310
  ref: ref,
243
311
  });
244
312
  this.ws?.send(msg);