liaise 5.0.1 → 5.0.2

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/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [5.0.2] — 2026-10-04
9
+
10
+ ### Fixed
11
+
12
+ - **A path parameter with no usable value is refused instead of sent.** A token
13
+ was filled with `String(value)` unchecked, so `getUser({ id: undefined })` on
14
+ `/users/:id` fetched `/users/undefined`, and `null`, `''`, an object or an array
15
+ built `/users/null`, `/users/`, `/users/%5Bobject%20Object%5D` or `/users/1%2C2`.
16
+ The usual cause is a component rendering before the id has loaded. Such a call
17
+ now returns an error Result (`kind: 'network'`, a `TypeError` naming each bad
18
+ param, e.g. `Path parameter "id" is undefined in path "/users/:id", so the call
19
+ was not sent.`) and nothing reaches the server. Accepted values are non-empty
20
+ strings, finite numbers, bigints and booleans; a `Date` is refused with a hint
21
+ to convert it first (`toISOString()` or `getTime()`), as in a query string.
22
+ `NaN` and `Infinity` are refused too. See
23
+ [MIGRATION.md](./MIGRATION.md#upgrading-to-502).
24
+
25
+ ### Changed
26
+
27
+ - The README's size figures are re-measured with `npm run size`: about 5.8 kB
28
+ gzipped for a REST-only import, 6.9 kB for the core entry, 8.0 kB with all
29
+ middleware (the new check and its error messages add about 0.2 kB).
30
+
8
31
  ## [5.0.1] — 2026-10-03
9
32
 
10
33
  A bug-fix release from an audit of 5.0.0. Nothing in the API changes; a few calls
@@ -1008,6 +1031,7 @@ Initial release of the rewritten client. Reconstructed from the release commit
1008
1031
  `ArrayBuffer` and strings
1009
1032
  - Response parsing as `json`, `text`, `blob`, `arrayBuffer` or `formData`
1010
1033
 
1034
+ [5.0.2]: https://github.com/iremlopsum/liaise/compare/v5.0.1...v5.0.2
1011
1035
  [5.0.1]: https://github.com/iremlopsum/liaise/compare/v5.0.0...v5.0.1
1012
1036
  [5.0.0]: https://github.com/iremlopsum/liaise/compare/v4.4.3...v5.0.0
1013
1037
  [4.4.3]: https://github.com/iremlopsum/liaise/compare/v4.4.2...v4.4.3
package/MIGRATION.md CHANGED
@@ -7,6 +7,16 @@ For the full record of what changed in each release, see [CHANGELOG.md](./CHANGE
7
7
 
8
8
  ---
9
9
 
10
+ ## Upgrading to 5.0.2
11
+
12
+ No code changes needed. A call whose path parameter is `undefined`, `null`, an
13
+ empty string, an object, an array, a `Date`, a function, a symbol, `NaN` or `Infinity` now returns an
14
+ error Result instead of being sent to a URL like `/users/undefined`. Those calls
15
+ were already hitting the wrong URL; now they say so. If you relied on a literal
16
+ `null` or `undefined` segment, pass the string (`'null'`) explicitly.
17
+
18
+ ---
19
+
10
20
  ## Upgrading to 5.0.1
11
21
 
12
22
  No code changes for most callers. Six things you may observe.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Runtime-agnostic, type-safe HTTP client for REST and GraphQL. Built on standard
11
11
  - **Composable middleware** — retry, cache, dedupe, auth, logging — applied at global, per-endpoint, or per-call level
12
12
  - **Types by inference** — declare params and response once on the endpoint definition; types flow to every call site automatically
13
13
  - **Runtime-agnostic** — Node.js 20+, browsers, Bun, Deno, Cloudflare Workers, React Native (its built-in `fetch`; not tested in CI) — any environment with `fetch`
14
- - **Tiny** — about **5.6 kB gzipped** for a REST-only import, 6.7 kB for the core entry, 7.8 kB with all middleware (measured by `npm run size`); tree-shaking drops what you do not import
14
+ - **Tiny** — about **5.8 kB gzipped** for a REST-only import, 6.9 kB for the core entry, 8.0 kB with all middleware (measured by `npm run size`); tree-shaking drops what you do not import
15
15
 
16
16
  ```
17
17
  npm install liaise
@@ -135,6 +135,8 @@ const getItem = new Request<{ orgId: string; id: string }, Item>({
135
135
  await api.getItem({ orgId: 'acme', id: '42' })
136
136
  ```
137
137
 
138
+ A path parameter must be a non-empty string, a finite number, a bigint or a boolean. Anything else (`undefined`, `null`, `''`, an object, an array, a `Date`, `NaN`) is refused before the request is sent, with an error Result naming the parameter. This catches the common front-end mistake of calling before an id has loaded: `getItem({ orgId: 'acme', id: undefined })` returns an error instead of fetching `/orgs/acme/items/undefined`.
139
+
138
140
  #### `responseType`
139
141
 
140
142
  Controls how the response body is parsed. Defaults to `'json'`.
@@ -56,10 +56,12 @@ export declare class FragmentError extends TypeError {
56
56
  * This is the main URL construction function used by the request engine.
57
57
  * It handles the full lifecycle from path template to final URL.
58
58
  *
59
- * **Path param matching** uses regex with a word-boundary lookahead to prevent
60
- * partial matches. For example, a param key `id` will match `:id` but NOT
61
- * `:idExtra`. This is achieved by requiring that the character after the param
62
- * name is either a non-alphanumeric-underscore character or end of string.
59
+ * **Path param matching** scans the template once for `:name` tokens
60
+ * (`[a-zA-Z0-9_]+`, greedy), so a param key `id` fills `:id` but never part of
61
+ * `:idExtra`. A token is filled only from a non-empty string, a finite number,
62
+ * a bigint or a boolean; `undefined`, `null`, `''`, objects, arrays and Dates
63
+ * throw a TypeError naming the param, so a call is never sent to
64
+ * '/users/undefined'.
63
65
  *
64
66
  * **Query string rules** (when `asQuery` is true):
65
67
  * - Primitives: `{ page: 1 }` → `?page=1`
@@ -25,17 +25,44 @@ export class FragmentError extends TypeError {
25
25
  this.resolvedUrl = resolvedUrl;
26
26
  }
27
27
  }
28
+ function unusableSegment(value) {
29
+ if (value === undefined)
30
+ return 'undefined';
31
+ if (value === null)
32
+ return 'null';
33
+ if (value === '')
34
+ return 'an empty string';
35
+ if (typeof value === 'number' && !Number.isFinite(value))
36
+ return String(value);
37
+ if (value instanceof Date)
38
+ return 'a Date (convert it first, e.g. date.toISOString() or date.getTime())';
39
+ if (Array.isArray(value))
40
+ return 'an array';
41
+ if (typeof value === 'object')
42
+ return 'an object';
43
+ if (typeof value === 'function' || typeof value === 'symbol')
44
+ return `a ${typeof value}`;
45
+ return null;
46
+ }
28
47
  export function buildUrl(baseUrl, path, params, asQuery = false) {
29
48
  const fragmentIn = path.includes('#') ? { where: 'path', value: path } : baseUrl.includes('#') ? { where: 'baseUrl', value: baseUrl } : null;
30
49
  let resolvedPath = path;
31
50
  const remaining = {};
32
51
  const lookup = new Map(Object.entries(params));
33
52
  const consumed = new Set();
53
+ const unusable = [];
34
54
  resolvedPath = resolvedPath.replace(/:([a-zA-Z0-9_]+)/g, (token, name) => {
35
55
  if (!lookup.has(name))
36
56
  return token;
37
57
  consumed.add(name);
38
- return encodeURIComponent(String(lookup.get(name)));
58
+ const value = lookup.get(name);
59
+ const problem = unusableSegment(value);
60
+ if (problem) {
61
+ if (!unusable.some(entry => entry.startsWith(`"${name}"`)))
62
+ unusable.push(`"${name}" is ${problem}`);
63
+ return token;
64
+ }
65
+ return encodeURIComponent(String(value));
39
66
  });
40
67
  for (const [key, value] of lookup) {
41
68
  if (!consumed.has(key))
@@ -46,6 +73,10 @@ export function buildUrl(baseUrl, path, params, asQuery = false) {
46
73
  throw new FragmentError(`A URL fragment is never sent to the server, so it cannot appear in a ${fragmentIn.where}. ` +
47
74
  `Remove "${fragment}" from "${fragmentIn.value}".`, joinUrl(baseUrl, resolvedPath));
48
75
  }
76
+ if (unusable.length > 0) {
77
+ throw new TypeError(`Path parameter ${unusable.join(', ')} in path "${path}", so the call was not sent. ` +
78
+ 'A path parameter must be a non-empty string, a finite number, a bigint or a boolean.');
79
+ }
49
80
  const unresolved = resolvedPath
50
81
  .split('/')
51
82
  .map(segment => /^:[a-zA-Z0-9_]+/.exec(segment)?.[0])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "liaise",
3
- "version": "5.0.1",
3
+ "version": "5.0.2",
4
4
  "description": "Type-safe API client for REST and GraphQL on standard fetch. Never throws. Zero dependencies.",
5
5
  "type": "module",
6
6
  "sideEffects": false,