@evolu/common 8.0.0-next.4 → 8.0.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/dist/src/Array.d.ts +279 -173
- package/dist/src/Array.d.ts.map +1 -1
- package/dist/src/Array.js +105 -101
- package/dist/src/Assert.d.ts +44 -44
- package/dist/src/Assert.d.ts.map +1 -1
- package/dist/src/Assert.js +44 -32
- package/dist/src/BigInt.d.ts +6 -3
- package/dist/src/BigInt.d.ts.map +1 -1
- package/dist/src/BigInt.js +5 -2
- package/dist/src/Brand.d.ts +27 -40
- package/dist/src/Brand.d.ts.map +1 -1
- package/dist/src/Buffer.d.ts +27 -11
- package/dist/src/Buffer.d.ts.map +1 -1
- package/dist/src/Cache.d.ts +9 -4
- package/dist/src/Cache.d.ts.map +1 -1
- package/dist/src/Cache.js +8 -3
- package/dist/src/Callbacks.d.ts +30 -19
- package/dist/src/Callbacks.d.ts.map +1 -1
- package/dist/src/Console.d.ts +145 -66
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +91 -43
- package/dist/src/Crypto.d.ts +85 -42
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +44 -20
- package/dist/src/Eq.d.ts +87 -21
- package/dist/src/Eq.d.ts.map +1 -1
- package/dist/src/Eq.js +67 -19
- package/dist/src/Error.d.ts +5 -3
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +16 -0
- package/dist/src/Function.d.ts +119 -58
- package/dist/src/Function.d.ts.map +1 -1
- package/dist/src/Function.js +86 -43
- package/dist/src/Http.d.ts +394 -0
- package/dist/src/Http.d.ts.map +1 -0
- package/dist/src/Http.js +102 -0
- package/dist/src/Identicon.d.ts +16 -7
- package/dist/src/Identicon.d.ts.map +1 -1
- package/dist/src/Identicon.js +14 -5
- package/dist/src/LeakDetector.d.ts +88 -0
- package/dist/src/LeakDetector.d.ts.map +1 -0
- package/dist/src/LeakDetector.js +86 -0
- package/dist/src/LockManager.d.ts +18 -9
- package/dist/src/LockManager.d.ts.map +1 -1
- package/dist/src/LockManager.js +7 -8
- package/dist/src/Lookup.d.ts +43 -13
- package/dist/src/Lookup.d.ts.map +1 -1
- package/dist/src/Lookup.js +28 -8
- package/dist/src/Microtask.d.ts +1 -1
- package/dist/src/Microtask.d.ts.map +1 -1
- package/dist/src/Microtask.js +3 -7
- package/dist/src/Number.d.ts +49 -13
- package/dist/src/Number.d.ts.map +1 -1
- package/dist/src/Number.js +34 -5
- package/dist/src/Object.d.ts +160 -40
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +92 -46
- package/dist/src/Option.d.ts +10 -7
- package/dist/src/Option.d.ts.map +1 -1
- package/dist/src/Option.js +9 -6
- package/dist/src/Order.d.ts +21 -21
- package/dist/src/Order.d.ts.map +1 -1
- package/dist/src/Order.js +18 -21
- package/dist/src/Platform.d.ts +31 -0
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +98 -0
- package/dist/src/Random.d.ts +32 -17
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Random.js +12 -8
- package/dist/src/Redacted.d.ts +43 -29
- package/dist/src/Redacted.d.ts.map +1 -1
- package/dist/src/Redacted.js +14 -3
- package/dist/src/Ref.d.ts +11 -3
- package/dist/src/Ref.d.ts.map +1 -1
- package/dist/src/Ref.js +2 -2
- package/dist/src/RefCount.d.ts +2 -6
- package/dist/src/RefCount.d.ts.map +1 -1
- package/dist/src/RefCount.js +33 -90
- package/dist/src/Relation.d.ts +70 -5
- package/dist/src/Relation.d.ts.map +1 -1
- package/dist/src/Relation.js +77 -2
- package/dist/src/Resource.d.ts +590 -155
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +592 -343
- package/dist/src/Result.d.ts +838 -191
- package/dist/src/Result.d.ts.map +1 -1
- package/dist/src/Result.js +163 -102
- package/dist/src/Schedule.d.ts +684 -231
- package/dist/src/Schedule.d.ts.map +1 -1
- package/dist/src/Schedule.js +766 -295
- package/dist/src/Set.d.ts +89 -40
- package/dist/src/Set.d.ts.map +1 -1
- package/dist/src/Set.js +50 -29
- package/dist/src/Sqlite.d.ts +29 -72
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +41 -46
- package/dist/src/StackTrace.d.ts +40 -0
- package/dist/src/StackTrace.d.ts.map +1 -0
- package/dist/src/StackTrace.js +140 -0
- package/dist/src/Store.d.ts +5 -4
- package/dist/src/Store.d.ts.map +1 -1
- package/dist/src/Store.js +5 -4
- package/dist/src/String.d.ts +7 -1
- package/dist/src/String.d.ts.map +1 -1
- package/dist/src/String.js +20 -7
- package/dist/src/Task.d.ts +3572 -1770
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +1879 -1274
- package/dist/src/Test.d.ts +17 -125
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +17 -68
- package/dist/src/Time.d.ts +108 -49
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +172 -73
- package/dist/src/Type.d.ts +3542 -1926
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +3070 -1784
- package/dist/src/Types.d.ts +200 -108
- package/dist/src/Types.d.ts.map +1 -1
- package/dist/src/Types.js +39 -8
- package/dist/src/WebSocket.d.ts +48 -23
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +15 -6
- package/dist/src/Worker.d.ts +94 -36
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +58 -24
- package/dist/src/index.d.ts +55 -52
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +3 -1
- package/dist/src/intl/cs.d.ts +94 -0
- package/dist/src/intl/cs.d.ts.map +1 -0
- package/dist/src/intl/cs.js +220 -0
- package/dist/src/intl/index.d.ts +7 -0
- package/dist/src/intl/index.d.ts.map +1 -0
- package/dist/src/intl/index.js +6 -0
- package/dist/src/local-first/Db.d.ts +12 -8
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +109 -132
- package/dist/src/local-first/Error.d.ts +4 -4
- package/dist/src/local-first/Evolu.d.ts +338 -92
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +47 -55
- package/dist/src/local-first/LocalAuth.d.ts +4 -4
- package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
- package/dist/src/local-first/Owner.d.ts +61 -24
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +45 -8
- package/dist/src/local-first/Protocol.d.ts +44 -31
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +36 -13
- package/dist/src/local-first/Query.d.ts +147 -62
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Query.js +112 -58
- package/dist/src/local-first/Relay.d.ts +44 -14
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Schema.d.ts +92 -41
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +25 -8
- package/dist/src/local-first/Shared.d.ts +23 -21
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +100 -57
- package/dist/src/local-first/Storage.d.ts +53 -32
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +194 -234
- package/dist/src/local-first/Timestamp.d.ts +21 -21
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +2 -2
- package/dist/src/local-first/index.d.ts +11 -11
- package/package.json +14 -13
- package/src/Array.ts +306 -205
- package/src/Assert.ts +54 -69
- package/src/BigInt.ts +6 -3
- package/src/Brand.ts +27 -40
- package/src/Buffer.ts +28 -12
- package/src/Cache.ts +9 -4
- package/src/Callbacks.ts +30 -19
- package/src/Console.ts +156 -84
- package/src/Crypto.ts +83 -41
- package/src/Eq.ts +95 -22
- package/src/Error.ts +8 -4
- package/src/Function.ts +167 -59
- package/src/Http.ts +549 -0
- package/src/Identicon.ts +16 -7
- package/src/LeakDetector.ts +182 -0
- package/src/LockManager.ts +43 -39
- package/src/Lookup.ts +62 -28
- package/src/Microtask.ts +19 -23
- package/src/Number.ts +64 -12
- package/src/Object.ts +173 -49
- package/src/Option.ts +10 -7
- package/src/Order.ts +23 -21
- package/src/Platform.ts +136 -0
- package/src/Polyfills.ts +6 -12
- package/src/Random.ts +32 -19
- package/src/Redacted.ts +44 -30
- package/src/Ref.ts +14 -6
- package/src/RefCount.ts +38 -80
- package/src/Relation.ts +202 -10
- package/src/Resource.ts +1199 -591
- package/src/Result.ts +930 -271
- package/src/Schedule.ts +859 -343
- package/src/Set.ts +89 -40
- package/src/Sqlite.ts +144 -138
- package/src/StackTrace.ts +230 -0
- package/src/Store.ts +8 -7
- package/src/String.ts +22 -5
- package/src/Task.ts +5101 -2832
- package/src/Test.ts +20 -168
- package/src/Time.ts +293 -90
- package/src/Type.ts +10034 -4117
- package/src/Types.ts +227 -110
- package/src/WebSocket.ts +64 -36
- package/src/Worker.ts +180 -72
- package/src/index.ts +55 -52
- package/src/intl/cs.ts +388 -0
- package/src/intl/index.ts +7 -0
- package/src/local-first/Db.ts +152 -179
- package/src/local-first/Error.ts +4 -4
- package/src/local-first/Evolu.ts +495 -250
- package/src/local-first/LocalAuth.ts +6 -12
- package/src/local-first/Owner.ts +58 -21
- package/src/local-first/Protocol.ts +49 -31
- package/src/local-first/Query.ts +146 -61
- package/src/local-first/Relay.ts +51 -21
- package/src/local-first/Schema.ts +109 -51
- package/src/local-first/Shared.ts +266 -226
- package/src/local-first/Storage.ts +266 -264
- package/src/local-first/Timestamp.ts +25 -18
- package/src/local-first/index.ts +11 -11
- package/dist/src/Skiplist.d.ts +0 -28
- package/dist/src/Skiplist.d.ts.map +0 -1
- package/dist/src/Skiplist.js +0 -64
- package/dist/src/Tracer.d.ts +0 -48
- package/dist/src/Tracer.d.ts.map +0 -1
- package/dist/src/Tracer.js +0 -6
- package/src/Skiplist.ts +0 -108
- package/src/Tracer.ts +0 -54
package/src/Http.ts
ADDED
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task-aware HTTP helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { err, ok, tryAsync, type Err, type Result } from "./Result.ts";
|
|
8
|
+
import type { AbortError, retry, Task, timeout } from "./Task.ts";
|
|
9
|
+
import type { Typed } from "./Type.ts";
|
|
10
|
+
import type { Awaitable } from "./Types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* WHATWG-fetch-compatible function used by {@link fetch}.
|
|
14
|
+
*
|
|
15
|
+
* A default dependency, replaceable at the composition root — for a
|
|
16
|
+
* platform-optimized fetch such as React Native's, a configured undici
|
|
17
|
+
* instance, or a test double. Implementations must be pre-bound; unbound
|
|
18
|
+
* `globalThis.fetch` throws in browsers.
|
|
19
|
+
*
|
|
20
|
+
* @group Fetch
|
|
21
|
+
*/
|
|
22
|
+
export type NativeFetch = typeof globalThis.fetch;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Dependency wrapper for {@link NativeFetch}.
|
|
26
|
+
*
|
|
27
|
+
* @group Fetch
|
|
28
|
+
*/
|
|
29
|
+
export interface NativeFetchDep {
|
|
30
|
+
readonly nativeFetch: NativeFetch;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Body mode for {@link fetch}.
|
|
35
|
+
*
|
|
36
|
+
* A mode selects a built-in consumer that reads the {@link Response} inside the
|
|
37
|
+
* fetch Task:
|
|
38
|
+
*
|
|
39
|
+
* - `"bytes"` — reads the body as a `Uint8Array<ArrayBuffer>`.
|
|
40
|
+
* - `"headers"` — cancels the body and returns {@link FetchResponse}. Pair it with
|
|
41
|
+
* `{ method: "HEAD" }` when the server should not send a body.
|
|
42
|
+
* - `"json"` — parses the body as JSON and returns `unknown`. Narrow or decode at
|
|
43
|
+
* the call site; there is deliberately no generic to cast through.
|
|
44
|
+
* - `"text"` — reads the body as a string.
|
|
45
|
+
*
|
|
46
|
+
* Body modes (`"bytes"`, `"json"`, `"text"`) return {@link FetchStatusError} for
|
|
47
|
+
* non-2xx responses. `"headers"` does not judge status, because the status is
|
|
48
|
+
* usually the value being asked for.
|
|
49
|
+
*
|
|
50
|
+
* @group Fetch
|
|
51
|
+
*/
|
|
52
|
+
export type FetchMode = "text" | "json" | "bytes" | "headers";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Body-free {@link Response} view safe to return from {@link fetch}.
|
|
56
|
+
*
|
|
57
|
+
* {@link fetch} returns this view only after the native body has been drained,
|
|
58
|
+
* errored, or cancelled, so none of its usable members depends on the request
|
|
59
|
+
* signal. Body-reading members and `Response.clone` are omitted because they
|
|
60
|
+
* would be invalid after the fetch Task settles.
|
|
61
|
+
*
|
|
62
|
+
* This is a TypeScript contract, not a runtime security boundary: the runtime
|
|
63
|
+
* value can still be a native Response, and casts can access omitted members.
|
|
64
|
+
*
|
|
65
|
+
* @group Fetch
|
|
66
|
+
*/
|
|
67
|
+
export type FetchResponse = Omit<
|
|
68
|
+
Response,
|
|
69
|
+
| "arrayBuffer"
|
|
70
|
+
| "blob"
|
|
71
|
+
| "body"
|
|
72
|
+
| "bodyUsed"
|
|
73
|
+
| "bytes"
|
|
74
|
+
| "clone"
|
|
75
|
+
| "formData"
|
|
76
|
+
| "json"
|
|
77
|
+
| "text"
|
|
78
|
+
>;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Error returned by {@link fetch} body modes.
|
|
82
|
+
*
|
|
83
|
+
* The union distinguishes native request failures, non-2xx responses, and body
|
|
84
|
+
* read failures. Narrow on `type` when handling a specific case. The
|
|
85
|
+
* distinction matters for retries: {@link FetchBodyError} means a 2xx response
|
|
86
|
+
* failed during reading, so the server may have committed the request.
|
|
87
|
+
*
|
|
88
|
+
* @group Fetch
|
|
89
|
+
*/
|
|
90
|
+
export type FetchError =
|
|
91
|
+
FetchTransportError | FetchStatusError | FetchBodyError;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Error returned when the native fetch request fails before a response exists.
|
|
95
|
+
*
|
|
96
|
+
* @group Fetch
|
|
97
|
+
*/
|
|
98
|
+
export interface FetchTransportError extends Typed<"FetchTransportError"> {
|
|
99
|
+
readonly error: unknown;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Error returned when a body mode receives a non-2xx response.
|
|
104
|
+
*
|
|
105
|
+
* The response body is drained as text before this error is returned. Draining
|
|
106
|
+
* buffers the whole error body; error responses are expected to be small. If
|
|
107
|
+
* the drain itself fails, `body` contains the read error.
|
|
108
|
+
*
|
|
109
|
+
* @group Fetch
|
|
110
|
+
*/
|
|
111
|
+
export interface FetchStatusError extends Typed<"FetchStatusError"> {
|
|
112
|
+
readonly response: FetchResponse;
|
|
113
|
+
readonly body: Result<string, unknown>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Error returned when a body mode cannot read or decode a 2xx response.
|
|
118
|
+
*
|
|
119
|
+
* This includes mid-stream network failures, decoding failures, and — for the
|
|
120
|
+
* `"json"` mode — empty bodies such as 204 responses, which are not JSON. Do
|
|
121
|
+
* not use `"json"` for endpoints that return no content.
|
|
122
|
+
*
|
|
123
|
+
* @group Fetch
|
|
124
|
+
*/
|
|
125
|
+
export interface FetchBodyError extends Typed<"FetchBodyError"> {
|
|
126
|
+
readonly response: FetchResponse;
|
|
127
|
+
readonly error: unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Consumes a native {@link Response} before {@link fetch} settles.
|
|
132
|
+
*
|
|
133
|
+
* The callback runs inside the fetch Task, while the request signal is still
|
|
134
|
+
* alive. Return a Result error for expected domain failures. Throwing or
|
|
135
|
+
* rejecting with a non-abort error is a defect, like any other Task body.
|
|
136
|
+
*
|
|
137
|
+
* Consumers take values, not dependencies; close over anything they need. A
|
|
138
|
+
* consumer that needs its own timeout, retries, or child Tasks has outgrown
|
|
139
|
+
* being a consumer: fetch the body with a mode and compose Tasks on the plain
|
|
140
|
+
* value, or write a Task that owns the whole request.
|
|
141
|
+
*
|
|
142
|
+
* Consumers do not have to normalize abort. {@link fetch} rethrows Evolu
|
|
143
|
+
* {@link AbortError}, normalizes host abort errors after the Run aborts, and
|
|
144
|
+
* treats an Err returned after abort as abort control flow — so a consumer's
|
|
145
|
+
* `try`/`catch` around a body read cannot accidentally turn an abort into a
|
|
146
|
+
* domain error. An Ok returned after abort is kept; only errors are presumed to
|
|
147
|
+
* be abort in disguise.
|
|
148
|
+
*
|
|
149
|
+
* The Response and anything derived from its live body, such as a reader, must
|
|
150
|
+
* not escape the consumer. Consume to plain values before returning.
|
|
151
|
+
*
|
|
152
|
+
* @group Fetch
|
|
153
|
+
*/
|
|
154
|
+
export type FetchConsume<T, E = never> = (
|
|
155
|
+
response: Response,
|
|
156
|
+
) => Awaitable<Result<T, E>>;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Fetches a resource and consumes the {@link Response} inside the Task, so the
|
|
160
|
+
* body is read while the request signal is still alive.
|
|
161
|
+
*
|
|
162
|
+
* The request runs through {@link NativeFetchDep | run.deps.nativeFetch}.
|
|
163
|
+
* Because native fetch is a default dependency, platforms and tests can replace
|
|
164
|
+
* it without changing call sites.
|
|
165
|
+
*
|
|
166
|
+
* With a {@link FetchMode}, non-2xx responses return {@link FetchStatusError}
|
|
167
|
+
* (except `"headers"`, which reports status as a value) and unreadable bodies
|
|
168
|
+
* return {@link FetchBodyError}. With a {@link FetchConsume} callback, native
|
|
169
|
+
* status semantics apply: HTTP error statuses resolve, and the consumer decides
|
|
170
|
+
* how to interpret the status and body.
|
|
171
|
+
*
|
|
172
|
+
* `signal` is not accepted in init because abort is controlled by the current
|
|
173
|
+
* Run.
|
|
174
|
+
*
|
|
175
|
+
* Aborting the Run aborts the underlying request, any response that arrives
|
|
176
|
+
* after abort, and any in-progress body read. Abort is represented as
|
|
177
|
+
* {@link AbortError}, not FetchError: `run(fetch(...))` rejects with AbortError,
|
|
178
|
+
* and `run.abortable(fetch(...))` returns it as an {@link Err}.
|
|
179
|
+
*
|
|
180
|
+
* Some runtimes reject aborted fetches with their own error instead of
|
|
181
|
+
* `signal.reason`. This wrapper normalizes abort rejections from native fetch,
|
|
182
|
+
* built-in body reads, and consumer callbacks back to the Run's AbortError.
|
|
183
|
+
*
|
|
184
|
+
* `fetch` owns request lifetime and Response containment. It does not transform
|
|
185
|
+
* requests or interpret app protocols beyond the built-in modes. Use Task
|
|
186
|
+
* helpers for resilience, app helpers for app conventions, a replacement
|
|
187
|
+
* {@link NativeFetch} for request-wide behavior (base URLs, auth, logging), and
|
|
188
|
+
* consumers for response interpretation.
|
|
189
|
+
*
|
|
190
|
+
* ### Composing fetch
|
|
191
|
+
*
|
|
192
|
+
* Resilience is ordinary Task composition: wrap `fetch(url, "json")` in
|
|
193
|
+
* {@link timeout}, then in {@link retry}.
|
|
194
|
+
*
|
|
195
|
+
* ```ts
|
|
196
|
+
* import {
|
|
197
|
+
* createRun,
|
|
198
|
+
* exponential,
|
|
199
|
+
* fetch,
|
|
200
|
+
* retry,
|
|
201
|
+
* take,
|
|
202
|
+
* timeout,
|
|
203
|
+
* type NativeFetch,
|
|
204
|
+
* } from "@evolu/common";
|
|
205
|
+
*
|
|
206
|
+
* const fetchWithRetry = (url: string) =>
|
|
207
|
+
* retry(
|
|
208
|
+
* timeout(fetch(url, "json"), "30s"),
|
|
209
|
+
* take(2)(exponential("100ms")),
|
|
210
|
+
* );
|
|
211
|
+
*
|
|
212
|
+
* let requestCount = 0;
|
|
213
|
+
* const nativeFetch: NativeFetch = () => {
|
|
214
|
+
* requestCount++;
|
|
215
|
+
* return Promise.resolve(
|
|
216
|
+
* requestCount === 1
|
|
217
|
+
* ? new Response("Try again", { status: 503 })
|
|
218
|
+
* : new Response('{"name":"Ada"}'),
|
|
219
|
+
* );
|
|
220
|
+
* };
|
|
221
|
+
* await using run = createRun({ nativeFetch });
|
|
222
|
+
*
|
|
223
|
+
* expectOk(await run(fetchWithRetry("/api/user")), { name: "Ada" });
|
|
224
|
+
* ```
|
|
225
|
+
*
|
|
226
|
+
* App conventions belong in small app-owned helpers. For example, posting JSON
|
|
227
|
+
* is native `init` plus two conventions worth centralizing — the content-type
|
|
228
|
+
* header and the stringify:
|
|
229
|
+
*
|
|
230
|
+
* ```ts
|
|
231
|
+
* import {
|
|
232
|
+
* createRun,
|
|
233
|
+
* fetch,
|
|
234
|
+
* type FetchError,
|
|
235
|
+
* type NativeFetch,
|
|
236
|
+
* type Task,
|
|
237
|
+
* } from "@evolu/common";
|
|
238
|
+
*
|
|
239
|
+
* const postJson = (
|
|
240
|
+
* url: string,
|
|
241
|
+
* data: unknown,
|
|
242
|
+
* ): Task<unknown, FetchError> =>
|
|
243
|
+
* fetch(url, "json", {
|
|
244
|
+
* method: "POST",
|
|
245
|
+
* headers: { "content-type": "application/json" },
|
|
246
|
+
* body: JSON.stringify(data),
|
|
247
|
+
* });
|
|
248
|
+
*
|
|
249
|
+
* const nativeFetch: NativeFetch = () =>
|
|
250
|
+
* Promise.resolve(new Response('{"id":"user-1"}'));
|
|
251
|
+
* await using run = createRun({ nativeFetch });
|
|
252
|
+
*
|
|
253
|
+
* expectOk(await run(postJson("/api/users", { name: "Ada" })), {
|
|
254
|
+
* id: "user-1",
|
|
255
|
+
* });
|
|
256
|
+
* ```
|
|
257
|
+
*
|
|
258
|
+
* Your app's version will grow your conventions — auth, envelopes, error
|
|
259
|
+
* mapping — which is why it belongs to the app, not to `fetch`.
|
|
260
|
+
*
|
|
261
|
+
* ### Intercepting requests
|
|
262
|
+
*
|
|
263
|
+
* Request-wide behavior belongs to a replacement {@link NativeFetch} installed
|
|
264
|
+
* at the composition root. This is the equivalent of interceptors or hooks in
|
|
265
|
+
* libraries that expose client instances.
|
|
266
|
+
*
|
|
267
|
+
* ```ts
|
|
268
|
+
* import { createRun, fetch, type NativeFetch } from "@evolu/common";
|
|
269
|
+
*
|
|
270
|
+
* const token = "secret-token";
|
|
271
|
+
* const baseUrl = "https://api.example.com/v1/";
|
|
272
|
+
* let interceptedRequest: Request | undefined;
|
|
273
|
+
* const baseFetch: NativeFetch = (input, init) => {
|
|
274
|
+
* interceptedRequest = new Request(input, init);
|
|
275
|
+
* return Promise.resolve(new Response("ok"));
|
|
276
|
+
* };
|
|
277
|
+
*
|
|
278
|
+
* const nativeFetch: NativeFetch = (input, init) => {
|
|
279
|
+
* const headers = new Headers(init?.headers);
|
|
280
|
+
* headers.set("authorization", `Bearer ${token}`);
|
|
281
|
+
*
|
|
282
|
+
* // Only string inputs are resolved against the base URL; URL and Request
|
|
283
|
+
* // inputs are passed through unchanged.
|
|
284
|
+
* const url =
|
|
285
|
+
* typeof input === "string" ? new URL(input, baseUrl) : input;
|
|
286
|
+
* return baseFetch(url, { ...init, headers });
|
|
287
|
+
* };
|
|
288
|
+
*
|
|
289
|
+
* await using run = createRun({ nativeFetch });
|
|
290
|
+
* expectOk(await run(fetch("users", "text")), "ok");
|
|
291
|
+
* expect({
|
|
292
|
+
* url: interceptedRequest?.url,
|
|
293
|
+
* authorization: interceptedRequest?.headers.get("authorization"),
|
|
294
|
+
* }).toEqual({
|
|
295
|
+
* url: "https://api.example.com/v1/users",
|
|
296
|
+
* authorization: "Bearer secret-token",
|
|
297
|
+
* });
|
|
298
|
+
* ```
|
|
299
|
+
*
|
|
300
|
+
* ### Consuming responses
|
|
301
|
+
*
|
|
302
|
+
* Built-in modes handle common bodies. Specialized response interpretation
|
|
303
|
+
* belongs in a consumer. Typed decoders, response envelopes, streaming, and
|
|
304
|
+
* custom status semantics can be built on top without changing `fetch`.
|
|
305
|
+
*
|
|
306
|
+
* ```ts
|
|
307
|
+
* import {
|
|
308
|
+
* createRun,
|
|
309
|
+
* fetch,
|
|
310
|
+
* ok,
|
|
311
|
+
* type FetchTransportError,
|
|
312
|
+
* type NativeFetch,
|
|
313
|
+
* type Task,
|
|
314
|
+
* } from "@evolu/common";
|
|
315
|
+
*
|
|
316
|
+
* const nativeFetch: NativeFetch = (input) =>
|
|
317
|
+
* Promise.resolve(
|
|
318
|
+
* String(input).endsWith("/metadata")
|
|
319
|
+
* ? new Response(null, {
|
|
320
|
+
* status: 204,
|
|
321
|
+
* headers: { "cache-control": "max-age=60" },
|
|
322
|
+
* })
|
|
323
|
+
* : new Response('{"name":"Ada"}'),
|
|
324
|
+
* );
|
|
325
|
+
* await using run = createRun({ nativeFetch });
|
|
326
|
+
*
|
|
327
|
+
* const user = await run(fetch("/api/user", "json"));
|
|
328
|
+
* const metadata = fetch("/api/user/metadata", (response) =>
|
|
329
|
+
* ok({
|
|
330
|
+
* status: response.status,
|
|
331
|
+
* cache: response.headers.get("cache-control"),
|
|
332
|
+
* }),
|
|
333
|
+
* );
|
|
334
|
+
* expectTypeOf(metadata).toEqualTypeOf<
|
|
335
|
+
* Task<{ status: number; cache: string | null }, FetchTransportError>
|
|
336
|
+
* >();
|
|
337
|
+
* expectOk(user, { name: "Ada" });
|
|
338
|
+
* expectOk(await run(metadata), { status: 204, cache: "max-age=60" });
|
|
339
|
+
* ```
|
|
340
|
+
*
|
|
341
|
+
* ### Aborting fetch
|
|
342
|
+
*
|
|
343
|
+
* Abort follows the standard Task rules: a Fiber from `run(fetch(...))` rejects
|
|
344
|
+
* with {@link AbortError}, and `run.abortable(fetch(...))` returns it as a
|
|
345
|
+
* Result error.
|
|
346
|
+
*
|
|
347
|
+
* ```ts
|
|
348
|
+
* import {
|
|
349
|
+
* AbortError,
|
|
350
|
+
* createRun,
|
|
351
|
+
* fetch,
|
|
352
|
+
* type NativeFetch,
|
|
353
|
+
* } from "@evolu/common";
|
|
354
|
+
*
|
|
355
|
+
* const nativeFetch: NativeFetch = (_input, init) =>
|
|
356
|
+
* new Promise<Response>((_resolve, reject) => {
|
|
357
|
+
* const signal = init?.signal;
|
|
358
|
+
* if (!signal) throw new Error("Missing signal");
|
|
359
|
+
* signal.addEventListener("abort", () => reject(signal.reason), {
|
|
360
|
+
* once: true,
|
|
361
|
+
* });
|
|
362
|
+
* });
|
|
363
|
+
* await using run = createRun({ nativeFetch });
|
|
364
|
+
*
|
|
365
|
+
* const fiber = run.abortable(fetch("/api/user", "json"));
|
|
366
|
+
* fiber.abort();
|
|
367
|
+
* const result = await fiber;
|
|
368
|
+
*
|
|
369
|
+
* expect(!result.ok && AbortError.is(result.error)).toBe(true);
|
|
370
|
+
* ```
|
|
371
|
+
*
|
|
372
|
+
* @group Fetch
|
|
373
|
+
*/
|
|
374
|
+
export function fetch(
|
|
375
|
+
input: RequestInfo | URL,
|
|
376
|
+
mode: "text",
|
|
377
|
+
init?: Omit<RequestInit, "signal">,
|
|
378
|
+
): Task<string, FetchError>;
|
|
379
|
+
|
|
380
|
+
export function fetch(
|
|
381
|
+
input: RequestInfo | URL,
|
|
382
|
+
mode: "json",
|
|
383
|
+
init?: Omit<RequestInit, "signal">,
|
|
384
|
+
): Task<unknown, FetchError>;
|
|
385
|
+
|
|
386
|
+
export function fetch(
|
|
387
|
+
input: RequestInfo | URL,
|
|
388
|
+
mode: "bytes",
|
|
389
|
+
init?: Omit<RequestInit, "signal">,
|
|
390
|
+
): Task<Uint8Array<ArrayBuffer>, FetchError>;
|
|
391
|
+
|
|
392
|
+
export function fetch(
|
|
393
|
+
input: RequestInfo | URL,
|
|
394
|
+
mode: "headers",
|
|
395
|
+
init?: Omit<RequestInit, "signal">,
|
|
396
|
+
): Task<FetchResponse, FetchTransportError>;
|
|
397
|
+
|
|
398
|
+
export function fetch<T, E = never>(
|
|
399
|
+
input: RequestInfo | URL,
|
|
400
|
+
consume: FetchConsume<T, E>,
|
|
401
|
+
init?: Omit<RequestInit, "signal">,
|
|
402
|
+
): Task<T, FetchTransportError | E>;
|
|
403
|
+
|
|
404
|
+
export function fetch(
|
|
405
|
+
input: RequestInfo | URL,
|
|
406
|
+
modeOrConsume: FetchMode | FetchConsume<unknown, unknown>,
|
|
407
|
+
init?: Omit<RequestInit, "signal">,
|
|
408
|
+
): Task<unknown, unknown> {
|
|
409
|
+
return async (run) => {
|
|
410
|
+
const { signal } = run;
|
|
411
|
+
|
|
412
|
+
// Abort control flow wins over step errors, normalizing host abort errors
|
|
413
|
+
// from native fetch, body reads, body cancel, and consumers.
|
|
414
|
+
const orAbort = (error: unknown): unknown => {
|
|
415
|
+
signal.throwIfAborted();
|
|
416
|
+
return error;
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const fetched = await tryAsync(
|
|
420
|
+
() => run.deps.nativeFetch(input, { ...init, signal }),
|
|
421
|
+
orAbort,
|
|
422
|
+
);
|
|
423
|
+
let result: Result<unknown, unknown>;
|
|
424
|
+
|
|
425
|
+
if (!fetched.ok) {
|
|
426
|
+
result = err({ type: "FetchTransportError", error: fetched.error });
|
|
427
|
+
} else {
|
|
428
|
+
const response = fetched.value;
|
|
429
|
+
|
|
430
|
+
// A response that arrives after abort is not processed: cancel its body
|
|
431
|
+
// best-effort, then let abort win before any mode or consumer runs.
|
|
432
|
+
if (signal.aborted) {
|
|
433
|
+
await tryAsync(() => response.body?.cancel());
|
|
434
|
+
signal.throwIfAborted();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (typeof modeOrConsume === "function") {
|
|
438
|
+
const consumed = await tryAsync(() => modeOrConsume(response), orAbort);
|
|
439
|
+
// A consumer throw is a defect once orAbort has ruled out abort.
|
|
440
|
+
if (!consumed.ok) throw consumed.error;
|
|
441
|
+
result = consumed.value;
|
|
442
|
+
} else if (modeOrConsume === "headers") {
|
|
443
|
+
// The head is already a value; body cancel failures are irrelevant.
|
|
444
|
+
await tryAsync(() => response.body?.cancel(), orAbort);
|
|
445
|
+
result = ok(response);
|
|
446
|
+
} else if (!response.ok) {
|
|
447
|
+
result = err({
|
|
448
|
+
type: "FetchStatusError",
|
|
449
|
+
response,
|
|
450
|
+
// Error bodies are diagnostics; text preserves proxy/HTML/plain responses across modes.
|
|
451
|
+
body: await tryAsync(() => response.text(), orAbort),
|
|
452
|
+
});
|
|
453
|
+
} else {
|
|
454
|
+
const body = await tryAsync(() => {
|
|
455
|
+
switch (modeOrConsume) {
|
|
456
|
+
case "text":
|
|
457
|
+
return response.text();
|
|
458
|
+
case "json":
|
|
459
|
+
return response.json() as Promise<unknown>;
|
|
460
|
+
case "bytes":
|
|
461
|
+
return response.bytes();
|
|
462
|
+
}
|
|
463
|
+
}, orAbort);
|
|
464
|
+
result = body.ok
|
|
465
|
+
? body
|
|
466
|
+
: err({ type: "FetchBodyError", response, error: body.error });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// An Err produced after abort is treated as abort control flow.
|
|
471
|
+
if (!result.ok) signal.throwIfAborted();
|
|
472
|
+
return result;
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Test {@link NativeFetch} that records calls and serves queued handlers.
|
|
478
|
+
*
|
|
479
|
+
* Each call shifts the next handler. Calling with an empty queue throws, so a
|
|
480
|
+
* test never silently reaches an unplanned request.
|
|
481
|
+
*
|
|
482
|
+
* @group Testing
|
|
483
|
+
*/
|
|
484
|
+
export interface TestNativeFetch extends NativeFetch {
|
|
485
|
+
readonly calls: ReadonlyArray<TestNativeFetchCall>;
|
|
486
|
+
readonly handle: (handler: TestNativeFetchHandler) => void;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Recorded {@link TestNativeFetch} call.
|
|
491
|
+
*
|
|
492
|
+
* @group Testing
|
|
493
|
+
*/
|
|
494
|
+
export interface TestNativeFetchCall {
|
|
495
|
+
readonly input: RequestInfo | URL;
|
|
496
|
+
readonly init: RequestInit | undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Handler for one {@link TestNativeFetch} call.
|
|
501
|
+
*
|
|
502
|
+
* @group Testing
|
|
503
|
+
*/
|
|
504
|
+
export type TestNativeFetchHandler = (
|
|
505
|
+
input: RequestInfo | URL,
|
|
506
|
+
init: RequestInit | undefined,
|
|
507
|
+
) => Awaitable<Response>;
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Creates {@link TestNativeFetch}.
|
|
511
|
+
*
|
|
512
|
+
* @group Testing
|
|
513
|
+
*/
|
|
514
|
+
export const testCreateNativeFetch = (
|
|
515
|
+
...handlers: ReadonlyArray<TestNativeFetchHandler>
|
|
516
|
+
): TestNativeFetch => {
|
|
517
|
+
const calls: Array<TestNativeFetchCall> = [];
|
|
518
|
+
const handlerQueue = [...handlers];
|
|
519
|
+
|
|
520
|
+
return Object.assign(
|
|
521
|
+
(input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
522
|
+
calls.push({ input, init });
|
|
523
|
+
const handler = handlerQueue.shift();
|
|
524
|
+
if (!handler) throw new Error("Unexpected NativeFetch call");
|
|
525
|
+
return Promise.resolve(handler(input, init));
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
calls,
|
|
529
|
+
handle: (handler: TestNativeFetchHandler): void => {
|
|
530
|
+
handlerQueue.push(handler);
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
);
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Creates a Response body for {@link NativeFetch} tests that errors on first
|
|
538
|
+
* read.
|
|
539
|
+
*
|
|
540
|
+
* @group Testing
|
|
541
|
+
*/
|
|
542
|
+
export const testCreateNativeFetchErroringBody = (
|
|
543
|
+
error: unknown = new Error("stream failed"),
|
|
544
|
+
): ReadableStream<Uint8Array> =>
|
|
545
|
+
new ReadableStream<Uint8Array>({
|
|
546
|
+
pull: (controller) => {
|
|
547
|
+
controller.error(error);
|
|
548
|
+
},
|
|
549
|
+
});
|
package/src/Identicon.ts
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { md5 } from "@noble/hashes/legacy.js";
|
|
8
|
-
import type { Brand } from "./Brand.
|
|
9
|
-
import { Id, idToIdBytes } from "./Type.
|
|
8
|
+
import type { Brand } from "./Brand.ts";
|
|
9
|
+
import { Id, idToIdBytes } from "./Type.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* SVG string representing a visual identicon for an {@link Id}, created with
|
|
@@ -32,13 +32,22 @@ export type IdenticonStyle = "github" | "quadrant" | "gradient" | "sutnar";
|
|
|
32
32
|
* ### Example
|
|
33
33
|
*
|
|
34
34
|
* ```ts
|
|
35
|
+
* import { createIdFromString, createIdenticon } from "@evolu/common";
|
|
36
|
+
*
|
|
37
|
+
* const id = createIdFromString("identicon-example");
|
|
35
38
|
* const svg = createIdenticon(id);
|
|
36
|
-
* const
|
|
37
|
-
* const
|
|
38
|
-
*
|
|
39
|
+
* const styles = ["quadrant", "gradient", "sutnar"] as const;
|
|
40
|
+
* const alternativeSvgs = styles.map((style) =>
|
|
41
|
+
* createIdenticon(id, style),
|
|
42
|
+
* );
|
|
43
|
+
*
|
|
44
|
+
* // Branded IDs work too.
|
|
45
|
+
* const todoId = createIdFromString<"Todo">("todo-1");
|
|
46
|
+
* const todoSvg = createIdenticon(todoId);
|
|
39
47
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
48
|
+
* expect(svg.startsWith("<svg")).toBe(true);
|
|
49
|
+
* expect(new Set([svg, ...alternativeSvgs]).size).toBe(4);
|
|
50
|
+
* expect(todoSvg.startsWith("<svg")).toBe(true);
|
|
42
51
|
* ```
|
|
43
52
|
*/
|
|
44
53
|
export const createIdenticon = (
|