@evumi/berth-config 0.0.0-stage → 0.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/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # @evumi/berth-config
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
+ 读取 Berth 项目 Config 的客户端。第一次读取时拉一次整份配置,之后都从内存读;后台用一个长轮询保持最新。零运行时依赖,支持 Node ≥ 18、Bun、Deno 和不带 Node 内置模块的 edge 运行时。
4
+
5
+ > **不要放机密。** Config 对组织成员可读。密码、密钥放到服务变量里。
6
+
7
+ ## 用法
8
+
9
+ Berth 会给容器注入 `BERTH_CONFIG_URL` 和 `BERTH_CONFIG_TOKEN`,直接用默认导出:
10
+
11
+ ```ts
12
+ import { config } from '@evumi/berth-config';
13
+
14
+ const enabled = await config.get<boolean>('newCheckout');
15
+ const all = await config.getAll();
16
+ const unsubscribe = config.subscribe((doc) => console.log('config changed', doc));
17
+ ```
18
+
19
+ 自己建客户端:
20
+
21
+ ```ts
22
+ import { createClient } from '@evumi/berth-config';
23
+
24
+ const cfg = createClient({
25
+ url: process.env.BERTH_CONFIG_URL, // 默认读环境变量
26
+ token: process.env.BERTH_CONFIG_TOKEN,
27
+ fallback: './config.local.json', // 本地开发或启动时连不上服务器时使用
28
+ timeoutMs: 5000,
29
+ onError: (err) => console.warn('config:', err.code, err.message), // 默认什么都不打
30
+ });
31
+ ```
32
+
33
+ | 方法 | 说明 |
34
+ |---|---|
35
+ | `get<T>(key)` | 某个顶层字段,不存在时返回 `undefined` |
36
+ | `getAll<T>(keys?)` | 整份文档,或只取给出的字段 |
37
+ | `has(key)` | 字段是否存在 |
38
+ | `digest()` | 当前 ETag(不带引号);用的是 fallback 时为 `""` |
39
+ | `version()` | 当前版本号;还没有配置或用的是 fallback 时为 `0` |
40
+ | `subscribe(cb)` | 配置变化时回调,返回取消函数 |
41
+ | `close()` | 停止长轮询并中断进行中的请求 |
42
+
43
+ 返回值是副本,改了不影响缓存。
44
+
45
+ ## 行为
46
+
47
+ - 第一次调用任意方法时拉取 `GET /v1/config`,并发调用共用这一次请求;失败了下次调用会重试。
48
+ - 之后后台只有一个长轮询(`?wait=55` + `If-None-Match`),变化时立即更新并通知订阅者。
49
+ - 出错时按指数退避重试(带抖动,最长 30 秒);429 按 `Retry-After` 等待。
50
+ - Node 里长轮询的连接和定时器都已 `unref`,不会让进程退出不了;脚本里也不需要手动 `close()`。
51
+ - 错误都是 `BerthConfigError`,带 `code`(`unauthorized`、`not_found`、`unavailable`、`rate_limited`、`timeout`、`network`、`missing_env`、`fallback` 等)和 `status`。令牌不会出现在错误或日志里。
52
+
53
+ ## Next.js
54
+
55
+ ```ts
56
+ // app/page.tsx(服务端组件)
57
+ import { config } from '@evumi/berth-config';
58
+
59
+ export default async function Page() {
60
+ const banner = await config.get<string>('banner');
61
+ return <main>{banner && <p>{banner}</p>}</main>;
62
+ }
63
+ ```
64
+
65
+ Edge 运行时也能用,`fallback` 只能传对象(edge 没有文件系统)。
66
+
67
+ ## Express
68
+
69
+ ```ts
70
+ import express from 'express';
71
+ import { config } from '@evumi/berth-config';
72
+
73
+ const app = express();
74
+ app.get('/', async (_req, res) => {
75
+ const { maxItems = 20 } = await config.getAll<{ maxItems?: number }>();
76
+ res.json({ maxItems });
77
+ });
78
+ app.listen(3000);
79
+ ```
80
+
81
+ ## 本地开发
82
+
83
+ 本地没有 `BERTH_CONFIG_URL` / `BERTH_CONFIG_TOKEN` 时读 `fallback`:
84
+
85
+ ```ts
86
+ const cfg = createClient({ fallback: './config.local.json' }); // 文件(Node/Bun/Deno)
87
+ const cfg2 = createClient({ fallback: { newCheckout: true } }); // 或直接给对象
88
+ ```
89
+
90
+ 没有环境变量也没有 `fallback` 时,读取会抛 `missing_env`。
91
+
92
+ ## 开发
93
+
94
+ ```sh
95
+ pnpm install
96
+ pnpm test # 先构建,再跑 vitest(含 ESM/CJS 子进程退出测试)
97
+ pnpm build # dist/esm + dist/cjs
98
+ ```
@@ -0,0 +1,19 @@
1
+ /** Error codes. The first four come from the server; the rest are raised by the client. */
2
+ export type ErrorCode = 'unauthorized' | 'not_found' | 'bad_request' | 'unavailable' | 'rate_limited' | 'timeout' | 'network' | 'invalid_response' | 'missing_env' | 'fallback' | 'closed' | 'subscriber' | (string & {});
3
+ export declare class BerthConfigError extends Error {
4
+ name: string;
5
+ readonly code: ErrorCode;
6
+ /** HTTP status, when the error came from a response. */
7
+ readonly status?: number;
8
+ /** Parsed Retry-After, for 429 responses. */
9
+ readonly retryAfterMs?: number;
10
+ constructor(code: ErrorCode, message: string, extra?: {
11
+ status?: number;
12
+ retryAfterMs?: number;
13
+ cause?: unknown;
14
+ });
15
+ }
16
+ /** Builds an error from a non-2xx/304 response body of the form {"error":{"code","message"}}. */
17
+ export declare function responseError(status: number, body: string, retryAfter: string | null): BerthConfigError;
18
+ /** Retry-After is either delta-seconds or an HTTP date. */
19
+ export declare function parseRetryAfter(v: string | null): number | undefined;
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.BerthConfigError = void 0;
4
+ exports.responseError = responseError;
5
+ exports.parseRetryAfter = parseRetryAfter;
6
+ class BerthConfigError extends Error {
7
+ name = 'BerthConfigError';
8
+ code;
9
+ /** HTTP status, when the error came from a response. */
10
+ status;
11
+ /** Parsed Retry-After, for 429 responses. */
12
+ retryAfterMs;
13
+ constructor(code, message, extra = {}) {
14
+ super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
15
+ this.code = code;
16
+ this.status = extra.status;
17
+ this.retryAfterMs = extra.retryAfterMs;
18
+ }
19
+ }
20
+ exports.BerthConfigError = BerthConfigError;
21
+ const statusCodes = { 400: 'bad_request', 401: 'unauthorized', 403: 'unauthorized', 404: 'not_found', 429: 'rate_limited' };
22
+ /** Builds an error from a non-2xx/304 response body of the form {"error":{"code","message"}}. */
23
+ function responseError(status, body, retryAfter) {
24
+ let code = statusCodes[status] ?? (status >= 500 ? 'unavailable' : 'bad_request');
25
+ let message = `config request failed with HTTP ${status}`;
26
+ try {
27
+ const e = JSON.parse(body)?.error;
28
+ if (typeof e?.code === 'string' && status !== 429)
29
+ code = e.code;
30
+ if (typeof e?.message === 'string' && e.message)
31
+ message = e.message;
32
+ }
33
+ catch {
34
+ // Non-JSON error body (proxy page etc.): keep the generic message.
35
+ }
36
+ return new BerthConfigError(code, message, { status, retryAfterMs: parseRetryAfter(retryAfter) });
37
+ }
38
+ /** Retry-After is either delta-seconds or an HTTP date. */
39
+ function parseRetryAfter(v) {
40
+ if (!v)
41
+ return undefined;
42
+ const s = Number(v);
43
+ if (Number.isFinite(s))
44
+ return Math.max(0, s * 1000);
45
+ const t = Date.parse(v);
46
+ return Number.isNaN(t) ? undefined : Math.max(0, t - Date.now());
47
+ }
@@ -0,0 +1,45 @@
1
+ import { BerthConfigError } from './errors.js';
2
+ import { type FetchLike } from './transport.js';
3
+ export { BerthConfigError, type ErrorCode } from './errors.js';
4
+ export type ConfigDocument = Record<string, unknown>;
5
+ export interface ClientOptions {
6
+ /** Base URL without /v1. Default: env BERTH_CONFIG_URL. */
7
+ url?: string;
8
+ /** Bearer token (bcfg_...). Default: env BERTH_CONFIG_TOKEN. */
9
+ token?: string;
10
+ /** Local JSON file path or object, used when env vars are absent or the server is unreachable at startup. */
11
+ fallback?: string | ConfigDocument;
12
+ /** Timeout for a normal request; a long poll gets waitSeconds on top. Default 5000. */
13
+ timeoutMs?: number;
14
+ /** Long-poll wait sent as ?wait= (1..60). Default 55. */
15
+ waitSeconds?: number;
16
+ /** Keep the cache fresh with a background long poll. Default true. */
17
+ poll?: boolean;
18
+ /** Retry delays for the poll loop. baseMs is also the minimum gap between polls. Default 1000 / 30000. */
19
+ backoff?: {
20
+ baseMs?: number;
21
+ maxMs?: number;
22
+ };
23
+ /** Called with background errors (poll failures, fallback use, subscriber throws). Nothing is logged by default. */
24
+ onError?: (err: BerthConfigError) => void;
25
+ /** Custom fetch. When set it is used for every request, including long polls. */
26
+ fetch?: FetchLike;
27
+ }
28
+ export interface ConfigClient {
29
+ /** Value of one top-level key, or undefined when absent. */
30
+ get<T = unknown>(key: string): Promise<T | undefined>;
31
+ /** Whole document, or only the given keys. */
32
+ getAll<T extends ConfigDocument = ConfigDocument>(keys?: string[]): Promise<T>;
33
+ has(key: string): Promise<boolean>;
34
+ /** ETag digest without quotes; "" when served from fallback. */
35
+ digest(): Promise<string>;
36
+ /** Berth-Config-Version; 0 when there is no document yet or served from fallback. */
37
+ version(): Promise<number>;
38
+ /** Called with the new document after each change. Returns an unsubscribe function. */
39
+ subscribe(cb: (doc: ConfigDocument) => void): () => void;
40
+ /** Stops the poll loop and aborts the in-flight request. */
41
+ close(): void;
42
+ }
43
+ export declare function createClient(opts?: ClientOptions): ConfigClient;
44
+ /** Client built from BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN on first use. */
45
+ export declare const config: ConfigClient;
@@ -0,0 +1,296 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.config = exports.BerthConfigError = void 0;
37
+ exports.createClient = createClient;
38
+ const errors_js_1 = require("./errors.js");
39
+ const transport_js_1 = require("./transport.js");
40
+ var errors_js_2 = require("./errors.js");
41
+ Object.defineProperty(exports, "BerthConfigError", { enumerable: true, get: function () { return errors_js_2.BerthConfigError; } });
42
+ function env(name) {
43
+ const g = globalThis;
44
+ try {
45
+ return g.process?.env?.[name] ?? g.Deno?.env.get(name) ?? undefined;
46
+ }
47
+ catch {
48
+ return undefined; // Deno without --allow-env
49
+ }
50
+ }
51
+ const clone = (v) => (v === undefined ? v : structuredClone(v));
52
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
53
+ async function readFallback(fb) {
54
+ if (typeof fb !== 'string')
55
+ return clone(fb);
56
+ const fsName = 'node:fs/promises';
57
+ const fs = await Promise.resolve(`${fsName}`).then(s => __importStar(require(s)));
58
+ let doc;
59
+ try {
60
+ doc = JSON.parse(await fs.readFile(fb, 'utf8'));
61
+ }
62
+ catch (e) {
63
+ throw new errors_js_1.BerthConfigError('fallback', `cannot read fallback file ${fb}: ${e.message}`, { cause: e });
64
+ }
65
+ if (!isObject(doc))
66
+ throw new errors_js_1.BerthConfigError('fallback', `fallback file ${fb} must contain a JSON object`);
67
+ return doc;
68
+ }
69
+ /** Errors that mean "server unreachable", where falling back is right. Auth and request errors are not. */
70
+ const transient = (e) => e instanceof errors_js_1.BerthConfigError && ['network', 'timeout', 'unavailable', 'rate_limited', 'invalid_response'].includes(e.code);
71
+ class Client {
72
+ opts;
73
+ endpoint;
74
+ token;
75
+ timeoutMs;
76
+ wait;
77
+ baseMs;
78
+ maxMs;
79
+ doc;
80
+ etag = '';
81
+ ver = 0;
82
+ ready;
83
+ polling = false;
84
+ closed = false;
85
+ abort = new AbortController();
86
+ wake;
87
+ subs = new Set();
88
+ constructor(opts = {}) {
89
+ this.opts = opts;
90
+ const url = opts.url ?? env('BERTH_CONFIG_URL');
91
+ this.token = opts.token ?? env('BERTH_CONFIG_TOKEN');
92
+ if (url)
93
+ this.endpoint = url.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1/config';
94
+ this.timeoutMs = opts.timeoutMs ?? 5000;
95
+ this.wait = Math.min(60, Math.max(1, Math.round(opts.waitSeconds ?? 55)));
96
+ this.baseMs = opts.backoff?.baseMs ?? 1000;
97
+ this.maxMs = opts.backoff?.maxMs ?? 30_000;
98
+ }
99
+ async get(key) {
100
+ return clone((await this.load())[key]);
101
+ }
102
+ async getAll(keys) {
103
+ const doc = await this.load();
104
+ if (!keys)
105
+ return clone(doc);
106
+ const out = {};
107
+ for (const k of keys)
108
+ if (Object.hasOwn(doc, k))
109
+ out[k] = clone(doc[k]);
110
+ return out;
111
+ }
112
+ async has(key) {
113
+ return Object.hasOwn(await this.load(), key);
114
+ }
115
+ async digest() {
116
+ await this.load();
117
+ return this.etag.replace(/^W\//, '').replace(/^"|"$/g, '');
118
+ }
119
+ async version() {
120
+ await this.load();
121
+ return this.ver;
122
+ }
123
+ subscribe(cb) {
124
+ this.subs.add(cb);
125
+ this.load().catch((e) => this.report(e));
126
+ return () => void this.subs.delete(cb);
127
+ }
128
+ close() {
129
+ this.closed = true;
130
+ this.subs.clear();
131
+ this.abort.abort();
132
+ this.wake?.();
133
+ }
134
+ /** First call fetches the document once; concurrent callers share it; a failed load is retried next call. */
135
+ async load() {
136
+ if (!this.doc) {
137
+ if (this.closed)
138
+ throw new errors_js_1.BerthConfigError('closed', 'client closed');
139
+ this.ready ??= this.init().catch((e) => {
140
+ this.ready = undefined;
141
+ throw e;
142
+ });
143
+ await this.ready;
144
+ }
145
+ return this.doc;
146
+ }
147
+ async init() {
148
+ const { fallback } = this.opts;
149
+ if (!this.endpoint || !this.token) {
150
+ if (fallback === undefined) {
151
+ throw new errors_js_1.BerthConfigError('missing_env', 'BERTH_CONFIG_URL and BERTH_CONFIG_TOKEN are not set and no fallback was given');
152
+ }
153
+ this.doc = await readFallback(fallback);
154
+ return;
155
+ }
156
+ try {
157
+ const res = await this.request(false);
158
+ if (res.status !== 200)
159
+ throw (0, errors_js_1.responseError)(res.status, res.body, res.header('retry-after'));
160
+ this.apply(res);
161
+ }
162
+ catch (e) {
163
+ if (fallback === undefined || !transient(e))
164
+ throw e;
165
+ this.doc = await readFallback(fallback);
166
+ this.report(e);
167
+ }
168
+ if (this.opts.poll !== false)
169
+ void this.pollLoop();
170
+ }
171
+ request(longPoll) {
172
+ const headers = { authorization: `Bearer ${this.token}`, accept: 'application/json' };
173
+ let url = this.endpoint;
174
+ let timeout = this.timeoutMs;
175
+ if (longPoll) {
176
+ url += `?wait=${this.wait}`;
177
+ timeout += this.wait * 1000;
178
+ if (this.etag)
179
+ headers['if-none-match'] = this.etag;
180
+ }
181
+ if (this.opts.fetch)
182
+ return (0, transport_js_1.viaFetch)(this.opts.fetch, url, headers, timeout, this.abort.signal);
183
+ if (longPoll && transport_js_1.isNode)
184
+ return (0, transport_js_1.viaNodeHttp)(url, headers, timeout, this.abort.signal);
185
+ return (0, transport_js_1.viaFetch)((u, i) => fetch(u, i), url, headers, timeout, this.abort.signal);
186
+ }
187
+ /** Applies a 200 response. Returns true when the document changed. */
188
+ apply(res) {
189
+ let doc;
190
+ try {
191
+ doc = JSON.parse(res.body);
192
+ }
193
+ catch {
194
+ throw new errors_js_1.BerthConfigError('invalid_response', 'config response is not JSON', { status: res.status });
195
+ }
196
+ if (!isObject(doc))
197
+ throw new errors_js_1.BerthConfigError('invalid_response', 'config document is not a JSON object', { status: res.status });
198
+ const etag = res.header('etag') ?? '';
199
+ if (this.doc && etag && etag === this.etag)
200
+ return false;
201
+ this.doc = doc;
202
+ this.etag = etag;
203
+ this.ver = Number.parseInt(res.header('berth-config-version') ?? '0', 10) || 0;
204
+ return true;
205
+ }
206
+ /** The single background loop. `polling` guarantees at most one per client. */
207
+ async pollLoop() {
208
+ if (this.polling)
209
+ return;
210
+ this.polling = true;
211
+ let failures = 0;
212
+ try {
213
+ while (!this.closed) {
214
+ const started = Date.now();
215
+ let delay;
216
+ try {
217
+ const res = await this.request(true);
218
+ if (res.status === 200) {
219
+ if (this.apply(res))
220
+ this.notify();
221
+ }
222
+ else if (res.status !== 304) {
223
+ throw (0, errors_js_1.responseError)(res.status, res.body, res.header('retry-after'));
224
+ }
225
+ failures = 0;
226
+ delay = this.baseMs - (Date.now() - started); // never poll faster than baseMs
227
+ }
228
+ catch (e) {
229
+ if (this.closed)
230
+ break;
231
+ this.report(e);
232
+ failures++;
233
+ const backoff = Math.min(this.maxMs, this.baseMs * 2 ** (failures - 1)) * (0.5 + Math.random() / 2);
234
+ const ra = e instanceof errors_js_1.BerthConfigError ? e.retryAfterMs : undefined;
235
+ delay = ra === undefined ? backoff : Math.min(ra, 300_000);
236
+ }
237
+ if (delay > 0 && !this.closed)
238
+ await this.sleep(delay);
239
+ }
240
+ }
241
+ finally {
242
+ this.polling = false;
243
+ }
244
+ }
245
+ sleep(ms) {
246
+ return new Promise((resolve) => {
247
+ const done = () => {
248
+ clearTimeout(t);
249
+ this.wake = undefined;
250
+ resolve();
251
+ };
252
+ const t = setTimeout(done, ms);
253
+ (0, transport_js_1.unref)(t);
254
+ this.wake = done;
255
+ });
256
+ }
257
+ notify() {
258
+ for (const cb of [...this.subs]) {
259
+ try {
260
+ cb(clone(this.doc));
261
+ }
262
+ catch (e) {
263
+ this.report(e);
264
+ }
265
+ }
266
+ }
267
+ report(e) {
268
+ if (!this.opts.onError)
269
+ return;
270
+ const err = e instanceof errors_js_1.BerthConfigError ? e : new errors_js_1.BerthConfigError('subscriber', String(e?.message ?? e), { cause: e });
271
+ try {
272
+ this.opts.onError(err);
273
+ }
274
+ catch {
275
+ // An onError that throws must not break the loop.
276
+ }
277
+ }
278
+ }
279
+ function createClient(opts) {
280
+ return new Client(opts);
281
+ }
282
+ let shared;
283
+ const def = () => (shared ??= createClient());
284
+ /** Client built from BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN on first use. */
285
+ exports.config = {
286
+ get: (key) => def().get(key),
287
+ getAll: (keys) => def().getAll(keys),
288
+ has: (key) => def().has(key),
289
+ digest: () => def().digest(),
290
+ version: () => def().version(),
291
+ subscribe: (cb) => def().subscribe(cb),
292
+ close: () => {
293
+ shared?.close();
294
+ shared = undefined;
295
+ },
296
+ };
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -0,0 +1,20 @@
1
+ export interface HttpResponse {
2
+ status: number;
3
+ header(name: string): string | null;
4
+ body: string;
5
+ }
6
+ export type FetchLike = (url: string, init: {
7
+ headers: Record<string, string>;
8
+ signal: AbortSignal;
9
+ }) => Promise<Response>;
10
+ /** Lets a timer not hold a Node/Bun process open. No-op elsewhere. */
11
+ export declare function unref(t: unknown): void;
12
+ /** Real Node.js (not Bun/Deno compatibility layers). */
13
+ export declare const isNode: boolean;
14
+ /** One request through fetch, with a timeout and an outer abort signal (close()). */
15
+ export declare function viaFetch(f: FetchLike, url: string, headers: Record<string, string>, timeoutMs: number, signal: AbortSignal): Promise<HttpResponse>;
16
+ /**
17
+ * Background long polls in Node go through node:http with an unref'd socket:
18
+ * an in-flight fetch() would keep the process alive for up to a minute.
19
+ */
20
+ export declare function viaNodeHttp(url: string, headers: Record<string, string>, timeoutMs: number, signal: AbortSignal): Promise<HttpResponse>;
@@ -0,0 +1,112 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.isNode = void 0;
37
+ exports.unref = unref;
38
+ exports.viaFetch = viaFetch;
39
+ exports.viaNodeHttp = viaNodeHttp;
40
+ const errors_js_1 = require("./errors.js");
41
+ /** Lets a timer not hold a Node/Bun process open. No-op elsewhere. */
42
+ function unref(t) {
43
+ t?.unref?.();
44
+ }
45
+ const g = globalThis;
46
+ /** Real Node.js (not Bun/Deno compatibility layers). */
47
+ exports.isNode = !!g.process?.versions?.node && !g.process.versions.bun && !g.Deno;
48
+ function timeoutError(ms) {
49
+ return new errors_js_1.BerthConfigError('timeout', `config request timed out after ${ms} ms`);
50
+ }
51
+ /** One request through fetch, with a timeout and an outer abort signal (close()). */
52
+ async function viaFetch(f, url, headers, timeoutMs, signal) {
53
+ const ctrl = new AbortController();
54
+ const abort = () => ctrl.abort();
55
+ signal.addEventListener('abort', abort, { once: true });
56
+ let timedOut = false;
57
+ const timer = setTimeout(() => ((timedOut = true), ctrl.abort()), timeoutMs);
58
+ unref(timer);
59
+ try {
60
+ const res = await f(url, { headers, signal: ctrl.signal });
61
+ const body = await res.text();
62
+ return { status: res.status, header: (n) => res.headers.get(n), body };
63
+ }
64
+ catch (e) {
65
+ if (timedOut)
66
+ throw timeoutError(timeoutMs);
67
+ if (signal.aborted)
68
+ throw new errors_js_1.BerthConfigError('closed', 'client closed');
69
+ throw new errors_js_1.BerthConfigError('network', `config request failed: ${e?.message ?? e}`, { cause: e });
70
+ }
71
+ finally {
72
+ clearTimeout(timer);
73
+ signal.removeEventListener('abort', abort);
74
+ }
75
+ }
76
+ // Kept in variables so bundlers for edge runtimes do not try to resolve them.
77
+ const HTTP = 'node:http';
78
+ const HTTPS = 'node:https';
79
+ /**
80
+ * Background long polls in Node go through node:http with an unref'd socket:
81
+ * an in-flight fetch() would keep the process alive for up to a minute.
82
+ */
83
+ async function viaNodeHttp(url, headers, timeoutMs, signal) {
84
+ const u = new URL(url);
85
+ const mod = await Promise.resolve(`${u.protocol === 'https:' ? HTTPS : HTTP}`).then(s => __importStar(require(s)));
86
+ return new Promise((resolve, reject) => {
87
+ const req = mod.request(u, { headers, signal }, (res) => {
88
+ let body = '';
89
+ res.setEncoding('utf8');
90
+ res.on('data', (c) => (body += c));
91
+ res.on('error', (e) => reject(new errors_js_1.BerthConfigError('network', `config request failed: ${e.message}`, { cause: e })));
92
+ res.on('end', () => {
93
+ const header = (n) => {
94
+ const v = res.headers[n.toLowerCase()];
95
+ return v === undefined ? null : Array.isArray(v) ? v.join(', ') : v;
96
+ };
97
+ resolve({ status: res.statusCode ?? 0, header, body });
98
+ });
99
+ });
100
+ req.on('socket', (s) => s.unref());
101
+ req.setTimeout(timeoutMs, () => req.destroy(timeoutError(timeoutMs)));
102
+ req.on('error', (e) => {
103
+ if (e instanceof errors_js_1.BerthConfigError)
104
+ reject(e);
105
+ else if (signal.aborted)
106
+ reject(new errors_js_1.BerthConfigError('closed', 'client closed'));
107
+ else
108
+ reject(new errors_js_1.BerthConfigError('network', `config request failed: ${e.message}`, { cause: e }));
109
+ });
110
+ req.end();
111
+ });
112
+ }
@@ -0,0 +1,19 @@
1
+ /** Error codes. The first four come from the server; the rest are raised by the client. */
2
+ export type ErrorCode = 'unauthorized' | 'not_found' | 'bad_request' | 'unavailable' | 'rate_limited' | 'timeout' | 'network' | 'invalid_response' | 'missing_env' | 'fallback' | 'closed' | 'subscriber' | (string & {});
3
+ export declare class BerthConfigError extends Error {
4
+ name: string;
5
+ readonly code: ErrorCode;
6
+ /** HTTP status, when the error came from a response. */
7
+ readonly status?: number;
8
+ /** Parsed Retry-After, for 429 responses. */
9
+ readonly retryAfterMs?: number;
10
+ constructor(code: ErrorCode, message: string, extra?: {
11
+ status?: number;
12
+ retryAfterMs?: number;
13
+ cause?: unknown;
14
+ });
15
+ }
16
+ /** Builds an error from a non-2xx/304 response body of the form {"error":{"code","message"}}. */
17
+ export declare function responseError(status: number, body: string, retryAfter: string | null): BerthConfigError;
18
+ /** Retry-After is either delta-seconds or an HTTP date. */
19
+ export declare function parseRetryAfter(v: string | null): number | undefined;
@@ -0,0 +1,41 @@
1
+ export class BerthConfigError extends Error {
2
+ name = 'BerthConfigError';
3
+ code;
4
+ /** HTTP status, when the error came from a response. */
5
+ status;
6
+ /** Parsed Retry-After, for 429 responses. */
7
+ retryAfterMs;
8
+ constructor(code, message, extra = {}) {
9
+ super(message, extra.cause === undefined ? undefined : { cause: extra.cause });
10
+ this.code = code;
11
+ this.status = extra.status;
12
+ this.retryAfterMs = extra.retryAfterMs;
13
+ }
14
+ }
15
+ const statusCodes = { 400: 'bad_request', 401: 'unauthorized', 403: 'unauthorized', 404: 'not_found', 429: 'rate_limited' };
16
+ /** Builds an error from a non-2xx/304 response body of the form {"error":{"code","message"}}. */
17
+ export function responseError(status, body, retryAfter) {
18
+ let code = statusCodes[status] ?? (status >= 500 ? 'unavailable' : 'bad_request');
19
+ let message = `config request failed with HTTP ${status}`;
20
+ try {
21
+ const e = JSON.parse(body)?.error;
22
+ if (typeof e?.code === 'string' && status !== 429)
23
+ code = e.code;
24
+ if (typeof e?.message === 'string' && e.message)
25
+ message = e.message;
26
+ }
27
+ catch {
28
+ // Non-JSON error body (proxy page etc.): keep the generic message.
29
+ }
30
+ return new BerthConfigError(code, message, { status, retryAfterMs: parseRetryAfter(retryAfter) });
31
+ }
32
+ /** Retry-After is either delta-seconds or an HTTP date. */
33
+ export function parseRetryAfter(v) {
34
+ if (!v)
35
+ return undefined;
36
+ const s = Number(v);
37
+ if (Number.isFinite(s))
38
+ return Math.max(0, s * 1000);
39
+ const t = Date.parse(v);
40
+ return Number.isNaN(t) ? undefined : Math.max(0, t - Date.now());
41
+ }
@@ -0,0 +1,45 @@
1
+ import { BerthConfigError } from './errors.js';
2
+ import { type FetchLike } from './transport.js';
3
+ export { BerthConfigError, type ErrorCode } from './errors.js';
4
+ export type ConfigDocument = Record<string, unknown>;
5
+ export interface ClientOptions {
6
+ /** Base URL without /v1. Default: env BERTH_CONFIG_URL. */
7
+ url?: string;
8
+ /** Bearer token (bcfg_...). Default: env BERTH_CONFIG_TOKEN. */
9
+ token?: string;
10
+ /** Local JSON file path or object, used when env vars are absent or the server is unreachable at startup. */
11
+ fallback?: string | ConfigDocument;
12
+ /** Timeout for a normal request; a long poll gets waitSeconds on top. Default 5000. */
13
+ timeoutMs?: number;
14
+ /** Long-poll wait sent as ?wait= (1..60). Default 55. */
15
+ waitSeconds?: number;
16
+ /** Keep the cache fresh with a background long poll. Default true. */
17
+ poll?: boolean;
18
+ /** Retry delays for the poll loop. baseMs is also the minimum gap between polls. Default 1000 / 30000. */
19
+ backoff?: {
20
+ baseMs?: number;
21
+ maxMs?: number;
22
+ };
23
+ /** Called with background errors (poll failures, fallback use, subscriber throws). Nothing is logged by default. */
24
+ onError?: (err: BerthConfigError) => void;
25
+ /** Custom fetch. When set it is used for every request, including long polls. */
26
+ fetch?: FetchLike;
27
+ }
28
+ export interface ConfigClient {
29
+ /** Value of one top-level key, or undefined when absent. */
30
+ get<T = unknown>(key: string): Promise<T | undefined>;
31
+ /** Whole document, or only the given keys. */
32
+ getAll<T extends ConfigDocument = ConfigDocument>(keys?: string[]): Promise<T>;
33
+ has(key: string): Promise<boolean>;
34
+ /** ETag digest without quotes; "" when served from fallback. */
35
+ digest(): Promise<string>;
36
+ /** Berth-Config-Version; 0 when there is no document yet or served from fallback. */
37
+ version(): Promise<number>;
38
+ /** Called with the new document after each change. Returns an unsubscribe function. */
39
+ subscribe(cb: (doc: ConfigDocument) => void): () => void;
40
+ /** Stops the poll loop and aborts the in-flight request. */
41
+ close(): void;
42
+ }
43
+ export declare function createClient(opts?: ClientOptions): ConfigClient;
44
+ /** Client built from BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN on first use. */
45
+ export declare const config: ConfigClient;
@@ -0,0 +1,258 @@
1
+ import { BerthConfigError, responseError } from './errors.js';
2
+ import { isNode, unref, viaFetch, viaNodeHttp } from './transport.js';
3
+ export { BerthConfigError } from './errors.js';
4
+ function env(name) {
5
+ const g = globalThis;
6
+ try {
7
+ return g.process?.env?.[name] ?? g.Deno?.env.get(name) ?? undefined;
8
+ }
9
+ catch {
10
+ return undefined; // Deno without --allow-env
11
+ }
12
+ }
13
+ const clone = (v) => (v === undefined ? v : structuredClone(v));
14
+ const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
15
+ async function readFallback(fb) {
16
+ if (typeof fb !== 'string')
17
+ return clone(fb);
18
+ const fsName = 'node:fs/promises';
19
+ const fs = await import(/* webpackIgnore: true */ fsName);
20
+ let doc;
21
+ try {
22
+ doc = JSON.parse(await fs.readFile(fb, 'utf8'));
23
+ }
24
+ catch (e) {
25
+ throw new BerthConfigError('fallback', `cannot read fallback file ${fb}: ${e.message}`, { cause: e });
26
+ }
27
+ if (!isObject(doc))
28
+ throw new BerthConfigError('fallback', `fallback file ${fb} must contain a JSON object`);
29
+ return doc;
30
+ }
31
+ /** Errors that mean "server unreachable", where falling back is right. Auth and request errors are not. */
32
+ const transient = (e) => e instanceof BerthConfigError && ['network', 'timeout', 'unavailable', 'rate_limited', 'invalid_response'].includes(e.code);
33
+ class Client {
34
+ opts;
35
+ endpoint;
36
+ token;
37
+ timeoutMs;
38
+ wait;
39
+ baseMs;
40
+ maxMs;
41
+ doc;
42
+ etag = '';
43
+ ver = 0;
44
+ ready;
45
+ polling = false;
46
+ closed = false;
47
+ abort = new AbortController();
48
+ wake;
49
+ subs = new Set();
50
+ constructor(opts = {}) {
51
+ this.opts = opts;
52
+ const url = opts.url ?? env('BERTH_CONFIG_URL');
53
+ this.token = opts.token ?? env('BERTH_CONFIG_TOKEN');
54
+ if (url)
55
+ this.endpoint = url.replace(/\/+$/, '').replace(/\/v1$/, '') + '/v1/config';
56
+ this.timeoutMs = opts.timeoutMs ?? 5000;
57
+ this.wait = Math.min(60, Math.max(1, Math.round(opts.waitSeconds ?? 55)));
58
+ this.baseMs = opts.backoff?.baseMs ?? 1000;
59
+ this.maxMs = opts.backoff?.maxMs ?? 30_000;
60
+ }
61
+ async get(key) {
62
+ return clone((await this.load())[key]);
63
+ }
64
+ async getAll(keys) {
65
+ const doc = await this.load();
66
+ if (!keys)
67
+ return clone(doc);
68
+ const out = {};
69
+ for (const k of keys)
70
+ if (Object.hasOwn(doc, k))
71
+ out[k] = clone(doc[k]);
72
+ return out;
73
+ }
74
+ async has(key) {
75
+ return Object.hasOwn(await this.load(), key);
76
+ }
77
+ async digest() {
78
+ await this.load();
79
+ return this.etag.replace(/^W\//, '').replace(/^"|"$/g, '');
80
+ }
81
+ async version() {
82
+ await this.load();
83
+ return this.ver;
84
+ }
85
+ subscribe(cb) {
86
+ this.subs.add(cb);
87
+ this.load().catch((e) => this.report(e));
88
+ return () => void this.subs.delete(cb);
89
+ }
90
+ close() {
91
+ this.closed = true;
92
+ this.subs.clear();
93
+ this.abort.abort();
94
+ this.wake?.();
95
+ }
96
+ /** First call fetches the document once; concurrent callers share it; a failed load is retried next call. */
97
+ async load() {
98
+ if (!this.doc) {
99
+ if (this.closed)
100
+ throw new BerthConfigError('closed', 'client closed');
101
+ this.ready ??= this.init().catch((e) => {
102
+ this.ready = undefined;
103
+ throw e;
104
+ });
105
+ await this.ready;
106
+ }
107
+ return this.doc;
108
+ }
109
+ async init() {
110
+ const { fallback } = this.opts;
111
+ if (!this.endpoint || !this.token) {
112
+ if (fallback === undefined) {
113
+ throw new BerthConfigError('missing_env', 'BERTH_CONFIG_URL and BERTH_CONFIG_TOKEN are not set and no fallback was given');
114
+ }
115
+ this.doc = await readFallback(fallback);
116
+ return;
117
+ }
118
+ try {
119
+ const res = await this.request(false);
120
+ if (res.status !== 200)
121
+ throw responseError(res.status, res.body, res.header('retry-after'));
122
+ this.apply(res);
123
+ }
124
+ catch (e) {
125
+ if (fallback === undefined || !transient(e))
126
+ throw e;
127
+ this.doc = await readFallback(fallback);
128
+ this.report(e);
129
+ }
130
+ if (this.opts.poll !== false)
131
+ void this.pollLoop();
132
+ }
133
+ request(longPoll) {
134
+ const headers = { authorization: `Bearer ${this.token}`, accept: 'application/json' };
135
+ let url = this.endpoint;
136
+ let timeout = this.timeoutMs;
137
+ if (longPoll) {
138
+ url += `?wait=${this.wait}`;
139
+ timeout += this.wait * 1000;
140
+ if (this.etag)
141
+ headers['if-none-match'] = this.etag;
142
+ }
143
+ if (this.opts.fetch)
144
+ return viaFetch(this.opts.fetch, url, headers, timeout, this.abort.signal);
145
+ if (longPoll && isNode)
146
+ return viaNodeHttp(url, headers, timeout, this.abort.signal);
147
+ return viaFetch((u, i) => fetch(u, i), url, headers, timeout, this.abort.signal);
148
+ }
149
+ /** Applies a 200 response. Returns true when the document changed. */
150
+ apply(res) {
151
+ let doc;
152
+ try {
153
+ doc = JSON.parse(res.body);
154
+ }
155
+ catch {
156
+ throw new BerthConfigError('invalid_response', 'config response is not JSON', { status: res.status });
157
+ }
158
+ if (!isObject(doc))
159
+ throw new BerthConfigError('invalid_response', 'config document is not a JSON object', { status: res.status });
160
+ const etag = res.header('etag') ?? '';
161
+ if (this.doc && etag && etag === this.etag)
162
+ return false;
163
+ this.doc = doc;
164
+ this.etag = etag;
165
+ this.ver = Number.parseInt(res.header('berth-config-version') ?? '0', 10) || 0;
166
+ return true;
167
+ }
168
+ /** The single background loop. `polling` guarantees at most one per client. */
169
+ async pollLoop() {
170
+ if (this.polling)
171
+ return;
172
+ this.polling = true;
173
+ let failures = 0;
174
+ try {
175
+ while (!this.closed) {
176
+ const started = Date.now();
177
+ let delay;
178
+ try {
179
+ const res = await this.request(true);
180
+ if (res.status === 200) {
181
+ if (this.apply(res))
182
+ this.notify();
183
+ }
184
+ else if (res.status !== 304) {
185
+ throw responseError(res.status, res.body, res.header('retry-after'));
186
+ }
187
+ failures = 0;
188
+ delay = this.baseMs - (Date.now() - started); // never poll faster than baseMs
189
+ }
190
+ catch (e) {
191
+ if (this.closed)
192
+ break;
193
+ this.report(e);
194
+ failures++;
195
+ const backoff = Math.min(this.maxMs, this.baseMs * 2 ** (failures - 1)) * (0.5 + Math.random() / 2);
196
+ const ra = e instanceof BerthConfigError ? e.retryAfterMs : undefined;
197
+ delay = ra === undefined ? backoff : Math.min(ra, 300_000);
198
+ }
199
+ if (delay > 0 && !this.closed)
200
+ await this.sleep(delay);
201
+ }
202
+ }
203
+ finally {
204
+ this.polling = false;
205
+ }
206
+ }
207
+ sleep(ms) {
208
+ return new Promise((resolve) => {
209
+ const done = () => {
210
+ clearTimeout(t);
211
+ this.wake = undefined;
212
+ resolve();
213
+ };
214
+ const t = setTimeout(done, ms);
215
+ unref(t);
216
+ this.wake = done;
217
+ });
218
+ }
219
+ notify() {
220
+ for (const cb of [...this.subs]) {
221
+ try {
222
+ cb(clone(this.doc));
223
+ }
224
+ catch (e) {
225
+ this.report(e);
226
+ }
227
+ }
228
+ }
229
+ report(e) {
230
+ if (!this.opts.onError)
231
+ return;
232
+ const err = e instanceof BerthConfigError ? e : new BerthConfigError('subscriber', String(e?.message ?? e), { cause: e });
233
+ try {
234
+ this.opts.onError(err);
235
+ }
236
+ catch {
237
+ // An onError that throws must not break the loop.
238
+ }
239
+ }
240
+ }
241
+ export function createClient(opts) {
242
+ return new Client(opts);
243
+ }
244
+ let shared;
245
+ const def = () => (shared ??= createClient());
246
+ /** Client built from BERTH_CONFIG_URL / BERTH_CONFIG_TOKEN on first use. */
247
+ export const config = {
248
+ get: (key) => def().get(key),
249
+ getAll: (keys) => def().getAll(keys),
250
+ has: (key) => def().has(key),
251
+ digest: () => def().digest(),
252
+ version: () => def().version(),
253
+ subscribe: (cb) => def().subscribe(cb),
254
+ close: () => {
255
+ shared?.close();
256
+ shared = undefined;
257
+ },
258
+ };
@@ -0,0 +1,20 @@
1
+ export interface HttpResponse {
2
+ status: number;
3
+ header(name: string): string | null;
4
+ body: string;
5
+ }
6
+ export type FetchLike = (url: string, init: {
7
+ headers: Record<string, string>;
8
+ signal: AbortSignal;
9
+ }) => Promise<Response>;
10
+ /** Lets a timer not hold a Node/Bun process open. No-op elsewhere. */
11
+ export declare function unref(t: unknown): void;
12
+ /** Real Node.js (not Bun/Deno compatibility layers). */
13
+ export declare const isNode: boolean;
14
+ /** One request through fetch, with a timeout and an outer abort signal (close()). */
15
+ export declare function viaFetch(f: FetchLike, url: string, headers: Record<string, string>, timeoutMs: number, signal: AbortSignal): Promise<HttpResponse>;
16
+ /**
17
+ * Background long polls in Node go through node:http with an unref'd socket:
18
+ * an in-flight fetch() would keep the process alive for up to a minute.
19
+ */
20
+ export declare function viaNodeHttp(url: string, headers: Record<string, string>, timeoutMs: number, signal: AbortSignal): Promise<HttpResponse>;
@@ -0,0 +1,73 @@
1
+ import { BerthConfigError } from './errors.js';
2
+ /** Lets a timer not hold a Node/Bun process open. No-op elsewhere. */
3
+ export function unref(t) {
4
+ t?.unref?.();
5
+ }
6
+ const g = globalThis;
7
+ /** Real Node.js (not Bun/Deno compatibility layers). */
8
+ export const isNode = !!g.process?.versions?.node && !g.process.versions.bun && !g.Deno;
9
+ function timeoutError(ms) {
10
+ return new BerthConfigError('timeout', `config request timed out after ${ms} ms`);
11
+ }
12
+ /** One request through fetch, with a timeout and an outer abort signal (close()). */
13
+ export async function viaFetch(f, url, headers, timeoutMs, signal) {
14
+ const ctrl = new AbortController();
15
+ const abort = () => ctrl.abort();
16
+ signal.addEventListener('abort', abort, { once: true });
17
+ let timedOut = false;
18
+ const timer = setTimeout(() => ((timedOut = true), ctrl.abort()), timeoutMs);
19
+ unref(timer);
20
+ try {
21
+ const res = await f(url, { headers, signal: ctrl.signal });
22
+ const body = await res.text();
23
+ return { status: res.status, header: (n) => res.headers.get(n), body };
24
+ }
25
+ catch (e) {
26
+ if (timedOut)
27
+ throw timeoutError(timeoutMs);
28
+ if (signal.aborted)
29
+ throw new BerthConfigError('closed', 'client closed');
30
+ throw new BerthConfigError('network', `config request failed: ${e?.message ?? e}`, { cause: e });
31
+ }
32
+ finally {
33
+ clearTimeout(timer);
34
+ signal.removeEventListener('abort', abort);
35
+ }
36
+ }
37
+ // Kept in variables so bundlers for edge runtimes do not try to resolve them.
38
+ const HTTP = 'node:http';
39
+ const HTTPS = 'node:https';
40
+ /**
41
+ * Background long polls in Node go through node:http with an unref'd socket:
42
+ * an in-flight fetch() would keep the process alive for up to a minute.
43
+ */
44
+ export async function viaNodeHttp(url, headers, timeoutMs, signal) {
45
+ const u = new URL(url);
46
+ const mod = await import(/* webpackIgnore: true */ u.protocol === 'https:' ? HTTPS : HTTP);
47
+ return new Promise((resolve, reject) => {
48
+ const req = mod.request(u, { headers, signal }, (res) => {
49
+ let body = '';
50
+ res.setEncoding('utf8');
51
+ res.on('data', (c) => (body += c));
52
+ res.on('error', (e) => reject(new BerthConfigError('network', `config request failed: ${e.message}`, { cause: e })));
53
+ res.on('end', () => {
54
+ const header = (n) => {
55
+ const v = res.headers[n.toLowerCase()];
56
+ return v === undefined ? null : Array.isArray(v) ? v.join(', ') : v;
57
+ };
58
+ resolve({ status: res.statusCode ?? 0, header, body });
59
+ });
60
+ });
61
+ req.on('socket', (s) => s.unref());
62
+ req.setTimeout(timeoutMs, () => req.destroy(timeoutError(timeoutMs)));
63
+ req.on('error', (e) => {
64
+ if (e instanceof BerthConfigError)
65
+ reject(e);
66
+ else if (signal.aborted)
67
+ reject(new BerthConfigError('closed', 'client closed'));
68
+ else
69
+ reject(new BerthConfigError('network', `config request failed: ${e.message}`, { cause: e }));
70
+ });
71
+ req.end();
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@evumi/berth-config",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Client for Berth project Config: cached reads with background long-poll refresh.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/evumi/berth.git",
9
+ "directory": "sdk/js"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": false,
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "main": "./dist/cjs/index.js",
17
+ "module": "./dist/esm/index.js",
18
+ "types": "./dist/esm/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": {
22
+ "types": "./dist/esm/index.d.ts",
23
+ "default": "./dist/esm/index.js"
24
+ },
25
+ "require": {
26
+ "types": "./dist/cjs/index.d.ts",
27
+ "default": "./dist/cjs/index.js"
28
+ }
29
+ }
30
+ },
31
+ "files": [
32
+ "dist",
33
+ "README.md"
34
+ ],
35
+ "scripts": {
36
+ "build": "rm -rf dist && tsc -p tsconfig.esm.json && tsc -p tsconfig.cjs.json && node scripts/cjs-package.mjs",
37
+ "typecheck": "tsc -p tsconfig.json --noEmit",
38
+ "test": "pnpm run build && vitest run"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^26.6.5",
42
+ "typescript": "6.0.3",
43
+ "vitest": "^5.0.5"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }