@mandujs/core 0.23.0 → 0.25.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -13,11 +13,13 @@
13
13
  "./auth/reset": "./src/auth/reset.ts",
14
14
  "./auth/verification": "./src/auth/verification.ts",
15
15
  "./client": "./src/client/index.ts",
16
+ "./content": "./src/content/index.ts",
16
17
  "./db": "./src/db/index.ts",
17
18
  "./desktop": "./src/desktop/index.ts",
18
19
  "./desktop/worker": "./src/desktop/worker.ts",
19
20
  "./email": "./src/email/index.ts",
20
21
  "./filling/session-sqlite": "./src/filling/session-sqlite.ts",
22
+ "./kitchen": "./src/kitchen/index.ts",
21
23
  "./middleware": "./src/middleware/index.ts",
22
24
  "./middleware/oauth": "./src/middleware/oauth/index.ts",
23
25
  "./middleware/secure": "./src/middleware/secure/index.ts",
@@ -0,0 +1,519 @@
1
+ /**
2
+ * #189 — Unit coverage for the reverse import-graph invalidation.
3
+ *
4
+ * Three classes of tests:
5
+ * 1. Pure in-memory graph (BFS closure, cycles, diamond, depth cap).
6
+ * 2. Import scanner (regex over ESM import patterns, dynamic imports,
7
+ * barrel re-exports).
8
+ * 3. Integration — `startDevBundler` dispatches an unknown-file change
9
+ * through the transitive-importer path so a deep leaf edit shows up
10
+ * as an `onSSRChange` call for its ancestor root.
11
+ *
12
+ * Category 1 is pure and runs unconditionally. Category 3 shares the
13
+ * bundler-startup gate with `dev-reliability.test.ts` to avoid the
14
+ * `Bun.build` cross-worker race pattern.
15
+ */
16
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
17
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
18
+ import { tmpdir } from "os";
19
+ import path from "path";
20
+ import {
21
+ ReverseImportGraph,
22
+ scanFileImports,
23
+ DEFAULT_MAX_CLOSURE_DEPTH,
24
+ extractImportSpecifiers,
25
+ resolveRelativeImport,
26
+ } from "../reverse-import-graph";
27
+ import { startDevBundler, SSR_CHANGE_WILDCARD } from "../dev";
28
+ import type { RoutesManifest } from "../../spec/schema";
29
+
30
+ // -----------------------------------------------------------------------------
31
+ // Pure graph coverage — no fs, no bundler startup
32
+ // -----------------------------------------------------------------------------
33
+
34
+ describe("ReverseImportGraph — pure graph operations", () => {
35
+ // Reproduce the exact normalization `ReverseImportGraph` applies so
36
+ // tests can assert against the stored key shape (forward slashes,
37
+ // lowercased on win32).
38
+ const normalizedKey = (p: string): string => {
39
+ const abs = path.resolve(p).replace(/\\/g, "/");
40
+ return process.platform === "win32" ? abs.toLowerCase() : abs;
41
+ };
42
+
43
+ it("records a single edge and exposes it via directImporters", () => {
44
+ const g = new ReverseImportGraph();
45
+ const barrel = path.resolve("/abs/barrel.ts");
46
+ const leaf = path.resolve("/abs/leaf.ts");
47
+ g.update(barrel, [leaf]);
48
+
49
+ const direct = g.directImporters(leaf);
50
+ expect(direct.has(normalizedKey(barrel))).toBe(true);
51
+ });
52
+
53
+ it("transitiveImporters walks a diamond without duplicates", () => {
54
+ const g = new ReverseImportGraph();
55
+ const leaf = path.resolve("/abs/leaf.ts");
56
+ const left = path.resolve("/abs/left.ts");
57
+ const right = path.resolve("/abs/right.ts");
58
+ const top = path.resolve("/abs/top.ts");
59
+
60
+ // top -> left -> leaf
61
+ // top -> right -> leaf
62
+ g.update(left, [leaf]);
63
+ g.update(right, [leaf]);
64
+ g.update(top, [left, right]);
65
+
66
+ const closure = g.transitiveImporters(leaf);
67
+ expect(closure.has(normalizedKey(left))).toBe(true);
68
+ expect(closure.has(normalizedKey(right))).toBe(true);
69
+ expect(closure.has(normalizedKey(top))).toBe(true);
70
+ // The target itself is never in its own closure.
71
+ expect(closure.has(normalizedKey(leaf))).toBe(false);
72
+ expect(closure.size).toBe(3);
73
+ });
74
+
75
+ it("transitiveImporters handles simple cycles without looping", () => {
76
+ const g = new ReverseImportGraph();
77
+ const a = path.resolve("/abs/a.ts");
78
+ const b = path.resolve("/abs/b.ts");
79
+ const c = path.resolve("/abs/c.ts");
80
+
81
+ // a -> b -> c -> a (cycle). Ask: who imports c?
82
+ g.update(a, [b]);
83
+ g.update(b, [c]);
84
+ g.update(c, [a]);
85
+
86
+ const closure = g.transitiveImporters(c);
87
+ // Every node except c itself reaches c transitively.
88
+ expect(closure.size).toBe(2);
89
+ });
90
+
91
+ it("honors maxDepth cap — depth 1 returns only direct importers", () => {
92
+ const g = new ReverseImportGraph();
93
+ const leaf = path.resolve("/abs/leaf.ts");
94
+ const lvl1 = path.resolve("/abs/lvl1.ts");
95
+ const lvl2 = path.resolve("/abs/lvl2.ts");
96
+ const lvl3 = path.resolve("/abs/lvl3.ts");
97
+ g.update(lvl1, [leaf]);
98
+ g.update(lvl2, [lvl1]);
99
+ g.update(lvl3, [lvl2]);
100
+
101
+ const direct = g.transitiveImporters(leaf, 1);
102
+ expect(direct.size).toBe(1);
103
+
104
+ const twoHop = g.transitiveImporters(leaf, 2);
105
+ expect(twoHop.size).toBe(2);
106
+
107
+ // Default depth suffices for 3 hops.
108
+ const full = g.transitiveImporters(leaf, DEFAULT_MAX_CLOSURE_DEPTH);
109
+ expect(full.size).toBe(3);
110
+ });
111
+
112
+ it("treats depth 0 / negative as a no-op", () => {
113
+ const g = new ReverseImportGraph();
114
+ const leaf = path.resolve("/abs/leaf.ts");
115
+ const top = path.resolve("/abs/top.ts");
116
+ g.update(top, [leaf]);
117
+ expect(g.transitiveImporters(leaf, 0).size).toBe(0);
118
+ expect(g.transitiveImporters(leaf, -1).size).toBe(0);
119
+ });
120
+
121
+ it("update replaces stale outgoing edges without leaking", () => {
122
+ const g = new ReverseImportGraph();
123
+ const importer = path.resolve("/abs/i.ts");
124
+ const oldDep = path.resolve("/abs/old.ts");
125
+ const newDep = path.resolve("/abs/new.ts");
126
+
127
+ g.update(importer, [oldDep]);
128
+ g.update(importer, [newDep]);
129
+
130
+ // Stale entry must not resurface in the reverse index.
131
+ expect(g.directImporters(oldDep).size).toBe(0);
132
+ expect(g.directImporters(newDep).size).toBe(1);
133
+ });
134
+
135
+ it("remove drops a single importer and its reverse edges", () => {
136
+ const g = new ReverseImportGraph();
137
+ const leaf = path.resolve("/abs/leaf.ts");
138
+ const a = path.resolve("/abs/a.ts");
139
+ const b = path.resolve("/abs/b.ts");
140
+ g.update(a, [leaf]);
141
+ g.update(b, [leaf]);
142
+
143
+ expect(g.directImporters(leaf).size).toBe(2);
144
+ g.remove(a);
145
+ expect(g.directImporters(leaf).size).toBe(1);
146
+ });
147
+
148
+ it("drops a self-import without creating a trivial cycle", () => {
149
+ const g = new ReverseImportGraph();
150
+ const self = path.resolve("/abs/self.ts");
151
+ g.update(self, [self]);
152
+ // Self-edge rejected at insert time → no importer recorded.
153
+ expect(g.directImporters(self).size).toBe(0);
154
+ expect(g.transitiveImporters(self).size).toBe(0);
155
+ });
156
+
157
+ it("clear() drops every tracked edge", () => {
158
+ const g = new ReverseImportGraph();
159
+ g.update(path.resolve("/a.ts"), [path.resolve("/b.ts")]);
160
+ g.update(path.resolve("/c.ts"), [path.resolve("/d.ts")]);
161
+ expect(g.size).toBe(2);
162
+ g.clear();
163
+ expect(g.size).toBe(0);
164
+ });
165
+ });
166
+
167
+ // -----------------------------------------------------------------------------
168
+ // Import scanner coverage
169
+ // -----------------------------------------------------------------------------
170
+
171
+ describe("extractImportSpecifiers — regex scanner", () => {
172
+ it("matches plain `import from` statements", () => {
173
+ const src = `import foo from "./a";\nimport { b } from "./b";\n`;
174
+ const specs = extractImportSpecifiers(src);
175
+ expect(specs).toContain("./a");
176
+ expect(specs).toContain("./b");
177
+ });
178
+
179
+ it("matches side-effect-only imports", () => {
180
+ const src = `import "./side-effects";\n`;
181
+ const specs = extractImportSpecifiers(src);
182
+ expect(specs).toContain("./side-effects");
183
+ });
184
+
185
+ it("matches type-only imports", () => {
186
+ const src = `import type { Foo } from "./types";\n`;
187
+ const specs = extractImportSpecifiers(src);
188
+ expect(specs).toContain("./types");
189
+ });
190
+
191
+ it("matches `export from` re-exports (barrel pattern)", () => {
192
+ const src = `export * from "./en";\nexport { greet } from "./ko";\n`;
193
+ const specs = extractImportSpecifiers(src);
194
+ expect(specs).toContain("./en");
195
+ expect(specs).toContain("./ko");
196
+ });
197
+
198
+ it("matches dynamic import calls with string literals", () => {
199
+ const src = `const m = await import("./lazy");\n`;
200
+ const specs = extractImportSpecifiers(src);
201
+ expect(specs).toContain("./lazy");
202
+ });
203
+
204
+ it("deduplicates repeated specifiers in a single file", () => {
205
+ const src = `import a from "./dup";\nimport b from "./dup";\n`;
206
+ const specs = extractImportSpecifiers(src);
207
+ expect(specs.filter((s) => s === "./dup").length).toBe(1);
208
+ });
209
+
210
+ it("ignores bare specifiers at extraction time (filtered at resolve)", () => {
211
+ // extractImportSpecifiers returns raw specifiers. Filtering of
212
+ // bare packages happens inside resolveRelativeImport.
213
+ const src = `import React from "react";\nimport { Core } from "@mandujs/core";\n`;
214
+ const specs = extractImportSpecifiers(src);
215
+ // We expect them present in raw form; the scanner does not judge
216
+ // first-party vs bare.
217
+ expect(specs).toContain("react");
218
+ });
219
+ });
220
+
221
+ describe("resolveRelativeImport — first-party filter + extension search", () => {
222
+ let tmpRoot: string;
223
+
224
+ beforeEach(() => {
225
+ tmpRoot = mkdtempSync(path.join(tmpdir(), "mandu-resolve-"));
226
+ });
227
+
228
+ afterEach(() => {
229
+ try {
230
+ rmSync(tmpRoot, { recursive: true, force: true });
231
+ } catch {
232
+ // Windows may hold the handle.
233
+ }
234
+ });
235
+
236
+ it("rejects bare module specifiers", () => {
237
+ const from = path.join(tmpRoot, "a.ts");
238
+ writeFileSync(from, "");
239
+ expect(resolveRelativeImport(from, "react")).toBeNull();
240
+ expect(resolveRelativeImport(from, "@mandujs/core")).toBeNull();
241
+ });
242
+
243
+ it("resolves a sibling .ts file", () => {
244
+ const from = path.join(tmpRoot, "a.ts");
245
+ const sib = path.join(tmpRoot, "b.ts");
246
+ writeFileSync(from, "");
247
+ writeFileSync(sib, "");
248
+ const resolved = resolveRelativeImport(from, "./b");
249
+ expect(resolved).not.toBeNull();
250
+ expect(path.resolve(resolved!)).toBe(path.resolve(sib));
251
+ });
252
+
253
+ it("resolves a sibling .tsx file when .ts does not exist", () => {
254
+ const from = path.join(tmpRoot, "a.ts");
255
+ const sib = path.join(tmpRoot, "b.tsx");
256
+ writeFileSync(from, "");
257
+ writeFileSync(sib, "");
258
+ const resolved = resolveRelativeImport(from, "./b");
259
+ expect(resolved).not.toBeNull();
260
+ expect(path.resolve(resolved!)).toBe(path.resolve(sib));
261
+ });
262
+
263
+ it("resolves a barrel directory via index.ts", () => {
264
+ const from = path.join(tmpRoot, "a.ts");
265
+ mkdirSync(path.join(tmpRoot, "pkg"));
266
+ const index = path.join(tmpRoot, "pkg/index.ts");
267
+ writeFileSync(from, "");
268
+ writeFileSync(index, "");
269
+ const resolved = resolveRelativeImport(from, "./pkg");
270
+ expect(resolved).not.toBeNull();
271
+ expect(path.resolve(resolved!)).toBe(path.resolve(index));
272
+ });
273
+
274
+ it("returns null when the target file does not exist", () => {
275
+ const from = path.join(tmpRoot, "a.ts");
276
+ writeFileSync(from, "");
277
+ expect(resolveRelativeImport(from, "./nonexistent")).toBeNull();
278
+ });
279
+ });
280
+
281
+ describe("scanFileImports — end-to-end", () => {
282
+ let tmpRoot: string;
283
+
284
+ beforeEach(() => {
285
+ tmpRoot = mkdtempSync(path.join(tmpdir(), "mandu-scan-"));
286
+ });
287
+
288
+ afterEach(() => {
289
+ try {
290
+ rmSync(tmpRoot, { recursive: true, force: true });
291
+ } catch {
292
+ // Windows may hold the handle.
293
+ }
294
+ });
295
+
296
+ it("returns absolute paths for resolvable first-party imports only", async () => {
297
+ const a = path.join(tmpRoot, "a.ts");
298
+ const b = path.join(tmpRoot, "b.ts");
299
+ writeFileSync(
300
+ a,
301
+ `import React from "react";\nimport { x } from "./b";\nimport "./missing";\n`,
302
+ );
303
+ writeFileSync(b, "export const x = 1;\n");
304
+
305
+ const imports = await scanFileImports(a);
306
+ expect(imports.length).toBe(1);
307
+ expect(path.resolve(imports[0])).toBe(path.resolve(b));
308
+ });
309
+
310
+ it("returns [] for an unreadable / missing file", async () => {
311
+ const missing = path.join(tmpRoot, "does-not-exist.ts");
312
+ const imports = await scanFileImports(missing);
313
+ expect(imports).toEqual([]);
314
+ });
315
+
316
+ it("captures a 3-deep chain (barrel -> module -> leaf)", async () => {
317
+ const leaf = path.join(tmpRoot, "leaf.ts");
318
+ const middle = path.join(tmpRoot, "middle.ts");
319
+ const barrel = path.join(tmpRoot, "barrel.ts");
320
+ writeFileSync(leaf, "export const v = 1;\n");
321
+ writeFileSync(middle, `export { v } from "./leaf";\n`);
322
+ writeFileSync(barrel, `import { v } from "./middle";\nexport { v };\n`);
323
+
324
+ // Each hop should scan to exactly one outgoing edge.
325
+ const barrelImports = await scanFileImports(barrel);
326
+ expect(barrelImports.length).toBe(1);
327
+ expect(path.resolve(barrelImports[0])).toBe(path.resolve(middle));
328
+
329
+ const middleImports = await scanFileImports(middle);
330
+ expect(middleImports.length).toBe(1);
331
+ expect(path.resolve(middleImports[0])).toBe(path.resolve(leaf));
332
+ });
333
+ });
334
+
335
+ // -----------------------------------------------------------------------------
336
+ // Integration — transitive dispatch through startDevBundler
337
+ // -----------------------------------------------------------------------------
338
+
339
+ /**
340
+ * Minimum time (ms) to wait after a writeFile for fs.watch + debounce
341
+ * to flush. Mirrors `dev-reliability.test.ts`'s WATCH_SETTLE_MS so
342
+ * Windows polling latency is tolerated.
343
+ */
344
+ const WATCH_SETTLE_MS = 400;
345
+
346
+ function sleep(ms: number): Promise<void> {
347
+ return new Promise((resolve) => setTimeout(resolve, ms));
348
+ }
349
+
350
+ function createTempProject(): string {
351
+ const root = mkdtempSync(path.join(tmpdir(), "mandu-189-"));
352
+ mkdirSync(path.join(root, ".mandu"), { recursive: true });
353
+ mkdirSync(path.join(root, ".mandu/client"), { recursive: true });
354
+ writeFileSync(
355
+ path.join(root, ".mandu/manifest.json"),
356
+ JSON.stringify(
357
+ {
358
+ version: 1,
359
+ buildTime: new Date().toISOString(),
360
+ env: "development",
361
+ bundles: {},
362
+ shared: {
363
+ runtime: "/.mandu/client/runtime.js",
364
+ vendor: "/.mandu/client/vendor.js",
365
+ },
366
+ },
367
+ null,
368
+ 2,
369
+ ),
370
+ );
371
+ mkdirSync(path.join(root, "app"), { recursive: true });
372
+ // Barrel + leaf pattern — the canonical #189 scenario. The leaf lives
373
+ // OUTSIDE any default common-dir so the pre-#189 watcher would not
374
+ // have dispatched it.
375
+ mkdirSync(path.join(root, "app/_utils/translations"), { recursive: true });
376
+ writeFileSync(
377
+ path.join(root, "app/_utils/translations/ko.ts"),
378
+ 'export const greeting = "안녕";\n',
379
+ );
380
+ writeFileSync(
381
+ path.join(root, "app/_utils/translations/en.ts"),
382
+ 'export const greeting = "Hello";\n',
383
+ );
384
+ writeFileSync(
385
+ path.join(root, "app/_utils/translations/index.ts"),
386
+ `import { greeting as ko } from "./ko";\nimport { greeting as en } from "./en";\nexport const translations = { ko, en };\n`,
387
+ );
388
+ writeFileSync(
389
+ path.join(root, "app/page.tsx"),
390
+ `import { translations } from "./_utils/translations";\nexport default function Page() { return null; }\n`,
391
+ );
392
+ return root;
393
+ }
394
+
395
+ describe.skipIf(process.env.MANDU_SKIP_BUNDLER_TESTS === "1")(
396
+ "#189 — reverse import-graph invalidation (integration)",
397
+ () => {
398
+ let rootDir: string;
399
+ let close: (() => void) | null = null;
400
+
401
+ beforeEach(() => {
402
+ rootDir = createTempProject();
403
+ });
404
+
405
+ afterEach(() => {
406
+ close?.();
407
+ close = null;
408
+ try {
409
+ rmSync(rootDir, { recursive: true, force: true });
410
+ } catch {
411
+ // Windows may hold handles.
412
+ }
413
+ });
414
+
415
+ it("leaf edit outside common-dir still invalidates the SSR root (barrel + static map)", async () => {
416
+ const ssrCalls: string[] = [];
417
+
418
+ const manifest: RoutesManifest = {
419
+ version: 1,
420
+ routes: [
421
+ {
422
+ id: "page",
423
+ kind: "page",
424
+ pattern: "/",
425
+ module: "app/page.tsx",
426
+ componentModule: "app/page.tsx",
427
+ },
428
+ ],
429
+ } as unknown as RoutesManifest;
430
+
431
+ const bundler = await startDevBundler({
432
+ rootDir,
433
+ manifest,
434
+ onSSRChange: (filePath) => {
435
+ ssrCalls.push(filePath);
436
+ },
437
+ });
438
+ close = bundler.close;
439
+
440
+ // Give the seedReverseGraph fire-and-forget a chance to finish.
441
+ // The seed is O(|routes| * fs.readFile) which on a 2-route
442
+ // manifest completes in <50 ms on any dev machine.
443
+ await sleep(100);
444
+
445
+ // Deep leaf edit — NOT in any known root set, NOT in a default
446
+ // common dir (`app/` isn't a common dir by default).
447
+ writeFileSync(
448
+ path.join(rootDir, "app/_utils/translations/ko.ts"),
449
+ 'export const greeting = "안녕하세요";\n',
450
+ );
451
+
452
+ await sleep(WATCH_SETTLE_MS);
453
+
454
+ // The leaf change should reach onSSRChange via the
455
+ // barrel -> page.tsx transitive chain. The callback is
456
+ // invoked with the normalized absolute path of page.tsx
457
+ // (the SSR root that imports the barrel).
458
+ const normalizedPage =
459
+ process.platform === "win32"
460
+ ? path
461
+ .resolve(rootDir, "app/page.tsx")
462
+ .replace(/\\/g, "/")
463
+ .toLowerCase()
464
+ : path.resolve(rootDir, "app/page.tsx").replace(/\\/g, "/");
465
+
466
+ expect(ssrCalls.length).toBeGreaterThanOrEqual(1);
467
+ // Exact path match — defensive so a future refactor that loses
468
+ // the normalization surfaces here, not in production.
469
+ expect(ssrCalls).toContain(normalizedPage);
470
+ });
471
+
472
+ it("emits no transitive dispatch for a genuinely unrelated file", async () => {
473
+ const ssrCalls: string[] = [];
474
+
475
+ const manifest: RoutesManifest = {
476
+ version: 1,
477
+ routes: [
478
+ {
479
+ id: "page",
480
+ kind: "page",
481
+ pattern: "/",
482
+ module: "app/page.tsx",
483
+ componentModule: "app/page.tsx",
484
+ },
485
+ ],
486
+ } as unknown as RoutesManifest;
487
+
488
+ const bundler = await startDevBundler({
489
+ rootDir,
490
+ manifest,
491
+ onSSRChange: (filePath) => {
492
+ // Wildcard common-dir fires too — we only care about the
493
+ // specific-path dispatch here.
494
+ if (filePath !== SSR_CHANGE_WILDCARD) ssrCalls.push(filePath);
495
+ },
496
+ });
497
+ close = bundler.close;
498
+ await sleep(100);
499
+
500
+ // Write a brand-new file that nothing imports. Has to live
501
+ // UNDER a watched directory for the watcher to see it at all;
502
+ // `app/` is covered because `page.tsx` itself is there.
503
+ const orphan = path.join(rootDir, "app/orphan.ts");
504
+ writeFileSync(orphan, "export const NOTHING = 1;\n");
505
+
506
+ await sleep(WATCH_SETTLE_MS);
507
+
508
+ // No known importer → legacy silent-drop → zero SSR calls with
509
+ // the orphan's path. (Some platforms may emit spurious
510
+ // wildcard fires for unrelated file-system noise; we already
511
+ // filtered those.)
512
+ const normalizedOrphan =
513
+ process.platform === "win32"
514
+ ? orphan.replace(/\\/g, "/").toLowerCase()
515
+ : orphan.replace(/\\/g, "/");
516
+ expect(ssrCalls).not.toContain(normalizedOrphan);
517
+ });
518
+ },
519
+ );
@@ -859,11 +859,34 @@ function getListeners() {
859
859
  return window.__MANDU_ROUTER_LISTENERS__;
860
860
  }
861
861
 
862
- // 패턴 매칭 캐시
862
+ // 패턴 매칭 캐시 (Phase 17 — bounded LRU, inlined to keep the runtime
863
+ // bundle self-contained. Same semantics as packages/core/src/utils/lru-cache.ts
864
+ // but hand-written here so the client-side shim has no server imports.)
865
+ var PATTERN_CACHE_MAX = 200;
863
866
  var patternCache = new Map();
864
867
 
868
+ function patternCacheGet(key) {
869
+ if (!patternCache.has(key)) return undefined;
870
+ var value = patternCache.get(key);
871
+ // Promote to MRU.
872
+ patternCache.delete(key);
873
+ patternCache.set(key, value);
874
+ return value;
875
+ }
876
+
877
+ function patternCacheSet(key, value) {
878
+ if (patternCache.has(key)) {
879
+ patternCache.delete(key);
880
+ } else if (patternCache.size >= PATTERN_CACHE_MAX) {
881
+ var oldest = patternCache.keys().next().value;
882
+ if (oldest !== undefined) patternCache.delete(oldest);
883
+ }
884
+ patternCache.set(key, value);
885
+ }
886
+
865
887
  function compilePattern(pattern) {
866
- if (patternCache.has(pattern)) return patternCache.get(pattern);
888
+ var cached = patternCacheGet(pattern);
889
+ if (cached) return cached;
867
890
 
868
891
  const paramNames = [];
869
892
  let paramIndex = 0;
@@ -881,7 +904,7 @@ function compilePattern(pattern) {
881
904
  });
882
905
 
883
906
  const compiled = { regex: new RegExp('^' + regexStr + '$'), paramNames };
884
- patternCache.set(pattern, compiled);
907
+ patternCacheSet(pattern, compiled);
885
908
  return compiled;
886
909
  }
887
910