@rxferrix/client 0.0.0-stage → 0.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/dist/api.d.ts +60 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +22 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +251 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +531 -0
- package/dist/auth.js.map +1 -0
- package/dist/bytes.d.ts +21 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +51 -0
- package/dist/bytes.js.map +1 -0
- package/dist/canonical.d.ts +13 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +66 -0
- package/dist/canonical.js.map +1 -0
- package/dist/client.d.ts +170 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +638 -0
- package/dist/client.js.map +1 -0
- package/dist/diff.d.ts +20 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +100 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +34 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/local_query_set.d.ts +56 -0
- package/dist/local_query_set.d.ts.map +1 -0
- package/dist/local_query_set.js +108 -0
- package/dist/local_query_set.js.map +1 -0
- package/dist/logging.d.ts +15 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +17 -0
- package/dist/logging.js.map +1 -0
- package/dist/optimistic_updates.d.ts +48 -0
- package/dist/optimistic_updates.d.ts.map +1 -0
- package/dist/optimistic_updates.js +113 -0
- package/dist/optimistic_updates.js.map +1 -0
- package/dist/paginated_query.d.ts +118 -0
- package/dist/paginated_query.d.ts.map +1 -0
- package/dist/paginated_query.js +199 -0
- package/dist/paginated_query.js.map +1 -0
- package/dist/protocol.d.ts +136 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +11 -0
- package/dist/protocol.js.map +1 -0
- package/dist/query_state.d.ts +16 -0
- package/dist/query_state.d.ts.map +1 -0
- package/dist/query_state.js +2 -0
- package/dist/query_state.js.map +1 -0
- package/dist/remote_query_set.d.ts +37 -0
- package/dist/remote_query_set.d.ts.map +1 -0
- package/dist/remote_query_set.js +87 -0
- package/dist/remote_query_set.js.map +1 -0
- package/dist/request_manager.d.ts +57 -0
- package/dist/request_manager.d.ts.map +1 -0
- package/dist/request_manager.js +163 -0
- package/dist/request_manager.js.map +1 -0
- package/dist/storage.d.ts +45 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +120 -0
- package/dist/storage.js.map +1 -0
- package/dist/testing.d.ts +68 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +97 -0
- package/dist/testing.js.map +1 -0
- package/dist/web_socket_manager.d.ts +88 -0
- package/dist/web_socket_manager.d.ts.map +1 -0
- package/dist/web_socket_manager.js +291 -0
- package/dist/web_socket_manager.js.map +1 -0
- package/package.json +50 -4
- package/src/api.ts +86 -0
- package/src/auth.ts +636 -0
- package/src/bytes.ts +57 -0
- package/src/canonical.ts +61 -0
- package/src/client.ts +740 -0
- package/src/diff.ts +87 -0
- package/src/errors.ts +36 -0
- package/src/index.ts +60 -0
- package/src/local_query_set.ts +130 -0
- package/src/logging.ts +26 -0
- package/src/optimistic_updates.ts +137 -0
- package/src/paginated_query.ts +299 -0
- package/src/protocol.ts +152 -0
- package/src/query_state.ts +12 -0
- package/src/remote_query_set.ts +105 -0
- package/src/request_manager.ts +199 -0
- package/src/storage.ts +171 -0
- package/src/testing.ts +120 -0
- package/src/web_socket_manager.ts +322 -0
- package/README.md +0 -3
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { CONNECTION_LOST, RxFerrixError } from "./errors.js";
|
|
2
|
+
import type {
|
|
3
|
+
ActionMessage,
|
|
4
|
+
ActionResponseMessage,
|
|
5
|
+
MutationMessage,
|
|
6
|
+
MutationResponseMessage,
|
|
7
|
+
RequestId,
|
|
8
|
+
} from "./protocol.js";
|
|
9
|
+
|
|
10
|
+
type Settle = { resolve: (value: unknown) => void; reject: (error: unknown) => void };
|
|
11
|
+
|
|
12
|
+
type MutationRequest = Settle & {
|
|
13
|
+
kind: "mutation";
|
|
14
|
+
message: MutationMessage;
|
|
15
|
+
/** Sent on the current socket. */
|
|
16
|
+
sent: boolean;
|
|
17
|
+
/** Set once a successful MutationResponse arrived: waiting for a Transition with ts >= this. */
|
|
18
|
+
completed: { ts: number; result: unknown } | null;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ActionRequest = Settle & {
|
|
23
|
+
kind: "action";
|
|
24
|
+
message: ActionMessage;
|
|
25
|
+
sent: boolean;
|
|
26
|
+
startedAt: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type Request = MutationRequest | ActionRequest;
|
|
30
|
+
|
|
31
|
+
export interface SettledMutation {
|
|
32
|
+
requestId: RequestId;
|
|
33
|
+
settle: () => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Tracks in-flight mutations and actions.
|
|
38
|
+
*
|
|
39
|
+
* A mutation's promise resolves only once BOTH its successful `MutationResponse { ts: M }` has arrived AND the client
|
|
40
|
+
* has applied a Transition with `endVersion.ts >= M` (read-your-writes). Failures reject immediately.
|
|
41
|
+
*
|
|
42
|
+
* Settling is split in two (`settle` callbacks are returned rather than invoked) so the client can update its query
|
|
43
|
+
* view (dropping optimistic updates) before promise continuations run.
|
|
44
|
+
*/
|
|
45
|
+
export class RequestManager {
|
|
46
|
+
private nextRequestId = 0;
|
|
47
|
+
private readonly requests = new Map<RequestId, Request>();
|
|
48
|
+
|
|
49
|
+
allocateRequestId(): RequestId {
|
|
50
|
+
return this.nextRequestId++;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
trackMutation(message: MutationMessage): Promise<unknown> {
|
|
54
|
+
return new Promise((resolve, reject) => {
|
|
55
|
+
this.requests.set(message.requestId, {
|
|
56
|
+
kind: "mutation",
|
|
57
|
+
message,
|
|
58
|
+
sent: false,
|
|
59
|
+
completed: null,
|
|
60
|
+
startedAt: Date.now(),
|
|
61
|
+
resolve,
|
|
62
|
+
reject,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
trackAction(message: ActionMessage): Promise<unknown> {
|
|
68
|
+
return new Promise((resolve, reject) => {
|
|
69
|
+
this.requests.set(message.requestId, {
|
|
70
|
+
kind: "action",
|
|
71
|
+
message,
|
|
72
|
+
sent: false,
|
|
73
|
+
startedAt: Date.now(),
|
|
74
|
+
resolve,
|
|
75
|
+
reject,
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Messages that still have to go out on the current socket, in request order. */
|
|
81
|
+
unsent(): (MutationMessage | ActionMessage)[] {
|
|
82
|
+
const out: (MutationMessage | ActionMessage)[] = [];
|
|
83
|
+
for (const request of this.requests.values()) {
|
|
84
|
+
if (request.sent) continue;
|
|
85
|
+
if (request.kind === "mutation" && request.completed) continue;
|
|
86
|
+
out.push(request.message);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The message went out on the current socket. */
|
|
92
|
+
markSent(requestId: RequestId): void {
|
|
93
|
+
const request = this.requests.get(requestId);
|
|
94
|
+
if (request) request.sent = true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Handles a MutationResponse. `observedTs` is the ts of the last applied Transition, or null when the client's view
|
|
99
|
+
* is not currently consistent with the server (right after a reconnect).
|
|
100
|
+
*
|
|
101
|
+
* Returns the settled mutation (whose optimistic update must be dropped), or null if it is still waiting for its
|
|
102
|
+
* ts to be observed (or the requestId is unknown).
|
|
103
|
+
*/
|
|
104
|
+
onMutationResponse(response: MutationResponseMessage, observedTs: number | null): SettledMutation | null {
|
|
105
|
+
const request = this.requests.get(response.requestId);
|
|
106
|
+
if (!request || request.kind !== "mutation" || request.completed) return null;
|
|
107
|
+
if (!response.success) {
|
|
108
|
+
this.requests.delete(response.requestId);
|
|
109
|
+
const error = RxFerrixError.fromServer(response.errorMessage, response.errorData);
|
|
110
|
+
return { requestId: response.requestId, settle: () => request.reject(error) };
|
|
111
|
+
}
|
|
112
|
+
request.completed = { ts: response.ts, result: response.result };
|
|
113
|
+
if (observedTs !== null && observedTs >= response.ts) {
|
|
114
|
+
this.requests.delete(response.requestId);
|
|
115
|
+
const result = response.result;
|
|
116
|
+
return { requestId: response.requestId, settle: () => request.resolve(result) };
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Handles an ActionResponse. Returns the settle callback, or null for an unknown request. */
|
|
122
|
+
onActionResponse(response: ActionResponseMessage): (() => void) | null {
|
|
123
|
+
const request = this.requests.get(response.requestId);
|
|
124
|
+
if (!request || request.kind !== "action") return null;
|
|
125
|
+
this.requests.delete(response.requestId);
|
|
126
|
+
if (response.success) {
|
|
127
|
+
const result = response.result;
|
|
128
|
+
return () => request.resolve(result);
|
|
129
|
+
}
|
|
130
|
+
const error = RxFerrixError.fromServer(response.errorMessage, response.errorData);
|
|
131
|
+
return () => request.reject(error);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** A Transition with `endVersion.ts = ts` was applied: completes every mutation with a commit ts <= ts. */
|
|
135
|
+
removeCompleted(ts: number): SettledMutation[] {
|
|
136
|
+
const settled: SettledMutation[] = [];
|
|
137
|
+
for (const [requestId, request] of this.requests) {
|
|
138
|
+
if (request.kind !== "mutation" || !request.completed || request.completed.ts > ts) continue;
|
|
139
|
+
this.requests.delete(requestId);
|
|
140
|
+
const result = request.completed.result;
|
|
141
|
+
settled.push({ requestId, settle: () => request.resolve(result) });
|
|
142
|
+
}
|
|
143
|
+
return settled;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The socket dropped. Mutations without a response will be re-sent on the next socket. Actions that were already
|
|
148
|
+
* sent are rejected because they are not idempotent; actions that never left the client stay queued.
|
|
149
|
+
*/
|
|
150
|
+
onSocketClosed(): { settle: () => void; requestId: RequestId }[] {
|
|
151
|
+
const rejected: { settle: () => void; requestId: RequestId }[] = [];
|
|
152
|
+
for (const [requestId, request] of this.requests) {
|
|
153
|
+
if (request.kind === "mutation") {
|
|
154
|
+
if (!request.completed) request.sent = false;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (!request.sent) continue;
|
|
158
|
+
this.requests.delete(requestId);
|
|
159
|
+
const error = new RxFerrixError(
|
|
160
|
+
`Connection lost while action "${request.message.udfPath}" was in flight`,
|
|
161
|
+
{ code: CONNECTION_LOST },
|
|
162
|
+
);
|
|
163
|
+
rejected.push({ requestId, settle: () => request.reject(error) });
|
|
164
|
+
}
|
|
165
|
+
return rejected;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Rejects everything (client closed). */
|
|
169
|
+
rejectAll(error: RxFerrixError): void {
|
|
170
|
+
const all = [...this.requests.values()];
|
|
171
|
+
this.requests.clear();
|
|
172
|
+
for (const request of all) request.reject(error);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
hasInflight(): boolean {
|
|
176
|
+
return this.requests.size > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
hasInflightMutations(): boolean {
|
|
180
|
+
for (const request of this.requests.values()) if (request.kind === "mutation") return true;
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
counts(): { mutations: number; actions: number; oldestStartedAt: number | null } {
|
|
185
|
+
let mutations = 0;
|
|
186
|
+
let actions = 0;
|
|
187
|
+
let oldest: number | null = null;
|
|
188
|
+
for (const request of this.requests.values()) {
|
|
189
|
+
if (request.kind === "mutation") mutations++;
|
|
190
|
+
else actions++;
|
|
191
|
+
if (oldest === null || request.startedAt < oldest) oldest = request.startedAt;
|
|
192
|
+
}
|
|
193
|
+
return { mutations, actions, oldestStartedAt: oldest };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
udfPathOf(requestId: RequestId): string | undefined {
|
|
197
|
+
return this.requests.get(requestId)?.message.udfPath;
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/storage.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File uploads to a RxFerrix server.
|
|
3
|
+
*
|
|
4
|
+
* The flow: a mutation returns `ctx.storage.generate_upload_url()`, the client `POST`s the file to it
|
|
5
|
+
* with {@link uploadFile}, and a second mutation stores the returned `Id<"_storage">` in a document. Serve it with
|
|
6
|
+
* the URL from `ctx.storage.get_url(id)` (`/api/storage/<id>`).
|
|
7
|
+
*
|
|
8
|
+
* ```ts
|
|
9
|
+
* const url = await client.mutation(api.files.generateUploadUrl, {});
|
|
10
|
+
* const storageId = await uploadFile(url, file, { onProgress: ({ loaded, total }) => (pct = loaded / total) });
|
|
11
|
+
* await client.mutation(api.files.attach, { storageId });
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import type { Id } from "./api.js";
|
|
15
|
+
import { RxFerrixError } from "./errors.js";
|
|
16
|
+
import type { ErrorData } from "./protocol.js";
|
|
17
|
+
|
|
18
|
+
export interface UploadProgress {
|
|
19
|
+
/** Bytes sent so far. */
|
|
20
|
+
loaded: number;
|
|
21
|
+
/** Total bytes to send. */
|
|
22
|
+
total: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface UploadFileOptions {
|
|
26
|
+
/** Called as the upload progresses (with `XMLHttpRequest`; with `fetch`, only at the start and the end). */
|
|
27
|
+
onProgress?: (progress: UploadProgress) => void;
|
|
28
|
+
/** Aborts the upload; the promise rejects with an `AbortError`. */
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
/** The `Content-Type` to store. Default: the Blob's `type` (none if empty). */
|
|
31
|
+
contentType?: string;
|
|
32
|
+
/** The original file name to store. Default: `File.name` for a `File`; `null` sends none. */
|
|
33
|
+
filename?: string | null;
|
|
34
|
+
/** Base for a root-relative upload URL (`/api/storage/upload?…`). Default: the page's `location`. */
|
|
35
|
+
baseUrl?: string;
|
|
36
|
+
/** `fetch` to use when `XMLHttpRequest` is unavailable (or `transport: "fetch"`). Default: `globalThis.fetch`. */
|
|
37
|
+
fetch?: typeof globalThis.fetch;
|
|
38
|
+
/** Force a transport. Default: `XMLHttpRequest` when available (browsers, for progress events), else `fetch`. */
|
|
39
|
+
transport?: "xhr" | "fetch";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A successful upload's response body. */
|
|
43
|
+
interface UploadResponse {
|
|
44
|
+
storageId: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The final URL: `uploadUrl` resolved against `baseUrl`, with `filename` appended. */
|
|
48
|
+
export function uploadRequestUrl(uploadUrl: string, filename: string | null | undefined, baseUrl?: string): string {
|
|
49
|
+
const base = baseUrl ?? (typeof location !== "undefined" ? location.href : undefined);
|
|
50
|
+
let url: URL;
|
|
51
|
+
try {
|
|
52
|
+
url = base === undefined ? new URL(uploadUrl) : new URL(uploadUrl, base);
|
|
53
|
+
} catch {
|
|
54
|
+
throw new RxFerrixError(`Invalid upload URL ${JSON.stringify(uploadUrl)} (pass options.baseUrl for relative URLs)`, {
|
|
55
|
+
code: "invalid_upload_url",
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
if (filename) url.searchParams.set("filename", filename);
|
|
59
|
+
return url.toString();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseResult(status: number, text: string): Id<"_storage"> {
|
|
63
|
+
let body: unknown = null;
|
|
64
|
+
try {
|
|
65
|
+
body = text ? JSON.parse(text) : null;
|
|
66
|
+
} catch {
|
|
67
|
+
// Not JSON (e.g. a proxy's error page).
|
|
68
|
+
}
|
|
69
|
+
if (status >= 200 && status < 300) {
|
|
70
|
+
const id = (body as UploadResponse | null)?.storageId;
|
|
71
|
+
if (typeof id !== "string") throw new RxFerrixError(`Unexpected upload response: ${text.slice(0, 200)}`);
|
|
72
|
+
return id as Id<"_storage">;
|
|
73
|
+
}
|
|
74
|
+
const err = body as { errorMessage?: string; errorData?: ErrorData | null } | null;
|
|
75
|
+
if (err && typeof err === "object" && (err.errorMessage || err.errorData)) {
|
|
76
|
+
throw RxFerrixError.fromServer(err.errorMessage, err.errorData ?? null);
|
|
77
|
+
}
|
|
78
|
+
throw new RxFerrixError(`Upload failed with HTTP ${status}`, { code: status === 413 ? "file_too_large" : "http_error" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function abortError(signal: AbortSignal | undefined): unknown {
|
|
82
|
+
return signal?.reason ?? new DOMException("The upload was aborted", "AbortError");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Upload `file` to an upload URL from `ctx.storage.generate_upload_url()` and return the new file's
|
|
87
|
+
* `Id<"_storage">`. Rejects with a {@link RxFerrixError} whose `code` is the server's (`file_too_large`,
|
|
88
|
+
* `upload_url_expired`, `upload_url_used`, `invalid_upload_url`, `rate_limited`, ...), or `network_error`.
|
|
89
|
+
*/
|
|
90
|
+
export async function uploadFile(uploadUrl: string, file: Blob, options: UploadFileOptions = {}): Promise<Id<"_storage">> {
|
|
91
|
+
const filename =
|
|
92
|
+
options.filename !== undefined
|
|
93
|
+
? options.filename
|
|
94
|
+
: typeof File !== "undefined" && file instanceof File && file.name
|
|
95
|
+
? file.name
|
|
96
|
+
: null;
|
|
97
|
+
const url = uploadRequestUrl(uploadUrl, filename, options.baseUrl);
|
|
98
|
+
const contentType = options.contentType ?? (file.type || undefined);
|
|
99
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
100
|
+
const useXhr = options.transport ? options.transport === "xhr" : typeof XMLHttpRequest === "function";
|
|
101
|
+
return useXhr ? uploadWithXhr(url, file, contentType, options) : uploadWithFetch(url, file, contentType, options);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function uploadWithXhr(
|
|
105
|
+
url: string,
|
|
106
|
+
file: Blob,
|
|
107
|
+
contentType: string | undefined,
|
|
108
|
+
options: UploadFileOptions,
|
|
109
|
+
): Promise<Id<"_storage">> {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const xhr = new XMLHttpRequest();
|
|
112
|
+
const { signal, onProgress } = options;
|
|
113
|
+
const onAbort = () => xhr.abort();
|
|
114
|
+
const cleanup = () => signal?.removeEventListener("abort", onAbort);
|
|
115
|
+
xhr.open("POST", url);
|
|
116
|
+
if (contentType) xhr.setRequestHeader("Content-Type", contentType);
|
|
117
|
+
if (onProgress) {
|
|
118
|
+
xhr.upload.onprogress = (e: ProgressEvent) => {
|
|
119
|
+
onProgress({ loaded: e.loaded, total: e.lengthComputable ? e.total : file.size });
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
xhr.onload = () => {
|
|
123
|
+
cleanup();
|
|
124
|
+
try {
|
|
125
|
+
const id = parseResult(xhr.status, xhr.responseText);
|
|
126
|
+
onProgress?.({ loaded: file.size, total: file.size });
|
|
127
|
+
resolve(id);
|
|
128
|
+
} catch (e) {
|
|
129
|
+
reject(e);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
xhr.onerror = () => {
|
|
133
|
+
cleanup();
|
|
134
|
+
reject(new RxFerrixError("The upload failed: network error", { code: "network_error" }));
|
|
135
|
+
};
|
|
136
|
+
xhr.onabort = () => {
|
|
137
|
+
cleanup();
|
|
138
|
+
reject(abortError(signal));
|
|
139
|
+
};
|
|
140
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
141
|
+
xhr.send(file);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function uploadWithFetch(
|
|
146
|
+
url: string,
|
|
147
|
+
file: Blob,
|
|
148
|
+
contentType: string | undefined,
|
|
149
|
+
options: UploadFileOptions,
|
|
150
|
+
): Promise<Id<"_storage">> {
|
|
151
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
152
|
+
options.onProgress?.({ loaded: 0, total: file.size });
|
|
153
|
+
let res: Response;
|
|
154
|
+
try {
|
|
155
|
+
res = await fetchFn(url, {
|
|
156
|
+
method: "POST",
|
|
157
|
+
body: file,
|
|
158
|
+
headers: contentType ? { "Content-Type": contentType } : {},
|
|
159
|
+
signal: options.signal,
|
|
160
|
+
});
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (options.signal?.aborted) throw abortError(options.signal);
|
|
163
|
+
throw new RxFerrixError(`The upload failed: ${e instanceof Error ? e.message : String(e)}`, {
|
|
164
|
+
code: "network_error",
|
|
165
|
+
cause: e,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
const id = parseResult(res.status, await res.text());
|
|
169
|
+
options.onProgress?.({ loaded: file.size, total: file.size });
|
|
170
|
+
return id;
|
|
171
|
+
}
|
package/src/testing.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test utilities: a scriptable fake of the RxFerrix sync endpoint (`@rxferrix/client/testing`).
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* const server = new FakeServer();
|
|
6
|
+
* const client = new RxFerrixClient("http://localhost", { webSocketConstructor: server.WebSocket });
|
|
7
|
+
* server.open(); // the socket connects
|
|
8
|
+
* await flushMicrotasks(); // batched ModifyQuerySet goes out
|
|
9
|
+
* server.sent; // what the client sent on this socket
|
|
10
|
+
* server.transition({ querySet: 1, ts: 1 }, [{ type: "QueryUpdated", queryId: 0, value: [] }]);
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
import type { ClientMessage, ServerMessage, StateVersion } from "./protocol.js";
|
|
14
|
+
|
|
15
|
+
type Modification = Extract<ServerMessage, { type: "Transition" }>["modifications"][number];
|
|
16
|
+
|
|
17
|
+
export class FakeSocket {
|
|
18
|
+
static readonly CONNECTING = 0;
|
|
19
|
+
static readonly OPEN = 1;
|
|
20
|
+
static readonly CLOSING = 2;
|
|
21
|
+
static readonly CLOSED = 3;
|
|
22
|
+
|
|
23
|
+
readyState = FakeSocket.CONNECTING;
|
|
24
|
+
readonly sent: ClientMessage[] = [];
|
|
25
|
+
closedByClient = false;
|
|
26
|
+
/** The server-side StateVersion of this socket, advanced by `transition`. */
|
|
27
|
+
version: StateVersion = { querySet: 0, identity: 0, ts: 0 };
|
|
28
|
+
|
|
29
|
+
onopen: ((ev: unknown) => void) | null = null;
|
|
30
|
+
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
31
|
+
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
|
32
|
+
onerror: ((ev: unknown) => void) | null = null;
|
|
33
|
+
|
|
34
|
+
constructor(readonly url: string) {}
|
|
35
|
+
|
|
36
|
+
send(data: string): void {
|
|
37
|
+
if (this.readyState !== FakeSocket.OPEN) throw new Error("send on a socket that is not open");
|
|
38
|
+
this.sent.push(JSON.parse(data) as ClientMessage);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
close(): void {
|
|
42
|
+
if (this.readyState === FakeSocket.CLOSED) return;
|
|
43
|
+
this.closedByClient = true;
|
|
44
|
+
this.readyState = FakeSocket.CLOSED;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export class FakeServer {
|
|
49
|
+
readonly sockets: FakeSocket[] = [];
|
|
50
|
+
readonly WebSocket: typeof WebSocket;
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
const sockets = this.sockets;
|
|
54
|
+
class BoundFakeSocket extends FakeSocket {
|
|
55
|
+
constructor(url: string) {
|
|
56
|
+
super(url);
|
|
57
|
+
sockets.push(this);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
this.WebSocket = BoundFakeSocket as unknown as typeof WebSocket;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The most recently created socket. */
|
|
64
|
+
get socket(): FakeSocket {
|
|
65
|
+
const s = this.sockets[this.sockets.length - 1];
|
|
66
|
+
if (!s) throw new Error("no socket created yet");
|
|
67
|
+
return s;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Everything the client sent on the current socket. */
|
|
71
|
+
get sent(): ClientMessage[] {
|
|
72
|
+
return this.socket.sent;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Messages of one type sent on the current socket. */
|
|
76
|
+
sentOfType<T extends ClientMessage["type"]>(type: T): Extract<ClientMessage, { type: T }>[] {
|
|
77
|
+
return this.sent.filter((m): m is Extract<ClientMessage, { type: T }> => m.type === type);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Returns and forgets what the client sent on the current socket so far. */
|
|
81
|
+
drain(): ClientMessage[] {
|
|
82
|
+
return this.socket.sent.splice(0);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
open(): void {
|
|
86
|
+
const s = this.socket;
|
|
87
|
+
s.readyState = FakeSocket.OPEN;
|
|
88
|
+
s.onopen?.({});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
push(message: ServerMessage | Record<string, unknown>): void {
|
|
92
|
+
const s = this.socket;
|
|
93
|
+
if (s.readyState !== FakeSocket.OPEN) throw new Error("push on a socket that is not open");
|
|
94
|
+
s.onmessage?.({ data: JSON.stringify(message) });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Pushes a Transition from the socket's current version. `end` overrides fields of the end version; unspecified
|
|
99
|
+
* fields keep their current value.
|
|
100
|
+
*/
|
|
101
|
+
transition(end: Partial<StateVersion>, modifications: Modification[] = []): void {
|
|
102
|
+
const s = this.socket;
|
|
103
|
+
const startVersion = { ...s.version };
|
|
104
|
+
const endVersion = { ...s.version, ...end };
|
|
105
|
+
s.version = endVersion;
|
|
106
|
+
this.push({ type: "Transition", startVersion, endVersion, modifications });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The server closes the current socket. */
|
|
110
|
+
close(code = 1011, reason = "server closed"): void {
|
|
111
|
+
const s = this.socket;
|
|
112
|
+
s.readyState = FakeSocket.CLOSED;
|
|
113
|
+
s.onclose?.({ code, reason });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lets queued microtasks (query-set flush, promise continuations) run. */
|
|
118
|
+
export async function flushMicrotasks(): Promise<void> {
|
|
119
|
+
for (let i = 0; i < 5; i++) await Promise.resolve();
|
|
120
|
+
}
|