@tuyau/core 1.0.0 → 1.2.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.
@@ -13,11 +13,18 @@ function buildSearchParams(query) {
13
13
  const encodedKey = encodeURIComponent(key);
14
14
  const fullKey = prefix ? `${prefix}[${encodedKey}]` : encodedKey;
15
15
  if (Array.isArray(value)) {
16
- for (const item of value) {
17
- if (item !== void 0 && item !== null) {
16
+ value.forEach((item, index) => {
17
+ if (item === void 0 || item === null) return;
18
+ if (item instanceof Date) {
19
+ parts.push(`${fullKey}[]=${encodeURIComponent(item.toISOString())}`);
20
+ } else if (typeof item === "object") {
21
+ serialize2(item, `${fullKey}[${index}]`);
22
+ } else {
18
23
  parts.push(`${fullKey}[]=${encodeURIComponent(item)}`);
19
24
  }
20
- }
25
+ });
26
+ } else if (value instanceof Date) {
27
+ parts.push(`${fullKey}=${encodeURIComponent(value.toISOString())}`);
21
28
  } else if (typeof value === "object") {
22
29
  serialize2(value, fullKey);
23
30
  } else {
@@ -29,28 +36,234 @@ function buildSearchParams(query) {
29
36
  return parts.length ? parts.join("&") : "";
30
37
  }
31
38
 
32
- // src/client/errors.ts
33
- async function parseResponse(response) {
34
- if (!response) return;
35
- const responseType = response.headers.get("Content-Type")?.split(";")[0];
36
- if (responseType === "application/json") {
37
- return await response.json();
38
- } else if (responseType === "application/octet-stream") {
39
- return await response.arrayBuffer();
39
+ // src/client/router.ts
40
+ var TuyauRouter = class _TuyauRouter {
41
+ #entries;
42
+ #baseUrl;
43
+ constructor(entries, baseUrl) {
44
+ this.#entries = entries;
45
+ this.#baseUrl = baseUrl;
40
46
  }
41
- return await response.text();
42
- }
43
- var TuyauHTTPError = class extends Error {
47
+ /**
48
+ * Matches a route name against a pattern that may contain `*` wildcards.
49
+ * `*` matches one or more dot-separated segments via recursive backtracking.
50
+ */
51
+ static matchesRouteNamePattern(routeName, pattern) {
52
+ if (pattern === routeName) return true;
53
+ if (!pattern.includes("*")) return false;
54
+ const patternParts = pattern.split(".");
55
+ const routeParts = routeName.split(".");
56
+ function match(pIdx, rIdx) {
57
+ if (pIdx === patternParts.length) return rIdx === routeParts.length;
58
+ if (patternParts[pIdx] === "*") {
59
+ const remainingPattern = patternParts.length - pIdx - 1;
60
+ for (let i = rIdx + 1; i <= routeParts.length - remainingPattern; i++) {
61
+ if (match(pIdx + 1, i)) return true;
62
+ }
63
+ return false;
64
+ }
65
+ if (rIdx >= routeParts.length) return false;
66
+ if (patternParts[pIdx] !== routeParts[rIdx]) return false;
67
+ return match(pIdx + 1, rIdx + 1);
68
+ }
69
+ return match(0, 0);
70
+ }
71
+ /**
72
+ * Matches a URL pathname against an AdonisJS route pattern and extracts
73
+ * dynamic parameters. Supports `:param`, `:param?` (optional), and `*` (wildcard).
74
+ *
75
+ * Returns a record of extracted params on match, or `null` on mismatch.
76
+ */
77
+ static matchPathAgainstPattern(pathname, pattern) {
78
+ const urlSegments = pathname.split("/").filter(Boolean);
79
+ const patternSegments = pattern.split("/").filter(Boolean);
80
+ const params = {};
81
+ let ui = 0;
82
+ for (const seg of patternSegments) {
83
+ if (seg === "*") {
84
+ if (ui >= urlSegments.length) return null;
85
+ params["*"] = urlSegments.slice(ui).join("/");
86
+ return params;
87
+ }
88
+ if (seg.startsWith(":")) {
89
+ const optional = seg.endsWith("?");
90
+ const name = seg.slice(1, optional ? -1 : void 0);
91
+ if (ui < urlSegments.length) {
92
+ params[name] = urlSegments[ui++];
93
+ } else if (!optional) {
94
+ return null;
95
+ }
96
+ continue;
97
+ }
98
+ if (ui >= urlSegments.length || urlSegments[ui] !== seg) return null;
99
+ ui++;
100
+ }
101
+ return ui === urlSegments.length ? params : null;
102
+ }
103
+ #getCurrentPathname() {
104
+ if (typeof globalThis.window === "undefined") return void 0;
105
+ const pathname = globalThis.window.location.pathname;
106
+ const basePathname = new URL(this.#baseUrl).pathname;
107
+ if (basePathname !== "/" && pathname.startsWith(basePathname)) {
108
+ return pathname.slice(basePathname.length) || "/";
109
+ }
110
+ return pathname;
111
+ }
112
+ #isNavigableRoute(endpoint) {
113
+ return endpoint.methods.includes("GET") || endpoint.methods.includes("HEAD");
114
+ }
115
+ #findCurrentRouteName(pathname) {
116
+ for (const [name, endpoint] of this.#entries) {
117
+ if (!this.#isNavigableRoute(endpoint)) continue;
118
+ if (_TuyauRouter.matchPathAgainstPattern(pathname, endpoint.pattern)) return name;
119
+ }
120
+ return void 0;
121
+ }
122
+ #matchesCurrentRoute(pathname, routeName, options) {
123
+ for (const [name, endpoint] of this.#entries) {
124
+ if (!this.#isNavigableRoute(endpoint)) continue;
125
+ if (!_TuyauRouter.matchesRouteNamePattern(name, routeName)) continue;
126
+ const params = _TuyauRouter.matchPathAgainstPattern(pathname, endpoint.pattern);
127
+ if (!params) continue;
128
+ if (options?.params) {
129
+ const mismatch = Object.entries(options.params).some(
130
+ ([key, value]) => params[key] !== String(value)
131
+ );
132
+ if (mismatch) continue;
133
+ }
134
+ if (options?.query) {
135
+ if (!this.#matchesQueryString(globalThis.window.location.search, options.query)) continue;
136
+ }
137
+ return true;
138
+ }
139
+ return false;
140
+ }
141
+ #parseQueryString(search) {
142
+ if (!search || search === "?") return {};
143
+ const result = {};
144
+ for (const [key, value] of new URLSearchParams(search)) {
145
+ const current = result[key];
146
+ if (current === void 0) {
147
+ result[key] = value;
148
+ continue;
149
+ }
150
+ result[key] = Array.isArray(current) ? [...current, value] : [current, value];
151
+ }
152
+ return result;
153
+ }
154
+ #matchesQueryString(search, query) {
155
+ const currentQuery = this.#parseQueryString(search);
156
+ const expectedQuery = this.#parseQueryString(buildSearchParams(query));
157
+ return Object.entries(expectedQuery).every(([key, value]) => {
158
+ const currentValue = currentQuery[key];
159
+ if (currentValue === void 0) return false;
160
+ if (Array.isArray(value)) {
161
+ return Array.isArray(currentValue) ? value.length === currentValue.length && value.every((entry, index) => currentValue[index] === entry) : value.length === 1 && currentValue === value[0];
162
+ }
163
+ return Array.isArray(currentValue) ? currentValue.length === 1 && currentValue[0] === value : currentValue === value;
164
+ });
165
+ }
166
+ /**
167
+ * Checks if a route name exists in the registry.
168
+ */
169
+ has(routeName) {
170
+ return this.#entries.some(([name]) => _TuyauRouter.matchesRouteNamePattern(name, routeName));
171
+ }
172
+ current(routeName, options) {
173
+ const pathname = this.#getCurrentPathname();
174
+ if (!pathname) return routeName ? false : void 0;
175
+ if (!routeName) return this.#findCurrentRouteName(pathname);
176
+ return this.#matchesCurrentRoute(pathname, routeName, options);
177
+ }
178
+ };
179
+
180
+ // src/client/promise.ts
181
+ var TuyauPromise = class {
182
+ #promise;
183
+ constructor(promise) {
184
+ this.#promise = promise;
185
+ }
186
+ then(onfulfilled, onrejected) {
187
+ return this.#promise.then(onfulfilled, onrejected);
188
+ }
189
+ catch(onrejected) {
190
+ return this.#promise.catch(onrejected);
191
+ }
192
+ finally(onfinally) {
193
+ return this.#promise.finally(onfinally);
194
+ }
195
+ /**
196
+ * Returns a tuple instead of throwing on error.
197
+ * - On success: `[data, null]`
198
+ * - On error: `[null, TuyauError]`
199
+ */
200
+ safe() {
201
+ return this.#promise.then(
202
+ (data) => [data, null],
203
+ (error) => [null, error]
204
+ );
205
+ }
206
+ };
207
+
208
+ // src/client/errors.ts
209
+ var TuyauError = class extends Error {
210
+ /**
211
+ * Error kind exposed by Tuyau.
212
+ * - `'http'`: the server responded with a non-2xx status code.
213
+ * - `'network'`: the request failed before receiving a response.
214
+ */
215
+ kind;
216
+ /**
217
+ * HTTP status code returned by the server.
218
+ * `undefined` when the request failed before a response was received.
219
+ */
44
220
  status;
221
+ /**
222
+ * Original Ky response object for HTTP errors.
223
+ * `undefined` for network errors.
224
+ */
45
225
  rawResponse;
226
+ /**
227
+ * Original Ky request object when available.
228
+ */
46
229
  rawRequest;
230
+ /**
231
+ * Parsed error payload returned by the server.
232
+ * `undefined` for network errors.
233
+ */
47
234
  response;
235
+ constructor(message, options) {
236
+ super(message, options);
237
+ this.status = void 0;
238
+ this.response = void 0;
239
+ this.rawResponse = void 0;
240
+ this.rawRequest = void 0;
241
+ this.name = "TuyauError";
242
+ }
243
+ /**
244
+ * Type guard that narrows the error to a specific HTTP status code.
245
+ * After calling `isStatus(422)`, the `response` property is narrowed
246
+ * to the type associated with that status code.
247
+ */
248
+ isStatus(status) {
249
+ return this.kind === "http" && this.status === status;
250
+ }
251
+ /**
252
+ * Type guard that narrows to a 422 validation error.
253
+ * Alias for `isStatus(422)`.
254
+ */
255
+ isValidationError() {
256
+ return this.isStatus(422);
257
+ }
258
+ };
259
+ var TuyauHTTPError = class extends TuyauError {
48
260
  constructor(kyError, response) {
49
261
  const status = kyError.response?.status;
50
262
  const reason = status ? `status code ${status}` : "an unknown error";
51
263
  const url = kyError.request?.url.replace(kyError.options.prefixUrl || "", "");
52
264
  const message = kyError.response ? `Request failed with ${reason}: ${kyError.request?.method.toUpperCase()} /${url}` : `Request failed with ${reason}`;
53
265
  super(message, { cause: kyError });
266
+ this.kind = "http";
54
267
  this.rawResponse = kyError.response;
55
268
  this.response = response;
56
269
  this.status = kyError.response?.status;
@@ -58,12 +271,11 @@ var TuyauHTTPError = class extends Error {
58
271
  this.name = "TuyauHTTPError";
59
272
  }
60
273
  };
61
- var TuyauNetworkError = class extends Error {
62
- cause;
274
+ var TuyauNetworkError = class extends TuyauError {
63
275
  constructor(cause, request) {
64
276
  const message = request ? `Network error: ${request.method.toUpperCase()} ${request.url}` : "Network error occurred";
65
277
  super(message, { cause });
66
- this.cause = cause;
278
+ this.kind = "network";
67
279
  this.name = "TuyauNetworkError";
68
280
  }
69
281
  };
@@ -83,6 +295,16 @@ function segmentsToRouteName(segments) {
83
295
  function segmentsToKebabRouteName(segments) {
84
296
  return segments.map((segment) => segment.replace(/[A-Z]/g, (g) => `-${g.toLowerCase()}`)).join(".");
85
297
  }
298
+ async function parseResponse(response) {
299
+ if (!response) return;
300
+ const responseType = response.headers.get("Content-Type")?.split(";")[0];
301
+ if (responseType === "application/json") {
302
+ return await response.json();
303
+ } else if (responseType === "application/octet-stream") {
304
+ return await response.arrayBuffer();
305
+ }
306
+ return await response.text();
307
+ }
86
308
 
87
309
  // src/client/tuyau.ts
88
310
  var Tuyau = class {
@@ -91,6 +313,7 @@ var Tuyau = class {
91
313
  #entries;
92
314
  #client;
93
315
  #config;
316
+ #router;
94
317
  /**
95
318
  * Merges the default Ky configuration with user-provided config
96
319
  */
@@ -128,6 +351,7 @@ var Tuyau = class {
128
351
  this.api = this.#makeNamed([]);
129
352
  this.#entries = Object.entries(this.#config.registry.routes);
130
353
  this.urlFor = this.#createUrlBuilder();
354
+ this.#router = new TuyauRouter(this.#entries, this.#config.baseUrl);
131
355
  this.#applyPlugins();
132
356
  this.#client = ky.create(this.#mergeKyConfiguration());
133
357
  }
@@ -174,45 +398,48 @@ var Tuyau = class {
174
398
  /**
175
399
  * Performs the actual HTTP request with proper body formatting
176
400
  */
177
- async #doFetch(name, method, args) {
178
- const url = this.#buildUrl(name, method, args);
179
- let key = "json";
180
- let body = args.body;
181
- if (!(body instanceof FormData) && this.#hasFile(body)) {
182
- body = serialize(body, { indices: true });
183
- key = "body";
184
- } else if (body instanceof FormData) {
185
- key = "body";
186
- }
187
- const isGetOrHead = ["GET", "HEAD"].includes(method);
188
- const { body: _, ...restArgs } = args;
189
- const requestOptions = {
190
- searchParams: buildSearchParams(args?.query || {}),
191
- [key]: !isGetOrHead ? body : void 0,
192
- ...restArgs
193
- };
194
- try {
195
- const res = await this.#client[this.#getLowercaseMethod(method)](
196
- removeSlash(url),
197
- requestOptions
198
- );
199
- let data;
200
- const responseType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
201
- if (responseType === "application/json") {
202
- data = await res.json();
203
- } else if (responseType === "application/octet-stream") {
204
- data = await res.arrayBuffer();
205
- } else {
206
- data = await res.text();
401
+ #doFetch(name, method, args) {
402
+ const promise = (async () => {
403
+ const url = this.#buildUrl(name, method, args);
404
+ let key = "json";
405
+ let body = args.body;
406
+ if (!(body instanceof FormData) && this.#hasFile(body)) {
407
+ body = serialize(body, { indices: true });
408
+ key = "body";
409
+ } else if (body instanceof FormData) {
410
+ key = "body";
207
411
  }
208
- return data;
209
- } catch (originalError) {
210
- if (originalError instanceof HTTPError) {
211
- const parsedResponse = await parseResponse(originalError.response);
212
- throw new TuyauHTTPError(originalError, parsedResponse);
412
+ const isGetOrHead = ["GET", "HEAD"].includes(method);
413
+ const { body: _, responseType: requestedResponseType, ...restArgs } = args;
414
+ const requestOptions = {
415
+ searchParams: buildSearchParams(args?.query || {}),
416
+ [key]: !isGetOrHead ? body : void 0,
417
+ ...restArgs
418
+ };
419
+ try {
420
+ const res = await this.#client[this.#getLowercaseMethod(method)](
421
+ removeSlash(url),
422
+ requestOptions
423
+ );
424
+ let data;
425
+ if (requestedResponseType) {
426
+ data = await res[requestedResponseType]();
427
+ } else {
428
+ const contentType = res.headers.get("Content-Type")?.split(";")[0]?.trim();
429
+ if (contentType === "application/json") data = await res.json();
430
+ else if (contentType === "application/octet-stream") data = await res.arrayBuffer();
431
+ else data = await res.text();
432
+ }
433
+ return data;
434
+ } catch (originalError) {
435
+ if (originalError instanceof HTTPError) {
436
+ const parsedResponse = await parseResponse(originalError.response);
437
+ throw new TuyauHTTPError(originalError, parsedResponse);
438
+ }
439
+ throw new TuyauNetworkError(originalError, { url, method });
213
440
  }
214
- throw new TuyauNetworkError(originalError, { url, method });
215
- }
441
+ })();
442
+ return new TuyauPromise(promise);
216
443
  }
217
444
  /**
218
445
  * Finds an endpoint definition by HTTP method and pattern
@@ -302,14 +529,24 @@ var Tuyau = class {
302
529
  }
303
530
  return new Proxy({}, { get: (_t, prop) => this.#makeNamed([...segments, String(prop)]) });
304
531
  }
532
+ /**
533
+ * Checks if a route name exists in the registry.
534
+ */
535
+ has(routeName) {
536
+ return this.#router.has(routeName);
537
+ }
538
+ current(routeName, options) {
539
+ return this.#router.current(routeName, options);
540
+ }
305
541
  };
306
542
  function createTuyau(config) {
307
543
  return new Tuyau(config);
308
544
  }
309
545
  export {
310
546
  Tuyau,
547
+ TuyauError,
311
548
  TuyauHTTPError,
312
549
  TuyauNetworkError,
313
- createTuyau,
314
- parseResponse
550
+ TuyauPromise,
551
+ createTuyau
315
552
  };