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.
- package/.claude/settings.local.json +45 -0
- package/CONTEXT.md +53 -0
- package/README.md +223 -0
- package/dist/cli.js +32577 -0
- package/dist/index.d.mts +232 -0
- package/dist/index.d.ts +232 -0
- package/dist/index.mjs +430 -0
- package/dist/package.json +62 -0
- package/dist/server/entry.d.mts +94 -0
- package/dist/server/entry.d.ts +94 -0
- package/dist/server/entry.js +573 -0
- package/dist/server/entry.mjs +533 -0
- package/dist/server-V7WCW4ZB.mjs +530 -0
- package/dist/storage/jsonl-entry.d.mts +42 -0
- package/dist/storage/jsonl-entry.d.ts +42 -0
- package/dist/storage/jsonl-entry.js +112 -0
- package/dist/storage/jsonl-entry.mjs +74 -0
- package/dist/storage/mongodb-entry.d.mts +40 -0
- package/dist/storage/mongodb-entry.d.ts +40 -0
- package/dist/storage/mongodb-entry.js +114 -0
- package/dist/storage/mongodb-entry.mjs +86 -0
- package/docs/BUG_DIAGNOSIS_REMOTE_STORAGE_OPTIONS.md +104 -0
- package/docs/BUG_FIX_REMOTE_STORAGE_OPTIONS.md +63 -0
- package/docs/BUG_FIX_SESSION_2026-06-03.md +171 -0
- package/docs/IMPLEMENTATION.md +333 -0
- package/docs/PLAN.md +153 -0
- package/docs/REMOTE_STORAGE_CONFIG.md +125 -0
- package/docs/SIDECAR_SERVER.md +184 -0
- package/package.json +62 -0
- package/scripts/adapt-dist-package.js +33 -0
- package/src/atomCreator.ts +76 -0
- package/src/createStore.ts +77 -0
- package/src/debouncer.ts +84 -0
- package/src/index.ts +35 -0
- package/src/server/cli.ts +62 -0
- package/src/server/entityIdCache.ts +57 -0
- package/src/server/entry.ts +12 -0
- package/src/server/handlers.ts +271 -0
- package/src/server/index.ts +182 -0
- package/src/server/router.ts +74 -0
- package/src/server.ts +5 -0
- package/src/storage/adapters/remoteStorage.ts +70 -0
- package/src/storage/base.ts +28 -0
- package/src/storage/index.ts +9 -0
- package/src/storage/jsonl-entry.ts +9 -0
- package/src/storage/jsonl.ts +111 -0
- package/src/storage/memory.ts +49 -0
- package/src/storage/mongodb-entry.ts +9 -0
- package/src/storage/mongodb.ts +132 -0
- package/src/storage/protocol.ts +170 -0
- package/src/storage/transport.ts +95 -0
- package/src/types.ts +76 -0
- package/src/useStore.ts +83 -0
- package/tests/atomCreator.test.ts +153 -0
- package/tests/createStore.test.ts +126 -0
- package/tests/debouncer.test.ts +125 -0
- package/tests/server.test.ts +158 -0
- package/tests/storage/jsonl.test.ts +132 -0
- package/tests/storage/memory.test.ts +101 -0
- package/tests/storage/mongodb.test.ts +40 -0
- package/tests/storage/remoteStorage.test.ts +126 -0
- package/tests/useStore.test.tsx +147 -0
- package/tsconfig.json +18 -0
- package/tsup.config.ts +53 -0
- 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
|
+
}
|