bff-store 0.1.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.
Files changed (65) hide show
  1. package/.claude/settings.local.json +45 -0
  2. package/CONTEXT.md +53 -0
  3. package/README.md +223 -0
  4. package/dist/cli.js +32577 -0
  5. package/dist/index.d.mts +232 -0
  6. package/dist/index.d.ts +232 -0
  7. package/dist/index.mjs +430 -0
  8. package/dist/package.json +62 -0
  9. package/dist/server/entry.d.mts +94 -0
  10. package/dist/server/entry.d.ts +94 -0
  11. package/dist/server/entry.js +573 -0
  12. package/dist/server/entry.mjs +533 -0
  13. package/dist/server-V7WCW4ZB.mjs +530 -0
  14. package/dist/storage/jsonl-entry.d.mts +42 -0
  15. package/dist/storage/jsonl-entry.d.ts +42 -0
  16. package/dist/storage/jsonl-entry.js +112 -0
  17. package/dist/storage/jsonl-entry.mjs +74 -0
  18. package/dist/storage/mongodb-entry.d.mts +40 -0
  19. package/dist/storage/mongodb-entry.d.ts +40 -0
  20. package/dist/storage/mongodb-entry.js +114 -0
  21. package/dist/storage/mongodb-entry.mjs +86 -0
  22. package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
  23. package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
  24. package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
  25. package/docs/IMPLEMENTATION.md +333 -0
  26. package/docs/PLAN.md +153 -0
  27. package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
  28. package/docs/SIDECAR_SERVER.md +184 -0
  29. package/package.json +62 -0
  30. package/scripts/adapt-dist-package.js +33 -0
  31. package/src/atomCreator.ts +76 -0
  32. package/src/createStore.ts +77 -0
  33. package/src/debouncer.ts +84 -0
  34. package/src/index.ts +35 -0
  35. package/src/server/cli.ts +62 -0
  36. package/src/server/entityIdCache.ts +57 -0
  37. package/src/server/entry.ts +12 -0
  38. package/src/server/handlers.ts +271 -0
  39. package/src/server/index.ts +182 -0
  40. package/src/server/router.ts +74 -0
  41. package/src/server.ts +5 -0
  42. package/src/storage/adapters/remoteStorage.ts +70 -0
  43. package/src/storage/base.ts +28 -0
  44. package/src/storage/index.ts +9 -0
  45. package/src/storage/jsonl-entry.ts +9 -0
  46. package/src/storage/jsonl.ts +111 -0
  47. package/src/storage/memory.ts +49 -0
  48. package/src/storage/mongodb-entry.ts +9 -0
  49. package/src/storage/mongodb.ts +132 -0
  50. package/src/storage/protocol.ts +170 -0
  51. package/src/storage/transport.ts +95 -0
  52. package/src/types.ts +76 -0
  53. package/src/useStore.ts +83 -0
  54. package/tests/atomCreator.test.ts +153 -0
  55. package/tests/createStore.test.ts +126 -0
  56. package/tests/debouncer.test.ts +125 -0
  57. package/tests/server.test.ts +158 -0
  58. package/tests/storage/jsonl.test.ts +132 -0
  59. package/tests/storage/memory.test.ts +101 -0
  60. package/tests/storage/mongodb.test.ts +40 -0
  61. package/tests/storage/remoteStorage.test.ts +126 -0
  62. package/tests/useStore.test.tsx +147 -0
  63. package/tsconfig.json +18 -0
  64. package/tsup.config.ts +53 -0
  65. package/vitest.config.ts +14 -0
@@ -0,0 +1,533 @@
1
+ // src/server/index.ts
2
+ import * as http from "http";
3
+
4
+ // src/storage/mongodb.ts
5
+ import { MongoClient } from "mongodb";
6
+ async function mongodbStorage(options) {
7
+ const {
8
+ url,
9
+ database = "jotai_state_store"
10
+ } = options;
11
+ const client = new MongoClient(url);
12
+ await client.connect();
13
+ const db = client.db(database);
14
+ let currentEntityId = "default";
15
+ function getCollectionName(eId) {
16
+ return `state_${eId}`;
17
+ }
18
+ function getCollection(eId) {
19
+ return db.collection(getCollectionName(eId));
20
+ }
21
+ const storage = {
22
+ async get(key) {
23
+ const collection = getCollection(currentEntityId);
24
+ const entry = await collection.findOne(
25
+ { key },
26
+ { sort: { timestamp: -1 } }
27
+ );
28
+ return entry ? entry.value : null;
29
+ },
30
+ async set(key, value) {
31
+ const collection = getCollection(currentEntityId);
32
+ await collection.insertOne({
33
+ key,
34
+ value,
35
+ timestamp: Date.now(),
36
+ entityId: currentEntityId
37
+ });
38
+ },
39
+ async remove(key) {
40
+ const collection = getCollection(currentEntityId);
41
+ await collection.deleteMany({ key });
42
+ },
43
+ async getMultiple(keys) {
44
+ const collection = getCollection(currentEntityId);
45
+ const entries = await collection.find({ key: { $in: keys } }).sort({ timestamp: -1 }).toArray();
46
+ const result = /* @__PURE__ */ new Map();
47
+ const seen = /* @__PURE__ */ new Set();
48
+ for (const entry of entries) {
49
+ if (!seen.has(entry.key)) {
50
+ result.set(entry.key, entry.value);
51
+ seen.add(entry.key);
52
+ }
53
+ }
54
+ return result;
55
+ },
56
+ async setMultiple(entries) {
57
+ const collection = getCollection(currentEntityId);
58
+ const docs = [];
59
+ entries.forEach((value, key) => {
60
+ docs.push({
61
+ key,
62
+ value,
63
+ timestamp: Date.now(),
64
+ entityId: currentEntityId
65
+ });
66
+ });
67
+ if (docs.length > 0) {
68
+ await collection.insertMany(docs);
69
+ }
70
+ }
71
+ };
72
+ const adapter = {
73
+ storage,
74
+ name: "mongodb",
75
+ client,
76
+ setEntityId(id) {
77
+ currentEntityId = id;
78
+ },
79
+ async close() {
80
+ await client.close();
81
+ }
82
+ };
83
+ return adapter;
84
+ }
85
+
86
+ // src/server/router.ts
87
+ var Router = class {
88
+ constructor() {
89
+ this.routes = [];
90
+ }
91
+ addRoute(method, path2, handler) {
92
+ const paramNames = [];
93
+ const regexPattern = path2.replace(/:([^/]+)/g, (_, name) => {
94
+ paramNames.push(name);
95
+ return "([^/]+)";
96
+ });
97
+ const pattern = new RegExp(`^${regexPattern}$`);
98
+ this.routes.push({
99
+ method: method.toUpperCase(),
100
+ pattern,
101
+ paramNames,
102
+ handler: (req, res, params) => handler(req, res, params)
103
+ });
104
+ }
105
+ get(path2, handler) {
106
+ this.addRoute("GET", path2, handler);
107
+ }
108
+ post(path2, handler) {
109
+ this.addRoute("POST", path2, handler);
110
+ }
111
+ delete(path2, handler) {
112
+ this.addRoute("DELETE", path2, handler);
113
+ }
114
+ async handle(req, res) {
115
+ const method = req.method?.toUpperCase() ?? "GET";
116
+ const url = new URL(req.url ?? "/", "http://localhost");
117
+ for (const route of this.routes) {
118
+ if (route.method !== method) continue;
119
+ const match = url.pathname.match(route.pattern);
120
+ if (match) {
121
+ const params = {};
122
+ route.paramNames.forEach((name, index) => {
123
+ params[name] = decodeURIComponent(match[index + 1]);
124
+ });
125
+ await route.handler(req, res, params);
126
+ return true;
127
+ }
128
+ }
129
+ return false;
130
+ }
131
+ };
132
+
133
+ // src/storage/jsonl.ts
134
+ import * as fs from "fs";
135
+ import * as path from "path";
136
+ function jsonlStorage(options) {
137
+ const baseDir = options?.dir ?? "./sessions";
138
+ let entityId = "default";
139
+ function getFilePath(eId, key) {
140
+ const safeKey = key.replace(/[^a-zA-Z0-9_]/g, "_");
141
+ return path.join(baseDir, eId, `${safeKey}.jsonl`);
142
+ }
143
+ const storage = {
144
+ async get(key) {
145
+ if (!entityId) return null;
146
+ const filePath = getFilePath(entityId, key);
147
+ if (!fs.existsSync(filePath)) {
148
+ return null;
149
+ }
150
+ try {
151
+ const content = fs.readFileSync(filePath, "utf-8");
152
+ const lines = content.trim().split("\n").filter(Boolean);
153
+ if (lines.length === 0) return null;
154
+ const lastLine = lines[lines.length - 1];
155
+ const entry = JSON.parse(lastLine);
156
+ return entry.value;
157
+ } catch {
158
+ return null;
159
+ }
160
+ },
161
+ async set(key, value) {
162
+ if (!entityId) return;
163
+ const filePath = getFilePath(entityId, key);
164
+ const dir = path.dirname(filePath);
165
+ if (!fs.existsSync(dir)) {
166
+ fs.mkdirSync(dir, { recursive: true });
167
+ }
168
+ const entry = {
169
+ key,
170
+ value,
171
+ timestamp: Date.now()
172
+ };
173
+ const line = JSON.stringify(entry) + "\n";
174
+ fs.appendFileSync(filePath, line, "utf-8");
175
+ },
176
+ async remove(key) {
177
+ if (!entityId) return;
178
+ const filePath = getFilePath(entityId, key);
179
+ if (fs.existsSync(filePath)) {
180
+ fs.unlinkSync(filePath);
181
+ }
182
+ },
183
+ async getMultiple(keys) {
184
+ const result = /* @__PURE__ */ new Map();
185
+ for (const key of keys) {
186
+ const value = await this.get(key);
187
+ if (value !== null) {
188
+ result.set(key, value);
189
+ }
190
+ }
191
+ return result;
192
+ }
193
+ };
194
+ return {
195
+ storage,
196
+ name: "jsonl",
197
+ setEntityId(id) {
198
+ entityId = id;
199
+ }
200
+ };
201
+ }
202
+
203
+ // src/server/entityIdCache.ts
204
+ var EntityIdCache = class {
205
+ constructor(options) {
206
+ this.cache = /* @__PURE__ */ new Map();
207
+ this.dir = options.dir;
208
+ const adapter = jsonlStorage({ dir: options.dir });
209
+ adapter.setEntityId("default");
210
+ this.defaultStorage = adapter.storage;
211
+ }
212
+ getStorage(entityId) {
213
+ if (!entityId || entityId === "default") {
214
+ return this.defaultStorage;
215
+ }
216
+ let storage = this.cache.get(entityId);
217
+ if (storage) {
218
+ return storage;
219
+ }
220
+ const adapter = jsonlStorage({ dir: this.dir });
221
+ adapter.setEntityId(entityId);
222
+ storage = adapter.storage;
223
+ this.cache.set(entityId, storage);
224
+ return storage;
225
+ }
226
+ getEntityIds() {
227
+ return Array.from(this.cache.keys());
228
+ }
229
+ clear() {
230
+ this.cache.clear();
231
+ }
232
+ size() {
233
+ return this.cache.size;
234
+ }
235
+ };
236
+
237
+ // src/server/handlers.ts
238
+ var storageCache = /* @__PURE__ */ new Map();
239
+ var MAX_CACHE_SIZE = 10;
240
+ var CACHE_TTL_MS = 5 * 60 * 1e3;
241
+ function getCacheKey(config) {
242
+ if (config.backend === "mongodb") {
243
+ return `mongodb:${config.mongoUrl}:${config.mongoDb ?? "default"}`;
244
+ }
245
+ if (config.backend === "jsonl") {
246
+ return `jsonl:${config.jsonlDir ?? "./data"}`;
247
+ }
248
+ return "default";
249
+ }
250
+ function getBackendConfig(req) {
251
+ const url = new URL(req.url ?? "/", "http://localhost");
252
+ return {
253
+ backend: url.searchParams.get("backend") ?? void 0,
254
+ mongoUrl: url.searchParams.get("mongoUrl") ?? void 0,
255
+ mongoDb: url.searchParams.get("mongoDb") ?? void 0,
256
+ jsonlDir: url.searchParams.get("jsonlDir") ?? void 0
257
+ };
258
+ }
259
+ function getEntityId(req) {
260
+ const url = new URL(req.url ?? "/", "http://localhost");
261
+ return url.searchParams.get("entityId") ?? void 0;
262
+ }
263
+ async function parseBody(req) {
264
+ let body = "";
265
+ for await (const chunk of req) {
266
+ body += chunk;
267
+ }
268
+ return JSON.parse(body);
269
+ }
270
+ function cleanExpiredCache() {
271
+ const now = Date.now();
272
+ for (const [key, entry] of storageCache.entries()) {
273
+ if (now - entry.lastUsed > CACHE_TTL_MS) {
274
+ storageCache.delete(key);
275
+ }
276
+ }
277
+ }
278
+ async function getCachedStorage(config, entityId) {
279
+ const key = getCacheKey(config);
280
+ if (storageCache.has(key)) {
281
+ const entry = storageCache.get(key);
282
+ entry.lastUsed = Date.now();
283
+ if (entityId && "setEntityId" in entry.adapter && typeof entry.adapter.setEntityId === "function") {
284
+ entry.adapter.setEntityId(entityId);
285
+ }
286
+ return entry.adapter.storage;
287
+ }
288
+ if (storageCache.size >= MAX_CACHE_SIZE) {
289
+ let oldestKey = null;
290
+ let oldestTime = Infinity;
291
+ for (const [k, entry] of storageCache.entries()) {
292
+ if (entry.lastUsed < oldestTime) {
293
+ oldestTime = entry.lastUsed;
294
+ oldestKey = k;
295
+ }
296
+ }
297
+ if (oldestKey) {
298
+ storageCache.delete(oldestKey);
299
+ }
300
+ }
301
+ let adapter;
302
+ if (config.backend === "mongodb") {
303
+ if (!config.mongoUrl) {
304
+ throw new Error("mongoUrl is required for mongodb backend");
305
+ }
306
+ adapter = await mongodbStorage({
307
+ url: config.mongoUrl,
308
+ database: config.mongoDb ?? "jotai_state_store"
309
+ });
310
+ } else if (config.backend === "jsonl") {
311
+ adapter = jsonlStorage({ dir: config.jsonlDir ?? "./data" });
312
+ } else {
313
+ throw new Error("Unknown backend type");
314
+ }
315
+ if (entityId && "setEntityId" in adapter && typeof adapter.setEntityId === "function") {
316
+ adapter.setEntityId(entityId);
317
+ }
318
+ storageCache.set(key, { adapter, lastUsed: Date.now() });
319
+ return adapter.storage;
320
+ }
321
+ function createStorageHandlers(options) {
322
+ const { getStorage } = options;
323
+ async function resolveStorage(req) {
324
+ const entityId = getEntityId(req);
325
+ const urlConfig = getBackendConfig(req);
326
+ let body = null;
327
+ if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") {
328
+ try {
329
+ body = await parseBody(req);
330
+ } catch {
331
+ }
332
+ }
333
+ const config = body ? {
334
+ backend: body.backend ?? urlConfig.backend,
335
+ mongoUrl: body.mongoUrl ?? urlConfig.mongoUrl,
336
+ mongoDb: body.mongoDb ?? urlConfig.mongoDb,
337
+ jsonlDir: body.jsonlDir ?? urlConfig.jsonlDir
338
+ } : urlConfig;
339
+ if (config.backend) {
340
+ cleanExpiredCache();
341
+ const storage = await getCachedStorage(config, entityId);
342
+ return { storage, body };
343
+ }
344
+ return { storage: getStorage(entityId), body };
345
+ }
346
+ async function handleGet(req, res, params) {
347
+ const { storage } = await resolveStorage(req);
348
+ const value = await storage.get(params?.key ?? "");
349
+ res.setHeader("Content-Type", "application/json");
350
+ res.writeHead(200);
351
+ res.end(JSON.stringify({ value }));
352
+ }
353
+ async function handleSet(req, res, params) {
354
+ const { storage, body } = await resolveStorage(req);
355
+ const value = body?.value;
356
+ await storage.set(params?.key ?? "", value);
357
+ res.setHeader("Content-Type", "application/json");
358
+ res.writeHead(200);
359
+ res.end(JSON.stringify({ success: true }));
360
+ }
361
+ async function handleDelete(req, res, params) {
362
+ const { storage } = await resolveStorage(req);
363
+ await storage.remove(params?.key ?? "");
364
+ res.setHeader("Content-Type", "application/json");
365
+ res.writeHead(200);
366
+ res.end(JSON.stringify({ success: true }));
367
+ }
368
+ async function handleBatchGet(req, res) {
369
+ const { storage, body } = await resolveStorage(req);
370
+ const keys = body?.keys ?? [];
371
+ let result;
372
+ if (storage.getMultiple) {
373
+ result = await storage.getMultiple(keys);
374
+ } else {
375
+ result = /* @__PURE__ */ new Map();
376
+ for (const key of keys) {
377
+ const value = await storage.get(key);
378
+ if (value !== null) {
379
+ result.set(key, value);
380
+ }
381
+ }
382
+ }
383
+ const entries = {};
384
+ result.forEach((value, key) => {
385
+ entries[key] = value;
386
+ });
387
+ res.setHeader("Content-Type", "application/json");
388
+ res.writeHead(200);
389
+ res.end(JSON.stringify({ entries }));
390
+ }
391
+ async function handleBatchSet(req, res) {
392
+ const { storage, body } = await resolveStorage(req);
393
+ const entries = body?.entries ?? {};
394
+ if (storage.setMultiple) {
395
+ const map = new Map(Object.entries(entries));
396
+ await storage.setMultiple(map);
397
+ } else {
398
+ for (const [key, value] of Object.entries(entries)) {
399
+ await storage.set(key, value);
400
+ }
401
+ }
402
+ res.setHeader("Content-Type", "application/json");
403
+ res.writeHead(200);
404
+ res.end(JSON.stringify({ success: true }));
405
+ }
406
+ async function handleHealth(req, res) {
407
+ res.setHeader("Content-Type", "application/json");
408
+ res.writeHead(200);
409
+ res.end(JSON.stringify({ status: "ok" }));
410
+ }
411
+ return {
412
+ handleGet,
413
+ handleSet,
414
+ handleDelete,
415
+ handleBatchGet,
416
+ handleBatchSet,
417
+ handleHealth
418
+ };
419
+ }
420
+
421
+ // src/server/index.ts
422
+ var serverInstance = null;
423
+ var serverPromise = null;
424
+ var defaultOptions = {
425
+ backend: "jsonl",
426
+ port: 3847,
427
+ host: "localhost",
428
+ jsonlDir: "./data"
429
+ };
430
+ async function startServer(options) {
431
+ if (serverInstance && serverInstance.listening) {
432
+ return serverInstance;
433
+ }
434
+ if (serverPromise) {
435
+ return serverPromise;
436
+ }
437
+ if (serverInstance) {
438
+ serverInstance = null;
439
+ }
440
+ const opts = {
441
+ backend: options?.mongoUrl ? "mongodb" : options?.backend ?? defaultOptions.backend,
442
+ port: options?.port ?? defaultOptions.port,
443
+ host: options?.host ?? defaultOptions.host,
444
+ jsonlDir: options?.jsonlDir ?? defaultOptions.jsonlDir,
445
+ mongoUrl: options?.mongoUrl,
446
+ mongoDb: options?.mongoDb
447
+ };
448
+ serverPromise = _startServer(opts);
449
+ try {
450
+ serverInstance = await serverPromise;
451
+ return serverInstance;
452
+ } finally {
453
+ serverPromise = null;
454
+ }
455
+ }
456
+ async function _startServer(options) {
457
+ const port = options.port ?? 3847;
458
+ const host = options.host ?? "localhost";
459
+ let storage;
460
+ let entityIdCache;
461
+ if (options.backend === "jsonl") {
462
+ entityIdCache = new EntityIdCache({ dir: options.jsonlDir ?? "./data" });
463
+ storage = entityIdCache.getStorage();
464
+ } else if (options.backend === "mongodb") {
465
+ const adapter = await mongodbStorage({
466
+ url: options.mongoUrl,
467
+ database: options.mongoDb ?? "jotai_state_store"
468
+ });
469
+ storage = adapter.storage;
470
+ } else {
471
+ throw new Error(`Unsupported backend: ${options.backend}`);
472
+ }
473
+ const handlers = createStorageHandlers({
474
+ getStorage: (entityId) => entityIdCache ? entityIdCache.getStorage(entityId) : storage
475
+ });
476
+ const router = new Router();
477
+ router.get("/storage/get/:key", handlers.handleGet);
478
+ router.post("/storage/set/:key", handlers.handleSet);
479
+ router.delete("/storage/delete/:key", handlers.handleDelete);
480
+ router.post("/storage/batch-get", handlers.handleBatchGet);
481
+ router.post("/storage/batch-set", handlers.handleBatchSet);
482
+ router.get("/health", handlers.handleHealth);
483
+ const server = http.createServer(async (req, res) => {
484
+ res.setHeader("Access-Control-Allow-Origin", "*");
485
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
486
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
487
+ res.setHeader("Content-Type", "application/json");
488
+ if (req.method === "OPTIONS") {
489
+ res.writeHead(204);
490
+ res.end();
491
+ return;
492
+ }
493
+ try {
494
+ const matched = await router.handle(req, res);
495
+ if (!matched) {
496
+ res.writeHead(404);
497
+ res.end(JSON.stringify({ error: "Not found" }));
498
+ }
499
+ } catch (err) {
500
+ console.error("[bff-store] Error:", err);
501
+ res.writeHead(500);
502
+ res.end(JSON.stringify({ error: String(err) }));
503
+ }
504
+ });
505
+ return new Promise((resolve, reject) => {
506
+ server.on("error", reject);
507
+ server.listen(port, host, () => {
508
+ console.log(`[bff-store] Server running on http://${host}:${port}`);
509
+ console.log(`[bff-store] Backend: ${options.backend}`);
510
+ resolve(server);
511
+ });
512
+ const shutdown = (signal) => {
513
+ console.log(`
514
+ [bff-store] Received ${signal}, shutting down...`);
515
+ server.close(() => {
516
+ console.log("[bff-store] Server closed");
517
+ process.exit(0);
518
+ });
519
+ setTimeout(() => {
520
+ console.error("[bff-store] Forced shutdown");
521
+ process.exit(1);
522
+ }, 5e3);
523
+ };
524
+ process.on("SIGINT", () => shutdown("SIGINT"));
525
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
526
+ });
527
+ }
528
+ export {
529
+ EntityIdCache,
530
+ Router,
531
+ createStorageHandlers,
532
+ startServer
533
+ };