@mostajs/net 1.0.0-alpha.1
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/dist/auth/apikey-middleware.d.ts +12 -0
- package/dist/auth/apikey-middleware.js +62 -0
- package/dist/auth/apikeys.d.ts +34 -0
- package/dist/auth/apikeys.js +130 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +127 -0
- package/dist/core/config.d.ts +15 -0
- package/dist/core/config.js +60 -0
- package/dist/core/factory.d.ts +15 -0
- package/dist/core/factory.js +60 -0
- package/dist/core/middleware.d.ts +12 -0
- package/dist/core/middleware.js +32 -0
- package/dist/core/types.d.ts +62 -0
- package/dist/core/types.js +4 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +20 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +221 -0
- package/dist/transports/graphql.transport.d.ts +29 -0
- package/dist/transports/graphql.transport.js +164 -0
- package/dist/transports/jsonrpc.transport.d.ts +41 -0
- package/dist/transports/jsonrpc.transport.js +116 -0
- package/dist/transports/mcp.transport.d.ts +34 -0
- package/dist/transports/mcp.transport.js +188 -0
- package/dist/transports/rest.transport.d.ts +29 -0
- package/dist/transports/rest.transport.js +184 -0
- package/dist/transports/sse.transport.d.ts +45 -0
- package/dist/transports/sse.transport.js +99 -0
- package/dist/transports/ws.transport.d.ts +33 -0
- package/dist/transports/ws.transport.js +100 -0
- package/package.json +60 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// McpTransport — Model Context Protocol transport adapter
|
|
2
|
+
// Exposes ORM entities as MCP tools and resources for AI agents (Claude, GPT, etc.)
|
|
3
|
+
// Uses the official @modelcontextprotocol/sdk
|
|
4
|
+
// Author: Dr Hamid MADANI drmdh@msn.com
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
export class McpTransport {
|
|
9
|
+
name = 'mcp';
|
|
10
|
+
config = null;
|
|
11
|
+
schemas = [];
|
|
12
|
+
middlewares = [];
|
|
13
|
+
ormHandler = null;
|
|
14
|
+
stats = { requests: 0, errors: 0, startedAt: 0 };
|
|
15
|
+
setHandler(handler) { this.ormHandler = handler; }
|
|
16
|
+
use(mw) { this.middlewares.push(mw); }
|
|
17
|
+
registerEntity(schema) { this.schemas.push(schema); }
|
|
18
|
+
async start(config) {
|
|
19
|
+
this.config = config;
|
|
20
|
+
this.stats.startedAt = Date.now();
|
|
21
|
+
// MCP server is created per-request in handleRequest() (stateless mode)
|
|
22
|
+
}
|
|
23
|
+
async stop() {
|
|
24
|
+
this.config = null;
|
|
25
|
+
}
|
|
26
|
+
getInfo() {
|
|
27
|
+
return {
|
|
28
|
+
name: this.name,
|
|
29
|
+
status: this.config ? 'running' : 'stopped',
|
|
30
|
+
url: this.config?.path || '/mcp',
|
|
31
|
+
entities: this.schemas.map(s => s.name),
|
|
32
|
+
stats: { ...this.stats },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
getPath() {
|
|
36
|
+
return this.config?.path || '/mcp';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Handle an incoming MCP HTTP request.
|
|
40
|
+
* Called by the main Fastify server via route handler.
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Handle an incoming MCP HTTP request.
|
|
44
|
+
* Creates a new McpServer + Transport per session for stateless mode.
|
|
45
|
+
*/
|
|
46
|
+
async handleRequest(req, res, body) {
|
|
47
|
+
// Create a fresh MCP server per request (stateless mode)
|
|
48
|
+
const server = new McpServer({ name: '@mostajs/orm', version: '1.5.0' }, { capabilities: { tools: {}, resources: {} } });
|
|
49
|
+
// Register tools and resources
|
|
50
|
+
for (const schema of this.schemas) {
|
|
51
|
+
this.registerEntityToolsOn(server, schema);
|
|
52
|
+
this.registerEntityResourcesOn(server, schema);
|
|
53
|
+
}
|
|
54
|
+
// Create transport and connect
|
|
55
|
+
const transport = new StreamableHTTPServerTransport({
|
|
56
|
+
sessionIdGenerator: undefined, // stateless
|
|
57
|
+
});
|
|
58
|
+
await server.connect(transport);
|
|
59
|
+
await transport.handleRequest(req, res, body);
|
|
60
|
+
}
|
|
61
|
+
// ============================================================
|
|
62
|
+
// Tool registration (CRUD operations per entity)
|
|
63
|
+
// ============================================================
|
|
64
|
+
registerEntityToolsOn(server, schema) {
|
|
65
|
+
const name = schema.name;
|
|
66
|
+
// Tool: {Entity}_findAll — query entities
|
|
67
|
+
server.registerTool(`${name}_findAll`, {
|
|
68
|
+
description: `Find all ${name} entities. Optionally filter, sort, limit.`,
|
|
69
|
+
inputSchema: {
|
|
70
|
+
filter: z.string().optional().describe('JSON filter object (MongoDB-style)'),
|
|
71
|
+
sort: z.string().optional().describe('JSON sort object, e.g. {"name":1}'),
|
|
72
|
+
limit: z.number().optional().describe('Max results to return'),
|
|
73
|
+
skip: z.number().optional().describe('Number of results to skip'),
|
|
74
|
+
},
|
|
75
|
+
}, async (params) => {
|
|
76
|
+
this.stats.requests++;
|
|
77
|
+
const res = await this.callOrm({
|
|
78
|
+
op: 'findAll',
|
|
79
|
+
entity: name,
|
|
80
|
+
filter: params.filter ? JSON.parse(params.filter) : {},
|
|
81
|
+
options: {
|
|
82
|
+
sort: params.sort ? JSON.parse(params.sort) : undefined,
|
|
83
|
+
limit: params.limit,
|
|
84
|
+
skip: params.skip,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
88
|
+
});
|
|
89
|
+
// Tool: {Entity}_findById — get one entity by ID
|
|
90
|
+
server.registerTool(`${name}_findById`, {
|
|
91
|
+
description: `Find a ${name} entity by its ID.`,
|
|
92
|
+
inputSchema: {
|
|
93
|
+
id: z.string().describe(`The ${name} ID`),
|
|
94
|
+
},
|
|
95
|
+
}, async (params) => {
|
|
96
|
+
this.stats.requests++;
|
|
97
|
+
const res = await this.callOrm({ op: 'findById', entity: name, id: params.id });
|
|
98
|
+
if (!res.data) {
|
|
99
|
+
return { content: [{ type: 'text', text: `${name} with id "${params.id}" not found` }], isError: true };
|
|
100
|
+
}
|
|
101
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
102
|
+
});
|
|
103
|
+
// Tool: {Entity}_create — create a new entity
|
|
104
|
+
server.registerTool(`${name}_create`, {
|
|
105
|
+
description: `Create a new ${name} entity.`,
|
|
106
|
+
inputSchema: {
|
|
107
|
+
data: z.string().describe(`JSON object with ${name} fields: ${Object.keys(schema.fields).join(', ')}`),
|
|
108
|
+
},
|
|
109
|
+
}, async (params) => {
|
|
110
|
+
this.stats.requests++;
|
|
111
|
+
const data = JSON.parse(params.data);
|
|
112
|
+
const res = await this.callOrm({ op: 'create', entity: name, data });
|
|
113
|
+
if (res.status === 'error') {
|
|
114
|
+
return { content: [{ type: 'text', text: `Error: ${res.error?.message}` }], isError: true };
|
|
115
|
+
}
|
|
116
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
117
|
+
});
|
|
118
|
+
// Tool: {Entity}_update — update an entity
|
|
119
|
+
server.registerTool(`${name}_update`, {
|
|
120
|
+
description: `Update a ${name} entity by ID.`,
|
|
121
|
+
inputSchema: {
|
|
122
|
+
id: z.string().describe(`The ${name} ID to update`),
|
|
123
|
+
data: z.string().describe('JSON object with fields to update'),
|
|
124
|
+
},
|
|
125
|
+
}, async (params) => {
|
|
126
|
+
this.stats.requests++;
|
|
127
|
+
const data = JSON.parse(params.data);
|
|
128
|
+
const res = await this.callOrm({ op: 'update', entity: name, id: params.id, data });
|
|
129
|
+
if (res.status === 'error') {
|
|
130
|
+
return { content: [{ type: 'text', text: `Error: ${res.error?.message}` }], isError: true };
|
|
131
|
+
}
|
|
132
|
+
return { content: [{ type: 'text', text: JSON.stringify(res.data, null, 2) }] };
|
|
133
|
+
});
|
|
134
|
+
// Tool: {Entity}_delete — delete an entity
|
|
135
|
+
server.registerTool(`${name}_delete`, {
|
|
136
|
+
description: `Delete a ${name} entity by ID.`,
|
|
137
|
+
inputSchema: {
|
|
138
|
+
id: z.string().describe(`The ${name} ID to delete`),
|
|
139
|
+
},
|
|
140
|
+
}, async (params) => {
|
|
141
|
+
this.stats.requests++;
|
|
142
|
+
const res = await this.callOrm({ op: 'delete', entity: name, id: params.id });
|
|
143
|
+
return { content: [{ type: 'text', text: res.data ? 'Deleted' : 'Not found' }] };
|
|
144
|
+
});
|
|
145
|
+
// Tool: {Entity}_count — count entities
|
|
146
|
+
server.registerTool(`${name}_count`, {
|
|
147
|
+
description: `Count ${name} entities. Optionally filter.`,
|
|
148
|
+
inputSchema: {
|
|
149
|
+
filter: z.string().optional().describe('JSON filter object'),
|
|
150
|
+
},
|
|
151
|
+
}, async (params) => {
|
|
152
|
+
this.stats.requests++;
|
|
153
|
+
const res = await this.callOrm({
|
|
154
|
+
op: 'count',
|
|
155
|
+
entity: name,
|
|
156
|
+
filter: params.filter ? JSON.parse(params.filter) : {},
|
|
157
|
+
});
|
|
158
|
+
return { content: [{ type: 'text', text: String(res.data) }] };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
// ============================================================
|
|
162
|
+
// Resource registration (schema info per entity)
|
|
163
|
+
// ============================================================
|
|
164
|
+
registerEntityResourcesOn(server, schema) {
|
|
165
|
+
// Resource: entity schema definition
|
|
166
|
+
server.registerResource(`${schema.name} Schema`, `entity://${schema.name}/schema`, { description: `Schema definition for ${schema.name} entity`, mimeType: 'application/json' }, async () => ({
|
|
167
|
+
contents: [{
|
|
168
|
+
uri: `entity://${schema.name}/schema`,
|
|
169
|
+
text: JSON.stringify(schema, null, 2),
|
|
170
|
+
mimeType: 'application/json',
|
|
171
|
+
}],
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
// ============================================================
|
|
175
|
+
// ORM call helper
|
|
176
|
+
// ============================================================
|
|
177
|
+
async callOrm(req) {
|
|
178
|
+
if (!this.ormHandler) {
|
|
179
|
+
return { status: 'error', error: { code: 'NO_HANDLER', message: 'ORM handler not initialized' } };
|
|
180
|
+
}
|
|
181
|
+
const ctx = { transport: this.name };
|
|
182
|
+
return this.ormHandler(req, ctx);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Factory */
|
|
186
|
+
export function createTransport() {
|
|
187
|
+
return new McpTransport();
|
|
188
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import type { EntitySchema, OrmRequest, OrmResponse } from '@mostajs/orm';
|
|
3
|
+
import type { ITransport, TransportConfig, TransportInfo, TransportMiddleware, TransportContext } from '../core/types.js';
|
|
4
|
+
/** Handler function that executes OrmRequest (set by the server) */
|
|
5
|
+
type OrmHandler = (req: OrmRequest, ctx: TransportContext) => Promise<OrmResponse>;
|
|
6
|
+
export declare class RestTransport implements ITransport {
|
|
7
|
+
readonly name = "rest";
|
|
8
|
+
private app;
|
|
9
|
+
private config;
|
|
10
|
+
private schemas;
|
|
11
|
+
private middlewares;
|
|
12
|
+
private ormHandler;
|
|
13
|
+
private stats;
|
|
14
|
+
/** Set the ORM handler (called by the server after creating EntityService) */
|
|
15
|
+
setHandler(handler: OrmHandler): void;
|
|
16
|
+
use(middleware: TransportMiddleware): void;
|
|
17
|
+
registerEntity(schema: EntitySchema): void;
|
|
18
|
+
start(config: TransportConfig): Promise<void>;
|
|
19
|
+
stop(): Promise<void>;
|
|
20
|
+
getInfo(): TransportInfo;
|
|
21
|
+
/** Get the Fastify instance (for mounting in the shared server) */
|
|
22
|
+
getApp(): FastifyInstance | null;
|
|
23
|
+
private registerRoutes;
|
|
24
|
+
private handle;
|
|
25
|
+
private mapErrorToHttpStatus;
|
|
26
|
+
}
|
|
27
|
+
/** Factory function (used by transport loader) */
|
|
28
|
+
export declare function createTransport(): ITransport;
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// RestTransport — REST API transport adapter
|
|
2
|
+
// Generates CRUD routes from EntitySchema, translates HTTP → OrmRequest → OrmResponse → HTTP
|
|
3
|
+
// Mirror of MongoDialect / PostgresDialect in @mostajs/orm
|
|
4
|
+
// Author: Dr Hamid MADANI drmdh@msn.com
|
|
5
|
+
import Fastify from 'fastify';
|
|
6
|
+
import { composeMiddleware } from '../core/middleware.js';
|
|
7
|
+
export class RestTransport {
|
|
8
|
+
name = 'rest';
|
|
9
|
+
app = null;
|
|
10
|
+
config = null;
|
|
11
|
+
schemas = [];
|
|
12
|
+
middlewares = [];
|
|
13
|
+
ormHandler = null;
|
|
14
|
+
stats = { requests: 0, errors: 0, startedAt: 0 };
|
|
15
|
+
/** Set the ORM handler (called by the server after creating EntityService) */
|
|
16
|
+
setHandler(handler) {
|
|
17
|
+
this.ormHandler = handler;
|
|
18
|
+
}
|
|
19
|
+
use(middleware) {
|
|
20
|
+
this.middlewares.push(middleware);
|
|
21
|
+
}
|
|
22
|
+
registerEntity(schema) {
|
|
23
|
+
this.schemas.push(schema);
|
|
24
|
+
}
|
|
25
|
+
async start(config) {
|
|
26
|
+
this.config = config;
|
|
27
|
+
this.stats.startedAt = Date.now();
|
|
28
|
+
// Fastify instance is created here but NOT listened —
|
|
29
|
+
// the main server.ts will inject it into the shared Fastify instance
|
|
30
|
+
this.app = Fastify({ logger: false });
|
|
31
|
+
// Register routes for each entity
|
|
32
|
+
for (const schema of this.schemas) {
|
|
33
|
+
this.registerRoutes(schema);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async stop() {
|
|
37
|
+
this.app = null;
|
|
38
|
+
}
|
|
39
|
+
getInfo() {
|
|
40
|
+
return {
|
|
41
|
+
name: this.name,
|
|
42
|
+
status: this.app ? 'running' : 'stopped',
|
|
43
|
+
url: this.config?.path || '/api/v1',
|
|
44
|
+
entities: this.schemas.map(s => s.name),
|
|
45
|
+
stats: { ...this.stats },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/** Get the Fastify instance (for mounting in the shared server) */
|
|
49
|
+
getApp() {
|
|
50
|
+
return this.app;
|
|
51
|
+
}
|
|
52
|
+
// ============================================================
|
|
53
|
+
// Route generation
|
|
54
|
+
// ============================================================
|
|
55
|
+
registerRoutes(schema) {
|
|
56
|
+
if (!this.app)
|
|
57
|
+
return;
|
|
58
|
+
const prefix = this.config?.path || '/api/v1';
|
|
59
|
+
const collection = schema.collection;
|
|
60
|
+
// GET /api/v1/{collection} → findAll
|
|
61
|
+
this.app.get(`${prefix}/${collection}`, async (req, reply) => {
|
|
62
|
+
const query = req.query;
|
|
63
|
+
const ormReq = {
|
|
64
|
+
op: 'findAll',
|
|
65
|
+
entity: schema.name,
|
|
66
|
+
filter: query.filter ? JSON.parse(query.filter) : {},
|
|
67
|
+
options: {
|
|
68
|
+
sort: query.sort ? JSON.parse(query.sort) : undefined,
|
|
69
|
+
limit: query.limit ? parseInt(query.limit, 10) : undefined,
|
|
70
|
+
skip: query.skip ? parseInt(query.skip, 10) : undefined,
|
|
71
|
+
select: query.select ? query.select.split(',') : undefined,
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return this.handle(ormReq, reply);
|
|
75
|
+
});
|
|
76
|
+
// GET /api/v1/{collection}/:id → findById
|
|
77
|
+
this.app.get(`${prefix}/${collection}/:id`, async (req, reply) => {
|
|
78
|
+
const { id } = req.params;
|
|
79
|
+
const query = req.query;
|
|
80
|
+
const ormReq = {
|
|
81
|
+
op: 'findById',
|
|
82
|
+
entity: schema.name,
|
|
83
|
+
id,
|
|
84
|
+
relations: query.include ? query.include.split(',') : undefined,
|
|
85
|
+
};
|
|
86
|
+
return this.handle(ormReq, reply);
|
|
87
|
+
});
|
|
88
|
+
// POST /api/v1/{collection} → create
|
|
89
|
+
this.app.post(`${prefix}/${collection}`, async (req, reply) => {
|
|
90
|
+
const ormReq = {
|
|
91
|
+
op: 'create',
|
|
92
|
+
entity: schema.name,
|
|
93
|
+
data: req.body,
|
|
94
|
+
};
|
|
95
|
+
const res = await this.handle(ormReq, reply);
|
|
96
|
+
if (reply.statusCode < 400)
|
|
97
|
+
reply.status(201);
|
|
98
|
+
return res;
|
|
99
|
+
});
|
|
100
|
+
// PUT /api/v1/{collection}/:id → update
|
|
101
|
+
this.app.put(`${prefix}/${collection}/:id`, async (req, reply) => {
|
|
102
|
+
const { id } = req.params;
|
|
103
|
+
const ormReq = {
|
|
104
|
+
op: 'update',
|
|
105
|
+
entity: schema.name,
|
|
106
|
+
id,
|
|
107
|
+
data: req.body,
|
|
108
|
+
};
|
|
109
|
+
return this.handle(ormReq, reply);
|
|
110
|
+
});
|
|
111
|
+
// DELETE /api/v1/{collection}/:id → delete
|
|
112
|
+
this.app.delete(`${prefix}/${collection}/:id`, async (req, reply) => {
|
|
113
|
+
const { id } = req.params;
|
|
114
|
+
const ormReq = {
|
|
115
|
+
op: 'delete',
|
|
116
|
+
entity: schema.name,
|
|
117
|
+
id,
|
|
118
|
+
};
|
|
119
|
+
return this.handle(ormReq, reply);
|
|
120
|
+
});
|
|
121
|
+
// GET /api/v1/{collection}/count → count
|
|
122
|
+
this.app.get(`${prefix}/${collection}/count`, async (req, reply) => {
|
|
123
|
+
const query = req.query;
|
|
124
|
+
const ormReq = {
|
|
125
|
+
op: 'count',
|
|
126
|
+
entity: schema.name,
|
|
127
|
+
filter: query.filter ? JSON.parse(query.filter) : {},
|
|
128
|
+
};
|
|
129
|
+
return this.handle(ormReq, reply);
|
|
130
|
+
});
|
|
131
|
+
// POST /api/v1/{collection}/search → search
|
|
132
|
+
this.app.post(`${prefix}/${collection}/search`, async (req, reply) => {
|
|
133
|
+
const body = req.body;
|
|
134
|
+
const ormReq = {
|
|
135
|
+
op: 'search',
|
|
136
|
+
entity: schema.name,
|
|
137
|
+
query: body.query,
|
|
138
|
+
searchFields: body.fields,
|
|
139
|
+
options: body.options,
|
|
140
|
+
};
|
|
141
|
+
return this.handle(ormReq, reply);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
// ============================================================
|
|
145
|
+
// Request handling (OrmRequest → middleware → OrmResponse → HTTP)
|
|
146
|
+
// ============================================================
|
|
147
|
+
async handle(ormReq, reply) {
|
|
148
|
+
this.stats.requests++;
|
|
149
|
+
if (!this.ormHandler) {
|
|
150
|
+
this.stats.errors++;
|
|
151
|
+
reply.status(503);
|
|
152
|
+
return { status: 'error', error: { code: 'NO_HANDLER', message: 'ORM handler not initialized' } };
|
|
153
|
+
}
|
|
154
|
+
const ctx = { transport: this.name };
|
|
155
|
+
// Compose middleware chain → final handler
|
|
156
|
+
const chain = composeMiddleware(this.middlewares, this.ormHandler);
|
|
157
|
+
const res = await chain(ormReq, ctx);
|
|
158
|
+
if (res.status === 'error') {
|
|
159
|
+
this.stats.errors++;
|
|
160
|
+
const code = this.mapErrorToHttpStatus(res.error?.code);
|
|
161
|
+
reply.status(code);
|
|
162
|
+
}
|
|
163
|
+
return res;
|
|
164
|
+
}
|
|
165
|
+
mapErrorToHttpStatus(code) {
|
|
166
|
+
switch (code) {
|
|
167
|
+
case 'ENTITY_NOT_FOUND': return 404;
|
|
168
|
+
case 'EntityNotFoundError': return 404;
|
|
169
|
+
case 'MISSING_ID': return 400;
|
|
170
|
+
case 'MISSING_DATA': return 400;
|
|
171
|
+
case 'MISSING_QUERY': return 400;
|
|
172
|
+
case 'MISSING_PARAMS': return 400;
|
|
173
|
+
case 'MISSING_STAGES': return 400;
|
|
174
|
+
case 'UNKNOWN_OP': return 400;
|
|
175
|
+
case 'ValidationError': return 422;
|
|
176
|
+
case 'ConnectionError': return 503;
|
|
177
|
+
default: return 500;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Factory function (used by transport loader) */
|
|
182
|
+
export function createTransport() {
|
|
183
|
+
return new RestTransport();
|
|
184
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EntitySchema } from '@mostajs/orm';
|
|
2
|
+
import type { ITransport, TransportConfig, TransportInfo, TransportMiddleware } from '../core/types.js';
|
|
3
|
+
import type { ServerResponse } from 'http';
|
|
4
|
+
/**
|
|
5
|
+
* SSETransport streams entity change events to clients via Server-Sent Events.
|
|
6
|
+
*
|
|
7
|
+
* Clients connect to GET /events (or configured path) and receive:
|
|
8
|
+
* event: entity.created
|
|
9
|
+
* data: {"entity":"User","data":{...}}
|
|
10
|
+
*
|
|
11
|
+
* event: entity.updated
|
|
12
|
+
* data: {"entity":"User","id":"123","data":{...}}
|
|
13
|
+
*
|
|
14
|
+
* event: entity.deleted
|
|
15
|
+
* data: {"entity":"User","id":"123"}
|
|
16
|
+
*/
|
|
17
|
+
export declare class SSETransport implements ITransport {
|
|
18
|
+
readonly name = "sse";
|
|
19
|
+
private config;
|
|
20
|
+
private schemas;
|
|
21
|
+
private middlewares;
|
|
22
|
+
private clients;
|
|
23
|
+
private stats;
|
|
24
|
+
use(middleware: TransportMiddleware): void;
|
|
25
|
+
registerEntity(schema: EntitySchema): void;
|
|
26
|
+
start(config: TransportConfig): Promise<void>;
|
|
27
|
+
stop(): Promise<void>;
|
|
28
|
+
getInfo(): TransportInfo;
|
|
29
|
+
/** Get the SSE path for Fastify route registration */
|
|
30
|
+
getPath(): string;
|
|
31
|
+
/** Get connected client count */
|
|
32
|
+
getClientCount(): number;
|
|
33
|
+
/**
|
|
34
|
+
* Handle a new SSE client connection.
|
|
35
|
+
* Called by the server when a GET request arrives at the SSE path.
|
|
36
|
+
*/
|
|
37
|
+
addClient(res: ServerResponse): void;
|
|
38
|
+
/**
|
|
39
|
+
* Broadcast an event to all connected SSE clients.
|
|
40
|
+
* Called by the server when EntityService emits change events.
|
|
41
|
+
*/
|
|
42
|
+
broadcast(eventName: string, data: Record<string, unknown>): void;
|
|
43
|
+
}
|
|
44
|
+
/** Factory function */
|
|
45
|
+
export declare function createTransport(): ITransport;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// SSETransport — Server-Sent Events transport adapter
|
|
2
|
+
// Pushes entity change notifications (created, updated, deleted) to connected clients
|
|
3
|
+
// Author: Dr Hamid MADANI drmdh@msn.com
|
|
4
|
+
/**
|
|
5
|
+
* SSETransport streams entity change events to clients via Server-Sent Events.
|
|
6
|
+
*
|
|
7
|
+
* Clients connect to GET /events (or configured path) and receive:
|
|
8
|
+
* event: entity.created
|
|
9
|
+
* data: {"entity":"User","data":{...}}
|
|
10
|
+
*
|
|
11
|
+
* event: entity.updated
|
|
12
|
+
* data: {"entity":"User","id":"123","data":{...}}
|
|
13
|
+
*
|
|
14
|
+
* event: entity.deleted
|
|
15
|
+
* data: {"entity":"User","id":"123"}
|
|
16
|
+
*/
|
|
17
|
+
export class SSETransport {
|
|
18
|
+
name = 'sse';
|
|
19
|
+
config = null;
|
|
20
|
+
schemas = [];
|
|
21
|
+
middlewares = [];
|
|
22
|
+
clients = new Set();
|
|
23
|
+
stats = { requests: 0, errors: 0, startedAt: 0 };
|
|
24
|
+
use(middleware) {
|
|
25
|
+
this.middlewares.push(middleware);
|
|
26
|
+
}
|
|
27
|
+
registerEntity(schema) {
|
|
28
|
+
this.schemas.push(schema);
|
|
29
|
+
}
|
|
30
|
+
async start(config) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.stats.startedAt = Date.now();
|
|
33
|
+
}
|
|
34
|
+
async stop() {
|
|
35
|
+
// Close all connected clients
|
|
36
|
+
for (const client of this.clients) {
|
|
37
|
+
client.end();
|
|
38
|
+
}
|
|
39
|
+
this.clients.clear();
|
|
40
|
+
}
|
|
41
|
+
getInfo() {
|
|
42
|
+
return {
|
|
43
|
+
name: this.name,
|
|
44
|
+
status: this.config ? 'running' : 'stopped',
|
|
45
|
+
url: this.config?.path || '/events',
|
|
46
|
+
entities: this.schemas.map(s => s.name),
|
|
47
|
+
stats: { ...this.stats },
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Get the SSE path for Fastify route registration */
|
|
51
|
+
getPath() {
|
|
52
|
+
return this.config?.path || '/events';
|
|
53
|
+
}
|
|
54
|
+
/** Get connected client count */
|
|
55
|
+
getClientCount() {
|
|
56
|
+
return this.clients.size;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Handle a new SSE client connection.
|
|
60
|
+
* Called by the server when a GET request arrives at the SSE path.
|
|
61
|
+
*/
|
|
62
|
+
addClient(res) {
|
|
63
|
+
this.stats.requests++;
|
|
64
|
+
// SSE headers
|
|
65
|
+
res.writeHead(200, {
|
|
66
|
+
'Content-Type': 'text/event-stream',
|
|
67
|
+
'Cache-Control': 'no-cache',
|
|
68
|
+
'Connection': 'keep-alive',
|
|
69
|
+
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
70
|
+
});
|
|
71
|
+
// Send initial comment (keep-alive)
|
|
72
|
+
res.write(':ok\n\n');
|
|
73
|
+
// Send connected event with available entities
|
|
74
|
+
const connectData = JSON.stringify({
|
|
75
|
+
entities: this.schemas.map(s => s.name),
|
|
76
|
+
connectedAt: new Date().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
res.write(`event: connected\ndata: ${connectData}\n\n`);
|
|
79
|
+
this.clients.add(res);
|
|
80
|
+
// Remove client on disconnect
|
|
81
|
+
res.on('close', () => {
|
|
82
|
+
this.clients.delete(res);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Broadcast an event to all connected SSE clients.
|
|
87
|
+
* Called by the server when EntityService emits change events.
|
|
88
|
+
*/
|
|
89
|
+
broadcast(eventName, data) {
|
|
90
|
+
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
91
|
+
for (const client of this.clients) {
|
|
92
|
+
client.write(payload);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Factory function */
|
|
97
|
+
export function createTransport() {
|
|
98
|
+
return new SSETransport();
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { EntitySchema, OrmRequest, OrmResponse } from '@mostajs/orm';
|
|
2
|
+
import type { ITransport, TransportConfig, TransportInfo, TransportMiddleware, TransportContext } from '../core/types.js';
|
|
3
|
+
type OrmHandler = (req: OrmRequest, ctx: TransportContext) => Promise<OrmResponse>;
|
|
4
|
+
export declare class WebSocketTransport implements ITransport {
|
|
5
|
+
readonly name = "ws";
|
|
6
|
+
private wss;
|
|
7
|
+
private config;
|
|
8
|
+
private schemas;
|
|
9
|
+
private middlewares;
|
|
10
|
+
private ormHandler;
|
|
11
|
+
private stats;
|
|
12
|
+
setHandler(handler: OrmHandler): void;
|
|
13
|
+
use(mw: TransportMiddleware): void;
|
|
14
|
+
registerEntity(schema: EntitySchema): void;
|
|
15
|
+
start(config: TransportConfig): Promise<void>;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
getInfo(): TransportInfo;
|
|
18
|
+
/** Get connected client count */
|
|
19
|
+
getClientCount(): number;
|
|
20
|
+
/**
|
|
21
|
+
* Attach WebSocket server to an existing HTTP server.
|
|
22
|
+
* Called by server.ts after Fastify starts listening.
|
|
23
|
+
*/
|
|
24
|
+
attachToServer(httpServer: import('http').Server): void;
|
|
25
|
+
/**
|
|
26
|
+
* Broadcast an entity change event to all connected WebSocket clients.
|
|
27
|
+
*/
|
|
28
|
+
broadcast(eventName: string, data: Record<string, unknown>): void;
|
|
29
|
+
private handleRequest;
|
|
30
|
+
}
|
|
31
|
+
/** Factory */
|
|
32
|
+
export declare function createTransport(): ITransport;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// WebSocketTransport — Bidirectional WebSocket transport
|
|
2
|
+
// Clients send OrmRequest as JSON, receive OrmResponse + entity change events
|
|
3
|
+
// Author: Dr Hamid MADANI drmdh@msn.com
|
|
4
|
+
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
|
+
export class WebSocketTransport {
|
|
6
|
+
name = 'ws';
|
|
7
|
+
wss = null;
|
|
8
|
+
config = null;
|
|
9
|
+
schemas = [];
|
|
10
|
+
middlewares = [];
|
|
11
|
+
ormHandler = null;
|
|
12
|
+
stats = { requests: 0, errors: 0, startedAt: 0 };
|
|
13
|
+
setHandler(handler) { this.ormHandler = handler; }
|
|
14
|
+
use(mw) { this.middlewares.push(mw); }
|
|
15
|
+
registerEntity(schema) { this.schemas.push(schema); }
|
|
16
|
+
async start(config) {
|
|
17
|
+
this.config = config;
|
|
18
|
+
this.stats.startedAt = Date.now();
|
|
19
|
+
// WSS is created later when the HTTP server is available (see attachToServer)
|
|
20
|
+
}
|
|
21
|
+
async stop() {
|
|
22
|
+
if (this.wss) {
|
|
23
|
+
for (const client of this.wss.clients) {
|
|
24
|
+
client.close(1000, 'Server shutting down');
|
|
25
|
+
}
|
|
26
|
+
this.wss.close();
|
|
27
|
+
this.wss = null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
getInfo() {
|
|
31
|
+
return {
|
|
32
|
+
name: this.name,
|
|
33
|
+
status: this.wss ? 'running' : 'stopped',
|
|
34
|
+
url: this.config?.path || '/ws',
|
|
35
|
+
entities: this.schemas.map(s => s.name),
|
|
36
|
+
stats: { ...this.stats },
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** Get connected client count */
|
|
40
|
+
getClientCount() {
|
|
41
|
+
return this.wss?.clients.size || 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Attach WebSocket server to an existing HTTP server.
|
|
45
|
+
* Called by server.ts after Fastify starts listening.
|
|
46
|
+
*/
|
|
47
|
+
attachToServer(httpServer) {
|
|
48
|
+
const path = this.config?.path || '/ws';
|
|
49
|
+
this.wss = new WebSocketServer({ server: httpServer, path });
|
|
50
|
+
this.wss.on('connection', (socket) => {
|
|
51
|
+
// Send welcome message
|
|
52
|
+
socket.send(JSON.stringify({
|
|
53
|
+
type: 'connected',
|
|
54
|
+
entities: this.schemas.map(s => s.name),
|
|
55
|
+
connectedAt: new Date().toISOString(),
|
|
56
|
+
}));
|
|
57
|
+
// Handle incoming messages (OrmRequest)
|
|
58
|
+
socket.on('message', async (raw) => {
|
|
59
|
+
this.stats.requests++;
|
|
60
|
+
try {
|
|
61
|
+
const req = JSON.parse(raw.toString());
|
|
62
|
+
const res = await this.handleRequest(req);
|
|
63
|
+
socket.send(JSON.stringify({ type: 'response', ...res }));
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
this.stats.errors++;
|
|
67
|
+
socket.send(JSON.stringify({
|
|
68
|
+
type: 'response',
|
|
69
|
+
status: 'error',
|
|
70
|
+
error: { code: 'PARSE_ERROR', message: err.message },
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Broadcast an entity change event to all connected WebSocket clients.
|
|
78
|
+
*/
|
|
79
|
+
broadcast(eventName, data) {
|
|
80
|
+
if (!this.wss)
|
|
81
|
+
return;
|
|
82
|
+
const payload = JSON.stringify({ type: 'event', event: eventName, ...data });
|
|
83
|
+
for (const client of this.wss.clients) {
|
|
84
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
85
|
+
client.send(payload);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async handleRequest(req) {
|
|
90
|
+
if (!this.ormHandler) {
|
|
91
|
+
return { status: 'error', error: { code: 'NO_HANDLER', message: 'ORM handler not initialized' } };
|
|
92
|
+
}
|
|
93
|
+
const ctx = { transport: this.name };
|
|
94
|
+
return this.ormHandler(req, ctx);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Factory */
|
|
98
|
+
export function createTransport() {
|
|
99
|
+
return new WebSocketTransport();
|
|
100
|
+
}
|