app-settings-js 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.
@@ -0,0 +1,183 @@
1
+ import { AppSettingsError } from "./errors.ts";
2
+
3
+ /**
4
+ * Helpers for the `DATETIME` type, which accepts only a strict RFC 3339
5
+ * date-time with a mandatory offset. The offset is what makes a value an
6
+ * instant rather than a wall-clock reading, so the server rejects anything
7
+ * without one — including what `<input type="datetime-local">` produces.
8
+ */
9
+
10
+ /** RFC 3339 §5.6 `date-time`, with the offset required. Lower-case t/z are legal. */
11
+ const RFC_3339 =
12
+ /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;
13
+
14
+ /** What `<input type="datetime-local">` yields: no offset, often no seconds. */
15
+ const DATETIME_LOCAL = /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}(:\d{2}(\.\d+)?)?$/;
16
+
17
+ /**
18
+ * Normalises a value to the canonical UTC instant the server stores.
19
+ *
20
+ * A `Date` or an epoch milliseconds number is unambiguous and converts freely.
21
+ * A string must already carry an offset: a bare `2026-01-02T15:04` means a
22
+ * different moment in every timezone, so guessing one would be a bug waiting
23
+ * to happen. Use {@link localToInstant} to state that assumption explicitly.
24
+ *
25
+ * @example
26
+ * toInstant(new Date()) // "2026-01-02T20:04:05.250Z"
27
+ * toInstant("2026-01-02T15:04:05-05:00") // "2026-01-02T20:04:05.000Z"
28
+ */
29
+ export function toInstant(value: Date | string | number): string {
30
+ if (value instanceof Date) {
31
+ if (Number.isNaN(value.getTime())) {
32
+ throw invalid("an Invalid Date cannot be converted to an instant");
33
+ }
34
+ return value.toISOString();
35
+ }
36
+
37
+ if (typeof value === "number") {
38
+ if (!Number.isFinite(value)) {
39
+ throw invalid(`${value} is not a valid epoch milliseconds value`);
40
+ }
41
+ return new Date(value).toISOString();
42
+ }
43
+
44
+ if (DATETIME_LOCAL.test(value)) {
45
+ throw invalid(
46
+ `"${value}" has no UTC offset, so it is a wall-clock reading rather than an instant. ` +
47
+ "Pass it through localToInstant() to read it in a specific timezone, " +
48
+ "or through toInstant(new Date(value)) to accept the runtime's own zone.",
49
+ );
50
+ }
51
+ if (!RFC_3339.test(value)) {
52
+ throw invalid(
53
+ `"${value}" is not an RFC 3339 date-time. The expected shape is ` +
54
+ "2026-01-02T15:04:05Z or 2026-01-02T15:04:05-05:00.",
55
+ );
56
+ }
57
+
58
+ const parsed = new Date(value);
59
+ if (Number.isNaN(parsed.getTime())) {
60
+ throw invalid(`"${value}" is shaped like an RFC 3339 date-time but is not a real moment`);
61
+ }
62
+ return parsed.toISOString();
63
+ }
64
+
65
+ /**
66
+ * Reads a `datetime-local` input in a named timezone and returns the instant.
67
+ *
68
+ * Passing the zone makes the assumption visible at the call site, which is the
69
+ * whole point: the same reading is a different moment in each zone.
70
+ *
71
+ * @param local A `datetime-local` value such as `2026-01-02T15:04`.
72
+ * @param timeZone An IANA zone. Defaults to the runtime's own.
73
+ *
74
+ * @example
75
+ * localToInstant("2026-01-02T15:04", "America/New_York") // "2026-01-02T20:04:00.000Z"
76
+ */
77
+ export function localToInstant(local: string, timeZone?: string): string {
78
+ if (!DATETIME_LOCAL.test(local) && !RFC_3339.test(local)) {
79
+ throw invalid(`"${local}" is not a datetime-local value such as 2026-01-02T15:04`);
80
+ }
81
+ if (RFC_3339.test(local)) return toInstant(local);
82
+
83
+ // Without a zone the runtime's own is the only sensible reading, and `Date`
84
+ // already applies it.
85
+ if (!timeZone) {
86
+ const parsed = new Date(local);
87
+ if (Number.isNaN(parsed.getTime())) throw invalid(`"${local}" is not a real moment`);
88
+ return parsed.toISOString();
89
+ }
90
+
91
+ // Treat the reading as UTC, then measure how far that guess sits from the
92
+ // target zone and correct by it. Two passes settle the case where the offset
93
+ // itself changes across the correction, as it does at a DST boundary.
94
+ const naive = Date.parse(`${withSeconds(local)}Z`);
95
+ if (Number.isNaN(naive)) throw invalid(`"${local}" is not a real moment`);
96
+
97
+ let instant = naive;
98
+ for (let pass = 0; pass < 2; pass++) {
99
+ instant = naive + zoneOffsetMs(instant, timeZone);
100
+ }
101
+ return new Date(instant).toISOString();
102
+ }
103
+
104
+ /**
105
+ * Parses a value returned by the API into a `Date`.
106
+ *
107
+ * Returns `undefined` for null or undefined, so an unset `DATETIME` reads
108
+ * naturally without a guard at every call site.
109
+ */
110
+ export function parseInstant(value: unknown): Date | undefined {
111
+ if (value === null || value === undefined) return undefined;
112
+ if (value instanceof Date) return value;
113
+
114
+ if (typeof value !== "string") {
115
+ throw invalid(`expected an RFC 3339 string, got ${typeof value}`);
116
+ }
117
+ const parsed = new Date(value);
118
+ if (Number.isNaN(parsed.getTime())) {
119
+ throw invalid(`"${value}" is not a parseable date-time`);
120
+ }
121
+ return parsed;
122
+ }
123
+
124
+ /** Reports whether a string is something the server will accept as a DATETIME. */
125
+ export function isInstant(value: unknown): value is string {
126
+ return typeof value === "string" && RFC_3339.test(value) && !Number.isNaN(Date.parse(value));
127
+ }
128
+
129
+ /**
130
+ * Renders an instant for a `<input type="datetime-local">`, which is the
131
+ * inverse of {@link localToInstant} and equally zone-dependent.
132
+ */
133
+ export function toDateTimeLocal(value: Date | string | number, timeZone?: string): string {
134
+ const date = value instanceof Date ? value : new Date(typeof value === "string" ? value : Number(value));
135
+ if (Number.isNaN(date.getTime())) throw invalid("cannot render an Invalid Date");
136
+
137
+ const parts = zoneParts(date, timeZone);
138
+ return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}`;
139
+ }
140
+
141
+ /** How far `timeZone` sits from UTC at a given instant, in milliseconds. */
142
+ function zoneOffsetMs(instant: number, timeZone: string): number {
143
+ const parts = zoneParts(new Date(instant), timeZone);
144
+ const asUtc = Date.UTC(
145
+ Number(parts.year),
146
+ Number(parts.month) - 1,
147
+ Number(parts.day),
148
+ Number(parts.hour),
149
+ Number(parts.minute),
150
+ Number(parts.second),
151
+ new Date(instant).getUTCMilliseconds(),
152
+ );
153
+ return instant - asUtc;
154
+ }
155
+
156
+ /** Splits an instant into calendar fields as they read in a timezone. */
157
+ function zoneParts(date: Date, timeZone: string | undefined) {
158
+ const formatter = new Intl.DateTimeFormat("en-US", {
159
+ timeZone,
160
+ hourCycle: "h23",
161
+ year: "numeric",
162
+ month: "2-digit",
163
+ day: "2-digit",
164
+ hour: "2-digit",
165
+ minute: "2-digit",
166
+ second: "2-digit",
167
+ });
168
+
169
+ const parts: Record<string, string> = {};
170
+ for (const part of formatter.formatToParts(date)) {
171
+ if (part.type !== "literal") parts[part.type] = part.value;
172
+ }
173
+ return parts as Record<"year" | "month" | "day" | "hour" | "minute" | "second", string>;
174
+ }
175
+
176
+ /** `datetime-local` may omit seconds; `Date.parse` wants them. */
177
+ function withSeconds(local: string): string {
178
+ return /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}$/.test(local) ? `${local}:00` : local;
179
+ }
180
+
181
+ function invalid(message: string): AppSettingsError {
182
+ return new AppSettingsError(message, { code: "invalid_value" });
183
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,95 @@
1
+ import type { ErrorCode } from "./types.ts";
2
+
3
+ /**
4
+ * Codes this SDK raises on top of the ones the server defines. They occupy the
5
+ * same `code` field so a single `catch` can branch on one value.
6
+ */
7
+ export type ClientErrorCode =
8
+ /** The request never got a response: DNS, TLS, CORS, offline. */
9
+ | "network_error"
10
+ /** The request exceeded `timeoutMs`, or its signal was aborted. */
11
+ | "timeout"
12
+ /** The caller aborted the request through an `AbortSignal`. */
13
+ | "aborted"
14
+ /** A response arrived but was not the JSON this SDK expected. */
15
+ | "invalid_response"
16
+ /** A typed accessor was used on a setting of a different type. */
17
+ | "type_mismatch"
18
+ /** A value was rejected before it was ever sent. */
19
+ | "invalid_value";
20
+
21
+ /** Every code an {@link AppSettingsError} can carry. */
22
+ export type AnyErrorCode = ErrorCode | ClientErrorCode;
23
+
24
+ /**
25
+ * The single error type this SDK throws.
26
+ *
27
+ * Branch on {@link AppSettingsError.code}, which is stable, rather than on the
28
+ * message, which is written for a human.
29
+ */
30
+ export class AppSettingsError extends Error {
31
+ override readonly name = "AppSettingsError";
32
+
33
+ /** A stable identifier for the failure. */
34
+ readonly code: AnyErrorCode;
35
+ /** The HTTP status, when the failure came from a response. */
36
+ readonly status?: number;
37
+ /** The server's `X-Request-ID`, worth quoting in a bug report. */
38
+ readonly requestId?: string;
39
+ /** The request that failed, as `GET /api/v1/settings`. */
40
+ readonly request?: string;
41
+ /** The parsed response body, when there was one. */
42
+ readonly body?: unknown;
43
+
44
+ constructor(
45
+ message: string,
46
+ options: {
47
+ code: AnyErrorCode;
48
+ status?: number;
49
+ requestId?: string;
50
+ request?: string;
51
+ body?: unknown;
52
+ cause?: unknown;
53
+ },
54
+ ) {
55
+ super(message, { cause: options.cause });
56
+ this.code = options.code;
57
+ this.status = options.status;
58
+ this.requestId = options.requestId;
59
+ this.request = options.request;
60
+ this.body = options.body;
61
+ }
62
+
63
+ /** Whether the failure is worth trying again unchanged. */
64
+ get retryable(): boolean {
65
+ if (this.code === "network_error" || this.code === "timeout" || this.code === "unavailable") {
66
+ return true;
67
+ }
68
+ return this.status === 429 || (this.status !== undefined && this.status >= 500);
69
+ }
70
+
71
+ /** Narrows an unknown caught value to this class. */
72
+ static is(error: unknown): error is AppSettingsError {
73
+ return error instanceof AppSettingsError;
74
+ }
75
+ }
76
+
77
+ /** The requested thing does not exist, or this key may not see that it does. */
78
+ export const isNotFound = (error: unknown): boolean => codeIs(error, "not_found");
79
+
80
+ /** The API key was missing, malformed, expired or revoked. */
81
+ export const isUnauthorized = (error: unknown): boolean => codeIs(error, "unauthorized");
82
+
83
+ /** The key is real but is fenced out of what it asked for. */
84
+ export const isForbidden = (error: unknown): boolean => codeIs(error, "forbidden");
85
+
86
+ /** The request collided with existing state, such as a duplicate name. */
87
+ export const isConflict = (error: unknown): boolean => codeIs(error, "conflict");
88
+
89
+ /** The request was malformed or a value failed validation. */
90
+ export const isInvalidRequest = (error: unknown): boolean =>
91
+ codeIs(error, "invalid_request") || codeIs(error, "invalid_value");
92
+
93
+ function codeIs(error: unknown, code: AnyErrorCode): boolean {
94
+ return AppSettingsError.is(error) && error.code === code;
95
+ }
package/src/http.ts ADDED
@@ -0,0 +1,314 @@
1
+ import { AppSettingsError } from "./errors.ts";
2
+
3
+ /** The subset of `fetch` this SDK uses, so any compatible implementation fits. */
4
+ export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
5
+
6
+ /** A value that can appear in a query string. Arrays become repeated keys. */
7
+ export type QueryValue = string | number | boolean | string[] | undefined | null;
8
+
9
+ /** Per-call options accepted by every method on the client. */
10
+ export interface RequestOptions {
11
+ /** Cancels the request, and any retry still pending. */
12
+ signal?: AbortSignal;
13
+ /** Overrides the client's `timeoutMs` for this call. */
14
+ timeoutMs?: number;
15
+ /** Extra headers, merged over the client's own. */
16
+ headers?: Record<string, string>;
17
+ }
18
+
19
+ /** Everything the transport needs to make one call. */
20
+ export interface TransportConfig {
21
+ baseUrl: string;
22
+ apiKey: string;
23
+ fetch: FetchLike;
24
+ timeoutMs: number;
25
+ retries: number;
26
+ retryDelayMs: number;
27
+ headers: Record<string, string>;
28
+ userAgent?: string;
29
+ }
30
+
31
+ /** Options for building a client's transport. */
32
+ export interface TransportOptions {
33
+ /** Where the server lives, such as `https://settings.example.com`. */
34
+ baseUrl: string;
35
+ /** The API key sent as `Authorization: Bearer`. */
36
+ apiKey: string;
37
+ /** A `fetch` implementation. Defaults to the global one. */
38
+ fetch?: FetchLike;
39
+ /** How long one attempt may take. Defaults to 10000; 0 disables the timeout. */
40
+ timeoutMs?: number;
41
+ /** How many times to retry a retryable failure. Defaults to 2. */
42
+ retries?: number;
43
+ /** Base backoff between retries, doubled each time. Defaults to 200. */
44
+ retryDelayMs?: number;
45
+ /** Headers added to every request. */
46
+ headers?: Record<string, string>;
47
+ }
48
+
49
+ /**
50
+ * Builds the transport config, resolving defaults once so each request does no
51
+ * more work than it has to.
52
+ */
53
+ export function createTransport(options: TransportOptions): TransportConfig {
54
+ const fetchImpl = options.fetch ?? globalThis.fetch;
55
+ if (typeof fetchImpl !== "function") {
56
+ throw new AppSettingsError(
57
+ "No `fetch` is available. Pass one as `fetch` in the client options, or run on Node 18+, Bun, Deno or a browser.",
58
+ { code: "invalid_request" },
59
+ );
60
+ }
61
+ if (!options.baseUrl) {
62
+ throw new AppSettingsError("`baseUrl` is required, for example http://localhost:8080", {
63
+ code: "invalid_request",
64
+ });
65
+ }
66
+ if (!options.apiKey) {
67
+ throw new AppSettingsError("`apiKey` is required; every route below /api/v1 needs one", {
68
+ code: "invalid_request",
69
+ });
70
+ }
71
+
72
+ return {
73
+ // A trailing slash would double up when paths are appended.
74
+ baseUrl: options.baseUrl.replace(/\/+$/, ""),
75
+ apiKey: options.apiKey,
76
+ // Unbind so an implementation that checks its receiver (the browser's) works.
77
+ fetch: (input, init) => fetchImpl(input, init),
78
+ timeoutMs: options.timeoutMs ?? 10_000,
79
+ retries: Math.max(0, options.retries ?? 2),
80
+ retryDelayMs: Math.max(0, options.retryDelayMs ?? 200),
81
+ headers: { ...options.headers },
82
+ };
83
+ }
84
+
85
+ /** One request, before defaults and retries are applied. */
86
+ export interface RequestSpec {
87
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
88
+ path: string;
89
+ query?: Record<string, QueryValue>;
90
+ body?: unknown;
91
+ options?: RequestOptions;
92
+ }
93
+
94
+ /**
95
+ * Performs a request and decodes its body.
96
+ *
97
+ * Returns `undefined` for a 204, which is what every successful DELETE returns.
98
+ */
99
+ export async function request<T>(config: TransportConfig, spec: RequestSpec): Promise<T> {
100
+ const url = config.baseUrl + spec.path + buildQuery(spec.query);
101
+ const label = `${spec.method} ${spec.path}`;
102
+ const timeoutMs = spec.options?.timeoutMs ?? config.timeoutMs;
103
+
104
+ const headers: Record<string, string> = {
105
+ accept: "application/json",
106
+ authorization: `Bearer ${config.apiKey}`,
107
+ ...config.headers,
108
+ ...lowercaseKeys(spec.options?.headers),
109
+ };
110
+ let payload: string | undefined;
111
+ if (spec.body !== undefined) {
112
+ payload = JSON.stringify(spec.body);
113
+ headers["content-type"] = "application/json";
114
+ }
115
+
116
+ // POST is the only non-idempotent method here, so it is the only one a retry
117
+ // could duplicate. Everything else is safe to repeat.
118
+ const attempts = spec.method === "POST" ? 1 : config.retries + 1;
119
+ let lastError: AppSettingsError | undefined;
120
+
121
+ for (let attempt = 0; attempt < attempts; attempt++) {
122
+ if (attempt > 0) {
123
+ await delay(backoffFor(attempt, config.retryDelayMs, lastError), spec.options?.signal);
124
+ }
125
+
126
+ let response: Response;
127
+ try {
128
+ response = await config.fetch(url, {
129
+ method: spec.method,
130
+ headers,
131
+ body: payload,
132
+ signal: timeoutSignal(timeoutMs, spec.options?.signal),
133
+ });
134
+ } catch (cause) {
135
+ lastError = fromThrown(cause, label, spec.options?.signal, timeoutMs);
136
+ // An abort is the caller's decision, and a timeout has already spent its
137
+ // budget on a signal we cannot renew. Neither is worth another attempt.
138
+ if (lastError.code === "aborted" || lastError.code === "timeout") throw lastError;
139
+ continue;
140
+ }
141
+
142
+ if (response.ok) return (await decode<T>(response, label)) as T;
143
+
144
+ lastError = await errorFromResponse(response, label);
145
+ if (!lastError.retryable || attempt === attempts - 1) throw lastError;
146
+ }
147
+
148
+ throw lastError ?? new AppSettingsError(`${label} failed`, { code: "network_error", request: label });
149
+ }
150
+
151
+ /** Renders a query string, repeating a key for each element of an array. */
152
+ export function buildQuery(query: Record<string, QueryValue> | undefined): string {
153
+ if (!query) return "";
154
+
155
+ const params = new URLSearchParams();
156
+ for (const [key, value] of Object.entries(query)) {
157
+ if (value === undefined || value === null || value === "") continue;
158
+ if (Array.isArray(value)) {
159
+ for (const item of value) if (item !== "") params.append(key, item);
160
+ } else {
161
+ params.append(key, String(value));
162
+ }
163
+ }
164
+
165
+ const rendered = params.toString();
166
+ return rendered ? `?${rendered}` : "";
167
+ }
168
+
169
+ /** Reads a successful body, tolerating the empty one a 204 carries. */
170
+ async function decode<T>(response: Response, label: string): Promise<T | undefined> {
171
+ if (response.status === 204) return undefined;
172
+
173
+ const text = await response.text();
174
+ if (text === "") return undefined;
175
+
176
+ try {
177
+ return JSON.parse(text) as T;
178
+ } catch (cause) {
179
+ throw new AppSettingsError(`${label} returned a body that is not JSON`, {
180
+ code: "invalid_response",
181
+ status: response.status,
182
+ requestId: response.headers.get("x-request-id") ?? undefined,
183
+ request: label,
184
+ body: text.slice(0, 512),
185
+ cause,
186
+ });
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Turns a failed response into an error, preferring the server's own message.
192
+ * Every endpoint returns `{"error": {"code", "message"}}`, so this is usually
193
+ * exact; a proxy in between might not, hence the fallbacks.
194
+ */
195
+ async function errorFromResponse(response: Response, label: string): Promise<AppSettingsError> {
196
+ const requestId = response.headers.get("x-request-id") ?? undefined;
197
+ const text = await response.text().catch(() => "");
198
+
199
+ let body: unknown;
200
+ try {
201
+ body = text ? JSON.parse(text) : undefined;
202
+ } catch {
203
+ body = text;
204
+ }
205
+
206
+ const detail = (body as { error?: { code?: string; message?: string } } | undefined)?.error;
207
+ const message = detail?.message ?? (text ? text.slice(0, 512) : response.statusText) ?? "request failed";
208
+
209
+ return new AppSettingsError(`${label} failed with ${response.status}: ${message}`, {
210
+ code: (detail?.code as AppSettingsError["code"]) ?? statusToCode(response.status),
211
+ status: response.status,
212
+ requestId,
213
+ request: label,
214
+ body,
215
+ });
216
+ }
217
+
218
+ /** Maps a status onto the code the server would have used for it. */
219
+ function statusToCode(status: number): AppSettingsError["code"] {
220
+ switch (status) {
221
+ case 400:
222
+ return "invalid_request";
223
+ case 401:
224
+ return "unauthorized";
225
+ case 403:
226
+ return "forbidden";
227
+ case 404:
228
+ return "not_found";
229
+ case 409:
230
+ return "conflict";
231
+ case 503:
232
+ return "unavailable";
233
+ default:
234
+ return status >= 500 ? "internal_error" : "invalid_request";
235
+ }
236
+ }
237
+
238
+ /** Classifies a throw from `fetch` itself, which never reached a response. */
239
+ function fromThrown(cause: unknown, label: string, signal: AbortSignal | undefined, timeoutMs: number): AppSettingsError {
240
+ const aborted = cause instanceof Error && (cause.name === "AbortError" || cause.name === "TimeoutError");
241
+
242
+ if (aborted && signal?.aborted) {
243
+ return new AppSettingsError(`${label} was aborted`, { code: "aborted", request: label, cause });
244
+ }
245
+ if (aborted) {
246
+ return new AppSettingsError(`${label} timed out after ${timeoutMs}ms`, {
247
+ code: "timeout",
248
+ request: label,
249
+ cause,
250
+ });
251
+ }
252
+ return new AppSettingsError(`${label} could not reach the server: ${errorText(cause)}`, {
253
+ code: "network_error",
254
+ request: label,
255
+ cause,
256
+ });
257
+ }
258
+
259
+ /** Combines the caller's signal with a timeout, using whichever exist. */
260
+ function timeoutSignal(timeoutMs: number, signal: AbortSignal | undefined): AbortSignal | undefined {
261
+ const timeout = timeoutMs > 0 && typeof AbortSignal?.timeout === "function" ? AbortSignal.timeout(timeoutMs) : undefined;
262
+
263
+ if (!timeout) return signal;
264
+ if (!signal) return timeout;
265
+ if (typeof AbortSignal.any === "function") return AbortSignal.any([signal, timeout]);
266
+
267
+ // An older runtime without AbortSignal.any: the caller's own signal wins,
268
+ // since losing cancellation is worse than losing a timeout.
269
+ return signal;
270
+ }
271
+
272
+ /** Honours `Retry-After` when the server sent one, else backs off exponentially. */
273
+ function backoffFor(attempt: number, base: number, previous: AppSettingsError | undefined): number {
274
+ const retryAfter = retryAfterMs(previous);
275
+ if (retryAfter !== undefined) return retryAfter;
276
+
277
+ const exponential = base * 2 ** (attempt - 1);
278
+ // Jitter keeps a fleet of clients from retrying in lockstep after an outage.
279
+ return exponential + Math.random() * base;
280
+ }
281
+
282
+ function retryAfterMs(error: AppSettingsError | undefined): number | undefined {
283
+ if (error?.status !== 429 && error?.status !== 503) return undefined;
284
+ const header = (error.body as { retry_after?: number } | undefined)?.retry_after;
285
+ return typeof header === "number" && header >= 0 ? header * 1000 : undefined;
286
+ }
287
+
288
+ /** A cancellable sleep, so an abort during backoff takes effect immediately. */
289
+ function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
290
+ if (ms <= 0) return Promise.resolve();
291
+
292
+ return new Promise((resolve, reject) => {
293
+ const timer = setTimeout(finish, ms);
294
+ signal?.addEventListener("abort", onAbort, { once: true });
295
+
296
+ function finish() {
297
+ signal?.removeEventListener("abort", onAbort);
298
+ resolve();
299
+ }
300
+ function onAbort() {
301
+ clearTimeout(timer);
302
+ reject(new AppSettingsError("the request was aborted", { code: "aborted" }));
303
+ }
304
+ });
305
+ }
306
+
307
+ function lowercaseKeys(headers: Record<string, string> | undefined): Record<string, string> {
308
+ if (!headers) return {};
309
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
310
+ }
311
+
312
+ function errorText(cause: unknown): string {
313
+ return cause instanceof Error ? cause.message : String(cause);
314
+ }
package/src/index.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * A TypeScript SDK for the App Settings API.
3
+ *
4
+ * It has no dependencies and runs anywhere `fetch` does: browsers, Node 18+,
5
+ * Bun, Deno and edge runtimes.
6
+ */
7
+
8
+ export { AppSettingsClient } from "./client.ts";
9
+ export type {
10
+ ClientOptions,
11
+ CreateGroupInput,
12
+ CreateKeyInput,
13
+ CreateSettingInput,
14
+ FetchLike,
15
+ GroupValueOptions,
16
+ ListSettingsOptions,
17
+ RequestOptions,
18
+ ResolveOptions,
19
+ ResolveUserOptions,
20
+ UpdateSettingInput,
21
+ } from "./client.ts";
22
+
23
+ export { SettingsSnapshot, snapshotFrom } from "./snapshot.ts";
24
+
25
+ export { createSettingsStore } from "./store.ts";
26
+ export type { SettingsState, SettingsStore, SettingsStoreOptions } from "./store.ts";
27
+
28
+ export {
29
+ AppSettingsError,
30
+ isConflict,
31
+ isForbidden,
32
+ isInvalidRequest,
33
+ isNotFound,
34
+ isUnauthorized,
35
+ } from "./errors.ts";
36
+ export type { AnyErrorCode, ClientErrorCode } from "./errors.ts";
37
+
38
+ export { isInstant, localToInstant, parseInstant, toDateTimeLocal, toInstant } from "./datetime.ts";
39
+
40
+ export type {
41
+ ApiKey,
42
+ CreatedApiKey,
43
+ Environment,
44
+ ErrorCode,
45
+ Group,
46
+ GroupMember,
47
+ Health,
48
+ IntermediateValue,
49
+ Override,
50
+ PersonalValue,
51
+ Platform,
52
+ ResolvedSetting,
53
+ ResolveResponse,
54
+ Role,
55
+ Scope,
56
+ SelectOption,
57
+ ServerValue,
58
+ Setting,
59
+ SettingScope,
60
+ SettingType,
61
+ SettingValue,
62
+ TypeConfig,
63
+ ValueSource,
64
+ WhoAmI,
65
+ } from "./types.ts";