@pikku/inspector 0.12.95 → 0.12.98
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 +66 -0
- package/dist/add/add-cli.js +4 -1
- package/dist/add/add-credential.js +2 -0
- package/dist/add/add-functions.js +19 -0
- package/dist/add/add-rpc-invocations.d.ts +17 -0
- package/dist/add/add-rpc-invocations.js +62 -5
- package/dist/add/add-trigger.js +16 -0
- package/dist/add/collect-started-workflows.d.ts +26 -0
- package/dist/add/collect-started-workflows.js +72 -0
- package/dist/inspector.js +2 -1
- package/dist/types.d.ts +1 -0
- package/dist/utils/filter-inspector-state.js +48 -0
- package/dist/utils/load-addon-functions-meta.d.ts +8 -0
- package/dist/utils/load-addon-functions-meta.js +47 -0
- package/dist/utils/program-path.d.ts +7 -0
- package/dist/utils/program-path.js +8 -0
- package/dist/utils/schema-generator.js +30 -3
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,69 @@
|
|
|
1
|
+
## 0.12.98
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 8ac25a8: An OAuth2 credential can set `scopeSeparator` for a provider that wants scopes joined by something other than a space (Twist wants a comma). A credential with no scopes already sent no `scope` parameter; that is now tested.
|
|
6
|
+
- 6606777: A webhook source can declare how its requests are signed, and the runner checks every request before `receive` runs:
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
wireTriggerWebhookSource({
|
|
10
|
+
name: 'github',
|
|
11
|
+
verify: {
|
|
12
|
+
hmac: {
|
|
13
|
+
header: 'x-hub-signature-256',
|
|
14
|
+
prefix: 'sha256=',
|
|
15
|
+
algorithm: 'sha256',
|
|
16
|
+
encoding: 'hex',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
receive: githubWebhookReceive,
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`verify` is an HMAC over the raw body, a shared token or a public-key signature in one header, or a function `(request, secret, services) => boolean` for anything else. A request is refused while the secret is unset or when the signature does not match. A request without a body reaches `receive` unchecked so handshakes still work, but it may only be answered: events from it are refused.
|
|
24
|
+
|
|
25
|
+
Declaring `verify` declares the secret's credential too, so it needs no `defineCredential`. It is a singleton string named `<source>WebhookSecret` in camelCase (`microsoft-outlook` → `microsoftOutlookWebhookSecret`, see `webhookSecretCredentialName`), or whatever `credential` names, described by `credentialDescription`.
|
|
26
|
+
|
|
27
|
+
`@pikku/core/hmac` gains `hmacDigest`, `verifyHmacSignature` and `verifyPublicKeySignature`. `WebhookSigningSecret` is deprecated.
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [d327fa5]
|
|
30
|
+
- Updated dependencies [942ebdd]
|
|
31
|
+
- Updated dependencies [8ac25a8]
|
|
32
|
+
- Updated dependencies [6606777]
|
|
33
|
+
- @pikku/core@0.12.132
|
|
34
|
+
|
|
35
|
+
## 0.12.97
|
|
36
|
+
|
|
37
|
+
### Patch Changes
|
|
38
|
+
|
|
39
|
+
- 26dbfc0: A `func` written inline in a `wireCLI` command now registers its function metadata under `cli:<program>:<command path>`, with `sessionless` taken from the helper it was built with. Before, the command pointed at an id nothing had registered. An inline channel `onMessage` still fails the build with the named "No function metadata found" error, and a test now pins that.
|
|
40
|
+
- 2dcc651: The inspector finds a project's source on Windows: its root directory is compared with TypeScript's forward-slash file names, not the backslash path `path.resolve` returns.
|
|
41
|
+
- Updated dependencies [58cccc1]
|
|
42
|
+
- Updated dependencies [a26c60e]
|
|
43
|
+
- @pikku/core@0.12.131
|
|
44
|
+
|
|
45
|
+
## 0.12.96
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- dfcd351: The deploy planner now binds a unit to the units of the functions its functions call with `rpc.invoke('name')` / `rpc.remote('name')`. Before, a caller and callee split into different units (e.g. a no-service function in `svc-base` calling a DB function in `svc-kysely`) got no service binding and failed on the deployed stage with "No service binding for function". The inspector records literal RPC names per function as `invokes` (single, double or substitution-free template quotes; `rpc!`, `wire.rpc`) and warns on a computed name, which the planner cannot see (#1883).
|
|
50
|
+
- cf40182: Deployed units that call `rpc.startWorkflow('x')` now get x's meta, so the call no longer fails with `WorkflowNotFoundError`. The inspector records `startsWorkflows` for literal `rpc.startWorkflow(...)` calls. It warns when a handler computes the workflow name or passes `rpc` to a helper, because the planner cannot see those calls.
|
|
51
|
+
|
|
52
|
+
With workflow queues, the deploy planner gives a starter unit the workflow meta and orchestrator queue meta only (new `--workflowMeta` filter), plus `workflow-state` and `queue` services. Without queues the start runs inline, so the whole workflow is bundled. Core's `startWorkflow` now requires the workflow registration only for inline runs; queued runs need only the meta.
|
|
53
|
+
|
|
54
|
+
- f817f1c: Schema generation no longer re-walks every source file for each type. ts-json-schema-generator looks each root type up by name by scanning the whole program, so a project with 677 types scanned it 677 times. The inspector now builds that name index once per program. On the e2e project, TS schema generation drops from 8.7s to 0.1s and a cold `pikku all` from 38s to 8s, with identical output.
|
|
55
|
+
- 5bce779: Webhook trigger sources are off until someone turns them on, and addons declare their own.
|
|
56
|
+
|
|
57
|
+
An addon calls `wireTriggerWebhookSource` in its own package, and an app that wires the addon gets the source (its route included) without declaring it: named and routed after the addon's namespace, so two instances get one each. A source the app declares under the same name wins.
|
|
58
|
+
|
|
59
|
+
Every source now has an `enabled` switch in the `triggerSourceStore`, off by default. `reconcileTriggerSources` registers only enabled sources and records each one's `baseUrl` and `labelPrefix`; a disabled source's route answers 404 without running `receive`. New in core: `enableTriggerSource` registers a source with its provider and `disableTriggerSource` stops it receiving, then tears it down, both at the recorded address unless one is given. The admin addon exposes them as `triggerSourceEnable` and `triggerSourceDisable`. The `pikkuTriggerSource` table gains `enabled`, `baseUrl` and `labelPrefix`: run `pikku db generate` for the migration.
|
|
60
|
+
|
|
61
|
+
- Updated dependencies [dfcd351]
|
|
62
|
+
- Updated dependencies [cf40182]
|
|
63
|
+
- Updated dependencies [5bce779]
|
|
64
|
+
- Updated dependencies [5bce779]
|
|
65
|
+
- @pikku/core@0.12.130
|
|
66
|
+
|
|
1
67
|
## 0.12.95
|
|
2
68
|
|
|
3
69
|
### Patch Changes
|
package/dist/add/add-cli.js
CHANGED
|
@@ -3,6 +3,7 @@ import { extractFunctionName, makeContextBasedId, } from '../utils/extract-funct
|
|
|
3
3
|
import { resolveCoreType } from '../utils/resolve-core-type.js';
|
|
4
4
|
import { resolveMiddleware } from '../utils/middleware.js';
|
|
5
5
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
|
|
6
|
+
import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js';
|
|
6
7
|
import { extractWireNames } from '../utils/post-process.js';
|
|
7
8
|
import { getPropertyValue } from '../utils/get-property-value.js';
|
|
8
9
|
import { resolveIdentifier } from '../utils/resolve-identifier.js';
|
|
@@ -253,9 +254,11 @@ function processCommand(logger, inspectorState, options, name, node, sourceFile,
|
|
|
253
254
|
}
|
|
254
255
|
}
|
|
255
256
|
else {
|
|
256
|
-
|
|
257
|
+
const extracted = extractFunctionName(prop.initializer, typeChecker, inspectorState.rootDir);
|
|
258
|
+
pikkuFuncId = extracted.pikkuFuncId;
|
|
257
259
|
if (pikkuFuncId.startsWith('__temp_')) {
|
|
258
260
|
pikkuFuncId = makeContextBasedId('cli', programName, ...fullPath);
|
|
261
|
+
ensureInlineWiringFunction(inspectorState, pikkuFuncId, fullPath.join(' '), prop.initializer, typeChecker, extracted.isHelper);
|
|
259
262
|
}
|
|
260
263
|
meta.pikkuFuncId = pikkuFuncId;
|
|
261
264
|
const cliPackageName = ts.isIdentifier(prop.initializer)
|
|
@@ -81,6 +81,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
|
|
|
81
81
|
const authorizationUrl = getPropertyValue(oauth2Obj, 'authorizationUrl');
|
|
82
82
|
const tokenUrl = getPropertyValue(oauth2Obj, 'tokenUrl');
|
|
83
83
|
const scopes = getArrayPropertyValue(oauth2Obj, 'scopes');
|
|
84
|
+
const scopeSeparator = getPropertyValue(oauth2Obj, 'scopeSeparator');
|
|
84
85
|
const pkce = getPropertyValue(oauth2Obj, 'pkce');
|
|
85
86
|
const additionalParams = getRecordPropertyValue(oauth2Obj, 'additionalParams');
|
|
86
87
|
if (appCredentialSecretId && authorizationUrl && tokenUrl && scopes) {
|
|
@@ -90,6 +91,7 @@ export const addCredential = (logger, node, checker, state, _options) => {
|
|
|
90
91
|
authorizationUrl,
|
|
91
92
|
tokenUrl,
|
|
92
93
|
scopes,
|
|
94
|
+
scopeSeparator: scopeSeparator || undefined,
|
|
93
95
|
pkce: pkce || undefined,
|
|
94
96
|
additionalParams: additionalParams || undefined,
|
|
95
97
|
};
|
|
@@ -3,6 +3,8 @@ import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
|
|
|
3
3
|
import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-function-name.js';
|
|
4
4
|
import { extractFunctionNode } from '../utils/extract-function-node.js';
|
|
5
5
|
import { extractUsedWires } from '../utils/extract-services.js';
|
|
6
|
+
import { collectInvokedRpcNames } from './add-rpc-invocations.js';
|
|
7
|
+
import { collectStartedWorkflows } from './collect-started-workflows.js';
|
|
6
8
|
import { formatVersionedId, parseVersionedId } from '@pikku/core/utils';
|
|
7
9
|
/** Binding keys a `pikkuScenarioStep` may declare, in report order. */
|
|
8
10
|
const SCENARIO_SURFACE_KEYS = [
|
|
@@ -720,6 +722,10 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
720
722
|
}
|
|
721
723
|
}
|
|
722
724
|
const wires = extractUsedWires(handler, 2);
|
|
725
|
+
const invoked = collectInvokedRpcNames(handler.body);
|
|
726
|
+
for (const call of invoked.dynamic) {
|
|
727
|
+
logger.warn(`• ${pikkuFuncId} calls ${call} with a computed name — the deploy planner cannot bind its unit to the callee's, so the call fails with "No service binding" if the two are deployed apart. Use a literal RPC name.`);
|
|
728
|
+
}
|
|
723
729
|
// --- Generics → ts.Type[], unwrapped from Promise ---
|
|
724
730
|
const genericTypes = (typeArguments ?? [])
|
|
725
731
|
.map((tn) => checker.getTypeFromTypeNode(tn))
|
|
@@ -986,6 +992,17 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
986
992
|
bodyStart: lineOf(handlerBody.getStart(handlerSourceFile)),
|
|
987
993
|
bodyEnd: lineOf(handlerBody.getEnd()),
|
|
988
994
|
};
|
|
995
|
+
const started = collectStartedWorkflows(handler.body);
|
|
996
|
+
// Generated scaffolds (the workflow start/run routes) take the name from the
|
|
997
|
+
// request by design, and are served by units that have every workflow.
|
|
998
|
+
const handlerFile = handler.getSourceFile().fileName;
|
|
999
|
+
const warnOnStarts = !/\.gen\.[cm]?[jt]s$/.test(handlerFile);
|
|
1000
|
+
for (const call of warnOnStarts ? started.dynamic : []) {
|
|
1001
|
+
logger.warn(`• ${pikkuFuncId} calls ${call} with a computed name — the deploy planner cannot bundle that workflow's meta into ${pikkuFuncId}'s unit, so the deployed call fails with "Workflow not found" unless the unit has it for another reason. Use a literal workflow name.`);
|
|
1002
|
+
}
|
|
1003
|
+
if (warnOnStarts && started.rpcHandoffs.length > 0) {
|
|
1004
|
+
logger.warn(`• ${pikkuFuncId} passes rpc to ${started.rpcHandoffs.join(', ')} — rpc.startWorkflow / rpc.invoke calls made there are invisible to the deploy planner, so their targets are not bundled into or bound to ${pikkuFuncId}'s unit. Make those calls in the handler itself.`);
|
|
1005
|
+
}
|
|
989
1006
|
state.functions.meta[pikkuFuncId] = {
|
|
990
1007
|
pikkuFuncId,
|
|
991
1008
|
functionType: 'user',
|
|
@@ -994,6 +1011,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
994
1011
|
name,
|
|
995
1012
|
services,
|
|
996
1013
|
wires: wires.wires.length > 0 || !wires.optimized ? wires : undefined,
|
|
1014
|
+
invokes: invoked.names.length > 0 ? invoked.names : undefined,
|
|
997
1015
|
inputSchemaName: inputNames[0] ?? null,
|
|
998
1016
|
outputSchemaName: outputNames[0] ?? null,
|
|
999
1017
|
inputs: inputNames.filter((n) => n !== 'void') ?? null,
|
|
@@ -1037,6 +1055,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
1037
1055
|
bodySourceFile,
|
|
1038
1056
|
exportedName: exportedName || undefined,
|
|
1039
1057
|
...bodySpan,
|
|
1058
|
+
startsWorkflows: started.names.length > 0 ? started.names : undefined,
|
|
1040
1059
|
};
|
|
1041
1060
|
if (handlerHasDynamicImport(handler)) {
|
|
1042
1061
|
state.functions.dynamicImportIds.add(pikkuFuncId);
|
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import type { InspectorState, InspectorLogger } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The RPC names a function handler calls through `rpc.invoke(...)` /
|
|
5
|
+
* `rpc.remote(...)`, read from its own body.
|
|
6
|
+
*
|
|
7
|
+
* This is what the deploy planner uses to bind a unit to the units its
|
|
8
|
+
* functions call: without it, a caller and callee split into different units
|
|
9
|
+
* type-check, work in a single process, and fail on the deployed stage with
|
|
10
|
+
* "No service binding for function". `dynamic` holds the source text of calls
|
|
11
|
+
* whose name is not a literal — those cannot be planned for.
|
|
12
|
+
*
|
|
13
|
+
* Only the handler's own body is read. A call made from a helper defined
|
|
14
|
+
* elsewhere, with `rpc` passed in, is not seen.
|
|
15
|
+
*/
|
|
16
|
+
export declare function collectInvokedRpcNames(handler: ts.Node): {
|
|
17
|
+
names: string[];
|
|
18
|
+
dynamic: string[];
|
|
19
|
+
};
|
|
3
20
|
/**
|
|
4
21
|
* Scan for rpc.invoke() calls to track which functions are actually being invoked
|
|
5
22
|
* Also detects addon usage via:
|
|
@@ -32,6 +32,63 @@ function invocationReceiver(node) {
|
|
|
32
32
|
}
|
|
33
33
|
return inner;
|
|
34
34
|
}
|
|
35
|
+
/** `rpc`, or anything ending in `.rpc` (`wire.rpc`), seen through `!` and parens. */
|
|
36
|
+
function isRpcReceiver(node) {
|
|
37
|
+
const receiver = invocationReceiver(node);
|
|
38
|
+
return ts.isIdentifier(receiver)
|
|
39
|
+
? receiver.text === 'rpc'
|
|
40
|
+
: ts.isPropertyAccessExpression(receiver) && receiver.name.text === 'rpc';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Methods on `rpc` that call another function by name and, when that function
|
|
44
|
+
* is not bundled in the caller's deploy unit, cross to another unit through the
|
|
45
|
+
* `DeploymentService`.
|
|
46
|
+
*/
|
|
47
|
+
const CROSS_UNIT_RPC_METHODS = new Set(['invoke', 'remote']);
|
|
48
|
+
/** The name argument when it is statically known: `'x'`, `"x"` or `` `x` ``. */
|
|
49
|
+
function literalRpcName(arg) {
|
|
50
|
+
if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
|
|
51
|
+
return arg.text;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The RPC names a function handler calls through `rpc.invoke(...)` /
|
|
57
|
+
* `rpc.remote(...)`, read from its own body.
|
|
58
|
+
*
|
|
59
|
+
* This is what the deploy planner uses to bind a unit to the units its
|
|
60
|
+
* functions call: without it, a caller and callee split into different units
|
|
61
|
+
* type-check, work in a single process, and fail on the deployed stage with
|
|
62
|
+
* "No service binding for function". `dynamic` holds the source text of calls
|
|
63
|
+
* whose name is not a literal — those cannot be planned for.
|
|
64
|
+
*
|
|
65
|
+
* Only the handler's own body is read. A call made from a helper defined
|
|
66
|
+
* elsewhere, with `rpc` passed in, is not seen.
|
|
67
|
+
*/
|
|
68
|
+
export function collectInvokedRpcNames(handler) {
|
|
69
|
+
const names = new Set();
|
|
70
|
+
const dynamic = [];
|
|
71
|
+
const visit = (node) => {
|
|
72
|
+
if (ts.isCallExpression(node) &&
|
|
73
|
+
ts.isPropertyAccessExpression(node.expression) &&
|
|
74
|
+
CROSS_UNIT_RPC_METHODS.has(node.expression.name.text) &&
|
|
75
|
+
isRpcReceiver(node.expression.expression)) {
|
|
76
|
+
const [firstArg] = node.arguments;
|
|
77
|
+
if (firstArg) {
|
|
78
|
+
const name = literalRpcName(firstArg);
|
|
79
|
+
if (name !== undefined) {
|
|
80
|
+
names.add(name);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
dynamic.push(node.getText());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
ts.forEachChild(node, visit);
|
|
88
|
+
};
|
|
89
|
+
visit(handler);
|
|
90
|
+
return { names: [...names].sort(), dynamic };
|
|
91
|
+
}
|
|
35
92
|
/**
|
|
36
93
|
* Helper to extract namespace from a namespaced function reference like 'ext:hello'
|
|
37
94
|
*/
|
|
@@ -152,8 +209,9 @@ export function addRPCInvocations(node, state, logger) {
|
|
|
152
209
|
}
|
|
153
210
|
const [firstArg] = args;
|
|
154
211
|
if (firstArg) {
|
|
155
|
-
|
|
156
|
-
|
|
212
|
+
const literalName = literalRpcName(firstArg);
|
|
213
|
+
if (literalName !== undefined) {
|
|
214
|
+
const functionRef = literalName;
|
|
157
215
|
logger.debug(`• Found RPC invocation: ${functionRef}`);
|
|
158
216
|
state.rpc.invokedFunctions.add(functionRef);
|
|
159
217
|
let byFile = state.rpc.invokedFunctionsByFile.get(sourceFileName);
|
|
@@ -170,10 +228,9 @@ export function addRPCInvocations(node, state, logger) {
|
|
|
170
228
|
}
|
|
171
229
|
}
|
|
172
230
|
// Handle template literals like `function-${name}`
|
|
173
|
-
else if (ts.isTemplateExpression(firstArg)
|
|
174
|
-
ts.isNoSubstitutionTemplateLiteral(firstArg)) {
|
|
231
|
+
else if (ts.isTemplateExpression(firstArg)) {
|
|
175
232
|
logger.warn(`• Found dynamic RPC invocation: ${firstArg.getText()}`);
|
|
176
|
-
logger.warn(`\tYou can only use
|
|
233
|
+
logger.warn(`\tYou can only use literal RPC function names, without \${...} substitutions`);
|
|
177
234
|
}
|
|
178
235
|
}
|
|
179
236
|
}
|
package/dist/add/add-trigger.js
CHANGED
|
@@ -8,6 +8,7 @@ import { extractWireNames } from '../utils/post-process.js';
|
|
|
8
8
|
import { resolveAddonName } from '../utils/resolve-addon-package.js';
|
|
9
9
|
import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
|
|
10
10
|
import { ErrorCode } from '../error-codes.js';
|
|
11
|
+
import { webhookSecretCredentialName } from '@pikku/core/trigger';
|
|
11
12
|
const WEBHOOK_SOURCE_STEPS = ['receive', 'check', 'setup', 'teardown'];
|
|
12
13
|
export const addTrigger = (logger, node, checker, state, options) => {
|
|
13
14
|
if (!ts.isCallExpression(node)) {
|
|
@@ -161,6 +162,21 @@ const addWireTriggerWebhookSource = (logger, node, checker, state, firstArg) =>
|
|
|
161
162
|
};
|
|
162
163
|
state.triggers.webhookSourceMeta[name] = meta;
|
|
163
164
|
state.triggers.files.add(node.getSourceFile().fileName);
|
|
165
|
+
if (!getPropertyAssignmentInitializer(obj, 'verify', false, checker))
|
|
166
|
+
return;
|
|
167
|
+
const sourceFile = node.getSourceFile().fileName;
|
|
168
|
+
const credential = getPropertyValue(obj, 'credential') ??
|
|
169
|
+
webhookSecretCredentialName(name);
|
|
170
|
+
state.credentials.files.add(sourceFile);
|
|
171
|
+
state.credentials.definitions.push({
|
|
172
|
+
name: credential,
|
|
173
|
+
displayName: `${name} webhook secret`,
|
|
174
|
+
description: getPropertyValue(obj, 'credentialDescription') ??
|
|
175
|
+
undefined,
|
|
176
|
+
type: 'singleton',
|
|
177
|
+
schema: { type: 'string' },
|
|
178
|
+
sourceFile,
|
|
179
|
+
});
|
|
164
180
|
};
|
|
165
181
|
/**
|
|
166
182
|
* A trigger named `<source>:<event>` must name an event its webhook source
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
export type StartedWorkflows = {
|
|
3
|
+
/** Workflow names passed as a literal to `rpc.startWorkflow(...)`. */
|
|
4
|
+
names: string[];
|
|
5
|
+
/** Source text of `rpc.startWorkflow(...)` calls whose name is computed. */
|
|
6
|
+
dynamic: string[];
|
|
7
|
+
/**
|
|
8
|
+
* Plain functions the handler passes `rpc` to — calls made inside them are
|
|
9
|
+
* not seen. Methods (`workflowService.runToCompletion(..., rpc)`) are left
|
|
10
|
+
* out: they are services, not the app's own helpers.
|
|
11
|
+
*/
|
|
12
|
+
rpcHandoffs: string[];
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The workflows a function handler starts through `rpc.startWorkflow(...)`,
|
|
16
|
+
* read from its own body.
|
|
17
|
+
*
|
|
18
|
+
* `startWorkflow` looks the workflow up by name in the process that calls it,
|
|
19
|
+
* so a deployed unit that starts a workflow needs that workflow's meta bundled
|
|
20
|
+
* in. The deploy planner does that from these names.
|
|
21
|
+
*
|
|
22
|
+
* Only the handler's own body is read. When the handler passes `rpc` on to a
|
|
23
|
+
* helper, whatever the helper starts is invisible here; those callees are
|
|
24
|
+
* returned in `rpcHandoffs` so the caller can say so.
|
|
25
|
+
*/
|
|
26
|
+
export declare function collectStartedWorkflows(handler: ts.Node): StartedWorkflows;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as ts from 'typescript';
|
|
2
|
+
/** `x` seen through `x!` and `(x)`. */
|
|
3
|
+
function unwrap(node) {
|
|
4
|
+
let inner = node;
|
|
5
|
+
while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
|
|
6
|
+
inner = inner.expression;
|
|
7
|
+
}
|
|
8
|
+
return inner;
|
|
9
|
+
}
|
|
10
|
+
/** `rpc`, or anything ending in `.rpc` (`wire.rpc`), seen through `!` and parens. */
|
|
11
|
+
function isRpcExpression(node) {
|
|
12
|
+
const inner = unwrap(node);
|
|
13
|
+
return ts.isIdentifier(inner)
|
|
14
|
+
? inner.text === 'rpc'
|
|
15
|
+
: ts.isPropertyAccessExpression(inner) && inner.name.text === 'rpc';
|
|
16
|
+
}
|
|
17
|
+
/** An argument that hands `rpc` over: `rpc`, `wire.rpc`, `{ rpc }`, `{ rpc: wire.rpc }`. */
|
|
18
|
+
function passesRpc(arg) {
|
|
19
|
+
if (isRpcExpression(arg))
|
|
20
|
+
return true;
|
|
21
|
+
const inner = unwrap(arg);
|
|
22
|
+
if (!ts.isObjectLiteralExpression(inner))
|
|
23
|
+
return false;
|
|
24
|
+
return inner.properties.some((p) => (ts.isShorthandPropertyAssignment(p) && p.name.text === 'rpc') ||
|
|
25
|
+
(ts.isPropertyAssignment(p) && isRpcExpression(p.initializer)));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The workflows a function handler starts through `rpc.startWorkflow(...)`,
|
|
29
|
+
* read from its own body.
|
|
30
|
+
*
|
|
31
|
+
* `startWorkflow` looks the workflow up by name in the process that calls it,
|
|
32
|
+
* so a deployed unit that starts a workflow needs that workflow's meta bundled
|
|
33
|
+
* in. The deploy planner does that from these names.
|
|
34
|
+
*
|
|
35
|
+
* Only the handler's own body is read. When the handler passes `rpc` on to a
|
|
36
|
+
* helper, whatever the helper starts is invisible here; those callees are
|
|
37
|
+
* returned in `rpcHandoffs` so the caller can say so.
|
|
38
|
+
*/
|
|
39
|
+
export function collectStartedWorkflows(handler) {
|
|
40
|
+
const names = new Set();
|
|
41
|
+
const dynamic = [];
|
|
42
|
+
const rpcHandoffs = new Set();
|
|
43
|
+
const visit = (node) => {
|
|
44
|
+
if (ts.isCallExpression(node)) {
|
|
45
|
+
const callee = node.expression;
|
|
46
|
+
const isRpcMethod = ts.isPropertyAccessExpression(callee) &&
|
|
47
|
+
isRpcExpression(callee.expression);
|
|
48
|
+
if (isRpcMethod && callee.name.text === 'startWorkflow') {
|
|
49
|
+
const [firstArg] = node.arguments;
|
|
50
|
+
if (firstArg) {
|
|
51
|
+
if (ts.isStringLiteral(firstArg) ||
|
|
52
|
+
ts.isNoSubstitutionTemplateLiteral(firstArg)) {
|
|
53
|
+
names.add(firstArg.text);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
dynamic.push(node.getText());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
else if (ts.isIdentifier(callee) && node.arguments.some(passesRpc)) {
|
|
61
|
+
rpcHandoffs.add(callee.text);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
ts.forEachChild(node, visit);
|
|
65
|
+
};
|
|
66
|
+
visit(handler);
|
|
67
|
+
return {
|
|
68
|
+
names: [...names].sort(),
|
|
69
|
+
dynamic,
|
|
70
|
+
rpcHandoffs: [...rpcHandoffs].sort(),
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/inspector.js
CHANGED
|
@@ -7,6 +7,7 @@ import { getFilesAndMethods } from './utils/get-files-and-methods.js';
|
|
|
7
7
|
import { findCommonAncestor } from './utils/find-root-dir.js';
|
|
8
8
|
import { createSourceFileCacheHost } from './source-file-cache.js';
|
|
9
9
|
import { createNestedProjectFilter } from './utils/nested-project-filter.js';
|
|
10
|
+
import { toProgramPath } from './utils/program-path.js';
|
|
10
11
|
import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, registerDerivedOAuth2AppSecrets, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateScenarioFeatures, validateWorkflowGraphAddons, } from './utils/post-process.js';
|
|
11
12
|
import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
|
|
12
13
|
import { validateTagsResolveToMiddleware } from './utils/validate-tags-resolve-to-middleware.js';
|
|
@@ -293,7 +294,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
|
|
|
293
294
|
const checker = program.getTypeChecker();
|
|
294
295
|
logger.debug(`Got type checker in ${(performance.now() - startChecker).toFixed(2)}ms`);
|
|
295
296
|
// Use provided rootDir or infer from source files
|
|
296
|
-
const rootDir = options.rootDir || findCommonAncestor(normalizedRouteFiles);
|
|
297
|
+
const rootDir = toProgramPath(options.rootDir || findCommonAncestor(normalizedRouteFiles));
|
|
297
298
|
const startSourceFiles = performance.now();
|
|
298
299
|
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
299
300
|
// dependency, not project source — scanning it double-counts the addon's
|
package/dist/types.d.ts
CHANGED
|
@@ -227,6 +227,7 @@ export type InspectorFilters = {
|
|
|
227
227
|
excludeTarget?: Array<'serverless' | 'server'>;
|
|
228
228
|
serverlessIncompatible?: string[];
|
|
229
229
|
defaultTarget?: 'serverless' | 'server';
|
|
230
|
+
workflowMeta?: string[];
|
|
230
231
|
};
|
|
231
232
|
export type AddonConfig = {
|
|
232
233
|
package: string;
|
|
@@ -831,6 +831,54 @@ export function filterInspectorState(state, filters, logger) {
|
|
|
831
831
|
}
|
|
832
832
|
}
|
|
833
833
|
}
|
|
834
|
+
// Workflows asked for by meta only — a unit that `rpc.startWorkflow`s one the
|
|
835
|
+
// orchestrator unit runs. The run is created from the meta and handed to the
|
|
836
|
+
// orchestrator queue, so the meta (and the workflow function's meta, which
|
|
837
|
+
// the generated workflow map types the start from) is restored, while the
|
|
838
|
+
// registration files stay out: bundling them would pull the workflow
|
|
839
|
+
// function and everything it imports into the unit.
|
|
840
|
+
const fullWorkflowNames = new Set(filters.names ?? []);
|
|
841
|
+
for (const name of filters.workflowMeta ?? []) {
|
|
842
|
+
const graphMeta = state.workflows?.graphMeta?.[name];
|
|
843
|
+
const workflowMeta = state.workflows?.meta?.[name];
|
|
844
|
+
if (!graphMeta && !workflowMeta)
|
|
845
|
+
continue;
|
|
846
|
+
const pikkuFuncId = graphMeta?.pikkuFuncId ?? workflowMeta?.pikkuFuncId;
|
|
847
|
+
if (graphMeta && !filteredState.workflows.graphMeta[name]) {
|
|
848
|
+
filteredState.workflows.graphMeta[name] = JSON.parse(JSON.stringify(graphMeta));
|
|
849
|
+
}
|
|
850
|
+
if (workflowMeta && !filteredState.workflows.meta[name]) {
|
|
851
|
+
filteredState.workflows.meta[name] = JSON.parse(JSON.stringify(workflowMeta));
|
|
852
|
+
}
|
|
853
|
+
const funcMeta = pikkuFuncId ? state.functions.meta[pikkuFuncId] : undefined;
|
|
854
|
+
if (pikkuFuncId && funcMeta && !filteredState.functions.meta[pikkuFuncId]) {
|
|
855
|
+
filteredState.functions.meta[pikkuFuncId] = funcMeta;
|
|
856
|
+
}
|
|
857
|
+
// The orchestrator queue's meta routes the start to the workflow's own
|
|
858
|
+
// queue instead of the shared fallback; its consumer is core's synthetic
|
|
859
|
+
// orchestrator, so no registration comes with it.
|
|
860
|
+
const orchestratorQueue = `wf-orchestrator-${name
|
|
861
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
|
|
862
|
+
.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
|
|
863
|
+
.toLowerCase()}`;
|
|
864
|
+
const orchestratorMeta = state.queueWorkers?.meta?.[orchestratorQueue];
|
|
865
|
+
if (orchestratorMeta &&
|
|
866
|
+
!filteredState.queueWorkers.meta[orchestratorQueue]) {
|
|
867
|
+
filteredState.queueWorkers.meta[orchestratorQueue] = JSON.parse(JSON.stringify(orchestratorMeta));
|
|
868
|
+
}
|
|
869
|
+
const keepsRegistration = fullWorkflowNames.has(name) ||
|
|
870
|
+
(pikkuFuncId !== undefined &&
|
|
871
|
+
filteredState.serviceAggregation.usedFunctions.has(pikkuFuncId));
|
|
872
|
+
if (!keepsRegistration) {
|
|
873
|
+
for (const key of [name, pikkuFuncId]) {
|
|
874
|
+
if (!key)
|
|
875
|
+
continue;
|
|
876
|
+
filteredState.workflows.files.delete(key);
|
|
877
|
+
filteredState.workflows.graphFiles.delete(key);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
filteredState.serviceAggregation.requiredServices.add('workflowService');
|
|
881
|
+
}
|
|
834
882
|
// Recompute requiredSchemas based on pruned functions.meta
|
|
835
883
|
if (filteredState.serviceAggregation.usedFunctions.size > 0) {
|
|
836
884
|
const prunedSchemas = new Set();
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { InspectorState, InspectorLogger } from '../types.js';
|
|
2
|
+
import type { WebhookSourcesMeta } from '@pikku/core/trigger';
|
|
2
3
|
export declare const describeMissingAddonMeta: (namespace: string, packageName: string, dirs: string[], declFile?: string) => string;
|
|
4
|
+
/**
|
|
5
|
+
* The webhook sources an addon declares, as the app mounts them. The source is
|
|
6
|
+
* named after the addon's namespace, so two instances of one addon get a
|
|
7
|
+
* route each; an addon declaring several suffixes each with its own name.
|
|
8
|
+
* Every source starts off: the app turns one on at runtime.
|
|
9
|
+
*/
|
|
10
|
+
export declare const namespaceAddonWebhookSources: (sources: WebhookSourcesMeta, namespace: string) => WebhookSourcesMeta;
|
|
3
11
|
/**
|
|
4
12
|
* After the setup sweep discovers wireAddon() declarations, load each addon
|
|
5
13
|
* package's function metadata so that wiring handlers (channels, HTTP routes,
|
|
@@ -151,6 +151,31 @@ const registerAddonTypes = (state, metaPath, meta) => {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
};
|
|
154
|
+
/**
|
|
155
|
+
* The webhook sources an addon declares, as the app mounts them. The source is
|
|
156
|
+
* named after the addon's namespace, so two instances of one addon get a
|
|
157
|
+
* route each; an addon declaring several suffixes each with its own name.
|
|
158
|
+
* Every source starts off: the app turns one on at runtime.
|
|
159
|
+
*/
|
|
160
|
+
export const namespaceAddonWebhookSources = (sources, namespace) => {
|
|
161
|
+
const declared = Object.values(sources);
|
|
162
|
+
const namespaced = {};
|
|
163
|
+
for (const source of declared) {
|
|
164
|
+
const name = declared.length === 1 ? namespace : `${namespace}-${source.name}`;
|
|
165
|
+
const meta = {
|
|
166
|
+
...source,
|
|
167
|
+
name,
|
|
168
|
+
route: `/webhooks/${name}`,
|
|
169
|
+
};
|
|
170
|
+
for (const step of ['receive', 'check', 'setup', 'teardown']) {
|
|
171
|
+
const funcId = source[step];
|
|
172
|
+
if (funcId && !funcId.includes(':'))
|
|
173
|
+
meta[step] = `${namespace}:${funcId}`;
|
|
174
|
+
}
|
|
175
|
+
namespaced[name] = meta;
|
|
176
|
+
}
|
|
177
|
+
return namespaced;
|
|
178
|
+
};
|
|
154
179
|
/**
|
|
155
180
|
* After the setup sweep discovers wireAddon() declarations, load each addon
|
|
156
181
|
* package's function metadata so that wiring handlers (channels, HTTP routes,
|
|
@@ -366,6 +391,28 @@ export async function loadAddonFunctionsMeta(logger, state) {
|
|
|
366
391
|
}
|
|
367
392
|
catch { }
|
|
368
393
|
}
|
|
394
|
+
try {
|
|
395
|
+
const webhookSourcesPath = require.resolve(`${decl.package}/.pikku/webhooks/pikku-webhook-sources-meta.gen.json`);
|
|
396
|
+
const sources = namespaceAddonWebhookSources(JSON.parse(await readFile(webhookSourcesPath, 'utf-8')), namespace);
|
|
397
|
+
for (const [name, source] of Object.entries(sources)) {
|
|
398
|
+
if (state.triggers.webhookSourceMeta[name])
|
|
399
|
+
continue;
|
|
400
|
+
state.triggers.webhookSourceMeta[name] = source;
|
|
401
|
+
for (const step of [
|
|
402
|
+
'receive',
|
|
403
|
+
'check',
|
|
404
|
+
'setup',
|
|
405
|
+
'teardown',
|
|
406
|
+
]) {
|
|
407
|
+
const funcId = source[step];
|
|
408
|
+
if (funcId)
|
|
409
|
+
state.serviceAggregation.usedFunctions.add(funcId);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// No addon webhook sources
|
|
415
|
+
}
|
|
369
416
|
try {
|
|
370
417
|
const httpContractsPath = require.resolve(`${decl.package}/.pikku/http/pikku-http-contracts-meta.gen.json`);
|
|
371
418
|
const httpContractsRaw = await readFile(httpContractsPath, 'utf-8');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A native path in the form a TypeScript program names its files: forward
|
|
3
|
+
* slashes on every platform. `sourceFile.fileName` is `D:/a/app/src/x.ts` on
|
|
4
|
+
* Windows while `path.resolve` gives `D:\a\app`, so comparing the two by
|
|
5
|
+
* prefix rejects every file unless one side is converted.
|
|
6
|
+
*/
|
|
7
|
+
export declare const toProgramPath: (nativePath: string, separator?: "/" | "\\") => string;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { sep } from 'path';
|
|
2
|
+
/**
|
|
3
|
+
* A native path in the form a TypeScript program names its files: forward
|
|
4
|
+
* slashes on every platform. `sourceFile.fileName` is `D:/a/app/src/x.ts` on
|
|
5
|
+
* Windows while `path.resolve` gives `D:\a\app`, so comparing the two by
|
|
6
|
+
* prefix rejects every file unless one side is converted.
|
|
7
|
+
*/
|
|
8
|
+
export const toProgramPath = (nativePath, separator = sep) => nativePath.split(separator).join('/');
|
|
@@ -2,7 +2,7 @@ import * as ts from 'typescript';
|
|
|
2
2
|
import { createHash } from 'crypto';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'fs';
|
|
4
4
|
import { dirname, join, resolve } from 'path';
|
|
5
|
-
import {
|
|
5
|
+
import { createFormatter, createParser, DEFAULT_CONFIG, RootlessError, SchemaGenerator, } from 'ts-json-schema-generator';
|
|
6
6
|
import { register } from 'tsx/esm/api';
|
|
7
7
|
import * as z from 'zod';
|
|
8
8
|
import { findZodTransform } from './find-zod-transform.js';
|
|
@@ -184,6 +184,34 @@ export function writeDiskTSSchemas(logger, cacheDir, key, schemas, deps) {
|
|
|
184
184
|
logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
+
// The base class walks every source file again for each type it looks up by
|
|
188
|
+
// name, which was nearly all of schema generation (8.7s for 677 types on e2e).
|
|
189
|
+
class IndexedSchemaGenerator extends SchemaGenerator {
|
|
190
|
+
projectTypes;
|
|
191
|
+
allTypes;
|
|
192
|
+
findNamedNode(fullName) {
|
|
193
|
+
const typeChecker = this.program.getTypeChecker();
|
|
194
|
+
if (!this.projectTypes) {
|
|
195
|
+
this.projectTypes = new Map();
|
|
196
|
+
this.appendTypes(this.partitionFiles().projectFiles, typeChecker, this.projectTypes);
|
|
197
|
+
}
|
|
198
|
+
const projectType = this.projectTypes.get(fullName);
|
|
199
|
+
if (projectType)
|
|
200
|
+
return projectType;
|
|
201
|
+
if (!this.allTypes) {
|
|
202
|
+
this.allTypes = new Map(this.projectTypes);
|
|
203
|
+
this.appendTypes(this.partitionFiles().externalFiles, typeChecker, this.allTypes);
|
|
204
|
+
}
|
|
205
|
+
const type = this.allTypes.get(fullName);
|
|
206
|
+
if (type)
|
|
207
|
+
return type;
|
|
208
|
+
throw new RootlessError(fullName);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const createIndexedGenerator = (program, config) => {
|
|
212
|
+
const completedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
213
|
+
return new IndexedSchemaGenerator(program, createParser(program, completedConfig), createFormatter(completedConfig), completedConfig);
|
|
214
|
+
};
|
|
187
215
|
function createProgramWithVirtualFile(logger, tsconfig, virtualFilePath, virtualFileContent) {
|
|
188
216
|
const configPath = resolve(tsconfig);
|
|
189
217
|
// Cache the parsed tsconfig — it doesn't change between runs
|
|
@@ -271,8 +299,7 @@ function generateTSSchemas(logger, tsconfig, customTypesContent, typesMap, funct
|
|
|
271
299
|
logger.debug(`generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`);
|
|
272
300
|
const virtualFilePath = join(dirname(resolve(tsconfig)), '__pikku_virtual_types__.ts');
|
|
273
301
|
const program = createProgramWithVirtualFile(logger, tsconfig, virtualFilePath, customTypesContent);
|
|
274
|
-
const generator =
|
|
275
|
-
tsProgram: program,
|
|
302
|
+
const generator = createIndexedGenerator(program, {
|
|
276
303
|
skipTypeCheck: true,
|
|
277
304
|
topRef: false,
|
|
278
305
|
discriminatorType: 'open-api',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.98",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/pikkujs/pikku.git",
|
|
@@ -54,10 +54,10 @@
|
|
|
54
54
|
"zod": "^4.4.3"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
|
-
"@pikku/core": "^0.12.
|
|
57
|
+
"@pikku/core": "^0.12.132"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@pikku/core": "^0.12.
|
|
60
|
+
"@pikku/core": "^0.12.132",
|
|
61
61
|
"@types/node": "^24.13.3"
|
|
62
62
|
},
|
|
63
63
|
"engines": {
|