mcp-http-webhook 1.0.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 (80) hide show
  1. package/.eslintrc.json +16 -0
  2. package/.prettierrc.json +8 -0
  3. package/ARCHITECTURE.md +269 -0
  4. package/CONTRIBUTING.md +136 -0
  5. package/GETTING_STARTED.md +310 -0
  6. package/IMPLEMENTATION.md +294 -0
  7. package/LICENSE +21 -0
  8. package/MIGRATION_TO_SDK.md +263 -0
  9. package/README.md +496 -0
  10. package/SDK_INTEGRATION_COMPLETE.md +300 -0
  11. package/STANDARD_SUBSCRIPTIONS.md +268 -0
  12. package/STANDARD_SUBSCRIPTIONS_COMPLETE.md +309 -0
  13. package/SUMMARY.md +272 -0
  14. package/Spec.md +2778 -0
  15. package/dist/errors/index.d.ts +52 -0
  16. package/dist/errors/index.d.ts.map +1 -0
  17. package/dist/errors/index.js +81 -0
  18. package/dist/errors/index.js.map +1 -0
  19. package/dist/index.d.ts +9 -0
  20. package/dist/index.d.ts.map +1 -0
  21. package/dist/index.js +37 -0
  22. package/dist/index.js.map +1 -0
  23. package/dist/protocol/ProtocolHandler.d.ts +37 -0
  24. package/dist/protocol/ProtocolHandler.d.ts.map +1 -0
  25. package/dist/protocol/ProtocolHandler.js +172 -0
  26. package/dist/protocol/ProtocolHandler.js.map +1 -0
  27. package/dist/server.d.ts +6 -0
  28. package/dist/server.d.ts.map +1 -0
  29. package/dist/server.js +502 -0
  30. package/dist/server.js.map +1 -0
  31. package/dist/stores/InMemoryStore.d.ts +27 -0
  32. package/dist/stores/InMemoryStore.d.ts.map +1 -0
  33. package/dist/stores/InMemoryStore.js +73 -0
  34. package/dist/stores/InMemoryStore.js.map +1 -0
  35. package/dist/stores/RedisStore.d.ts +18 -0
  36. package/dist/stores/RedisStore.d.ts.map +1 -0
  37. package/dist/stores/RedisStore.js +45 -0
  38. package/dist/stores/RedisStore.js.map +1 -0
  39. package/dist/stores/index.d.ts +3 -0
  40. package/dist/stores/index.d.ts.map +1 -0
  41. package/dist/stores/index.js +9 -0
  42. package/dist/stores/index.js.map +1 -0
  43. package/dist/subscriptions/SubscriptionManager.d.ts +49 -0
  44. package/dist/subscriptions/SubscriptionManager.d.ts.map +1 -0
  45. package/dist/subscriptions/SubscriptionManager.js +181 -0
  46. package/dist/subscriptions/SubscriptionManager.js.map +1 -0
  47. package/dist/types/index.d.ts +271 -0
  48. package/dist/types/index.d.ts.map +1 -0
  49. package/dist/types/index.js +16 -0
  50. package/dist/types/index.js.map +1 -0
  51. package/dist/utils/index.d.ts +51 -0
  52. package/dist/utils/index.d.ts.map +1 -0
  53. package/dist/utils/index.js +154 -0
  54. package/dist/utils/index.js.map +1 -0
  55. package/dist/webhooks/WebhookManager.d.ts +27 -0
  56. package/dist/webhooks/WebhookManager.d.ts.map +1 -0
  57. package/dist/webhooks/WebhookManager.js +174 -0
  58. package/dist/webhooks/WebhookManager.js.map +1 -0
  59. package/examples/GITHUB_LIVE_EXAMPLE.md +308 -0
  60. package/examples/GITHUB_LIVE_SETUP.md +253 -0
  61. package/examples/QUICKSTART.md +130 -0
  62. package/examples/basic-setup.ts +142 -0
  63. package/examples/github-server-live.ts +690 -0
  64. package/examples/github-server.ts +223 -0
  65. package/examples/google-drive-server-live.ts +773 -0
  66. package/examples/start-github-live.sh +53 -0
  67. package/jest.config.js +20 -0
  68. package/package.json +58 -0
  69. package/src/errors/index.ts +81 -0
  70. package/src/index.ts +19 -0
  71. package/src/server.ts +595 -0
  72. package/src/stores/InMemoryStore.ts +87 -0
  73. package/src/stores/RedisStore.ts +51 -0
  74. package/src/stores/index.ts +2 -0
  75. package/src/subscriptions/SubscriptionManager.ts +240 -0
  76. package/src/types/index.ts +341 -0
  77. package/src/utils/index.ts +156 -0
  78. package/src/webhooks/WebhookManager.ts +230 -0
  79. package/test-sdk-integration.sh +157 -0
  80. package/tsconfig.json +21 -0
package/src/server.ts ADDED
@@ -0,0 +1,595 @@
1
+ import express, { Express, Request, Response, NextFunction } from 'express';
2
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { z } from 'zod';
5
+ import { MCPServerConfig, MCPServer, Logger, JSONSchema } from './types';
6
+ import { SubscriptionManager } from './subscriptions/SubscriptionManager';
7
+ import { WebhookManager } from './webhooks/WebhookManager';
8
+ import { MCPError, AuthenticationError, ValidationError } from './errors';
9
+ import { isValidUrl, isHttpsUrl } from './utils';
10
+ import cors from 'cors';
11
+
12
+ /**
13
+ * Default console logger
14
+ */
15
+ const defaultLogger: Logger = {
16
+ debug: (msg, meta) => console.debug(msg, meta),
17
+ info: (msg, meta) => console.info(msg, meta),
18
+ warn: (msg, meta) => console.warn(msg, meta),
19
+ error: (msg, meta) => console.error(msg, meta),
20
+ };
21
+
22
+ /**
23
+ * Convert JSON Schema to Zod schema (simplified version)
24
+ * Handles basic types and nested objects
25
+ */
26
+ function convertToZodSchema(jsonSchema: JSONSchema): Record<string, z.ZodTypeAny> {
27
+ const schema: Record<string, z.ZodTypeAny> = {};
28
+
29
+ if (!jsonSchema || !jsonSchema.properties) {
30
+ return schema;
31
+ }
32
+
33
+ const required = jsonSchema.required || [];
34
+
35
+ for (const [key, value] of Object.entries(jsonSchema.properties)) {
36
+ let zodType: z.ZodTypeAny;
37
+
38
+ if (value.type === 'string') {
39
+ zodType = z.string();
40
+ } else if (value.type === 'number' || value.type === 'integer') {
41
+ zodType = z.number();
42
+ } else if (value.type === 'boolean') {
43
+ zodType = z.boolean();
44
+ } else if (value.type === 'array') {
45
+ // Handle array items
46
+ if (value.items?.type === 'string') {
47
+ zodType = z.array(z.string());
48
+ } else if (value.items?.type === 'number') {
49
+ zodType = z.array(z.number());
50
+ } else if (value.items?.type === 'object') {
51
+ zodType = z.array(z.object(convertToZodSchema(value.items as JSONSchema)));
52
+ } else {
53
+ zodType = z.array(z.any());
54
+ }
55
+ } else if (value.type === 'object') {
56
+ zodType = z.object(convertToZodSchema(value as JSONSchema));
57
+ } else {
58
+ zodType = z.any();
59
+ }
60
+
61
+ // Mark as optional if not in required array
62
+ if (!required.includes(key)) {
63
+ zodType = zodType.optional();
64
+ }
65
+
66
+ schema[key] = zodType;
67
+ }
68
+
69
+ return schema;
70
+ }
71
+
72
+ /**
73
+ * Create MCP HTTP Webhook Server using standard MCP SDK
74
+ */
75
+ export function createMCPServer(config: MCPServerConfig): MCPServer {
76
+ const logger = config.logger || defaultLogger;
77
+ const basePath = config.basePath || '/mcp';
78
+ const port = config.port || 3000;
79
+ const host = config.host || '0.0.0.0';
80
+
81
+ // Validate configuration
82
+ validateConfig(config);
83
+
84
+ // Create Express app
85
+ const app: Express = express();
86
+
87
+ // Middleware
88
+ app.use(express.json());
89
+ // allow cors
90
+ app.use(cors());
91
+ // Custom middleware
92
+ if (config.middleware) {
93
+ config.middleware.forEach((mw) => app.use(mw));
94
+ }
95
+
96
+ // Create MCP SDK server
97
+ const sdkServer = new McpServer(
98
+ {
99
+ name: config.name,
100
+ version: config.version,
101
+ },
102
+ {
103
+ capabilities: {
104
+ resources: {
105
+ subscribe: true,
106
+ listChanged: true,
107
+ },
108
+ experimental: {
109
+ isConnector: true,
110
+ },
111
+ },
112
+ }
113
+ );
114
+
115
+ // Initialize managers for webhook subscriptions
116
+ const subscriptionManager = new SubscriptionManager(
117
+ config.store,
118
+ config.resources,
119
+ config.publicUrl,
120
+ logger
121
+ );
122
+
123
+ const webhookManager = new WebhookManager(config.webhooks, config.resources, logger);
124
+
125
+ // Register tools with MCP SDK
126
+ config.tools.forEach((toolDef) => {
127
+ sdkServer.registerTool(
128
+ toolDef.name,
129
+ {
130
+ title: toolDef.description,
131
+ description: toolDef.description,
132
+ inputSchema: convertToZodSchema(toolDef.inputSchema),
133
+ },
134
+ async (input) => {
135
+ // Get auth context from request metadata if available
136
+ const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
137
+
138
+ const result = await toolDef.handler(input, context);
139
+
140
+ return {
141
+ content: [
142
+ {
143
+ type: 'text',
144
+ text: JSON.stringify(result, null, 2),
145
+ },
146
+ ],
147
+ structuredContent: result,
148
+ };
149
+ }
150
+ );
151
+ });
152
+
153
+ // Register resources with MCP SDK
154
+ config.resources.forEach((resourceDef) => {
155
+ // Check if URI is a template (contains {})
156
+ const isTemplate = resourceDef.uri.includes('{') && resourceDef.uri.includes('}');
157
+
158
+ if (isTemplate && resourceDef.list) {
159
+ // Use ResourceTemplate for templated URIs
160
+ const template = new ResourceTemplate(resourceDef.uri, {
161
+ list: async () => {
162
+ const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
163
+ const resources = await resourceDef.list!(context);
164
+
165
+ // Map to MCP SDK Resource format with index signature
166
+ return {
167
+ resources: resources.map((r: any) => ({
168
+ uri: r.uri,
169
+ name: r.name,
170
+ description: r.description,
171
+ mimeType: resourceDef.mimeType,
172
+ ...r, // Spread to add index signature
173
+ })),
174
+ };
175
+ },
176
+ });
177
+ sdkServer.registerResource(
178
+ resourceDef.name,
179
+ template,
180
+ {
181
+ description: resourceDef.description,
182
+ mimeType: resourceDef.mimeType || 'application/json',
183
+ },
184
+ async (uri: any, _variables: any, _extra: any) => {
185
+ const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
186
+ const result = await resourceDef.read(uri.href, context);
187
+
188
+ return {
189
+ contents: Array.isArray(result.contents)
190
+ ? result.contents.map((content: any) => ({
191
+ uri: uri.href,
192
+ text: typeof content === 'string' ? content : JSON.stringify(content),
193
+ mimeType: resourceDef.mimeType || 'text/plain',
194
+ }))
195
+ : [{
196
+ uri: uri.href,
197
+ text: typeof result.contents === 'string' ? result.contents : JSON.stringify(result.contents),
198
+ mimeType: resourceDef.mimeType || 'text/plain',
199
+ }],
200
+ };
201
+ }
202
+ );
203
+ } else {
204
+ // Static resource - simple URI
205
+ sdkServer.registerResource(
206
+ resourceDef.name,
207
+ resourceDef.uri,
208
+ {
209
+ description: resourceDef.description,
210
+ mimeType: resourceDef.mimeType || 'application/json',
211
+ },
212
+ async (uri: any, _extra: any) => {
213
+ const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
214
+ const result = await resourceDef.read(uri.href, context);
215
+
216
+ return {
217
+ contents: Array.isArray(result.contents)
218
+ ? result.contents.map((content: any) => ({
219
+ uri: uri.href,
220
+ text: typeof content === 'string' ? content : JSON.stringify(content),
221
+ mimeType: resourceDef.mimeType || 'application/json',
222
+ }))
223
+ : [{
224
+ uri: uri.href,
225
+ text: typeof result.contents === 'string' ? result.contents : JSON.stringify(result.contents),
226
+ mimeType: resourceDef.mimeType || 'application/json',
227
+ }],
228
+ };
229
+ }
230
+ );
231
+ }
232
+
233
+ });
234
+
235
+ // Register prompts with MCP SDK
236
+ if (config.prompts) {
237
+ config.prompts.forEach((promptDef) => {
238
+ sdkServer.registerPrompt(
239
+ promptDef.name,
240
+ {
241
+ title: promptDef.name,
242
+ description: promptDef.description,
243
+ argsSchema: promptDef.arguments?.reduce(
244
+ (schema, arg) => {
245
+ schema[arg.name] = z.string();
246
+ return schema;
247
+ },
248
+ {} as Record<string, any>
249
+ ) || {},
250
+ },
251
+ async (args: any) => {
252
+ const context = (sdkServer as any)._currentContext || { userId: 'anonymous' };
253
+ const result = await promptDef.handler(args, context);
254
+
255
+ // Convert to MCP SDK message format
256
+ return {
257
+ messages: result.messages.map((msg: any) => ({
258
+ role: msg.role,
259
+ content: {
260
+ type: 'text',
261
+ text: msg.content,
262
+ },
263
+ })),
264
+ };
265
+ }
266
+ );
267
+ });
268
+ }
269
+
270
+ // Health check endpoints
271
+ app.get('/health', (_req: any, res: any) => {
272
+ res.json({ status: 'ok' });
273
+ });
274
+
275
+ app.get('/ready', async (_req: any, res: any) => {
276
+ try {
277
+ // Test store connectivity
278
+ await config.store.set('health-check', 'ok', 10);
279
+ await config.store.delete('health-check');
280
+ res.json({ status: 'ready' });
281
+ } catch (error) {
282
+ res.status(503).json({ status: 'not ready', error: String(error) });
283
+ }
284
+ });
285
+
286
+ // Standard MCP endpoint with StreamableHTTPServerTransport
287
+ app.post(basePath, asyncHandler(async (req, res) => {
288
+ // Authenticate and store context
289
+ const context = await authenticate(req, config);
290
+ (sdkServer as any)._currentContext = context;
291
+
292
+ // Intercept resources/subscribe to support webhook callbacks
293
+ if (req.body.method === 'resources/subscribe') {
294
+ const { uri } = req.body.params || {};
295
+ const webhookUrl = req.body.params?._meta?.webhookUrl || req.body.params?.webhookUrl;
296
+ const webhookSecret = req.body.params?._meta?.webhookSecret || req.body.params?.webhookSecret;
297
+
298
+ if (webhookUrl) {
299
+ // Handle webhook-based subscription
300
+ logger.info('Standard MCP subscribe with webhook support', { uri, webhookUrl });
301
+
302
+ if (!isValidUrl(webhookUrl)) {
303
+ return res.json({
304
+ jsonrpc: '2.0',
305
+ id: req.body.id,
306
+ error: {
307
+ code: -32602,
308
+ message: 'Invalid webhookUrl in _meta'
309
+ }
310
+ });
311
+ }
312
+
313
+ try {
314
+ await subscriptionManager.createSubscription({
315
+ uri,
316
+ clientCallbackUrl: webhookUrl,
317
+ clientCallbackSecret: webhookSecret,
318
+ context,
319
+ });
320
+
321
+ // Return standard MCP response
322
+ return res.json({
323
+ jsonrpc: '2.0',
324
+ id: req.body.id,
325
+ method: 'notifications/resources/updated',
326
+ params: {
327
+ uri: uri,
328
+ }
329
+ });
330
+ } catch (error: any) {
331
+ return res.json({
332
+ jsonrpc: '2.0',
333
+ id: req.body.id,
334
+ error: {
335
+ code: -32603,
336
+ message: error.message || 'Failed to create subscription'
337
+ }
338
+ });
339
+ }
340
+ } else {
341
+ // No webhookUrl - throw error
342
+ return res.json({
343
+ jsonrpc: '2.0',
344
+ id: req.body.id,
345
+ error: {
346
+ code: -32602,
347
+ message: 'Invalid webhookUrl in _meta'
348
+ }
349
+ });
350
+ }
351
+ }
352
+
353
+ // Intercept resources/unsubscribe to support webhook cleanup
354
+ if (req.body.method === 'resources/unsubscribe') {
355
+ const subscriptionId = req.body.params?._meta?.subscriptionId || req.body.params?.subscriptionId;
356
+
357
+ if (subscriptionId) {
358
+ logger.info('Standard MCP unsubscribe with webhook cleanup', { subscriptionId });
359
+
360
+ try {
361
+ await subscriptionManager.deleteSubscription(subscriptionId, context);
362
+
363
+ return res.json({
364
+ jsonrpc: '2.0',
365
+ id: req.body.id,
366
+ result: {
367
+ success: true,
368
+ _meta: {
369
+ webhookEnabled: true
370
+ }
371
+ }
372
+ });
373
+ } catch (error: any) {
374
+ return res.json({
375
+ jsonrpc: '2.0',
376
+ id: req.body.id,
377
+ error: {
378
+ code: -32603,
379
+ message: error.message || 'Failed to unsubscribe'
380
+ }
381
+ });
382
+ }
383
+ }
384
+ }
385
+
386
+ // Standard MCP request handling
387
+ const transport = new StreamableHTTPServerTransport({
388
+ sessionIdGenerator: undefined,
389
+ enableJsonResponse: true,
390
+ });
391
+
392
+ res.on('close', () => {
393
+ transport.close();
394
+ delete (sdkServer as any)._currentContext;
395
+ });
396
+
397
+ await sdkServer.connect(transport);
398
+ await transport.handleRequest(req, res, req.body);
399
+ }));
400
+
401
+ // Additional webhook subscription endpoints (extensions to MCP)
402
+ app.post(`${basePath}/resources/subscribe`, asyncHandler(async (req, res) => {
403
+ const context = await authenticate(req, config);
404
+ const { uri, callbackUrl, callbackSecret } = req.body.params || req.body;
405
+
406
+ if (!uri || !callbackUrl) {
407
+ throw new ValidationError('uri and callbackUrl are required');
408
+ }
409
+
410
+ if (!isValidUrl(callbackUrl)) {
411
+ throw new ValidationError('Invalid callbackUrl');
412
+ }
413
+
414
+ const result = await subscriptionManager.createSubscription({
415
+ uri,
416
+ clientCallbackUrl: callbackUrl,
417
+ clientCallbackSecret: callbackSecret,
418
+ context,
419
+ });
420
+
421
+ res.json(result);
422
+ }));
423
+
424
+ app.post(`${basePath}/resources/unsubscribe`, asyncHandler(async (req, res) => {
425
+ const context = await authenticate(req, config);
426
+ const { subscriptionId } = req.body.params || req.body;
427
+
428
+ if (!subscriptionId) {
429
+ throw new ValidationError('subscriptionId is required');
430
+ }
431
+
432
+ await subscriptionManager.deleteSubscription(subscriptionId, context);
433
+
434
+ res.json({ status: 'unsubscribed' });
435
+ }));
436
+
437
+ // Webhook endpoints
438
+ const webhookPath = config.webhooks?.incomingPath || '/webhooks/incoming';
439
+
440
+ app.post(`${webhookPath}/:subscriptionId`, asyncHandler(async (req, res) => {
441
+ const { subscriptionId } = req.params;
442
+ const payload = req.body;
443
+ const headers = req.headers as Record<string, string>;
444
+
445
+ logger.debug('Received webhook', { subscriptionId });
446
+
447
+ // Load subscription
448
+ const subscription = await subscriptionManager.getSubscription(subscriptionId);
449
+ if (!subscription) {
450
+ logger.warn('Subscription not found', { subscriptionId });
451
+ return res.status(404).json({ error: 'Subscription not found' });
452
+ }
453
+
454
+ // Process webhook
455
+ const changeInfo = await webhookManager.processIncomingWebhook(
456
+ subscriptionId,
457
+ payload,
458
+ headers,
459
+ subscription
460
+ );
461
+
462
+ if (changeInfo) {
463
+ // Notify client (async, don't wait)
464
+ webhookManager.notifyClient(subscription, changeInfo).catch((error) => {
465
+ logger.error('Failed to notify client', {
466
+ subscriptionId,
467
+ error: error instanceof Error ? error.message : String(error),
468
+ });
469
+ });
470
+ }
471
+
472
+ res.json({ received: true });
473
+ }));
474
+
475
+ // Error handler
476
+ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
477
+ if (err instanceof MCPError) {
478
+ logger.warn('MCP Error', { code: err.code, message: err.message });
479
+ return res.status(400).json(err.toJSON());
480
+ }
481
+
482
+ logger.error('Unexpected error', {
483
+ error: err.message,
484
+ stack: err.stack,
485
+ });
486
+
487
+ res.status(500).json({
488
+ error: {
489
+ code: -1,
490
+ message: 'Internal server error',
491
+ },
492
+ });
493
+ });
494
+
495
+ // Server instance
496
+ let server: any;
497
+
498
+ const mcpServer: MCPServer = {
499
+ async start() {
500
+ return new Promise((resolve) => {
501
+ server = app.listen(port, host, () => {
502
+ logger.info(`MCP Server started`, {
503
+ name: config.name,
504
+ version: config.version,
505
+ host,
506
+ port,
507
+ publicUrl: config.publicUrl,
508
+ });
509
+ resolve();
510
+ });
511
+ });
512
+ },
513
+
514
+ async stop() {
515
+ if (server) {
516
+ return new Promise((resolve) => {
517
+ server.close(() => {
518
+ logger.info('MCP Server stopped');
519
+ resolve();
520
+ });
521
+ });
522
+ }
523
+ },
524
+
525
+ getApp() {
526
+ return app;
527
+ },
528
+ };
529
+
530
+ return mcpServer;
531
+ }
532
+
533
+ /**
534
+ * Validate server configuration
535
+ */
536
+ function validateConfig(config: MCPServerConfig): void {
537
+ if (!config.name) {
538
+ throw new Error('Server name is required');
539
+ }
540
+
541
+ if (!config.version) {
542
+ throw new Error('Server version is required');
543
+ }
544
+
545
+ if (!config.publicUrl) {
546
+ throw new Error('publicUrl is required');
547
+ }
548
+
549
+ if (!isValidUrl(config.publicUrl)) {
550
+ throw new Error('Invalid publicUrl');
551
+ }
552
+
553
+ if (process.env.NODE_ENV === 'production' && !isHttpsUrl(config.publicUrl)) {
554
+ throw new Error('publicUrl must use HTTPS in production');
555
+ }
556
+
557
+ if (!config.store) {
558
+ throw new Error('Store is required');
559
+ }
560
+
561
+ if (!Array.isArray(config.tools)) {
562
+ throw new Error('tools must be an array');
563
+ }
564
+
565
+ if (!Array.isArray(config.resources)) {
566
+ throw new Error('resources must be an array');
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Authenticate request
572
+ */
573
+ async function authenticate(req: Request, config: MCPServerConfig) {
574
+ if (!config.authenticate) {
575
+ // No authentication configured - return default context
576
+ return { userId: 'anonymous' };
577
+ }
578
+
579
+ try {
580
+ return await config.authenticate(req);
581
+ } catch (error) {
582
+ throw new AuthenticationError(
583
+ error instanceof Error ? error.message : 'Authentication failed'
584
+ );
585
+ }
586
+ }
587
+
588
+ /**
589
+ * Async handler wrapper
590
+ */
591
+ function asyncHandler(fn: (req: Request, res: Response) => Promise<any>) {
592
+ return (req: Request, res: Response, next: NextFunction) => {
593
+ Promise.resolve(fn(req, res)).catch(next);
594
+ };
595
+ }
@@ -0,0 +1,87 @@
1
+ import { KeyValueStore } from '../types';
2
+
3
+ /**
4
+ * In-memory store implementation (for development/testing only)
5
+ * DO NOT USE IN PRODUCTION
6
+ */
7
+ export class InMemoryStore implements KeyValueStore {
8
+ private data: Map<string, { value: string; expiresAt?: number }> = new Map();
9
+ private cleanupInterval: NodeJS.Timeout;
10
+
11
+ constructor() {
12
+ // Cleanup expired keys every minute
13
+ this.cleanupInterval = setInterval(() => this.cleanup(), 60000);
14
+ }
15
+
16
+ async get(key: string): Promise<string | null> {
17
+ const item = this.data.get(key);
18
+
19
+ if (!item) {
20
+ return null;
21
+ }
22
+
23
+ // Check expiration
24
+ if (item.expiresAt && item.expiresAt < Date.now()) {
25
+ this.data.delete(key);
26
+ return null;
27
+ }
28
+
29
+ return item.value;
30
+ }
31
+
32
+ async set(key: string, value: string, ttl?: number): Promise<void> {
33
+ const item: { value: string; expiresAt?: number } = { value };
34
+
35
+ if (ttl) {
36
+ item.expiresAt = Date.now() + ttl * 1000;
37
+ }
38
+
39
+ this.data.set(key, item);
40
+ }
41
+
42
+ async delete(key: string): Promise<void> {
43
+ this.data.delete(key);
44
+ }
45
+
46
+ async scan(pattern: string): Promise<string[]> {
47
+ // Simple glob pattern matching
48
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
49
+ const keys: string[] = [];
50
+
51
+ for (const key of this.data.keys()) {
52
+ if (regex.test(key)) {
53
+ keys.push(key);
54
+ }
55
+ }
56
+
57
+ return keys;
58
+ }
59
+
60
+ /**
61
+ * Cleanup expired keys
62
+ */
63
+ private cleanup(): void {
64
+ const now = Date.now();
65
+
66
+ for (const [key, item] of this.data.entries()) {
67
+ if (item.expiresAt && item.expiresAt < now) {
68
+ this.data.delete(key);
69
+ }
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Clear all data
75
+ */
76
+ clear(): void {
77
+ this.data.clear();
78
+ }
79
+
80
+ /**
81
+ * Destroy store and cleanup interval
82
+ */
83
+ destroy(): void {
84
+ clearInterval(this.cleanupInterval);
85
+ this.clear();
86
+ }
87
+ }