lazypock 0.1.5 → 0.2.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/http.ts CHANGED
@@ -8,11 +8,29 @@ import type { AuthStore } from "./auth";
8
8
  * Low-level HTTP client wrapping `fetch` with automatic auth token injection.
9
9
  * Only relies on `globalThis.fetch` — works in browser, React Native, and Node 18+.
10
10
  */
11
+ /** Detect whether an unknown thrown value is an abort/`AbortError`-style error. */
12
+ function isAbortError(err: unknown): boolean {
13
+ return (
14
+ err instanceof Error &&
15
+ (err.name === "AbortError" || err.message === "Aborted")
16
+ );
17
+ }
18
+
11
19
  export class HttpClient {
12
20
  private baseUrl: string;
13
21
  private authStore: AuthStore;
14
22
  private defaultFetch: typeof globalThis.fetch;
15
23
 
24
+ /**
25
+ * Abort controllers for in-flight requests, keyed by their cancellation key
26
+ * (default `METHOD path`). A new request with the same key aborts the
27
+ * previous one — PocketBase-style auto-cancellation of duplicated requests.
28
+ */
29
+ private cancelControllers: Record<string, AbortController> = {};
30
+
31
+ /** Global toggle for the auto-cancellation behaviour (default: on). */
32
+ private enableAutoCancellation = true;
33
+
16
34
  /**
17
35
  * @param baseUrl The API base URL (e.g. `http://localhost:4000/api`). Trailing slash stripped.
18
36
  * @param authStore The auth store providing the token for Authorization headers.
@@ -60,15 +78,52 @@ export class HttpClient {
60
78
  }
61
79
  }
62
80
 
81
+ /**
82
+ * Globally enable or disable auto-cancellation of duplicated pending requests.
83
+ * Fluent — returns `this` for chaining.
84
+ */
85
+ autoCancellation(enable: boolean): this {
86
+ this.enableAutoCancellation = !!enable;
87
+ return this;
88
+ }
89
+
90
+ /**
91
+ * Abort a pending request identified by its cancellation key
92
+ * (default `METHOD path`, e.g. `"GET /api/posts"`). No-op if not pending.
93
+ */
94
+ cancelRequest(requestKey: string): this {
95
+ const controller = this.cancelControllers[requestKey];
96
+ if (controller) {
97
+ controller.abort();
98
+ delete this.cancelControllers[requestKey];
99
+ }
100
+ return this;
101
+ }
102
+
103
+ /** Abort all pending requests. */
104
+ cancelAllRequests(): this {
105
+ for (const key in this.cancelControllers) {
106
+ this.cancelControllers[key].abort();
107
+ }
108
+ this.cancelControllers = {};
109
+ return this;
110
+ }
111
+
63
112
  /**
64
113
  * Make an HTTP request with automatic auth token injection and optional auto-refresh.
65
114
  *
115
+ * Auto-cancellation: a request keyed by `options.requestKey` (default
116
+ * `METHOD path`) aborts any previous pending request with the same key,
117
+ * so only the last duplicate executes. Set `requestKey: null` or
118
+ * `autoCancel: false` to opt out per request.
119
+ *
66
120
  * @param method HTTP method.
67
121
  * @param path URL path (appended to baseUrl).
68
122
  * @param body JSON-serializable body, or FormData for file uploads.
69
123
  * @param options Optional request options.
70
124
  * @returns Parsed JSON response, or null for 204 No Content.
71
- * @throws {ApiError} On non-2xx responses.
125
+ * @throws {ApiError} On non-2xx responses or when the request is aborted
126
+ * (aborted requests throw an `ApiError` with `isAbort === true`).
72
127
  */
73
128
  async request<T = unknown>(
74
129
  method: Method,
@@ -81,6 +136,39 @@ export class HttpClient {
81
136
  await this.refreshAuth();
82
137
  }
83
138
 
139
+ // Resolve the auto-cancellation key (PocketBase `requestKey` semantics):
140
+ // - options.requestKey null → disabled for this request
141
+ // - options.requestKey string → use it verbatim
142
+ // - options.autoCancel false → disabled (legacy compat)
143
+ // - options.cancelKey string → use it verbatim (legacy compat)
144
+ // - otherwise → default to `${method} ${path}`
145
+ let requestKey: string | null =
146
+ options?.requestKey === undefined
147
+ ? (options?.cancelKey ?? `${method} ${path}`)
148
+ : options.requestKey;
149
+ if (options?.autoCancel === false) requestKey = null;
150
+
151
+ // Wire a fresh AbortController for this request, merging any caller signal.
152
+ // When auto-cancellation is enabled, the previous pending request sharing
153
+ // our key is aborted first (only the last duplicate executes).
154
+ let controller: AbortController | null = null;
155
+ const externalSignal = options?.signal;
156
+ if (requestKey !== null) {
157
+ if (this.enableAutoCancellation) {
158
+ this.cancelRequest(requestKey);
159
+ }
160
+ controller = new AbortController();
161
+ this.cancelControllers[requestKey] = controller;
162
+ if (externalSignal?.aborted) {
163
+ controller.abort();
164
+ } else if (externalSignal) {
165
+ externalSignal.addEventListener("abort", () => controller?.abort(), {
166
+ once: true,
167
+ });
168
+ }
169
+ }
170
+ const signal = controller?.signal ?? externalSignal;
171
+
84
172
  let url = this.baseUrl + path;
85
173
  if (options?.params) {
86
174
  const qs = new URLSearchParams(options.params).toString();
@@ -104,7 +192,7 @@ export class HttpClient {
104
192
  const init: RequestInit = {
105
193
  method,
106
194
  headers,
107
- signal: options?.signal,
195
+ signal,
108
196
  };
109
197
 
110
198
  if (body != null && method !== "GET" && method !== "DELETE") {
@@ -115,10 +203,34 @@ export class HttpClient {
115
203
  }
116
204
  }
117
205
 
206
+ let res: Response | null = null;
118
207
  const fetcher = options?.fetch ?? this.defaultFetch;
119
- const res = await fetcher(url, init);
208
+ try {
209
+ res = await fetcher(url, init);
210
+ } catch (err) {
211
+ // Aborted (auto-cancelled duplicate, manual cancel, or external signal)
212
+ // → normalized ApiError with isAbort === true, like PocketBase.
213
+ if (isAbortError(err)) {
214
+ throw new ApiError(
215
+ "The request was aborted (most likely auto-cancelled by a newer request with the same requestKey)",
216
+ {},
217
+ 0,
218
+ true,
219
+ );
220
+ }
221
+ throw err;
222
+ } finally {
223
+ // The request has settled — no longer pending, so drop the controller
224
+ // unless a newer request already replaced it (same key).
225
+ if (
226
+ requestKey !== null &&
227
+ this.cancelControllers[requestKey] === controller
228
+ ) {
229
+ delete this.cancelControllers[requestKey];
230
+ }
231
+ }
120
232
 
121
- if (res.status === 204) return null;
233
+ if (res!.status === 204) return null;
122
234
 
123
235
  // Safely parse JSON — some errored responses may have empty or non-JSON bodies
124
236
  let bodyText = "";
@@ -132,12 +244,12 @@ export class HttpClient {
132
244
  // Not JSON — keep data as empty object
133
245
  }
134
246
 
135
- if (!res.ok) {
247
+ if (!res!.ok) {
136
248
  throw new ApiError(
137
- (typeof data.message === "string" ? data.message : res.statusText) ||
138
- `Request failed with status ${res.status}`,
249
+ (typeof data.message === "string" ? data.message : res!.statusText) ||
250
+ `Request failed with status ${res!.status}`,
139
251
  data,
140
- res.status,
252
+ res!.status,
141
253
  );
142
254
  }
143
255
 
package/src/index.ts CHANGED
@@ -8,10 +8,13 @@ export {
8
8
  // LazypockClient + options + everything it re-exports
9
9
  AuthStore,
10
10
  ApiError,
11
+ HttpClient,
11
12
  RealtimeService,
12
13
  wsUrlFromBaseUrl,
13
14
  FilesService,
14
15
  getFileUrl,
16
+ getThumbUrl,
17
+ getScaleUrl,
15
18
  LazypockClient,
16
19
  CollectionService,
17
20
  CollectionsService,
package/src/lazypock.ts CHANGED
@@ -21,7 +21,13 @@ import {
21
21
  type RequestOptions,
22
22
  } from "./types";
23
23
  import { RealtimeService, wsUrlFromBaseUrl } from "./realtime";
24
- import { FilesService, getFileUrl, type FileRecord } from "./files";
24
+ import {
25
+ FilesService,
26
+ getFileUrl,
27
+ getThumbUrl,
28
+ getScaleUrl,
29
+ type FileRecord,
30
+ } from "./files";
25
31
  import { CollectionsService } from "./collections";
26
32
  import type { CollectionSchema, SchemaField } from "./schema";
27
33
  import { generateTypes, collectionTypeName } from "./codegen";
@@ -30,10 +36,13 @@ import { fieldTypeScriptType, fieldTypeKind, schemaFieldType } from "./typegen";
30
36
  export {
31
37
  AuthStore,
32
38
  ApiError,
39
+ HttpClient,
33
40
  RealtimeService,
34
41
  wsUrlFromBaseUrl,
35
42
  FilesService,
36
43
  getFileUrl,
44
+ getThumbUrl,
45
+ getScaleUrl,
37
46
  CollectionService,
38
47
  CollectionsService,
39
48
  generateTypes,
@@ -171,6 +180,41 @@ export class LazypockClient {
171
180
  return generateTypes(schemas, options);
172
181
  }
173
182
 
183
+ // ── Auto-cancellation (PocketBase `autoCancellation` parity) ──
184
+
185
+ /**
186
+ * Globally enable or disable auto-cancellation of duplicated pending requests.
187
+ *
188
+ * When enabled (default), a new request whose `requestKey` (default
189
+ * `HTTP_METHOD + path`) matches a still-pending request aborts the previous
190
+ * one — only the last duplicate executes.
191
+ *
192
+ * @example
193
+ * ```ts
194
+ * client.autoCancellation(false); // keep every request
195
+ * ```
196
+ */
197
+ autoCancellation(enable: boolean): this {
198
+ this.http.autoCancellation(enable);
199
+ return this;
200
+ }
201
+
202
+ /**
203
+ * Abort a single pending request by its cancellation key
204
+ * (default `HTTP_METHOD + path`, e.g. `"GET /api/posts?page=1"`).
205
+ * The request rejects with an `ApiError` whose `isAbort` is `true`.
206
+ */
207
+ cancelRequest(requestKey: string): this {
208
+ this.http.cancelRequest(requestKey);
209
+ return this;
210
+ }
211
+
212
+ /** Abort all pending requests. */
213
+ cancelAllRequests(): this {
214
+ this.http.cancelAllRequests();
215
+ return this;
216
+ }
217
+
174
218
  // ── Auth ──
175
219
 
176
220
  /** Check whether any superuser exists (for login vs setup screen routing). */
@@ -230,11 +274,24 @@ export class LazypockClient {
230
274
  this.authStore.setCollectionName(collection);
231
275
  this.authStore.set(data.token, data.record as unknown as AuthModel);
232
276
  }
233
- } else {
234
- data = await this.http.post<{ token: string } & Record<string, unknown>>(
235
- "/superusers/login",
236
- { email, password },
237
- );
277
+ } else {
278
+ // Superuser login — PocketBase parity: `_superusers` is an auth collection.
279
+ // Falls back to the legacy /superusers/login for older servers.
280
+ try {
281
+ data = await this.http.post<
282
+ { token: string; record: ApiRecord } & Record<string, unknown>
283
+ >("/_superusers/auth-with-password", {
284
+ identity: email,
285
+ password,
286
+ });
287
+ } catch {
288
+ data = null;
289
+ }
290
+ if (!data) {
291
+ data = await this.http.post<
292
+ { token: string } & Record<string, unknown>
293
+ >("/superusers/login", { email, password });
294
+ }
238
295
  if (data) {
239
296
  this.authStore.setCollectionName(null);
240
297
  this.authStore.set(data.token, null);
@@ -243,9 +300,13 @@ export class LazypockClient {
243
300
  return data;
244
301
  }
245
302
 
246
- /** Fetch the current superuser profile and refresh the auth model. */
303
+ /**
304
+ * Fetch the current authenticated identity (superuser OR auth collection user).
305
+ * Uses `GET /api/me` (PocketBase parity) — works with both superuser tokens
306
+ * and auth collection user tokens.
307
+ */
247
308
  async me<T = ApiRecord>(options?: RequestOptions): Promise<T | null> {
248
- const data = await this.http.get<T>("/superusers/me", options);
309
+ const data = await this.http.get<T>("/me", options);
249
310
  if (data) {
250
311
  // Update the auth model with fresh data
251
312
  this.authStore.set(this.authStore.token, data as unknown as AuthModel);
package/src/types.ts CHANGED
@@ -65,16 +65,45 @@ export interface RequestOptions {
65
65
  signal?: AbortSignal;
66
66
  /** Custom fetch implementation (for RN or test mocking) */
67
67
  fetch?: typeof globalThis.fetch;
68
+ /**
69
+ * Request identifier used by the auto-cancellation mechanism.
70
+ *
71
+ * Pending requests sharing the same key cancel each other — only the
72
+ * last one is executed (PocketBase `requestKey` semantics).
73
+ *
74
+ * - `string` — use this exact key instead of the default `METHOD + path`.
75
+ * - `null` — disable auto-cancellation for this request (never auto-cancelled).
76
+ *
77
+ * @default `${method} ${path}`
78
+ */
79
+ requestKey?: string | null;
80
+ /**
81
+ * Disable auto-cancellation for this request.
82
+ * Alias of `requestKey: null` (PocketBase `$autoCancel: false` compat).
83
+ */
84
+ autoCancel?: boolean;
85
+ /**
86
+ * Custom request key used for auto-cancellation.
87
+ * Alias of `requestKey` (PocketBase `$cancelKey` compat).
88
+ */
89
+ cancelKey?: string;
68
90
  }
69
91
 
70
92
  export class ApiError extends Error {
71
93
  readonly data: unknown;
72
94
  readonly status: number;
95
+ /**
96
+ * `true` when this error was caused by an aborted/cancelled request
97
+ * (auto-cancelled duplicate, or manually via `cancelRequest()` /
98
+ * `cancelAllRequests()` / an external AbortSignal).
99
+ */
100
+ readonly isAbort: boolean;
73
101
 
74
- constructor(message: string, data: unknown, status: number) {
102
+ constructor(message: string, data: unknown, status: number, isAbort = false) {
75
103
  super(message);
76
104
  this.name = "ApiError";
77
105
  this.data = data;
78
106
  this.status = status;
107
+ this.isAbort = isAbort;
79
108
  }
80
109
  }