@pikku/inspector 0.12.22 → 0.12.23

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 (40) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/add/add-addon-bans.d.ts +7 -0
  3. package/dist/add/add-addon-bans.js +65 -0
  4. package/dist/add/add-channel.js +47 -6
  5. package/dist/add/add-cli.js +17 -0
  6. package/dist/add/add-http-route.d.ts +11 -1
  7. package/dist/add/add-http-route.js +37 -0
  8. package/dist/add/add-http-routes.d.ts +0 -3
  9. package/dist/add/add-http-routes.js +179 -36
  10. package/dist/error-codes.d.ts +3 -1
  11. package/dist/error-codes.js +3 -0
  12. package/dist/inspector.js +17 -5
  13. package/dist/types.d.ts +43 -1
  14. package/dist/utils/get-exported-variable-name.d.ts +2 -0
  15. package/dist/utils/get-exported-variable-name.js +34 -0
  16. package/dist/utils/load-addon-functions-meta.js +98 -0
  17. package/dist/utils/resolve-addon-package.js +3 -1
  18. package/dist/utils/resolve-ref-contract.d.ts +21 -0
  19. package/dist/utils/resolve-ref-contract.js +46 -0
  20. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  21. package/dist/utils/serialize-inspector-state.js +9 -0
  22. package/dist/visit.js +24 -19
  23. package/package.json +1 -1
  24. package/src/add/add-addon-bans.ts +84 -0
  25. package/src/add/add-channel.ts +66 -7
  26. package/src/add/add-cli.ts +30 -0
  27. package/src/add/add-http-route.ts +75 -1
  28. package/src/add/add-http-routes.ts +283 -41
  29. package/src/add/addon-bans.test.ts +121 -0
  30. package/src/add/addon-contracts.test.ts +221 -0
  31. package/src/error-codes.ts +4 -0
  32. package/src/inspector.ts +17 -5
  33. package/src/types.ts +65 -1
  34. package/src/utils/get-exported-variable-name.ts +48 -0
  35. package/src/utils/load-addon-functions-meta.ts +164 -0
  36. package/src/utils/resolve-addon-package.ts +6 -1
  37. package/src/utils/resolve-ref-contract.ts +71 -0
  38. package/src/utils/serialize-inspector-state.ts +10 -0
  39. package/src/visit.ts +26 -19
  40. package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.12.23
2
+
3
+ ### Patch Changes
4
+
5
+ - 807a8d0: Add `refHTTP` / `refChannel` / `refCLI` so a consumer can wire an addon's HTTP routes, channel actions, and CLI commands directly from the addon's published `.pikku` contract metadata — no addon source is imported and nothing is hand-wired. These mirror the existing `ref('namespace:fn')` helper: each reference resolves the addon's already-loaded contract (via `wireAddon`) and proxies every function through `ref()` (RPC) at runtime.
6
+ - **Inspector:** `wireHTTPRoutes`/`wireChannel`/`wireCLI` now expand `refHTTP('ns:contract')` / `refChannel('ns:contract')` / `refCLI('ns:contract')` call expressions against `state.exportedContracts.addon{Http,Channel,Cli}` (already namespaced and `packageName`-tagged by `loadAddonFunctionsMeta`). An optional second argument overrides the mount basePath, e.g. `refHTTP('ext:helloRoutes', { basePath: '/ext' })`; otherwise the addon contract's own basePath is preserved.
7
+ - **CLI codegen:** the generated `pikku-function-types.gen.ts` now emits `refHTTP`/`refChannel`/`refCLI` (exported through `#pikku`) backed by const maps built from each wired addon's contract metadata, with every function pre-bound to `ref('ns:fn')`. Type-checking and runtime wiring resolve from the same generated artifact, so a reference can never be an inert marker.
8
+ - **Addon authoring bans:** when inspecting an addon package (`isAddon`), the inspector now raises a critical error if the addon calls a transport wiring helper (`wireHTTP`/`wireHTTPRoutes`/`wireChannel`/`wireCLI`/`wireScheduler`/`wireQueueWorker`/`wireMCPPrompt`/`wireMCPResource`/`wireTrigger`/`wireTriggerSource`/`wireGateway`/`wireAddon`) — these are the consuming app's responsibility (`PKU920`) — or if a `define*` contract carries `middleware`/`permissions`, which the consuming app applies, not the addon (`PKU921`). Service declarations (`wireSecret`/`wireVariable`/`wireCredential`) and function-level middleware/permissions remain allowed.
9
+ - **Deploy-bundle fix:** the HTTP/channel/CLI codegen commands now always emit their wiring and meta gen files once they report the category as active (truthy return), including the contracts-only or synthetic-route case where there are no local `wireHTTP`/`addChannel`/`wireCLI` source files. The generated bootstrap imports those files unconditionally, so skipping them left per-unit deploy bundles (e.g. Cloudflare units for scheduled tasks and workflow steps) unable to resolve `pikku-http-wirings.gen.js` and failing to build.
10
+
1
11
  ## 0.12.22
2
12
 
3
13
  ### Patch Changes
@@ -0,0 +1,7 @@
1
+ import type { AddWiring } from '../types.js';
2
+ /**
3
+ * Enforce addon authoring rules. Only runs when inspecting an addon package
4
+ * (options.isAddon). Addons cannot wire transports, and their contracts cannot
5
+ * carry middleware or permissions — those are the consuming app's concern.
6
+ */
7
+ export declare const checkAddonBans: AddWiring;
@@ -0,0 +1,65 @@
1
+ import * as ts from 'typescript';
2
+ import { ErrorCode } from '../error-codes.js';
3
+ /**
4
+ * Wiring helpers an addon must not call. Addons declare contracts with the
5
+ * define* helpers and export functions; the consuming app does the wiring via
6
+ * refHTTP / refChannel / refCLI. Service declarations remain allowed.
7
+ */
8
+ const BANNED_WIRINGS = new Set([
9
+ 'wireAddon',
10
+ 'wireChannel',
11
+ 'wireCLI',
12
+ 'wireGateway',
13
+ 'wireHTTP',
14
+ 'wireHTTPRoutes',
15
+ 'wireMCPPrompt',
16
+ 'wireMCPResource',
17
+ 'wireQueueWorker',
18
+ 'wireScheduler',
19
+ 'wireTrigger',
20
+ 'wireTriggerSource',
21
+ ]);
22
+ const CONTRACT_DEFINERS = new Set([
23
+ 'defineHTTPRoutes',
24
+ 'defineChannelRoutes',
25
+ 'defineCLICommands',
26
+ ]);
27
+ const hasHandlerProperty = (node) => {
28
+ let found = false;
29
+ const visit = (current) => {
30
+ if (found)
31
+ return;
32
+ if (ts.isPropertyAssignment(current) &&
33
+ (ts.isIdentifier(current.name) || ts.isStringLiteral(current.name)) &&
34
+ (current.name.text === 'middleware' ||
35
+ current.name.text === 'permissions')) {
36
+ found = true;
37
+ return;
38
+ }
39
+ ts.forEachChild(current, visit);
40
+ };
41
+ visit(node);
42
+ return found;
43
+ };
44
+ /**
45
+ * Enforce addon authoring rules. Only runs when inspecting an addon package
46
+ * (options.isAddon). Addons cannot wire transports, and their contracts cannot
47
+ * carry middleware or permissions — those are the consuming app's concern.
48
+ */
49
+ export const checkAddonBans = (logger, node, _checker, _state, options) => {
50
+ if (!options.isAddon)
51
+ return;
52
+ if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression))
53
+ return;
54
+ const name = node.expression.text;
55
+ if (BANNED_WIRINGS.has(name)) {
56
+ logger.critical(ErrorCode.ADDON_WIRING_NOT_ALLOWED, `Addons must not call '${name}'. Declare contracts with define* and export functions; the consuming app wires them via refHTTP / refChannel / refCLI.`);
57
+ return;
58
+ }
59
+ if (CONTRACT_DEFINERS.has(name)) {
60
+ const [arg] = node.arguments;
61
+ if (arg && hasHandlerProperty(arg)) {
62
+ logger.critical(ErrorCode.ADDON_CONTRACT_HANDLERS_NOT_ALLOWED, `Addon contract '${name}' must not declare middleware or permissions — these are applied by the consuming app, not the addon.`);
63
+ }
64
+ }
65
+ };
@@ -10,6 +10,8 @@ import { resolveIdentifier } from '../utils/resolve-identifier.js';
10
10
  import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
11
11
  import { resolveAddonName } from '../utils/resolve-addon-package.js';
12
12
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js';
13
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js';
14
+ import { resolveRefContract } from '../utils/resolve-ref-contract.js';
13
15
  /**
14
16
  * Safely get the "initializer" expression of a property-like AST node:
15
17
  * - for `foo: expr`, returns `expr`
@@ -25,6 +27,17 @@ function getInitializerOf(elem) {
25
27
  }
26
28
  return undefined;
27
29
  }
30
+ function getObjectPropertyName(name) {
31
+ if (!name)
32
+ return null;
33
+ if (ts.isIdentifier(name))
34
+ return name.text;
35
+ if (ts.isStringLiteral(name) || ts.isNumericLiteral(name))
36
+ return name.text;
37
+ if (ts.isComputedPropertyName(name))
38
+ return null;
39
+ return name.getText();
40
+ }
28
41
  /**
29
42
  * Resolve a handler expression (Identifier, CallExpression, or { func })
30
43
  * into its underlying function name.
@@ -84,6 +97,14 @@ function getHandlerNameFromExpression(expr, checker, rootDir) {
84
97
  }
85
98
  return null;
86
99
  }
100
+ function extractExportedChannelRoutes(logger, routes, state, checker) {
101
+ const wrapper = ts.factory.createObjectLiteralExpression([
102
+ ts.factory.createPropertyAssignment('onMessageWiring', ts.factory.createObjectLiteralExpression([
103
+ ts.factory.createPropertyAssignment('contract', routes),
104
+ ])),
105
+ ]);
106
+ return addMessagesRoutes(logger, wrapper, state, checker).contract ?? {};
107
+ }
87
108
  /**
88
109
  * Build out the nested message-routes by looking up each handler
89
110
  * in state.functions.meta instead of re-inferring it here.
@@ -108,9 +129,21 @@ export function addMessagesRoutes(logger, obj, state, checker) {
108
129
  chanInit = resolved;
109
130
  }
110
131
  }
132
+ const refContract = resolveRefContract(chanInit, 'refChannel', state.exportedContracts.addonChannel);
133
+ if (refContract) {
134
+ const refChannelKey = getObjectPropertyName(chanElem.name);
135
+ if (!refChannelKey)
136
+ continue;
137
+ result[refChannelKey] = {
138
+ ...refContract.contract,
139
+ };
140
+ continue;
141
+ }
111
142
  if (!ts.isObjectLiteralExpression(chanInit))
112
143
  continue;
113
- const channelKey = chanElem.name.getText();
144
+ const channelKey = getObjectPropertyName(chanElem.name);
145
+ if (!channelKey)
146
+ continue;
114
147
  result[channelKey] = {};
115
148
  for (const routeElem of chanInit.properties) {
116
149
  const init = getInitializerOf(routeElem);
@@ -120,11 +153,9 @@ export function addMessagesRoutes(logger, obj, state, checker) {
120
153
  const routeName = routeElem.name;
121
154
  if (!routeName)
122
155
  continue;
123
- let routeKey = routeName.getText();
124
- // For string literals like 'greet' or "greet", strip the quotes
125
- if (ts.isStringLiteral(routeName)) {
126
- routeKey = routeName.text;
127
- }
156
+ const routeKey = getObjectPropertyName(routeName);
157
+ if (!routeKey)
158
+ continue;
128
159
  // For shorthand properties, we need to resolve the identifier to its declaration
129
160
  if (ts.isShorthandPropertyAssignment(routeElem)) {
130
161
  // Get the symbol for the shorthand property
@@ -359,6 +390,16 @@ export function addMessagesRoutes(logger, obj, state, checker) {
359
390
  export const addChannel = (logger, node, checker, state, options) => {
360
391
  if (!ts.isCallExpression(node))
361
392
  return;
393
+ if (ts.isIdentifier(node.expression) &&
394
+ node.expression.text === 'defineChannelRoutes') {
395
+ const exportName = getExportedVariableName(node, options.sourceFile);
396
+ const [firstArg] = node.arguments;
397
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
398
+ state.exportedContracts.channel[exportName] =
399
+ extractExportedChannelRoutes(logger, firstArg, state, checker);
400
+ }
401
+ return;
402
+ }
362
403
  const { expression, arguments: args } = node;
363
404
  if (!ts.isIdentifier(expression) || expression.text !== 'wireChannel')
364
405
  return;
@@ -8,6 +8,8 @@ import { resolveIdentifier } from '../utils/resolve-identifier.js';
8
8
  import { resolveAddonName } from '../utils/resolve-addon-package.js';
9
9
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js';
10
10
  import { extractServicesFromFunction } from '../utils/extract-services.js';
11
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js';
12
+ import { resolveRefContract } from '../utils/resolve-ref-contract.js';
11
13
  // Track if we've warned about missing Config type to avoid duplicate warnings
12
14
  const configTypeWarningShown = new Set();
13
15
  /**
@@ -16,6 +18,15 @@ const configTypeWarningShown = new Set();
16
18
  export const addCLI = (logger, node, typeChecker, inspectorState, options) => {
17
19
  if (!ts.isCallExpression(node))
18
20
  return;
21
+ if (ts.isIdentifier(node.expression) &&
22
+ node.expression.text === 'defineCLICommands') {
23
+ const exportName = getExportedVariableName(node, options.sourceFile);
24
+ const [firstArg] = node.arguments;
25
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
26
+ inspectorState.exportedContracts.cli[exportName] = processCommands(logger, firstArg, node.getSourceFile(), typeChecker, exportName, inspectorState, options);
27
+ }
28
+ return;
29
+ }
19
30
  // Check if this is a wireCLI call
20
31
  if (!node || !node.expression) {
21
32
  return;
@@ -129,6 +140,12 @@ function processCommands(logger, node, sourceFile, typeChecker, programName, ins
129
140
  const spreadCommands = processCommands(logger, spreadTarget, sourceFile, typeChecker, programName, inspectorState, options, programTags);
130
141
  Object.assign(commands, spreadCommands);
131
142
  }
143
+ else {
144
+ const refCommands = resolveRefContract(prop.expression, 'refCLI', inspectorState.exportedContracts.addonCli);
145
+ if (refCommands) {
146
+ Object.assign(commands, refCommands.contract);
147
+ }
148
+ }
132
149
  continue;
133
150
  }
134
151
  if (!ts.isPropertyAssignment(prop))
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript';
2
- import type { AddWiring, InspectorState } from '../types.js';
2
+ import type { AddWiring, ExportedHTTPRouteConfigMeta, InspectorState } from '../types.js';
3
3
  import type { InspectorLogger } from '../types.js';
4
4
  /**
5
5
  * Parameters for registering an HTTP route
@@ -14,11 +14,21 @@ export interface RegisterHTTPRouteParams {
14
14
  inheritedTags?: string[];
15
15
  inheritedAuth?: boolean;
16
16
  }
17
+ export interface RegisterHTTPRouteMetaParams {
18
+ route: ExportedHTTPRouteConfigMeta;
19
+ state: InspectorState;
20
+ logger: InspectorLogger;
21
+ sourceFile: ts.SourceFile;
22
+ basePath?: string;
23
+ inheritedTags?: string[];
24
+ inheritedAuth?: boolean;
25
+ }
17
26
  /**
18
27
  * Shared function to register an HTTP route in the inspector state.
19
28
  * Used by both wireHTTP and wireHTTPRoutes.
20
29
  */
21
30
  export declare function registerHTTPRoute({ obj, state, checker, logger, sourceFile, basePath, inheritedTags, inheritedAuth, }: RegisterHTTPRouteParams): void;
31
+ export declare function registerHTTPRouteMeta({ route, state, logger, sourceFile, basePath, inheritedTags, inheritedAuth, }: RegisterHTTPRouteMetaParams): void;
22
32
  /**
23
33
  * Process wireHTTP calls
24
34
  */
@@ -251,6 +251,43 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
251
251
  groupBasePath: basePath || undefined,
252
252
  };
253
253
  }
254
+ export function registerHTTPRouteMeta({ route, state, logger, sourceFile, basePath = '', inheritedTags = [], inheritedAuth, }) {
255
+ const method = route.method.toLowerCase();
256
+ const fullRoute = basePath + route.route;
257
+ const tags = [...inheritedTags, ...(route.tags || [])];
258
+ const funcName = route.func.pikkuFuncId;
259
+ const fnMeta = resolveFunctionMeta(state, funcName);
260
+ if (!fnMeta) {
261
+ logger.critical(ErrorCode.FUNCTION_METADATA_NOT_FOUND, `No function metadata found for '${funcName}'.`);
262
+ return;
263
+ }
264
+ let params = [];
265
+ try {
266
+ const keys = pathToRegexp(fullRoute).keys;
267
+ params = keys.filter((k) => k.type === 'param').map((k) => k.name);
268
+ }
269
+ catch (error) {
270
+ logger.error(`Failed to parse route '${fullRoute}': ${error instanceof Error ? error.message : error}`);
271
+ return;
272
+ }
273
+ if (!route.func.packageName) {
274
+ computeInputTypes(state.http.metaInputTypes, method, fnMeta.inputs?.[0] || null, [], params);
275
+ }
276
+ state.serviceAggregation.usedFunctions.add(funcName);
277
+ state.http.files.add(sourceFile.fileName);
278
+ state.http.meta[method][fullRoute] = {
279
+ pikkuFuncId: funcName,
280
+ ...(route.func.packageName && { packageName: route.func.packageName }),
281
+ route: fullRoute,
282
+ sourceFile: sourceFile.fileName,
283
+ method: method,
284
+ params: params.length > 0 ? params : undefined,
285
+ inputTypes: undefined,
286
+ tags: tags.length > 0 ? tags : undefined,
287
+ sse: route.sse ? true : undefined,
288
+ groupBasePath: basePath || undefined,
289
+ };
290
+ }
254
291
  /**
255
292
  * Process wireHTTP calls
256
293
  */
@@ -1,5 +1,2 @@
1
1
  import type { AddWiring } from '../types.js';
2
- /**
3
- * Process wireHTTPRoutes calls
4
- */
5
2
  export declare const addHTTPRoutes: AddWiring;
@@ -1,31 +1,40 @@
1
1
  import * as ts from 'typescript';
2
2
  import { getPropertyValue } from '../utils/get-property-value.js';
3
- import { registerHTTPRoute } from './add-http-route.js';
3
+ import { registerHTTPRoute, registerHTTPRouteMeta } from './add-http-route.js';
4
4
  import { resolveIdentifier } from '../utils/resolve-identifier.js';
5
- /**
6
- * Process wireHTTPRoutes calls
7
- */
8
- export const addHTTPRoutes = (logger, node, checker, state, _options) => {
5
+ import { extractFunctionName } from '../utils/extract-function-name.js';
6
+ import { getPropertyAssignmentInitializer } from '../utils/type-utils.js';
7
+ import { resolveAddonName } from '../utils/resolve-addon-package.js';
8
+ import { resolveRefContract, } from '../utils/resolve-ref-contract.js';
9
+ import { getExportedVariableName } from '../utils/get-exported-variable-name.js';
10
+ export const addHTTPRoutes = (logger, node, checker, state, options) => {
9
11
  if (!ts.isCallExpression(node))
10
12
  return;
11
13
  const { expression, arguments: args } = node;
12
- if (!ts.isIdentifier(expression) || expression.text !== 'wireHTTPRoutes')
14
+ if (!ts.isIdentifier(expression))
15
+ return;
16
+ if (expression.text === 'defineHTTPRoutes') {
17
+ const exportName = getExportedVariableName(node, options.sourceFile);
18
+ const firstArg = args[0];
19
+ if (exportName && firstArg && ts.isObjectLiteralExpression(firstArg)) {
20
+ const contract = serializeHTTPRoutesContract(firstArg, checker, state);
21
+ if (contract) {
22
+ state.exportedContracts.http[exportName] = contract;
23
+ }
24
+ }
25
+ return;
26
+ }
27
+ if (expression.text !== 'wireHTTPRoutes')
13
28
  return;
14
29
  const firstArg = args[0];
15
30
  if (!firstArg || !ts.isObjectLiteralExpression(firstArg))
16
31
  return;
17
- // Extract group config
18
32
  const groupConfig = extractGroupConfig(firstArg);
19
- // Get routes property
20
33
  const routesProp = getPropertyAssignment(firstArg, 'routes');
21
34
  if (!routesProp)
22
35
  return;
23
- // Process routes recursively
24
36
  processRoutes(routesProp.initializer, groupConfig, state, checker, logger, node.getSourceFile());
25
37
  };
26
- /**
27
- * Get a property assignment from an object literal
28
- */
29
38
  function getPropertyAssignment(obj, propName) {
30
39
  for (const prop of obj.properties) {
31
40
  if (ts.isPropertyAssignment(prop) &&
@@ -36,9 +45,6 @@ function getPropertyAssignment(obj, propName) {
36
45
  }
37
46
  return undefined;
38
47
  }
39
- /**
40
- * Extract group configuration from an object literal
41
- */
42
48
  function extractGroupConfig(obj) {
43
49
  const basePath = getPropertyValue(obj, 'basePath') || '';
44
50
  const tags = getPropertyValue(obj, 'tags') || [];
@@ -49,9 +55,6 @@ function extractGroupConfig(obj) {
49
55
  auth: auth === true ? true : auth === false ? false : undefined,
50
56
  };
51
57
  }
52
- /**
53
- * Merge two group configs following cascading rules
54
- */
55
58
  function mergeConfigs(parent, child) {
56
59
  return {
57
60
  basePath: parent.basePath + child.basePath,
@@ -59,9 +62,6 @@ function mergeConfigs(parent, child) {
59
62
  auth: child.auth ?? parent.auth,
60
63
  };
61
64
  }
62
- /**
63
- * Check if a value is a route config (has method, func, and route)
64
- */
65
65
  function isRouteConfig(obj) {
66
66
  let hasMethod = false;
67
67
  let hasFunc = false;
@@ -78,9 +78,6 @@ function isRouteConfig(obj) {
78
78
  }
79
79
  return hasMethod && hasFunc && hasRoute;
80
80
  }
81
- /**
82
- * Check if a value is a route contract (has routes property but no method/func)
83
- */
84
81
  function isRouteContract(obj) {
85
82
  let hasRoutes = false;
86
83
  let hasMethod = false;
@@ -97,11 +94,7 @@ function isRouteContract(obj) {
97
94
  }
98
95
  return hasRoutes && !hasMethod && !hasFunc;
99
96
  }
100
- /**
101
- * Recursively process routes - handles nested maps, contracts, and identifiers
102
- */
103
97
  function processRoutes(node, parentConfig, state, checker, logger, sourceFile) {
104
- // Handle array of routes
105
98
  if (ts.isArrayLiteralExpression(node)) {
106
99
  for (const element of node.elements) {
107
100
  if (ts.isObjectLiteralExpression(element) && isRouteConfig(element)) {
@@ -110,14 +103,11 @@ function processRoutes(node, parentConfig, state, checker, logger, sourceFile) {
110
103
  }
111
104
  return;
112
105
  }
113
- // Handle object literal
114
106
  if (ts.isObjectLiteralExpression(node)) {
115
- // Check if this is a route config
116
107
  if (isRouteConfig(node)) {
117
108
  processRoute(node, parentConfig, state, checker, logger, sourceFile);
118
109
  return;
119
110
  }
120
- // Check if this is a route contract
121
111
  if (isRouteContract(node)) {
122
112
  const contractConfig = extractGroupConfig(node);
123
113
  const mergedConfig = mergeConfigs(parentConfig, contractConfig);
@@ -127,15 +117,25 @@ function processRoutes(node, parentConfig, state, checker, logger, sourceFile) {
127
117
  }
128
118
  return;
129
119
  }
130
- // Otherwise it's a nested map - process each property
131
120
  for (const prop of node.properties) {
132
121
  if (ts.isPropertyAssignment(prop)) {
122
+ const ref = resolveRefContract(prop.initializer, 'refHTTP', state.exportedContracts.addonHttp);
123
+ if (ref) {
124
+ processRefHTTPContract(ref, parentConfig, state, logger, sourceFile);
125
+ continue;
126
+ }
133
127
  processRoutes(prop.initializer, parentConfig, state, checker, logger, sourceFile);
134
128
  }
135
129
  }
136
130
  return;
137
131
  }
138
- // Handle identifier - resolve to its definition
132
+ if (ts.isCallExpression(node)) {
133
+ const ref = resolveRefContract(node, 'refHTTP', state.exportedContracts.addonHttp);
134
+ if (ref) {
135
+ processRefHTTPContract(ref, parentConfig, state, logger, sourceFile);
136
+ }
137
+ return;
138
+ }
139
139
  if (ts.isIdentifier(node)) {
140
140
  const resolved = resolveIdentifier(node, checker, ['defineHTTPRoutes']);
141
141
  if (resolved) {
@@ -143,9 +143,152 @@ function processRoutes(node, parentConfig, state, checker, logger, sourceFile) {
143
143
  }
144
144
  }
145
145
  }
146
- /**
147
- * Register a single route using the shared registerHTTPRoute function
148
- */
146
+ function processRefHTTPContract(ref, parentConfig, state, logger, sourceFile) {
147
+ const basePath = ref.basePath !== undefined ? ref.basePath : ref.contract.basePath || '';
148
+ processExportedRouteMap(ref.contract.routes, mergeConfigs(parentConfig, {
149
+ basePath,
150
+ tags: ref.contract.tags || [],
151
+ auth: ref.contract.auth,
152
+ }), state, logger, sourceFile);
153
+ }
154
+ function processExportedRouteMap(routes, parentConfig, state, logger, sourceFile) {
155
+ for (const value of Object.values(routes)) {
156
+ if (isExportedRouteConfig(value)) {
157
+ registerHTTPRouteMeta({
158
+ route: value,
159
+ state,
160
+ logger,
161
+ sourceFile,
162
+ basePath: parentConfig.basePath,
163
+ inheritedTags: parentConfig.tags,
164
+ inheritedAuth: parentConfig.auth,
165
+ });
166
+ continue;
167
+ }
168
+ if (isExportedRouteContract(value)) {
169
+ processExportedRouteMap(value.routes, mergeConfigs(parentConfig, {
170
+ basePath: value.basePath || '',
171
+ tags: value.tags || [],
172
+ auth: value.auth,
173
+ }), state, logger, sourceFile);
174
+ continue;
175
+ }
176
+ processExportedRouteMap(value, parentConfig, state, logger, sourceFile);
177
+ }
178
+ }
179
+ function isExportedRouteConfig(value) {
180
+ return (typeof value === 'object' &&
181
+ value !== null &&
182
+ 'method' in value &&
183
+ 'route' in value &&
184
+ 'func' in value);
185
+ }
186
+ function isExportedRouteContract(value) {
187
+ return (typeof value === 'object' &&
188
+ value !== null &&
189
+ 'routes' in value &&
190
+ !('method' in value));
191
+ }
192
+ function serializeHTTPRoutesContract(node, checker, state) {
193
+ if (isRouteContract(node)) {
194
+ const routesProp = getPropertyAssignment(node, 'routes');
195
+ if (!routesProp || !ts.isObjectLiteralExpression(routesProp.initializer)) {
196
+ return null;
197
+ }
198
+ return {
199
+ ...extractGroupConfig(node),
200
+ routes: serializeHTTPRouteMap(routesProp.initializer, checker, state),
201
+ };
202
+ }
203
+ return {
204
+ routes: serializeHTTPRouteMap(node, checker, state),
205
+ };
206
+ }
207
+ function serializeHTTPRouteMap(node, checker, state) {
208
+ const result = {};
209
+ for (const prop of node.properties) {
210
+ if (!ts.isPropertyAssignment(prop))
211
+ continue;
212
+ const key = prop.name.getText().replace(/^['"]|['"]$/g, '');
213
+ const value = prop.initializer;
214
+ if (ts.isObjectLiteralExpression(value)) {
215
+ if (isRouteConfig(value)) {
216
+ const route = serializeHTTPRouteConfig(value, checker, state);
217
+ if (route) {
218
+ result[key] = route;
219
+ }
220
+ continue;
221
+ }
222
+ if (isRouteContract(value)) {
223
+ const routeContract = serializeHTTPRoutesContract(value, checker, state);
224
+ if (routeContract) {
225
+ result[key] = routeContract;
226
+ }
227
+ continue;
228
+ }
229
+ result[key] = serializeHTTPRouteMap(value, checker, state);
230
+ continue;
231
+ }
232
+ if (ts.isIdentifier(value)) {
233
+ const resolved = resolveIdentifier(value, checker, ['defineHTTPRoutes']);
234
+ if (resolved && ts.isObjectLiteralExpression(resolved)) {
235
+ if (isRouteContract(resolved)) {
236
+ const routeContract = serializeHTTPRoutesContract(resolved, checker, state);
237
+ if (routeContract) {
238
+ result[key] = routeContract;
239
+ }
240
+ }
241
+ else {
242
+ result[key] = serializeHTTPRouteMap(resolved, checker, state);
243
+ }
244
+ }
245
+ }
246
+ }
247
+ return result;
248
+ }
249
+ function serializeHTTPRouteConfig(obj, checker, state) {
250
+ const method = getPropertyValue(obj, 'method');
251
+ const route = getPropertyValue(obj, 'route');
252
+ const funcInitializer = getPropertyAssignmentInitializer(obj, 'func', true, checker);
253
+ if (!method || !route || !funcInitializer) {
254
+ return null;
255
+ }
256
+ let pikkuFuncId = extractFunctionName(funcInitializer, checker, state.rootDir).pikkuFuncId;
257
+ let packageName;
258
+ if (ts.isCallExpression(funcInitializer) &&
259
+ ts.isIdentifier(funcInitializer.expression) &&
260
+ funcInitializer.expression.text === 'ref') {
261
+ const [firstArg] = funcInitializer.arguments;
262
+ if (firstArg && ts.isStringLiteral(firstArg)) {
263
+ pikkuFuncId = firstArg.text;
264
+ const addonNamespace = pikkuFuncId.includes(':')
265
+ ? pikkuFuncId.split(':')[0]
266
+ : null;
267
+ packageName = addonNamespace
268
+ ? state.rpc.wireAddonDeclarations.get(addonNamespace)?.package
269
+ : undefined;
270
+ }
271
+ }
272
+ else if (ts.isIdentifier(funcInitializer)) {
273
+ packageName =
274
+ resolveAddonName(funcInitializer, checker, state.rpc.wireAddonDeclarations) || undefined;
275
+ }
276
+ return {
277
+ auth: getPropertyValue(obj, 'auth'),
278
+ contentType: getPropertyValue(obj, 'contentType'),
279
+ headers: getPropertyValue(obj, 'headers') ||
280
+ undefined,
281
+ method,
282
+ route,
283
+ sse: getPropertyValue(obj, 'sse'),
284
+ tags: getPropertyValue(obj, 'tags') || undefined,
285
+ timeout: getPropertyValue(obj, 'timeout'),
286
+ func: {
287
+ pikkuFuncId,
288
+ ...(packageName && { packageName }),
289
+ },
290
+ };
291
+ }
149
292
  function processRoute(obj, groupConfig, state, checker, logger, sourceFile) {
150
293
  registerHTTPRoute({
151
294
  obj,
@@ -57,7 +57,9 @@ export declare enum ErrorCode {
57
57
  SERVICES_NOT_DESTRUCTURED = "PKU410",
58
58
  WIRES_NOT_DESTRUCTURED = "PKU411",
59
59
  WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
60
- PII_IN_OUTPUT = "PKU910"
60
+ PII_IN_OUTPUT = "PKU910",
61
+ ADDON_WIRING_NOT_ALLOWED = "PKU920",
62
+ ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921"
61
63
  }
62
64
  /**
63
65
  * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
@@ -72,4 +72,7 @@ export var ErrorCode;
72
72
  ErrorCode["WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED"] = "PKU901";
73
73
  // Data classification errors
74
74
  ErrorCode["PII_IN_OUTPUT"] = "PKU910";
75
+ // Addon authoring errors
76
+ ErrorCode["ADDON_WIRING_NOT_ALLOWED"] = "PKU920";
77
+ ErrorCode["ADDON_CONTRACT_HANDLERS_NOT_ALLOWED"] = "PKU921";
75
78
  })(ErrorCode || (ErrorCode = {}));