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,62 @@
1
+ /**
2
+ * CLI entry point for the embedded server.
3
+ * This file should only be included in the CJS server bundle, NOT in the main library bundle.
4
+ */
5
+ import { startServer } from './index';
6
+ import type { ServerOptions } from './index';
7
+
8
+ const args = process.argv.slice(2);
9
+ const options: ServerOptions = {
10
+ backend: 'jsonl',
11
+ port: 3847,
12
+ host: 'localhost',
13
+ jsonlDir: './data',
14
+ };
15
+
16
+ for (let i = 0; i < args.length; i++) {
17
+ const arg = args[i];
18
+ if (arg === '--backend' && args[i + 1]) {
19
+ options.backend = args[++i] as 'jsonl' | 'mongodb';
20
+ } else if (arg === '--port' && args[i + 1]) {
21
+ options.port = parseInt(args[++i], 10);
22
+ } else if (arg === '--host' && args[i + 1]) {
23
+ options.host = args[++i];
24
+ } else if (arg === '--jsonl-dir' && args[i + 1]) {
25
+ options.jsonlDir = args[++i];
26
+ } else if (arg === '--mongo-url' && args[i + 1]) {
27
+ options.mongoUrl = args[++i];
28
+ } else if (arg === '--mongo-db' && args[i + 1]) {
29
+ options.mongoDb = args[++i];
30
+ } else if (arg === '--help') {
31
+ console.log(`
32
+ [bff-store] Embedded Server
33
+
34
+ Usage:
35
+ bff-store-server [options]
36
+
37
+ Options:
38
+ --backend <jsonl|mongodb> Storage backend (default: jsonl)
39
+ --port <port> Server port (default: 3847)
40
+ --host <host> Server host (default: localhost)
41
+ --jsonl-dir <dir> JSONL directory (default: ./data)
42
+ --mongo-url <url> MongoDB URL (required for mongodb backend)
43
+ --mongo-db <db> MongoDB database (default: jotai_state_store)
44
+ --help Show this help
45
+
46
+ Examples:
47
+ bff-store-server --backend jsonl --jsonl-dir ./data
48
+ bff-store-server --backend mongodb --mongo-url mongodb://localhost:27017
49
+ `);
50
+ process.exit(0);
51
+ }
52
+ }
53
+
54
+ if (options.backend === 'mongodb' && !options.mongoUrl) {
55
+ console.error('[bff-store] Error: --mongo-url required for mongodb backend');
56
+ process.exit(1);
57
+ }
58
+
59
+ startServer(options).catch((err) => {
60
+ console.error('[bff-store] Failed to start:', err);
61
+ process.exit(1);
62
+ });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * EntityId Cache for Multi-Tenant Storage
3
+ *
4
+ * Caches storage instances per entityId to avoid recreating them.
5
+ */
6
+
7
+ import type { Storage } from '../storage/base';
8
+ import { jsonlStorage } from '../storage/jsonl';
9
+
10
+ export interface EntityIdCacheOptions {
11
+ dir: string;
12
+ }
13
+
14
+ export class EntityIdCache {
15
+ private cache = new Map<string, Storage>();
16
+ private defaultStorage: Storage;
17
+ private dir: string;
18
+
19
+ constructor(options: EntityIdCacheOptions) {
20
+ this.dir = options.dir;
21
+ // Create default storage
22
+ const adapter = jsonlStorage({ dir: options.dir });
23
+ adapter.setEntityId('default');
24
+ this.defaultStorage = adapter.storage;
25
+ }
26
+
27
+ getStorage(entityId?: string): Storage {
28
+ if (!entityId || entityId === 'default') {
29
+ return this.defaultStorage;
30
+ }
31
+
32
+ let storage = this.cache.get(entityId);
33
+ if (storage) {
34
+ return storage;
35
+ }
36
+
37
+ // Create new storage for this entityId
38
+ const adapter = jsonlStorage({ dir: this.dir });
39
+ adapter.setEntityId(entityId);
40
+ storage = adapter.storage;
41
+ this.cache.set(entityId, storage);
42
+
43
+ return storage;
44
+ }
45
+
46
+ getEntityIds(): string[] {
47
+ return Array.from(this.cache.keys());
48
+ }
49
+
50
+ clear(): void {
51
+ this.cache.clear();
52
+ }
53
+
54
+ size(): number {
55
+ return this.cache.size;
56
+ }
57
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Embedded Server entry point
3
+ * Import from 'bff-store/server' instead of 'bff-store'
4
+ *
5
+ * @example
6
+ * import { startServer } from 'bff-store/server';
7
+ */
8
+ export { startServer } from './index';
9
+ export type { ServerOptions } from './index';
10
+ export { Router } from './router';
11
+ export { EntityIdCache } from './entityIdCache';
12
+ export { createStorageHandlers } from './handlers';
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Storage Handlers
3
+ *
4
+ * Request handlers for storage operations with caching for dynamic storage adapters.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'http';
8
+ import type { Storage, StorageAdapter } from '../storage/base';
9
+ import type { BackendConfig } from '../types';
10
+ import { jsonlStorage } from '../storage/jsonl';
11
+ import { mongodbStorage } from '../storage/mongodb';
12
+
13
+ export interface StorageHandlersOptions {
14
+ getStorage: (entityId?: string) => Storage;
15
+ }
16
+
17
+ interface CacheEntry {
18
+ adapter: StorageAdapter;
19
+ lastUsed: number;
20
+ }
21
+
22
+ // Storage adapter cache - keyed by backend+mongoUrl or backend+jsonlDir
23
+ const storageCache = new Map<string, CacheEntry>();
24
+ const MAX_CACHE_SIZE = 10;
25
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
26
+
27
+ function getCacheKey(config: BackendConfig): string {
28
+ if (config.backend === 'mongodb') {
29
+ return `mongodb:${config.mongoUrl}:${config.mongoDb ?? 'default'}`;
30
+ }
31
+ if (config.backend === 'jsonl') {
32
+ return `jsonl:${config.jsonlDir ?? './data'}`;
33
+ }
34
+ return 'default';
35
+ }
36
+
37
+ function getBackendConfig(req: IncomingMessage): BackendConfig {
38
+ const url = new URL(req.url ?? '/', 'http://localhost');
39
+ return {
40
+ backend: (url.searchParams.get('backend') as 'mongodb' | 'jsonl') ?? undefined,
41
+ mongoUrl: url.searchParams.get('mongoUrl') ?? undefined,
42
+ mongoDb: url.searchParams.get('mongoDb') ?? undefined,
43
+ jsonlDir: url.searchParams.get('jsonlDir') ?? undefined,
44
+ };
45
+ }
46
+
47
+ function getEntityId(req: IncomingMessage): string | undefined {
48
+ const url = new URL(req.url ?? '/', 'http://localhost');
49
+ return url.searchParams.get('entityId') ?? undefined;
50
+ }
51
+
52
+ async function parseBody<T>(req: IncomingMessage): Promise<T> {
53
+ let body = '';
54
+ for await (const chunk of req) {
55
+ body += chunk;
56
+ }
57
+ return JSON.parse(body);
58
+ }
59
+
60
+ function cleanExpiredCache(): void {
61
+ const now = Date.now();
62
+ for (const [key, entry] of storageCache.entries()) {
63
+ if (now - entry.lastUsed > CACHE_TTL_MS) {
64
+ storageCache.delete(key);
65
+ }
66
+ }
67
+ }
68
+
69
+ async function getCachedStorage(config: BackendConfig, entityId?: string): Promise<Storage> {
70
+ const key = getCacheKey(config);
71
+
72
+ // Check cache
73
+ if (storageCache.has(key)) {
74
+ const entry = storageCache.get(key)!;
75
+ entry.lastUsed = Date.now();
76
+
77
+ // Set entityId if provided
78
+ if (entityId && 'setEntityId' in entry.adapter && typeof entry.adapter.setEntityId === 'function') {
79
+ entry.adapter.setEntityId(entityId);
80
+ }
81
+ return entry.adapter.storage;
82
+ }
83
+
84
+ // Evict oldest if cache is full
85
+ if (storageCache.size >= MAX_CACHE_SIZE) {
86
+ let oldestKey: string | null = null;
87
+ let oldestTime = Infinity;
88
+ for (const [k, entry] of storageCache.entries()) {
89
+ if (entry.lastUsed < oldestTime) {
90
+ oldestTime = entry.lastUsed;
91
+ oldestKey = k;
92
+ }
93
+ }
94
+ if (oldestKey) {
95
+ storageCache.delete(oldestKey);
96
+ }
97
+ }
98
+
99
+ // Create new adapter
100
+ let adapter: StorageAdapter;
101
+ if (config.backend === 'mongodb') {
102
+ if (!config.mongoUrl) {
103
+ throw new Error('mongoUrl is required for mongodb backend');
104
+ }
105
+ adapter = await mongodbStorage({
106
+ url: config.mongoUrl,
107
+ database: config.mongoDb ?? 'jotai_state_store',
108
+ });
109
+ } else if (config.backend === 'jsonl') {
110
+ adapter = jsonlStorage({ dir: config.jsonlDir ?? './data' });
111
+ } else {
112
+ throw new Error('Unknown backend type');
113
+ }
114
+
115
+ // Set entityId if provided
116
+ if (entityId && 'setEntityId' in adapter && typeof adapter.setEntityId === 'function') {
117
+ adapter.setEntityId(entityId);
118
+ }
119
+
120
+ // Cache it
121
+ storageCache.set(key, { adapter, lastUsed: Date.now() });
122
+
123
+ return adapter.storage;
124
+ }
125
+
126
+ async function getStorageForRequest(
127
+ req: IncomingMessage,
128
+ entityId?: string
129
+ ): Promise<Storage> {
130
+ const urlConfig = getBackendConfig(req);
131
+ const body = await parseBody<BackendConfig>(req);
132
+
133
+ const config: BackendConfig = {
134
+ backend: body.backend ?? urlConfig.backend,
135
+ mongoUrl: body.mongoUrl ?? urlConfig.mongoUrl,
136
+ mongoDb: body.mongoDb ?? urlConfig.mongoDb,
137
+ jsonlDir: body.jsonlDir ?? urlConfig.jsonlDir,
138
+ };
139
+
140
+ // Periodic cleanup of expired cache entries
141
+ cleanExpiredCache();
142
+
143
+ return getCachedStorage(config, entityId);
144
+ }
145
+
146
+ export function createStorageHandlers(options: StorageHandlersOptions) {
147
+ const { getStorage } = options;
148
+
149
+ // Resolve storage based on request - parses body only once for POST/PATCH methods
150
+ async function resolveStorage(req: IncomingMessage): Promise<{ storage: Storage; body: Record<string, unknown> | null }> {
151
+ const entityId = getEntityId(req);
152
+ const urlConfig = getBackendConfig(req);
153
+ let body: Record<string, unknown> | null = null;
154
+
155
+ // For POST/PUT/PATCH methods, parse body to get backend config
156
+ if (req.method === 'POST' || req.method === 'PUT' || req.method === 'PATCH') {
157
+ try {
158
+ body = await parseBody<Record<string, unknown>>(req);
159
+ } catch {
160
+ // Ignore parse errors
161
+ }
162
+ }
163
+
164
+ const config: BackendConfig = body
165
+ ? {
166
+ backend: (body.backend as 'mongodb' | 'jsonl') ?? urlConfig.backend,
167
+ mongoUrl: (body.mongoUrl as string) ?? urlConfig.mongoUrl,
168
+ mongoDb: (body.mongoDb as string) ?? urlConfig.mongoDb,
169
+ jsonlDir: (body.jsonlDir as string) ?? urlConfig.jsonlDir,
170
+ }
171
+ : urlConfig;
172
+
173
+ // If backend is specified, use dynamic storage
174
+ if (config.backend) {
175
+ cleanExpiredCache();
176
+ const storage = await getCachedStorage(config, entityId);
177
+ return { storage, body };
178
+ }
179
+
180
+ // Otherwise use default storage
181
+ return { storage: getStorage(entityId), body };
182
+ }
183
+
184
+ async function handleGet(req: IncomingMessage, res: ServerResponse, params?: Record<string, string>): Promise<void> {
185
+ const { storage } = await resolveStorage(req);
186
+ const value = await storage.get(params?.key ?? '');
187
+
188
+ res.setHeader('Content-Type', 'application/json');
189
+ res.writeHead(200);
190
+ res.end(JSON.stringify({ value }));
191
+ }
192
+
193
+ async function handleSet(req: IncomingMessage, res: ServerResponse, params?: Record<string, string>): Promise<void> {
194
+ const { storage, body } = await resolveStorage(req);
195
+ const value = body?.value as unknown;
196
+ await storage.set(params?.key ?? '', value);
197
+
198
+ res.setHeader('Content-Type', 'application/json');
199
+ res.writeHead(200);
200
+ res.end(JSON.stringify({ success: true }));
201
+ }
202
+
203
+ async function handleDelete(req: IncomingMessage, res: ServerResponse, params?: Record<string, string>): Promise<void> {
204
+ const { storage } = await resolveStorage(req);
205
+ await storage.remove(params?.key ?? '');
206
+
207
+ res.setHeader('Content-Type', 'application/json');
208
+ res.writeHead(200);
209
+ res.end(JSON.stringify({ success: true }));
210
+ }
211
+
212
+ async function handleBatchGet(req: IncomingMessage, res: ServerResponse): Promise<void> {
213
+ const { storage, body } = await resolveStorage(req);
214
+ const keys = (body?.keys as string[]) ?? [];
215
+
216
+ let result: Map<string, unknown>;
217
+ if (storage.getMultiple) {
218
+ result = await storage.getMultiple(keys);
219
+ } else {
220
+ result = new Map();
221
+ for (const key of keys) {
222
+ const value = await storage.get(key);
223
+ if (value !== null) {
224
+ result.set(key, value);
225
+ }
226
+ }
227
+ }
228
+
229
+ const entries: Record<string, unknown> = {};
230
+ result.forEach((value, key) => {
231
+ entries[key] = value;
232
+ });
233
+
234
+ res.setHeader('Content-Type', 'application/json');
235
+ res.writeHead(200);
236
+ res.end(JSON.stringify({ entries }));
237
+ }
238
+
239
+ async function handleBatchSet(req: IncomingMessage, res: ServerResponse): Promise<void> {
240
+ const { storage, body } = await resolveStorage(req);
241
+ const entries = (body?.entries as Record<string, unknown>) ?? {};
242
+
243
+ if (storage.setMultiple) {
244
+ const map = new Map(Object.entries(entries));
245
+ await storage.setMultiple(map);
246
+ } else {
247
+ for (const [key, value] of Object.entries(entries)) {
248
+ await storage.set(key, value);
249
+ }
250
+ }
251
+
252
+ res.setHeader('Content-Type', 'application/json');
253
+ res.writeHead(200);
254
+ res.end(JSON.stringify({ success: true }));
255
+ }
256
+
257
+ async function handleHealth(req: IncomingMessage, res: ServerResponse): Promise<void> {
258
+ res.setHeader('Content-Type', 'application/json');
259
+ res.writeHead(200);
260
+ res.end(JSON.stringify({ status: 'ok' }));
261
+ }
262
+
263
+ return {
264
+ handleGet,
265
+ handleSet,
266
+ handleDelete,
267
+ handleBatchGet,
268
+ handleBatchSet,
269
+ handleHealth,
270
+ };
271
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Embedded Sidecar API Server
3
+ *
4
+ * Lightweight HTTP server that proxies storage operations to JSONL or MongoDB.
5
+ * Auto-shuts down when parent process exits.
6
+ *
7
+ * Supports singleton mode: call startServer() multiple times, only one server starts.
8
+ */
9
+
10
+ import * as http from 'http';
11
+ import { mongodbStorage } from '../storage/mongodb';
12
+ import type { Storage } from '../storage/base';
13
+ import { Router } from './router';
14
+ import { EntityIdCache } from './entityIdCache';
15
+ import { createStorageHandlers } from './handlers';
16
+
17
+ export { Router } from './router';
18
+ export { EntityIdCache } from './entityIdCache';
19
+ export { createStorageHandlers } from './handlers';
20
+
21
+ export interface ServerOptions {
22
+ port?: number;
23
+ host?: string;
24
+ backend: 'jsonl' | 'mongodb';
25
+ jsonlDir?: string;
26
+ mongoUrl?: string;
27
+ mongoDb?: string;
28
+ }
29
+
30
+ // Singleton state
31
+ let serverInstance: http.Server | null = null;
32
+ let serverPromise: Promise<http.Server> | null = null;
33
+
34
+ const defaultOptions: ServerOptions = {
35
+ backend: 'jsonl',
36
+ port: 3847,
37
+ host: 'localhost',
38
+ jsonlDir: './data',
39
+ };
40
+
41
+ function isServerRunning(): boolean {
42
+ return serverInstance !== null;
43
+ }
44
+
45
+ /**
46
+ * Start the embedded API server (singleton).
47
+ *
48
+ * First call starts the server. Subsequent calls return the existing instance.
49
+ * If the existing server has been closed externally, a new one will be started.
50
+ * Use options to customize on first call only.
51
+ */
52
+ export async function startServer(options?: Partial<ServerOptions>): Promise<http.Server> {
53
+ // Return existing instance if it's still running
54
+ if (serverInstance && serverInstance.listening) {
55
+ return serverInstance;
56
+ }
57
+
58
+ // If a start is already in progress, wait for it
59
+ if (serverPromise) {
60
+ return serverPromise;
61
+ }
62
+
63
+ // Server was closed externally, reset the reference
64
+ if (serverInstance) {
65
+ serverInstance = null;
66
+ }
67
+
68
+ // Merge options with defaults
69
+ const opts: ServerOptions = {
70
+ backend: options?.mongoUrl ? 'mongodb' : (options?.backend ?? defaultOptions.backend),
71
+ port: options?.port ?? defaultOptions.port,
72
+ host: options?.host ?? defaultOptions.host,
73
+ jsonlDir: options?.jsonlDir ?? defaultOptions.jsonlDir,
74
+ mongoUrl: options?.mongoUrl,
75
+ mongoDb: options?.mongoDb,
76
+ };
77
+
78
+ serverPromise = _startServer(opts);
79
+
80
+ try {
81
+ serverInstance = await serverPromise;
82
+ return serverInstance;
83
+ } finally {
84
+ serverPromise = null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Internal start that does the actual server creation (no singleton check)
90
+ */
91
+ async function _startServer(options: ServerOptions): Promise<http.Server> {
92
+ const port = options.port ?? 3847;
93
+ const host = options.host ?? 'localhost';
94
+
95
+ // Initialize storage backend
96
+ let storage: Storage;
97
+ let entityIdCache: EntityIdCache | undefined;
98
+
99
+ if (options.backend === 'jsonl') {
100
+ // Create entityId cache for JSONL
101
+ entityIdCache = new EntityIdCache({ dir: options.jsonlDir ?? './data' });
102
+ storage = entityIdCache.getStorage();
103
+ } else if (options.backend === 'mongodb') {
104
+ const adapter = await mongodbStorage({
105
+ url: options.mongoUrl!,
106
+ database: options.mongoDb ?? 'jotai_state_store',
107
+ });
108
+ storage = adapter.storage;
109
+ } else {
110
+ throw new Error(`Unsupported backend: ${options.backend}`);
111
+ }
112
+
113
+ // Create storage handlers
114
+ const handlers = createStorageHandlers({
115
+ getStorage: (entityId?: string) => entityIdCache
116
+ ? entityIdCache.getStorage(entityId)
117
+ : storage,
118
+ });
119
+
120
+ // Setup router
121
+ const router = new Router();
122
+ router.get('/storage/get/:key', handlers.handleGet);
123
+ router.post('/storage/set/:key', handlers.handleSet);
124
+ router.delete('/storage/delete/:key', handlers.handleDelete);
125
+ router.post('/storage/batch-get', handlers.handleBatchGet);
126
+ router.post('/storage/batch-set', handlers.handleBatchSet);
127
+ router.get('/health', handlers.handleHealth);
128
+
129
+ const server = http.createServer(async (req, res) => {
130
+ // CORS headers
131
+ res.setHeader('Access-Control-Allow-Origin', '*');
132
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
133
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
134
+ res.setHeader('Content-Type', 'application/json');
135
+
136
+ if (req.method === 'OPTIONS') {
137
+ res.writeHead(204);
138
+ res.end();
139
+ return;
140
+ }
141
+
142
+ try {
143
+ const matched = await router.handle(req, res);
144
+ if (!matched) {
145
+ res.writeHead(404);
146
+ res.end(JSON.stringify({ error: 'Not found' }));
147
+ }
148
+ } catch (err) {
149
+ console.error('[bff-store] Error:', err);
150
+ res.writeHead(500);
151
+ res.end(JSON.stringify({ error: String(err) }));
152
+ }
153
+ });
154
+
155
+ return new Promise<http.Server>((resolve, reject) => {
156
+ server.on('error', reject);
157
+
158
+ server.listen(port, host, () => {
159
+ console.log(`[bff-store] Server running on http://${host}:${port}`);
160
+ console.log(`[bff-store] Backend: ${options.backend}`);
161
+ resolve(server);
162
+ });
163
+
164
+ // Graceful shutdown
165
+ const shutdown = (signal: string) => {
166
+ console.log(`\n[bff-store] Received ${signal}, shutting down...`);
167
+ server.close(() => {
168
+ console.log('[bff-store] Server closed');
169
+ process.exit(0);
170
+ });
171
+
172
+ // Force close after 5s
173
+ setTimeout(() => {
174
+ console.error('[bff-store] Forced shutdown');
175
+ process.exit(1);
176
+ }, 5000);
177
+ };
178
+
179
+ process.on('SIGINT', () => shutdown('SIGINT'));
180
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
181
+ });
182
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * HTTP Router for Storage Server
3
+ *
4
+ * Simple pattern-based router for HTTP requests.
5
+ */
6
+
7
+ import type { IncomingMessage, ServerResponse } from 'http';
8
+
9
+ export type RequestHandler = (req: IncomingMessage, res: ServerResponse, params?: Record<string, string>) => Promise<void>;
10
+
11
+ export interface Route {
12
+ method: string;
13
+ pattern: RegExp;
14
+ paramNames: string[];
15
+ handler: RequestHandler;
16
+ }
17
+
18
+ export class Router {
19
+ private routes: Route[] = [];
20
+
21
+ addRoute(method: string, path: string, handler: RequestHandler): void {
22
+ // Convert path pattern like /storage/get/:key to regex
23
+ // :param becomes a named capture group
24
+ const paramNames: string[] = [];
25
+ const regexPattern = path.replace(/:([^/]+)/g, (_, name) => {
26
+ paramNames.push(name);
27
+ return '([^/]+)';
28
+ });
29
+
30
+ const pattern = new RegExp(`^${regexPattern}$`);
31
+
32
+ this.routes.push({
33
+ method: method.toUpperCase(),
34
+ pattern,
35
+ paramNames,
36
+ handler: (req, res, params) => handler(req, res, params as Record<string, string>),
37
+ });
38
+ }
39
+
40
+ get(path: string, handler: RequestHandler): void {
41
+ this.addRoute('GET', path, handler);
42
+ }
43
+
44
+ post(path: string, handler: RequestHandler): void {
45
+ this.addRoute('POST', path, handler);
46
+ }
47
+
48
+ delete(path: string, handler: RequestHandler): void {
49
+ this.addRoute('DELETE', path, handler);
50
+ }
51
+
52
+ async handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
53
+ const method = req.method?.toUpperCase() ?? 'GET';
54
+ const url = new URL(req.url ?? '/', 'http://localhost');
55
+
56
+ for (const route of this.routes) {
57
+ if (route.method !== method) continue;
58
+
59
+ const match = url.pathname.match(route.pattern);
60
+ if (match) {
61
+ // Extract named params using stored paramNames
62
+ const params: Record<string, string> = {};
63
+ route.paramNames.forEach((name, index) => {
64
+ params[name] = decodeURIComponent(match[index + 1]);
65
+ });
66
+
67
+ await route.handler(req, res, params);
68
+ return true;
69
+ }
70
+ }
71
+
72
+ return false; // No route matched
73
+ }
74
+ }
package/src/server.ts ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @deprecated Use './server/index' instead
3
+ */
4
+ export { startServer } from './server/index';
5
+ export type { ServerOptions } from './server/index';