@nexussdk/flags 0.0.1 → 0.0.4

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.
@@ -0,0 +1,767 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/dev-server/flag-store.ts
4
+ import * as fs from "fs";
5
+ import * as path from "path";
6
+ import * as crypto from "crypto";
7
+ var DevFlagStore = class {
8
+ _flags = {};
9
+ _filePath;
10
+ constructor(filePath) {
11
+ this._filePath = path.resolve(filePath);
12
+ this._load();
13
+ }
14
+ getAll() {
15
+ return Object.values(this._flags).sort(
16
+ (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
17
+ );
18
+ }
19
+ getByKey(key) {
20
+ return this._flags[key];
21
+ }
22
+ size() {
23
+ return Object.keys(this._flags).length;
24
+ }
25
+ /** Evaluation-compatible bootstrap snapshot */
26
+ getBootstrapSnapshot() {
27
+ const out = {};
28
+ for (const [key, flag] of Object.entries(this._flags)) {
29
+ out[key] = {
30
+ enabled: flag.enabled,
31
+ variants: flag.variants,
32
+ reason: flag.enabled ? "EXPLICITLY_INCLUDED" : "DISABLED"
33
+ };
34
+ }
35
+ return out;
36
+ }
37
+ create(data) {
38
+ if (this._flags[data.key]) {
39
+ throw new Error(`Flag with key "${data.key}" already exists.`);
40
+ }
41
+ const now = (/* @__PURE__ */ new Date()).toISOString();
42
+ const flag = {
43
+ key: data.key,
44
+ name: data.name ?? data.key,
45
+ enabled: data.enabled ?? false,
46
+ description: data.description ?? "",
47
+ rolloutPercentage: data.rolloutPercentage ?? 100,
48
+ variants: data.variants ?? {},
49
+ createdAt: now,
50
+ updatedAt: now
51
+ };
52
+ this._flags[data.key] = flag;
53
+ this._persist();
54
+ return flag;
55
+ }
56
+ update(key, patch) {
57
+ const existing = this._flags[key];
58
+ if (!existing) throw new Error(`Flag "${key}" not found.`);
59
+ const updated = { ...existing, ...patch, key, createdAt: existing.createdAt, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
60
+ this._flags[key] = updated;
61
+ this._persist();
62
+ return updated;
63
+ }
64
+ delete(key) {
65
+ if (!this._flags[key]) return false;
66
+ delete this._flags[key];
67
+ this._persist();
68
+ return true;
69
+ }
70
+ toggle(key) {
71
+ const flag = this._flags[key];
72
+ if (!flag) throw new Error(`Flag "${key}" not found.`);
73
+ return this.update(key, { enabled: !flag.enabled });
74
+ }
75
+ seedIfEmpty() {
76
+ if (this.size() > 0) return;
77
+ const now = (/* @__PURE__ */ new Date()).toISOString();
78
+ const seeds = [
79
+ { key: "dark_mode", name: "Dark Mode", enabled: true, description: "Toggle dark/light theme.", rolloutPercentage: 100, variants: { theme: "dark" }, createdAt: now, updatedAt: now },
80
+ { key: "new_checkout", name: "New Checkout Flow", enabled: false, description: "Redesigned checkout UI.", rolloutPercentage: 50, variants: { discount: 10, layout: "single-page" }, createdAt: now, updatedAt: now },
81
+ { key: "beta_dashboard", name: "Beta Dashboard", enabled: false, description: "New analytics dashboard (internal beta).", rolloutPercentage: 0, variants: {}, createdAt: now, updatedAt: now }
82
+ ];
83
+ for (const flag of seeds) {
84
+ this._flags[flag.key] = flag;
85
+ }
86
+ this._persist();
87
+ }
88
+ _load() {
89
+ try {
90
+ if (fs.existsSync(this._filePath)) {
91
+ const raw = fs.readFileSync(this._filePath, "utf8");
92
+ const parsed = JSON.parse(raw);
93
+ for (const [k, v] of Object.entries(parsed)) {
94
+ if (v && typeof v === "object" && "key" in v) {
95
+ this._flags[k] = v;
96
+ }
97
+ }
98
+ }
99
+ } catch {
100
+ this._flags = {};
101
+ }
102
+ }
103
+ _persist() {
104
+ try {
105
+ const dir = path.dirname(this._filePath);
106
+ if (!fs.existsSync(dir)) {
107
+ fs.mkdirSync(dir, { recursive: true });
108
+ }
109
+ fs.writeFileSync(this._filePath, JSON.stringify(this._flags, null, 2), "utf8");
110
+ } catch {
111
+ }
112
+ }
113
+ };
114
+
115
+ // src/dev-server/sse.ts
116
+ var FlagSseBroadcaster = class {
117
+ _clients = /* @__PURE__ */ new Set();
118
+ _heartbeatInterval = null;
119
+ constructor() {
120
+ this._startHeartbeat();
121
+ }
122
+ addClient(res) {
123
+ res.writeHead(200, {
124
+ "Content-Type": "text/event-stream",
125
+ "Cache-Control": "no-cache, no-transform",
126
+ "Connection": "keep-alive",
127
+ "Access-Control-Allow-Origin": "*",
128
+ "X-Accel-Buffering": "no"
129
+ });
130
+ res.write(`: connected
131
+ data: ${JSON.stringify({ timestamp: Date.now() })}
132
+
133
+ `);
134
+ this._clients.add(res);
135
+ res.on("close", () => {
136
+ this._clients.delete(res);
137
+ });
138
+ res.on("error", () => {
139
+ this._clients.delete(res);
140
+ });
141
+ }
142
+ /**
143
+ * Broadcasts a FLAG_UPDATE event - SDK client receives and re-evaluates the flag.
144
+ */
145
+ broadcastFlagUpdate(key, enabled, variants) {
146
+ const streamEvent = {
147
+ type: "FLAG_UPDATE",
148
+ key,
149
+ data: {
150
+ key,
151
+ enabled,
152
+ variants,
153
+ reason: enabled ? "DEFAULT_ENABLED" : "KILL_SWITCH",
154
+ version: 1
155
+ },
156
+ timestamp: Date.now()
157
+ };
158
+ this._broadcast("FLAG_UPDATE", streamEvent);
159
+ }
160
+ /**
161
+ * Broadcasts a FLAG_DELETE event - SDK client removes from cache.
162
+ */
163
+ broadcastFlagDelete(key) {
164
+ const streamEvent = {
165
+ type: "FLAG_DELETE",
166
+ key,
167
+ timestamp: Date.now()
168
+ };
169
+ this._broadcast("FLAG_DELETE", streamEvent);
170
+ }
171
+ get clientCount() {
172
+ return this._clients.size;
173
+ }
174
+ destroy() {
175
+ if (this._heartbeatInterval) {
176
+ clearInterval(this._heartbeatInterval);
177
+ this._heartbeatInterval = null;
178
+ }
179
+ for (const client of this._clients) {
180
+ try {
181
+ client.end();
182
+ } catch {
183
+ }
184
+ }
185
+ this._clients.clear();
186
+ }
187
+ _broadcast(eventName, data) {
188
+ const payload = `event: ${eventName}
189
+ data: ${JSON.stringify(data)}
190
+
191
+ `;
192
+ for (const client of this._clients) {
193
+ try {
194
+ client.write(payload);
195
+ } catch {
196
+ this._clients.delete(client);
197
+ }
198
+ }
199
+ }
200
+ _startHeartbeat() {
201
+ this._heartbeatInterval = setInterval(() => {
202
+ const ping = `: ping ${Date.now()}
203
+
204
+ `;
205
+ for (const client of this._clients) {
206
+ try {
207
+ client.write(ping);
208
+ } catch {
209
+ this._clients.delete(client);
210
+ }
211
+ }
212
+ }, 15e3);
213
+ const interval = this._heartbeatInterval;
214
+ if (interval && typeof interval === "object" && "unref" in interval && typeof interval.unref === "function") {
215
+ interval.unref();
216
+ }
217
+ }
218
+ };
219
+
220
+ // src/dev-server/server.ts
221
+ import { createServer } from "http";
222
+ import { URL } from "url";
223
+
224
+ // src/dev-server/gui.ts
225
+ function renderFlagsGuiHtml(port) {
226
+ return `<!DOCTYPE html>
227
+ <html lang="en">
228
+ <head>
229
+ <meta charset="UTF-8">
230
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
231
+ <title>Nexus Feature Flags Dev Server</title>
232
+ <style>
233
+ :root {
234
+ --bg: #090d16; --card-bg: #111827; --border: #1f2937; --border-focus: #374151;
235
+ --text: #f3f4f6; --text-muted: #9ca3af; --accent: #8b5cf6; --accent-hover: #7c3aed;
236
+ --green: #10b981; --red: #ef4444; --yellow: #f59e0b; --code-bg: #030712;
237
+ --radius: 8px; --toggle-off: #374151; --toggle-on: #8b5cf6;
238
+ }
239
+ * { box-sizing: border-box; margin: 0; padding: 0; }
240
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; }
241
+ header { background: rgba(17,24,39,0.9); backdrop-filter: blur(8px); border-bottom: 1px solid var(--border); padding: 14px 24px; display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 50; }
242
+ .brand { display: flex; align-items: center; gap: 10px; }
243
+ .brand-icon { width: 32px; height: 32px; background: linear-gradient(135deg, #8b5cf6, #6d28d9); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; }
244
+ .brand-name { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.02em; }
245
+ .brand-sub { font-size: 0.7rem; color: var(--text-muted); margin-top: 1px; }
246
+ .header-meta { display: flex; align-items: center; gap: 16px; }
247
+ .status-badge { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--green); }
248
+ .status-dot { width: 7px; height: 7px; background: var(--green); border-radius: 50%; animation: pulse 2s infinite; }
249
+ @keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.4;} }
250
+ .sse-counter { font-size: 0.72rem; color: var(--text-muted); }
251
+ main { max-width: 960px; margin: 0 auto; padding: 32px 24px; }
252
+ .page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24px; }
253
+ .page-title { font-size: 1.35rem; font-weight: 700; }
254
+ .page-title span { color: var(--accent); }
255
+ .btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: var(--radius); border: none; cursor: pointer; font-size: 0.8rem; font-weight: 600; transition: all 0.15s; }
256
+ .btn-primary { background: var(--accent); color: #fff; }
257
+ .btn-primary:hover { background: var(--accent-hover); }
258
+ .btn-sm { padding: 4px 10px; font-size: 0.72rem; }
259
+ .btn-danger { background: transparent; border: 1px solid #374151; color: var(--red); }
260
+ .btn-danger:hover { border-color: var(--red); background: rgba(239,68,68,0.08); }
261
+ .btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text-muted); }
262
+ .btn-ghost:hover { border-color: var(--border-focus); color: var(--text); }
263
+ .stats-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 24px; }
264
+ .stat-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 18px; }
265
+ .stat-label { font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
266
+ .stat-value { font-size: 1.5rem; font-weight: 700; margin-top: 4px; }
267
+ .stat-value.green { color: var(--green); }
268
+ .stat-value.red { color: var(--red); }
269
+ .flag-list { display: flex; flex-direction: column; gap: 12px; }
270
+ .flag-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; transition: border-color 0.15s; }
271
+ .flag-card:hover { border-color: var(--border-focus); }
272
+ .flag-card.enabled { border-left: 3px solid var(--green); }
273
+ .flag-card.disabled { border-left: 3px solid var(--border); }
274
+ .flag-top { display: flex; align-items: center; justify-content: space-between; }
275
+ .flag-info { flex: 1; min-width: 0; }
276
+ .flag-key { font-family: 'SF Mono', 'Fira Code', Consolas, monospace; font-size: 0.85rem; font-weight: 600; color: var(--accent); }
277
+ .flag-name { font-size: 1rem; font-weight: 600; margin-top: 2px; }
278
+ .flag-desc { font-size: 0.8rem; color: var(--text-muted); margin-top: 4px; }
279
+ .flag-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
280
+ .toggle { position: relative; width: 44px; height: 24px; cursor: pointer; }
281
+ .toggle input { opacity: 0; width: 0; height: 0; }
282
+ .toggle-slider { position: absolute; inset: 0; background: var(--toggle-off); border-radius: 24px; transition: 0.2s; }
283
+ .toggle-slider:before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: 0.2s; }
284
+ input:checked + .toggle-slider { background: var(--toggle-on); }
285
+ input:checked + .toggle-slider:before { transform: translateX(20px); }
286
+ .flag-meta { display: flex; align-items: center; gap: 16px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
287
+ .meta-item { display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: var(--text-muted); }
288
+ .badge { padding: 2px 8px; border-radius: 4px; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
289
+ .badge-on { background: rgba(16,185,129,0.15); color: var(--green); }
290
+ .badge-off { background: rgba(107,114,128,0.15); color: #6b7280; }
291
+ .variants-chip { font-family: monospace; font-size: 0.72rem; background: var(--code-bg); padding: 2px 8px; border-radius: 4px; color: var(--text-muted); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
292
+ /* Modal */
293
+ .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); z-index: 100; display: none; align-items: center; justify-content: center; }
294
+ .modal-backdrop.open { display: flex; }
295
+ .modal { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 28px; width: 90%; max-width: 520px; }
296
+ .modal-title { font-size: 1.1rem; font-weight: 700; margin-bottom: 20px; }
297
+ .form-group { margin-bottom: 16px; }
298
+ .form-label { display: block; font-size: 0.78rem; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 6px; }
299
+ .form-input { width: 100%; padding: 9px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 0.875rem; outline: none; transition: border-color 0.15s; }
300
+ .form-input:focus { border-color: var(--accent); }
301
+ .form-input.mono { font-family: monospace; }
302
+ .form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
303
+ .modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }
304
+ .empty-state { text-align: center; padding: 64px 24px; color: var(--text-muted); }
305
+ .empty-icon { font-size: 2.5rem; margin-bottom: 12px; }
306
+ .empty-text { font-size: 0.9rem; margin-bottom: 16px; }
307
+ .toast { position: fixed; bottom: 24px; right: 24px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px; padding: 12px 18px; font-size: 0.82rem; z-index: 200; transform: translateY(80px); opacity: 0; transition: all 0.25s; }
308
+ .toast.show { transform: translateY(0); opacity: 1; }
309
+ .toast.success { border-left: 3px solid var(--green); }
310
+ .toast.error { border-left: 3px solid var(--red); }
311
+ .rollout-bar { height: 4px; background: var(--border); border-radius: 2px; margin-top: 8px; overflow: hidden; }
312
+ .rollout-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s; }
313
+ </style>
314
+ </head>
315
+ <body>
316
+ <header>
317
+ <div class="brand">
318
+ <div class="brand-icon">\u{1F6A9}</div>
319
+ <div>
320
+ <div class="brand-name">nexus-flags-dev</div>
321
+ <div class="brand-sub">Local Feature Flag Management</div>
322
+ </div>
323
+ </div>
324
+ <div class="header-meta">
325
+ <div class="sse-counter" id="sseCount">0 connected clients</div>
326
+ <div class="status-badge"><div class="status-dot"></div>Live on :${port}</div>
327
+ </div>
328
+ </header>
329
+
330
+ <main>
331
+ <div class="page-header">
332
+ <div>
333
+ <div class="page-title">Feature <span>Flags</span></div>
334
+ </div>
335
+ <button class="btn btn-primary" onclick="openModal()">\uFF0B New Flag</button>
336
+ </div>
337
+
338
+ <div class="stats-bar">
339
+ <div class="stat-card">
340
+ <div class="stat-label">Total Flags</div>
341
+ <div class="stat-value" id="statTotal">0</div>
342
+ </div>
343
+ <div class="stat-card">
344
+ <div class="stat-label">Enabled</div>
345
+ <div class="stat-value green" id="statEnabled">0</div>
346
+ </div>
347
+ <div class="stat-card">
348
+ <div class="stat-label">SSE Clients</div>
349
+ <div class="stat-value" id="statClients">0</div>
350
+ </div>
351
+ </div>
352
+
353
+ <div class="flag-list" id="flagList">
354
+ <div class="empty-state">
355
+ <div class="empty-icon">\u{1F6A9}</div>
356
+ <div class="empty-text">Loading flags...</div>
357
+ </div>
358
+ </div>
359
+ </main>
360
+
361
+ <!-- Create / Edit Modal -->
362
+ <div class="modal-backdrop" id="modal">
363
+ <div class="modal">
364
+ <div class="modal-title" id="modalTitle">Create Flag</div>
365
+ <div class="form-group">
366
+ <label class="form-label">Flag Key <span style="color:#ef4444">*</span></label>
367
+ <input class="form-input mono" id="fieldKey" placeholder="new_checkout (snake_case)" />
368
+ </div>
369
+ <div class="form-group">
370
+ <label class="form-label">Display Name</label>
371
+ <input class="form-input" id="fieldName" placeholder="New Checkout Flow" />
372
+ </div>
373
+ <div class="form-group">
374
+ <label class="form-label">Description</label>
375
+ <input class="form-input" id="fieldDesc" placeholder="Short description of this flag" />
376
+ </div>
377
+ <div class="form-row">
378
+ <div class="form-group">
379
+ <label class="form-label">Rollout %</label>
380
+ <input class="form-input" id="fieldRollout" type="number" min="0" max="100" value="100" />
381
+ </div>
382
+ <div class="form-group">
383
+ <label class="form-label">Enabled</label>
384
+ <select class="form-input" id="fieldEnabled">
385
+ <option value="false">Disabled</option>
386
+ <option value="true">Enabled</option>
387
+ </select>
388
+ </div>
389
+ </div>
390
+ <div class="form-group">
391
+ <label class="form-label">Variants (JSON object)</label>
392
+ <input class="form-input mono" id="fieldVariants" placeholder='{"theme": "dark", "discount": 10}' />
393
+ </div>
394
+ <div class="modal-actions">
395
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
396
+ <button class="btn btn-primary" onclick="submitFlag()" id="submitBtn">Create Flag</button>
397
+ </div>
398
+ </div>
399
+ </div>
400
+
401
+ <div class="toast" id="toast"></div>
402
+
403
+ <script>
404
+ const API = '';
405
+ let flags = [];
406
+ let editingKey = null;
407
+
408
+ async function loadFlags() {
409
+ try {
410
+ const res = await fetch(API + '/api/v1/flags');
411
+ const data = await res.json();
412
+ flags = data.flags || [];
413
+ renderFlags();
414
+ updateStats();
415
+ } catch (e) { showToast('Failed to load flags', 'error'); }
416
+ }
417
+
418
+ function renderFlags() {
419
+ const list = document.getElementById('flagList');
420
+ if (!flags.length) {
421
+ list.innerHTML = '<div class="empty-state"><div class="empty-icon">\u{1F6A9}</div><div class="empty-text">No flags yet. Create your first flag!</div></div>';
422
+ return;
423
+ }
424
+ list.innerHTML = flags.map(f => {
425
+ const variantStr = Object.keys(f.variants || {}).length > 0
426
+ ? JSON.stringify(f.variants).substring(0, 60) + (JSON.stringify(f.variants).length > 60 ? '...' : '')
427
+ : 'no variants';
428
+ return \`
429
+ <div class="flag-card \${f.enabled ? 'enabled' : 'disabled'}" id="card-\${f.key}">
430
+ <div class="flag-top">
431
+ <div class="flag-info">
432
+ <div class="flag-key">\${f.key}</div>
433
+ <div class="flag-name">\${f.name}</div>
434
+ \${f.description ? \`<div class="flag-desc">\${f.description}</div>\` : ''}
435
+ </div>
436
+ <div class="flag-actions">
437
+ <label class="toggle" title="\${f.enabled ? 'Disable' : 'Enable'} flag">
438
+ <input type="checkbox" \${f.enabled ? 'checked' : ''} onchange="toggleFlag('\${f.key}', this.checked)">
439
+ <span class="toggle-slider"></span>
440
+ </label>
441
+ <button class="btn btn-ghost btn-sm" onclick="openEditModal('\${f.key}')">Edit</button>
442
+ <button class="btn btn-danger btn-sm" onclick="deleteFlag('\${f.key}')">Delete</button>
443
+ </div>
444
+ </div>
445
+ <div class="flag-meta">
446
+ <span class="badge \${f.enabled ? 'badge-on' : 'badge-off'}">\${f.enabled ? 'enabled' : 'disabled'}</span>
447
+ <span class="meta-item">Rollout: \${f.rolloutPercentage}%</span>
448
+ <span class="variants-chip">\${variantStr}</span>
449
+ <span class="meta-item" style="margin-left:auto;font-size:0.68rem;">\${new Date(f.updatedAt).toLocaleString()}</span>
450
+ </div>
451
+ <div class="rollout-bar"><div class="rollout-fill" style="width:\${f.rolloutPercentage}%"></div></div>
452
+ </div>
453
+ \`;
454
+ }).join('');
455
+ }
456
+
457
+ function updateStats() {
458
+ document.getElementById('statTotal').textContent = flags.length;
459
+ document.getElementById('statEnabled').textContent = flags.filter(f => f.enabled).length;
460
+ }
461
+
462
+ async function toggleFlag(key, enabled) {
463
+ try {
464
+ await fetch(API + '/api/v1/flags/' + key + '/toggle', { method: 'POST' });
465
+ await loadFlags();
466
+ showToast(enabled ? \`"\${key}" enabled \u2014 all connected clients updated!\` : \`"\${key}" disabled\`, 'success');
467
+ } catch { showToast('Toggle failed', 'error'); }
468
+ }
469
+
470
+ async function deleteFlag(key) {
471
+ if (!confirm(\`Delete flag "\${key}"? This cannot be undone.\`)) return;
472
+ try {
473
+ await fetch(API + '/api/v1/flags/' + key, { method: 'DELETE' });
474
+ await loadFlags();
475
+ showToast(\`Flag "\${key}" deleted\`, 'success');
476
+ } catch { showToast('Delete failed', 'error'); }
477
+ }
478
+
479
+ function openModal() {
480
+ editingKey = null;
481
+ document.getElementById('modalTitle').textContent = 'Create Flag';
482
+ document.getElementById('submitBtn').textContent = 'Create Flag';
483
+ document.getElementById('fieldKey').disabled = false;
484
+ ['fieldKey','fieldName','fieldDesc','fieldVariants'].forEach(id => document.getElementById(id).value = '');
485
+ document.getElementById('fieldRollout').value = '100';
486
+ document.getElementById('fieldEnabled').value = 'false';
487
+ document.getElementById('modal').classList.add('open');
488
+ setTimeout(() => document.getElementById('fieldKey').focus(), 100);
489
+ }
490
+
491
+ function openEditModal(key) {
492
+ const flag = flags.find(f => f.key === key);
493
+ if (!flag) return;
494
+ editingKey = key;
495
+ document.getElementById('modalTitle').textContent = 'Edit Flag';
496
+ document.getElementById('submitBtn').textContent = 'Save Changes';
497
+ document.getElementById('fieldKey').value = key;
498
+ document.getElementById('fieldKey').disabled = true;
499
+ document.getElementById('fieldName').value = flag.name || '';
500
+ document.getElementById('fieldDesc').value = flag.description || '';
501
+ document.getElementById('fieldRollout').value = String(flag.rolloutPercentage ?? 100);
502
+ document.getElementById('fieldEnabled').value = String(flag.enabled);
503
+ document.getElementById('fieldVariants').value = Object.keys(flag.variants || {}).length ? JSON.stringify(flag.variants) : '';
504
+ document.getElementById('modal').classList.add('open');
505
+ }
506
+
507
+ function closeModal() { document.getElementById('modal').classList.remove('open'); }
508
+
509
+ async function submitFlag() {
510
+ const key = document.getElementById('fieldKey').value.trim();
511
+ const name = document.getElementById('fieldName').value.trim() || key;
512
+ const description = document.getElementById('fieldDesc').value.trim();
513
+ const rolloutPercentage = parseInt(document.getElementById('fieldRollout').value, 10) || 100;
514
+ const enabled = document.getElementById('fieldEnabled').value === 'true';
515
+ const variantsRaw = document.getElementById('fieldVariants').value.trim();
516
+ let variants = {};
517
+ if (variantsRaw) {
518
+ try { variants = JSON.parse(variantsRaw); } catch { showToast('Variants must be valid JSON', 'error'); return; }
519
+ }
520
+ if (!key) { showToast('Flag key is required', 'error'); return; }
521
+ const body = { key, name, description, rolloutPercentage, enabled, variants };
522
+ try {
523
+ if (editingKey) {
524
+ await fetch(API + '/api/v1/flags/' + editingKey, { method: 'PATCH', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
525
+ } else {
526
+ await fetch(API + '/api/v1/flags', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
527
+ }
528
+ closeModal();
529
+ await loadFlags();
530
+ showToast(editingKey ? 'Flag updated' : \`Flag "\${key}" created\`, 'success');
531
+ } catch { showToast('Save failed', 'error'); }
532
+ }
533
+
534
+ function showToast(msg, type = 'success') {
535
+ const t = document.getElementById('toast');
536
+ t.textContent = msg;
537
+ t.className = 'toast ' + type + ' show';
538
+ setTimeout(() => { t.className = 'toast ' + type; }, 2800);
539
+ }
540
+
541
+ // SSE \u2014 realtime flag sync display
542
+ const evtSrc = new EventSource(API + '/api/v1/flags/stream');
543
+ evtSrc.addEventListener('FLAG_UPDATE', () => loadFlags());
544
+ evtSrc.addEventListener('FLAG_DELETE', () => loadFlags());
545
+
546
+ // Poll stats
547
+ async function pollStats() {
548
+ try {
549
+ const r = await fetch(API + '/health');
550
+ const d = await r.json();
551
+ const c = d.connectedClients || 0;
552
+ document.getElementById('statClients').textContent = c;
553
+ document.getElementById('sseCount').textContent = c + ' connected client' + (c === 1 ? '' : 's');
554
+ } catch {}
555
+ }
556
+ setInterval(pollStats, 5000);
557
+
558
+ // Close modal on backdrop click
559
+ document.getElementById('modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModal(); });
560
+
561
+ loadFlags();
562
+ pollStats();
563
+ </script>
564
+ </body>
565
+ </html>`;
566
+ }
567
+
568
+ // src/dev-server/server.ts
569
+ function setCors(res) {
570
+ res.setHeader("Access-Control-Allow-Origin", "*");
571
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS");
572
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Nexus-API-Key");
573
+ res.setHeader("Access-Control-Max-Age", "86400");
574
+ }
575
+ function json(res, status, data) {
576
+ const body = JSON.stringify(data);
577
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Content-Length": Buffer.byteLength(body) });
578
+ res.end(body);
579
+ }
580
+ function html(res, body) {
581
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Content-Length": Buffer.byteLength(body) });
582
+ res.end(body);
583
+ }
584
+ function problem(res, status, title, detail) {
585
+ json(res, status, { type: `https://nexus.dev/problems/${title.toLowerCase().replace(/ /g, "-")}`, title, status, detail });
586
+ }
587
+ function parseBody(req) {
588
+ return new Promise((resolve2, reject) => {
589
+ let raw = "";
590
+ req.setEncoding("utf8");
591
+ req.on("data", (chunk) => {
592
+ raw += chunk;
593
+ if (raw.length > 1048576) {
594
+ req.destroy();
595
+ reject(new Error("Payload too large"));
596
+ }
597
+ });
598
+ req.on("end", () => {
599
+ try {
600
+ resolve2(raw.trim() ? JSON.parse(raw) : {});
601
+ } catch {
602
+ reject(new Error("Invalid JSON"));
603
+ }
604
+ });
605
+ req.on("error", reject);
606
+ });
607
+ }
608
+ function startFlagsDevServer(options = {}) {
609
+ const port = options.port ?? 4568;
610
+ const host = options.host ?? "localhost";
611
+ const flagsFile = options.flagsFile ?? "./nexus-flags.json";
612
+ const store = new DevFlagStore(flagsFile);
613
+ const sse = new FlagSseBroadcaster();
614
+ if (options.seed !== false) store.seedIfEmpty();
615
+ return new Promise((resolve2, reject) => {
616
+ const server = createServer(async (req, res) => {
617
+ setCors(res);
618
+ if (req.method === "OPTIONS") {
619
+ res.writeHead(204);
620
+ res.end();
621
+ return;
622
+ }
623
+ const url = new URL(req.url ?? "/", `http://${host}:${port}`);
624
+ const p = url.pathname;
625
+ const method = req.method ?? "GET";
626
+ if (method === "GET" && p === "/health") {
627
+ json(res, 200, { status: "ok", uptime: process.uptime(), flagCount: store.size(), connectedClients: sse.clientCount });
628
+ return;
629
+ }
630
+ if (method === "GET" && (p === "/" || p === "/gui")) {
631
+ html(res, renderFlagsGuiHtml(port));
632
+ return;
633
+ }
634
+ if (method === "GET" && (p === "/api/v1/flags/stream" || p === "/flags/stream")) {
635
+ sse.addClient(res);
636
+ return;
637
+ }
638
+ if (method === "GET" && (p === "/api/v1/flags/bootstrap" || p === "/flags/bootstrap")) {
639
+ json(res, 200, store.getBootstrapSnapshot());
640
+ return;
641
+ }
642
+ if (method === "GET" && (p === "/api/v1/flags/eval" || p === "/flags/eval")) {
643
+ const snapshot = {};
644
+ for (const flag of store.getAll()) {
645
+ snapshot[flag.key] = {
646
+ id: flag.key,
647
+ projectId: "dev-project",
648
+ environment: "development",
649
+ key: flag.key,
650
+ name: flag.name,
651
+ description: flag.description,
652
+ isEnabled: flag.enabled,
653
+ rolloutPercentage: flag.rolloutPercentage,
654
+ targetingRules: [],
655
+ variants: flag.variants,
656
+ version: 1,
657
+ createdAt: flag.createdAt,
658
+ updatedAt: flag.updatedAt
659
+ };
660
+ }
661
+ json(res, 200, snapshot);
662
+ return;
663
+ }
664
+ if (method === "GET" && p === "/api/v1/flags") {
665
+ json(res, 200, { flags: store.getAll(), total: store.size() });
666
+ return;
667
+ }
668
+ if (method === "POST" && p === "/api/v1/flags") {
669
+ try {
670
+ const body = await parseBody(req);
671
+ const key = String(body.key ?? "").trim();
672
+ if (!key) {
673
+ problem(res, 400, "Bad Request", '"key" is required.');
674
+ return;
675
+ }
676
+ const flag = store.create({
677
+ key,
678
+ name: body.name ? String(body.name) : void 0,
679
+ description: body.description ? String(body.description) : void 0,
680
+ enabled: Boolean(body.enabled ?? false),
681
+ rolloutPercentage: typeof body.rolloutPercentage === "number" ? body.rolloutPercentage : 100,
682
+ variants: body.variants && typeof body.variants === "object" ? body.variants : {}
683
+ });
684
+ sse.broadcastFlagUpdate(flag.key, flag.enabled, flag.variants);
685
+ json(res, 201, { flag });
686
+ } catch (err) {
687
+ problem(res, 409, "Conflict", err instanceof Error ? err.message : "Unknown error");
688
+ }
689
+ return;
690
+ }
691
+ const flagKeyMatch = p.match(/^\/api\/v1\/flags\/([^/]+)$/);
692
+ if (method === "GET" && flagKeyMatch) {
693
+ const key = decodeURIComponent(flagKeyMatch[1] ?? "");
694
+ const flag = store.getByKey(key);
695
+ if (!flag) {
696
+ problem(res, 404, "Not Found", `Flag "${key}" does not exist.`);
697
+ return;
698
+ }
699
+ json(res, 200, { flag });
700
+ return;
701
+ }
702
+ if (method === "PATCH" && flagKeyMatch) {
703
+ const key = decodeURIComponent(flagKeyMatch[1] ?? "");
704
+ try {
705
+ const body = await parseBody(req);
706
+ const flag = store.update(key, {
707
+ name: body.name ? String(body.name) : void 0,
708
+ description: body.description !== void 0 ? String(body.description) : void 0,
709
+ enabled: body.enabled !== void 0 ? Boolean(body.enabled) : void 0,
710
+ rolloutPercentage: typeof body.rolloutPercentage === "number" ? body.rolloutPercentage : void 0,
711
+ variants: body.variants && typeof body.variants === "object" ? body.variants : void 0
712
+ });
713
+ sse.broadcastFlagUpdate(flag.key, flag.enabled, flag.variants);
714
+ json(res, 200, { flag });
715
+ } catch (err) {
716
+ problem(res, 404, "Not Found", err instanceof Error ? err.message : "Not found");
717
+ }
718
+ return;
719
+ }
720
+ if (method === "DELETE" && flagKeyMatch) {
721
+ const key = decodeURIComponent(flagKeyMatch[1] ?? "");
722
+ const deleted = store.delete(key);
723
+ if (!deleted) {
724
+ problem(res, 404, "Not Found", `Flag "${key}" does not exist.`);
725
+ return;
726
+ }
727
+ sse.broadcastFlagDelete(key);
728
+ json(res, 200, { success: true, key });
729
+ return;
730
+ }
731
+ const toggleMatch = p.match(/^\/api\/v1\/flags\/([^/]+)\/toggle$/);
732
+ if (method === "POST" && toggleMatch) {
733
+ const key = decodeURIComponent(toggleMatch[1] ?? "");
734
+ try {
735
+ const flag = store.toggle(key);
736
+ sse.broadcastFlagUpdate(flag.key, flag.enabled, flag.variants);
737
+ json(res, 200, { flag });
738
+ } catch (err) {
739
+ problem(res, 404, "Not Found", err instanceof Error ? err.message : "Not found");
740
+ }
741
+ return;
742
+ }
743
+ problem(res, 404, "Not Found", `No handler for ${method} ${p}`);
744
+ });
745
+ server.on("error", (err) => reject(err));
746
+ server.listen(port, host, () => {
747
+ const instance = {
748
+ server,
749
+ port,
750
+ host,
751
+ store,
752
+ sse,
753
+ close: () => new Promise((done) => {
754
+ sse.destroy();
755
+ server.close(() => done());
756
+ })
757
+ };
758
+ resolve2(instance);
759
+ });
760
+ });
761
+ }
762
+
763
+ export {
764
+ DevFlagStore,
765
+ FlagSseBroadcaster,
766
+ startFlagsDevServer
767
+ };