glassframe-protocol 1.2.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/dist/index.js ADDED
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+
3
+ const GlassFrame = require("./src/GlassFrame");
4
+ const VersionInfo = require("./src/core/VersionInfo");
5
+ const { MemoryStateStore, JSONFileStateStore } = require("./src/core/StateStore");
6
+
7
+ // module.exports IS the class (const GlassFrame = require("glassframe-protocol")),
8
+ // with named properties attached for anyone who prefers destructuring.
9
+ module.exports = GlassFrame;
10
+ module.exports.GlassFrame = GlassFrame;
11
+ module.exports.VersionInfo = VersionInfo;
12
+ module.exports.MemoryStateStore = MemoryStateStore;
13
+ module.exports.JSONFileStateStore = JSONFileStateStore;
@@ -0,0 +1,195 @@
1
+ "use strict";
2
+
3
+ const EventEmitter = require("events");
4
+ const { PermissionsBitField } = require("discord.js");
5
+
6
+ const defaultConfig = require("../config");
7
+ const VersionInfo = require("./core/VersionInfo");
8
+ const ThreatEngine = require("./core/ThreatEngine");
9
+ const { MemoryStateStore } = require("./core/StateStore");
10
+ const EventQueue = require("./core/EventQueue");
11
+ const PerformanceMonitor = require("./core/PerformanceMonitor");
12
+
13
+ const RoleAnalyzer = require("./moderation/RoleAnalyzer");
14
+ const PunishmentEngine = require("./moderation/PunishmentEngine");
15
+
16
+ const SmartLogger = require("./logging/SmartLogger");
17
+ const GroqClient = require("./ai/GroqClient");
18
+ const PhishingDatabase = require("./security/PhishingDatabase");
19
+
20
+ const BasicSecurityLayer = require("./layers/BasicSecurityLayer");
21
+ const AntiRaidLayer = require("./layers/AntiRaidLayer");
22
+ const AntiNukeLayer = require("./layers/AntiNukeLayer");
23
+ const AIModerationLayer = require("./layers/AIModerationLayer");
24
+
25
+ const PrefixRouter = require("./commands/PrefixRouter");
26
+ const { buildPanel } = require("./ui/ControlPanel");
27
+
28
+ /**
29
+ * GlassFrame Protocol - a layered, self-contained Discord security engine.
30
+ * One instance manages every layer, the shared threat/punishment pipeline,
31
+ * logging, the prefix command router, and the 5-button control panel for a
32
+ * discord.js Client. See docs/PROTOCOL_LAYERS.md for the architecture.
33
+ */
34
+ class GlassFrame extends EventEmitter {
35
+ constructor(client, options = {}) {
36
+ super();
37
+ if (!client) throw new Error("GlassFrame Protocol: a discord.js Client is required");
38
+ if (typeof options.getLogChannel !== "function") {
39
+ throw new Error("GlassFrame Protocol: options.getLogChannel(guild) is required");
40
+ }
41
+
42
+ this.client = client;
43
+ this.config = mergeConfig(defaultConfig, options.config || {});
44
+ this.whitelist = new Set(options.whitelist || []);
45
+ this.stateStore = options.stateStore || new MemoryStateStore();
46
+ this.panelMessages = new Map(); // messageId -> guildId
47
+ this.debug = Boolean(options.debug ?? this.config.cache.debug);
48
+
49
+ this.threatEngine = new ThreatEngine(this.config, { debug: this.debug });
50
+ this.roleAnalyzer = new RoleAnalyzer(this.config);
51
+ this.logger = new SmartLogger(options.getLogChannel, this.config, { debug: this.debug });
52
+ this.performance = new PerformanceMonitor();
53
+ this.phishingDatabase = new PhishingDatabase(this.config.basicSecurity.linkGuard.seedDomains);
54
+
55
+ // Every heavy outbound call (Discord mutations, Groq requests) is routed
56
+ // through a bounded-concurrency queue instead of firing unbounded - see
57
+ // docs/PERFORMANCE.md. Two separate queues since Discord and Groq have
58
+ // entirely independent rate limits.
59
+ this.actionQueue = new EventQueue({ concurrency: this.config.performance.actionConcurrency });
60
+ this.aiQueue = new EventQueue({ concurrency: this.config.performance.aiConcurrency });
61
+
62
+ this.groqClient = new GroqClient(this.config, { debug: this.debug, queue: this.aiQueue, performance: this.performance });
63
+ this.punishmentEngine = new PunishmentEngine({
64
+ config: this.config,
65
+ threatEngine: this.threatEngine,
66
+ roleAnalyzer: this.roleAnalyzer,
67
+ logger: this.logger,
68
+ whitelist: this.whitelist,
69
+ queue: this.actionQueue,
70
+ performance: this.performance,
71
+ debug: this.debug
72
+ });
73
+
74
+ // All four layers start disabled - nothing runs until armed via
75
+ // options.autoStart, the control panel, or enableLayer().
76
+ this.layers = {
77
+ basicSecurity: new BasicSecurityLayer(this),
78
+ antiRaid: new AntiRaidLayer(this),
79
+ antiNuke: new AntiNukeLayer(this),
80
+ aiModeration: new AIModerationLayer(this)
81
+ };
82
+
83
+ this.prefixRouter = new PrefixRouter(this);
84
+ this.prefixRouter.attach();
85
+ this._attachButtonHandler();
86
+
87
+ if (this.debug) console.log(VersionInfo.banner());
88
+
89
+ for (const name of options.autoStart || []) {
90
+ if (this.layers[name]) this.layers[name].enable();
91
+ }
92
+ }
93
+
94
+ isAuthorized(member) {
95
+ if (!member) return false;
96
+ if (member.id === member.guild.ownerId) return true;
97
+ return member.permissions.has(PermissionsBitField.Flags.ManageGuild);
98
+ }
99
+
100
+ getStatus(guildId) {
101
+ const out = {};
102
+ for (const [name, layer] of Object.entries(this.layers)) {
103
+ out[name] = layer.enabled;
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /** One-call health snapshot: event throughput/latency, queue depth, and every cache's stats. */
109
+ getMetrics() {
110
+ return {
111
+ performance: this.performance.snapshot(),
112
+ queues: {
113
+ actions: this.actionQueue.getStats(),
114
+ ai: this.aiQueue.getStats()
115
+ },
116
+ caches: {
117
+ threatScores: this.threatEngine.scores.getStats(),
118
+ openCases: this.punishmentEngine.cases.getStats(),
119
+ groqVerdicts: this.groqClient.cache.getStats()
120
+ }
121
+ };
122
+ }
123
+
124
+ enableLayer(name) {
125
+ if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
126
+ this.layers[name].enable();
127
+ return this;
128
+ }
129
+
130
+ disableLayer(name) {
131
+ if (!this.layers[name]) throw new Error(`GlassFrame Protocol: unknown layer "${name}"`);
132
+ this.layers[name].disable();
133
+ return this;
134
+ }
135
+
136
+ _attachButtonHandler() {
137
+ this.client.on("interactionCreate", async (interaction) => {
138
+ if (!interaction.isButton() || !interaction.customId.startsWith("gfp_toggle_")) return;
139
+
140
+ try {
141
+ if (!this.isAuthorized(interaction.member)) {
142
+ await interaction.reply({ content: "You need Manage Server permission to use this.", ephemeral: true });
143
+ return;
144
+ }
145
+
146
+ const layerKey = interaction.customId.replace("gfp_toggle_", "");
147
+
148
+ if (layerKey === "all") {
149
+ const status = this.getStatus(interaction.guild.id);
150
+ const allOn = Object.keys(this.layers).every((l) => status[l]);
151
+ for (const name of Object.keys(this.layers)) {
152
+ if (allOn) this.disableLayer(name);
153
+ else this.enableLayer(name);
154
+ }
155
+ } else if (this.layers[layerKey]) {
156
+ if (this.layers[layerKey].enabled) this.disableLayer(layerKey);
157
+ else this.enableLayer(layerKey);
158
+ } else {
159
+ return;
160
+ }
161
+
162
+ const payload = buildPanel(this, interaction.guild.id);
163
+ await interaction.update(payload);
164
+
165
+ await this.logger.log(interaction.guild, {
166
+ level: "info",
167
+ title: "Protocol Layer Toggled",
168
+ description: `${interaction.user.tag} updated GlassFrame Protocol layers from the control panel.`,
169
+ dedupeKey: "panel:toggle"
170
+ });
171
+ } catch (err) {
172
+ this.emit("warning", { layer: "controlPanel", message: err.message });
173
+ }
174
+ });
175
+ }
176
+ }
177
+
178
+ function mergeConfig(base, override) {
179
+ const out = { ...base };
180
+ for (const key of Object.keys(override)) {
181
+ if (
182
+ typeof override[key] === "object" &&
183
+ override[key] !== null &&
184
+ !Array.isArray(override[key]) &&
185
+ typeof base[key] === "object"
186
+ ) {
187
+ out[key] = mergeConfig(base[key], override[key]);
188
+ } else {
189
+ out[key] = override[key];
190
+ }
191
+ }
192
+ return out;
193
+ }
194
+
195
+ module.exports = GlassFrame;
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+
3
+ const ProtocolCache = require("../core/Cache");
4
+
5
+ /**
6
+ * Thin client for Groq's OpenAI-compatible chat completions endpoint, built
7
+ * around a pool of API keys rather than one: if a key comes back
8
+ * rate-limited it's benched for a cooldown and the next key in the pool is
9
+ * tried, so one exhausted key doesn't take AI moderation offline. Every
10
+ * verdict is cached briefly so a burst of identical/near-identical spam
11
+ * only costs one real API call (see docs/CACHE_ARCHITECTURE.md).
12
+ *
13
+ * Uses Node's built-in fetch/AbortController - no extra dependency, and
14
+ * consistent with the rest of the library staying dependency-light.
15
+ */
16
+ class GroqClient {
17
+ constructor(config, { debug = false, queue, performance } = {}) {
18
+ this.config = config.aiModeration;
19
+ this.keys = (this.config.apiKeys || []).map((key) => ({ key, cooldownUntil: 0 }));
20
+ this.keyIndex = 0;
21
+ this.cache = new ProtocolCache("groq-verdicts", { ttlMs: this.config.cacheTtlMs, debug });
22
+ this.callTimestamps = [];
23
+ // Outbound requests go through a bounded-concurrency queue so a wave of
24
+ // gray-zone messages can't fire dozens of simultaneous Groq calls at
25
+ // once; falls back to running inline if no queue is supplied.
26
+ this.queue = queue || { push: (task) => task() };
27
+ this.performance = performance || { recordEvent() {}, time: () => () => {} };
28
+ }
29
+
30
+ get available() {
31
+ return this.keys.length > 0;
32
+ }
33
+
34
+ _nextKey() {
35
+ const now = Date.now();
36
+ for (let i = 0; i < this.keys.length; i++) {
37
+ const idx = (this.keyIndex + i) % this.keys.length;
38
+ if (this.keys[idx].cooldownUntil < now) {
39
+ this.keyIndex = (idx + 1) % this.keys.length;
40
+ return this.keys[idx];
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ _underRateBudget() {
47
+ const now = Date.now();
48
+ this.callTimestamps = this.callTimestamps.filter((t) => now - t < 60000);
49
+ return this.callTimestamps.length < this.config.maxCallsPerMinute;
50
+ }
51
+
52
+ _hash(text) {
53
+ let h = 0;
54
+ const s = String(text).slice(0, 500);
55
+ for (let i = 0; i < s.length; i++) {
56
+ h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
57
+ }
58
+ return `h${h}`;
59
+ }
60
+
61
+ /**
62
+ * Returns { category, confidence, reason } or null if AI moderation is
63
+ * unavailable, unconfigured, exhausted, or errors out. Callers must treat
64
+ * null as "fall back to local detection", never as "message is clean".
65
+ */
66
+ async classify(text, context = "") {
67
+ if (!this.available || !text) return null;
68
+ if (!this._underRateBudget()) return null;
69
+
70
+ const cacheKey = this._hash(text);
71
+ const cached = this.cache.get(cacheKey);
72
+ if (cached) return cached;
73
+
74
+ const slot = this._nextKey();
75
+ if (!slot) return null;
76
+
77
+ const done = this.performance.time("groqClient.classify");
78
+ try {
79
+ this.callTimestamps.push(Date.now());
80
+ // Queued rather than fired directly - the request's own timeout clock
81
+ // (below) only starts once it actually leaves the queue, so a busy
82
+ // moment never causes a false request-level timeout.
83
+ const verdict = await this.queue.push(() => this._request(slot, text, context));
84
+ if (verdict) this.cache.set(cacheKey, verdict);
85
+ return verdict;
86
+ } catch {
87
+ return null;
88
+ } finally {
89
+ done();
90
+ this.performance.recordEvent("groqClient.classify");
91
+ }
92
+ }
93
+
94
+ async _request(slot, text, context) {
95
+ const controller = new AbortController();
96
+ const timer = setTimeout(() => controller.abort(), this.config.timeoutMs);
97
+
98
+ try {
99
+ const res = await fetch(this.config.endpoint, {
100
+ method: "POST",
101
+ headers: {
102
+ "Content-Type": "application/json",
103
+ Authorization: `Bearer ${slot.key}`
104
+ },
105
+ body: JSON.stringify({
106
+ model: this.config.model,
107
+ temperature: 0,
108
+ max_tokens: 200,
109
+ messages: [
110
+ {
111
+ role: "system",
112
+ content: "You are a Discord moderation classifier. Respond with ONLY compact JSON, no prose, no markdown fences."
113
+ },
114
+ { role: "user", content: this._buildPrompt(text, context) }
115
+ ]
116
+ }),
117
+ signal: controller.signal
118
+ });
119
+
120
+ if (res.status === 429) {
121
+ slot.cooldownUntil = Date.now() + 60000;
122
+ return null;
123
+ }
124
+ if (!res.ok) return null;
125
+
126
+ const data = await res.json();
127
+ const raw = data.choices?.[0]?.message?.content || "";
128
+ return this._parse(raw);
129
+ } finally {
130
+ clearTimeout(timer);
131
+ }
132
+ }
133
+
134
+ _buildPrompt(text, context) {
135
+ return [
136
+ "Classify this Discord message for moderation purposes.",
137
+ context ? `Context: ${context}` : null,
138
+ `Message: ${JSON.stringify(text.slice(0, 1000))}`,
139
+ 'Return JSON exactly like: {"category":"clean|spam|scam|phishing|raid_coordination|harassment|nsfw","confidence":0.0,"reason":"short reason"}'
140
+ ]
141
+ .filter(Boolean)
142
+ .join("\n");
143
+ }
144
+
145
+ _parse(raw) {
146
+ try {
147
+ const cleaned = raw.replace(/```json|```/g, "").trim();
148
+ const parsed = JSON.parse(cleaned);
149
+ if (typeof parsed.category !== "string" || typeof parsed.confidence !== "number") return null;
150
+ return {
151
+ category: parsed.category,
152
+ confidence: Math.max(0, Math.min(1, parsed.confidence)),
153
+ reason: parsed.reason || "AI classification"
154
+ };
155
+ } catch {
156
+ return null;
157
+ }
158
+ }
159
+ }
160
+
161
+ module.exports = GroqClient;
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+
3
+ const { buildPanel } = require("../ui/ControlPanel");
4
+ const { buildLogMessage } = require("../logging/ComponentsV2");
5
+
6
+ const LAYER_NAMES = ["antiRaid", "antiNuke", "basicSecurity", "aiModeration"];
7
+ const COMMANDS = ["panel", "status", "help", "whitelist", "scan", "metrics", "phishing"];
8
+
9
+ /**
10
+ * GlassFrame Protocol registers no slash commands, by design (see
11
+ * docs/COMMANDS.md). Every command here is a plain prefix command gated on
12
+ * Manage Server permission, with a short per-user cooldown so a doubled
13
+ * keypress can't fire the same command twice in a row.
14
+ */
15
+ class PrefixRouter {
16
+ constructor(frame) {
17
+ this.frame = frame;
18
+ this.lastUsed = new Map(); // `guildId:userId` -> timestamp
19
+ }
20
+
21
+ attach() {
22
+ this.frame.client.on("messageCreate", (message) => this._handle(message).catch(() => {}));
23
+ }
24
+
25
+ _onCooldown(message) {
26
+ const key = `${message.guild.id}:${message.author.id}`;
27
+ const now = Date.now();
28
+ const last = this.lastUsed.get(key) || 0;
29
+ const cooldownMs = this.frame.config.performance.commandCooldownMs;
30
+ if (now - last < cooldownMs) return true;
31
+ this.lastUsed.set(key, now);
32
+ return false;
33
+ }
34
+
35
+ async _handle(message) {
36
+ if (!message.guild || message.author.bot) return;
37
+ const prefix = this.frame.config.prefix;
38
+ if (!message.content.toLowerCase().startsWith(prefix.toLowerCase())) return;
39
+
40
+ const args = message.content.slice(prefix.length).trim().split(/\s+/);
41
+ const command = (args.shift() || "").toLowerCase();
42
+ if (!COMMANDS.includes(command)) return;
43
+
44
+ if (!this.frame.isAuthorized(message.member)) {
45
+ await message.reply({ content: "You need Manage Server permission to use GlassFrame Protocol commands." });
46
+ return;
47
+ }
48
+
49
+ if (this._onCooldown(message)) return;
50
+
51
+ if (command === "panel") return this._panel(message);
52
+ if (command === "status") return this._status(message);
53
+ if (command === "help") return this._help(message);
54
+ if (command === "whitelist") return this._whitelist(message, args);
55
+ if (command === "scan") return this._scan(message);
56
+ if (command === "metrics") return this._metrics(message);
57
+ if (command === "phishing") return this._phishing(message, args);
58
+ }
59
+
60
+ async _panel(message) {
61
+ const payload = buildPanel(this.frame, message.guild.id);
62
+ const sent = await message.channel.send(payload);
63
+ this.frame.panelMessages.set(sent.id, message.guild.id);
64
+ }
65
+
66
+ async _status(message) {
67
+ const status = this.frame.getStatus(message.guild.id);
68
+ const fields = LAYER_NAMES.map((l) => ({ name: l, value: status[l] ? "ACTIVE" : "INACTIVE" }));
69
+ const payload = buildLogMessage({ level: "info", title: "GlassFrame Protocol - Status", fields });
70
+ await message.channel.send(payload);
71
+ }
72
+
73
+ async _help(message) {
74
+ const prefix = this.frame.config.prefix;
75
+ const payload = buildLogMessage({
76
+ level: "info",
77
+ title: "GlassFrame Protocol - Commands",
78
+ description: [
79
+ `${prefix}panel - open the 5-button control panel`,
80
+ `${prefix}status - show which layers are active`,
81
+ `${prefix}scan - check current roles for name/permission mismatches`,
82
+ `${prefix}metrics - show performance and cache health`,
83
+ `${prefix}phishing add|remove|list <domain> - manage the link blocklist`,
84
+ `${prefix}whitelist add <userId> - exempt a user from all punitive action`,
85
+ `${prefix}whitelist remove <userId> - remove an exemption`,
86
+ `${prefix}help - this message`
87
+ ].join("\n")
88
+ });
89
+ await message.channel.send(payload);
90
+ }
91
+
92
+ async _whitelist(message, args) {
93
+ const [sub, id] = args;
94
+ if (!sub || !id) {
95
+ await message.reply({ content: `Usage: ${this.frame.config.prefix}whitelist <add|remove> <userId>` });
96
+ return;
97
+ }
98
+
99
+ const action = sub.toLowerCase();
100
+ if (action === "add") this.frame.whitelist.add(id);
101
+ else if (action === "remove") this.frame.whitelist.delete(id);
102
+ else return;
103
+
104
+ await message.reply({
105
+ content: `Whitelist updated: ${id} ${action === "add" ? "added to" : "removed from"} the exemption list.`
106
+ });
107
+ }
108
+
109
+ async _scan(message) {
110
+ const flags = this.frame.roleAnalyzer.scanRoles(message.guild);
111
+ if (!flags.length) {
112
+ await message.reply({ content: "No role name/permission mismatches found." });
113
+ return;
114
+ }
115
+ const payload = buildLogMessage({
116
+ level: "warn",
117
+ title: "Role Audit Results",
118
+ description: flags.map((f) => `**${f.name}** - ${f.reason}`).join("\n")
119
+ });
120
+ await message.channel.send(payload);
121
+ }
122
+
123
+ async _metrics(message) {
124
+ const m = this.frame.getMetrics();
125
+ const fmtCache = (c) => `size ${c.size} | hits ${c.hits} | misses ${c.misses} | evictions ${c.evictions}`;
126
+ const fmtQueue = (q) => `active ${q.active}/${q.concurrency} | pending ${q.pending} | completed ${q.completed} | failed ${q.failed}`;
127
+ const fmtLatency = Object.entries(m.performance.avgLatencyMs)
128
+ .map(([name, ms]) => `${name}: ${ms}ms avg`)
129
+ .join("\n") || "no samples yet";
130
+
131
+ const payload = buildLogMessage({
132
+ level: "info",
133
+ title: "GlassFrame Protocol - Performance",
134
+ description: `Uptime: ${Math.round(m.performance.uptimeMs / 60000)} min`,
135
+ fields: [
136
+ { name: "Action queue", value: fmtQueue(m.queues.actions) },
137
+ { name: "AI queue", value: fmtQueue(m.queues.ai) },
138
+ { name: "Threat score cache", value: fmtCache(m.caches.threatScores) },
139
+ { name: "Open case cache", value: fmtCache(m.caches.openCases) },
140
+ { name: "Groq verdict cache", value: fmtCache(m.caches.groqVerdicts) },
141
+ { name: "Average latency", value: fmtLatency }
142
+ ]
143
+ });
144
+ await message.channel.send(payload);
145
+ }
146
+
147
+ async _phishing(message, args) {
148
+ const [sub, domain] = args;
149
+ const prefix = this.frame.config.prefix;
150
+
151
+ if (!sub || sub.toLowerCase() === "list") {
152
+ const domains = this.frame.phishingDatabase.list();
153
+ await message.reply({
154
+ content: domains.length ? `Blocked domains:\n${domains.join("\n")}` : "The phishing blocklist is empty."
155
+ });
156
+ return;
157
+ }
158
+
159
+ if (!domain) {
160
+ await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
161
+ return;
162
+ }
163
+
164
+ const action = sub.toLowerCase();
165
+ if (action === "add") {
166
+ this.frame.phishingDatabase.add(domain);
167
+ await message.reply({ content: `Added **${domain}** to the phishing blocklist.` });
168
+ } else if (action === "remove") {
169
+ const existed = this.frame.phishingDatabase.remove(domain);
170
+ await message.reply({ content: existed ? `Removed **${domain}** from the blocklist.` : `**${domain}** wasn't on the blocklist.` });
171
+ } else {
172
+ await message.reply({ content: `Usage: ${prefix}phishing <add|remove|list> <domain>` });
173
+ }
174
+ }
175
+ }
176
+
177
+ module.exports = PrefixRouter;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Small TTL cache used by every stateful piece of GlassFrame Protocol
5
+ * instead of a bare Map. Centralizing it means every cache in the library
6
+ * gets the same expiry rules, the same stats, and the same optional debug
7
+ * logging for free. See docs/CACHE_ARCHITECTURE.md for the full picture of
8
+ * which cache is used where and why.
9
+ */
10
+ class ProtocolCache {
11
+ constructor(name, { ttlMs = 5 * 60 * 1000, debug = false, sweepEveryMs = 60000 } = {}) {
12
+ this.name = name;
13
+ this.ttlMs = ttlMs;
14
+ this.debug = debug;
15
+ this.store = new Map();
16
+ this.stats = { hits: 0, misses: 0, sets: 0, evictions: 0, sweeps: 0 };
17
+ this._sweeper = null;
18
+
19
+ if (sweepEveryMs) {
20
+ this._sweeper = setInterval(() => this.sweep(), sweepEveryMs);
21
+ this._sweeper.unref?.();
22
+ }
23
+ }
24
+
25
+ /** ttlOverrideMs of 0 or less means "never expires" for this entry. */
26
+ set(key, value, ttlOverrideMs) {
27
+ const ttl = ttlOverrideMs ?? this.ttlMs;
28
+ this.store.set(key, { value, expiresAt: ttl > 0 ? Date.now() + ttl : Infinity });
29
+ this.stats.sets++;
30
+ this._log("SET", key);
31
+ return value;
32
+ }
33
+
34
+ get(key) {
35
+ const entry = this.store.get(key);
36
+ if (!entry) {
37
+ this.stats.misses++;
38
+ this._log("MISS", key);
39
+ return undefined;
40
+ }
41
+ if (Date.now() > entry.expiresAt) {
42
+ this.store.delete(key);
43
+ this.stats.misses++;
44
+ this.stats.evictions++;
45
+ this._log("EXPIRE", key);
46
+ return undefined;
47
+ }
48
+ this.stats.hits++;
49
+ this._log("HIT", key);
50
+ return entry.value;
51
+ }
52
+
53
+ has(key) {
54
+ return this.get(key) !== undefined;
55
+ }
56
+
57
+ delete(key) {
58
+ const existed = this.store.delete(key);
59
+ if (existed) this._log("DELETE", key);
60
+ return existed;
61
+ }
62
+
63
+ sweep() {
64
+ const now = Date.now();
65
+ let removed = 0;
66
+ for (const [key, entry] of this.store) {
67
+ if (now > entry.expiresAt) {
68
+ this.store.delete(key);
69
+ removed++;
70
+ }
71
+ }
72
+ this.stats.evictions += removed;
73
+ this.stats.sweeps++;
74
+ if (removed) this._log("SWEEP", `${removed} expired`);
75
+ return removed;
76
+ }
77
+
78
+ clear() {
79
+ this.store.clear();
80
+ this._log("CLEAR", "all keys");
81
+ }
82
+
83
+ getStats() {
84
+ return { ...this.stats, size: this.store.size, name: this.name };
85
+ }
86
+
87
+ destroy() {
88
+ if (this._sweeper) clearInterval(this._sweeper);
89
+ }
90
+
91
+ _log(op, key) {
92
+ if (this.debug) {
93
+ console.log(`[GlassFrame:Cache:${this.name}] ${op} ${key}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ module.exports = ProtocolCache;