@pikku/inspector 0.12.89 → 0.12.91

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 CHANGED
@@ -1,3 +1,36 @@
1
+ ## 0.12.91
2
+
3
+ ### Patch Changes
4
+
5
+ - 5e93f30: Add `defineOutgoingWebhook({ event, title, description?, payload })` in `@pikku/core/webhook`. The CLI collects every exported declaration into `.pikku/webhooks/pikku-outgoing-webhooks-meta.gen.json` and `pikku-outgoing-webhooks.gen.ts`, which exports `OutgoingWebhooksMap`, `TypedWebhookService` and `typedWebhookService(service)`: `send` checks `data` against the declared payload for a declared event and accepts any other event unchanged. `MetaService.getOutgoingWebhooksMeta()` and the console addon's `outgoingWebhooksMeta` serve the declarations.
6
+ - 5e93f30: Add `wireTriggerWebhookSource({ name, method?, route?, secret?, events, receive?, check?, setup?, teardown? })` in `#pikku/trigger`. Each source becomes a `POST /webhooks/<name>` route whose events are validated against their schemas and queued on `pikku-incoming-webhooks` through `IncomingWebhookService`; a generated worker runs the matching `wireTrigger({ name: '<source>:<event>' })` and the queue retries it on failure. `pikku webhooks status | setup | teardown --url --labelPrefix [--previous]` registers the routes with the provider and prints one JSON line per source.
7
+
8
+ `KyselyIncomingWebhookService` (with the `incoming-webhook` schema) records a receipt per event, drops a provider's redelivery of an event it already accepted, and keeps each dispatch's attempts and last error. `pikku dev` and `pikku serve` use it when a Kysely database is configured.
9
+
10
+ - Updated dependencies [5e93f30]
11
+ - Updated dependencies [5e93f30]
12
+ - @pikku/core@0.12.125
13
+
14
+ ## 0.12.90
15
+
16
+ ### Patch Changes
17
+
18
+ - 4e05a10: Print the TypeScript text for a Zod schema directly, dropping `zod-to-ts`.
19
+
20
+ `processZodSchema` built a TypeScript AST only to print it straight back to a
21
+ string. `zodToTypeText` walks Zod's own definitions instead, which removes
22
+ `zod-to-ts`, `ts.createPrinter`, `ts.EmitHint` and `ts.createSourceFile` from
23
+ the Zod path — it no longer touches the compiler API at all. `@pikku/cli`
24
+ declared `zod-to-ts` without importing it; that is dropped too.
25
+
26
+ A defaulted field is now optional in the generated type. `processZodSchema`
27
+ already strips defaulted fields out of the JSON Schema's `required`, so the two
28
+ halves of the same contract disagreed: the validator accepted a payload that
29
+ omitted the field, while the type said a caller had to pass it.
30
+
31
+ - Updated dependencies [1394385]
32
+ - @pikku/core@0.12.124
33
+
1
34
  ## 0.12.89
2
35
 
3
36
  ### Patch Changes
@@ -1,5 +1,28 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { InspectorLogger, InspectorState } from '../types.js';
3
+ /**
4
+ * A call is the pikku one when the symbol it resolves to is, whatever the file
5
+ * chose to call it locally — `import { defineAnalyticsEvents as declare }` is
6
+ * an ordinary thing to write, and matching on the callee's text alone both
7
+ * misses that and claims a same-named local helper.
8
+ */
9
+ export declare const callsImportedDefiner: (expression: ts.Expression, checker: ts.TypeChecker, definer: string) => boolean;
10
+ /**
11
+ * The name the generated ingest has to import the declaration by — which is
12
+ * the name the module exports it under, not the name of the const. A module
13
+ * that keeps the declaration to itself has no such name, so it is refused here
14
+ * rather than emitted into an ingest that cannot compile.
15
+ */
16
+ export declare const exportedName: (declaration: ts.VariableDeclaration, name: ts.Identifier, checker: ts.TypeChecker) => string | undefined;
17
+ /**
18
+ * An event's props, as source text rather than a resolved type — `z.string()`
19
+ * is what the declaration says and what a reader recognises, and resolving it
20
+ * would mean type-checking a third-party generic to render one table cell.
21
+ *
22
+ * Absent for a schema pikku cannot read the shape off (a shared const, a union,
23
+ * a vendor that is not zod). The catalog renders nothing rather than guessing.
24
+ */
25
+ export declare const readEventShape: (initializer: ts.Expression) => Record<string, string> | undefined;
3
26
  /**
4
27
  * Record where each `defineAnalyticsEvents` declaration is, which names it
5
28
  * declares, and the shape each name validates — the names drive the generated
@@ -7,18 +7,17 @@ const DEFINE_ANALYTICS_EVENTS = 'defineAnalyticsEvents';
7
7
  * an ordinary thing to write, and matching on the callee's text alone both
8
8
  * misses that and claims a same-named local helper.
9
9
  */
10
- const callsDefineAnalyticsEvents = (expression, checker) => {
10
+ export const callsImportedDefiner = (expression, checker, definer) => {
11
11
  if (!ts.isIdentifier(expression))
12
12
  return false;
13
13
  const symbol = checker.getSymbolAtLocation(expression);
14
14
  if (!symbol)
15
- return expression.text === DEFINE_ANALYTICS_EVENTS;
15
+ return expression.text === definer;
16
16
  // An import specifier names what it imported even when the module itself
17
17
  // does not resolve, which is every project whose deps are not installed yet.
18
18
  const declaration = symbol.declarations?.[0];
19
19
  if (declaration && ts.isImportSpecifier(declaration)) {
20
- return ((declaration.propertyName ?? declaration.name).text ===
21
- DEFINE_ANALYTICS_EVENTS);
20
+ return (declaration.propertyName ?? declaration.name).text === definer;
22
21
  }
23
22
  // Only an import can be pikku's. A file's own helper of the same name
24
23
  // resolves to a symbol whose `.name` matches, so resolving without this
@@ -26,7 +25,7 @@ const callsDefineAnalyticsEvents = (expression, checker) => {
26
25
  if (!(symbol.flags & ts.SymbolFlags.Alias))
27
26
  return false;
28
27
  const resolved = checker.getAliasedSymbol(symbol) ?? symbol;
29
- return resolved.name === DEFINE_ANALYTICS_EVENTS;
28
+ return resolved.name === definer;
30
29
  };
31
30
  /**
32
31
  * The name the generated ingest has to import the declaration by — which is
@@ -34,7 +33,7 @@ const callsDefineAnalyticsEvents = (expression, checker) => {
34
33
  * that keeps the declaration to itself has no such name, so it is refused here
35
34
  * rather than emitted into an ingest that cannot compile.
36
35
  */
37
- const exportedName = (declaration, name, checker) => {
36
+ export const exportedName = (declaration, name, checker) => {
38
37
  const moduleSymbol = checker.getSymbolAtLocation(declaration.getSourceFile());
39
38
  const declarationSymbol = checker.getSymbolAtLocation(name);
40
39
  if (!moduleSymbol || !declarationSymbol) {
@@ -78,7 +77,7 @@ const findObjectShape = (expression) => {
78
77
  * Absent for a schema pikku cannot read the shape off (a shared const, a union,
79
78
  * a vendor that is not zod). The catalog renders nothing rather than guessing.
80
79
  */
81
- const readEventShape = (initializer) => {
80
+ export const readEventShape = (initializer) => {
82
81
  const shape = findObjectShape(initializer);
83
82
  if (!shape)
84
83
  return undefined;
@@ -110,7 +109,7 @@ export const addAnalytics = (logger, node, checker, state) => {
110
109
  const { initializer, name } = node;
111
110
  if (!initializer || !ts.isCallExpression(initializer))
112
111
  return;
113
- if (!callsDefineAnalyticsEvents(initializer.expression, checker))
112
+ if (!callsImportedDefiner(initializer.expression, checker, DEFINE_ANALYTICS_EVENTS))
114
113
  return;
115
114
  if (!ts.isIdentifier(name))
116
115
  return;
@@ -0,0 +1,3 @@
1
+ import * as ts from 'typescript';
2
+ import type { InspectorLogger, InspectorState } from '../types.js';
3
+ export declare const addOutgoingWebhook: (logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState) => void;
@@ -0,0 +1,81 @@
1
+ import * as ts from 'typescript';
2
+ import { callsImportedDefiner, exportedName, readEventShape, } from './add-analytics.js';
3
+ const DEFINE_OUTGOING_WEBHOOK = 'defineOutgoingWebhook';
4
+ const stringProperty = (object, key) => {
5
+ for (const property of object.properties) {
6
+ if (!ts.isPropertyAssignment(property))
7
+ continue;
8
+ const name = property.name;
9
+ const text = ts.isIdentifier(name) || ts.isStringLiteral(name) ? name.text : undefined;
10
+ if (text !== key)
11
+ continue;
12
+ const value = property.initializer;
13
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value))
14
+ return value.text;
15
+ return undefined;
16
+ }
17
+ return undefined;
18
+ };
19
+ const propertyInitializer = (object, key) => {
20
+ for (const property of object.properties) {
21
+ if (!ts.isPropertyAssignment(property))
22
+ continue;
23
+ const name = property.name;
24
+ if ((ts.isIdentifier(name) || ts.isStringLiteral(name)) &&
25
+ name.text === key)
26
+ return property.initializer;
27
+ }
28
+ return undefined;
29
+ };
30
+ export const addOutgoingWebhook = (logger, node, checker, state) => {
31
+ if (!ts.isVariableDeclaration(node))
32
+ return;
33
+ const { initializer, name } = node;
34
+ if (!initializer || !ts.isCallExpression(initializer))
35
+ return;
36
+ if (!callsImportedDefiner(initializer.expression, checker, DEFINE_OUTGOING_WEBHOOK))
37
+ return;
38
+ if (!ts.isIdentifier(name))
39
+ return;
40
+ const file = node.getSourceFile().fileName;
41
+ const [argument] = initializer.arguments;
42
+ if (!argument || !ts.isObjectLiteralExpression(argument)) {
43
+ logger.error(`defineOutgoingWebhook in ${file} must be called with an object literal: { event, title, payload }.`);
44
+ return;
45
+ }
46
+ const event = stringProperty(argument, 'event');
47
+ const title = stringProperty(argument, 'title');
48
+ if (!event || !title) {
49
+ logger.error(`defineOutgoingWebhook '${name.text}' in ${file} needs 'event' and 'title' as string literals, so they can be listed without running the app.`);
50
+ return;
51
+ }
52
+ const variable = exportedName(node, name, checker);
53
+ if (!variable) {
54
+ logger.error(`defineOutgoingWebhook '${event}' in ${file} is assigned to '${name.text}', which the module does not export. The generated webhook types import it by name, so export it.`);
55
+ return;
56
+ }
57
+ const description = stringProperty(argument, 'description');
58
+ const payloadExpression = propertyInitializer(argument, 'payload');
59
+ const payload = payloadExpression
60
+ ? readEventShape(payloadExpression)
61
+ : undefined;
62
+ const webhooks = (state.outgoingWebhooks ??= []);
63
+ const existing = webhooks.findIndex((webhook) => webhook.file === file && webhook.variable === variable);
64
+ const declared = webhooks.find((webhook, index) => webhook.event === event && index !== existing);
65
+ if (declared) {
66
+ logger.error(`Webhook event '${event}' is declared twice: ${declared.file} and ${file}. An event belongs to one defineOutgoingWebhook.`);
67
+ return;
68
+ }
69
+ const entry = {
70
+ file,
71
+ variable,
72
+ event,
73
+ title,
74
+ ...(description !== undefined ? { description } : {}),
75
+ ...(payload ? { payload } : {}),
76
+ };
77
+ if (existing >= 0)
78
+ webhooks[existing] = entry;
79
+ else
80
+ webhooks.push(entry);
81
+ };
@@ -1,2 +1,7 @@
1
1
  import type { AddWiring } from '../types.js';
2
2
  export declare const addTrigger: AddWiring;
3
+ /**
4
+ * A trigger named `<source>:<event>` must name an event its webhook source
5
+ * declares, or it would never fire.
6
+ */
7
+ export declare const validateWebhookSourceTriggers: (logger: Parameters<AddWiring>[0], state: Parameters<AddWiring>[3]) => void;
@@ -6,7 +6,9 @@ import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js
6
6
  import { resolveMiddleware } from '../utils/middleware.js';
7
7
  import { extractWireNames } from '../utils/post-process.js';
8
8
  import { resolveAddonName } from '../utils/resolve-addon-package.js';
9
+ import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
9
10
  import { ErrorCode } from '../error-codes.js';
11
+ const WEBHOOK_SOURCE_STEPS = ['receive', 'check', 'setup', 'teardown'];
10
12
  export const addTrigger = (logger, node, checker, state, options) => {
11
13
  if (!ts.isCallExpression(node)) {
12
14
  return;
@@ -23,6 +25,9 @@ export const addTrigger = (logger, node, checker, state, options) => {
23
25
  else if (expression.text === 'wireTriggerSource') {
24
26
  addWireTriggerSource(logger, node, checker, state, options, firstArg);
25
27
  }
28
+ else if (expression.text === 'wireTriggerWebhookSource') {
29
+ addWireTriggerWebhookSource(logger, node, checker, state, firstArg);
30
+ }
26
31
  };
27
32
  const addWireTrigger = (logger, node, checker, state, firstArg) => {
28
33
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
@@ -88,3 +93,102 @@ const addWireTriggerSource = (logger, node, checker, state, options, firstArg) =
88
93
  }
89
94
  state.triggers.files.add(node.getSourceFile().fileName);
90
95
  };
96
+ const refTarget = (initializer) => {
97
+ if (!ts.isCallExpression(initializer) ||
98
+ !ts.isIdentifier(initializer.expression) ||
99
+ initializer.expression.text !== 'ref') {
100
+ return null;
101
+ }
102
+ const [target] = initializer.arguments;
103
+ return target && ts.isStringLiteral(target) && target.text.includes(':')
104
+ ? target.text
105
+ : null;
106
+ };
107
+ const addWireTriggerWebhookSource = (logger, node, checker, state, firstArg) => {
108
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
109
+ return;
110
+ }
111
+ const obj = firstArg;
112
+ const name = getPropertyValue(obj, 'name');
113
+ if (!name) {
114
+ logger.critical(ErrorCode.MISSING_NAME, `wireTriggerWebhookSource in ${node.getSourceFile().fileName} needs 'name' as a string literal.`);
115
+ return;
116
+ }
117
+ const method = getPropertyValue(obj, 'method') ?? 'post';
118
+ const route = getPropertyValue(obj, 'route') ?? `/webhooks/${name}`;
119
+ let secret = getPropertyValue(obj, 'secret');
120
+ const eventsInitializer = getPropertyAssignmentInitializer(obj, 'events', false, checker);
121
+ const events = eventsInitializer
122
+ ? checker
123
+ .getTypeAtLocation(eventsInitializer)
124
+ .getProperties()
125
+ .map((property) => property.getName())
126
+ .sort()
127
+ : [];
128
+ const steps = {};
129
+ for (const step of WEBHOOK_SOURCE_STEPS) {
130
+ const initializer = getPropertyAssignmentInitializer(obj, step, true, checker);
131
+ if (!initializer)
132
+ continue;
133
+ const target = refTarget(initializer);
134
+ if (target) {
135
+ if (!resolveFunctionMeta(state, target)) {
136
+ logger.warn(`Webhook source '${name}': addon function metadata for '${target}' is not available yet.`);
137
+ }
138
+ steps[step] = target;
139
+ state.serviceAggregation.usedFunctions.add(target);
140
+ continue;
141
+ }
142
+ const extracted = extractFunctionName(initializer, checker, state.rootDir);
143
+ let pikkuFuncId = extracted.pikkuFuncId;
144
+ if (pikkuFuncId.startsWith('__temp_')) {
145
+ pikkuFuncId = makeContextBasedId('trigger-webhook', name, step);
146
+ }
147
+ ensureInlineWiringFunction(state, pikkuFuncId, `${name}:${step}`, initializer, checker, extracted.isHelper);
148
+ const meta = state.functions.meta[pikkuFuncId];
149
+ if (meta && meta.sessionless === undefined)
150
+ meta.sessionless = true;
151
+ steps[step] = pikkuFuncId;
152
+ state.serviceAggregation.usedFunctions.add(pikkuFuncId);
153
+ }
154
+ // An addon's receive reads the secret by its own name; the app may have
155
+ // renamed it for this instance.
156
+ const namespace = steps.receive?.includes(':')
157
+ ? steps.receive.slice(0, steps.receive.indexOf(':'))
158
+ : null;
159
+ const addon = namespace
160
+ ? state.rpc.wireAddonDeclarations.get(namespace)
161
+ : undefined;
162
+ if (secret && addon?.secretOverrides?.[secret]) {
163
+ secret = addon.secretOverrides[secret];
164
+ }
165
+ const meta = {
166
+ name,
167
+ method: method,
168
+ route,
169
+ events,
170
+ ...(secret ? { secret } : {}),
171
+ ...steps,
172
+ };
173
+ state.triggers.webhookSourceMeta[name] = meta;
174
+ state.triggers.files.add(node.getSourceFile().fileName);
175
+ };
176
+ /**
177
+ * A trigger named `<source>:<event>` must name an event its webhook source
178
+ * declares, or it would never fire.
179
+ */
180
+ export const validateWebhookSourceTriggers = (logger, state) => {
181
+ const sources = state.triggers.webhookSourceMeta;
182
+ for (const trigger of Object.keys(state.triggers.meta)) {
183
+ const colon = trigger.indexOf(':');
184
+ if (colon === -1)
185
+ continue;
186
+ const source = sources[trigger.slice(0, colon)];
187
+ if (!source || source.events.length === 0)
188
+ continue;
189
+ const event = trigger.slice(colon + 1);
190
+ if (!source.events.includes(event)) {
191
+ logger.error(`Trigger '${trigger}' listens for '${event}', which webhook source '${source.name}' does not declare. Declared: ${source.events.join(', ')}.`);
192
+ }
193
+ }
194
+ };
package/dist/inspector.js CHANGED
@@ -14,6 +14,7 @@ import { annotateHttpRouteAuth } from './utils/annotate-http-route-auth.js';
14
14
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js';
15
15
  import { pikkuState } from '@pikku/core/state';
16
16
  import { resolveLatestVersions } from './utils/resolve-versions.js';
17
+ import { validateWebhookSourceTriggers } from './add/add-trigger.js';
17
18
  import { finalizeWorkflows } from './utils/workflow/graph/finalize-workflows.js';
18
19
  import { finalizeWorkflowHelperTypes, finalizeWorkflowWires, } from './utils/workflow/graph/finalize-workflow-wires.js';
19
20
  import { generateAllSchemas } from './utils/schema-generator.js';
@@ -79,6 +80,7 @@ export function getInitialInspectorState(rootDir) {
79
80
  triggers: {
80
81
  meta: {},
81
82
  sourceMeta: {},
83
+ webhookSourceMeta: {},
82
84
  files: new Set(),
83
85
  },
84
86
  scheduledTasks: {
@@ -353,6 +355,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
353
355
  ts.forEachChild(sourceFile, (child) => visitRoutes(logger, checker, child, state, sourceOptions));
354
356
  }
355
357
  logger.debug(`Visit routes phase completed in ${(performance.now() - startRoutes).toFixed(0)}ms`);
358
+ validateWebhookSourceTriggers(logger, state);
356
359
  resolveLatestVersions(state, logger);
357
360
  if (options.schemaConfig) {
358
361
  const startSchemas = performance.now();
package/dist/types.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { ChannelMessageMeta, ChannelsMeta } from '@pikku/core/channel';
5
5
  import type { GatewaysMeta } from '@pikku/core/gateway';
6
6
  import type { HTTPWiringsMeta } from '@pikku/core/http';
7
7
  import type { ScheduledTasksMeta } from '@pikku/core/scheduler';
8
- import type { TriggerMeta, TriggerSourceMeta } from '@pikku/core/trigger';
8
+ import type { TriggerMeta, TriggerSourceMeta, WebhookSourcesMeta } from '@pikku/core/trigger';
9
9
  import type { QueueWorkersMeta } from '@pikku/core/queue';
10
10
  import type { WorkflowsMeta } from '@pikku/core/workflow';
11
11
  import type { MCPResourceMeta, MCPToolMeta, MCPPromptMeta } from '@pikku/core/mcp';
@@ -515,6 +515,14 @@ export interface InspectorState {
515
515
  * inspector cannot read off the declaration. */
516
516
  props?: Record<string, Record<string, string>>;
517
517
  }>;
518
+ outgoingWebhooks?: Array<{
519
+ file: string;
520
+ variable: string;
521
+ event: string;
522
+ title: string;
523
+ description?: string;
524
+ payload?: Record<string, string>;
525
+ }>;
518
526
  addonServerlessIncompatible: Map<string, string[]>;
519
527
  configFactories: PathToNameAndType;
520
528
  serverLifecycleFactories: PathToNameAndType;
@@ -539,6 +547,7 @@ export interface InspectorState {
539
547
  triggers: {
540
548
  meta: TriggerMeta;
541
549
  sourceMeta: TriggerSourceMeta;
550
+ webhookSourceMeta: WebhookSourcesMeta;
542
551
  files: Set<string>;
543
552
  };
544
553
  scheduledTasks: {
@@ -6,9 +6,9 @@
6
6
  * contract has to describe one shape. Zod says so twice, in two different
7
7
  * places, and neither says it usefully on its own — `toJSONSchema` with
8
8
  * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
- * everything, and `zodToTs` throws about TypeScript rather than about the
10
- * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
- * and the call.
9
+ * everything, and `zodToTypeText` prints the input side of the pipe, which is
10
+ * not the shape the function receives. Finding it up front lets the diagnostic
11
+ * name the schema, the path, and the call.
12
12
  *
13
13
  * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
14
  * the walk looks for that node rather than for the method.
@@ -6,9 +6,9 @@
6
6
  * contract has to describe one shape. Zod says so twice, in two different
7
7
  * places, and neither says it usefully on its own — `toJSONSchema` with
8
8
  * `unrepresentable: 'any'` quietly yields `{}`, a schema that accepts
9
- * everything, and `zodToTs` throws about TypeScript rather than about the
10
- * wiring. Finding it up front lets the diagnostic name the schema, the path,
11
- * and the call.
9
+ * everything, and `zodToTypeText` prints the input side of the pipe, which is
10
+ * not the shape the function receives. Finding it up front lets the diagnostic
11
+ * name the schema, the path, and the call.
12
12
  *
13
13
  * Zod 4 models `x.transform(fn)` as a `pipe` whose `out` is a `transform`, so
14
14
  * the walk looks for that node rather than for the method.
@@ -208,6 +208,15 @@ export function aggregateRequiredServices(state) {
208
208
  requiredServices.add('schedulerService');
209
209
  requiredServices.add('queueService');
210
210
  }
211
+ // Webhook source routes and their worker are generated and destructure
212
+ // nothing, so they are found by id: the route accepts into the queue through
213
+ // incomingWebhookService and the worker records attempts through it.
214
+ const hasWebhookSources = Object.values(state.triggers?.webhookSourceMeta ?? {}).some(({ method, route }) => state.functions.meta[`http:${method}:${route}`] !== undefined);
215
+ if (hasWebhookSources ||
216
+ state.functions.meta['queue:pikku-incoming-webhooks'] !== undefined) {
217
+ requiredServices.add('incomingWebhookService');
218
+ requiredServices.add('queueService');
219
+ }
211
220
  // 6b. Inject synthetic queue workers for workflow graph steps.
212
221
  // Each workflow gets an orchestrator queue and per-step queues.
213
222
  // Without these, the PikkuWorkflowService constructor can't find
@@ -169,6 +169,12 @@ function updateWiringReferences(state, oldId, newId) {
169
169
  source.pikkuFuncId = newId;
170
170
  }
171
171
  }
172
+ for (const source of Object.values(state.triggers.webhookSourceMeta ?? {})) {
173
+ for (const step of ['receive', 'check', 'setup', 'teardown']) {
174
+ if (source[step] === oldId)
175
+ source[step] = newId;
176
+ }
177
+ }
172
178
  }
173
179
  // MCP resources, tools, and prompts
174
180
  if (state.mcpEndpoints) {
@@ -5,8 +5,8 @@ import { dirname, join, resolve } from 'path';
5
5
  import { createGenerator, RootlessError } from 'ts-json-schema-generator';
6
6
  import { register } from 'tsx/esm/api';
7
7
  import * as z from 'zod';
8
- import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts';
9
8
  import { findZodTransform } from './find-zod-transform.js';
9
+ import { zodToTypeText } from './zod-to-type-text.js';
10
10
  import { ErrorCode } from '../error-codes.js';
11
11
  import { generateCustomTypes } from './custom-types-generator.js';
12
12
  const PRIMITIVE_TYPES = new Set([
@@ -382,7 +382,7 @@ async function importWithRegister(sourceFile) {
382
382
  void Promise.resolve(unregister()).catch(() => { });
383
383
  }
384
384
  }
385
- function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
385
+ function processZodSchema(schemaName, zodSchema, schemas, typesMap, logger) {
386
386
  const transformPath = findZodTransform(zodSchema);
387
387
  if (transformPath !== undefined) {
388
388
  const where = transformPath === '' ? 'the schema itself' : transformPath;
@@ -412,18 +412,13 @@ function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTyp
412
412
  delete schema.required;
413
413
  }
414
414
  }
415
- const { node: tsType } = zodToTs(zodSchema, { auxiliaryTypeStore });
416
- const typeText = printer.printNode(ts.EmitHint.Unspecified, tsType, fakeSourceFile);
417
- typesMap.addCustomType(schemaName, typeText, []);
415
+ typesMap.addCustomType(schemaName, zodToTypeText(zodSchema), []);
418
416
  schemas[schemaName] = schema;
419
417
  logger.debug(`• Generated schema from Zod: ${schemaName}`);
420
418
  }
421
419
  async function generateZodSchemas(logger, schemaLookup, typesMap) {
422
420
  const schemas = {};
423
421
  const errors = [];
424
- const auxiliaryTypeStore = createAuxiliaryTypeStore();
425
- const printer = ts.createPrinter();
426
- const fakeSourceFile = ts.createSourceFile('zod-types.ts', '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
427
422
  // Validate all schemas are zod (or unspecified vendor)
428
423
  for (const [schemaName, ref] of schemaLookup.entries()) {
429
424
  if (ref.vendor && ref.vendor !== 'zod') {
@@ -453,7 +448,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
453
448
  continue;
454
449
  }
455
450
  try {
456
- processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
451
+ processZodSchema(schemaName, zodSchema, schemas, typesMap, logger);
457
452
  }
458
453
  catch (e) {
459
454
  errors.push(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
@@ -478,7 +473,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
478
473
  errors.push(`Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
479
474
  continue;
480
475
  }
481
- processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
476
+ processZodSchema(schemaName, zodSchema, schemas, typesMap, logger);
482
477
  }
483
478
  catch (e) {
484
479
  errors.push(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
@@ -143,6 +143,7 @@ export interface SerializableInspectorState {
143
143
  triggers: {
144
144
  meta: InspectorState['triggers']['meta'];
145
145
  sourceMeta: InspectorState['triggers']['sourceMeta'];
146
+ webhookSourceMeta?: InspectorState['triggers']['webhookSourceMeta'];
146
147
  files: string[];
147
148
  };
148
149
  scheduledTasks: {
@@ -56,6 +56,7 @@ export function serializeInspectorState(state) {
56
56
  triggers: {
57
57
  meta: state.triggers.meta,
58
58
  sourceMeta: state.triggers.sourceMeta,
59
+ webhookSourceMeta: state.triggers.webhookSourceMeta,
59
60
  files: Array.from(state.triggers.files),
60
61
  },
61
62
  scheduledTasks: {
@@ -243,6 +244,7 @@ export function deserializeInspectorState(data) {
243
244
  triggers: {
244
245
  meta: data.triggers?.meta ?? {},
245
246
  sourceMeta: data.triggers?.sourceMeta ?? {},
247
+ webhookSourceMeta: data.triggers?.webhookSourceMeta ?? {},
246
248
  files: new Set(data.triggers?.files ?? []),
247
249
  },
248
250
  scheduledTasks: {
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Print the TypeScript type text for a Zod schema.
3
+ *
4
+ * `processZodSchema` needs two things from a schema: the JSON Schema, which
5
+ * `z.toJSONSchema` already gives it, and the TypeScript source text that goes
6
+ * into the generated types. Only the second needed a library, and only to build
7
+ * a TypeScript AST that was immediately printed back to a string — so this walks
8
+ * Zod's own definitions and writes the text directly.
9
+ *
10
+ * It prints from the Zod tree rather than from the JSON Schema on purpose. The
11
+ * JSON Schema has already lost what the text needs: `z.date()` is rewritten to
12
+ * `string`/`date-time` before anything can read it, and `z.any()` and
13
+ * `z.unknown()` both flatten to `{}`. The Zod tree still says `Date`, `any` and
14
+ * `unknown`.
15
+ *
16
+ * The walk mirrors {@link findZodTransform} — same `_zod.def` shapes, same
17
+ * seen-set and node budget, for the same reason.
18
+ */
19
+ /**
20
+ * The TypeScript type text for `schema`, as it would be written by hand.
21
+ *
22
+ * A schema that refers to itself cannot be written inline, so a cycle prints as
23
+ * `any` rather than recursing forever.
24
+ */
25
+ export declare function zodToTypeText(schema: unknown): string;
@@ -0,0 +1,228 @@
1
+ /**
2
+ * Print the TypeScript type text for a Zod schema.
3
+ *
4
+ * `processZodSchema` needs two things from a schema: the JSON Schema, which
5
+ * `z.toJSONSchema` already gives it, and the TypeScript source text that goes
6
+ * into the generated types. Only the second needed a library, and only to build
7
+ * a TypeScript AST that was immediately printed back to a string — so this walks
8
+ * Zod's own definitions and writes the text directly.
9
+ *
10
+ * It prints from the Zod tree rather than from the JSON Schema on purpose. The
11
+ * JSON Schema has already lost what the text needs: `z.date()` is rewritten to
12
+ * `string`/`date-time` before anything can read it, and `z.any()` and
13
+ * `z.unknown()` both flatten to `{}`. The Zod tree still says `Date`, `any` and
14
+ * `unknown`.
15
+ *
16
+ * The walk mirrors {@link findZodTransform} — same `_zod.def` shapes, same
17
+ * seen-set and node budget, for the same reason.
18
+ */
19
+ /** Max nodes visited, so a pathological or cyclic schema cannot hang the walk. */
20
+ const MAX_NODES = 10_000;
21
+ const INDENT = ' ';
22
+ const isNode = (value) => typeof value === 'object' &&
23
+ value !== null &&
24
+ typeof value._zod?.def?.type === 'string';
25
+ const tight = (text) => ({ text, loose: false });
26
+ const loose = (text) => ({ text, loose: true });
27
+ const parenthesized = ({ text, loose }) => loose ? `(${text})` : text;
28
+ /** A JS value as a TypeScript literal type. */
29
+ const literalOf = (value) => {
30
+ if (typeof value === 'string')
31
+ return JSON.stringify(value);
32
+ if (typeof value === 'bigint')
33
+ return `${value}n`;
34
+ if (value === undefined)
35
+ return 'undefined';
36
+ if (value === null)
37
+ return 'null';
38
+ return String(value);
39
+ };
40
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
41
+ const propertyKey = (name) => IDENTIFIER.test(name) ? name : JSON.stringify(name);
42
+ /** A `.describe()` string as a JSDoc block at the given indentation. */
43
+ const jsDoc = (description, indent) => {
44
+ if (!description)
45
+ return '';
46
+ const lines = description.split('\n');
47
+ if (lines.length === 1)
48
+ return `${indent}/** ${lines[0]} */\n`;
49
+ return `${indent}/**\n${lines.map((line) => `${indent} * ${line}`).join('\n')}\n${indent} */\n`;
50
+ };
51
+ const union = (parts) => {
52
+ const texts = [...new Set(parts.map(parenthesized))];
53
+ if (texts.length === 0)
54
+ return tight('never');
55
+ if (texts.length === 1)
56
+ return tight(texts[0]);
57
+ return loose(texts.join(' | '));
58
+ };
59
+ /**
60
+ * The TypeScript type text for `schema`, as it would be written by hand.
61
+ *
62
+ * A schema that refers to itself cannot be written inline, so a cycle prints as
63
+ * `any` rather than recursing forever.
64
+ */
65
+ export function zodToTypeText(schema) {
66
+ const seen = new Set();
67
+ let budget = MAX_NODES;
68
+ const print = (node, indent) => {
69
+ if (budget-- <= 0 || !isNode(node))
70
+ return tight('any');
71
+ if (seen.has(node))
72
+ return tight('any');
73
+ seen.add(node);
74
+ try {
75
+ return printDef(node, indent);
76
+ }
77
+ finally {
78
+ seen.delete(node);
79
+ }
80
+ };
81
+ const printDef = (node, indent) => {
82
+ const def = node._zod.def;
83
+ const inner = indent + INDENT;
84
+ switch (def.type) {
85
+ case 'string':
86
+ case 'email':
87
+ case 'url':
88
+ case 'uuid':
89
+ case 'guid':
90
+ case 'cuid':
91
+ case 'cuid2':
92
+ case 'ulid':
93
+ case 'emoji':
94
+ case 'nanoid':
95
+ case 'jwt':
96
+ case 'base64':
97
+ case 'base64url':
98
+ case 'ipv4':
99
+ case 'ipv6':
100
+ case 'cidrv4':
101
+ case 'cidrv6':
102
+ case 'iso_date':
103
+ case 'iso_time':
104
+ case 'iso_datetime':
105
+ case 'iso_duration':
106
+ return tight('string');
107
+ case 'number':
108
+ case 'int':
109
+ case 'nan':
110
+ return tight('number');
111
+ case 'bigint':
112
+ return tight('bigint');
113
+ case 'boolean':
114
+ return tight('boolean');
115
+ case 'date':
116
+ return tight('Date');
117
+ case 'null':
118
+ return tight('null');
119
+ case 'undefined':
120
+ case 'void':
121
+ return tight('undefined');
122
+ case 'symbol':
123
+ return tight('symbol');
124
+ case 'file':
125
+ return tight('File');
126
+ case 'never':
127
+ return tight('never');
128
+ case 'any':
129
+ return tight('any');
130
+ case 'unknown':
131
+ return tight('unknown');
132
+ case 'literal':
133
+ return union((def.values ?? []).map((v) => tight(literalOf(v))));
134
+ case 'enum':
135
+ return union(Object.values(def.entries ?? {}).map((v) => tight(literalOf(v))));
136
+ case 'template_literal': {
137
+ const body = (def.parts ?? [])
138
+ .map((part) => isNode(part) ? `\${${print(part, indent).text}}` : String(part))
139
+ .join('');
140
+ return tight(`\`${body}\``);
141
+ }
142
+ case 'array':
143
+ return tight(`${parenthesized(print(def.element, indent))}[]`);
144
+ case 'set':
145
+ return tight(`Set<${print(def.valueType, indent).text}>`);
146
+ case 'map':
147
+ return tight(`Map<${print(def.keyType, indent).text}, ${print(def.valueType, indent).text}>`);
148
+ case 'promise':
149
+ return tight(`Promise<${print(def.innerType, indent).text}>`);
150
+ case 'tuple': {
151
+ const items = (def.items ?? []).map((item) => print(item, inner).text);
152
+ if (def.rest) {
153
+ items.push(`...${parenthesized(print(def.rest, inner))}[]`);
154
+ }
155
+ if (items.length === 0)
156
+ return tight('[]');
157
+ return tight(`[\n${items.map((item) => `${inner}${item}`).join(',\n')}\n${indent}]`);
158
+ }
159
+ case 'union':
160
+ return union((def.options ?? []).map((option) => print(option, indent)));
161
+ case 'intersection':
162
+ return loose(`${parenthesized(print(def.left, indent))} & ${parenthesized(print(def.right, indent))}`);
163
+ case 'record':
164
+ return tight(`{\n${inner}[key: ${print(def.keyType, inner).text}]: ${print(def.valueType, inner).text};\n${indent}}`);
165
+ case 'object':
166
+ case 'interface': {
167
+ const shape = def.shape ?? {};
168
+ const entries = Object.entries(shape);
169
+ if (entries.length === 0 && !def.catchall)
170
+ return tight('{}');
171
+ const members = entries.map(([name, property]) => {
172
+ const optional = isNode(property) && isOptional(property);
173
+ const printed = print(property, inner);
174
+ const type = optional
175
+ ? `${parenthesized(printed)} | undefined`
176
+ : printed.text;
177
+ const doc = jsDoc(isNode(property) ? property.description : undefined, inner);
178
+ return `${doc}${inner}${propertyKey(name)}${optional ? '?' : ''}: ${type};`;
179
+ });
180
+ if (def.catchall) {
181
+ members.push(`${inner}[key: string]: ${print(def.catchall, inner).text};`);
182
+ }
183
+ return tight(`{\n${members.join('\n')}\n${indent}}`);
184
+ }
185
+ case 'nullable':
186
+ return union([print(def.innerType, indent), tight('null')]);
187
+ case 'lazy': {
188
+ try {
189
+ return print(def.getter(), indent);
190
+ }
191
+ catch {
192
+ // A lazy schema that cannot be forced tells us nothing about its type.
193
+ return tight('any');
194
+ }
195
+ }
196
+ default:
197
+ // optional, default, prefault, nonoptional, readonly, catch, success,
198
+ // pipe — every wrapper Zod spells `innerType`, and a pipe's input is the
199
+ // shape a caller writes.
200
+ if (def.innerType)
201
+ return print(def.innerType, indent);
202
+ if (def.in)
203
+ return print(def.in, indent);
204
+ return tight('any');
205
+ }
206
+ };
207
+ /**
208
+ * Whether a property may be omitted.
209
+ *
210
+ * `.default()` counts. `processZodSchema` strips every defaulted field out of
211
+ * the JSON Schema's `required`, because a caller who omits one gets the
212
+ * default rather than a validation error — so the text has to say the same
213
+ * thing the validator does.
214
+ */
215
+ const isOptional = (node) => {
216
+ const def = node._zod.def;
217
+ if (def.type === 'optional')
218
+ return true;
219
+ if (def.type === 'default' || def.type === 'prefault')
220
+ return true;
221
+ if (def.type === 'nonoptional')
222
+ return false;
223
+ if (def.innerType && isNode(def.innerType))
224
+ return isOptional(def.innerType);
225
+ return false;
226
+ };
227
+ return print(schema, '').text;
228
+ }
package/dist/visit.js CHANGED
@@ -31,6 +31,7 @@ import { addWorkflowGraph } from './add/add-workflow-graph.js';
31
31
  import { addFeature } from './add/add-feature.js';
32
32
  import { addAgent } from './add/add-agent.js';
33
33
  import { addAnalytics } from './add/add-analytics.js';
34
+ import { addOutgoingWebhook } from './add/add-outgoing-webhook.js';
34
35
  import { addAgentScorer } from './add/add-agent-scorer.js';
35
36
  import { addApprovalDescription } from './add/add-approval-description.js';
36
37
  export const visitSetup = (logger, checker, node, state, options) => {
@@ -43,6 +44,7 @@ export const visitSetup = (logger, checker, node, state, options) => {
43
44
  addFileWithFactory(node, checker, state.configFactories, 'CreateConfig');
44
45
  addFileWithFactory(node, checker, state.serverLifecycleFactories, 'ServerLifecycle');
45
46
  addAnalytics(logger, node, checker, state);
47
+ addOutgoingWebhook(logger, node, checker, state);
46
48
  addRPCInvocations(node, state, logger);
47
49
  addWireAddon(node, state, logger);
48
50
  addWireRemoteAddon(node, state, logger);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.89",
3
+ "version": "0.12.91",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/pikkujs/pikku.git",
@@ -51,14 +51,13 @@
51
51
  "ts-json-schema-generator": "2.9.1-next.18",
52
52
  "tsx": "^4.23.12",
53
53
  "typescript": "^6.0.3",
54
- "zod": "^4.4.3",
55
- "zod-to-ts": "^2.1.0"
54
+ "zod": "^4.4.3"
56
55
  },
57
56
  "peerDependencies": {
58
- "@pikku/core": "^0.12.123"
57
+ "@pikku/core": "^0.12.125"
59
58
  },
60
59
  "devDependencies": {
61
- "@pikku/core": "^0.12.123",
60
+ "@pikku/core": "^0.12.125",
62
61
  "@types/node": "^24.13.3"
63
62
  },
64
63
  "engines": {