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.
- package/CHANGELOG.md +34 -0
- package/README.md +1 -1
- package/docs/http.md +1 -1
- package/docs/reference.md +4 -2
- package/docs/tasks.md +19 -2
- package/package.json +4 -5
- package/public.d.ts +112 -0
- package/src/attempt.js +17 -7
- package/src/durable/context.js +107 -64
- package/src/durable/error.js +39 -15
- package/src/durable/index.js +18 -10
- package/src/durable/journal.js +29 -14
- package/src/durable/json.js +26 -9
- package/src/durable/lease.js +15 -5
- package/src/durable/persist.js +60 -22
- package/src/durable/run.js +103 -41
- package/src/durable/send.js +30 -13
- package/src/durable/serve.js +30 -24
- package/src/durable/state.js +28 -13
- package/src/durable/store.js +19 -10
- package/src/durable/watch.js +31 -11
- package/src/errors.js +9 -3
- package/src/flow/attempts.js +49 -29
- package/src/flow/builder.js +260 -161
- package/src/flow/compile.js +86 -66
- package/src/flow/graph.js +108 -42
- package/src/flow/index.js +13 -7
- package/src/flow/items.js +79 -46
- package/src/flow/nodes.js +267 -117
- package/src/flow/refs.js +27 -13
- package/src/flow/resources.js +9 -3
- package/src/flow/run.js +287 -114
- package/src/flow/streams.js +44 -19
- package/src/flow/subflows.js +93 -53
- package/src/http/config.js +11 -5
- package/src/http/index.js +25 -14
- package/src/http/refresh.js +29 -12
- package/src/http/request.js +246 -151
- package/src/http/response.js +105 -50
- package/src/http/retry.js +52 -27
- package/src/http/socket.js +220 -172
- package/src/http/sse.js +113 -74
- package/src/http/throttle.js +9 -4
- package/src/http/url.js +58 -35
- package/src/http/xhr.js +29 -24
- package/src/index.js +1 -0
- package/src/key.js +3 -5
- package/src/limiter.js +146 -65
- package/src/offload.js +116 -85
- package/src/offload_module.js +594 -0
- package/src/options.js +8 -4
- package/src/redial.js +129 -0
- package/src/signal.js +14 -4
- package/src/stream.js +353 -198
- package/types/attempt.d.ts.map +1 -1
- package/types/durable/index.d.ts.map +1 -1
- package/types/durable/state.d.ts.map +1 -1
- package/types/durable/store.d.ts.map +1 -1
- package/types/errors.d.ts.map +1 -1
- package/types/http/index.d.ts.map +1 -1
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
- package/types/limiter.d.ts +5 -1
- package/types/limiter.d.ts.map +1 -1
- package/types/offload.d.ts +2 -16
- package/types/offload.d.ts.map +1 -1
- package/types/redial.d.ts +28 -0
- package/types/redial.d.ts.map +1 -0
- package/types/stream.d.ts +2 -0
- 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
|
|
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
|
-
| `
|
|
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.
|
|
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
|
|
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))
|
|
33
|
-
|
|
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)
|
|
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)
|
|
61
|
-
|
|
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)
|
|
73
|
+
if (delay == null) {
|
|
74
|
+
throw error
|
|
75
|
+
}
|
|
66
76
|
clearTimeout(timer)
|
|
67
77
|
if (delay > 0) {
|
|
68
78
|
await sleep(delay, signal)
|
package/src/durable/context.js
CHANGED
|
@@ -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)
|
|
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)
|
|
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)
|
|
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")
|
|
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))
|
|
72
|
-
|
|
73
|
-
|
|
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)
|
|
87
|
+
if (refused) {
|
|
88
|
+
return refused
|
|
89
|
+
}
|
|
78
90
|
const entry = entry_of(execution, name, "sleep")
|
|
79
|
-
if (entry.done)
|
|
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)
|
|
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)
|
|
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)
|
|
143
|
-
|
|
144
|
-
|
|
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))
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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)
|
|
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)
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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))
|
|
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)
|
|
287
|
+
if (execution.finished || execution.lost) {
|
|
288
|
+
return
|
|
289
|
+
}
|
|
238
290
|
const index = list.indexOf(waiter)
|
|
239
|
-
if (index < 0)
|
|
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)
|
|
255
|
-
|
|
256
|
-
|
|
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
|
}
|