@pikku/core 0.12.57 → 0.12.60
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 +29 -0
- package/dist/services/local-gateway-service.js +4 -3
- package/dist/wirings/gateway/gateway-runner.d.ts +2 -1
- package/dist/wirings/gateway/gateway-runner.js +31 -9
- package/dist/wirings/gateway/gateway.types.d.ts +14 -3
- package/dist/wirings/gateway/index.d.ts +2 -2
- package/dist/wirings/gateway/index.js +1 -1
- package/dist/wirings/workflow/graph/graph-runner.js +14 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +4 -0
- package/package.json +2 -2
- package/src/services/local-gateway-service.ts +7 -3
- package/src/wirings/gateway/gateway-runner.test.ts +70 -0
- package/src/wirings/gateway/gateway-runner.ts +37 -8
- package/src/wirings/gateway/gateway.types.ts +17 -2
- package/src/wirings/gateway/index.ts +6 -1
- package/src/wirings/workflow/graph/graph-runner.test.ts +134 -0
- package/src/wirings/workflow/graph/graph-runner.ts +12 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +4 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +78 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
## 0.12.60
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- a3a49f2: fix(workflow): carry `pikkuUserId` onto queued workflow step wires so authed steps rehydrate their session
|
|
6
|
+
|
|
7
|
+
A workflow step invoked on the queued (pg-boss) executor received the bare job wire (payload is just `{ runId }`), so `pikkuUserId` was never on the step wire and an authed step (`pikkuFunc`) threw `Authentication required` — even though the run wire persisted the acting user's id and the inline executor worked. `invokeStepRpc` now reads `pikkuUserId` from the persisted run wire and merges it into the step wire override, so authed steps rehydrate their session via the `SessionStore` on both the inline and queued paths.
|
|
8
|
+
|
|
9
|
+
## 0.12.59
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 1f3f510: Warn when a Pikku function body performs a runtime dynamic `import(...)`.
|
|
14
|
+
|
|
15
|
+
The inspector now flags any `pikkuFunc`/`pikkuSessionlessFunc` (and friends) whose handler body contains a dynamic `import(...)` call — including nested callbacks — with the new `PKU498` diagnostic. Function bodies run on every invocation, so a dynamic import there adds per-call latency and defeats bundling/tree-shaking; the import belongs at the top of the module or in your services/`wireServices` setup instead.
|
|
16
|
+
|
|
17
|
+
Type-only positions like `import('x').Foo` are not flagged. The rule defaults to `warn` — a printed yellow warning that does not fail the build — and is configurable via `lint.functionDynamicImport` in `pikku.config.json` (`'off'` to silence, `'error'` to make it a hard build failure), matching the existing `servicesNotDestructured`/`wiresNotDestructured` lints.
|
|
18
|
+
|
|
19
|
+
## 0.12.58
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 7b17b14: Allow a workflow-graph node's `func` to reference a registered AI agent by name, dispatched as an agent run — exactly like sub-workflows. `executeGraphStep`/`executeGraphNodeInline` now check the agent registry and dispatch matching nodes via the agent-run path (`rpc.agent.run`), so the node's result is the agent's declared output and downstream nodes can `ref()` it. The generated `pikkuWorkflowGraph` wrapper widens its node-func union to also accept `keyof FlattenedWorkflowMap` and `keyof FlattenedAgentMap`, and `ref()` resolves an agent node's output keys.
|
|
24
|
+
- daec082: Drop Node 22 support — the minimum supported runtime is now Node 24 (LTS).
|
|
25
|
+
|
|
26
|
+
Node 22 deadlocks `pikku dev` at `loadUserBootstrap` (tsx `register()` + `require(esm)` cycle handling on node 22.12+), and Node 20 is already below our floor. The `engines.node` requirement is raised to `>=24` across all packages, matching `.nvmrc` and the CI test matrix. Closes #751.
|
|
27
|
+
|
|
28
|
+
- e0fd352: wireGateway: allow `adapter` to be a factory `(services) => GatewayAdapter | Promise<GatewayAdapter>`, resolved lazily on first inbound request (webhook/websocket) or gateway start (listener) and cached. Real platform adapters (WhatsApp Cloud API, Slack) need secrets that only exist after boot, while wireGateway runs at module load — a factory bridges that. Factory adapters register the GET verify route unconditionally since verifyWebhook can't be probed before first resolve.
|
|
29
|
+
|
|
1
30
|
## 0.12.57
|
|
2
31
|
|
|
3
32
|
### Patch Changes
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pikkuState, getSingletonServices } from '../pikku-state.js';
|
|
2
|
-
import { createListenerMessageHandler } from '../wirings/gateway/gateway-runner.js';
|
|
2
|
+
import { createListenerMessageHandler, resolveGatewayAdapter, } from '../wirings/gateway/gateway-runner.js';
|
|
3
3
|
/**
|
|
4
4
|
* Local GatewayService implementation.
|
|
5
5
|
*
|
|
@@ -27,8 +27,9 @@ export class LocalGatewayService {
|
|
|
27
27
|
if (this.activeAdapters.has(name))
|
|
28
28
|
continue;
|
|
29
29
|
const handleMessage = createListenerMessageHandler(name, config, singletonServices);
|
|
30
|
-
await config
|
|
31
|
-
|
|
30
|
+
const adapter = await resolveGatewayAdapter(config, singletonServices);
|
|
31
|
+
await adapter.init(handleMessage);
|
|
32
|
+
this.activeAdapters.set(name, adapter);
|
|
32
33
|
singletonServices.logger.info(`Started listener gateway: ${name}`);
|
|
33
34
|
}
|
|
34
35
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { CoreGateway } from './gateway.types.js';
|
|
1
|
+
import type { CoreGateway, GatewayAdapter } from './gateway.types.js';
|
|
2
|
+
export declare const resolveGatewayAdapter: (config: CoreGateway, services: CoreSingletonServices) => Promise<GatewayAdapter>;
|
|
2
3
|
import type { CoreSingletonServices } from '../../types/core.types.js';
|
|
3
4
|
/**
|
|
4
5
|
* Register a messaging gateway.
|
|
@@ -2,6 +2,23 @@ import { pikkuState } from '../../pikku-state.js';
|
|
|
2
2
|
import { addFunction } from '../../function/function-runner.js';
|
|
3
3
|
import { runMiddleware } from '../../middleware-runner.js';
|
|
4
4
|
import { httpRouter } from '../http/routers/http-router.js';
|
|
5
|
+
/**
|
|
6
|
+
* Lazily resolve a gateway's adapter. Factories are invoked once with the
|
|
7
|
+
* singleton services and cached (promise-cached, so concurrent first
|
|
8
|
+
* requests share one construction).
|
|
9
|
+
*/
|
|
10
|
+
const resolvedAdapters = new WeakMap();
|
|
11
|
+
export const resolveGatewayAdapter = (config, services) => {
|
|
12
|
+
let resolved = resolvedAdapters.get(config);
|
|
13
|
+
if (!resolved) {
|
|
14
|
+
resolved =
|
|
15
|
+
typeof config.adapter === 'function'
|
|
16
|
+
? Promise.resolve(config.adapter(services))
|
|
17
|
+
: Promise.resolve(config.adapter);
|
|
18
|
+
resolvedAdapters.set(config, resolved);
|
|
19
|
+
}
|
|
20
|
+
return resolved;
|
|
21
|
+
};
|
|
5
22
|
/**
|
|
6
23
|
* Register a messaging gateway.
|
|
7
24
|
*
|
|
@@ -67,7 +84,9 @@ const wireWebhookGateway = (config) => {
|
|
|
67
84
|
auth: false,
|
|
68
85
|
});
|
|
69
86
|
// --- GET handler (webhook verification, e.g. WhatsApp challenge) ---------
|
|
70
|
-
|
|
87
|
+
// Factory adapters can't be probed for verifyWebhook until first resolve,
|
|
88
|
+
// so register the GET route unconditionally for them.
|
|
89
|
+
if (typeof adapter === 'function' || adapter.verifyWebhook) {
|
|
71
90
|
const verifyFuncId = `gateway__${name}__verify`;
|
|
72
91
|
funcMeta[verifyFuncId] = {
|
|
73
92
|
pikkuFuncId: verifyFuncId,
|
|
@@ -82,7 +101,7 @@ const wireWebhookGateway = (config) => {
|
|
|
82
101
|
};
|
|
83
102
|
const verifyHandler = {
|
|
84
103
|
auth: false,
|
|
85
|
-
func: createWebhookVerifyHandler(
|
|
104
|
+
func: createWebhookVerifyHandler(config),
|
|
86
105
|
};
|
|
87
106
|
addFunction(verifyFuncId, verifyHandler);
|
|
88
107
|
if (!routes.has('get')) {
|
|
@@ -110,9 +129,10 @@ const wireWebhookGateway = (config) => {
|
|
|
110
129
|
* 6. Auto-send response via adapter if func returns outbound content
|
|
111
130
|
*/
|
|
112
131
|
const createWebhookPostHandler = (config) => {
|
|
113
|
-
const { name,
|
|
132
|
+
const { name, func: userFunc, middleware: userMiddleware } = config;
|
|
114
133
|
const userFuncConfig = userFunc;
|
|
115
134
|
return async (services, data, wire) => {
|
|
135
|
+
const adapter = await resolveGatewayAdapter(config, services);
|
|
116
136
|
// Check for POST-based webhook verification (e.g. Slack url_verification)
|
|
117
137
|
if (adapter.verifyWebhook) {
|
|
118
138
|
const verifyResult = await adapter.verifyWebhook(data, wire.http?.request);
|
|
@@ -157,8 +177,9 @@ const createWebhookPostHandler = (config) => {
|
|
|
157
177
|
* Creates the GET handler for webhook verification challenges.
|
|
158
178
|
* Passes query parameters to the adapter's verifyWebhook method.
|
|
159
179
|
*/
|
|
160
|
-
const createWebhookVerifyHandler = (
|
|
161
|
-
return async (
|
|
180
|
+
const createWebhookVerifyHandler = (config) => {
|
|
181
|
+
return async (services, _data, wire) => {
|
|
182
|
+
const adapter = await resolveGatewayAdapter(config, services);
|
|
162
183
|
if (!adapter.verifyWebhook) {
|
|
163
184
|
return { error: 'Verification not supported' };
|
|
164
185
|
}
|
|
@@ -174,7 +195,7 @@ const createWebhookVerifyHandler = (adapter) => {
|
|
|
174
195
|
// WebSocket gateway — client connects via WebSocket
|
|
175
196
|
// ---------------------------------------------------------------------------
|
|
176
197
|
const wireWebsocketGateway = (config) => {
|
|
177
|
-
const { name, route
|
|
198
|
+
const { name, route } = config;
|
|
178
199
|
if (!route) {
|
|
179
200
|
throw new Error(`WebSocket gateway '${name}' requires a route`);
|
|
180
201
|
}
|
|
@@ -211,8 +232,8 @@ const wireWebsocketGateway = (config) => {
|
|
|
211
232
|
// Register onConnect
|
|
212
233
|
addFunction(connectFuncId, {
|
|
213
234
|
auth: false,
|
|
214
|
-
func: async (
|
|
215
|
-
;
|
|
235
|
+
func: async (services, _data, wire) => {
|
|
236
|
+
const adapter = await resolveGatewayAdapter(config, services);
|
|
216
237
|
wire.gateway = {
|
|
217
238
|
gatewayName: name,
|
|
218
239
|
senderId: '',
|
|
@@ -227,6 +248,7 @@ const wireWebsocketGateway = (config) => {
|
|
|
227
248
|
addFunction(messageFuncId, {
|
|
228
249
|
auth: false,
|
|
229
250
|
func: async (services, data, wire) => {
|
|
251
|
+
const adapter = await resolveGatewayAdapter(config, services);
|
|
230
252
|
const parsed = adapter.parse(data);
|
|
231
253
|
if (!parsed)
|
|
232
254
|
return;
|
|
@@ -290,10 +312,10 @@ const wireListenerGateway = (config) => {
|
|
|
290
312
|
* @param singletonServices - Singleton services to pass to handler/middleware
|
|
291
313
|
*/
|
|
292
314
|
export const createListenerMessageHandler = (name, config, singletonServices) => {
|
|
293
|
-
const { adapter } = config;
|
|
294
315
|
const userFuncConfig = config.func;
|
|
295
316
|
const userMiddleware = config.middleware;
|
|
296
317
|
return async (rawData) => {
|
|
318
|
+
const adapter = await resolveGatewayAdapter(config, singletonServices);
|
|
297
319
|
const parsed = adapter.parse(rawData);
|
|
298
320
|
if (!parsed)
|
|
299
321
|
return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CommonWireMeta, CorePikkuMiddleware, CorePikkuMiddlewareGroup } from '../../types/core.types.js';
|
|
1
|
+
import type { CommonWireMeta, CorePikkuMiddleware, CorePikkuMiddlewareGroup, CoreSingletonServices } from '../../types/core.types.js';
|
|
2
2
|
import type { CorePikkuFunctionConfig, CorePermissionGroup, CorePikkuPermission } from '../../function/functions.types.js';
|
|
3
3
|
import type { PikkuHTTPRequest } from '../http/http.types.js';
|
|
4
4
|
/**
|
|
@@ -69,6 +69,16 @@ export interface GatewayAdapter {
|
|
|
69
69
|
* Receives the data (body or query params) and the Pikku HTTP request for additional inspection. */
|
|
70
70
|
verifyWebhook?(data: unknown, request?: PikkuHTTPRequest): WebhookVerificationResult | Promise<WebhookVerificationResult>;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Factory that builds a GatewayAdapter from singleton services.
|
|
74
|
+
*
|
|
75
|
+
* Real platform adapters (WhatsApp Cloud API, Slack, …) need secrets or
|
|
76
|
+
* services that only exist after boot, while `wireGateway` runs at module
|
|
77
|
+
* load. Pass a factory instead of an instance and it is resolved lazily on
|
|
78
|
+
* the first inbound request (webhook/websocket) or on gateway start
|
|
79
|
+
* (listener), then cached for the lifetime of the gateway.
|
|
80
|
+
*/
|
|
81
|
+
export type GatewayAdapterFactory = (services: CoreSingletonServices) => GatewayAdapter | Promise<GatewayAdapter>;
|
|
72
82
|
/**
|
|
73
83
|
* The gateway wire object available on wire.gateway inside handler functions and middleware
|
|
74
84
|
*/
|
|
@@ -100,8 +110,9 @@ export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
|
|
|
100
110
|
/** HTTP route for webhook/websocket types */
|
|
101
111
|
route?: string;
|
|
102
112
|
platform?: string;
|
|
103
|
-
/** The gateway adapter (parse inbound, send outbound)
|
|
104
|
-
|
|
113
|
+
/** The gateway adapter (parse inbound, send outbound), or a factory
|
|
114
|
+
* resolved lazily from singleton services (for adapters needing secrets) */
|
|
115
|
+
adapter: GatewayAdapter | GatewayAdapterFactory;
|
|
105
116
|
/** The handler function that processes parsed messages */
|
|
106
117
|
func: PikkuFunctionConfig;
|
|
107
118
|
/** Optional middleware chain (e.g., auth) */
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export { wireGateway, createListenerMessageHandler } from './gateway-runner.js';
|
|
2
|
-
export type { GatewayAdapter, GatewayAttachment, GatewayInboundMessage, GatewayOutboundMessage, GatewayMeta, GatewaysMeta, GatewayTransportType, CoreGateway, PikkuGateway, WebhookVerificationResult, } from './gateway.types.js';
|
|
1
|
+
export { wireGateway, createListenerMessageHandler, resolveGatewayAdapter, } from './gateway-runner.js';
|
|
2
|
+
export type { GatewayAdapter, GatewayAdapterFactory, GatewayAttachment, GatewayInboundMessage, GatewayOutboundMessage, GatewayMeta, GatewaysMeta, GatewayTransportType, CoreGateway, PikkuGateway, WebhookVerificationResult, } from './gateway.types.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { wireGateway, createListenerMessageHandler } from './gateway-runner.js';
|
|
1
|
+
export { wireGateway, createListenerMessageHandler, resolveGatewayAdapter, } from './gateway-runner.js';
|
|
@@ -441,6 +441,9 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
|
|
|
441
441
|
try {
|
|
442
442
|
let result;
|
|
443
443
|
const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
444
|
+
const agentMeta = subWorkflowMeta
|
|
445
|
+
? undefined
|
|
446
|
+
: pikkuState(null, 'agent', 'agentsMeta')[rpcName];
|
|
444
447
|
if (subWorkflowMeta) {
|
|
445
448
|
const childWire = {
|
|
446
449
|
type: 'workflow',
|
|
@@ -465,6 +468,10 @@ export async function executeGraphStep(workflowService, rpcService, runId, stepI
|
|
|
465
468
|
throw new ChildWorkflowStartedException(runId, stepId, childRunId);
|
|
466
469
|
}
|
|
467
470
|
}
|
|
471
|
+
else if (agentMeta) {
|
|
472
|
+
const agentRun = await rpcService.agent.run(rpcName, data);
|
|
473
|
+
result = agentRun.result;
|
|
474
|
+
}
|
|
468
475
|
else {
|
|
469
476
|
result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepId, nodeId, rpcName, data, graphName);
|
|
470
477
|
}
|
|
@@ -523,6 +530,9 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
|
|
|
523
530
|
try {
|
|
524
531
|
let result;
|
|
525
532
|
const subWorkflowMeta = pikkuState(null, 'workflows', 'meta')[rpcName];
|
|
533
|
+
const agentMeta = subWorkflowMeta
|
|
534
|
+
? undefined
|
|
535
|
+
: pikkuState(null, 'agent', 'agentsMeta')[rpcName];
|
|
526
536
|
if (subWorkflowMeta) {
|
|
527
537
|
const childWire = {
|
|
528
538
|
type: 'workflow',
|
|
@@ -541,6 +551,10 @@ async function executeGraphNodeInline(workflowService, rpcService, runId, graphN
|
|
|
541
551
|
}
|
|
542
552
|
result = childRun?.output;
|
|
543
553
|
}
|
|
554
|
+
else if (agentMeta) {
|
|
555
|
+
const agentRun = await rpcService.agent.run(rpcName, input);
|
|
556
|
+
result = agentRun.result;
|
|
557
|
+
}
|
|
544
558
|
else {
|
|
545
559
|
result = await invokeGraphNodeRpc(workflowService, rpcService, runId, stepState.stepId, nodeId, rpcName, input, graphName);
|
|
546
560
|
}
|
|
@@ -1082,7 +1082,11 @@ export class PikkuWorkflowService {
|
|
|
1082
1082
|
* that differs between transports is who calls it, not the call itself.
|
|
1083
1083
|
*/
|
|
1084
1084
|
async invokeStepRpc(runId, stepName, stepState, rpcName, data, rpcService) {
|
|
1085
|
+
// Carry the run's pikkuUserId onto the step wire so authed steps rehydrate their
|
|
1086
|
+
// session on the queued path too (the bare job wire lacks it; inline already has it).
|
|
1087
|
+
const run = await this.getRun(runId);
|
|
1085
1088
|
return rpcService.rpcWithWire(rpcName, data, {
|
|
1089
|
+
...(run?.wire?.pikkuUserId ? { pikkuUserId: run.wire.pikkuUserId } : {}),
|
|
1086
1090
|
workflowStep: {
|
|
1087
1091
|
runId,
|
|
1088
1092
|
stepId: stepState.stepId,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.60",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"module": "dist/index.js",
|
|
@@ -72,6 +72,6 @@
|
|
|
72
72
|
"typescript": "^6.0.3"
|
|
73
73
|
},
|
|
74
74
|
"engines": {
|
|
75
|
-
"node": ">=
|
|
75
|
+
"node": ">=24"
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { GatewayService } from './gateway-service.js'
|
|
2
2
|
import type { GatewayAdapter } from '../wirings/gateway/gateway.types.js'
|
|
3
3
|
import { pikkuState, getSingletonServices } from '../pikku-state.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
createListenerMessageHandler,
|
|
6
|
+
resolveGatewayAdapter,
|
|
7
|
+
} from '../wirings/gateway/gateway-runner.js'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Local GatewayService implementation.
|
|
@@ -36,8 +39,9 @@ export class LocalGatewayService implements GatewayService {
|
|
|
36
39
|
singletonServices
|
|
37
40
|
)
|
|
38
41
|
|
|
39
|
-
await config
|
|
40
|
-
|
|
42
|
+
const adapter = await resolveGatewayAdapter(config, singletonServices)
|
|
43
|
+
await adapter.init(handleMessage)
|
|
44
|
+
this.activeAdapters.set(name, adapter)
|
|
41
45
|
singletonServices.logger.info(`Started listener gateway: ${name}`)
|
|
42
46
|
}
|
|
43
47
|
}
|
|
@@ -139,6 +139,76 @@ describe('wireGateway', () => {
|
|
|
139
139
|
assert.equal(adapter.sentMessages[0].message.text, 'reply')
|
|
140
140
|
})
|
|
141
141
|
|
|
142
|
+
test('adapter factory resolves lazily from services and is cached', async () => {
|
|
143
|
+
const adapter = createMockAdapter({ name: 'factory-mock' })
|
|
144
|
+
const factoryCalls: any[] = []
|
|
145
|
+
|
|
146
|
+
wireGateway({
|
|
147
|
+
name: 'test-factory',
|
|
148
|
+
type: 'webhook',
|
|
149
|
+
route: '/webhooks/factory',
|
|
150
|
+
adapter: (services: any) => {
|
|
151
|
+
factoryCalls.push(services)
|
|
152
|
+
return adapter
|
|
153
|
+
},
|
|
154
|
+
func: {
|
|
155
|
+
func: async () => ({ text: 'pong' }),
|
|
156
|
+
},
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
httpRouter.initialize()
|
|
160
|
+
|
|
161
|
+
// Factory must NOT run at wiring time
|
|
162
|
+
assert.equal(factoryCalls.length, 0)
|
|
163
|
+
|
|
164
|
+
const makeRequest = () =>
|
|
165
|
+
fetch(
|
|
166
|
+
new Request('http://localhost/webhooks/factory', {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
169
|
+
body: JSON.stringify({ senderId: 'user-9', text: 'ping' }),
|
|
170
|
+
})
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const first = await makeRequest()
|
|
174
|
+
assert.equal(first.status, 200)
|
|
175
|
+
const second = await makeRequest()
|
|
176
|
+
assert.equal(second.status, 200)
|
|
177
|
+
|
|
178
|
+
// Factory ran exactly once, with the singleton services
|
|
179
|
+
assert.equal(factoryCalls.length, 1)
|
|
180
|
+
assert.equal(typeof factoryCalls[0].logger.info, 'function')
|
|
181
|
+
|
|
182
|
+
// Messages flowed through the resolved adapter (auto-send reply)
|
|
183
|
+
assert.equal(adapter.sentMessages.length, 2)
|
|
184
|
+
assert.equal(adapter.sentMessages[0].message.text, 'pong')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test('factory adapter registers GET verify route unconditionally', async () => {
|
|
188
|
+
const adapter = createMockAdapter({
|
|
189
|
+
verifyResult: { verified: true, response: { challenge: 'abc' } },
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
wireGateway({
|
|
193
|
+
name: 'test-factory-verify',
|
|
194
|
+
type: 'webhook',
|
|
195
|
+
route: '/webhooks/factory-verify',
|
|
196
|
+
adapter: async () => adapter,
|
|
197
|
+
func: { func: async () => {} },
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
httpRouter.initialize()
|
|
201
|
+
|
|
202
|
+
const response = await fetch(
|
|
203
|
+
new Request('http://localhost/webhooks/factory-verify?token=x', {
|
|
204
|
+
method: 'GET',
|
|
205
|
+
})
|
|
206
|
+
)
|
|
207
|
+
assert.equal(response.status, 200)
|
|
208
|
+
const body = await response.json()
|
|
209
|
+
assert.deepEqual(body, { challenge: 'abc' })
|
|
210
|
+
})
|
|
211
|
+
|
|
142
212
|
test('returns 200 OK for ignored events (adapter returns null)', async () => {
|
|
143
213
|
const adapter = createMockAdapter({ parseResult: null })
|
|
144
214
|
const funcCalls: any[] = []
|
|
@@ -8,6 +8,28 @@ import type {
|
|
|
8
8
|
GatewayOutboundMessage,
|
|
9
9
|
PikkuGateway,
|
|
10
10
|
} from './gateway.types.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lazily resolve a gateway's adapter. Factories are invoked once with the
|
|
14
|
+
* singleton services and cached (promise-cached, so concurrent first
|
|
15
|
+
* requests share one construction).
|
|
16
|
+
*/
|
|
17
|
+
const resolvedAdapters = new WeakMap<CoreGateway, Promise<GatewayAdapter>>()
|
|
18
|
+
|
|
19
|
+
export const resolveGatewayAdapter = (
|
|
20
|
+
config: CoreGateway,
|
|
21
|
+
services: CoreSingletonServices
|
|
22
|
+
): Promise<GatewayAdapter> => {
|
|
23
|
+
let resolved = resolvedAdapters.get(config)
|
|
24
|
+
if (!resolved) {
|
|
25
|
+
resolved =
|
|
26
|
+
typeof config.adapter === 'function'
|
|
27
|
+
? Promise.resolve(config.adapter(services))
|
|
28
|
+
: Promise.resolve(config.adapter)
|
|
29
|
+
resolvedAdapters.set(config, resolved)
|
|
30
|
+
}
|
|
31
|
+
return resolved
|
|
32
|
+
}
|
|
11
33
|
import type {
|
|
12
34
|
PikkuWire,
|
|
13
35
|
PikkuRawWire,
|
|
@@ -94,8 +116,10 @@ const wireWebhookGateway = (config: CoreGateway): void => {
|
|
|
94
116
|
} as any)
|
|
95
117
|
|
|
96
118
|
// --- GET handler (webhook verification, e.g. WhatsApp challenge) ---------
|
|
119
|
+
// Factory adapters can't be probed for verifyWebhook until first resolve,
|
|
120
|
+
// so register the GET route unconditionally for them.
|
|
97
121
|
|
|
98
|
-
if (adapter.verifyWebhook) {
|
|
122
|
+
if (typeof adapter === 'function' || adapter.verifyWebhook) {
|
|
99
123
|
const verifyFuncId = `gateway__${name}__verify`
|
|
100
124
|
|
|
101
125
|
funcMeta[verifyFuncId] = {
|
|
@@ -113,7 +137,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
|
|
|
113
137
|
|
|
114
138
|
const verifyHandler = {
|
|
115
139
|
auth: false,
|
|
116
|
-
func: createWebhookVerifyHandler(
|
|
140
|
+
func: createWebhookVerifyHandler(config),
|
|
117
141
|
}
|
|
118
142
|
|
|
119
143
|
addFunction(verifyFuncId, verifyHandler as any)
|
|
@@ -145,7 +169,7 @@ const wireWebhookGateway = (config: CoreGateway): void => {
|
|
|
145
169
|
* 6. Auto-send response via adapter if func returns outbound content
|
|
146
170
|
*/
|
|
147
171
|
const createWebhookPostHandler = (config: CoreGateway) => {
|
|
148
|
-
const { name,
|
|
172
|
+
const { name, func: userFunc, middleware: userMiddleware } = config
|
|
149
173
|
const userFuncConfig = userFunc as {
|
|
150
174
|
func: Function
|
|
151
175
|
middleware?: CorePikkuMiddleware[]
|
|
@@ -156,6 +180,8 @@ const createWebhookPostHandler = (config: CoreGateway) => {
|
|
|
156
180
|
data: unknown,
|
|
157
181
|
wire: PikkuWire
|
|
158
182
|
) => {
|
|
183
|
+
const adapter = await resolveGatewayAdapter(config, services)
|
|
184
|
+
|
|
159
185
|
// Check for POST-based webhook verification (e.g. Slack url_verification)
|
|
160
186
|
if (adapter.verifyWebhook) {
|
|
161
187
|
const verifyResult = await adapter.verifyWebhook(data, wire.http?.request)
|
|
@@ -207,12 +233,13 @@ const createWebhookPostHandler = (config: CoreGateway) => {
|
|
|
207
233
|
* Creates the GET handler for webhook verification challenges.
|
|
208
234
|
* Passes query parameters to the adapter's verifyWebhook method.
|
|
209
235
|
*/
|
|
210
|
-
const createWebhookVerifyHandler = (
|
|
236
|
+
const createWebhookVerifyHandler = (config: CoreGateway) => {
|
|
211
237
|
return async (
|
|
212
|
-
|
|
238
|
+
services: CoreSingletonServices,
|
|
213
239
|
_data: unknown,
|
|
214
240
|
wire: PikkuWire
|
|
215
241
|
) => {
|
|
242
|
+
const adapter = await resolveGatewayAdapter(config, services)
|
|
216
243
|
if (!adapter.verifyWebhook) {
|
|
217
244
|
return { error: 'Verification not supported' }
|
|
218
245
|
}
|
|
@@ -232,7 +259,7 @@ const createWebhookVerifyHandler = (adapter: GatewayAdapter) => {
|
|
|
232
259
|
// ---------------------------------------------------------------------------
|
|
233
260
|
|
|
234
261
|
const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
235
|
-
const { name, route
|
|
262
|
+
const { name, route } = config
|
|
236
263
|
if (!route) {
|
|
237
264
|
throw new Error(`WebSocket gateway '${name}' requires a route`)
|
|
238
265
|
}
|
|
@@ -279,7 +306,8 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
279
306
|
// Register onConnect
|
|
280
307
|
addFunction(connectFuncId, {
|
|
281
308
|
auth: false,
|
|
282
|
-
func: async (
|
|
309
|
+
func: async (services: any, _data: unknown, wire: PikkuWire) => {
|
|
310
|
+
const adapter = await resolveGatewayAdapter(config, services)
|
|
283
311
|
;(wire as any).gateway = {
|
|
284
312
|
gatewayName: name,
|
|
285
313
|
senderId: '',
|
|
@@ -295,6 +323,7 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
295
323
|
addFunction(messageFuncId, {
|
|
296
324
|
auth: false,
|
|
297
325
|
func: async (services: any, data: unknown, wire: PikkuWire) => {
|
|
326
|
+
const adapter = await resolveGatewayAdapter(config, services)
|
|
298
327
|
const parsed = adapter.parse(data)
|
|
299
328
|
if (!parsed) return
|
|
300
329
|
|
|
@@ -371,7 +400,6 @@ export const createListenerMessageHandler = (
|
|
|
371
400
|
config: CoreGateway,
|
|
372
401
|
singletonServices: CoreSingletonServices
|
|
373
402
|
): ((rawData: unknown) => Promise<void>) => {
|
|
374
|
-
const { adapter } = config
|
|
375
403
|
const userFuncConfig = config.func as {
|
|
376
404
|
func: Function
|
|
377
405
|
middleware?: CorePikkuMiddleware[]
|
|
@@ -379,6 +407,7 @@ export const createListenerMessageHandler = (
|
|
|
379
407
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
380
408
|
|
|
381
409
|
return async (rawData: unknown): Promise<void> => {
|
|
410
|
+
const adapter = await resolveGatewayAdapter(config, singletonServices)
|
|
382
411
|
const parsed = adapter.parse(rawData)
|
|
383
412
|
if (!parsed) return
|
|
384
413
|
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
CommonWireMeta,
|
|
3
3
|
CorePikkuMiddleware,
|
|
4
4
|
CorePikkuMiddlewareGroup,
|
|
5
|
+
CoreSingletonServices,
|
|
5
6
|
} from '../../types/core.types.js'
|
|
6
7
|
import type {
|
|
7
8
|
CorePikkuFunctionConfig,
|
|
@@ -83,6 +84,19 @@ export interface GatewayAdapter {
|
|
|
83
84
|
): WebhookVerificationResult | Promise<WebhookVerificationResult>
|
|
84
85
|
}
|
|
85
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Factory that builds a GatewayAdapter from singleton services.
|
|
89
|
+
*
|
|
90
|
+
* Real platform adapters (WhatsApp Cloud API, Slack, …) need secrets or
|
|
91
|
+
* services that only exist after boot, while `wireGateway` runs at module
|
|
92
|
+
* load. Pass a factory instead of an instance and it is resolved lazily on
|
|
93
|
+
* the first inbound request (webhook/websocket) or on gateway start
|
|
94
|
+
* (listener), then cached for the lifetime of the gateway.
|
|
95
|
+
*/
|
|
96
|
+
export type GatewayAdapterFactory = (
|
|
97
|
+
services: CoreSingletonServices
|
|
98
|
+
) => GatewayAdapter | Promise<GatewayAdapter>
|
|
99
|
+
|
|
86
100
|
/**
|
|
87
101
|
* The gateway wire object available on wire.gateway inside handler functions and middleware
|
|
88
102
|
*/
|
|
@@ -122,8 +136,9 @@ export type CoreGateway<
|
|
|
122
136
|
/** HTTP route for webhook/websocket types */
|
|
123
137
|
route?: string
|
|
124
138
|
platform?: string
|
|
125
|
-
/** The gateway adapter (parse inbound, send outbound)
|
|
126
|
-
|
|
139
|
+
/** The gateway adapter (parse inbound, send outbound), or a factory
|
|
140
|
+
* resolved lazily from singleton services (for adapters needing secrets) */
|
|
141
|
+
adapter: GatewayAdapter | GatewayAdapterFactory
|
|
127
142
|
/** The handler function that processes parsed messages */
|
|
128
143
|
func: PikkuFunctionConfig
|
|
129
144
|
/** Optional middleware chain (e.g., auth) */
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
wireGateway,
|
|
3
|
+
createListenerMessageHandler,
|
|
4
|
+
resolveGatewayAdapter,
|
|
5
|
+
} from './gateway-runner.js'
|
|
2
6
|
export type {
|
|
3
7
|
GatewayAdapter,
|
|
8
|
+
GatewayAdapterFactory,
|
|
4
9
|
GatewayAttachment,
|
|
5
10
|
GatewayInboundMessage,
|
|
6
11
|
GatewayOutboundMessage,
|