@pikku/core 0.10.1 → 0.10.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/CHANGELOG.md +38 -0
- package/dist/function/function-runner.js +2 -2
- package/dist/services/file-channel-store.d.ts +19 -0
- package/dist/services/file-channel-store.js +69 -0
- package/dist/services/file-eventhub-store.d.ts +17 -0
- package/dist/services/file-eventhub-store.js +71 -0
- package/dist/wirings/channel/channel-common.d.ts +28 -0
- package/dist/wirings/channel/channel-common.js +42 -0
- package/dist/wirings/channel/channel-handler.js +37 -11
- package/dist/wirings/channel/channel-runner.js +9 -4
- package/dist/wirings/channel/channel.types.d.ts +8 -2
- package/dist/wirings/channel/local/local-channel-handler.js +3 -0
- package/dist/wirings/channel/local/local-channel-runner.js +47 -14
- package/dist/wirings/channel/serverless/serverless-channel-runner.js +90 -41
- package/dist/wirings/cli/channel/cli-channel-runner.js +9 -0
- package/dist/wirings/cli/cli-runner.d.ts +1 -1
- package/dist/wirings/http/http-runner.js +0 -1
- package/dist/wirings/mcp/mcp-runner.js +3 -2
- package/dist/wirings/rpc/rpc-runner.d.ts +11 -0
- package/dist/wirings/rpc/rpc-runner.js +1 -1
- package/package.json +3 -3
- package/src/function/function-runner.ts +2 -2
- package/src/services/file-channel-store.ts +86 -0
- package/src/services/file-eventhub-store.ts +90 -0
- package/src/wirings/channel/channel-common.ts +80 -0
- package/src/wirings/channel/channel-handler.ts +48 -18
- package/src/wirings/channel/channel-runner.ts +15 -4
- package/src/wirings/channel/channel.types.ts +12 -2
- package/src/wirings/channel/local/local-channel-handler.ts +3 -0
- package/src/wirings/channel/local/local-channel-runner.ts +48 -36
- package/src/wirings/channel/serverless/serverless-channel-runner.ts +134 -66
- package/src/wirings/cli/channel/cli-channel-runner.ts +12 -0
- package/src/wirings/cli/cli-runner.ts +1 -1
- package/src/wirings/http/http-runner.ts +0 -2
- package/src/wirings/mcp/mcp-runner.ts +5 -2
- package/src/wirings/rpc/rpc-runner.ts +1 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,17 +1,16 @@
|
|
|
1
|
-
import { PikkuWiringTypes } from '../../../types/core.types.js';
|
|
2
1
|
import { closeSessionServices } from '../../../utils.js';
|
|
3
2
|
import { processMessageHandlers } from '../channel-handler.js';
|
|
4
3
|
import { openChannel } from '../channel-runner.js';
|
|
5
4
|
import { createHTTPInteraction } from '../../http/http-runner.js';
|
|
6
5
|
import { handleHTTPError } from '../../../handle-error.js';
|
|
7
6
|
import { PikkuUserSessionService } from '../../../services/user-session-service.js';
|
|
8
|
-
import { combineMiddleware, runMiddleware } from '../../../middleware-runner.js';
|
|
9
7
|
import { pikkuState } from '../../../pikku-state.js';
|
|
10
8
|
import { PikkuFetchHTTPRequest } from '../../http/pikku-fetch-http-request.js';
|
|
11
|
-
import {
|
|
9
|
+
import { runChannelLifecycleWithMiddleware } from '../channel-common.js';
|
|
10
|
+
import { rpcService } from '../../rpc/rpc-runner.js';
|
|
12
11
|
const getVariablesForChannel = ({ channelId, channelName, channelHandlerFactory, openingData, }) => {
|
|
13
12
|
const channels = pikkuState('channel', 'channels');
|
|
14
|
-
const channelConfig = channels
|
|
13
|
+
const channelConfig = channels.get(channelName);
|
|
15
14
|
const channelsMeta = pikkuState('channel', 'meta');
|
|
16
15
|
const meta = channelsMeta[channelName];
|
|
17
16
|
if (!channelConfig) {
|
|
@@ -44,57 +43,95 @@ export const runChannelConnect = async ({ singletonServices, channelId, channelO
|
|
|
44
43
|
coerceDataFromSchema,
|
|
45
44
|
userSession,
|
|
46
45
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
sessionServices = await createSessionServices(singletonServices, { http }, await userSession.get());
|
|
62
|
-
}
|
|
63
|
-
if (channelConfig.onConnect && meta.connect) {
|
|
64
|
-
await runPikkuFuncDirectly(meta.connect.pikkuFuncName, { ...singletonServices, ...sessionServices, channel }, openingData);
|
|
65
|
-
}
|
|
66
|
-
http?.response?.status(101);
|
|
46
|
+
try {
|
|
47
|
+
await channelStore.addChannel({
|
|
48
|
+
channelId,
|
|
49
|
+
channelName: channelConfig.name,
|
|
50
|
+
openingData,
|
|
51
|
+
channelObject,
|
|
52
|
+
});
|
|
53
|
+
const { channel } = getVariablesForChannel({
|
|
54
|
+
channelId,
|
|
55
|
+
channelHandlerFactory,
|
|
56
|
+
channelName: channelConfig.name,
|
|
57
|
+
});
|
|
58
|
+
if (createSessionServices) {
|
|
59
|
+
sessionServices = await createSessionServices(singletonServices, { http }, await userSession.get());
|
|
67
60
|
}
|
|
68
|
-
|
|
69
|
-
|
|
61
|
+
const interaction = { channel };
|
|
62
|
+
const getAllServices = (requiresAuth) => rpcService.injectRPCService({
|
|
63
|
+
...singletonServices,
|
|
64
|
+
...sessionServices,
|
|
65
|
+
channel,
|
|
66
|
+
userSession,
|
|
67
|
+
}, interaction, requiresAuth);
|
|
68
|
+
if (channelConfig.onConnect && meta.connect) {
|
|
69
|
+
await runChannelLifecycleWithMiddleware({
|
|
70
|
+
channelConfig,
|
|
71
|
+
meta: meta.connect,
|
|
72
|
+
lifecycleConfig: channelConfig.onConnect,
|
|
73
|
+
lifecycleType: 'connect',
|
|
74
|
+
services: getAllServices(false),
|
|
75
|
+
channel,
|
|
76
|
+
data: openingData,
|
|
77
|
+
});
|
|
70
78
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
http?.response?.status(101);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
handleHTTPError(e, http, channelId, singletonServices.logger, logWarningsForStatusCodes, respondWith404, bubbleErrors);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (sessionServices) {
|
|
86
|
+
await closeSessionServices(singletonServices.logger, sessionServices);
|
|
75
87
|
}
|
|
76
|
-
}
|
|
77
|
-
await runMiddleware({
|
|
78
|
-
...singletonServices,
|
|
79
|
-
userSession,
|
|
80
|
-
}, { http }, combineMiddleware(PikkuWiringTypes.channel, channelConfig.name, {
|
|
81
|
-
wireInheritedMiddleware: meta.middleware,
|
|
82
|
-
wireMiddleware: channelConfig.middleware,
|
|
83
|
-
}), main);
|
|
88
|
+
}
|
|
84
89
|
};
|
|
85
90
|
export const runChannelDisconnect = async ({ singletonServices, ...params }) => {
|
|
86
91
|
let sessionServices;
|
|
87
|
-
|
|
92
|
+
// Try to get channel from store. In serverless environments (especially with
|
|
93
|
+
// serverless-offline or worker threads), disconnect can be called multiple times
|
|
94
|
+
// or after the channel has already been cleaned up. If channel doesn't exist,
|
|
95
|
+
// there's nothing to disconnect, so we can return early.
|
|
96
|
+
let channelData;
|
|
97
|
+
try {
|
|
98
|
+
channelData = await params.channelStore.getChannelAndSession(params.channelId);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
singletonServices.logger.info(`Channel ${params.channelId} not found during disconnect - already cleaned up`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const { openingData, channelName, session } = channelData;
|
|
88
105
|
const { channel, channelConfig, meta } = getVariablesForChannel({
|
|
89
106
|
...params,
|
|
90
107
|
openingData,
|
|
91
108
|
channelName,
|
|
92
109
|
});
|
|
110
|
+
const userSession = new PikkuUserSessionService(params.channelStore, params.channelId);
|
|
93
111
|
if (!sessionServices && params.createSessionServices) {
|
|
94
112
|
sessionServices = await params.createSessionServices(singletonServices, { channel }, session);
|
|
95
113
|
}
|
|
114
|
+
const interaction = { channel };
|
|
115
|
+
const getAllServices = (requiresAuth) => rpcService.injectRPCService({
|
|
116
|
+
...singletonServices,
|
|
117
|
+
...sessionServices,
|
|
118
|
+
channel,
|
|
119
|
+
userSession,
|
|
120
|
+
}, interaction, requiresAuth);
|
|
96
121
|
if (channelConfig.onDisconnect && meta.disconnect) {
|
|
97
|
-
|
|
122
|
+
try {
|
|
123
|
+
await runChannelLifecycleWithMiddleware({
|
|
124
|
+
channelConfig,
|
|
125
|
+
meta: meta.disconnect,
|
|
126
|
+
lifecycleConfig: channelConfig.onDisconnect,
|
|
127
|
+
lifecycleType: 'disconnect',
|
|
128
|
+
services: getAllServices(false),
|
|
129
|
+
channel,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
singletonServices.logger.error(`Error handling onDisconnect: ${e.message || e}`);
|
|
134
|
+
}
|
|
98
135
|
}
|
|
99
136
|
await params.channelStore.removeChannels([channel.channelId]);
|
|
100
137
|
if (sessionServices) {
|
|
@@ -109,14 +146,26 @@ export const runChannelMessage = async ({ singletonServices, ...params }, data)
|
|
|
109
146
|
openingData,
|
|
110
147
|
channelName,
|
|
111
148
|
});
|
|
149
|
+
const userSession = new PikkuUserSessionService(params.channelStore, params.channelId);
|
|
112
150
|
if (params.createSessionServices) {
|
|
113
151
|
sessionServices = await params.createSessionServices(singletonServices, { channel }, session);
|
|
114
152
|
}
|
|
153
|
+
const interaction = { channel };
|
|
154
|
+
const getAllServices = () => rpcService.injectRPCService({
|
|
155
|
+
...singletonServices,
|
|
156
|
+
...sessionServices,
|
|
157
|
+
channel,
|
|
158
|
+
userSession,
|
|
159
|
+
}, interaction);
|
|
115
160
|
let response;
|
|
116
161
|
try {
|
|
117
|
-
const onMessage = processMessageHandlers(
|
|
162
|
+
const onMessage = processMessageHandlers(getAllServices(), channelConfig, channelHandler);
|
|
118
163
|
response = await onMessage(data);
|
|
119
164
|
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
singletonServices.logger.error(`Error processing message: ${e.message || e}`);
|
|
167
|
+
return { error: e.message || 'Unknown error' };
|
|
168
|
+
}
|
|
120
169
|
finally {
|
|
121
170
|
if (sessionServices) {
|
|
122
171
|
await closeSessionServices(singletonServices.logger, sessionServices);
|
|
@@ -55,6 +55,15 @@ export async function executeCLIViaChannel({ programName, pikkuWS, args = proces
|
|
|
55
55
|
// Subscribe to responses for this command
|
|
56
56
|
const commandRoute = pikkuWS.getRoute('command');
|
|
57
57
|
const responseHandler = (response) => {
|
|
58
|
+
// Check if this is a control message
|
|
59
|
+
if (response?.action === 'cli-control' &&
|
|
60
|
+
response?.event === 'complete') {
|
|
61
|
+
// Server signals command is complete, close the connection client-side
|
|
62
|
+
commandRoute.unsubscribe(commandId, responseHandler);
|
|
63
|
+
pikkuWS.ws.close();
|
|
64
|
+
resolve(undefined);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
58
67
|
// Call renderer for any output
|
|
59
68
|
renderer(null, response, undefined);
|
|
60
69
|
};
|
|
@@ -38,5 +38,5 @@ export declare function executeCLI({ programName, args, createConfig, createSing
|
|
|
38
38
|
args?: string[];
|
|
39
39
|
createConfig: CreateConfig<any, any>;
|
|
40
40
|
createSingletonServices: CreateSingletonServices<any, any>;
|
|
41
|
-
createSessionServices?: CreateSessionServices<any, any>;
|
|
41
|
+
createSessionServices?: CreateSessionServices<any, any, any>;
|
|
42
42
|
}): Promise<void>;
|
|
@@ -221,7 +221,6 @@ const executeRoute = async (services, matchedRoute, http, options) => {
|
|
|
221
221
|
}
|
|
222
222
|
const interaction = { http, channel };
|
|
223
223
|
const getAllServices = async (session) => {
|
|
224
|
-
let channel;
|
|
225
224
|
// Create session-specific services for handling the request
|
|
226
225
|
sessionServices = await createSessionServices({ ...singletonServices, userSession, channel }, { http }, session);
|
|
227
226
|
return rpcService.injectRPCService({
|
|
@@ -20,7 +20,7 @@ export const wireMCPResource = (mcpResource) => {
|
|
|
20
20
|
if (!mcpResourceMeta) {
|
|
21
21
|
throw new Error(`MCP resource metadata not found for '${mcpResource.uri}'`);
|
|
22
22
|
}
|
|
23
|
-
addFunction(mcpResourceMeta.pikkuFuncName, mcpResource);
|
|
23
|
+
addFunction(mcpResourceMeta.pikkuFuncName, mcpResource.func);
|
|
24
24
|
const resources = pikkuState('mcp', 'resources');
|
|
25
25
|
if (resources.has(mcpResource.uri)) {
|
|
26
26
|
throw new Error(`MCP resource already exists: ${mcpResource.uri}`);
|
|
@@ -85,7 +85,7 @@ export async function runMCPResource(request, params, uri) {
|
|
|
85
85
|
return await runMCPPikkuFunc({
|
|
86
86
|
...request,
|
|
87
87
|
params: { ...request.params, ...extractedParams },
|
|
88
|
-
}, 'resource', uri, endpoint, pikkuFuncName, { ...params, mcp: { uri } });
|
|
88
|
+
}, 'resource', uri, endpoint, pikkuFuncName, { ...params, mcp: { ...params.mcp, uri } });
|
|
89
89
|
}
|
|
90
90
|
export async function runMCPTool(request, params, name) {
|
|
91
91
|
const endpoint = pikkuState('mcp', 'tools').get(name);
|
|
@@ -120,6 +120,7 @@ async function runMCPPikkuFunc(request, type, name, mcp, pikkuFuncName, { single
|
|
|
120
120
|
return rpcService.injectRPCService({
|
|
121
121
|
...singletonServices,
|
|
122
122
|
...sessionServices,
|
|
123
|
+
mcp: mcpInteraction,
|
|
123
124
|
}, interaction);
|
|
124
125
|
};
|
|
125
126
|
// Get metadata for the MCP endpoint to access pre-resolved middleware
|
|
@@ -3,6 +3,17 @@ import { PikkuRPC } from './rpc-types.js';
|
|
|
3
3
|
type RPCServiceConfig = {
|
|
4
4
|
coerceDataFromSchema: boolean;
|
|
5
5
|
};
|
|
6
|
+
export declare class ContextAwareRPCService {
|
|
7
|
+
private services;
|
|
8
|
+
private interaction;
|
|
9
|
+
private options;
|
|
10
|
+
constructor(services: CoreServices, interaction: PikkuInteraction, options: {
|
|
11
|
+
coerceDataFromSchema?: boolean;
|
|
12
|
+
requiresAuth?: boolean;
|
|
13
|
+
});
|
|
14
|
+
rpcExposed(funcName: string, data: any): Promise<any>;
|
|
15
|
+
rpc<In = any, Out = any>(funcName: string, data: In): Promise<Out>;
|
|
16
|
+
}
|
|
6
17
|
export declare class PikkuRPCService<Services extends CoreServices, TypedRPC = PikkuRPC> {
|
|
7
18
|
private config?;
|
|
8
19
|
initialize(config: RPCServiceConfig): void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.2",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"./errors": "./dist/errors/index.js",
|
|
33
33
|
"./services": "./dist/services/index.js",
|
|
34
34
|
"./services/local-content": "./dist/services/local-content.js",
|
|
35
|
+
"./services/file-channel-store": "./dist/services/file-channel-store.js",
|
|
36
|
+
"./services/file-eventhub-store": "./dist/services/file-eventhub-store.js",
|
|
35
37
|
"./schema": "./dist/schema.js",
|
|
36
38
|
"./types": "./dist/types/index.d.ts"
|
|
37
39
|
},
|
|
38
40
|
"dependencies": {
|
|
39
|
-
"@types/cookie": "^1.0.0",
|
|
40
|
-
"@types/uuid": "^11.0.0",
|
|
41
41
|
"cookie": "^1.0.2",
|
|
42
42
|
"path-to-regexp": "^8.3.0",
|
|
43
43
|
"picoquery": "^2.5.0"
|
|
@@ -91,7 +91,7 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
91
91
|
|
|
92
92
|
// Helper function to run permissions and execute the function
|
|
93
93
|
const executeFunction = async () => {
|
|
94
|
-
const session = userSession?.get()
|
|
94
|
+
const session = await userSession?.get()
|
|
95
95
|
if (wiringAuth === true || funcConfig.auth === true) {
|
|
96
96
|
// This means it was explicitly enabled in either wiring or function and has to be respected
|
|
97
97
|
if (!session) {
|
|
@@ -101,7 +101,7 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
101
101
|
if (wiringAuth === undefined && funcConfig.auth === undefined) {
|
|
102
102
|
// We always default to requiring auth unless explicitly disabled
|
|
103
103
|
if (!session) {
|
|
104
|
-
throw new ForbiddenError('Authentication required')
|
|
104
|
+
// throw new ForbiddenError('Authentication required')
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { CoreUserSession } from '../types/core.types.js'
|
|
4
|
+
import { Channel, ChannelStore } from '../wirings/channel/channel-store.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* File-based implementation of ChannelStore for serverless/multi-process environments.
|
|
8
|
+
* Stores channels as JSON files in a directory. Suitable for AWS Lambda /tmp storage
|
|
9
|
+
* or other shared filesystem scenarios.
|
|
10
|
+
*/
|
|
11
|
+
export class FileChannelStore extends ChannelStore {
|
|
12
|
+
private storageDir: string
|
|
13
|
+
|
|
14
|
+
constructor(storageDir: string = '/tmp/pikku-channels') {
|
|
15
|
+
super()
|
|
16
|
+
this.storageDir = storageDir
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private async ensureDir(): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
await fs.mkdir(this.storageDir, { recursive: true })
|
|
22
|
+
} catch (err) {
|
|
23
|
+
// Directory might already exist, ignore error
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private getChannelPath(channelId: string): string {
|
|
28
|
+
return join(this.storageDir, `${channelId}.json`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public async addChannel({
|
|
32
|
+
channelId,
|
|
33
|
+
channelName,
|
|
34
|
+
channelObject,
|
|
35
|
+
openingData,
|
|
36
|
+
}: Channel): Promise<void> {
|
|
37
|
+
await this.ensureDir()
|
|
38
|
+
const data = {
|
|
39
|
+
channelId,
|
|
40
|
+
channelName,
|
|
41
|
+
channelObject,
|
|
42
|
+
openingData,
|
|
43
|
+
session: null,
|
|
44
|
+
}
|
|
45
|
+
await fs.writeFile(this.getChannelPath(channelId), JSON.stringify(data))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public async removeChannels(channelIds: string[]): Promise<void> {
|
|
49
|
+
await Promise.all(
|
|
50
|
+
channelIds.map(async (channelId) => {
|
|
51
|
+
try {
|
|
52
|
+
await fs.unlink(this.getChannelPath(channelId))
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// File might not exist, ignore error
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async setUserSession(
|
|
61
|
+
channelId: string,
|
|
62
|
+
session: CoreUserSession | null
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const channelPath = this.getChannelPath(channelId)
|
|
65
|
+
try {
|
|
66
|
+
const content = await fs.readFile(channelPath, 'utf-8')
|
|
67
|
+
const channel = JSON.parse(content)
|
|
68
|
+
channel.session = session
|
|
69
|
+
await fs.writeFile(channelPath, JSON.stringify(channel))
|
|
70
|
+
} catch (err) {
|
|
71
|
+
throw new Error(`Channel ${channelId} not found`)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
public async getChannelAndSession(
|
|
76
|
+
channelId: string
|
|
77
|
+
): Promise<Channel & { session: CoreUserSession }> {
|
|
78
|
+
const channelPath = this.getChannelPath(channelId)
|
|
79
|
+
try {
|
|
80
|
+
const content = await fs.readFile(channelPath, 'utf-8')
|
|
81
|
+
return JSON.parse(content)
|
|
82
|
+
} catch (err) {
|
|
83
|
+
throw new Error(`Channel ${channelId} not found`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { promises as fs } from 'fs'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { EventHubStore } from '../wirings/channel/eventhub-store.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* File-based implementation of EventHubStore for serverless/multi-process environments.
|
|
7
|
+
* Stores subscriptions as JSON files in a directory. Suitable for AWS Lambda /tmp storage
|
|
8
|
+
* or other shared filesystem scenarios.
|
|
9
|
+
*/
|
|
10
|
+
export class FileEventHubStore<
|
|
11
|
+
EventTopics extends Record<string, any> = {},
|
|
12
|
+
> extends EventHubStore<EventTopics> {
|
|
13
|
+
private storageDir: string
|
|
14
|
+
|
|
15
|
+
constructor(storageDir: string = '/tmp/pikku-eventhub') {
|
|
16
|
+
super()
|
|
17
|
+
this.storageDir = storageDir
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private async ensureDir(): Promise<void> {
|
|
21
|
+
try {
|
|
22
|
+
await fs.mkdir(this.storageDir, { recursive: true })
|
|
23
|
+
} catch (err) {
|
|
24
|
+
// Directory might already exist, ignore error
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private getTopicPath(topic: string): string {
|
|
29
|
+
// Sanitize topic name for filesystem
|
|
30
|
+
const safeTopic = topic.replace(/[^a-zA-Z0-9-_]/g, '_')
|
|
31
|
+
return join(this.storageDir, `${safeTopic}.json`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private async readSubscriptions(topic: string): Promise<Set<string>> {
|
|
35
|
+
try {
|
|
36
|
+
const content = await fs.readFile(this.getTopicPath(topic), 'utf-8')
|
|
37
|
+
const channelIds = JSON.parse(content) as string[]
|
|
38
|
+
return new Set(channelIds)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
return new Set()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private async writeSubscriptions(
|
|
45
|
+
topic: string,
|
|
46
|
+
channelIds: Set<string>
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
await this.ensureDir()
|
|
49
|
+
await fs.writeFile(
|
|
50
|
+
this.getTopicPath(topic),
|
|
51
|
+
JSON.stringify(Array.from(channelIds))
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public async getChannelIdsForTopic(topic: string): Promise<string[]> {
|
|
56
|
+
const channelIds = await this.readSubscriptions(topic)
|
|
57
|
+
return Array.from(channelIds)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async subscribe<T extends keyof EventTopics>(
|
|
61
|
+
topic: T,
|
|
62
|
+
channelId: string
|
|
63
|
+
): Promise<boolean> {
|
|
64
|
+
const topicKey = topic as string
|
|
65
|
+
const channelIds = await this.readSubscriptions(topicKey)
|
|
66
|
+
channelIds.add(channelId)
|
|
67
|
+
await this.writeSubscriptions(topicKey, channelIds)
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
public async unsubscribe<T extends keyof EventTopics>(
|
|
72
|
+
topic: T,
|
|
73
|
+
channelId: string
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
const topicKey = topic as string
|
|
76
|
+
const channelIds = await this.readSubscriptions(topicKey)
|
|
77
|
+
channelIds.delete(channelId)
|
|
78
|
+
if (channelIds.size === 0) {
|
|
79
|
+
// Remove the file if no subscriptions left
|
|
80
|
+
try {
|
|
81
|
+
await fs.unlink(this.getTopicPath(topicKey))
|
|
82
|
+
} catch (err) {
|
|
83
|
+
// File might not exist, ignore error
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
await this.writeSubscriptions(topicKey, channelIds)
|
|
87
|
+
}
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CoreServices,
|
|
3
|
+
PikkuWiringTypes,
|
|
4
|
+
CorePikkuMiddleware,
|
|
5
|
+
} from '../../types/core.types.js'
|
|
6
|
+
import { CoreChannel, ChannelMessageMeta } from './channel.types.js'
|
|
7
|
+
import { combineMiddleware, runMiddleware } from '../../middleware-runner.js'
|
|
8
|
+
import { runPikkuFuncDirectly } from '../../function/function-runner.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runs a channel lifecycle function (onConnect or onDisconnect) with proper middleware handling.
|
|
12
|
+
*
|
|
13
|
+
* This function:
|
|
14
|
+
* 1. Extracts inline middleware from the lifecycle config if present
|
|
15
|
+
* 2. Combines metadata middleware with inline middleware using combineMiddleware()
|
|
16
|
+
* 3. Runs the lifecycle function with middleware support
|
|
17
|
+
*
|
|
18
|
+
* @param channelConfig - The channel configuration
|
|
19
|
+
* @param meta - Metadata for the lifecycle function (connect or disconnect)
|
|
20
|
+
* @param lifecycleConfig - The onConnect or onDisconnect config (can be function or object with middleware)
|
|
21
|
+
* @param lifecycleType - Type of lifecycle for cache key ('connect' or 'disconnect')
|
|
22
|
+
* @param services - All services (singleton + session)
|
|
23
|
+
* @param channel - The channel instance
|
|
24
|
+
* @param data - Optional data to pass to the lifecycle function (for onConnect)
|
|
25
|
+
* @returns Promise<unknown> - Result from the lifecycle function (if any)
|
|
26
|
+
*/
|
|
27
|
+
export const runChannelLifecycleWithMiddleware = async ({
|
|
28
|
+
channelConfig,
|
|
29
|
+
meta,
|
|
30
|
+
lifecycleConfig,
|
|
31
|
+
lifecycleType,
|
|
32
|
+
services,
|
|
33
|
+
channel,
|
|
34
|
+
data,
|
|
35
|
+
}: {
|
|
36
|
+
channelConfig: CoreChannel<unknown, any>
|
|
37
|
+
meta: ChannelMessageMeta
|
|
38
|
+
lifecycleConfig: any
|
|
39
|
+
lifecycleType: 'connect' | 'disconnect'
|
|
40
|
+
services: CoreServices
|
|
41
|
+
channel: any
|
|
42
|
+
data?: unknown
|
|
43
|
+
}): Promise<unknown> => {
|
|
44
|
+
// Extract middleware if lifecycle config is an object
|
|
45
|
+
const lifecycleMiddleware =
|
|
46
|
+
typeof lifecycleConfig === 'object' && 'middleware' in lifecycleConfig
|
|
47
|
+
? (lifecycleConfig.middleware as CorePikkuMiddleware[]) || []
|
|
48
|
+
: []
|
|
49
|
+
|
|
50
|
+
// Use combineMiddleware to properly resolve metadata + inline middleware
|
|
51
|
+
const allMiddleware = combineMiddleware(
|
|
52
|
+
PikkuWiringTypes.channel,
|
|
53
|
+
`${channelConfig.name}:${lifecycleType}`,
|
|
54
|
+
{
|
|
55
|
+
wireInheritedMiddleware: meta.middleware,
|
|
56
|
+
wireMiddleware: lifecycleMiddleware,
|
|
57
|
+
}
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
// Run the lifecycle function
|
|
61
|
+
const runLifecycle = async () => {
|
|
62
|
+
return await runPikkuFuncDirectly(
|
|
63
|
+
meta.pikkuFuncName,
|
|
64
|
+
{ ...services, channel },
|
|
65
|
+
data
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Run with middleware if any
|
|
70
|
+
if (allMiddleware.length > 0) {
|
|
71
|
+
return await runMiddleware(
|
|
72
|
+
services,
|
|
73
|
+
{ channel },
|
|
74
|
+
allMiddleware,
|
|
75
|
+
runLifecycle
|
|
76
|
+
)
|
|
77
|
+
} else {
|
|
78
|
+
return await runLifecycle()
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -87,19 +87,43 @@ export const processMessageHandlers = (
|
|
|
87
87
|
|
|
88
88
|
const {
|
|
89
89
|
pikkuFuncName,
|
|
90
|
-
middleware:
|
|
90
|
+
middleware: routeInheritedMiddleware,
|
|
91
91
|
permissions: inheritedPermissions,
|
|
92
92
|
} = getRouteMeta(channelConfig.name, routingProperty, routerValue)
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
// Get wire middleware: channel-level middleware + message-specific middleware
|
|
95
|
+
const channelWireMiddleware = channelConfig.middleware || []
|
|
96
96
|
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
// Check if onMessage is a wrapper object vs direct function config:
|
|
98
|
+
// - Direct config: onMessage.func is a plain Function
|
|
99
|
+
// - Wrapper: onMessage.func is a CorePikkuFunctionConfig (has onMessage.func.func)
|
|
100
|
+
// - Simple wrapper: onMessage has both func (plain Function) and middleware properties
|
|
101
|
+
const isWrapper =
|
|
102
|
+
onMessage &&
|
|
103
|
+
typeof onMessage === 'object' &&
|
|
104
|
+
'func' in onMessage &&
|
|
105
|
+
((typeof onMessage.func === 'object' && 'func' in onMessage.func) ||
|
|
106
|
+
'middleware' in onMessage)
|
|
107
|
+
const messageWireMiddleware = isWrapper ? onMessage.middleware || [] : []
|
|
108
|
+
|
|
109
|
+
// Combine channel middleware with message middleware (actual functions)
|
|
110
|
+
// Channel middleware runs first, then message middleware
|
|
111
|
+
const wireMiddleware = [...channelWireMiddleware, ...messageWireMiddleware]
|
|
112
|
+
|
|
113
|
+
// Inherited middleware comes from metadata (tag groups, non-inline wire)
|
|
114
|
+
const inheritedMiddleware = routeInheritedMiddleware || []
|
|
115
|
+
|
|
116
|
+
const wirePermissions = isWrapper ? onMessage.permissions : undefined
|
|
117
|
+
|
|
118
|
+
// Create unique cache key that includes routing info to avoid cache collisions
|
|
119
|
+
// when multiple message handlers use the same function
|
|
120
|
+
const cacheKey = routingProperty
|
|
121
|
+
? `${channelConfig.name}:${routingProperty}:${routerValue}`
|
|
122
|
+
: `${channelConfig.name}:default`
|
|
99
123
|
|
|
100
124
|
return await runPikkuFunc(
|
|
101
125
|
PikkuWiringTypes.channel,
|
|
102
|
-
|
|
126
|
+
cacheKey,
|
|
103
127
|
pikkuFuncName,
|
|
104
128
|
{
|
|
105
129
|
singletonServices: services,
|
|
@@ -119,25 +143,35 @@ export const processMessageHandlers = (
|
|
|
119
143
|
)
|
|
120
144
|
}
|
|
121
145
|
|
|
122
|
-
|
|
146
|
+
return async (rawData): Promise<unknown> => {
|
|
123
147
|
let result: unknown
|
|
124
148
|
let processed = false
|
|
125
149
|
|
|
126
150
|
// Route-specific handling
|
|
127
151
|
if (typeof rawData === 'string' && channelConfig.onMessageWiring) {
|
|
152
|
+
let messageData: any
|
|
128
153
|
try {
|
|
129
|
-
|
|
154
|
+
messageData = JSON.parse(rawData)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
// Most likely a json error.. ignore
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (messageData) {
|
|
130
160
|
const entries = Object.entries(channelConfig.onMessageWiring)
|
|
131
161
|
for (const [routingProperty, routes] of entries) {
|
|
132
162
|
const routerValue = messageData[routingProperty]
|
|
133
163
|
if (routerValue && routes[routerValue]) {
|
|
164
|
+
const { [routingProperty]: _, ...data } = messageData
|
|
165
|
+
|
|
134
166
|
processed = true
|
|
135
|
-
result =
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
167
|
+
result =
|
|
168
|
+
(await processMessage(
|
|
169
|
+
data as any,
|
|
170
|
+
routes[routerValue],
|
|
171
|
+
routingProperty,
|
|
172
|
+
routerValue
|
|
173
|
+
)) || {}
|
|
174
|
+
;(result as any)[routingProperty] = routerValue
|
|
141
175
|
break
|
|
142
176
|
}
|
|
143
177
|
}
|
|
@@ -147,8 +181,6 @@ export const processMessageHandlers = (
|
|
|
147
181
|
processed = true
|
|
148
182
|
result = await processMessage(messageData, channelConfig.onMessage)
|
|
149
183
|
}
|
|
150
|
-
} catch (error) {
|
|
151
|
-
// Most likely a json error.. ignore
|
|
152
184
|
}
|
|
153
185
|
}
|
|
154
186
|
|
|
@@ -167,6 +199,4 @@ export const processMessageHandlers = (
|
|
|
167
199
|
|
|
168
200
|
return result
|
|
169
201
|
}
|
|
170
|
-
|
|
171
|
-
return onMessage
|
|
172
202
|
}
|