@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/function/function-runner.js +2 -2
  3. package/dist/services/file-channel-store.d.ts +19 -0
  4. package/dist/services/file-channel-store.js +69 -0
  5. package/dist/services/file-eventhub-store.d.ts +17 -0
  6. package/dist/services/file-eventhub-store.js +71 -0
  7. package/dist/wirings/channel/channel-common.d.ts +28 -0
  8. package/dist/wirings/channel/channel-common.js +42 -0
  9. package/dist/wirings/channel/channel-handler.js +37 -11
  10. package/dist/wirings/channel/channel-runner.js +9 -4
  11. package/dist/wirings/channel/channel.types.d.ts +8 -2
  12. package/dist/wirings/channel/local/local-channel-handler.js +3 -0
  13. package/dist/wirings/channel/local/local-channel-runner.js +47 -14
  14. package/dist/wirings/channel/serverless/serverless-channel-runner.js +90 -41
  15. package/dist/wirings/cli/channel/cli-channel-runner.js +9 -0
  16. package/dist/wirings/cli/cli-runner.d.ts +1 -1
  17. package/dist/wirings/http/http-runner.js +0 -1
  18. package/dist/wirings/mcp/mcp-runner.js +3 -2
  19. package/dist/wirings/rpc/rpc-runner.d.ts +11 -0
  20. package/dist/wirings/rpc/rpc-runner.js +1 -1
  21. package/package.json +3 -3
  22. package/src/function/function-runner.ts +2 -2
  23. package/src/services/file-channel-store.ts +86 -0
  24. package/src/services/file-eventhub-store.ts +90 -0
  25. package/src/wirings/channel/channel-common.ts +80 -0
  26. package/src/wirings/channel/channel-handler.ts +48 -18
  27. package/src/wirings/channel/channel-runner.ts +15 -4
  28. package/src/wirings/channel/channel.types.ts +12 -2
  29. package/src/wirings/channel/local/local-channel-handler.ts +3 -0
  30. package/src/wirings/channel/local/local-channel-runner.ts +48 -36
  31. package/src/wirings/channel/serverless/serverless-channel-runner.ts +134 -66
  32. package/src/wirings/cli/channel/cli-channel-runner.ts +12 -0
  33. package/src/wirings/cli/cli-runner.ts +1 -1
  34. package/src/wirings/http/http-runner.ts +0 -2
  35. package/src/wirings/mcp/mcp-runner.ts +5 -2
  36. package/src/wirings/rpc/rpc-runner.ts +1 -1
  37. 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 { runPikkuFuncDirectly } from '../../../function/function-runner.js';
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[channelName];
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
- const main = async () => {
48
- try {
49
- await channelStore.addChannel({
50
- channelId,
51
- channelName: channelConfig.name,
52
- openingData,
53
- channelObject,
54
- });
55
- const { channel } = getVariablesForChannel({
56
- channelId,
57
- channelHandlerFactory,
58
- channelName: channelConfig.name,
59
- });
60
- if (createSessionServices) {
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
- catch (e) {
69
- handleHTTPError(e, http, channelId, singletonServices.logger, logWarningsForStatusCodes, respondWith404, bubbleErrors);
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
- finally {
72
- if (sessionServices) {
73
- await closeSessionServices(singletonServices.logger, sessionServices);
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
- const { openingData, channelName, session } = await params.channelStore.getChannelAndSession(params.channelId);
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
- await runPikkuFuncDirectly(meta.disconnect.pikkuFuncName, { ...singletonServices, ...sessionServices, channel }, undefined);
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({ ...singletonServices, ...sessionServices }, channelConfig, channelHandler);
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;
@@ -11,7 +11,7 @@ const getPikkuFunctionName = (rpcName) => {
11
11
  return rpcMeta;
12
12
  };
13
13
  // Context-aware RPC client for use within services
14
- class ContextAwareRPCService {
14
+ export class ContextAwareRPCService {
15
15
  services;
16
16
  interaction;
17
17
  options;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.10.1",
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: inheritedMiddleware,
90
+ middleware: routeInheritedMiddleware,
91
91
  permissions: inheritedPermissions,
92
92
  } = getRouteMeta(channelConfig.name, routingProperty, routerValue)
93
93
 
94
- const wirePermissions =
95
- typeof onMessage === 'function' ? undefined : onMessage.permissions
94
+ // Get wire middleware: channel-level middleware + message-specific middleware
95
+ const channelWireMiddleware = channelConfig.middleware || []
96
96
 
97
- const wireMiddleware =
98
- typeof onMessage === 'function' ? [] : onMessage.middleware
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
- channelConfig.name,
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
- const onMessage = async (rawData): Promise<unknown> => {
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
- const messageData = JSON.parse(rawData)
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 = await processMessage(
136
- messageData,
137
- routes[routerValue],
138
- routingProperty,
139
- routerValue
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
  }