@mdrnio/sdk 0.0.0-stage → 0.3.1

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/src/index.js ADDED
@@ -0,0 +1,360 @@
1
+ // The MDRN SDK for JavaScript: one client for the products a release has (Database, Files,
2
+ // Mail) and for who is calling it (Auth). Web APIs only, so it runs in a Worker and in Node.js
3
+ // 20+. The wire contract is sdk/contracts/client.openapi.json; the names of the methods are
4
+ // sdk/contracts/operations.json, shared with the Python, Go and Rust SDKs.
5
+
6
+ /** A call that did not succeed. `status` is the HTTP status (0 when no response was received),
7
+ * `code` MDRN's error code or one of the SDK's own (PRODUCT_NOT_ENABLED, UNEXPECTED_REDIRECT,
8
+ * HTTP_ERROR, TIMEOUT, NETWORK_ERROR, UPLOAD_FAILED, FILE_REJECTED), `details` the response
9
+ * body or the underlying error. The message is MDRN's and is for your logs. */
10
+ export class MdrnError extends Error {
11
+ constructor(status, code, message, details) {
12
+ super(message || (status ? `MDRN request failed with HTTP ${status}` : code));
13
+ this.name = "MdrnError";
14
+ this.status = status;
15
+ this.code = code || "HTTP_ERROR";
16
+ this.details = details;
17
+ }
18
+ }
19
+
20
+ const ENVIRONMENTS = ["sandbox", "production"];
21
+ // The token is a bearer credential: it only travels over https, except to a development server
22
+ // on this machine (localhost, 127.0.0.1 or [::1]).
23
+ const LOOPBACK = ["localhost", "127.0.0.1", "[::1]"];
24
+ const USER_ID = /^[A-Za-z0-9_-]{1,128}$/;
25
+ const PRODUCTS = {
26
+ db: { variable: "DATABASE", label: "Database", path: "/_mdrn/v1/database" },
27
+ files: { variable: "STORAGE", label: "Files", path: "/v1/storage" },
28
+ mail: { variable: "MAIL", label: "Mail", path: "/v1/mail" },
29
+ };
30
+ const DEFAULT_PART_BYTES = 16 * 1024 * 1024;
31
+ const POLL_MS = 1000;
32
+ const WAIT_MS = 60_000;
33
+ const CONFLICT_RETRIES = 3;
34
+
35
+ function secureUrl(value, what) {
36
+ let url;
37
+ try { url = new URL(value); } catch { throw new TypeError(`${what} must be an https URL`); }
38
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && LOOPBACK.includes(url.hostname))) {
39
+ throw new TypeError(`${what} must be an https URL (http is allowed only for localhost, 127.0.0.1 or [::1])`);
40
+ }
41
+ return url.href.replace(/\/$/, "");
42
+ }
43
+
44
+ const segment = (value) => encodeURIComponent(String(value));
45
+ const records = (collection, id) => `/collections/${segment(collection)}/records${id === undefined ? "" : `/${segment(id)}`}`;
46
+ const page = (options = {}) => {
47
+ const query = new URLSearchParams();
48
+ if (options.limit !== undefined) query.set("limit", String(options.limit));
49
+ if (options.cursor !== undefined && options.cursor !== null) query.set("cursor", String(options.cursor));
50
+ return query.size ? `?${query}` : "";
51
+ };
52
+ // A write's options: its idempotency key (a fresh one unless given) and cancellation.
53
+ const write = (options = {}) => ({ idempotencyKey: options.idempotencyKey ?? crypto.randomUUID(), signal: options.signal });
54
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
55
+ const hex = (buffer) => [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
56
+
57
+ // The version an update or delete was given, checked; undefined when none was.
58
+ function givenVersion(options) {
59
+ const { version } = options;
60
+ if (version === undefined) return undefined;
61
+ if (!Number.isSafeInteger(version) || version < 1) throw new TypeError("version must be a positive integer");
62
+ return version;
63
+ }
64
+
65
+ // A file to upload, checked: its name and its bytes as a Uint8Array.
66
+ function checkFile({ name, type, bytes } = {}) {
67
+ if (typeof name !== "string" || !name) throw new TypeError("name is required");
68
+ if (bytes instanceof ArrayBuffer) bytes = new Uint8Array(bytes);
69
+ if (!(bytes instanceof Uint8Array)) throw new TypeError("bytes must be a Uint8Array or an ArrayBuffer");
70
+ return { name, type, bytes };
71
+ }
72
+
73
+ export class Mdrn {
74
+ #products;
75
+ #fetcher;
76
+ #transfer;
77
+ #timeoutMs;
78
+
79
+ /**
80
+ * @param {object} options
81
+ * @param {string} [options.origin] The release's origin (https://<your app>); each product's
82
+ * URL is derived from it. A token without it (and without that product's `urls` entry)
83
+ * enables nothing.
84
+ * @param {string} options.project MDRN_PROJECT_ID.
85
+ * @param {"sandbox"|"production"} options.environment MDRN_ENVIRONMENT.
86
+ * @param {{database?: string, storage?: string, mail?: string}} [options.tokens] The product
87
+ * tokens; a product without one is not enabled.
88
+ * @param {{database?: string, storage?: string, mail?: string}} [options.urls] Product URLs
89
+ * that replace the ones derived from `origin` (fromEnv passes MDRN_*_URL here).
90
+ * @param {typeof fetch} [options.fetcher] Sends every product request; in a Worker the
91
+ * MDRN_PRODUCTS binding's fetch. Defaults to the global fetch.
92
+ * @param {typeof fetch} [options.transfer] Sends bytes to and from grant URLs (the Files
93
+ * gateway). Always a plain fetch: never the binding, never with a product token.
94
+ * @param {number} [options.timeoutMs] Per-request deadline, 10 s by default.
95
+ */
96
+ constructor({ origin, project, environment, tokens = {}, urls = {}, fetcher = globalThis.fetch, transfer = globalThis.fetch, timeoutMs = 10_000 } = {}) {
97
+ if (typeof project !== "string" || !project) throw new TypeError("project is required");
98
+ if (!ENVIRONMENTS.includes(environment)) throw new TypeError("environment must be sandbox or production");
99
+ if (typeof fetcher !== "function") throw new TypeError("fetcher must be a function");
100
+ if (typeof transfer !== "function") throw new TypeError("transfer must be a function");
101
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new TypeError("timeoutMs must be a positive number");
102
+ this.project = project;
103
+ this.environment = environment;
104
+ this.#fetcher = fetcher;
105
+ this.#transfer = transfer;
106
+ this.#timeoutMs = timeoutMs;
107
+ const base = origin === undefined || origin === null || origin === "" ? undefined : secureUrl(origin, "origin");
108
+ this.#products = {};
109
+ for (const [name, product] of Object.entries(PRODUCTS)) {
110
+ const key = product.variable.toLowerCase();
111
+ const token = tokens[key] || undefined;
112
+ let url = urls[key] || undefined;
113
+ // A token without a URL (no origin, no urls entry) enables nothing: the product is
114
+ // not enabled, and a call on it says which MDRN_*_URL is missing.
115
+ if (url === undefined && token !== undefined && base !== undefined) url = base + product.path;
116
+ if (url !== undefined) url = secureUrl(url, `MDRN_${product.variable}_URL`);
117
+ const missing = token === undefined ? "TOKEN" : "URL";
118
+ this.#products[name] = {
119
+ url: url !== undefined && token !== undefined ? url : undefined,
120
+ token,
121
+ unavailable: `${product.label} is not enabled for this environment (MDRN_${product.variable}_${missing} is missing)`,
122
+ };
123
+ }
124
+ this.enabled = Object.keys(PRODUCTS).filter((name) => this.#products[name].url !== undefined);
125
+ const self = this;
126
+
127
+ this.db = {
128
+ create: (collection, data, options) => self.#call("db", records(collection), { method: "POST", body: { data }, ...write(options) }),
129
+ get: (collection, id) => self.#read("db", records(collection, id)),
130
+ list: (collection, options) => self.#call("db", records(collection) + page(options), { signal: options?.signal }),
131
+ // The version is checked before #mutate runs, so a bad one is a synchronous TypeError.
132
+ update: (collection, id, patch, options = {}) =>
133
+ self.#mutate(collection, id, givenVersion(options), options, (version, key) =>
134
+ self.#call("db", records(collection, id), { method: "PATCH", body: { data: patch }, ifMatch: version, idempotencyKey: key, signal: options.signal })),
135
+ delete: (collection, id, options = {}) =>
136
+ self.#mutate(collection, id, givenVersion(options), options, (version, key) =>
137
+ self.#call("db", records(collection, id), { method: "DELETE", ifMatch: version, idempotencyKey: key, signal: options.signal })).then(() => undefined),
138
+ };
139
+
140
+ const uploads = {
141
+ create: (input, options) => self.#call("files", "/uploads", { method: "POST", body: input, ...write(options) }),
142
+ get: (uploadId) => self.#read("files", `/uploads/${segment(uploadId)}`),
143
+ list: (options) => self.#call("files", "/uploads" + page(options), { signal: options?.signal }),
144
+ parts: (uploadId, options) => self.#call("files", `/uploads/${segment(uploadId)}/parts` + page(options), { signal: options?.signal }),
145
+ part: (uploadId, { partNumber, size }, options) =>
146
+ self.#call("files", `/uploads/${segment(uploadId)}/parts`, { method: "POST", body: { partNumber, bytes: size }, ...write(options) }),
147
+ complete: (uploadId, parts, options) =>
148
+ self.#call("files", `/uploads/${segment(uploadId)}/complete`, { method: "POST", body: { parts }, ...write(options) }),
149
+ abort: async (uploadId, options) => {
150
+ await self.#call("files", `/uploads/${segment(uploadId)}`, { method: "DELETE", ...write(options) });
151
+ },
152
+ };
153
+ this.files = {
154
+ uploads,
155
+ upload: (file, options) => self.#upload(checkFile(file), options),
156
+ get: (id) => self.#read("files", `/objects/${segment(id)}`),
157
+ list: (options) => self.#call("files", "/objects" + page(options), { signal: options?.signal }),
158
+ downloadUrl: async (id, options) => {
159
+ const grant = await self.#call("files", `/objects/${segment(id)}/download-grants`, { method: "POST", body: {}, ...write(options) });
160
+ return { url: grant.url, expiresAt: grant.expiresAt };
161
+ },
162
+ delete: async (id, options) => {
163
+ await self.#call("files", `/objects/${segment(id)}`, { method: "DELETE", ...write(options) });
164
+ },
165
+ operation: (id) => self.#call("files", `/operations/${segment(id)}`),
166
+ };
167
+
168
+ this.mail = {
169
+ send: (message, options) => {
170
+ const { to, subject, text, html, attachments, template, senderId, ...rest } = message ?? {};
171
+ const unknown = Object.keys(rest);
172
+ if (unknown.length) throw new TypeError(`Unknown mail field: ${unknown.join(", ")}`);
173
+ if (typeof to !== "string" || !to) throw new TypeError("to is required");
174
+ if (template !== undefined && (subject !== undefined || text !== undefined || html !== undefined)) {
175
+ throw new TypeError("A template supplies subject, text and html; send either a template or your own content");
176
+ }
177
+ const body = { recipient: to };
178
+ if (subject !== undefined) body.subject = subject;
179
+ if (text !== undefined) body.text = text;
180
+ if (html !== undefined) body.html = html;
181
+ if (template !== undefined) body.templateId = template;
182
+ if (attachments !== undefined) body.attachments = attachments;
183
+ if (senderId !== undefined) body.senderId = senderId;
184
+ return self.#call("mail", "/messages", { method: "POST", body, ...write(options) });
185
+ },
186
+ };
187
+
188
+ this.auth = {
189
+ // MDRN's Proxy deletes every x-mdrn-* header a client sent before adding these two for a
190
+ // signed-in visitor, so they can be trusted on a request MDRN delivered.
191
+ user: (request) => {
192
+ const id = request.headers.get("x-mdrn-user-id");
193
+ const token = request.headers.get("x-mdrn-identity-token");
194
+ if (!id || !token || !USER_ID.test(id)) return null;
195
+ return { id, token };
196
+ },
197
+ me: async (request) => {
198
+ const user = self.auth.user(request);
199
+ if (!user) return null;
200
+ const headers = new Headers({ Accept: "application/json", Authorization: `Bearer ${user.token}`, "MDRN-Project": self.project, "MDRN-Environment": self.environment });
201
+ return self.#send(self.#fetcher, new URL("/v1/auth/me", request.url).href, { method: "GET", headers }, undefined,
202
+ (response) => (response.status === 401 || response.status === 404 ? null : self.#payload(response)));
203
+ },
204
+ };
205
+ }
206
+
207
+ /** The client of a deployed release, from what MDRN injected into `env`. Product requests go
208
+ * through the MDRN_PRODUCTS service binding when there is one, else the global fetch. */
209
+ static fromEnv(env, { fetcher, transfer, timeoutMs } = {}) {
210
+ if (!env || typeof env !== "object") throw new TypeError("env is required");
211
+ const binding = env.MDRN_PRODUCTS;
212
+ const options = {
213
+ project: env.MDRN_PROJECT_ID,
214
+ environment: env.MDRN_ENVIRONMENT,
215
+ tokens: { database: env.MDRN_DATABASE_TOKEN, storage: env.MDRN_STORAGE_TOKEN, mail: env.MDRN_MAIL_TOKEN },
216
+ urls: { database: env.MDRN_DATABASE_URL, storage: env.MDRN_STORAGE_URL, mail: env.MDRN_MAIL_URL },
217
+ fetcher: fetcher ?? (typeof binding?.fetch === "function" ? binding.fetch.bind(binding) : globalThis.fetch),
218
+ };
219
+ if (transfer !== undefined) options.transfer = transfer;
220
+ if (timeoutMs !== undefined) options.timeoutMs = timeoutMs;
221
+ return new Mdrn(options);
222
+ }
223
+
224
+ // One request to a product: its URL and token, the project and environment, and the write
225
+ // headers when given. The answer is the JSON body, undefined for 204, or an MdrnError.
226
+ async #call(name, path, { method = "GET", body, idempotencyKey, ifMatch, signal } = {}) {
227
+ const product = this.#products[name];
228
+ if (product.url === undefined) throw new MdrnError(0, "PRODUCT_NOT_ENABLED", product.unavailable);
229
+ const headers = new Headers({
230
+ Accept: "application/json",
231
+ Authorization: `Bearer ${product.token}`,
232
+ "MDRN-Project": this.project,
233
+ "MDRN-Environment": this.environment,
234
+ });
235
+ if (body !== undefined) headers.set("Content-Type", "application/json");
236
+ if (ifMatch !== undefined) headers.set("If-Match", String(ifMatch));
237
+ if (idempotencyKey !== undefined) headers.set("Idempotency-Key", idempotencyKey);
238
+ return this.#send(this.#fetcher, product.url + path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }, signal, (response) => this.#payload(response));
239
+ }
240
+
241
+ // The response body as JSON; an MdrnError for a failure, with the code of MDRN's error
242
+ // envelope ({error: {code, message}} or {code, message}), or HTTP_ERROR when there is none.
243
+ async #payload(response) {
244
+ const payload = response.status === 204 ? undefined : await response.json().catch(() => undefined);
245
+ if (!response.ok) {
246
+ const error = payload?.error && typeof payload.error === "object" ? payload.error : payload;
247
+ throw new MdrnError(response.status, typeof error?.code === "string" ? error.code : "HTTP_ERROR", typeof error?.message === "string" ? error.message : undefined, payload);
248
+ }
249
+ return payload;
250
+ }
251
+
252
+ // A fetch with the deadline, without following redirects (a 3xx is an error, so the bearer
253
+ // token can never be carried to another origin), and with transport failures as MdrnErrors.
254
+ // `read` consumes the response under the same deadline; the deadline's timer is cleared once
255
+ // it has, so a finished request keeps no process alive. (AbortSignal.timeout would be shorter,
256
+ // but its timer is unreferenced on Node: a process with nothing else pending exits before it
257
+ // fires, and the request never settles.)
258
+ async #send(fetcher, url, init, signal, read = (response) => response) {
259
+ // A request cancelled before it is sent is not sent (and never handed to a fetcher that
260
+ // would wait for an abort event that already happened).
261
+ if (signal?.aborted) throw signal.reason;
262
+ const deadline = new AbortController();
263
+ const timer = setTimeout(() => deadline.abort(), this.#timeoutMs);
264
+ try {
265
+ let response;
266
+ try {
267
+ // Workers accept only "follow" or "manual" for redirect.
268
+ response = await fetcher(new Request(url, { ...init, redirect: "manual", signal: signal ? AbortSignal.any([signal, deadline.signal]) : deadline.signal }));
269
+ } catch (error) {
270
+ if (signal?.aborted) throw error;
271
+ if (deadline.signal.aborted) throw new MdrnError(0, "TIMEOUT", `MDRN did not answer within ${this.#timeoutMs} ms`, error);
272
+ throw new MdrnError(0, "NETWORK_ERROR", `MDRN could not be reached: ${error?.message ?? error}`, error);
273
+ }
274
+ if (response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
275
+ throw new MdrnError(response.status, "UNEXPECTED_REDIRECT", `MDRN answered with a redirect (HTTP ${response.status}), which the SDK does not follow`, { location: response.headers.get("location") });
276
+ }
277
+ return await read(response);
278
+ } finally {
279
+ clearTimeout(timer);
280
+ }
281
+ }
282
+
283
+ // A read that answers null when there is no such thing.
284
+ async #read(name, path) {
285
+ try {
286
+ return await this.#call(name, path);
287
+ } catch (error) {
288
+ if (error instanceof MdrnError && error.status === 404) return null;
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ // An update or delete. With a version: one request with that If-Match, never retried. Without:
294
+ // the record is read and the write sent with the version read, and on a 409 (the record
295
+ // changed in between) the pair is repeated up to CONFLICT_RETRIES more times. Each attempt is
296
+ // its own change, so a retry gets a fresh idempotency key (MDRN refuses a key reused with
297
+ // another If-Match); the caller's key, if any, is used for the first attempt.
298
+ async #mutate(collection, id, version, options, attempt) {
299
+ if (version !== undefined) return attempt(version, options.idempotencyKey ?? crypto.randomUUID());
300
+ for (let retries = 0; ; retries += 1) {
301
+ const current = await this.#call("db", records(collection, id), { signal: options.signal });
302
+ try {
303
+ return await attempt(current.version, retries === 0 && options.idempotencyKey !== undefined ? options.idempotencyKey : crypto.randomUUID());
304
+ } catch (error) {
305
+ if (!(error instanceof MdrnError && error.status === 409 && retries < CONFLICT_RETRIES)) throw error;
306
+ }
307
+ }
308
+ }
309
+
310
+ // The whole upload of a file: its SHA-256 up front, an upload session, one grant and one PUT
311
+ // per part of `partBytes` (16 MiB; the session says), completion, and (unless wait is false)
312
+ // the object once Files has verified it. Bytes go to the grant URL with the grant's own
313
+ // headers through `transfer`, never through the binding and never with a product token.
314
+ async #upload({ name, type, bytes }, options = {}) {
315
+ const { signal, wait = true } = options;
316
+ const key = options.idempotencyKey ?? crypto.randomUUID();
317
+ const sha256 = hex(await crypto.subtle.digest("SHA-256", bytes));
318
+ const opened = await this.files.uploads.create({ filename: name, contentType: type || "application/octet-stream", bytes: bytes.byteLength, sha256 }, { idempotencyKey: key, signal });
319
+ const upload = await this.#until(() => this.files.uploads.get(opened.id), (u) => u === null || u.state !== "pending");
320
+ if (upload === null || upload.state !== "uploading") throw new MdrnError(0, "UPLOAD_FAILED", `The upload is ${upload?.state ?? "gone"}, not uploading`, upload);
321
+ const partBytes = Number.isInteger(upload.partBytes) && upload.partBytes > 0 ? upload.partBytes : DEFAULT_PART_BYTES;
322
+ const parts = [];
323
+ for (let offset = 0, partNumber = 1; offset < bytes.byteLength; offset += partBytes, partNumber += 1) {
324
+ const chunk = bytes.subarray(offset, Math.min(offset + partBytes, bytes.byteLength));
325
+ const grant = await this.files.uploads.part(upload.id, { partNumber, size: chunk.byteLength }, { idempotencyKey: `${key}-part-${partNumber}`, signal });
326
+ const sent = await this.#send(this.#transfer, grant.url, { method: grant.method, headers: grant.headers, body: chunk }, signal,
327
+ async (response) => ({ ok: response.ok, status: response.status, result: await response.json().catch(() => undefined) }));
328
+ const { result } = sent;
329
+ if (!sent.ok) throw new MdrnError(sent.status, "UPLOAD_FAILED", `Part ${partNumber} was not accepted (HTTP ${sent.status})`, result);
330
+ parts.push({ partNumber, etag: result?.etag });
331
+ }
332
+ const completed = await this.files.uploads.complete(upload.id, parts, { idempotencyKey: `${key}-complete`, signal });
333
+ const object = wait
334
+ ? await this.#until(() => this.files.get(completed.id), (o) => o === null || o.state !== "verifying")
335
+ : await this.files.get(completed.id);
336
+ if (object === null) throw new MdrnError(0, "FILE_REJECTED", "The file is gone", null);
337
+ if (object.state === "rejected") throw new MdrnError(0, "FILE_REJECTED", "Files rejected the upload: its bytes did not match the declared size or SHA-256", object);
338
+ return object;
339
+ }
340
+
341
+ // Polls once a second, for at most a minute, until `done` holds for the answer.
342
+ async #until(read, done) {
343
+ const deadline = Date.now() + WAIT_MS;
344
+ for (;;) {
345
+ const value = await read();
346
+ if (done(value)) return value;
347
+ if (Date.now() >= deadline) throw new MdrnError(0, "TIMEOUT", `Gave up waiting after ${WAIT_MS / 1000} s`, value);
348
+ await sleep(POLL_MS);
349
+ }
350
+ }
351
+ }
352
+
353
+ /** The schedule run a request carries, or null when it is not one. MDRN strips every x-mdrn-*
354
+ * header from public requests, so only MDRN can send these. The run id is the same across
355
+ * retries of one run; use it to make the work idempotent. */
356
+ export function scheduledRun(request) {
357
+ const schedule = request.headers.get("x-mdrn-schedule"), runId = request.headers.get("x-mdrn-run-id");
358
+ if (!schedule || !runId) return null;
359
+ return { schedule, runId, scheduledTime: request.headers.get("x-mdrn-scheduled-time"), attempt: Number(request.headers.get("x-mdrn-attempt") ?? 1) };
360
+ }