@fenglimg/fabric-shared 2.1.0-rc.2 → 2.2.0-rc.3

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.
@@ -6,6 +6,31 @@ interface AtomicWriteJsonOptions extends AtomicWriteOptions {
6
6
  }
7
7
  declare function atomicWriteText(path: string, content: string, opts?: AtomicWriteOptions): Promise<void>;
8
8
  declare function atomicWriteJson(path: string, value: unknown, opts?: AtomicWriteJsonOptions): Promise<void>;
9
+ interface FileLockOptions {
10
+ /** A held lock older than this (ms, by lock-file mtime) is presumed stale —
11
+ * left by a crashed holder — and reclaimed. Default 10s. */
12
+ staleMs?: number;
13
+ /** Poll interval (ms) between acquire attempts while contended. Default 20ms. */
14
+ retryDelayMs?: number;
15
+ /** Give up acquiring after this long (ms) and throw. Default 10s. */
16
+ maxWaitMs?: number;
17
+ }
18
+ /**
19
+ * Run `fn` while holding a cross-process advisory lock at `lockPath`.
20
+ *
21
+ * Unlike the hook-side `appendLockedLine` (which DROPS on contention, fine for
22
+ * best-effort telemetry), this WAITS for the lock — the critical section it
23
+ * guards (e.g. a read-modify-write of a shared counter file) must not be
24
+ * skipped. The lock is a `wx` (O_CREAT|O_EXCL) lock file, so acquisition is
25
+ * atomic across processes; a crashed holder leaves the file behind, so any
26
+ * holder older than `staleMs` is reclaimed. The lock is always released in a
27
+ * `finally`, even if `fn` throws.
28
+ *
29
+ * Scope: cross-process AND in-process. Two concurrent callers on the same
30
+ * `lockPath` (same process or not) serialize, because both race the same
31
+ * O_EXCL create.
32
+ */
33
+ declare function withFileLock<T>(lockPath: string, fn: () => Promise<T>, opts?: FileLockOptions): Promise<T>;
9
34
  interface LedgerWriteQueue {
10
35
  append(path: string, line: string): Promise<void>;
11
36
  /**
@@ -27,4 +52,4 @@ interface LedgerWriteQueue {
27
52
  }
28
53
  declare function createLedgerWriteQueue(): LedgerWriteQueue;
29
54
 
30
- export { type AtomicWriteJsonOptions, type AtomicWriteOptions, type LedgerWriteQueue, atomicWriteJson, atomicWriteText, createLedgerWriteQueue };
55
+ export { type AtomicWriteJsonOptions, type AtomicWriteOptions, type FileLockOptions, type LedgerWriteQueue, atomicWriteJson, atomicWriteText, createLedgerWriteQueue, withFileLock };
@@ -1,5 +1,7 @@
1
1
  // src/node/atomic-write.ts
2
- import { appendFile, open, rename, unlink, writeFile } from "fs/promises";
2
+ import { appendFile, mkdir, open, readFile, rename, stat, unlink, writeFile } from "fs/promises";
3
+ import { dirname } from "path";
4
+ import { randomUUID } from "crypto";
3
5
  function makeTmpSuffix() {
4
6
  const rand = Math.floor(Math.random() * 65535).toString(16).padStart(4, "0");
5
7
  return `.${process.pid}.${Date.now()}.${rand}.tmp`;
@@ -32,6 +34,61 @@ async function atomicWriteJson(path, value, opts) {
32
34
  const content = JSON.stringify(value, null, indent) + "\n";
33
35
  await atomicWriteText(path, content, { fsync: opts?.fsync });
34
36
  }
37
+ function isErrnoException(err) {
38
+ return err instanceof Error && typeof err.code === "string";
39
+ }
40
+ function sleep(ms) {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+ async function withFileLock(lockPath, fn, opts = {}) {
44
+ const staleMs = opts.staleMs ?? 1e4;
45
+ const retryDelayMs = opts.retryDelayMs ?? 20;
46
+ const maxWaitMs = opts.maxWaitMs ?? 1e4;
47
+ await mkdir(dirname(lockPath), { recursive: true });
48
+ const token = `${process.pid}.${randomUUID()}`;
49
+ const start = Date.now();
50
+ for (; ; ) {
51
+ let handle;
52
+ try {
53
+ handle = await open(lockPath, "wx");
54
+ } catch (err) {
55
+ if (!isErrnoException(err) || err.code !== "EEXIST") throw err;
56
+ try {
57
+ const st = await stat(lockPath);
58
+ if (Date.now() - st.mtimeMs > staleMs) {
59
+ const staleToken = await readFile(lockPath, "utf8").catch(() => null);
60
+ if (staleToken !== null) {
61
+ await unlinkIfToken(lockPath, staleToken);
62
+ }
63
+ continue;
64
+ }
65
+ } catch {
66
+ continue;
67
+ }
68
+ if (Date.now() - start > maxWaitMs) {
69
+ throw new Error(`withFileLock: timed out acquiring ${lockPath} after ${maxWaitMs}ms`);
70
+ }
71
+ await sleep(retryDelayMs);
72
+ continue;
73
+ }
74
+ try {
75
+ await handle.writeFile(token, "utf8");
76
+ await handle.close();
77
+ return await fn();
78
+ } finally {
79
+ await unlinkIfToken(lockPath, token);
80
+ }
81
+ }
82
+ }
83
+ async function unlinkIfToken(lockPath, expected) {
84
+ try {
85
+ const current = await readFile(lockPath, "utf8");
86
+ if (current === expected) {
87
+ await unlink(lockPath).catch(() => void 0);
88
+ }
89
+ } catch {
90
+ }
91
+ }
35
92
  function createLedgerWriteQueue() {
36
93
  const chains = /* @__PURE__ */ new Map();
37
94
  async function doAppend(path, line) {
@@ -65,5 +122,6 @@ function createLedgerWriteQueue() {
65
122
  export {
66
123
  atomicWriteJson,
67
124
  atomicWriteText,
68
- createLedgerWriteQueue
125
+ createLedgerWriteQueue,
126
+ withFileLock
69
127
  };
@@ -14,5 +14,36 @@ interface PayloadGuardResult {
14
14
  declare const PAYLOAD_LIMIT_DEFAULT_WARN_BYTES = 16384;
15
15
  declare const PAYLOAD_LIMIT_DEFAULT_HARD_BYTES = 65536;
16
16
  declare function enforcePayloadLimit(serializedPayload: string, opts?: PayloadGuardOptions): PayloadGuardResult;
17
+ interface PayloadBudgetTrimResult<T> {
18
+ /** The retained head of `items` (the ranked tail was dropped to fit). */
19
+ items: T[];
20
+ /** How many trailing items were dropped to fit the hard budget. */
21
+ dropped: number;
22
+ /** Serialized byte size of the envelope built from the retained items. */
23
+ bytes: number;
24
+ /**
25
+ * True when even `minKeep` items still overflow the hard budget — the caller
26
+ * must surface this (a single oversized entry) rather than assume it fit.
27
+ */
28
+ overBudget: boolean;
29
+ }
30
+ /**
31
+ * v2.2 MC4-payload-budget (W1-T4): the byte-budget tail of the unified
32
+ * truncation chain (CJK → BM25 → top_k → payload). Rather than hard-throwing
33
+ * when a response overflows the hard limit, callers trim the LEAST-relevant
34
+ * items off the tail of an already-ranked list until the serialized envelope
35
+ * fits — turning a 413 crash into graceful degradation.
36
+ *
37
+ * `serialize` builds the FULL response envelope from a candidate slice (so the
38
+ * byte count includes warnings / metadata, not just the list). Trimming drops
39
+ * from the END, which is correct ONLY when the list is pre-ranked best-first
40
+ * (plan_context sorts by BM25 before calling this). `minKeep` (default 1)
41
+ * guarantees a non-empty result even under a pathological oversized head; in
42
+ * that case `overBudget` is returned true so the caller can warn instead of
43
+ * silently shipping an over-limit payload.
44
+ */
45
+ declare function trimToPayloadBudget<T>(items: T[], serialize: (items: T[]) => string, opts?: PayloadGuardOptions & {
46
+ minKeep?: number;
47
+ }): PayloadBudgetTrimResult<T>;
17
48
 
18
- export { PAYLOAD_LIMIT_DEFAULT_HARD_BYTES, PAYLOAD_LIMIT_DEFAULT_WARN_BYTES, type PayloadGuardOptions, type PayloadGuardResult, enforcePayloadLimit };
49
+ export { PAYLOAD_LIMIT_DEFAULT_HARD_BYTES, PAYLOAD_LIMIT_DEFAULT_WARN_BYTES, type PayloadBudgetTrimResult, type PayloadGuardOptions, type PayloadGuardResult, enforcePayloadLimit, trimToPayloadBudget };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  MCPError
3
- } from "../chunk-3SZRB42B.js";
3
+ } from "../chunk-VW5QGPIN.js";
4
4
 
5
5
  // src/node/mcp-payload-guard.ts
6
6
  var McpPayloadTooLargeError = class extends MCPError {
@@ -37,8 +37,22 @@ function enforcePayloadLimit(serializedPayload, opts) {
37
37
  }
38
38
  return { bytes };
39
39
  }
40
+ function trimToPayloadBudget(items, serialize, opts) {
41
+ const hardAt = opts?.hardBytes ?? DEFAULT_HARD;
42
+ const minKeep = Math.max(0, opts?.minKeep ?? 1);
43
+ let kept = items;
44
+ let bytes = Buffer.byteLength(serialize(kept), "utf8");
45
+ let dropped = 0;
46
+ while (bytes > hardAt && kept.length > minKeep) {
47
+ kept = kept.slice(0, -1);
48
+ dropped += 1;
49
+ bytes = Buffer.byteLength(serialize(kept), "utf8");
50
+ }
51
+ return { items: kept, dropped, bytes, overBudget: bytes > hardAt };
52
+ }
40
53
  export {
41
54
  PAYLOAD_LIMIT_DEFAULT_HARD_BYTES,
42
55
  PAYLOAD_LIMIT_DEFAULT_WARN_BYTES,
43
- enforcePayloadLimit
56
+ enforcePayloadLimit,
57
+ trimToPayloadBudget
44
58
  };