@belvador/spool 0.0.0-stage → 0.6.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,27 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Belvador
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.
22
+
23
+ ---
24
+
25
+ This software depends on the Sentry JavaScript SDK (https://github.com/getsentry/sentry-javascript),
26
+ which is distributed under its own MIT License (Copyright (c) Functional Software, Inc. dba Sentry)
27
+ in each of its npm packages. Belvador is not affiliated with or endorsed by Sentry.
package/README.md CHANGED
@@ -1,3 +1,42 @@
1
- # Temporary Holding Version
1
+ # @belvador/spool
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
+ Keeps errors on disk while Belvador cannot be reached — for Node.js, Bun and Deno servers that
4
+ use the open-source Sentry SDK with a Belvador DSN. When the network is back they are sent again,
5
+ oldest first, also by the next process if this one was restarted or killed.
6
+
7
+ ```ts
8
+ // Node.js
9
+ import * as Sentry from "@sentry/node";
10
+ import { withSpool } from "@belvador/spool";
11
+
12
+ Sentry.init({
13
+ dsn: process.env.BELVADOR_DSN,
14
+ transport: withSpool(Sentry.makeNodeTransport, { dir: "/var/lib/my-app/belvador" }),
15
+ });
16
+ ```
17
+
18
+ - **Bun:** `withSpool(Sentry.makeFetchTransport, { dir })` with `@sentry/bun`.
19
+ - **Deno:** `@sentry/deno` keeps its transport private, so use the one in this package:
20
+ `withSpool(makeFetchTransport, { dir })` with `npm:@sentry/deno` and `npm:@belvador/spool`.
21
+ Permissions: `--allow-net=<DSN host> --allow-env --allow-read=<dir> --allow-write=<dir>`.
22
+ - **Already using `makeOfflineTransport`?** Pass `createStore: makeFileStore({ dir })`.
23
+ `withSpool` is still better: it also keeps a burst larger than the engine's 64-send memory
24
+ buffer, and does not count an error it stored as lost.
25
+
26
+ | Option | Default | |
27
+ |---|---|---|
28
+ | `dir` | — | one directory per app; created with mode `0700`, files `0600`. Several processes of the same app may share it |
29
+ | `maxBytes` | 64 MiB | the oldest envelopes are dropped first and counted as `queue_overflow` |
30
+ | `ttlHours` | 72 | older envelopes are dropped instead of sent |
31
+ | `key` | — | 32 random bytes of your own: envelopes are encrypted with AES-256-GCM |
32
+
33
+ Tested: 200 errors while the intake is down, the process killed with `SIGKILL`, a new process
34
+ started once the intake is back — all 200 arrive, none twice (Node.js, Bun, Deno). A kill in the
35
+ middle of a write leaves at most one half-written file, which the next start drops and counts.
36
+
37
+ What it does not promise: delivery of every event. A full disk, a damaged file or an envelope
38
+ older than `ttlHours` is dropped and counted in the SDK's client reports; your app never waits
39
+ for the disk and never sees an error from it. Cloudflare Workers and other short-lived functions
40
+ have no lasting disk — this package does not help there.
41
+
42
+ License: MIT · https://belvador.dev/docs/sdk/offline
@@ -0,0 +1,21 @@
1
+ import type { BaseTransportOptions, Transport } from "@sentry/core";
2
+ import { type FileStoreOptions } from "./store.js";
3
+ export { SPOOL_DEFAULTS, createFileStore, makeFileStore, type FileStoreOptions, type OfflineStore, type StoreTransportOptions, } from "./store.js";
4
+ /**
5
+ * Wraps a Sentry SDK transport so failed sends wait on disk:
6
+ *
7
+ * import * as Sentry from "@sentry/node";
8
+ * import { withSpool } from "@belvador/spool";
9
+ * Sentry.init({ dsn: process.env.BELVADOR_DSN, transport: withSpool(Sentry.makeNodeTransport, { dir: "/var/lib/my-app/belvador" }) });
10
+ *
11
+ * Same as `makeOfflineTransport(base)` with `createStore: makeFileStore(options)` and
12
+ * `flushAtStartup: true`, plus: a burst larger than the engine's memory buffer goes to disk
13
+ * instead of being dropped, and a request the spool kept is not counted as lost.
14
+ */
15
+ export declare function withSpool<O extends object>(base: (options: O) => Transport, options: FileStoreOptions): (transportOptions: O) => Transport;
16
+ /**
17
+ * A plain `fetch` transport for runtimes whose Sentry SDK does not export one (`@sentry/deno`
18
+ * keeps its own private): `withSpool(makeFetchTransport, { dir })`. Node.js and Bun can use
19
+ * their SDK's `makeNodeTransport` / `makeFetchTransport` instead.
20
+ */
21
+ export declare function makeFetchTransport(options: BaseTransportOptions): Transport;
package/dist/index.js ADDED
@@ -0,0 +1,91 @@
1
+ // @belvador/spool — keeps errors on disk while Belvador cannot be reached, for Node.js, Bun and
2
+ // Deno servers using the open-source Sentry SDK with a Belvador DSN. Sent again when the network
3
+ // is back, within a size cap and a time limit — never a promise that every event arrives.
4
+ // Importing this module has no side effects.
5
+ import { createTransport, makeOfflineTransport } from "@sentry/core";
6
+ import { makeFileStore } from "./store.js";
7
+ export { SPOOL_DEFAULTS, createFileStore, makeFileStore, } from "./store.js";
8
+ /** The engine's default in-memory send buffer (`DEFAULT_TRANSPORT_BUFFER_SIZE` of `@sentry/core`). */
9
+ const ENGINE_BUFFER = 64;
10
+ /**
11
+ * The transport under the spool. Two engine behaviours would lose or miscount errors here:
12
+ * - a full in-memory buffer answers `{}` (counted as `queue_overflow`), so a burst while the
13
+ * network is down would keep only the first 64 — instead, sends past the buffer are refused,
14
+ * and the spool keeps them on disk;
15
+ * - every failed request is counted as `network_error`, though the spool kept it — the spool
16
+ * counts its own losses (disk failure, cap, time limit), so the base does not count that one.
17
+ */
18
+ function underSpool(base) {
19
+ return (options) => {
20
+ const o = options;
21
+ const record = o.recordDroppedEvent;
22
+ const limit = Math.max(1, Math.floor(Number(o.bufferSize) || ENGINE_BUFFER));
23
+ const transport = base({
24
+ ...o,
25
+ recordDroppedEvent: (reason, category, count) => {
26
+ if (reason !== "network_error")
27
+ record?.(reason, category, count);
28
+ },
29
+ });
30
+ let inFlight = 0;
31
+ return {
32
+ send(envelope) {
33
+ if (inFlight >= limit)
34
+ return Promise.reject(new Error("[belvador] spool: send buffer full, kept on disk"));
35
+ inFlight++;
36
+ return Promise.resolve(transport.send(envelope)).then((r) => {
37
+ inFlight--;
38
+ return r;
39
+ }, (e) => {
40
+ inFlight--;
41
+ throw e;
42
+ });
43
+ },
44
+ flush: (timeout) => transport.flush(timeout),
45
+ };
46
+ };
47
+ }
48
+ /**
49
+ * Wraps a Sentry SDK transport so failed sends wait on disk:
50
+ *
51
+ * import * as Sentry from "@sentry/node";
52
+ * import { withSpool } from "@belvador/spool";
53
+ * Sentry.init({ dsn: process.env.BELVADOR_DSN, transport: withSpool(Sentry.makeNodeTransport, { dir: "/var/lib/my-app/belvador" }) });
54
+ *
55
+ * Same as `makeOfflineTransport(base)` with `createStore: makeFileStore(options)` and
56
+ * `flushAtStartup: true`, plus: a burst larger than the engine's memory buffer goes to disk
57
+ * instead of being dropped, and a request the spool kept is not counted as lost.
58
+ */
59
+ export function withSpool(base, options) {
60
+ const offline = makeOfflineTransport(underSpool(base));
61
+ const createStore = makeFileStore(options);
62
+ return (transportOptions) => offline({
63
+ ...transportOptions,
64
+ createStore: createStore,
65
+ flushAtStartup: true,
66
+ });
67
+ }
68
+ /**
69
+ * A plain `fetch` transport for runtimes whose Sentry SDK does not export one (`@sentry/deno`
70
+ * keeps its own private): `withSpool(makeFetchTransport, { dir })`. Node.js and Bun can use
71
+ * their SDK's `makeNodeTransport` / `makeFetchTransport` instead.
72
+ */
73
+ export function makeFetchTransport(options) {
74
+ return createTransport(options, async (request) => {
75
+ const res = await fetch(options.url, {
76
+ method: "POST",
77
+ body: request.body,
78
+ referrerPolicy: "strict-origin",
79
+ ...(options.headers ? { headers: options.headers } : {}),
80
+ });
81
+ // Read the answer so the connection is released (Deno warns about unread bodies).
82
+ await res.arrayBuffer().catch(() => undefined);
83
+ return {
84
+ statusCode: res.status,
85
+ headers: {
86
+ "x-sentry-rate-limits": res.headers.get("X-Sentry-Rate-Limits"),
87
+ "retry-after": res.headers.get("Retry-After"),
88
+ },
89
+ };
90
+ });
91
+ }
@@ -0,0 +1,37 @@
1
+ import type { DataCategory, Envelope, EventDropReason } from "@sentry/core";
2
+ export declare const SPOOL_DEFAULTS: {
3
+ readonly maxBytes: number;
4
+ readonly ttlHours: 72;
5
+ /** A claim or temp file older than this is abandoned, whatever its process (pid reuse after reboot). */
6
+ readonly abandonMs: number;
7
+ /** The size estimate is re-read from disk at least this often (other processes write too). */
8
+ readonly rescanMs: 30000;
9
+ };
10
+ export type FileStoreOptions = {
11
+ /** Directory for this app's envelopes; created with mode 0700. Use one per app, not per process. */
12
+ dir: string;
13
+ /** Total size on disk; the oldest envelopes go first. Default 64 MiB. */
14
+ maxBytes?: number;
15
+ /** Envelopes older than this are dropped instead of sent. Default 72 h. */
16
+ ttlHours?: number;
17
+ /** 32 random bytes of your own: envelopes are encrypted with AES-256-GCM. A wrong key length disables the store. */
18
+ key?: Uint8Array;
19
+ };
20
+ /** What the engine hands a store factory (`OfflineTransportOptions`), the part we use. */
21
+ export type StoreTransportOptions = {
22
+ recordDroppedEvent?: (reason: EventDropReason, category: DataCategory, count?: number) => void;
23
+ };
24
+ /** The engine's `OfflineStore` (`@sentry/core` `makeOfflineTransport`). */
25
+ export type OfflineStore = {
26
+ push(env: Envelope): Promise<void>;
27
+ unshift(env: Envelope): Promise<void>;
28
+ shift(): Promise<Envelope | undefined>;
29
+ };
30
+ /**
31
+ * `createStore` for `makeOfflineTransport` of `@sentry/node`, `@sentry/bun` or `@sentry/deno`:
32
+ *
33
+ * transport: makeOfflineTransport(makeNodeTransport),
34
+ * transportOptions: { createStore: makeFileStore({ dir: "/var/lib/my-app/belvador" }), flushAtStartup: true }
35
+ */
36
+ export declare function makeFileStore(options: FileStoreOptions): (transport?: StoreTransportOptions) => OfflineStore;
37
+ export declare function createFileStore(options: FileStoreOptions, transport: StoreTransportOptions): OfflineStore;
package/dist/store.js ADDED
@@ -0,0 +1,352 @@
1
+ // A file store for the engine's offline transport (build 3.0 spec 12 §3): envelopes that could
2
+ // not be sent are kept on disk and sent again later, within a size cap and a time limit. One
3
+ // directory may be shared by several processes of the same app: writes are atomic (temp file +
4
+ // rename), an envelope is claimed by renaming it before it is sent, and claims of a dead process
5
+ // return to the queue. Never throws and never blocks the app: every failure is counted as a drop.
6
+ import { constants } from "node:fs";
7
+ import { chmod, mkdir, open, readdir, readFile, rename, rm, stat } from "node:fs/promises";
8
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
9
+ import { join } from "node:path";
10
+ import * as process from "node:process";
11
+ import { envelopeItemTypeToDataCategory, forEachEnvelopeItem, parseEnvelope, serializeEnvelope } from "@sentry/core";
12
+ export const SPOOL_DEFAULTS = {
13
+ maxBytes: 64 * 1024 * 1024,
14
+ ttlHours: 72,
15
+ /** A claim or temp file older than this is abandoned, whatever its process (pid reuse after reboot). */
16
+ abandonMs: 10 * 60000,
17
+ /** The size estimate is re-read from disk at least this often (other processes write too). */
18
+ rescanMs: 30000,
19
+ };
20
+ const QUEUED = /^q-(\d{16})-[0-9a-f]{8}\.bls$/;
21
+ const CLAIMED = /^c-(\d+)-(\d{16})-([0-9a-f]{8})\.bls$/;
22
+ const TEMP = /^t-(\d+)-[0-9a-f]{8}\.tmp$/;
23
+ const HEADER_MAX = 4096;
24
+ const IV = 12;
25
+ const TAG = 16;
26
+ const enc = new TextEncoder();
27
+ const dec = new TextDecoder();
28
+ const rand8 = () => randomBytes(4).toString("hex");
29
+ const pad16 = (n) => String(Math.max(0, Math.floor(n))).padStart(16, "0");
30
+ function alive(pid) {
31
+ if (pid === process.pid)
32
+ return true;
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ }
37
+ catch (e) {
38
+ // EPERM: it exists under another user. Anything else (ESRCH, unsupported): treat as gone
39
+ // only on ESRCH; otherwise rely on the age limit.
40
+ return e.code !== "ESRCH";
41
+ }
42
+ }
43
+ function categoriesOf(env) {
44
+ const out = [];
45
+ forEachEnvelopeItem(env, (_item, type) => {
46
+ if (type !== "client_report")
47
+ out.push(envelopeItemTypeToDataCategory(type));
48
+ });
49
+ return out;
50
+ }
51
+ /**
52
+ * `createStore` for `makeOfflineTransport` of `@sentry/node`, `@sentry/bun` or `@sentry/deno`:
53
+ *
54
+ * transport: makeOfflineTransport(makeNodeTransport),
55
+ * transportOptions: { createStore: makeFileStore({ dir: "/var/lib/my-app/belvador" }), flushAtStartup: true }
56
+ */
57
+ export function makeFileStore(options) {
58
+ return (transport) => createFileStore(options, transport ?? {});
59
+ }
60
+ export function createFileStore(options, transport) {
61
+ const dir = String(options?.dir ?? "");
62
+ const maxBytes = Number.isFinite(options?.maxBytes) && options.maxBytes > 0 ? Math.floor(options.maxBytes) : SPOOL_DEFAULTS.maxBytes;
63
+ const ttlMs = (Number.isFinite(options?.ttlHours) && options.ttlHours > 0 ? options.ttlHours : SPOOL_DEFAULTS.ttlHours) *
64
+ 3600000;
65
+ const key = options?.key;
66
+ const keyOk = key === undefined || (key instanceof Uint8Array && key.length === 32);
67
+ const record = (reason, cats) => {
68
+ try {
69
+ const counts = new Map();
70
+ for (const c of cats.length ? cats : ["error"])
71
+ counts.set(c, (counts.get(c) ?? 0) + 1);
72
+ for (const [c, n] of counts)
73
+ transport.recordDroppedEvent?.(reason, c, n);
74
+ }
75
+ catch {
76
+ /* the count is best effort */
77
+ }
78
+ };
79
+ let total = 0;
80
+ let scannedAt = 0;
81
+ let lastKey = 0;
82
+ let claim = null;
83
+ const created = new WeakMap();
84
+ const nextKey = () => {
85
+ lastKey = Math.max(lastKey + 1, Date.now() * 1000);
86
+ return lastKey;
87
+ };
88
+ function encode(env, createdAt) {
89
+ const raw = serializeEnvelope(env);
90
+ const body = typeof raw === "string" ? enc.encode(raw) : raw;
91
+ const header = { v: 1, c: createdAt, k: categoriesOf(env), e: key ? 1 : 0 };
92
+ const head = enc.encode(`${JSON.stringify(header)}\n`);
93
+ let payload = body;
94
+ if (key) {
95
+ const iv = randomBytes(IV);
96
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
97
+ cipher.setAAD(head);
98
+ const ct = Buffer.concat([cipher.update(body), cipher.final()]);
99
+ payload = Buffer.concat([iv, ct, cipher.getAuthTag()]);
100
+ }
101
+ const out = new Uint8Array(head.length + payload.length);
102
+ out.set(head, 0);
103
+ out.set(payload, head.length);
104
+ return out;
105
+ }
106
+ function readHeader(bytes) {
107
+ const nl = bytes.subarray(0, HEADER_MAX).indexOf(10);
108
+ if (nl < 0)
109
+ return null;
110
+ try {
111
+ const h = JSON.parse(dec.decode(bytes.subarray(0, nl)));
112
+ if (h?.v !== 1 || typeof h.c !== "number" || !Array.isArray(h.k))
113
+ return null;
114
+ return { header: h, at: nl + 1 };
115
+ }
116
+ catch {
117
+ return null;
118
+ }
119
+ }
120
+ function decode(bytes) {
121
+ const parsed = readHeader(bytes);
122
+ if (!parsed)
123
+ return { header: null };
124
+ const { header, at } = parsed;
125
+ try {
126
+ let body = bytes.subarray(at);
127
+ if (header.e === 1) {
128
+ if (!key || body.length < IV + TAG)
129
+ return { header };
130
+ const decipher = createDecipheriv("aes-256-gcm", key, body.subarray(0, IV));
131
+ decipher.setAAD(bytes.subarray(0, at));
132
+ decipher.setAuthTag(body.subarray(body.length - TAG));
133
+ body = Buffer.concat([decipher.update(body.subarray(IV, body.length - TAG)), decipher.final()]);
134
+ }
135
+ const env = parseEnvelope(body);
136
+ if (!Array.isArray(env) || env.length !== 2)
137
+ return { header };
138
+ return { env, header };
139
+ }
140
+ catch {
141
+ return { header };
142
+ }
143
+ }
144
+ async function headerCats(path) {
145
+ try {
146
+ const fh = await open(path, "r");
147
+ try {
148
+ const buf = new Uint8Array(HEADER_MAX);
149
+ const { bytesRead } = await fh.read(buf, 0, HEADER_MAX, 0);
150
+ return readHeader(buf.subarray(0, bytesRead))?.header.k ?? [];
151
+ }
152
+ finally {
153
+ await fh.close();
154
+ }
155
+ }
156
+ catch {
157
+ return [];
158
+ }
159
+ }
160
+ async function sizeOf(path) {
161
+ try {
162
+ return (await stat(path)).size;
163
+ }
164
+ catch {
165
+ return 0;
166
+ }
167
+ }
168
+ /** Queued names, oldest first, and the bytes of everything queued or claimed. */
169
+ async function scan() {
170
+ const names = await readdir(dir);
171
+ const queued = [];
172
+ let bytes = 0;
173
+ for (const n of names) {
174
+ if (QUEUED.test(n)) {
175
+ queued.push(n);
176
+ bytes += await sizeOf(join(dir, n));
177
+ }
178
+ else if (CLAIMED.test(n))
179
+ bytes += await sizeOf(join(dir, n));
180
+ }
181
+ queued.sort();
182
+ total = bytes;
183
+ scannedAt = Date.now();
184
+ return { queued, bytes };
185
+ }
186
+ async function prepare() {
187
+ if (!dir || !keyOk) {
188
+ if (!keyOk)
189
+ console.warn("[belvador] spool disabled: `key` must be 32 bytes");
190
+ return false;
191
+ }
192
+ await mkdir(dir, { recursive: true, mode: 0o700 });
193
+ await chmod(dir, 0o700).catch(() => { });
194
+ const now = Date.now();
195
+ for (const n of await readdir(dir)) {
196
+ const path = join(dir, n);
197
+ const t = TEMP.exec(n);
198
+ const c = CLAIMED.exec(n);
199
+ if (!t && !c)
200
+ continue;
201
+ const pid = Number((t ?? c)[1]);
202
+ let old = false;
203
+ try {
204
+ old = now - (await stat(path)).mtimeMs > SPOOL_DEFAULTS.abandonMs;
205
+ }
206
+ catch {
207
+ continue;
208
+ }
209
+ if (pid === process.pid || (!old && alive(pid)))
210
+ continue;
211
+ if (t) {
212
+ // Written halfway when its process died: never readable, counted once.
213
+ await rm(path, { force: true });
214
+ record("internal_sdk_error", ["error"]);
215
+ }
216
+ else {
217
+ // Claimed by a process that died mid-send: back to the queue (the node drops duplicates).
218
+ await rename(path, join(dir, `q-${c[2]}-${c[3]}.bls`)).catch(() => { });
219
+ }
220
+ }
221
+ await scan();
222
+ return true;
223
+ }
224
+ const ready = prepare().catch(() => false);
225
+ async function write(env, orderKey, createdAt) {
226
+ const bytes = encode(env, createdAt);
227
+ if (bytes.length > maxBytes) {
228
+ record("queue_overflow", categoriesOf(env));
229
+ return;
230
+ }
231
+ if (total + bytes.length > maxBytes || Date.now() - scannedAt > SPOOL_DEFAULTS.rescanMs) {
232
+ const { queued } = await scan();
233
+ for (const n of queued) {
234
+ if (total + bytes.length <= maxBytes)
235
+ break;
236
+ const path = join(dir, n);
237
+ const size = await sizeOf(path);
238
+ const cats = await headerCats(path);
239
+ try {
240
+ await rm(path);
241
+ total -= size;
242
+ record("queue_overflow", cats);
243
+ }
244
+ catch {
245
+ /* taken by another process meanwhile */
246
+ }
247
+ }
248
+ if (total + bytes.length > maxBytes) {
249
+ record("queue_overflow", categoriesOf(env));
250
+ return;
251
+ }
252
+ }
253
+ const tmp = join(dir, `t-${process.pid}-${rand8()}.tmp`);
254
+ const fh = await open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
255
+ try {
256
+ await fh.writeFile(bytes);
257
+ await fh.sync();
258
+ }
259
+ finally {
260
+ await fh.close();
261
+ }
262
+ await rename(tmp, join(dir, `q-${pad16(orderKey)}-${rand8()}.bls`));
263
+ total += bytes.length;
264
+ }
265
+ /** The previous claim was not handed back (unshift): it was sent, so it goes. */
266
+ async function settleClaim() {
267
+ if (!claim)
268
+ return;
269
+ const c = claim;
270
+ claim = null;
271
+ await rm(c.path, { force: true }).catch(() => { });
272
+ total = Math.max(0, total - c.size);
273
+ }
274
+ return {
275
+ async push(env) {
276
+ try {
277
+ if (!(await ready))
278
+ return record("internal_sdk_error", categoriesOf(env));
279
+ await write(env, nextKey(), Date.now());
280
+ }
281
+ catch {
282
+ record("internal_sdk_error", categoriesOf(env));
283
+ }
284
+ },
285
+ async unshift(env) {
286
+ try {
287
+ if (!(await ready))
288
+ return record("internal_sdk_error", categoriesOf(env));
289
+ if (claim && claim.env === env) {
290
+ // Same envelope back after a failed retry: keep its file and its place at the front.
291
+ const c = claim;
292
+ claim = null;
293
+ await rename(c.path, join(dir, c.name));
294
+ return;
295
+ }
296
+ const { queued } = await scan();
297
+ const first = queued[0] ? Number(QUEUED.exec(queued[0])[1]) : Date.now() * 1000;
298
+ await write(env, first - 1, created.get(env) ?? Date.now());
299
+ }
300
+ catch {
301
+ record("internal_sdk_error", categoriesOf(env));
302
+ }
303
+ },
304
+ async shift() {
305
+ try {
306
+ if (!(await ready))
307
+ return undefined;
308
+ await settleClaim();
309
+ const { queued } = await scan();
310
+ for (const n of queued) {
311
+ const m = QUEUED.exec(n);
312
+ const from = join(dir, n);
313
+ const name = `c-${process.pid}-${m[1]}-${n.slice(19, 27)}.bls`;
314
+ const path = join(dir, name);
315
+ try {
316
+ await rename(from, path);
317
+ }
318
+ catch {
319
+ continue; // another process claimed it first
320
+ }
321
+ let bytes;
322
+ try {
323
+ bytes = await readFile(path);
324
+ }
325
+ catch {
326
+ continue;
327
+ }
328
+ const out = decode(bytes);
329
+ if (!("env" in out)) {
330
+ await rm(path, { force: true }).catch(() => { });
331
+ total = Math.max(0, total - bytes.length);
332
+ record("internal_sdk_error", out.header?.k ?? []);
333
+ continue;
334
+ }
335
+ if (Date.now() - out.header.c > ttlMs) {
336
+ await rm(path, { force: true }).catch(() => { });
337
+ total = Math.max(0, total - bytes.length);
338
+ record("network_error", out.header.k);
339
+ continue;
340
+ }
341
+ created.set(out.env, out.header.c);
342
+ claim = { path, name: n, size: bytes.length, env: out.env };
343
+ return out.env;
344
+ }
345
+ return undefined;
346
+ }
347
+ catch {
348
+ return undefined;
349
+ }
350
+ },
351
+ };
352
+ }
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@belvador/spool",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.6.0",
4
+ "description": "Keeps errors on disk while Belvador cannot be reached, for Node.js, Bun and Deno servers using the open-source Sentry SDK: sent again when the network is back, within a size cap and a time limit.",
5
+ "license": "MIT",
6
+ "author": "Belvador (https://belvador.dev)",
7
+ "homepage": "https://belvador.dev/docs/sdk/offline",
8
+ "keywords": [
9
+ "belvador",
10
+ "error-tracking",
11
+ "errors",
12
+ "monitoring",
13
+ "sentry-compatible",
14
+ "offline",
15
+ "spool",
16
+ "nodejs",
17
+ "bun",
18
+ "deno"
19
+ ],
20
+ "type": "module",
21
+ "sideEffects": false,
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "dependencies": {
41
+ "@sentry/core": "10.76.2"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "tag": "latest"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc -p tsconfig.json"
49
+ }
50
+ }