async-lube 2.0.0 → 2.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/docs/http.md +1 -1
  4. package/docs/reference.md +4 -2
  5. package/docs/tasks.md +19 -2
  6. package/package.json +4 -5
  7. package/public.d.ts +112 -0
  8. package/src/attempt.js +17 -7
  9. package/src/durable/context.js +107 -64
  10. package/src/durable/error.js +39 -15
  11. package/src/durable/index.js +18 -10
  12. package/src/durable/journal.js +29 -14
  13. package/src/durable/json.js +26 -9
  14. package/src/durable/lease.js +15 -5
  15. package/src/durable/persist.js +60 -22
  16. package/src/durable/run.js +103 -41
  17. package/src/durable/send.js +30 -13
  18. package/src/durable/serve.js +30 -24
  19. package/src/durable/state.js +28 -13
  20. package/src/durable/store.js +19 -10
  21. package/src/durable/watch.js +31 -11
  22. package/src/errors.js +9 -3
  23. package/src/flow/attempts.js +49 -29
  24. package/src/flow/builder.js +260 -161
  25. package/src/flow/compile.js +86 -66
  26. package/src/flow/graph.js +108 -42
  27. package/src/flow/index.js +13 -7
  28. package/src/flow/items.js +79 -46
  29. package/src/flow/nodes.js +267 -117
  30. package/src/flow/refs.js +27 -13
  31. package/src/flow/resources.js +9 -3
  32. package/src/flow/run.js +287 -114
  33. package/src/flow/streams.js +44 -19
  34. package/src/flow/subflows.js +93 -53
  35. package/src/http/config.js +11 -5
  36. package/src/http/index.js +25 -14
  37. package/src/http/refresh.js +29 -12
  38. package/src/http/request.js +246 -151
  39. package/src/http/response.js +105 -50
  40. package/src/http/retry.js +52 -27
  41. package/src/http/socket.js +220 -172
  42. package/src/http/sse.js +113 -74
  43. package/src/http/throttle.js +9 -4
  44. package/src/http/url.js +58 -35
  45. package/src/http/xhr.js +29 -24
  46. package/src/index.js +1 -0
  47. package/src/key.js +3 -5
  48. package/src/limiter.js +146 -65
  49. package/src/offload.js +116 -85
  50. package/src/offload_module.js +594 -0
  51. package/src/options.js +8 -4
  52. package/src/redial.js +129 -0
  53. package/src/signal.js +14 -4
  54. package/src/stream.js +353 -198
  55. package/types/attempt.d.ts.map +1 -1
  56. package/types/durable/index.d.ts.map +1 -1
  57. package/types/durable/state.d.ts.map +1 -1
  58. package/types/durable/store.d.ts.map +1 -1
  59. package/types/errors.d.ts.map +1 -1
  60. package/types/http/index.d.ts.map +1 -1
  61. package/types/index.d.ts +1 -0
  62. package/types/index.d.ts.map +1 -1
  63. package/types/limiter.d.ts +5 -1
  64. package/types/limiter.d.ts.map +1 -1
  65. package/types/offload.d.ts +2 -16
  66. package/types/offload.d.ts.map +1 -1
  67. package/types/redial.d.ts +28 -0
  68. package/types/redial.d.ts.map +1 -0
  69. package/types/stream.d.ts +2 -0
  70. package/types/stream.d.ts.map +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0
4
+
5
+ ### Added
6
+
7
+ - `offload(url, { data, delay, onBatch })` loads a module file, a `URL` or a URL string, in one stateful worker and
8
+ returns `{ ready, call(request), close() }`. The module default-exports a setup function that gets `data` and
9
+ returns `{ call, batch?, close? }` (type it with `OffloadSetup`). Replies are matched by id, calls made in one tick
10
+ travel in one message, and the worker runs the requests that arrived together, gathered for `delay` milliseconds
11
+ (0: the next macrotask), as one batch, never two at once. `batch(run)` wraps a batch, e.g. in a transaction; if it
12
+ throws every call of the batch rejects, and a value it returns reaches `onBatch` in the calling thread, after which
13
+ the calls of the batch settle (they reject if `onBatch` rejects). A failing setup rejects `ready` and every call,
14
+ and a crashed worker rejects its calls and every later one. `close()` lets the sent calls finish, runs the module's
15
+ `close`, ends the thread and resolves after it exited; later calls reject with `CancelError`.
16
+ `offload("not a url")` throws a `TypeError`.
17
+ - `redial(connect, { signal, base, most })` keeps one connection up: it calls `connect({ attempt, signal, open, close })`
18
+ at once and again after each `close()`, waiting `base` milliseconds (default 1000) after a connection that
19
+ called `open()` and doubling the wait up to `most` (default 30000) after each one that closed without
20
+ opening. A `connect` that throws or rejects counts as a close. When `signal` aborts it stops the wait, aborts the
21
+ signal of the connection in hand and resolves once that connection has closed.
22
+ - `limiter(...).take(key?)` answers at once, without waiting, whether a start is free in the rate window:
23
+ it returns `0` and counts the start, or the milliseconds until the next start is accepted and counts
24
+ nothing. It ignores `concurrency` and the queue but shares the window with calls and `acquire()`, uses
25
+ the state of `key`, and throws a `TypeError` without a `rate`.
26
+ - `limiter({ rate: { window: "fixed" } })` opens a window at the first start after the last one ended and
27
+ allows `count` starts in it, for calls, `acquire()` and `take()`. The default `"sliding"` is unchanged.
28
+
29
+ ### Fixed
30
+
31
+ - `share` of a value that is not a stream rejects the first read and ends the loop, so reading again
32
+ without `return()` no longer waits forever.
33
+ - `merge` releases the streams it already opened when a later source is not a stream.
34
+ - A flow node that restarts with its next queued value while its `fallback` target is still running
35
+ no longer runs the target one more time.
36
+
3
37
  ## 2.0.0
4
38
 
5
39
  ### Breaking
package/README.md CHANGED
@@ -249,7 +249,7 @@ const thumbnail = await resize(bytes, 200, signal) // the signal terminates the
249
249
  ```
250
250
  - `attempt` takes the same `retry` options as a request and a flow node.
251
251
  - `limiter` starts the calls in order, and `key(id)` gives each user or tenant the same limits.
252
- - `offload` sends the function as its source, so it uses only its arguments and globals.
252
+ - `offload` sends the function as its source, so it uses only its arguments and globals. `offload(url, { data, delay, onBatch })` loads a module file in one stateful worker instead, and `call(request)` sends requests that run in batches.
253
253
 
254
254
  [docs/tasks.md](https://github.com/artxe/lube-series/blob/master/packages/async-lube/docs/tasks.md): leases, keys and workers.
255
255
 
package/docs/http.md CHANGED
@@ -233,7 +233,7 @@ const logged = http({
233
233
  ```
234
234
 
235
235
  ## Caches and stores
236
- `http` does not cache responses or hold UI state. It fits under a query cache, under a few lines of your own, or under a store of 27 lines.
236
+ `http` does not cache responses or hold UI state. It fits under a query cache, under a few lines of your own, or under a small store.
237
237
 
238
238
  ```ts
239
239
  // TanStack Query, SWR or any cache: pass its signal, and let the cache own the key
package/docs/reference.md CHANGED
@@ -71,7 +71,7 @@ A request is a promise of the data with `cancel(reason?)`, `safe()` and `raw()`,
71
71
  | `overlap` | `"restart"` (default), `"ignore"`, `"rerun"` or `"queue"`: what the node does when it is started again while it runs |
72
72
  | `concurrency` | The items that run at the same time, for a `flow.each()` node |
73
73
  | `limit` | The updates that `overlap: "queue"` or a `flow.queue()` dependency keeps waiting, dropping the oldest above it. The value in use is never dropped |
74
- | `overflow` | `"drop"` (default), or `"wait"` to stop reading the stream dependencies while the queue is full, which needs one |
74
+ | `overflow` | `"drop"` (default), or `"wait"` to stop reading the stream dependencies while the queue is full, which needs one. Either needs a `limit` |
75
75
  | `release` | `(result, error)` when the result is no longer used: commits, rolls back or closes it. The node starts only when a dependent can run |
76
76
  | `name` | The name in `nodes`, `results`, `errors` and snapshots |
77
77
 
@@ -97,7 +97,9 @@ The stream functions are listed in [Operators](https://github.com/artxe/lube-ser
97
97
  | API | |
98
98
  | --- | --- |
99
99
  | `attempt(fn, { retry, signal, timeout })` | Runs `fn({ attempt, signal })` again after it fails, with a timeout for each attempt |
100
- | `limiter({ concurrency, rate: { count, per } })` | A function `(fn, signal?)` that runs the calls within the limits, in order, with `acquire(signal?)`, `key(id)`, `pending` and `running` |
100
+ | `redial(connect, { signal, base, most })` | Calls `connect({ attempt, signal, open, close })` at once and again after each `close()`, waiting `base` ms (default 1000) after a connection that opened and doubling up to `most` ms (default 30000) after each that closed without opening, until `signal` aborts; resolves once the connection in hand has closed |
101
+ | `limiter({ concurrency, rate: { count, per, window } })` | A function `(fn, signal?)` that runs the calls within the limits, in order, with `acquire(signal?)`, `key(id)`, `take(id?)` (`0` or the milliseconds until a start is free, at once), `pending` and `running`; `window` is `"sliding"` (default) or `"fixed"` |
101
102
  | `durable(store, fn, { idle, lease, migrate, owner, poll, revision })` | From `async-lube/durable`: `run(key, input)`, `join(key)`, `start(key, input)`, `send(key, name, value)`, `cancel(key, reason?)`, `get(key)`, `serve({ batch, interval, onError, signal }?)`, `stop()` and `running`, with `step`, `sleep` and `wait(name, { timeout, until })` in `fn(input, context)` |
102
103
  | `memory()`, `sqlite(db, table?)`, `dueAt(run)` | From `async-lube/durable`: the stores, and when a run is due, for a store of your own |
103
104
  | `offload(fn, { concurrency })` | A function that runs `fn` in a worker, with `close()`. Its calls have `cancel()`, and an `AbortSignal` as the last argument cancels them |
105
+ | `offload(url, { data, delay, onBatch })` | A stateful worker that loads the module at `url`: `{ ready, call(request), close() }`. The requests of one tick travel in one message and run in one batch of the module's `batch(run)`, and a value it returns reaches `onBatch` before the calls settle |
package/docs/tasks.md CHANGED
@@ -2,7 +2,7 @@
2
2
  `attempt`, `limiter` and `offload` give any async work, on a page or a server, a retry and a timeout, a limit shared by everything that uses a database or an API quota, and a worker for heavy functions.
3
3
 
4
4
  ```ts
5
- import { attempt, limiter, offload } from "async-lube"
5
+ import { attempt, limiter, offload, redial } from "async-lube"
6
6
 
7
7
  // a database query with a timeout for each attempt, retried twice
8
8
  const rows = await attempt(({ signal }) => db.query(sql, { signal }), { retry: 2, timeout: 5000 })
@@ -18,6 +18,21 @@ await jobs.key(user.id)(() => export_data(user))
18
18
  const lease = await pool.acquire(signal)
19
19
  try { await copy_tables() } finally { lease.release() }
20
20
 
21
+ // a packet limit per socket, answered at once and never waiting
22
+ const packets = limiter({ rate: { count: 20, per: 1000, window: "fixed" } })
23
+ const retry_after = packets.take(socket.id) // 0, or the milliseconds until a start is free
24
+
25
+ // one WebSocket kept up, reconnecting after each close with a capped backoff
26
+ const stop = new AbortController()
27
+ const stopped = redial(({ attempt, signal, open, close }) => {
28
+ const socket = new WebSocket(urls[(attempt - 1) % urls.length])
29
+ socket.onopen = open
30
+ socket.onclose = close
31
+ signal.addEventListener("abort", () => socket.close(), { once: true })
32
+ }, { signal: stop.signal, base: 1000, most: 30000 })
33
+ stop.abort()
34
+ await stopped
35
+
21
36
  // heavy work in a worker, so that the page or the server keeps answering
22
37
  const resize = offload(async (bytes: ArrayBuffer, width: number) => {
23
38
  // only its arguments and globals: this runs in the worker
@@ -25,8 +40,10 @@ const resize = offload(async (bytes: ArrayBuffer, width: number) => {
25
40
  const thumbnail = await resize(bytes, 200, signal) // the signal terminates the worker
26
41
  ```
27
42
  - `attempt` gives the function `{ attempt, signal }`, and the signal aborts on a timeout or the `signal` option, so pass it on. `retry` takes a count or `{ count, delay, when }`, as a flow node does, where a `delay` function that returns undefined stops retrying, and a `signal` that aborts rejects with `CancelError`, also while it waits for the next attempt.
28
- - `limiter` starts the calls in order when they are within its limits. A signal as the second argument takes a call out of the line while it waits, and `pending` and `running` count the calls. `acquire()` holds a slot until `release()`, and `key(id)` is a limiter with the same limits for each key, dropped when it has nothing to do. Like `durable`, it throws a TypeError for an option it does not know, e.g. `concurency`, and `attempt` rejects with one.
43
+ - `limiter` starts the calls in order when they are within its limits. A signal as the second argument takes a call out of the line while it waits, and `pending` and `running` count the calls. `acquire()` holds a slot until `release()`, and `key(id)` is a limiter with the same limits for each key, dropped when it has nothing to do. `take(id?)` answers at once, without waiting: `0` when a start is free in the rate window, which it counts, otherwise the milliseconds until the next start is accepted, which it counts nothing for. It ignores `concurrency` and the queue but shares the window with the calls and `acquire()`, so a queued call waits for it, and it throws a TypeError without a `rate`. The window is `"sliding"` by default, where each start frees its slot `per` milliseconds later, or `"fixed"`, where a window opens at the first start after the last one ended and frees all `count` starts at once when it ends. Like `durable`, it throws a TypeError for an option it does not know, e.g. `concurency`, and `attempt` rejects with one.
29
44
  - `offload` sends the function as its source, so it may use only its arguments and globals: an arrow function or a function expression, not a method or a closure. The arguments and the result are copied as `postMessage` copies them, `concurrency` workers run at once, 1 by default, and `close()` terminates them. An `AbortSignal` as the last argument or `cancel()` of the call terminates its worker. In Node.js it needs 20.16 or later, and idle workers do not keep the process running.
45
+ - `offload(url, { data, delay, onBatch })` loads a module file, a `URL` or a URL string, in one stateful worker, for work that keeps a state, e.g. a database. The module default-exports a setup function that gets `data` and returns `{ call, batch, close }`, possibly after an async setup, and `ready` resolves when it has finished or rejects with its error, which every call rejects with too. `call(request)` returns the result of the module's `call`, matched by id, and the calls made in the same tick travel in one message. The worker gathers the requests that arrive within `delay` milliseconds (0, the next macrotask, by default) into one batch and never runs two at once: `batch(run)` wraps a batch, where `run()` calls `call` for every request in order and its promise settles when every result has settled, so `db.exec("BEGIN"); run(); db.exec("COMMIT")` works for sync calls and `await run()` for async ones. If `batch` throws or rejects, every call of the batch rejects with that error. A value it returns reaches `onBatch` in the calling thread, and the calls of the batch settle only after `onBatch` resolves (in order, and they reject with its error if it rejects), e.g. to ship a changeset before the replies. `close()` lets the calls already sent finish, runs the module's `close`, ends the thread and resolves after it exited; calls made after it reject with `CancelError`. A worker that crashed rejects its calls and every later one, and is not started again. Type the default export with `OffloadSetup<Data, Request, Result>`.
46
+ - `redial(connect, { signal, base, most })` keeps one connection up, e.g. a WebSocket: it calls `connect({ attempt, signal, open, close })` at once and again after each `close()`, until `signal` aborts. The connector calls `open()` when the connection is up, which resets the wait, and `close()` when it ended. The wait before the next `connect` is `base` milliseconds (1000 by default) after a connection that opened, and doubles up to `most` (30000 by default) after each one that closed without opening. `attempt` counts every call from 1, so it can rotate addresses, and a `connect` that throws or rejects counts as a close. When `signal` aborts, the signal of the connection in hand aborts with the same reason, so close the connection on it, and the result resolves once that connection has closed.
30
47
  - Files, database cursors and `readline` are async iterables, so they are streams: `for await (const line of readline.createInterface({ input: createReadStream(path) }))`, or a flow dependency that `overflow: "wait"` slows down.
31
48
 
32
49
  The signatures are in the [API tables](https://github.com/artxe/lube-series/blob/master/packages/async-lube/docs/reference.md#tasks).
package/package.json CHANGED
@@ -4,9 +4,6 @@
4
4
  "url": "https://github.com/artxe/lube-series/issues"
5
5
  },
6
6
  "description": "Syntax sugar for fetch requests, streams, async flows and durable runs",
7
- "devDependencies": {
8
- "@typescript/native": "npm:typescript@^7.0.2"
9
- },
10
7
  "engines": {
11
8
  "node": ">=18"
12
9
  },
@@ -35,6 +32,7 @@
35
32
  "types/index.*",
36
33
  "types/limiter.*",
37
34
  "types/offload.*",
35
+ "types/redial.*",
38
36
  "types/stream.*"
39
37
  ],
40
38
  "homepage": "https://github.com/artxe/lube-series/tree/master/packages/async-lube#readme",
@@ -50,6 +48,7 @@
50
48
  "http",
51
49
  "lube",
52
50
  "rate limit",
51
+ "reconnect",
53
52
  "retry",
54
53
  "server-sent events",
55
54
  "stream",
@@ -78,10 +77,10 @@
78
77
  ]
79
78
  }
80
79
  },
81
- "version": "2.0.0",
80
+ "version": "2.1.0",
82
81
  "scripts": {
83
82
  "dts": "node ../../scripts/tsc_with_clean.js async-lube",
84
83
  "lint": "pnpm -w lint",
85
- "test": "pnpm -w exec vitest run --coverage test/async-lube && pnpm -w exec vitest run --config vitest.browser.config.js"
84
+ "test": "pnpm -w exec vitest run test/async-lube test/browser"
86
85
  }
87
86
  }
package/public.d.ts CHANGED
@@ -39,6 +39,25 @@ export type AttemptOptions = {
39
39
  /** Milliseconds for each attempt, which aborts its `signal` and fails with TimeoutError. */
40
40
  timeout?: number | undefined
41
41
  }
42
+ export interface RedialOptions {
43
+ /** Milliseconds to wait before connecting again after a connection that opened, and after the first one that failed. Default: 1000. */
44
+ base?: number | undefined
45
+ /** Most milliseconds to wait: the wait doubles after each connection that closed without opening, up to this. Not less than `base`. Default: 30000. */
46
+ most?: number | undefined
47
+ /** Cancels the waiting and the connection in hand, and stops reconnecting. Required. */
48
+ signal: AbortSignal
49
+ }
50
+ /** What `redial` gives `connect` for one connection. */
51
+ export interface RedialContext {
52
+ /** Counts every call of `connect` from 1, e.g. to rotate addresses. */
53
+ attempt: number
54
+ /** Ends this connection, so `redial` connects again after the wait. Only the first call counts. */
55
+ close(): void
56
+ /** Marks this connection as up and resets the wait. Calling it again, or after the connection closed, does nothing. */
57
+ open(): void
58
+ /** Aborts, with the reason of the signal of `redial`, when that signal aborts while this connection is in hand. */
59
+ signal: AbortSignal
60
+ }
42
61
  /** A slot of a limiter, held until `release()`, e.g. by a transaction across several flow nodes. */
43
62
  export type Lease = {
44
63
  /** Gives the slot back. Calling it again does nothing. */
@@ -59,6 +78,19 @@ export interface Limiter {
59
78
  readonly pending: number
60
79
  /** The calls that run. */
61
80
  readonly running: number
81
+ /**
82
+ * Answers at once, without waiting, whether a start is free in the rate window: `0` and the start is counted,
83
+ * or the milliseconds until the next start is accepted, and nothing is counted.
84
+ * It ignores `concurrency` and the queue, and counts against the same window as the calls and `acquire()`, so a queued call waits for it.
85
+ * With a key it uses the state of that key, as `key(key)` would, without making a limiter. Throws a TypeError without a `rate`.
86
+ * @example
87
+ * const packets = limiter({ rate: { count: 20, per: 1000, window: "fixed" } })
88
+ * socket.on("message", () => {
89
+ * const retry_after = packets.take(socket.id)
90
+ * if (retry_after > 0) return socket.send(`slow down, retry in ${retry_after} ms`)
91
+ * })
92
+ */
93
+ take(key?: unknown): number
62
94
  }
63
95
  /** A call of an `offload()` function: its result, with `cancel()` to stop it. */
64
96
  export type OffloadCall<T> = Promise<T> & { cancel(reason?: unknown): void }
@@ -69,6 +101,86 @@ export interface Offloaded<A extends unknown[], R> {
69
101
  /** Terminates the workers and cancels the calls. A later call starts a worker again. */
70
102
  close(): void
71
103
  }
104
+ /** What a worker module returns from its setup function, see `offload(url)`. */
105
+ export interface OffloadHandlers<Q, R, B = unknown> {
106
+ /**
107
+ * Wraps one batch of requests, e.g. in a transaction. `run()` calls `call` for every request of the batch, in order, before it returns,
108
+ * and its promise settles when every result has settled. If `batch` throws or rejects, every call of the batch rejects with that error.
109
+ * A value other than `undefined` that it returns travels with the replies to the `onBatch` option of the caller.
110
+ */
111
+ batch?(run: () => Promise<void>): B | undefined | Promise<B | undefined>
112
+ /** Runs one request and returns its result or a promise of it. A throw or a rejection rejects that call only. */
113
+ call(request: Q): R | Promise<R>
114
+ /** Runs when the module is closed, after the last batch. */
115
+ close?(): void | Promise<void>
116
+ }
117
+ /**
118
+ * The default export of a worker module for `offload(url)`: it gets the `data` option and returns the handlers, possibly after an async setup.
119
+ * @example
120
+ * const setup: OffloadSetup<string, number, Row> = path => {
121
+ * const db = open(path)
122
+ * return { call: id => db.get(id), batch: async run => { db.exec("BEGIN"); await run(); db.exec("COMMIT") } }
123
+ * }
124
+ * export default setup
125
+ */
126
+ export type OffloadSetup<D, Q, R, B = unknown> = (data: D) => OffloadHandlers<Q, R, B> | Promise<OffloadHandlers<Q, R, B>>
127
+ export interface OffloadModuleOptions<D = unknown, B = unknown> {
128
+ /** Copied as `postMessage` copies it, and passed to the setup function of the module. */
129
+ data?: D | undefined
130
+ /** Milliseconds the worker gathers requests before it runs a batch, 0 by default: until the next macrotask. */
131
+ delay?: number | undefined
132
+ /** Gets the value that `batch` returned, in the thread that called `offload`. The calls of the batch settle after it resolves, and reject with its error if it rejects. */
133
+ onBatch?: ((value: B) => unknown) | undefined
134
+ }
135
+ /** A worker that loaded a module, see `offload(url)`. */
136
+ export interface OffloadModule<Q, R> {
137
+ /** Sends the request to the worker. Requests made in the same tick travel in one message. Rejects with the error of the module or of a crashed worker. */
138
+ call(request: Q): Promise<R>
139
+ /**
140
+ * Lets the calls already sent finish, runs the `close` of the module, then ends the thread. Calls made afterwards reject with a `CancelError`.
141
+ * Rejects with the error of the `close` of the module, but the thread ends anyway. Returns the same promise every time.
142
+ */
143
+ close(): Promise<void>
144
+ /** Resolves when the setup of the module has finished, and rejects with its error, which every call rejects with too. */
145
+ readonly ready: Promise<void>
146
+ }
147
+ export interface OffloadFunction {
148
+ /**
149
+ * Runs a function in a Worker, or a worker thread in Node.js 20.16 or later, so that heavy work does not block the page or the server.
150
+ * The function travels as its source, so it may use only its arguments and globals: an arrow function or a function expression,
151
+ * not a method or a closure. Arguments and results are copied as `postMessage` copies them. `concurrency` workers run at once, 1 by default.
152
+ * @example
153
+ * const resize = offload(async (bytes: ArrayBuffer, width: number) => shrink(bytes, width))
154
+ * const thumbnail = await resize(bytes, 200, signal) // the signal terminates the worker
155
+ */
156
+ <A extends unknown[], R>(fn: (...args: A) => R, options?: { concurrency?: number | undefined }): Offloaded<A, R>
157
+ /**
158
+ * Loads a module file in one stateful worker, for work that keeps a state, e.g. a database. The module default-exports a setup function
159
+ * that gets `data` and returns `{ call, batch?, close? }`, see `OffloadSetup`. Replies are matched by id, calls made in the same tick
160
+ * travel in one message, and the worker runs the requests that arrived together as one batch, never two at once: `batch(run)` can
161
+ * wrap them in a transaction, and a value it returns reaches `onBatch` before the calls of the batch settle. A crashed worker rejects
162
+ * its calls and every later one, and is not started again. Idle, it does not keep a Node.js process running.
163
+ * @example
164
+ * // main thread
165
+ * const store = offload<Request, Result>(new URL("./store.js", import.meta.url), {
166
+ * data: { path: "game.db" },
167
+ * onBatch: changes => socket.send(changes) // the calls settle after this resolves
168
+ * })
169
+ * await store.ready
170
+ * const [ a, b ] = await Promise.all([ store.call({ get: 1 }), store.call({ put: 2 }) ]) // one batch
171
+ * await store.close()
172
+ * // store.js
173
+ * export default function({ path }) {
174
+ * const db = open(path)
175
+ * return {
176
+ * call: request => run(db, request),
177
+ * batch: async run => { db.exec("BEGIN"); await run(); db.exec("COMMIT"); return db.changes() },
178
+ * close: () => db.close()
179
+ * }
180
+ * }
181
+ */
182
+ <Q, R, D = unknown, B = unknown>(url: string | URL, options?: OffloadModuleOptions<D, B>): OffloadModule<Q, R>
183
+ }
72
184
  export interface ChannelFunction {
73
185
  /**
74
186
  * A stream that values are pushed into, e.g. from a callback API or a form.
package/src/attempt.js CHANGED
@@ -29,9 +29,11 @@ export async function attempt(fn, options = {}) {
29
29
  signal,
30
30
  timeout
31
31
  } = options
32
- if (timeout != null && !(typeof timeout == "number" && timeout > 0)) throw TypeError(
33
- "The timeout of attempt() must be a positive number of milliseconds"
34
- )
32
+ if (timeout != null && !(typeof timeout == "number" && timeout > 0)) {
33
+ throw TypeError(
34
+ "The timeout of attempt() must be a positive number of milliseconds"
35
+ )
36
+ }
35
37
  const retry = typeof retry_option == "number" ? { count: retry_option } : retry_option
36
38
  for (let count = 1; ; count++) {
37
39
  const controller = new AbortController()
@@ -43,7 +45,9 @@ export async function attempt(fn, options = {}) {
43
45
  )
44
46
  : void 0
45
47
  try {
46
- if (controller.signal.aborted) throw controller.signal.reason
48
+ if (controller.signal.aborted) {
49
+ throw controller.signal.reason
50
+ }
47
51
  return /** @type {Awaited<T>} */(await race_signal(
48
52
  Promise.resolve()
49
53
  .then(
@@ -57,12 +61,18 @@ export async function attempt(fn, options = {}) {
57
61
  controller.signal
58
62
  ))/**/
59
63
  } catch (error) {
60
- if (signal?.aborted) throw to_cancel_error(signal.reason)
61
- if (!retry || count > retry.count || retry.when && !await retry.when(error, count)) throw error
64
+ if (signal?.aborted) {
65
+ throw to_cancel_error(signal.reason)
66
+ }
67
+ if (!retry || count > retry.count || retry.when && !await retry.when(error, count)) {
68
+ throw error
69
+ }
62
70
  const delay = typeof retry.delay == "function"
63
71
  ? retry.delay(count, error, 0)
64
72
  : retry.delay ?? 0
65
- if (delay == null) throw error
73
+ if (delay == null) {
74
+ throw error
75
+ }
66
76
  clearTimeout(timer)
67
77
  if (delay > 0) {
68
78
  await sleep(delay, signal)
@@ -20,12 +20,16 @@ import { take_event, unclaim, wake_if_sent } from "./state.js"
20
20
  * @returns {void}
21
21
  */
22
22
  function check_idle(d, execution) {
23
- if (!execution.blocked.length || execution.flights || execution.suspending) return
23
+ if (!execution.blocked.length || execution.flights || execution.suspending) {
24
+ return
25
+ }
24
26
  execution.suspending = true
25
27
  setTimeout(
26
28
  () => {
27
29
  execution.suspending = false
28
- if (!execution.blocked.length || execution.flights || execution.lost || execution.finished) return
30
+ if (!execution.blocked.length || execution.flights || execution.lost || execution.finished) {
31
+ return
32
+ }
29
33
  const wakes = execution.blocked.flatMap(
30
34
  item => item.wake == null ? [] : [ item.wake ]
31
35
  )
@@ -36,7 +40,9 @@ function check_idle(d, execution) {
36
40
  ? "waiting"
37
41
  : "sleeping"
38
42
  unclaim(saved)
39
- if (wakes.length) saved.wake = Math.min(...wakes)
43
+ if (wakes.length) {
44
+ saved.wake = Math.min(...wakes)
45
+ }
40
46
  wake_if_sent(saved)
41
47
  execution.finished = true
42
48
  void persist(d, execution).then(
@@ -49,7 +55,9 @@ function check_idle(d, execution) {
49
55
  return
50
56
  }
51
57
  execution.interrupt(SUSPENDED)
52
- if (saved.status == "running") void execution.gone.then(() => resume(d, execution.key)).catch(noop)
58
+ if (saved.status == "running") {
59
+ void execution.gone.then(() => resume(d, execution.key)).catch(noop)
60
+ }
53
61
  }
54
62
  )
55
63
  },
@@ -68,22 +76,30 @@ export function context_of(d, execution) {
68
76
  key,
69
77
  signal,
70
78
  sleep(name, ms) {
71
- if (!(Number.isFinite(ms) && ms >= 0)) return Promise.reject(
72
- TypeError(
73
- "The ms of sleep() must be a non-negative number of milliseconds"
79
+ if (!(Number.isFinite(ms) && ms >= 0)) {
80
+ return Promise.reject(
81
+ TypeError(
82
+ "The ms of sleep() must be a non-negative number of milliseconds"
83
+ )
74
84
  )
75
- )
85
+ }
76
86
  const refused = refuse(execution)
77
- if (refused) return refused
87
+ if (refused) {
88
+ return refused
89
+ }
78
90
  const entry = entry_of(execution, name, "sleep")
79
- if (entry.done) return Promise.resolve()
91
+ if (entry.done) {
92
+ return Promise.resolve()
93
+ }
80
94
  if (entry.until == null) {
81
95
  entry.until = Date.now() + ms
82
96
  void persist(d, execution)
83
97
  }
84
98
  const until = entry.until
85
99
  const left = until - Date.now()
86
- if (left > d.idle_ms) return suspend(d, execution, until, "sleeping")
100
+ if (left > d.idle_ms) {
101
+ return suspend(d, execution, until, "sleeping")
102
+ }
87
103
  execution.flights++
88
104
  return sleep_until(until, signal)
89
105
  .then(
@@ -101,7 +117,9 @@ export function context_of(d, execution) {
101
117
  },
102
118
  step(name, work) {
103
119
  const refused = refuse(execution)
104
- if (refused) return refused
120
+ if (refused) {
121
+ return refused
122
+ }
105
123
  const index = execution.cursor
106
124
  const entry = entry_of(execution, name, "step")
107
125
  if (entry.done) {
@@ -116,17 +134,6 @@ export function context_of(d, execution) {
116
134
  )
117
135
  }
118
136
  execution.flights++
119
- /**
120
- * @param {unknown} error
121
- * @returns {Promise<never>}
122
- */
123
- function failed(error) {
124
- if (signal.aborted || execution.lost || execution.finished) throw error
125
- entry.done = true
126
- entry.error = saved_error(error)
127
- void persist(d, execution)
128
- return fail(execution, entry, error)
129
- }
130
137
  return Promise.resolve()
131
138
  .then(
132
139
  () => refuse(execution) ?? work(
@@ -139,11 +146,13 @@ export function context_of(d, execution) {
139
146
  .then(
140
147
  value => {
141
148
  const problem = json_problem(value)
142
- if (problem) return failed(
143
- TypeError(
144
- `The result of step "${name}" of run "${key}" is not a JSON value: ${problem}`
149
+ if (problem) {
150
+ return failed(
151
+ TypeError(
152
+ `The result of step "${name}" of run "${key}" is not a JSON value: ${problem}`
153
+ )
145
154
  )
146
- )
155
+ }
147
156
  entry.done = true
148
157
  entry.value = copy(value)
149
158
  void persist(d, execution)
@@ -157,6 +166,19 @@ export function context_of(d, execution) {
157
166
  check_idle(d, execution)
158
167
  }
159
168
  )
169
+ /**
170
+ * @param {unknown} error
171
+ * @returns {Promise<never>}
172
+ */
173
+ function failed(error) {
174
+ if (signal.aborted || execution.lost || execution.finished) {
175
+ throw error
176
+ }
177
+ entry.done = true
178
+ entry.error = saved_error(error)
179
+ void persist(d, execution)
180
+ return fail(execution, entry, error)
181
+ }
160
182
  },
161
183
  wait(name, wait_options = {}) {
162
184
  try {
@@ -166,17 +188,23 @@ export function context_of(d, execution) {
166
188
  "wait"
167
189
  )
168
190
  const { timeout, until } = wait_options
169
- if (!(timeout == null || Number.isFinite(timeout) && timeout >= 0)) throw TypeError(
170
- "The timeout of wait() must be a non-negative number of milliseconds"
171
- )
172
- if (!(until == null || Math.abs(until) <= 8640000000000000)) throw TypeError(
173
- "The until of wait() must be a time in milliseconds since the epoch"
174
- )
191
+ if (!(timeout == null || Number.isFinite(timeout) && timeout >= 0)) {
192
+ throw TypeError(
193
+ "The timeout of wait() must be a non-negative number of milliseconds"
194
+ )
195
+ }
196
+ if (!(until == null || Math.abs(until) <= 8640000000000000)) {
197
+ throw TypeError(
198
+ "The until of wait() must be a time in milliseconds since the epoch"
199
+ )
200
+ }
175
201
  } catch (error) {
176
202
  return Promise.reject(error)
177
203
  }
178
204
  const refused = refuse(execution)
179
- if (refused) return refused
205
+ if (refused) {
206
+ return refused
207
+ }
180
208
  const entry = entry_of(execution, name, "wait")
181
209
  const { timeout } = wait_options
182
210
  if (entry.done) {
@@ -192,11 +220,15 @@ export function context_of(d, execution) {
192
220
  }
193
221
  const fresh = entry.until == null
194
222
  if (fresh) {
195
- if (timeout != null) entry.until = Date.now() + timeout
196
- if (wait_options.until != null) entry.until = Math.min(
197
- entry.until ?? Infinity,
198
- wait_options.until
199
- )
223
+ if (timeout != null) {
224
+ entry.until = Date.now() + timeout
225
+ }
226
+ if (wait_options.until != null) {
227
+ entry.until = Math.min(
228
+ entry.until ?? Infinity,
229
+ wait_options.until
230
+ )
231
+ }
200
232
  }
201
233
  const event = execution.saved.events?.[name]?.[0]
202
234
  if (event && !execution.unsaved.has(event) && (entry.until == null || event.at <= entry.until)) {
@@ -208,7 +240,9 @@ export function context_of(d, execution) {
208
240
  /** @type {never} */(copy(event.value))/**/
209
241
  )
210
242
  }
211
- if (fresh && entry.until != null) void persist(d, execution)
243
+ if (fresh && entry.until != null) {
244
+ void persist(d, execution)
245
+ }
212
246
  const until = entry.until
213
247
  return new Promise(
214
248
  (resolve, reject) => {
@@ -221,22 +255,42 @@ export function context_of(d, execution) {
221
255
  resolve: /** @type {(value: unknown) => void} */(resolve)/**/
222
256
  }
223
257
  const list = execution.waiters.get(name) ?? []
224
- if (!list.length) execution.waiters.set(name, list)
258
+ if (!list.length) {
259
+ execution.waiters.set(name, list)
260
+ }
225
261
  list.push(waiter)
226
262
  watch_inbox(d, execution)
227
263
  /** @type {ReturnType<typeof setTimeout>} */
228
264
  let timer
229
- /**
230
- * @returns {void}
231
- */
265
+ if (until != null && until <= Date.now()) {
266
+ expire()
267
+ } else {
268
+ timer = setTimeout(
269
+ expire,
270
+ clamp_delay(
271
+ Math.min(
272
+ d.idle_ms,
273
+ until == null ? Infinity : until - Date.now()
274
+ )
275
+ )
276
+ )
277
+ }
278
+ waiter.clear = () => clearTimeout(timer)
279
+ /** @returns {void} */
232
280
  function expire() {
233
- if (execution.finished || execution.lost || !list.includes(waiter)) return
281
+ if (execution.finished || execution.lost || !list.includes(waiter)) {
282
+ return
283
+ }
234
284
  if (until != null && until <= Date.now()) {
235
285
  void inbox_read(d, execution).then(
236
286
  read => {
237
- if (execution.finished || execution.lost) return
287
+ if (execution.finished || execution.lost) {
288
+ return
289
+ }
238
290
  const index = list.indexOf(waiter)
239
- if (index < 0) return
291
+ if (index < 0) {
292
+ return
293
+ }
240
294
  if (!read) {
241
295
  timer = setTimeout(expire, d.poll_ms)
242
296
  return
@@ -251,10 +305,12 @@ export function context_of(d, execution) {
251
305
  execution.blocked.push(waiter.block)
252
306
  check_idle(d, execution)
253
307
  }
254
- if (until != null) timer = setTimeout(
255
- expire,
256
- clamp_delay(until - Date.now())
257
- )
308
+ if (until != null) {
309
+ timer = setTimeout(
310
+ expire,
311
+ clamp_delay(until - Date.now())
312
+ )
313
+ }
258
314
  }
259
315
  /**
260
316
  * @param {number} index
@@ -273,19 +329,6 @@ export function context_of(d, execution) {
273
329
  )
274
330
  reject(error)
275
331
  }
276
- if (until != null && until <= Date.now()) expire()
277
- else {
278
- timer = setTimeout(
279
- expire,
280
- clamp_delay(
281
- Math.min(
282
- d.idle_ms,
283
- until == null ? Infinity : until - Date.now()
284
- )
285
- )
286
- )
287
- }
288
- waiter.clear = () => clearTimeout(timer)
289
332
  }
290
333
  )
291
334
  }