@seta-labs/geo 0.7.0 → 0.8.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.
- package/CHANGELOG.md +16 -0
- package/README.md +30 -9
- package/dist/index.d.ts +36 -14
- package/dist/index.js +127 -12
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @seta-labs/geo
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- **Breaking:** `LookupCache` becomes `CachePolicy`, the cache options every
|
|
6
|
+
Seta service and SDK shares: `refresh` is now `noCache`, like the header
|
|
7
|
+
directive; `noStore`, `maxAge` and `onlyIfCached` keep their names. The type
|
|
8
|
+
and its header writer come from `@seta-labs/nestjs-cache-control/policy`,
|
|
9
|
+
bundled into the SDK: nothing more to install.
|
|
10
|
+
- Each client paces its calls (`pacing`): at most 10 in flight, a `429` or
|
|
11
|
+
`503` sent again up to 4 times after its `Retry-After` (up to a minute;
|
|
12
|
+
1-8 s backoff without it), the whole queue waiting meanwhile. `pacing:
|
|
13
|
+
{ concurrency, retries }` tunes it, `pacing: false` turns it off; two queues
|
|
14
|
+
are two clients.
|
|
15
|
+
- `timeoutMs` (default 30 s) bounds each attempt, not the wait in the queue.
|
|
16
|
+
- `ApiError.code`: Geo's error code (`CADASTRE_BLOCKED`, `NOT_CACHED`, ...)
|
|
17
|
+
read from the body.
|
|
18
|
+
|
|
3
19
|
## 0.7.0
|
|
4
20
|
|
|
5
21
|
- `reverseGeocode(input)` answers from a chain of sources: the ANNCSU register
|
package/README.md
CHANGED
|
@@ -40,9 +40,20 @@ Do not use `$fetch` as `fetch`: it returns the parsed body instead of a Response
|
|
|
40
40
|
|
|
41
41
|
All calls return `Promise<T>` and accept optional `{ signal }` as the last
|
|
42
42
|
argument. An optional `fetch: (request: Request) => Promise<Response>` allows
|
|
43
|
-
consumer-owned instrumentation or transport adapters.
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
consumer-owned instrumentation or transport adapters.
|
|
44
|
+
|
|
45
|
+
Each client paces its calls: at most 10 in flight, and a `429` or `503` that
|
|
46
|
+
says when to come back (`Retry-After` up to a minute; 1 s, 2 s, 4 s, 8 s
|
|
47
|
+
without it) is sent again up to 4 times while the client's whole queue waits.
|
|
48
|
+
A refusal asking for a longer wait, every other status and network errors
|
|
49
|
+
reach the caller after one attempt. Each attempt has `timeoutMs` (default
|
|
50
|
+
30 s), not counting the wait in the queue. Two queues are two clients:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const geo = createGeoClient({ baseUrl, apiKey }); // lookups, territory
|
|
54
|
+
const cadastre = createGeoClient({ baseUrl, apiKey, pacing: { concurrency: 4 } }); // the map, apart
|
|
55
|
+
const panel = createGeoClient({ baseUrl: '/api', pacing: false }); // every call at once, once
|
|
56
|
+
```
|
|
46
57
|
|
|
47
58
|
| Method | Input |
|
|
48
59
|
| ---------------------------------------- | -------------------------------------- |
|
|
@@ -66,14 +77,24 @@ validation of received data. `include` may be omitted and defaults to `best`
|
|
|
66
77
|
on the service.
|
|
67
78
|
|
|
68
79
|
The four lookups (`geocode`, `geocodeBatch`, `reverseGeocode`,
|
|
69
|
-
`cadastreParcel`) take `cache` in their options
|
|
70
|
-
|
|
71
|
-
`{
|
|
72
|
-
|
|
73
|
-
|
|
80
|
+
`cadastreParcel`) take `cache` in their options: a `CachePolicy`, the shape
|
|
81
|
+
every Seta service and SDK shares (`@seta-labs/nestjs-cache-control/policy`,
|
|
82
|
+
bundled here), sent as `Cache-Control`. `{ noCache: true }` asks the provider
|
|
83
|
+
again and stores the new answer, `{ noCache: true, noStore: true }` bypasses
|
|
84
|
+
the store entirely, `{ maxAge }` asks again past that many seconds,
|
|
85
|
+
`{ onlyIfCached: true }` never asks the provider (504 `NOT_CACHED`, or
|
|
86
|
+
`status: 'NOT_CACHED'` for a batch item). Without `cache` the store answers
|
|
87
|
+
when it can and the provider otherwise. The other calls read Geo's own data
|
|
88
|
+
and take only `signal`.
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
await geo.reverseGeocode({ lat, lng }, { cache: { onlyIfCached: true } });
|
|
92
|
+
await geo.reverseGeocode({ lat, lng }, { cache: jobCachePolicy() }); // the job's, at each call
|
|
93
|
+
```
|
|
74
94
|
|
|
75
95
|
HTTP errors are `ApiError`; their body is JSON when parseable, otherwise text,
|
|
76
|
-
or `undefined` for an empty response
|
|
96
|
+
or `undefined` for an empty response, and `code` is Geo's error code when the
|
|
97
|
+
body has one. A `503` from `cadastreParcel` with
|
|
77
98
|
`code: 'CADASTRE_BLOCKED'` means the cadastral map is refusing calls: its
|
|
78
99
|
`Retry-After` header (and `retryAfterSeconds` in the body) says when to ask
|
|
79
100
|
again. Network failures, aborts and JSON parsing
|
package/dist/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ declare class ApiError extends Error {
|
|
|
4
4
|
readonly status: number;
|
|
5
5
|
readonly headers: Headers;
|
|
6
6
|
readonly requestId: string | undefined;
|
|
7
|
+
/** Geo's error `code` (`CADASTRE_BLOCKED`, `NOT_CACHED`, ...), when the body has one. */
|
|
8
|
+
readonly code: string | undefined;
|
|
7
9
|
constructor(response: Response, body: unknown);
|
|
8
10
|
}
|
|
9
11
|
|
|
@@ -1294,6 +1296,31 @@ type TerritoryQuotazione = ApiSchemas['TerritoryQuotazioneDto'];
|
|
|
1294
1296
|
type TerritoryCompravenditePage = ApiSchemas['TerritoryCompravenditePageDto'];
|
|
1295
1297
|
type TerritoryCompravendita = ApiSchemas['TerritoryCompravenditaDto'];
|
|
1296
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* The request directives of RFC 9111 a lookup honours; any other directive is
|
|
1301
|
+
* ignored. One shape for both sides: the server reads it from the header
|
|
1302
|
+
* (`parseCacheControl`, every flag set), a client writes it
|
|
1303
|
+
* (`formatCacheControl`, an absent flag is false).
|
|
1304
|
+
*/
|
|
1305
|
+
interface CachePolicy {
|
|
1306
|
+
/** `no-cache`: never answer from the store without asking the provider. */
|
|
1307
|
+
noCache?: boolean;
|
|
1308
|
+
/** `no-store`: never write the provider's answer to the store. */
|
|
1309
|
+
noStore?: boolean;
|
|
1310
|
+
/** `only-if-cached`: never ask the provider; without a usable row, refuse. */
|
|
1311
|
+
onlyIfCached?: boolean;
|
|
1312
|
+
/** `max-age=N`: a stored row older than N seconds is not usable. */
|
|
1313
|
+
maxAge?: number;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/** How a client paces its calls to Geo; each client has its own queue. */
|
|
1317
|
+
interface PacingOptions {
|
|
1318
|
+
/** Requests in flight at once; default 10. */
|
|
1319
|
+
concurrency?: number;
|
|
1320
|
+
/** New attempts after a 429 or 503 that says when to come back; default 4 (5 attempts in all). */
|
|
1321
|
+
retries?: number;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1297
1324
|
type TerritoryComuniQuery = NonNullable<ApiPaths['/territory/comuni']['get']['parameters']['query']>;
|
|
1298
1325
|
type TerritoryQuotazioniQuery = NonNullable<ApiPaths['/territory/quotazioni']['get']['parameters']['query']>;
|
|
1299
1326
|
type TerritoryCompravenditeQuery = NonNullable<ApiPaths['/territory/compravendite']['get']['parameters']['query']>;
|
|
@@ -1302,21 +1329,12 @@ interface RequestOptions {
|
|
|
1302
1329
|
signal?: AbortSignal;
|
|
1303
1330
|
}
|
|
1304
1331
|
/**
|
|
1305
|
-
*
|
|
1306
|
-
*
|
|
1332
|
+
* The options of a lookup, the four calls Geo answers from its store:
|
|
1333
|
+
* `cache` is sent as `Cache-Control` (RFC 9111). Without it Geo answers
|
|
1334
|
+
* from the store when it can and asks the provider otherwise.
|
|
1307
1335
|
*/
|
|
1308
|
-
interface LookupCache {
|
|
1309
|
-
/** `no-cache`: ask the provider even with a stored answer, and store the new one. */
|
|
1310
|
-
refresh?: boolean;
|
|
1311
|
-
/** `no-store`: never write the provider's answer (with `refresh`: a pure bypass). */
|
|
1312
|
-
noStore?: boolean;
|
|
1313
|
-
/** `max-age`: a stored answer older than this many seconds is asked again. */
|
|
1314
|
-
maxAge?: number;
|
|
1315
|
-
/** `only-if-cached`: never ask the provider; 504 `NOT_CACHED` without a stored answer. */
|
|
1316
|
-
onlyIfCached?: boolean;
|
|
1317
|
-
}
|
|
1318
1336
|
interface LookupOptions extends RequestOptions {
|
|
1319
|
-
cache?:
|
|
1337
|
+
cache?: CachePolicy;
|
|
1320
1338
|
}
|
|
1321
1339
|
interface GeoClientOptions {
|
|
1322
1340
|
/** Complete API root, including /api. Relative roots are supported in browsers. */
|
|
@@ -1325,6 +1343,10 @@ interface GeoClientOptions {
|
|
|
1325
1343
|
apiKey?: string;
|
|
1326
1344
|
/** A real Fetch implementation returning a Response, not a parsed-body $fetch. */
|
|
1327
1345
|
fetch?: (request: Request) => Promise<Response>;
|
|
1346
|
+
/** Each attempt's deadline, not counting the wait in the queue; default 30 s. */
|
|
1347
|
+
timeoutMs?: number;
|
|
1348
|
+
/** The client's queue and its retries on 429/503; `false` sends every call at once, once. */
|
|
1349
|
+
pacing?: false | PacingOptions;
|
|
1328
1350
|
}
|
|
1329
1351
|
interface TerritoryClient {
|
|
1330
1352
|
status(options?: RequestOptions): Promise<TerritoryStatus>;
|
|
@@ -1350,4 +1372,4 @@ interface GeoClient {
|
|
|
1350
1372
|
declare function createGeoClient(options: GeoClientOptions): GeoClient;
|
|
1351
1373
|
|
|
1352
1374
|
export { ApiError, createGeoClient };
|
|
1353
|
-
export type { CadastreParcelRequest, CadastreParcelResult, CadastreParcelRow, CadastreParcelsPage, CadastreParcelsQuery, GeoAdminArea, GeoClient, GeoClientOptions, GeoStreetMatch, GeocodeBatchRequest, GeocodeBatchResponse, GeocodeRequest, GeocodeResult,
|
|
1375
|
+
export type { CachePolicy, CadastreParcelRequest, CadastreParcelResult, CadastreParcelRow, CadastreParcelsPage, CadastreParcelsQuery, GeoAdminArea, GeoClient, GeoClientOptions, GeoStreetMatch, GeocodeBatchRequest, GeocodeBatchResponse, GeocodeRequest, GeocodeResult, LookupOptions, PacingOptions, RequestOptions, ReverseGeocodeRequest, ReverseGeocodeResult, TerritoryClient, TerritoryCompravendita, TerritoryCompravenditePage, TerritoryCompravenditeQuery, TerritoryComune, TerritoryComuneDetail, TerritoryComuneRiga, TerritoryComuniPage, TerritoryComuniQuery, TerritoryDatasets, TerritoryOmi, TerritoryPointResult, TerritoryQuotazione, TerritoryQuotazioniPage, TerritoryQuotazioniQuery, TerritoryResolveRequest, TerritoryResolveResponse, TerritoryStatus, TerritoryTerritorio };
|
package/dist/index.js
CHANGED
|
@@ -7,11 +7,14 @@ var ApiError = class extends Error {
|
|
|
7
7
|
this.status = response.status;
|
|
8
8
|
this.headers = new Headers(response.headers);
|
|
9
9
|
this.requestId = response.headers.get("x-request-id") ?? void 0;
|
|
10
|
+
this.code = codeOf(body);
|
|
10
11
|
}
|
|
11
12
|
body;
|
|
12
13
|
status;
|
|
13
14
|
headers;
|
|
14
15
|
requestId;
|
|
16
|
+
/** Geo's error `code` (`CADASTRE_BLOCKED`, `NOT_CACHED`, ...), when the body has one. */
|
|
17
|
+
code;
|
|
15
18
|
};
|
|
16
19
|
async function readErrorBody(response) {
|
|
17
20
|
const text = await response.text();
|
|
@@ -22,9 +25,126 @@ async function readErrorBody(response) {
|
|
|
22
25
|
return text;
|
|
23
26
|
}
|
|
24
27
|
}
|
|
28
|
+
function codeOf(body) {
|
|
29
|
+
if (typeof body !== "object" || body === null || !("code" in body)) return void 0;
|
|
30
|
+
return typeof body.code === "string" ? body.code : void 0;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ../../node_modules/.pnpm/@seta-labs+nestjs-cache-control@0.2.1_@nestjs+common@12.1.2_class-transformer@0.5.1_cla_d70e55208c987ac6b22f0eff0ce5869e/node_modules/@seta-labs/nestjs-cache-control/dist/cache-policy.util.js
|
|
34
|
+
var MAX_DELTA_SECONDS = 2147483648;
|
|
35
|
+
function formatCacheControl(policy) {
|
|
36
|
+
if (!policy)
|
|
37
|
+
return void 0;
|
|
38
|
+
const maxAge = policy.maxAge;
|
|
39
|
+
const directives = [
|
|
40
|
+
policy.noCache ? "no-cache" : void 0,
|
|
41
|
+
policy.noStore ? "no-store" : void 0,
|
|
42
|
+
maxAge !== void 0 && Number.isFinite(maxAge) ? `max-age=${Math.min(Math.max(0, Math.floor(maxAge)), MAX_DELTA_SECONDS)}` : void 0,
|
|
43
|
+
policy.onlyIfCached ? "only-if-cached" : void 0
|
|
44
|
+
].filter((directive) => directive !== void 0);
|
|
45
|
+
return directives.length > 0 ? directives.join(", ") : void 0;
|
|
46
|
+
}
|
|
25
47
|
|
|
26
48
|
// src/client.ts
|
|
27
49
|
import createClient from "openapi-fetch";
|
|
50
|
+
|
|
51
|
+
// src/pacing.ts
|
|
52
|
+
var DEFAULT_CONCURRENCY = 10;
|
|
53
|
+
var DEFAULT_RETRIES = 4;
|
|
54
|
+
var RETRY_STATUSES = /* @__PURE__ */ new Set([429, 503]);
|
|
55
|
+
var BACKOFF_MS = 1e3;
|
|
56
|
+
var MAX_WAIT_MS = 6e4;
|
|
57
|
+
function pacedFetch(fetch, options = {}) {
|
|
58
|
+
const run = limiter(options.concurrency ?? DEFAULT_CONCURRENCY);
|
|
59
|
+
const retries = options.retries ?? DEFAULT_RETRIES;
|
|
60
|
+
let pausedUntil = 0;
|
|
61
|
+
const send = async (request, attempt) => {
|
|
62
|
+
while (pausedUntil > Date.now()) await sleepUntil(pausedUntil, request.signal);
|
|
63
|
+
const response = await fetch(request.clone());
|
|
64
|
+
const waitMs = attempt < retries ? retryDelay(response, attempt) : void 0;
|
|
65
|
+
if (waitMs !== void 0) pausedUntil = Math.max(pausedUntil, Date.now() + waitMs);
|
|
66
|
+
return { response, waitMs };
|
|
67
|
+
};
|
|
68
|
+
return async (request) => {
|
|
69
|
+
for (let attempt = 0; ; attempt++) {
|
|
70
|
+
const { response, waitMs } = await run(request.signal, () => send(request, attempt));
|
|
71
|
+
if (waitMs === void 0) return response;
|
|
72
|
+
await response.body?.cancel();
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function timedFetch(fetch, timeoutMs) {
|
|
77
|
+
return (request) => fetch(
|
|
78
|
+
new Request(request, {
|
|
79
|
+
signal: AbortSignal.any([request.signal, AbortSignal.timeout(timeoutMs)])
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function retryDelay(response, attempt) {
|
|
84
|
+
if (!RETRY_STATUSES.has(response.status)) return void 0;
|
|
85
|
+
const header = response.headers.get("retry-after");
|
|
86
|
+
const waitMs = header === null ? BACKOFF_MS * 2 ** attempt : retryAfterMs(header);
|
|
87
|
+
return waitMs !== void 0 && waitMs <= MAX_WAIT_MS ? waitMs : void 0;
|
|
88
|
+
}
|
|
89
|
+
function retryAfterMs(header) {
|
|
90
|
+
const value = header.trim();
|
|
91
|
+
if (/^\d+$/.test(value)) return Number(value) * 1e3;
|
|
92
|
+
const date = Date.parse(value);
|
|
93
|
+
return Number.isNaN(date) ? void 0 : Math.max(0, date - Date.now());
|
|
94
|
+
}
|
|
95
|
+
function limiter(concurrency) {
|
|
96
|
+
let active = 0;
|
|
97
|
+
const waiting = [];
|
|
98
|
+
const acquire = (signal) => {
|
|
99
|
+
signal.throwIfAborted();
|
|
100
|
+
if (active < concurrency) {
|
|
101
|
+
active++;
|
|
102
|
+
return Promise.resolve();
|
|
103
|
+
}
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const start = () => {
|
|
106
|
+
signal.removeEventListener("abort", abort);
|
|
107
|
+
active++;
|
|
108
|
+
resolve();
|
|
109
|
+
};
|
|
110
|
+
const abort = () => {
|
|
111
|
+
waiting.splice(waiting.indexOf(start), 1);
|
|
112
|
+
reject(signal.reason);
|
|
113
|
+
};
|
|
114
|
+
waiting.push(start);
|
|
115
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
return async (signal, task) => {
|
|
119
|
+
await acquire(signal);
|
|
120
|
+
try {
|
|
121
|
+
return await task();
|
|
122
|
+
} finally {
|
|
123
|
+
active--;
|
|
124
|
+
waiting.shift()?.();
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function sleepUntil(time, signal) {
|
|
129
|
+
const delay = time - Date.now();
|
|
130
|
+
if (delay <= 0) return Promise.resolve();
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
signal.throwIfAborted();
|
|
133
|
+
const timer = setTimeout(done, delay);
|
|
134
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
135
|
+
function done() {
|
|
136
|
+
signal.removeEventListener("abort", abort);
|
|
137
|
+
resolve();
|
|
138
|
+
}
|
|
139
|
+
function abort() {
|
|
140
|
+
clearTimeout(timer);
|
|
141
|
+
reject(signal.reason);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/client.ts
|
|
147
|
+
var DEFAULT_TIMEOUT_MS = 3e4;
|
|
28
148
|
function apiBaseUrl(baseUrl) {
|
|
29
149
|
const url = new URL(baseUrl, globalThis.location?.href);
|
|
30
150
|
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
@@ -39,19 +159,9 @@ async function data(request) {
|
|
|
39
159
|
if (result.data === void 0) throw new SyntaxError("Geo API returned an empty JSON response");
|
|
40
160
|
return result.data;
|
|
41
161
|
}
|
|
42
|
-
function cacheControl(cache) {
|
|
43
|
-
if (!cache) return void 0;
|
|
44
|
-
const directives = [
|
|
45
|
-
cache.refresh ? "no-cache" : void 0,
|
|
46
|
-
cache.noStore ? "no-store" : void 0,
|
|
47
|
-
cache.maxAge === void 0 ? void 0 : `max-age=${Math.max(0, Math.floor(cache.maxAge))}`,
|
|
48
|
-
cache.onlyIfCached ? "only-if-cached" : void 0
|
|
49
|
-
].filter((directive) => directive !== void 0);
|
|
50
|
-
return directives.length > 0 ? directives.join(", ") : void 0;
|
|
51
|
-
}
|
|
52
162
|
function lookupInit(options) {
|
|
53
163
|
const { cache, ...request } = options ?? {};
|
|
54
|
-
const header =
|
|
164
|
+
const header = formatCacheControl(cache);
|
|
55
165
|
return header ? { ...request, headers: { "cache-control": header } } : request;
|
|
56
166
|
}
|
|
57
167
|
function territory(client) {
|
|
@@ -73,7 +183,7 @@ function territory(client) {
|
|
|
73
183
|
function createGeoClient(options) {
|
|
74
184
|
const client = createClient({
|
|
75
185
|
baseUrl: apiBaseUrl(options.baseUrl),
|
|
76
|
-
fetch: options
|
|
186
|
+
fetch: transport(options),
|
|
77
187
|
headers: options.apiKey ? { "x-api-key": options.apiKey } : void 0
|
|
78
188
|
});
|
|
79
189
|
client.use({
|
|
@@ -90,6 +200,11 @@ function createGeoClient(options) {
|
|
|
90
200
|
territory: territory(client)
|
|
91
201
|
};
|
|
92
202
|
}
|
|
203
|
+
function transport(options) {
|
|
204
|
+
const send = options.fetch ?? ((request) => globalThis.fetch(request));
|
|
205
|
+
const timed = timedFetch(send, options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
206
|
+
return options.pacing === false ? timed : pacedFetch(timed, options.pacing);
|
|
207
|
+
}
|
|
93
208
|
export {
|
|
94
209
|
ApiError,
|
|
95
210
|
createGeoClient
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seta-labs/geo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Typed Fetch client for the Seta Geo HTTP API",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
"devDependencies": {
|
|
45
45
|
"@seta-geo/types": "workspace:*",
|
|
46
46
|
"@seta-labs/config": "^0.16.1",
|
|
47
|
+
"@seta-labs/nestjs-cache-control": "0.2.1",
|
|
47
48
|
"tsup": "^9.0.0",
|
|
48
49
|
"typescript": "^6.0.3",
|
|
49
50
|
"vitest": "^5.0.3"
|