swarpc 0.17.2 → 0.18.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 CHANGED
@@ -254,6 +254,50 @@ setTimeout(() => cancel().then(() => console.warn("Took too long!!")), 5_000);
254
254
  await request;
255
255
  ```
256
256
 
257
+ ### Call in "once" mode
258
+
259
+ The "once" mode allows you to automatically cancel any previous ongoing call before running a new one. This is useful for scenarios like search-as-you-type, where you only care about the latest request.
260
+
261
+ #### Method-scoped once mode
262
+
263
+ Cancel any previous call of the same method:
264
+
265
+ ```js
266
+ // If any previous call of searchIMDb is ongoing, it gets cancelled beforehand
267
+ const result = await swarpc.searchIMDb.once({ query });
268
+ ```
269
+
270
+ #### Method-scoped once mode with key
271
+
272
+ Cancel any previous call of the same method with the same key:
273
+
274
+ ```js
275
+ // If any previous call of searchIMDb with "foo" as the key is ongoing,
276
+ // it gets cancelled beforehand
277
+ const result = await swarpc.searchIMDb.onceBy("foo", { query });
278
+ ```
279
+
280
+ This allows multiple concurrent calls with different keys:
281
+
282
+ ```js
283
+ // These two calls can run concurrently
284
+ const result1 = await swarpc.searchIMDb.onceBy("search-bar", {
285
+ query: "action",
286
+ });
287
+ const result2 = await swarpc.searchIMDb.onceBy("sidebar", { query: "comedy" });
288
+ ```
289
+
290
+ #### Global once mode
291
+
292
+ Cancel any ongoing call with the same global key, across all methods:
293
+
294
+ ```js
295
+ // Any call from ANY procedure with "global-search" key gets cancelled beforehand
296
+ const result = await swarpc.onceBy("global-search").searchIMDb({ query });
297
+ ```
298
+
299
+ This is useful when you want to ensure only one operation of a certain type is running at a time, regardless of which procedure is being called.
300
+
257
301
  ### Polyfill a `localStorage` for the Server to access
258
302
 
259
303
  You might call third-party code that accesses on `localStorage` from within your procedures.
package/dist/client.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * @mergeModuleWith <project>
4
4
  */
5
5
  import { type LogLevel } from "./log.js";
6
- import { ClientMethod, Hooks, WorkerConstructor, zProcedures, type ProceduresMap } from "./types.js";
6
+ import { ClientMethod, ClientMethodCallable, Hooks, WorkerConstructor, zProcedures, type ProceduresMap } from "./types.js";
7
7
  /**
8
8
  * The sw&rpc client instance, which provides {@link ClientMethod | methods to call procedures}.
9
9
  * Each property of the procedures map will be a method, that accepts an input, an optional onProgress callback and an optional request ID.
@@ -11,6 +11,13 @@ import { ClientMethod, Hooks, WorkerConstructor, zProcedures, type ProceduresMap
11
11
  */
12
12
  export type SwarpcClient<Procedures extends ProceduresMap> = {
13
13
  [zProcedures]: Procedures;
14
+ /**
15
+ * Create a proxy that cancels any ongoing call with the given global key before running new calls.
16
+ * Usage: `await swarpc.onceBy("global-key").myMethod(...)`
17
+ */
18
+ onceBy: (key: string) => {
19
+ [F in keyof Procedures]: ClientMethodCallable<Procedures[F]>;
20
+ };
14
21
  } & {
15
22
  [F in keyof Procedures]: ClientMethod<Procedures[F]>;
16
23
  };
package/dist/client.js CHANGED
@@ -3,6 +3,10 @@ import { makeNodeId, nodeIdOrSW, whoToSendTo } from "./nodes.js";
3
3
  import { zProcedures, } from "./types.js";
4
4
  import { findTransferables } from "./utils.js";
5
5
  const pendingRequests = new Map();
6
+ const onceByMethod = new Map();
7
+ const onceByMethodAndKey = new Map();
8
+ const onceByGlobalKey = new Map();
9
+ const emptyProgressCallback = () => { };
6
10
  let _clientListenerStarted = new Set();
7
11
  export function Client(procedures, { worker, nodes: nodeCount, loglevel = "debug", restartListener = false, hooks = {}, localStorage = {}, } = {}) {
8
12
  const l = createLogger("client", loglevel);
@@ -24,6 +28,23 @@ export function Client(procedures, { worker, nodes: nodeCount, loglevel = "debug
24
28
  }
25
29
  l.info(null, `Started ${nodeCount} node${nodeCount > 1 ? "s" : ""}`, Object.keys(nodes));
26
30
  }
31
+ const cancelRequest = (requestId, reason, functionName) => {
32
+ const pending = pendingRequests.get(requestId);
33
+ if (!pending)
34
+ return;
35
+ const nodeId = pending.nodeId;
36
+ const l = createLogger("client", loglevel, nodeIdOrSW(nodeId), requestId);
37
+ l.debug(requestId, `Cancelling ${functionName} with`, reason);
38
+ pending.reject(new Error(reason));
39
+ postMessageSync(l, nodeId ? nodes?.[nodeId] : undefined, {
40
+ by: "sw&rpc",
41
+ requestId,
42
+ functionName,
43
+ abort: { reason },
44
+ });
45
+ pendingRequests.delete(requestId);
46
+ };
47
+ const runProcedureFunctions = new Map();
27
48
  for (const functionName of Object.keys(procedures)) {
28
49
  if (typeof functionName !== "string") {
29
50
  throw new Error(`[SWARPC Client] Invalid function name, don't use symbols`);
@@ -43,7 +64,7 @@ export function Client(procedures, { worker, nodes: nodeCount, loglevel = "debug
43
64
  functionName,
44
65
  }, options);
45
66
  };
46
- const _runProcedure = async (input, onProgress = () => { }, reqid, nodeId) => {
67
+ const _runProcedure = async (input, onProgress = emptyProgressCallback, reqid, nodeId) => {
47
68
  const validation = procedures[functionName].input["~standard"].validate(input);
48
69
  if (validation instanceof Promise)
49
70
  throw new Error("Validations must not be async");
@@ -70,6 +91,7 @@ export function Client(procedures, { worker, nodes: nodeCount, loglevel = "debug
70
91
  .catch(reject);
71
92
  });
72
93
  };
94
+ runProcedureFunctions.set(functionName, _runProcedure);
73
95
  instance[functionName] = _runProcedure;
74
96
  instance[functionName].broadcast = async (input, onProgresses, nodesCount) => {
75
97
  let nodesToUse = [undefined];
@@ -111,7 +133,74 @@ export function Client(procedures, { worker, nodes: nodeCount, loglevel = "debug
111
133
  },
112
134
  };
113
135
  };
136
+ instance[functionName].once = async (input, onProgress) => {
137
+ const previousRequestId = onceByMethod.get(functionName);
138
+ if (previousRequestId) {
139
+ cancelRequest(previousRequestId, "Cancelled by .once() call", functionName);
140
+ onceByMethod.delete(functionName);
141
+ }
142
+ const requestId = makeRequestId();
143
+ onceByMethod.set(functionName, requestId);
144
+ try {
145
+ return await _runProcedure(input, onProgress, requestId);
146
+ }
147
+ finally {
148
+ if (onceByMethod.get(functionName) === requestId) {
149
+ onceByMethod.delete(functionName);
150
+ }
151
+ }
152
+ };
153
+ instance[functionName].onceBy = async (key, input, onProgress) => {
154
+ const trackingKey = `${functionName}:${key}`;
155
+ const previousRequestId = onceByMethodAndKey.get(trackingKey);
156
+ if (previousRequestId) {
157
+ cancelRequest(previousRequestId, `Cancelled by .onceBy("${key}") call`, functionName);
158
+ onceByMethodAndKey.delete(trackingKey);
159
+ }
160
+ const requestId = makeRequestId();
161
+ onceByMethodAndKey.set(trackingKey, requestId);
162
+ try {
163
+ return await _runProcedure(input, onProgress, requestId);
164
+ }
165
+ finally {
166
+ if (onceByMethodAndKey.get(trackingKey) === requestId) {
167
+ onceByMethodAndKey.delete(trackingKey);
168
+ }
169
+ }
170
+ };
114
171
  }
172
+ instance.onceBy = (globalKey) => {
173
+ const proxy = {};
174
+ for (const functionName of Object.keys(procedures)) {
175
+ if (typeof functionName !== "string")
176
+ continue;
177
+ proxy[functionName] = async (input, onProgress) => {
178
+ const previousRequestId = onceByGlobalKey.get(globalKey);
179
+ if (previousRequestId) {
180
+ const pending = pendingRequests.get(previousRequestId);
181
+ if (pending) {
182
+ cancelRequest(previousRequestId, `Cancelled by global onceBy("${globalKey}") call`, pending.functionName);
183
+ }
184
+ onceByGlobalKey.delete(globalKey);
185
+ }
186
+ const requestId = makeRequestId();
187
+ onceByGlobalKey.set(globalKey, requestId);
188
+ const _runProcedure = runProcedureFunctions.get(functionName);
189
+ if (!_runProcedure) {
190
+ throw new Error(`No procedure found for ${functionName}`);
191
+ }
192
+ try {
193
+ return await _runProcedure(input, onProgress ?? emptyProgressCallback, requestId);
194
+ }
195
+ finally {
196
+ if (onceByGlobalKey.get(globalKey) === requestId) {
197
+ onceByGlobalKey.delete(globalKey);
198
+ }
199
+ }
200
+ };
201
+ }
202
+ return proxy;
203
+ };
115
204
  return instance;
116
205
  }
117
206
  async function postMessage(ctx, message, options) {
package/dist/types.d.ts CHANGED
@@ -122,10 +122,14 @@ export type PayloadCore<PM extends ProceduresMap, Name extends keyof PM = keyof
122
122
  message: string;
123
123
  };
124
124
  };
125
+ /**
126
+ * The callable function signature for a client method
127
+ */
128
+ export type ClientMethodCallable<P extends Procedure<Schema, Schema, Schema>> = (input: Schema.InferInput<P["input"]>, onProgress?: (progress: Schema.InferOutput<P["progress"]>) => void) => Promise<Schema.InferOutput<P["success"]>>;
125
129
  /**
126
130
  * A procedure's corresponding method on the client instance -- used to call the procedure. If you want to be able to cancel the request, you can use the `cancelable` method instead of running the procedure directly.
127
131
  */
128
- export type ClientMethod<P extends Procedure<Schema, Schema, Schema>> = ((input: Schema.InferInput<P["input"]>, onProgress?: (progress: Schema.InferOutput<P["progress"]>) => void) => Promise<Schema.InferOutput<P["success"]>>) & {
132
+ export type ClientMethod<P extends Procedure<Schema, Schema, Schema>> = ClientMethodCallable<P> & {
129
133
  /**
130
134
  * A method that returns a `CancelablePromise`. Cancel it by calling `.cancel(reason)` on it, and wait for the request to resolve by awaiting the `request` property on the returned object.
131
135
  */
@@ -142,6 +146,14 @@ export type ClientMethod<P extends Procedure<Schema, Schema, Schema>> = ((input:
142
146
  nodes?: number) => Promise<Array<PromiseSettledResult<Schema.InferOutput<P["success"]>> & {
143
147
  node: string;
144
148
  }>>;
149
+ /**
150
+ * Call the procedure, cancelling any previous ongoing call of this procedure beforehand.
151
+ */
152
+ once: (input: Schema.InferInput<P["input"]>, onProgress?: (progress: Schema.InferOutput<P["progress"]>) => void) => Promise<Schema.InferOutput<P["success"]>>;
153
+ /**
154
+ * Call the procedure with a concurrency key, cancelling any previous ongoing call of this procedure with the same key beforehand.
155
+ */
156
+ onceBy: (key: string, input: Schema.InferInput<P["input"]>, onProgress?: (progress: Schema.InferOutput<P["progress"]>) => void) => Promise<Schema.InferOutput<P["success"]>>;
145
157
  };
146
158
  export type WorkerConstructor<T extends Worker | SharedWorker = Worker | SharedWorker> = {
147
159
  new (opts?: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swarpc",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
4
4
  "description": "Full type-safe RPC library for service worker -- move things off of the UI thread with ease!",
5
5
  "keywords": [
6
6
  "service-workers",
@@ -50,33 +50,33 @@
50
50
  "@playwright/test": "^1.57.0",
51
51
  "@size-limit/esbuild-why": "^12.0.0",
52
52
  "@size-limit/preset-small-lib": "^12.0.0",
53
- "@vitest/web-worker": "^4.0.15",
54
- "arktype": "^2.1.28",
53
+ "@vitest/web-worker": "^4.0.16",
54
+ "arktype": "^2.1.29",
55
55
  "date-fns": "^4.1.0",
56
56
  "husky": "^9.1.7",
57
57
  "kacl": "^1.1.1",
58
- "knip": "^5.72.0",
58
+ "knip": "^5.80.0",
59
59
  "lint-staged": "^16.2.7",
60
60
  "nodemon": "^3.1.11",
61
- "oxlint": "^1.32.0",
61
+ "oxlint": "^1.37.0",
62
62
  "pkg-pr-new": "^0.0.62",
63
63
  "prettier": "^3.7.4",
64
64
  "sirv-cli": "^3.0.1",
65
65
  "size-limit": "^12.0.0",
66
66
  "typedoc": "^0.28.15",
67
67
  "typedoc-material-theme": "^1.4.1",
68
- "typedoc-plugin-dt-links": "^2.0.32",
68
+ "typedoc-plugin-dt-links": "^2.0.36",
69
69
  "typedoc-plugin-extras": "^4.0.1",
70
70
  "typedoc-plugin-inline-sources": "^1.3.0",
71
71
  "typedoc-plugin-mdn-links": "^5.0.10",
72
72
  "typedoc-plugin-redirect": "^1.2.1",
73
73
  "typescript": "^5.9.3",
74
- "vite": "^7.2.7",
75
- "vitest": "^4.0.15"
74
+ "vite": "^7.3.0",
75
+ "vitest": "^4.0.16"
76
76
  },
77
77
  "volta": {
78
- "node": "24.11.1",
79
- "npm": "11.6.4"
78
+ "node": "24.12.0",
79
+ "npm": "11.7.0"
80
80
  },
81
81
  "lint-staged": {
82
82
  "*.{ts,js,md,json,yaml,yml}": [