@wtfalch/edge 0.0.0 → 0.3.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 William Tallis Falch
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,48 @@
1
1
  # @wtfalch/edge
2
2
 
3
- Placeholder. Use 0.1.0 or later.
3
+ Public client SDK and API types for the edge service. Requires Node 22+ or a
4
+ browser with Fetch support. The client has no runtime package dependencies.
5
+
6
+ ## Status
7
+
8
+ Published: yes, v0.3.0, 2026-10-06.
9
+
10
+ ```sh
11
+ npm install @wtfalch/edge
12
+ ```
13
+
14
+ ```ts
15
+ import { createEdgeClient } from '@wtfalch/edge';
16
+
17
+ const edge = createEdgeClient({
18
+ url: process.env.EDGE_URL,
19
+ apiKey: process.env.EDGE_API_KEY,
20
+ });
21
+
22
+ if ((await edge.available('alice')).available) {
23
+ const claim = await edge.claim({ name: 'alice', port: 8080, ref: 'device-1' });
24
+ const { token } = await edge.token(claim.id); // hand to the user's machine
25
+ }
26
+ ```
27
+
28
+ ```ts
29
+ // DNS records under the org's domain (`records:read`, `records:edit`)
30
+ const { records } = await edge.records.list();
31
+ const www = await edge.records.create({ label: 'www', type: 'A', content: '203.0.113.7' });
32
+ await edge.records.update(www.id, { ttl: 300 });
33
+ await edge.records.remove(www.id);
34
+ ```
35
+
36
+ The key carries the organisation and the one domain its names live under, so
37
+ no call names either. `claim` is safe to repeat: the same `ref` returns the same
38
+ claim, and a new `port` moves its route. `get(ref)` finds the claim for a `ref` without listing. `close` makes the name answer 404 and
39
+ keeps it claimed; `remove` deletes the tunnel and record. `setReservedLabels`,
40
+ `mintKey` and `revokeKey` need the platform credential and are refused for an
41
+ entry key. A `remove` that already happened succeeds again, so a lost response is safe to retry. `rate_limited` errors carry `retryAfterSeconds`; a `conflict` with `reason: 'upstream_rejected'` means Cloudflare permanently refused, so do not retry it. `records.list()` also shows the zone's other records as `owner: 'platform'`, `locked: true`; they are never changed through edge. `records.create` takes `label` (`@` for the apex, TXT only), `type` (TXT, MX, CNAME, A, AAAA, CAA) and `content`, or `data` for CAA. `records.update` changes `content`, `data`, `ttl` or `priority`; to change a label or type, remove the record and create another. A records `conflict` carries a `reason` of `busy`, `pending`, `removing`, `taken` or `upstream_rejected` (`RecordConflictReason`); a record the platform owns answers `forbidden`, and the org's records cap answers `over_budget`. API failures reject with `EdgeApiError`, which carries `status` and
42
+ a typed `code` (see `EdgeError`); the SDK never throws a bare `Error` for a
43
+ service failure.
44
+
45
+ The root export provides the client and public request/response types.
46
+ `@wtfalch/edge/client` is also supported. There are no server, database,
47
+ migration or authorization exports. The service implementation is a private
48
+ package.
@@ -0,0 +1,49 @@
1
+ import { type ClientOptions } from './transport.js';
2
+ import type { AvailableResponse, ClaimNameRequest, ListClaimsResponse, ListRecordsResponse, MintKeyRequest, MintKeyResponse, NameClaim, OrgSettingsRequest, RecordInput, RecordPatch, RecordView, TokenResponse } from './types.js';
3
+ export { EdgeApiError, type ClientOptions } from './transport.js';
4
+ /** Called with an entry's key: the key carries its organisation and domain, so
5
+ * no call names either. `setReservedLabels`, `mintKey` and `revokeKey` are
6
+ * refused by the service for any credential but the platform's own. */
7
+ export declare function createEdgeClient(options: ClientOptions): {
8
+ /** The org's DNS records. Needs `records:read` (`records:edit` for changes). */
9
+ records: {
10
+ /** The org's own records, then every other live record in the zone as a
11
+ * locked platform record. */
12
+ list: () => Promise<ListRecordsResponse>;
13
+ /** Rejects with `not_found` when there is no such record. */
14
+ get: (id: string) => Promise<RecordView>;
15
+ /** Rejects with `conflict` (see `RecordConflictReason`) when the record
16
+ * exists or clashes, `forbidden` for a platform-owned label, and
17
+ * `over_budget` at the org's records cap. */
18
+ create: (input: RecordInput) => Promise<RecordView>;
19
+ /** Changes content, ttl or priority of the org's own record. */
20
+ update: (id: string, patch: RecordPatch) => Promise<RecordView>;
21
+ /** Returns the tombstone (`state: 'removed'`); repeating it is safe. */
22
+ remove: (id: string) => Promise<RecordView>;
23
+ };
24
+ /** True when the name is free: not reserved, no record in the zone, not claimed. */
25
+ available: (name: string) => Promise<AvailableResponse>;
26
+ /** Creates the tunnel, routes `name` to `127.0.0.1:<port>` with a 404
27
+ * catch-all, publishes the proxied CNAME. Safe to repeat: the same `ref`
28
+ * returns the same claim, moving its route when `port` changed. */
29
+ claim: (input: ClaimNameRequest) => Promise<NameClaim>;
30
+ /** The org's own claim for `ref`, so a caller can find it without `list()`.
31
+ * Rejects with `not_found` when there is none. */
32
+ get: (ref: string) => Promise<NameClaim>;
33
+ /** Returns the tunnel token for the app to hand to its user's machine. */
34
+ token: (id: string) => Promise<TokenResponse>;
35
+ /** The tunnel answers 404 at once; the name stays claimed. */
36
+ close: (id: string) => Promise<NameClaim>;
37
+ /** Drops connections, deletes the record and the tunnel. */
38
+ remove: (id: string) => Promise<NameClaim>;
39
+ /** The org's claims with their state. */
40
+ list: () => Promise<ListClaimsResponse>;
41
+ /** Platform credential only: replaces an org's reserved labels. */
42
+ setReservedLabels: (organisationId: string, labels: string[]) => Promise<void>;
43
+ /** Platform credential only: the org's claim cap and rate bucket. */
44
+ setOrgSettings: (input: OrgSettingsRequest) => Promise<void>;
45
+ /** Platform credential only: mints an entry key for an org. */
46
+ mintKey: (input: MintKeyRequest) => Promise<MintKeyResponse>;
47
+ /** Platform credential only: revokes an entry key. */
48
+ revokeKey: (organisationId: string, id: string) => Promise<void>;
49
+ };
package/dist/client.js ADDED
@@ -0,0 +1,51 @@
1
+ import { createTransport } from './transport.js';
2
+ export { EdgeApiError } from './transport.js';
3
+ /** Called with an entry's key: the key carries its organisation and domain, so
4
+ * no call names either. `setReservedLabels`, `mintKey` and `revokeKey` are
5
+ * refused by the service for any credential but the platform's own. */
6
+ export function createEdgeClient(options) {
7
+ const call = createTransport(options);
8
+ return {
9
+ /** The org's DNS records. Needs `records:read` (`records:edit` for changes). */
10
+ records: {
11
+ /** The org's own records, then every other live record in the zone as a
12
+ * locked platform record. */
13
+ list: () => call('GET', '/v1/records'),
14
+ /** Rejects with `not_found` when there is no such record. */
15
+ get: (id) => call('GET', `/v1/records/${encodeURIComponent(id)}`),
16
+ /** Rejects with `conflict` (see `RecordConflictReason`) when the record
17
+ * exists or clashes, `forbidden` for a platform-owned label, and
18
+ * `over_budget` at the org's records cap. */
19
+ create: (input) => call('POST', '/v1/records', input),
20
+ /** Changes content, ttl or priority of the org's own record. */
21
+ update: (id, patch) => call('PATCH', `/v1/records/${encodeURIComponent(id)}`, patch),
22
+ /** Returns the tombstone (`state: 'removed'`); repeating it is safe. */
23
+ remove: (id) => call('DELETE', `/v1/records/${encodeURIComponent(id)}`),
24
+ },
25
+ /** True when the name is free: not reserved, no record in the zone, not claimed. */
26
+ available: (name) => call('GET', `/v1/names/available?${new URLSearchParams({ name })}`),
27
+ /** Creates the tunnel, routes `name` to `127.0.0.1:<port>` with a 404
28
+ * catch-all, publishes the proxied CNAME. Safe to repeat: the same `ref`
29
+ * returns the same claim, moving its route when `port` changed. */
30
+ claim: (input) => call('POST', '/v1/names/claims', input),
31
+ /** The org's own claim for `ref`, so a caller can find it without `list()`.
32
+ * Rejects with `not_found` when there is none. */
33
+ get: (ref) => call('GET', `/v1/names/claim?${new URLSearchParams({ ref })}`),
34
+ /** Returns the tunnel token for the app to hand to its user's machine. */
35
+ token: (id) => call('GET', `/v1/names/claims/${encodeURIComponent(id)}/token`),
36
+ /** The tunnel answers 404 at once; the name stays claimed. */
37
+ close: (id) => call('POST', `/v1/names/claims/${encodeURIComponent(id)}/close`, {}),
38
+ /** Drops connections, deletes the record and the tunnel. */
39
+ remove: (id) => call('DELETE', `/v1/names/claims/${encodeURIComponent(id)}`),
40
+ /** The org's claims with their state. */
41
+ list: () => call('GET', '/v1/names/claims'),
42
+ /** Platform credential only: replaces an org's reserved labels. */
43
+ setReservedLabels: (organisationId, labels) => call('PUT', '/v1/names/reserved-labels', { organisationId, labels }),
44
+ /** Platform credential only: the org's claim cap and rate bucket. */
45
+ setOrgSettings: (input) => call('PUT', '/v1/names/settings', input),
46
+ /** Platform credential only: mints an entry key for an org. */
47
+ mintKey: (input) => call('POST', '/v1/names/keys', input),
48
+ /** Platform credential only: revokes an entry key. */
49
+ revokeKey: (organisationId, id) => call('DELETE', `/v1/names/keys/${encodeURIComponent(id)}?${new URLSearchParams({ organisationId })}`),
50
+ };
51
+ }
@@ -0,0 +1,2 @@
1
+ export * from './client.js';
2
+ export type * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './client.js';
@@ -0,0 +1,23 @@
1
+ import type { EdgeConflictReason, EdgeErrorCode } from './types.js';
2
+ export interface ClientOptions {
3
+ /** The edge service's address (foundry's `EDGE_URL`). */
4
+ url: string;
5
+ /** The entry's key (foundry's `EDGE_API_KEY`), or the platform credential. */
6
+ apiKey: string;
7
+ fetch?: typeof fetch;
8
+ }
9
+ /** A typed failure, never a bare `Error`. `status` is the HTTP status; `code`
10
+ * and `message` are the service's own wire error (see `EdgeError`). */
11
+ export declare class EdgeApiError extends Error {
12
+ readonly status: number;
13
+ readonly code: EdgeErrorCode;
14
+ readonly retryAfterSeconds?: number | undefined;
15
+ /** Set on every `conflict`. */
16
+ readonly reason?: EdgeConflictReason | undefined;
17
+ constructor(status: number, code: EdgeErrorCode, message: string, retryAfterSeconds?: number | undefined,
18
+ /** Set on every `conflict`. */
19
+ reason?: EdgeConflictReason | undefined);
20
+ }
21
+ /** Minimal fetch-based transport: JSON in, JSON out, a bearer credential on
22
+ * every request. No Node-only imports -- this runs in a browser too. */
23
+ export declare function createTransport(options: ClientOptions): <T>(method: string, path: string, input?: unknown) => Promise<T>;
@@ -0,0 +1,84 @@
1
+ const KNOWN_CODES = [
2
+ 'invalid_request',
3
+ 'unauthorized',
4
+ 'forbidden',
5
+ 'not_found',
6
+ 'conflict',
7
+ 'over_budget',
8
+ 'rate_limited',
9
+ 'not_configured',
10
+ 'unavailable',
11
+ ];
12
+ /** A typed failure, never a bare `Error`. `status` is the HTTP status; `code`
13
+ * and `message` are the service's own wire error (see `EdgeError`). */
14
+ export class EdgeApiError extends Error {
15
+ status;
16
+ code;
17
+ retryAfterSeconds;
18
+ reason;
19
+ constructor(status, code, message, retryAfterSeconds,
20
+ /** Set on every `conflict`. */
21
+ reason) {
22
+ super(message);
23
+ this.status = status;
24
+ this.code = code;
25
+ this.retryAfterSeconds = retryAfterSeconds;
26
+ this.reason = reason;
27
+ this.name = 'EdgeApiError';
28
+ }
29
+ }
30
+ function errorFrom(value) {
31
+ const error = typeof value === 'object' && value !== null && 'error' in value
32
+ ? value.error
33
+ : null;
34
+ const rawCode = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
35
+ ? error.code
36
+ : undefined;
37
+ const rawMessage = typeof error === 'object' &&
38
+ error !== null &&
39
+ 'message' in error &&
40
+ typeof error.message === 'string'
41
+ ? error.message
42
+ : undefined;
43
+ const code = KNOWN_CODES.includes(rawCode ?? '')
44
+ ? rawCode
45
+ : 'unavailable';
46
+ const rawReason = typeof error === 'object' &&
47
+ error !== null &&
48
+ 'reason' in error &&
49
+ typeof error.reason === 'string'
50
+ ? error.reason
51
+ : undefined;
52
+ return {
53
+ code,
54
+ message: rawMessage ?? 'Request failed',
55
+ reason: rawReason,
56
+ };
57
+ }
58
+ /** Minimal fetch-based transport: JSON in, JSON out, a bearer credential on
59
+ * every request. No Node-only imports -- this runs in a browser too. */
60
+ export function createTransport(options) {
61
+ return async (method, path, input) => {
62
+ const response = await (options.fetch ?? fetch)(new URL(path.replace(/^\//, ''), options.url.endsWith('/') ? options.url : `${options.url}/`), {
63
+ method,
64
+ headers: {
65
+ authorization: `Bearer ${options.apiKey}`,
66
+ ...(input === undefined ? {} : { 'content-type': 'application/json' }),
67
+ },
68
+ body: input === undefined ? undefined : JSON.stringify(input),
69
+ });
70
+ let value;
71
+ try {
72
+ value = response.status === 204 ? undefined : await response.json();
73
+ }
74
+ catch {
75
+ throw new EdgeApiError(response.status, 'unavailable', 'Invalid response body');
76
+ }
77
+ if (!response.ok) {
78
+ const { code, message, reason } = errorFrom(value);
79
+ const retryAfter = Number(response.headers.get('retry-after'));
80
+ throw new EdgeApiError(response.status, code, message, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : undefined, reason);
81
+ }
82
+ return value;
83
+ };
84
+ }
@@ -0,0 +1,159 @@
1
+ /** Public HTTP contracts for the edge service. Internal storage rows --
2
+ * the Cloudflare tunnel and DNS record ids -- are never returned. */
3
+ export interface NameClaim {
4
+ id: string;
5
+ organisationId: string;
6
+ /** The DNS label, under the calling organisation's own domain. */
7
+ name: string;
8
+ /** `<name>.<the organisation's domain>` -- what the SDK calls `claim()`
9
+ * with and what the service actually resolved it to is always the same
10
+ * string, since the domain is never caller-supplied. */
11
+ hostname: string;
12
+ port: number;
13
+ /** The caller-chosen idempotency key passed to `claim()`. */
14
+ ref: string;
15
+ /** `pending`: still being set up (claim the same `ref` again to finish it).
16
+ * `removing`: teardown started (call `remove` again). `removed`: the
17
+ * tombstone `remove` returns, so a retried remove succeeds. */
18
+ state: 'pending' | 'active' | 'closed' | 'removing' | 'removed';
19
+ createdAt: string;
20
+ updatedAt: string;
21
+ }
22
+ export interface AvailableResponse {
23
+ available: boolean;
24
+ }
25
+ export interface ClaimNameRequest {
26
+ name: string;
27
+ port: number;
28
+ ref: string;
29
+ }
30
+ export interface TokenResponse {
31
+ token: string;
32
+ }
33
+ export interface ListClaimsResponse {
34
+ claims: NameClaim[];
35
+ }
36
+ export interface SetReservedLabelsRequest {
37
+ organisationId: string;
38
+ labels: string[];
39
+ }
40
+ /** The wire shape of a failed request, discriminated on `code` so a caller
41
+ * can narrow on it. Every non-2xx response body has this shape. */
42
+ export type EdgeError = {
43
+ code: 'invalid_request';
44
+ message: string;
45
+ } | {
46
+ code: 'unauthorized';
47
+ message: string;
48
+ } | {
49
+ code: 'forbidden';
50
+ message: string;
51
+ } | {
52
+ code: 'not_found';
53
+ message: string;
54
+ } | {
55
+ code: 'conflict';
56
+ message: string;
57
+ } | {
58
+ code: 'over_budget';
59
+ message: string;
60
+ } | {
61
+ code: 'rate_limited';
62
+ message: string;
63
+ } | {
64
+ code: 'not_configured';
65
+ message: string;
66
+ } | {
67
+ code: 'unavailable';
68
+ message: string;
69
+ };
70
+ /** `reason: 'upstream_rejected'` on a `conflict` means Cloudflare permanently
71
+ * refused the request: retrying will not help. `unavailable` and
72
+ * `rate_limited` are the retryable ones. */
73
+ export type EdgeErrorCode = EdgeError['code'];
74
+ /** Platform credential only: mints an entry key for an org. */
75
+ export interface MintKeyRequest {
76
+ organisationId: string;
77
+ /** The one domain this key's names may live under. */
78
+ domain: string;
79
+ name: string;
80
+ expiresDays?: number;
81
+ }
82
+ export interface MintKeyResponse {
83
+ id: string;
84
+ /** Shown once; the service stores only its hash. */
85
+ secret: string;
86
+ }
87
+ /** Platform credential only. A null or omitted field clears that override and
88
+ * the service default applies again. */
89
+ export interface OrgSettingsRequest {
90
+ organisationId: string;
91
+ /** Most live claims (pending, active, closed) this org may hold. */
92
+ maxClaims?: number | null;
93
+ /** Most live DNS records this org may hold. */
94
+ maxRecords?: number | null;
95
+ /** Rate bucket: calls allowed in a burst. */
96
+ rateBurst?: number | null;
97
+ /** Rate bucket: calls refilled per second. */
98
+ ratePerSecond?: number | null;
99
+ }
100
+ /** Why a `conflict` happened. `busy`: another call holds this claim, retry
101
+ * soon. `pending`: still being set up. `removing`: teardown in progress, retry
102
+ * the remove. `taken`: the name or ref belongs to something else; do not
103
+ * retry. `held`: the name was removed less than 30 days ago; do not retry
104
+ * until the hold ends. `upstream_rejected`: Cloudflare permanently refused; do not retry. */
105
+ export type EdgeConflictReason = 'busy' | 'pending' | 'removing' | 'taken' | 'held' | 'upstream_rejected';
106
+ export type RecordType = 'TXT' | 'MX' | 'CNAME' | 'A' | 'AAAA' | 'CAA';
107
+ /** CAA's structured value, in Cloudflare's form. */
108
+ export interface CaaData {
109
+ flags: number;
110
+ tag: string;
111
+ value: string;
112
+ }
113
+ /** A DNS record in the organisation's zone. The org's own records have
114
+ * `owner: 'organisation'`; every other live record in the zone is shown as
115
+ * `owner: 'platform'` with `locked: true` and is never changed through edge. */
116
+ export interface RecordView {
117
+ /** The row id for the org's own record; Cloudflare's id for a platform one. */
118
+ id: string;
119
+ /** The label under the org's domain; the apex is `@`. */
120
+ label: string;
121
+ type: string;
122
+ content: string;
123
+ /** Seconds, or 1 for automatic. */
124
+ ttl: number;
125
+ /** MX only; null otherwise. */
126
+ priority: number | null;
127
+ owner: 'organisation' | 'platform';
128
+ locked: boolean;
129
+ /** `pending`: still being set up. `removing`: teardown started. `removed`:
130
+ * the tombstone `records.remove` returns. */
131
+ state: 'pending' | 'active' | 'removing' | 'removed';
132
+ }
133
+ export interface RecordInput {
134
+ label: string;
135
+ type: RecordType;
136
+ /** Every type but CAA. */
137
+ content?: string;
138
+ /** CAA only. */
139
+ data?: CaaData;
140
+ /** Seconds (60 to 86400), or 1 for automatic (the default). */
141
+ ttl?: number;
142
+ /** MX only, 0 to 65535. */
143
+ priority?: number;
144
+ }
145
+ /** The label and type never change: remove the record and create another. */
146
+ export interface RecordPatch {
147
+ content?: string;
148
+ data?: CaaData;
149
+ ttl?: number;
150
+ priority?: number;
151
+ }
152
+ export interface ListRecordsResponse {
153
+ records: RecordView[];
154
+ }
155
+ /** Why a records call answered `conflict`: `busy` (retry soon), `pending`
156
+ * (still being set up), `removing` (teardown in progress), or `taken` (the
157
+ * record, or one it clashes with, already exists; do not retry), or `upstream_rejected`
158
+ * (Cloudflare permanently refused; do not retry). */
159
+ export type RecordConflictReason = Extract<EdgeConflictReason, 'busy' | 'pending' | 'removing' | 'taken' | 'upstream_rejected'>;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,7 +1,52 @@
1
1
  {
2
2
  "name": "@wtfalch/edge",
3
- "version": "0.0.0",
4
- "description": "Placeholder that claims the name. Real releases start at 0.1.0.",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
5
  "license": "MIT",
6
- "repository": { "type": "git", "url": "https://github.com/wtfalch/edge" }
6
+ "description": "Public client SDK and API types for the edge service.",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/wtfalch/edge",
10
+ "directory": "packages/sdk"
11
+ },
12
+ "files": [
13
+ "LICENSE",
14
+ "README.md",
15
+ "dist/client.d.ts",
16
+ "dist/client.js",
17
+ "dist/index.d.ts",
18
+ "dist/index.js",
19
+ "dist/transport.d.ts",
20
+ "dist/transport.js",
21
+ "dist/types.d.ts",
22
+ "dist/types.js"
23
+ ],
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "default": "./dist/index.js"
28
+ },
29
+ "./client": {
30
+ "types": "./dist/client.d.ts",
31
+ "default": "./dist/client.js"
32
+ }
33
+ },
34
+ "sideEffects": false,
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.0.0"
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json",
43
+ "typecheck": "tsc --noEmit",
44
+ "test": "vitest run",
45
+ "prepublishOnly": "pnpm build && node ../../scripts/check-package.mjs"
46
+ },
47
+ "devDependencies": {
48
+ "@types/node": "^22",
49
+ "typescript": "^5.9.0",
50
+ "vitest": "^4.1.6"
51
+ }
7
52
  }