@infrarates/cli 0.0.0-stage → 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oimo Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,249 @@
1
- # Temporary Holding Version
1
+ # Oimo CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Requires Node.js 22.11 or newer, with no runtime dependencies. The npm
4
+ package is `@infrarates/cli` and the command is `oimo`. MIT licence.
5
+
6
+ Install it from public npm (see [Releases](#releases)):
7
+
8
+ ```sh
9
+ npm install -g @infrarates/cli # install
10
+ npm install -g @infrarates/cli@latest # update to the newest release
11
+ oimo --version
12
+ npm uninstall -g @infrarates/cli # remove
13
+ ```
14
+
15
+ From a checkout, `npm install -g .` in this directory or `node oimo.mjs`
16
+ runs the unreleased code. Run `npm test` for its tests.
17
+
18
+ ## Read data with an organization API key
19
+
20
+ Create a key on the Oimo **API keys** page. The server accepts keys for
21
+ reviewed price history, customer price/terms/status reads, providers, catalog,
22
+ activity and comparisons. These additional key routes require deploying the
23
+ accompanying providers-tracker-ui changes.
24
+
25
+ ```sh
26
+ export OIMO_API_KEY='your-organization-api-key'
27
+ oimo providers list
28
+ oimo prices latest --provider openai --limit 100
29
+ oimo prices history --providers openai,anthropic --all > prices.json
30
+ oimo terms latest --provider openai
31
+ oimo terms diff --provider openai --doc terms-of-use
32
+ oimo status
33
+ oimo catalog --provider openai
34
+ oimo compare resources
35
+ oimo compare prices --resource 'resource-id-from-compare-resources'
36
+ ```
37
+
38
+ Keys always get the customer view, even if a staff session also exists. Pending
39
+ rows are hidden and status responses omit internal details. Keys carry an
40
+ organization identity and no user role; management and personal commands require
41
+ a user session.
42
+
43
+ `OIMO_API_KEY` is not tied to an origin. A command that accepts a key
44
+ sends it, as a Bearer header, to the origin that command targets:
45
+ `https://oimo.com` by default, otherwise the one named by `--base-url` or
46
+ `OIMO_BASE_URL`. This differs from saved sessions, which are stored per
47
+ origin and never sent anywhere else. The origin must be HTTPS (HTTP only on
48
+ loopback) and redirects are refused, so the key reaches the origin you named
49
+ and no other. When a key is set, no session is sent with it; session commands
50
+ never send the key.
51
+
52
+ A key works only in the environment that issued it, so create one per
53
+ environment and set it for the command instead of exporting a production key
54
+ in a shell that also targets other origins:
55
+
56
+ ```sh
57
+ OIMO_API_KEY="$OIMO_DEVELOP_KEY" oimo prices latest --base-url https://oimo.dev
58
+ OIMO_API_KEY="$OIMO_DEVELOP_KEY" OIMO_BASE_URL=https://oimo.dev oimo status
59
+ ```
60
+
61
+ Do not point `--base-url` or `OIMO_BASE_URL` at an origin you do not
62
+ trust with the key in your environment.
63
+
64
+ ## Customer operations with a session
65
+
66
+ Run `oimo login`. It opens your default browser, signs you in through the
67
+ website's existing WorkOS flow, and asks you to approve CLI access for the shown
68
+ account. Approval starts a fresh WorkOS authorization-code exchange for the CLI;
69
+ AuthKit requires a fresh sign-in (`max_age=0`). The new session has its own
70
+ refresh token, so browser and CLI refreshes do not invalidate each other. The callback leaves
71
+ the website's session and staff organization selection in place. No cookie
72
+ copying is needed.
73
+ The CLI validates the returned session with `/v1/me` before saving it.
74
+
75
+ Automatic login requires deploying the accompanying `/auth/cli` server routes.
76
+ Use `--base-url https://oimo.dev` to sign in against the develop deployment.
77
+ For local development, start the service with `AUTH_DEV_USER=you@example.com`
78
+ and run `oimo login --base-url http://localhost:8080`.
79
+
80
+ If your browser cannot be launched automatically, the terminal prints a URL.
81
+ Open it on the same computer. `login --no-browser` prints the URL without trying
82
+ to launch a browser. Login waits up to five minutes; `--timeout <seconds>` sets
83
+ a deadline from 1 to 600 seconds. Ctrl-C or the browser's Cancel button ends it.
84
+
85
+ The handoff uses a temporary listener bound to `127.0.0.1`, a random state value,
86
+ and an ephemeral X25519 key. The server requires same-origin browser consent,
87
+ binds the new code exchange to the approved account with a separate signed
88
+ OAuth state cookie, and encrypts the new CLI session with
89
+ X25519/HKDF-SHA256/AES-256-GCM. The encrypted result is POSTed to loopback,
90
+ with no credentials in URLs or the terminal. The CLI checks
91
+ the browser Origin, callback state, encryption tag and expiry, then closes the
92
+ listener. The handshake uses no persistent server records and works across
93
+ service instances.
94
+
95
+ `login --session-stdin` retains manual session import for scripts: it reads
96
+ `OIMO_SESSION` or a session value from stdin. Automatic browser login
97
+ always obtains a fresh session even if that environment variable is set.
98
+ Manually importing a browser session shares its refresh token; use browser
99
+ login to obtain independent credentials. Existing CLI sessions created before
100
+ this change must log in again to gain that independence.
101
+
102
+ ```sh
103
+ oimo login
104
+ oimo me
105
+ oimo organizations get
106
+ oimo organizations create --data '{"name":"My workspace"}'
107
+ oimo organizations switch --data '{"organizationId":"org_..."}'
108
+ oimo webhooks list
109
+ oimo webhooks create --data @webhook.json
110
+ oimo webhooks deliveries webhook-id
111
+ oimo webhooks test webhook-id
112
+ oimo members list
113
+ oimo members update membership-id --data '{"role":"member"}'
114
+ oimo invitations create --data '{"email":"person@example.com","role":"member"}'
115
+ oimo api-keys create --data '{"name":"My integration"}'
116
+ oimo preferences get
117
+ oimo provider-requests list
118
+ oimo logout
119
+ ```
120
+
121
+ The CLI sends the session cookie and target Origin; existing role checks, current
122
+ membership checks, tenant boundaries and last-admin protection remain in force.
123
+ Admins manage people; admins and members manage integrations. Creation and
124
+ rotation commands print newly issued API keys or signing secrets in their JSON
125
+ responses, as the API does. JSON bodies may be inline, `@file.json`, or `-` for
126
+ stdin. See the service README for request-body schemas and `oimo --help`
127
+ for every command and its options.
128
+
129
+ Sessions are stored per origin at `~/.config/oimo/sessions.json` with
130
+ permissions 0600. `--config <file>` overrides the location. Responses that rotate
131
+ or switch sessions update the saved value, and one that ends the session removes
132
+ it; when a response sets the cookie twice, the last one is kept, as in a
133
+ browser. Each update is applied to the file's current contents under a lock
134
+ file (`sessions.json.lock`), so commands running at the same time, against the
135
+ same or different origins, do not undo each other's rotations or logouts. The
136
+ organization a staff user is viewing is saved beside the session
137
+ and sent with later commands, until a switch into an organization they belong
138
+ to, a new login or `logout` clears it. An environment session overrides the
139
+ saved value and keeps nothing between commands, so a staff viewer selection
140
+ does not carry over; unset it after login to use the saved session and
141
+ rotations. `logout` removes only the selected origin's local session; it does
142
+ not revoke either remote WorkOS session or unset environment credentials.
143
+ Website logout ends the browser's WorkOS session and leaves an independently
144
+ created CLI session usable. Revoking the CLI's WorkOS session leaves the browser
145
+ session usable; revoking all sessions for the user ends both. Revocation is
146
+ enforced when the affected access token expires and its next refresh fails.
147
+
148
+ ## Output and paging
149
+
150
+ `prices history` shows a readable table in an interactive terminal: provider,
151
+ resource, dimension, exact price with currency, and billing unit. Each observation
152
+ also shows its tier, source and recorded timestamp, plus effective dates and
153
+ renames when present. Names wrap rather than being truncated; very narrow
154
+ terminals use stacked observations. The header shows the pinned snapshot and
155
+ providers, and a paginated result prints a command to read the next page with
156
+ the same `--base-url`, `--limit` and `--config`.
157
+
158
+ Piping or redirecting history output defaults to JSON with all original fields.
159
+ Use `--format json` to get JSON in the terminal, or `--format table` to redirect
160
+ the readable display. Rates retain their decimal strings and native currencies
161
+ and units, without conversions or rounding. Other commands print JSON; empty
162
+ responses print `null`, and `terms content` prints the upstream text.
163
+ Terms are vendor-written, so in an interactive terminal their control
164
+ characters (escape sequences, BEL, bidirectional overrides) are shown escaped
165
+ as `\uXXXX` instead of being interpreted; line breaks and tabs are kept. Piped
166
+ or redirected output is the text as served.
167
+ Errors go to stderr with exit code 1.
168
+
169
+ `prices history` reads `/v1/prices/history`, including older rates. By default,
170
+ one page is read. JSON includes `asOf`, `providers`, `prices` and `nextCursor`. `--all` combines
171
+ all pages in memory and prints only after every page succeeds. The cursor pins
172
+ the snapshot and providers; subsequent requests send only cursor and limit.
173
+ `--alias` takes an alias group's id or comparison name (`compare aliases` lists
174
+ them) and reads that resource at every provider that sells it; the answer adds
175
+ `alias` with the group's revision and members, and the cursor pins that revision.
176
+ Keep `asOf` to reproduce a reading. Default page size is 5000, maximum 10000.
177
+ Credit-denominated providers require explicit selection under the API defaults.
178
+
179
+ ```sh
180
+ oimo prices history --as-of 2026-10-01T00:00:00Z --limit 100
181
+ oimo prices history --providers openai --limit 20
182
+ oimo prices history --providers openai --format json
183
+ oimo prices history --alias claude-opus-5.5 --all
184
+ oimo prices history --all > prices.json
185
+ oimo prices history --cursor 'cursor-from-previous-response'
186
+ oimo prices resource-history --provider openai --resource gpt-5
187
+ oimo prices latest --provider openai --offset 100 --limit 100 --sort price --dir asc
188
+ oimo prices history --base-url https://oimo.dev --all
189
+ ```
190
+
191
+ `prices latest` uses offset paging (maximum 1000 rows per page; without
192
+ `--limit`, an API key or a customer session gets the first 1000); it is a current
193
+ reading, not a pinned export, and has no automatic paging. `resource-history`
194
+ reads the existing per-resource `/api/prices/history` endpoint. Other list
195
+ commands follow their existing API bounds; use `--after` where shown in help.
196
+ Timestamps and provider names are validated by the API.
197
+
198
+ `OIMO_BASE_URL` sets a default origin; `--base-url` overrides it. Saved
199
+ sessions are never sent to another origin; an environment API key goes to the
200
+ selected origin (see above). HTTP is supported only on loopback
201
+ for local development. Redirects are refused and each request times out after
202
+ 30 seconds. Requests are not automatically retried, including mutations.
203
+
204
+ ## Releases
205
+
206
+ The package is `@infrarates/cli` on public npm, first released as 0.1.0
207
+ (OIM-511). The `@infrarates` scope is the one Oimo can already publish to: its packages
208
+ are maintained by Jorge's personal npm account (`jtkm6`), which is also the
209
+ account that links the package to the publish workflow. The owner of `@oimo`
210
+ is unconfirmed. The command stays `oimo`.
211
+
212
+ - `oimo --version` prints the installed package's `package.json` version, so
213
+ a release changes only that file. Versions follow semver: a removed or
214
+ renamed command or option is a major bump while the version is 1.x or
215
+ later (a minor one while it is 0.x).
216
+ - `install-smoke.mjs` (part of `npm test`) packs the package, checks the
217
+ tarball holds exactly `package.json` and the `files` list (which includes
218
+ `LICENSE`), installs it globally into an empty prefix with an empty npm
219
+ cache and config, runs the installed `oimo` from outside the repository,
220
+ installs the next patch release's tarball over it (the upgrade path, e.g.
221
+ 0.1.0 → 0.1.1), then uninstalls it. Release versions are plain
222
+ `MAJOR.MINOR.PATCH`; the test refuses any other `package.json` version. It
223
+ has passed on `node:22.11.0-slim` (the `engines` floor) and `node:24-slim`
224
+ with only this directory copied in:
225
+
226
+ ```sh
227
+ podman run --rm -v "$PWD:/src:ro,Z" node:22.11.0-slim \
228
+ sh -c 'cp -r /src /cli && cd /cli && npm test'
229
+ ```
230
+
231
+ - `.github/workflows/publish-cli.yml` publishes. A pull request that touches
232
+ this directory runs the tests and `npm publish --dry-run`. To release:
233
+ 1. merge a PR that bumps `version` in `package.json`;
234
+ 2. tag that merged commit `cli-v<version>` and push the tag
235
+ (`git tag cli-v0.1.1 <sha> && git push origin cli-v0.1.1`);
236
+ 3. a platform reviewer approves the run in the `npm-publish` environment.
237
+
238
+ The workflow refuses a tag that does not match `package.json` or a commit
239
+ that is not on `main`. It publishes with npm trusted publishing, where the
240
+ job's GitHub OIDC token buys a one-time publish token, so no npm token is
241
+ stored. It then installs the published version from npm on Node 22.11 and
242
+ checks `oimo --version`. There is no provenance attestation because the
243
+ repository is private.
244
+
245
+ The package's Trusted Publisher settings on npmjs.com link it to
246
+ `oimo-ai/oimo`, `publish-cli.yml` and the `npm-publish` environment, which
247
+ is what lets that one job publish without a token. npm also requires
248
+ `repository.url` in `package.json` to name `oimo-ai/oimo` exactly;
249
+ without it the publish fails with only `ENEEDAUTH`.
@@ -0,0 +1,14 @@
1
+ export function openBrowser(
2
+ url: string,
3
+ platform?: NodeJS.Platform,
4
+ ): Promise<void>;
5
+ export function browserLogin(
6
+ origin: string,
7
+ options?: {
8
+ opener?: (url: string) => Promise<unknown>;
9
+ noBrowser?: boolean;
10
+ timeoutMs?: number;
11
+ signal?: AbortSignal;
12
+ report?: (text: string) => void;
13
+ },
14
+ ): Promise<string>;
@@ -0,0 +1,244 @@
1
+ import { createServer } from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ createDecipheriv,
5
+ createPublicKey,
6
+ diffieHellman,
7
+ generateKeyPairSync,
8
+ hkdfSync,
9
+ randomBytes,
10
+ timingSafeEqual,
11
+ } from "node:crypto";
12
+
13
+ export function openBrowser(url, platform = process.platform) {
14
+ const [command, args] =
15
+ platform === "darwin"
16
+ ? ["open", [url]]
17
+ : platform === "win32"
18
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
19
+ : ["xdg-open", [url]];
20
+ return new Promise((resolve, reject) => {
21
+ const child = spawn(command, args, { stdio: "ignore", shell: false });
22
+ child.once("error", reject);
23
+ child.once("exit", (code) =>
24
+ code === 0 ? resolve() : reject(new Error("Browser launch failed")),
25
+ );
26
+ child.unref();
27
+ });
28
+ }
29
+ const matches = (left, right) => {
30
+ if (typeof left !== "string") return false;
31
+ const a = Buffer.from(left),
32
+ b = Buffer.from(right);
33
+ return a.length === b.length && timingSafeEqual(a, b);
34
+ };
35
+
36
+ function decrypt(payload, privateKey, origin, callback, state) {
37
+ if (typeof payload !== "string" || !/^[A-Za-z0-9_-]+$/.test(payload))
38
+ throw new Error();
39
+ const envelope = JSON.parse(
40
+ Buffer.from(payload, "base64url").toString("utf8"),
41
+ );
42
+ if (
43
+ envelope.v !== 1 ||
44
+ typeof envelope.key !== "string" ||
45
+ !/^[A-Za-z0-9_-]{43}$/.test(envelope.key)
46
+ )
47
+ throw new Error();
48
+ for (const field of ["iv", "ciphertext", "tag"])
49
+ if (
50
+ typeof envelope[field] !== "string" ||
51
+ !/^[A-Za-z0-9_-]+$/.test(envelope[field])
52
+ )
53
+ throw new Error();
54
+ const publicKey = createPublicKey({
55
+ key: { kty: "OKP", crv: "X25519", x: envelope.key },
56
+ format: "jwk",
57
+ });
58
+ const info = Buffer.from(
59
+ JSON.stringify(["oimo-cli-login-v1", origin, callback, state]),
60
+ );
61
+ const key = Buffer.from(
62
+ hkdfSync(
63
+ "sha256",
64
+ diffieHellman({ privateKey, publicKey }),
65
+ Buffer.from(state, "base64url"),
66
+ info,
67
+ 32,
68
+ ),
69
+ );
70
+ const iv = Buffer.from(envelope.iv, "base64url"),
71
+ tag = Buffer.from(envelope.tag, "base64url");
72
+ if (iv.length !== 12 || tag.length !== 16) throw new Error();
73
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
74
+ decipher.setAAD(info);
75
+ decipher.setAuthTag(tag);
76
+ const value = JSON.parse(
77
+ Buffer.concat([
78
+ decipher.update(Buffer.from(envelope.ciphertext, "base64url")),
79
+ decipher.final(),
80
+ ]).toString("utf8"),
81
+ );
82
+ if (
83
+ value.origin !== origin ||
84
+ !matches(value.state, state) ||
85
+ !Number.isSafeInteger(value.expiresAt) ||
86
+ value.expiresAt <= Date.now() ||
87
+ value.expiresAt > Date.now() + 120_000
88
+ )
89
+ throw new Error();
90
+ if (value.cancelled === true) return null;
91
+ if (
92
+ typeof value.session !== "string" ||
93
+ !value.session ||
94
+ /[\s;,\x00-\x1f\x7f]/.test(value.session)
95
+ )
96
+ throw new Error();
97
+ return value.session;
98
+ }
99
+
100
+ /** Bind before launching the browser; accept one authenticated handoff only. */
101
+ export async function browserLogin(
102
+ origin,
103
+ {
104
+ opener = openBrowser,
105
+ noBrowser = false,
106
+ timeoutMs = 300_000,
107
+ signal,
108
+ report = (text) => process.stderr.write(text),
109
+ } = {},
110
+ ) {
111
+ const { privateKey, publicKey } = generateKeyPairSync("x25519");
112
+ const state = randomBytes(32).toString("base64url");
113
+ let callback,
114
+ host,
115
+ completed = false,
116
+ timer;
117
+ let resolveLogin, rejectLogin;
118
+ const received = new Promise((resolve, reject) => {
119
+ resolveLogin = resolve;
120
+ rejectLogin = reject;
121
+ });
122
+ // Install a rejection handler while the browser-launch promise is pending.
123
+ received.catch(() => {});
124
+ const server = createServer(async (request, response) => {
125
+ response.setHeader("Cache-Control", "no-store");
126
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
127
+ response.setHeader("X-Content-Type-Options", "nosniff");
128
+ response.setHeader(
129
+ "Content-Security-Policy",
130
+ "default-src 'none'; frame-ancestors 'none'",
131
+ );
132
+ response.setHeader("Referrer-Policy", "no-referrer");
133
+ const fail = (status) => {
134
+ response.writeHead(status);
135
+ response.end("Invalid sign-in callback.");
136
+ };
137
+ if (
138
+ completed ||
139
+ request.method !== "POST" ||
140
+ request.url !== "/callback" ||
141
+ request.headers.host !== host ||
142
+ request.headers.origin !== origin ||
143
+ request.headers["content-type"]?.split(";", 1)[0] !==
144
+ "application/x-www-form-urlencoded"
145
+ ) {
146
+ fail(403);
147
+ return;
148
+ }
149
+ try {
150
+ let size = 0;
151
+ const chunks = [];
152
+ for await (const chunk of request) {
153
+ size += chunk.length;
154
+ if (size > 65_536) {
155
+ fail(413);
156
+ request.resume();
157
+ return;
158
+ }
159
+ chunks.push(chunk);
160
+ }
161
+ const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
162
+ if (
163
+ form.getAll("state").length !== 1 ||
164
+ form.getAll("payload").length !== 1 ||
165
+ !matches(form.get("state"), state)
166
+ ) {
167
+ fail(403);
168
+ return;
169
+ }
170
+ const session = decrypt(
171
+ form.get("payload"),
172
+ privateKey,
173
+ origin,
174
+ callback,
175
+ state,
176
+ );
177
+ if (completed) {
178
+ fail(409);
179
+ return;
180
+ }
181
+ completed = true;
182
+ response.end(
183
+ session
184
+ ? "Signed in to Oimo CLI. You can close this tab."
185
+ : "CLI sign-in cancelled. You can close this tab.",
186
+ () => {
187
+ if (session) resolveLogin(session);
188
+ else rejectLogin(new Error("CLI sign-in cancelled."));
189
+ },
190
+ );
191
+ } catch {
192
+ if (!response.headersSent) fail(400);
193
+ }
194
+ });
195
+ server.requestTimeout = 15_000;
196
+ server.headersTimeout = 10_000;
197
+ server.keepAliveTimeout = 1_000;
198
+ const abort = () => rejectLogin(new Error("CLI sign-in cancelled."));
199
+ try {
200
+ await new Promise((resolve, reject) => {
201
+ server.once("error", reject);
202
+ server.listen(0, "127.0.0.1", resolve);
203
+ });
204
+ host = `127.0.0.1:${server.address().port}`;
205
+ callback = `http://${host}/callback`;
206
+ signal?.addEventListener("abort", abort, { once: true });
207
+ if (signal?.aborted) {
208
+ abort();
209
+ return await received;
210
+ }
211
+ timer = setTimeout(
212
+ () =>
213
+ rejectLogin(
214
+ new Error(
215
+ "CLI sign-in timed out. Run oimo login again. New to Oimo? Accounts open from the waitlist: run oimo login once your invitation email has arrived and you have finished signing up.",
216
+ ),
217
+ ),
218
+ timeoutMs,
219
+ );
220
+ const url = new URL("/auth/cli", origin);
221
+ url.search = new URLSearchParams({
222
+ callback,
223
+ state,
224
+ key: publicKey.export({ format: "jwk" }).x,
225
+ }).toString();
226
+ report(`Finish sign-in in your browser:\n${url.href}\n`);
227
+ if (!noBrowser) {
228
+ // Launching may hang on a headless system; it must not extend the login deadline.
229
+ void Promise.resolve()
230
+ .then(() => opener(url.href))
231
+ .catch(() =>
232
+ report(
233
+ "Could not open a browser automatically. Open the link above on this computer.\n",
234
+ ),
235
+ );
236
+ }
237
+ return await received;
238
+ } finally {
239
+ clearTimeout(timer);
240
+ signal?.removeEventListener("abort", abort);
241
+ server.close();
242
+ server.closeAllConnections();
243
+ }
244
+ }