@fonderie/client 0.2.0 → 0.4.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.
@@ -5,11 +5,41 @@
5
5
  ## @fonderie/client
6
6
 
7
7
  ```ts
8
+ interface IClientAuthConfig {
9
+ getRefreshToken?: () => string | undefined;
10
+ onTokensChanged?: (tokens: ITokens) => void;
11
+ onAuthError?: () => void;
12
+ }
13
+
8
14
  interface IFonderieClientOptions {
9
15
  baseUrl: string;
10
16
  accessToken?: string;
17
+ workspaceId?: string;
18
+ cache?: ICache;
19
+ auth?: IClientAuthConfig;
20
+ }
21
+
22
+ interface IRequestConfig {
23
+ workspaceId?: string;
24
+ cache?: number | false;
25
+ bust?: boolean;
26
+ invalidate?: string[];
27
+ }
28
+
29
+ interface ICache {
30
+ get<T>(key: string): T | undefined;
31
+ set<T>(key: string, value: T, ttlMs: number): void;
32
+ dedupe<T>(key: string, fn: () => Promise<T>): Promise<T>;
33
+ invalidate(fragment: string): void;
34
+ clear(): void;
11
35
  }
12
36
 
37
+ interface IMemoryCacheOptions {
38
+ defaultTtlMs?: number;
39
+ }
40
+
41
+ function createMemoryCache(opts?: IMemoryCacheOptions): ICache & { defaultTtlMs: number; }
42
+
13
43
  new FonderieClient(opts: IFonderieClientOptions): FonderieClient
14
44
  .auth: AuthClient
15
45
  .billing: BillingClient
@@ -17,6 +47,15 @@ new FonderieClient(opts: IFonderieClientOptions): FonderieClient
17
47
  .audit: AuditClient
18
48
  .webhooks: WebhooksClient
19
49
  .customers: CustomersClient
50
+ .setAccessToken(token: string | undefined): void
51
+ .clearCache(): void
52
+ .setWorkspaceId(workspaceId: string | undefined): void
53
+ .request<T = unknown>(opts: { method: string; path: string; body?: unknown; workspaceId?: string | undefined; cache?: number | false | undefined; bust?: boolean | undefined; invalidate?: string[] | undefined; }): Promise<IApiResponse<...>>
54
+ .get<T = unknown>(path: string, config?: IRequestConfig | undefined): Promise<IApiResponse<T>>
55
+ .post<T = unknown>(path: string, body?: unknown, config?: IRequestConfig | undefined): Promise<IApiResponse<T>>
56
+ .put<T = unknown>(path: string, body?: unknown, config?: IRequestConfig | undefined): Promise<IApiResponse<T>>
57
+ .patch<T = unknown>(path: string, body?: unknown, config?: IRequestConfig | undefined): Promise<IApiResponse<T>>
58
+ .delete<T = unknown>(path: string, config?: IRequestConfig | undefined): Promise<IApiResponse<T>>
20
59
 
21
60
  new FonderieApiError(reason: string, explanation: string, status: number, details?: unknown): FonderieApiError
22
61
  .reason: string
package/dist/index.cjs CHANGED
@@ -29,10 +29,48 @@ __export(index_exports, {
29
29
  FonderieApiError: () => FonderieApiError,
30
30
  FonderieClient: () => FonderieClient,
31
31
  WebhooksClient: () => WebhooksClient,
32
- WorkspacesClient: () => WorkspacesClient
32
+ WorkspacesClient: () => WorkspacesClient,
33
+ createMemoryCache: () => createMemoryCache
33
34
  });
34
35
  module.exports = __toCommonJS(index_exports);
35
36
 
37
+ // src/cache.ts
38
+ function createMemoryCache(opts = {}) {
39
+ const store = /* @__PURE__ */ new Map();
40
+ const inflight = /* @__PURE__ */ new Map();
41
+ const defaultTtlMs = opts.defaultTtlMs ?? 6e4;
42
+ return {
43
+ defaultTtlMs,
44
+ get(key) {
45
+ const entry = store.get(key);
46
+ if (!entry) return void 0;
47
+ if (Date.now() > entry.expiresAt) {
48
+ store.delete(key);
49
+ return void 0;
50
+ }
51
+ return entry.value;
52
+ },
53
+ set(key, value, ttlMs) {
54
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
55
+ },
56
+ dedupe(key, fn) {
57
+ const existing = inflight.get(key);
58
+ if (existing) return existing;
59
+ const req = fn().finally(() => inflight.delete(key));
60
+ inflight.set(key, req);
61
+ return req;
62
+ },
63
+ invalidate(fragment) {
64
+ for (const key of store.keys()) if (key.includes(fragment)) store.delete(key);
65
+ for (const key of inflight.keys()) if (key.includes(fragment)) inflight.delete(key);
66
+ },
67
+ clear() {
68
+ store.clear();
69
+ inflight.clear();
70
+ }
71
+ };
72
+ }
73
+
36
74
  // src/http.ts
37
75
  var FonderieApiError = class extends Error {
38
76
  constructor(reason, explanation, status, details) {
@@ -49,24 +87,52 @@ var FonderieApiError = class extends Error {
49
87
  details;
50
88
  };
51
89
  var HttpClient = class {
52
- constructor(baseUrl) {
90
+ constructor(baseUrl, deps = {}) {
53
91
  this.baseUrl = baseUrl;
92
+ this.cache = deps.cache;
93
+ this.defaultTtlMs = deps.defaultTtlMs ?? 6e4;
94
+ this.refresh = deps.refresh;
54
95
  }
55
96
  baseUrl;
97
+ cache;
98
+ defaultTtlMs;
99
+ refresh;
56
100
  async request(opts) {
57
- const headers = {
58
- "Content-Type": "application/json"
59
- };
101
+ const method = opts.method.toUpperCase();
102
+ const cache = this.cache;
103
+ if (cache && method === "GET" && opts.cache !== false) {
104
+ const key = `GET ${opts.path}::ws=${opts.workspaceId ?? ""}`;
105
+ if (!opts.bust) {
106
+ const hit = cache.get(key);
107
+ if (hit !== void 0) return hit;
108
+ }
109
+ const ttl = typeof opts.cache === "number" ? opts.cache : this.defaultTtlMs;
110
+ return cache.dedupe(key, async () => {
111
+ const data2 = await this.exec(opts);
112
+ cache.set(key, data2, ttl);
113
+ return data2;
114
+ });
115
+ }
116
+ const data = await this.exec(opts);
117
+ if (cache && method !== "GET") {
118
+ const resource = opts.path.split("?")[0]?.split("/").filter(Boolean)[0];
119
+ if (resource) cache.invalidate(`/${resource}`);
120
+ for (const fragment of opts.invalidate ?? []) cache.invalidate(fragment);
121
+ }
122
+ return data;
123
+ }
124
+ async exec(opts, retried = false) {
125
+ const headers = { "Content-Type": "application/json" };
60
126
  if (opts.token) headers["Authorization"] = `Bearer ${opts.token}`;
61
127
  if (opts.cookie) headers["Cookie"] = opts.cookie;
62
128
  if (opts.workspaceId) headers["X-Workspace-ID"] = opts.workspaceId;
63
- const fetchInit = {
64
- method: opts.method,
65
- headers,
66
- credentials: "include"
67
- };
129
+ const fetchInit = { method: opts.method, headers, credentials: "include" };
68
130
  if (opts.body !== void 0) fetchInit.body = JSON.stringify(opts.body);
69
131
  const res = await fetch(`${this.baseUrl}${opts.path}`, fetchInit);
132
+ if (res.status === 401 && this.refresh && !retried && !opts.path.startsWith("/auth/")) {
133
+ const newToken = await this.refresh();
134
+ if (newToken) return this.exec({ ...opts, token: newToken }, true);
135
+ }
70
136
  if (res.status === 204) {
71
137
  if (!res.ok) throw new FonderieApiError("unknown", res.statusText, res.status);
72
138
  return void 0;
@@ -1062,15 +1128,110 @@ var FonderieClient = class {
1062
1128
  webhooks;
1063
1129
  customers;
1064
1130
  http;
1131
+ tokens;
1132
+ workspaceId;
1133
+ cache;
1134
+ authConfig;
1135
+ refreshing = null;
1065
1136
  constructor(opts) {
1066
- this.http = new HttpClient(opts.baseUrl);
1067
- const tokens = new TokenStore(opts.accessToken);
1068
- this.auth = new AuthClient(this.http, tokens);
1069
- this.billing = new BillingClient(this.http, tokens);
1070
- this.workspaces = new WorkspacesClient(this.http, tokens);
1071
- this.audit = new AuditClient(this.http, tokens);
1072
- this.webhooks = new WebhooksClient(this.http, tokens);
1073
- this.customers = new CustomersClient(this.http, tokens);
1137
+ this.tokens = new TokenStore(opts.accessToken);
1138
+ this.workspaceId = opts.workspaceId;
1139
+ this.cache = opts.cache;
1140
+ this.authConfig = opts.auth;
1141
+ this.http = new HttpClient(opts.baseUrl, {
1142
+ cache: opts.cache,
1143
+ defaultTtlMs: opts.cache?.defaultTtlMs,
1144
+ refresh: opts.auth ? () => this.doRefresh() : void 0
1145
+ });
1146
+ this.auth = new AuthClient(this.http, this.tokens);
1147
+ this.billing = new BillingClient(this.http, this.tokens);
1148
+ this.workspaces = new WorkspacesClient(this.http, this.tokens);
1149
+ this.audit = new AuditClient(this.http, this.tokens);
1150
+ this.webhooks = new WebhooksClient(this.http, this.tokens);
1151
+ this.customers = new CustomersClient(this.http, this.tokens);
1152
+ }
1153
+ // Single-flight refresh: POST /auth/refresh with the app-supplied refresh
1154
+ // token, store the new access token, notify the app, return it for the retry.
1155
+ doRefresh() {
1156
+ if (this.refreshing) return this.refreshing;
1157
+ this.refreshing = (async () => {
1158
+ const refreshToken = this.authConfig?.getRefreshToken?.();
1159
+ if (!refreshToken) {
1160
+ this.authConfig?.onAuthError?.();
1161
+ return void 0;
1162
+ }
1163
+ try {
1164
+ const { result } = await this.auth.refreshTokens(refreshToken);
1165
+ const tokens = result.tokens;
1166
+ this.tokens.set(tokens.access);
1167
+ this.authConfig?.onTokensChanged?.(tokens);
1168
+ return tokens.access;
1169
+ } catch {
1170
+ this.tokens.set(void 0);
1171
+ this.authConfig?.onAuthError?.();
1172
+ return void 0;
1173
+ } finally {
1174
+ this.refreshing = null;
1175
+ }
1176
+ })();
1177
+ return this.refreshing;
1178
+ }
1179
+ // The JWT used to authenticate every request — the typed modules and the
1180
+ // generic transport share one token store. Passing undefined signs out and
1181
+ // clears the cache (so no session's data survives a logout).
1182
+ setAccessToken(token) {
1183
+ this.tokens.set(token);
1184
+ if (!token) this.clearCache();
1185
+ }
1186
+ // Drop all cached responses (e.g. on switching accounts).
1187
+ clearCache() {
1188
+ this.cache?.clear();
1189
+ }
1190
+ // Default X-Workspace-ID for the generic transport, also propagated to the
1191
+ // workspace-scoped modules so one call configures the whole client.
1192
+ setWorkspaceId(workspaceId) {
1193
+ this.workspaceId = workspaceId;
1194
+ this.billing.setWorkspaceId(workspaceId);
1195
+ this.workspaces.setWorkspaceId(workspaceId);
1196
+ this.customers.setWorkspaceId(workspaceId);
1197
+ }
1198
+ // ── Generic transport ──────────────────────────────────────────────────────
1199
+ // A Fonderie-aware HTTP client for endpoints outside the typed modules (an
1200
+ // app's own routes on the same backend). It attaches the shared JWT and
1201
+ // X-Workspace-ID automatically and returns Fonderie's { reason, explanation,
1202
+ // result } envelope — so you don't hand-roll auth for custom endpoints.
1203
+ request(opts) {
1204
+ return this.http.request({
1205
+ method: opts.method,
1206
+ path: opts.path,
1207
+ body: opts.body,
1208
+ token: this.tokens.get(),
1209
+ workspaceId: opts.workspaceId ?? this.workspaceId,
1210
+ cache: opts.cache,
1211
+ bust: opts.bust,
1212
+ invalidate: opts.invalidate
1213
+ });
1214
+ }
1215
+ get(path, config) {
1216
+ return this.request({
1217
+ method: "GET",
1218
+ path,
1219
+ workspaceId: config?.workspaceId,
1220
+ cache: config?.cache,
1221
+ bust: config?.bust
1222
+ });
1223
+ }
1224
+ post(path, body, config) {
1225
+ return this.request({ method: "POST", path, body, workspaceId: config?.workspaceId, invalidate: config?.invalidate });
1226
+ }
1227
+ put(path, body, config) {
1228
+ return this.request({ method: "PUT", path, body, workspaceId: config?.workspaceId, invalidate: config?.invalidate });
1229
+ }
1230
+ patch(path, body, config) {
1231
+ return this.request({ method: "PATCH", path, body, workspaceId: config?.workspaceId, invalidate: config?.invalidate });
1232
+ }
1233
+ delete(path, config) {
1234
+ return this.request({ method: "DELETE", path, workspaceId: config?.workspaceId, invalidate: config?.invalidate });
1074
1235
  }
1075
1236
  };
1076
1237
 
@@ -1254,6 +1415,7 @@ var CourierAdminClient = class {
1254
1415
  FonderieApiError,
1255
1416
  FonderieClient,
1256
1417
  WebhooksClient,
1257
- WorkspacesClient
1418
+ WorkspacesClient,
1419
+ createMemoryCache
1258
1420
  });
1259
1421
  //# sourceMappingURL=index.cjs.map