@onebun/core 0.1.1 → 0.1.2
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/README.md +233 -0
- package/package.json +1 -1
- package/src/application.test.ts +119 -0
- package/src/application.ts +112 -5
- package/src/docs-examples.test.ts +753 -0
- package/src/index.ts +96 -0
- package/src/module.ts +10 -4
- package/src/redis-client.ts +502 -0
- package/src/shared-redis.ts +231 -0
- package/src/types.ts +50 -0
- package/src/ws-base-gateway.test.ts +479 -0
- package/src/ws-base-gateway.ts +514 -0
- package/src/ws-client.test.ts +511 -0
- package/src/ws-client.ts +628 -0
- package/src/ws-client.types.ts +129 -0
- package/src/ws-decorators.test.ts +331 -0
- package/src/ws-decorators.ts +417 -0
- package/src/ws-guards.test.ts +334 -0
- package/src/ws-guards.ts +298 -0
- package/src/ws-handler.ts +658 -0
- package/src/ws-integration.test.ts +517 -0
- package/src/ws-pattern-matcher.test.ts +152 -0
- package/src/ws-pattern-matcher.ts +240 -0
- package/src/ws-service-definition.ts +223 -0
- package/src/ws-socketio-protocol.test.ts +344 -0
- package/src/ws-socketio-protocol.ts +567 -0
- package/src/ws-storage-memory.test.ts +246 -0
- package/src/ws-storage-memory.ts +222 -0
- package/src/ws-storage-redis.ts +302 -0
- package/src/ws-storage.ts +210 -0
- package/src/ws.types.ts +342 -0
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Pattern Matcher
|
|
3
|
+
*
|
|
4
|
+
* Provides pattern matching for WebSocket events and room names.
|
|
5
|
+
* Supports wildcards (*) and named parameters ({paramName}).
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* // Wildcard patterns
|
|
9
|
+
* matchPattern('chat:*', 'chat:general') // { matched: true, params: {} }
|
|
10
|
+
* matchPattern('user:*:action', 'user:123:action') // { matched: true, params: {} }
|
|
11
|
+
*
|
|
12
|
+
* // Parameter patterns
|
|
13
|
+
* matchPattern('chat:{roomId}', 'chat:general') // { matched: true, params: { roomId: 'general' } }
|
|
14
|
+
* matchPattern('user:{id}:message', 'user:123:message') // { matched: true, params: { id: '123' } }
|
|
15
|
+
*
|
|
16
|
+
* // Combined patterns
|
|
17
|
+
* matchPattern('service:{service}:*', 'service:auth:login') // { matched: true, params: { service: 'auth' } }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PatternMatch } from './ws.types';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Escape special regex characters in a string
|
|
24
|
+
*/
|
|
25
|
+
function escapeRegex(str: string): string {
|
|
26
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse a pattern into a regex and extract parameter names
|
|
31
|
+
*
|
|
32
|
+
* @param pattern - Pattern string with wildcards and/or parameters
|
|
33
|
+
* @returns Object with regex and parameter names
|
|
34
|
+
*/
|
|
35
|
+
export function parsePattern(pattern: string): {
|
|
36
|
+
regex: RegExp;
|
|
37
|
+
paramNames: string[];
|
|
38
|
+
} {
|
|
39
|
+
const paramNames: string[] = [];
|
|
40
|
+
let regexStr = '^';
|
|
41
|
+
let i = 0;
|
|
42
|
+
|
|
43
|
+
while (i < pattern.length) {
|
|
44
|
+
const char = pattern[i];
|
|
45
|
+
|
|
46
|
+
if (char === '*') {
|
|
47
|
+
// Wildcard: matches any characters except the separator
|
|
48
|
+
regexStr += '[^:]*';
|
|
49
|
+
i++;
|
|
50
|
+
} else if (char === '{') {
|
|
51
|
+
// Named parameter: {paramName}
|
|
52
|
+
const endIndex = pattern.indexOf('}', i);
|
|
53
|
+
if (endIndex === -1) {
|
|
54
|
+
// Invalid pattern, treat as literal
|
|
55
|
+
regexStr += escapeRegex(char);
|
|
56
|
+
i++;
|
|
57
|
+
} else {
|
|
58
|
+
const paramName = pattern.substring(i + 1, endIndex);
|
|
59
|
+
paramNames.push(paramName);
|
|
60
|
+
// Match any characters except the separator
|
|
61
|
+
regexStr += '([^:]+)';
|
|
62
|
+
i = endIndex + 1;
|
|
63
|
+
}
|
|
64
|
+
} else if (char === ':') {
|
|
65
|
+
// Separator
|
|
66
|
+
regexStr += ':';
|
|
67
|
+
i++;
|
|
68
|
+
} else {
|
|
69
|
+
// Literal character
|
|
70
|
+
regexStr += escapeRegex(char);
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
regexStr += '$';
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
regex: new RegExp(regexStr),
|
|
79
|
+
paramNames,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Match a value against a pattern
|
|
85
|
+
*
|
|
86
|
+
* @param pattern - Pattern string with wildcards and/or parameters
|
|
87
|
+
* @param value - Value to match
|
|
88
|
+
* @returns Match result with extracted parameters
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* matchPattern('chat:*', 'chat:general')
|
|
92
|
+
* // Returns: { matched: true, params: {} }
|
|
93
|
+
*
|
|
94
|
+
* matchPattern('chat:{roomId}:message', 'chat:general:message')
|
|
95
|
+
* // Returns: { matched: true, params: { roomId: 'general' } }
|
|
96
|
+
*
|
|
97
|
+
* matchPattern('chat:private', 'chat:public')
|
|
98
|
+
* // Returns: { matched: false, params: {} }
|
|
99
|
+
*/
|
|
100
|
+
export function matchPattern(pattern: string, value: string): PatternMatch {
|
|
101
|
+
// Exact match fast path
|
|
102
|
+
if (pattern === value) {
|
|
103
|
+
return { matched: true, params: {} };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if pattern has any special characters
|
|
107
|
+
if (!pattern.includes('*') && !pattern.includes('{')) {
|
|
108
|
+
// No wildcards or parameters, must be exact match
|
|
109
|
+
return { matched: false, params: {} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const { regex, paramNames } = parsePattern(pattern);
|
|
113
|
+
const match = value.match(regex);
|
|
114
|
+
|
|
115
|
+
if (!match) {
|
|
116
|
+
return { matched: false, params: {} };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Extract parameters
|
|
120
|
+
const params: Record<string, string> = {};
|
|
121
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
122
|
+
params[paramNames[i]] = match[i + 1];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { matched: true, params };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if a pattern matches a value (without extracting parameters)
|
|
130
|
+
*
|
|
131
|
+
* @param pattern - Pattern string
|
|
132
|
+
* @param value - Value to match
|
|
133
|
+
* @returns Whether the pattern matches
|
|
134
|
+
*/
|
|
135
|
+
export function isPatternMatch(pattern: string, value: string): boolean {
|
|
136
|
+
return matchPattern(pattern, value).matched;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Find all values that match a pattern
|
|
141
|
+
*
|
|
142
|
+
* @param pattern - Pattern string
|
|
143
|
+
* @param values - Array of values to check
|
|
144
|
+
* @returns Array of matching values
|
|
145
|
+
*/
|
|
146
|
+
export function findMatchingValues(pattern: string, values: string[]): string[] {
|
|
147
|
+
return values.filter((value) => isPatternMatch(pattern, value));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Create a pattern matcher function for a specific pattern
|
|
152
|
+
*
|
|
153
|
+
* @param pattern - Pattern string
|
|
154
|
+
* @returns Function that matches values against the pattern
|
|
155
|
+
*
|
|
156
|
+
* @example
|
|
157
|
+
* const matcher = createPatternMatcher('chat:{roomId}:message');
|
|
158
|
+
* matcher('chat:general:message') // { matched: true, params: { roomId: 'general' } }
|
|
159
|
+
* matcher('chat:private:action') // { matched: false, params: {} }
|
|
160
|
+
*/
|
|
161
|
+
export function createPatternMatcher(pattern: string): (value: string) => PatternMatch {
|
|
162
|
+
const { regex, paramNames } = parsePattern(pattern);
|
|
163
|
+
|
|
164
|
+
return (value: string): PatternMatch => {
|
|
165
|
+
// Exact match fast path
|
|
166
|
+
if (pattern === value) {
|
|
167
|
+
return { matched: true, params: {} };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const match = value.match(regex);
|
|
171
|
+
|
|
172
|
+
if (!match) {
|
|
173
|
+
return { matched: false, params: {} };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const params: Record<string, string> = {};
|
|
177
|
+
for (let i = 0; i < paramNames.length; i++) {
|
|
178
|
+
params[paramNames[i]] = match[i + 1];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { matched: true, params };
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if a string is a pattern (contains wildcards or parameters)
|
|
187
|
+
*
|
|
188
|
+
* @param value - String to check
|
|
189
|
+
* @returns Whether the string is a pattern
|
|
190
|
+
*/
|
|
191
|
+
export function isPattern(value: string): boolean {
|
|
192
|
+
return value.includes('*') || value.includes('{');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Extract parameter names from a pattern
|
|
197
|
+
*
|
|
198
|
+
* @param pattern - Pattern string
|
|
199
|
+
* @returns Array of parameter names
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* getPatternParams('chat:{roomId}:user:{userId}')
|
|
203
|
+
* // Returns: ['roomId', 'userId']
|
|
204
|
+
*/
|
|
205
|
+
export function getPatternParams(pattern: string): string[] {
|
|
206
|
+
const params: string[] = [];
|
|
207
|
+
const regex = /\{([^}]+)\}/g;
|
|
208
|
+
let match: RegExpExecArray | null;
|
|
209
|
+
|
|
210
|
+
while ((match = regex.exec(pattern)) !== null) {
|
|
211
|
+
params.push(match[1]);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return params;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Build a value from a pattern and parameters
|
|
219
|
+
*
|
|
220
|
+
* @param pattern - Pattern string
|
|
221
|
+
* @param params - Parameter values
|
|
222
|
+
* @returns Built value string
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* buildFromPattern('chat:{roomId}:message', { roomId: 'general' })
|
|
226
|
+
* // Returns: 'chat:general:message'
|
|
227
|
+
*/
|
|
228
|
+
export function buildFromPattern(pattern: string, params: Record<string, string>): string {
|
|
229
|
+
let result = pattern;
|
|
230
|
+
|
|
231
|
+
// Replace wildcards with empty string (or you could throw an error)
|
|
232
|
+
result = result.replace(/\*/g, '');
|
|
233
|
+
|
|
234
|
+
// Replace parameters with values
|
|
235
|
+
for (const [key, value] of Object.entries(params)) {
|
|
236
|
+
result = result.replace(`{${key}}`, value);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket Service Definition
|
|
3
|
+
*
|
|
4
|
+
* Collects metadata from WebSocket gateways for generating typed clients.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { WsHandlerType, WsParamMetadata } from './ws.types';
|
|
8
|
+
|
|
9
|
+
import { getModuleMetadata } from './decorators';
|
|
10
|
+
import { getGatewayMetadata, isWebSocketGateway } from './ws-decorators';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Metadata for a single WebSocket endpoint/event
|
|
14
|
+
*/
|
|
15
|
+
export interface WsEndpointMetadata {
|
|
16
|
+
/** Gateway class name */
|
|
17
|
+
gateway: string;
|
|
18
|
+
/** Event pattern */
|
|
19
|
+
event: string;
|
|
20
|
+
/** Handler method name */
|
|
21
|
+
handler: string;
|
|
22
|
+
/** Handler type (message, connect, etc.) */
|
|
23
|
+
type: WsHandlerType;
|
|
24
|
+
/** Parameter metadata */
|
|
25
|
+
params?: WsParamMetadata[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Definition of a WebSocket gateway with its events
|
|
30
|
+
*/
|
|
31
|
+
export interface WsGatewayDefinition {
|
|
32
|
+
/** Gateway class name */
|
|
33
|
+
name: string;
|
|
34
|
+
/** WebSocket path */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Namespace (if any) */
|
|
37
|
+
namespace?: string;
|
|
38
|
+
/** Map of event patterns to endpoint metadata */
|
|
39
|
+
events: Map<string, WsEndpointMetadata>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Service definition containing all WebSocket gateways and events.
|
|
44
|
+
* Used for generating typed WebSocket clients.
|
|
45
|
+
*/
|
|
46
|
+
export interface WsServiceDefinition<TModule = unknown> {
|
|
47
|
+
/** Reference to the module class */
|
|
48
|
+
readonly _module: TModule;
|
|
49
|
+
/** Flat list of all endpoints */
|
|
50
|
+
readonly _endpoints: WsEndpointMetadata[];
|
|
51
|
+
/** Gateways grouped by name */
|
|
52
|
+
readonly _gateways: Map<string, WsGatewayDefinition>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Module metadata structure
|
|
57
|
+
*/
|
|
58
|
+
interface ModuleMetadata {
|
|
59
|
+
imports?: Function[];
|
|
60
|
+
controllers?: Function[];
|
|
61
|
+
providers?: unknown[];
|
|
62
|
+
exports?: unknown[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Collect WebSocket endpoints recursively from a module and its imports
|
|
67
|
+
*/
|
|
68
|
+
function collectWsEndpointsRecursively(metadata: ModuleMetadata): WsEndpointMetadata[] {
|
|
69
|
+
const endpoints: WsEndpointMetadata[] = [];
|
|
70
|
+
|
|
71
|
+
// Collect from controllers (gateways are in controllers)
|
|
72
|
+
for (const controller of metadata.controllers || []) {
|
|
73
|
+
// Check if it's a WebSocket gateway
|
|
74
|
+
if (!isWebSocketGateway(controller)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const gatewayMeta = getGatewayMetadata(controller);
|
|
79
|
+
if (!gatewayMeta) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Add each handler as an endpoint
|
|
84
|
+
for (const handler of gatewayMeta.handlers) {
|
|
85
|
+
endpoints.push({
|
|
86
|
+
gateway: controller.name,
|
|
87
|
+
event: handler.pattern || handler.type,
|
|
88
|
+
handler: handler.handler,
|
|
89
|
+
type: handler.type,
|
|
90
|
+
params: handler.params,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Recursively collect from imported modules
|
|
96
|
+
for (const importedModule of metadata.imports || []) {
|
|
97
|
+
const importedMeta = getModuleMetadata(importedModule);
|
|
98
|
+
if (importedMeta) {
|
|
99
|
+
endpoints.push(...collectWsEndpointsRecursively(importedMeta));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return endpoints;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Group endpoints by gateway
|
|
108
|
+
*/
|
|
109
|
+
function groupByGateway(
|
|
110
|
+
endpoints: WsEndpointMetadata[],
|
|
111
|
+
moduleClass: Function,
|
|
112
|
+
): Map<string, WsGatewayDefinition> {
|
|
113
|
+
const gateways = new Map<string, WsGatewayDefinition>();
|
|
114
|
+
const metadata = getModuleMetadata(moduleClass);
|
|
115
|
+
|
|
116
|
+
if (!metadata) {
|
|
117
|
+
return gateways;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// First, collect gateway metadata
|
|
121
|
+
const gatewayPaths = new Map<string, { path: string; namespace?: string }>();
|
|
122
|
+
|
|
123
|
+
for (const controller of metadata.controllers || []) {
|
|
124
|
+
if (isWebSocketGateway(controller)) {
|
|
125
|
+
const gatewayMeta = getGatewayMetadata(controller);
|
|
126
|
+
if (gatewayMeta) {
|
|
127
|
+
gatewayPaths.set(controller.name, {
|
|
128
|
+
path: gatewayMeta.path,
|
|
129
|
+
namespace: gatewayMeta.namespace,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Group endpoints by gateway
|
|
136
|
+
for (const endpoint of endpoints) {
|
|
137
|
+
if (!gateways.has(endpoint.gateway)) {
|
|
138
|
+
const pathInfo = gatewayPaths.get(endpoint.gateway) || { path: '/' };
|
|
139
|
+
gateways.set(endpoint.gateway, {
|
|
140
|
+
name: endpoint.gateway,
|
|
141
|
+
path: pathInfo.path,
|
|
142
|
+
namespace: pathInfo.namespace,
|
|
143
|
+
events: new Map(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
gateways.get(endpoint.gateway)!.events.set(endpoint.event, endpoint);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return gateways;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Create a WebSocket service definition from a module class.
|
|
155
|
+
* Collects all endpoint metadata from gateways and imported modules.
|
|
156
|
+
*
|
|
157
|
+
* @param moduleClass - The root module class decorated with @Module
|
|
158
|
+
* @returns Service definition containing all WebSocket endpoints
|
|
159
|
+
*
|
|
160
|
+
* @example
|
|
161
|
+
* ```typescript
|
|
162
|
+
* import { createWsServiceDefinition } from '@onebun/core';
|
|
163
|
+
* import { ChatModule } from './chat.module';
|
|
164
|
+
*
|
|
165
|
+
* export const chatWsDefinition = createWsServiceDefinition(ChatModule);
|
|
166
|
+
*
|
|
167
|
+
* // Use with createWsClient
|
|
168
|
+
* const client = createWsClient(chatWsDefinition, {
|
|
169
|
+
* url: 'ws://localhost:3000',
|
|
170
|
+
* });
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
export function createWsServiceDefinition<TModule>(
|
|
174
|
+
moduleClass: new (...args: unknown[]) => TModule,
|
|
175
|
+
): WsServiceDefinition<TModule> {
|
|
176
|
+
const metadata = getModuleMetadata(moduleClass);
|
|
177
|
+
if (!metadata) {
|
|
178
|
+
throw new Error(`${moduleClass.name} is not decorated with @Module`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const endpoints = collectWsEndpointsRecursively(metadata);
|
|
182
|
+
const gateways = groupByGateway(endpoints, moduleClass);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
_module: moduleClass as unknown as TModule,
|
|
186
|
+
_endpoints: endpoints,
|
|
187
|
+
_gateways: gateways,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Get gateway names from a service definition
|
|
193
|
+
*/
|
|
194
|
+
export function getWsGatewayNames<TDef extends WsServiceDefinition>(
|
|
195
|
+
definition: TDef,
|
|
196
|
+
): string[] {
|
|
197
|
+
return Array.from(definition._gateways.keys());
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Get event names for a gateway
|
|
202
|
+
*/
|
|
203
|
+
export function getWsEventNames<TDef extends WsServiceDefinition>(
|
|
204
|
+
definition: TDef,
|
|
205
|
+
gatewayName: string,
|
|
206
|
+
): string[] {
|
|
207
|
+
const gateway = definition._gateways.get(gatewayName);
|
|
208
|
+
|
|
209
|
+
return gateway ? Array.from(gateway.events.keys()) : [];
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get endpoint metadata by gateway and event
|
|
214
|
+
*/
|
|
215
|
+
export function getWsEndpoint<TDef extends WsServiceDefinition>(
|
|
216
|
+
definition: TDef,
|
|
217
|
+
gatewayName: string,
|
|
218
|
+
event: string,
|
|
219
|
+
): WsEndpointMetadata | undefined {
|
|
220
|
+
const gateway = definition._gateways.get(gatewayName);
|
|
221
|
+
|
|
222
|
+
return gateway?.events.get(event);
|
|
223
|
+
}
|