@mondaydotcomorg/z2h-cli 0.27.1 → 0.28.0

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 (116) hide show
  1. package/dist/backend/__tests__/ast.test.d.ts +2 -0
  2. package/dist/backend/__tests__/ast.test.d.ts.map +1 -0
  3. package/dist/backend/__tests__/parse.test.d.ts +2 -0
  4. package/dist/backend/__tests__/parse.test.d.ts.map +1 -0
  5. package/dist/backend/__tests__/validate.test.d.ts +2 -0
  6. package/dist/backend/__tests__/validate.test.d.ts.map +1 -0
  7. package/dist/backend/ast.d.ts +41 -0
  8. package/dist/backend/ast.d.ts.map +1 -0
  9. package/dist/backend/ast.js +152 -0
  10. package/dist/backend/detect-integrations.d.ts +8 -4
  11. package/dist/backend/detect-integrations.d.ts.map +1 -1
  12. package/dist/backend/detect-integrations.js +12 -15
  13. package/dist/backend/handler-declaration.d.ts +17 -0
  14. package/dist/backend/handler-declaration.d.ts.map +1 -0
  15. package/dist/backend/handler-declaration.js +83 -0
  16. package/dist/backend/handlers.d.ts +2 -0
  17. package/dist/backend/handlers.d.ts.map +1 -1
  18. package/dist/backend/handlers.js +7 -36
  19. package/dist/backend/messages.d.ts +10 -0
  20. package/dist/backend/messages.d.ts.map +1 -0
  21. package/dist/backend/messages.js +14 -0
  22. package/dist/backend/parse.d.ts +11 -0
  23. package/dist/backend/parse.d.ts.map +1 -0
  24. package/dist/backend/parse.js +66 -0
  25. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  26. package/dist/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  27. package/dist/backend/rules/index.d.ts +5 -0
  28. package/dist/backend/rules/index.d.ts.map +1 -0
  29. package/dist/backend/rules/index.js +8 -0
  30. package/dist/backend/rules/snowflake-static-sql.d.ts +15 -0
  31. package/dist/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  32. package/dist/backend/rules/snowflake-static-sql.js +75 -0
  33. package/dist/backend/rules/types.d.ts +29 -0
  34. package/dist/backend/rules/types.d.ts.map +1 -0
  35. package/dist/backend/rules/types.js +1 -0
  36. package/dist/backend/validate.d.ts +23 -0
  37. package/dist/backend/validate.d.ts.map +1 -0
  38. package/dist/backend/validate.js +64 -0
  39. package/dist/commands/__tests__/backend-validate.test.d.ts +2 -0
  40. package/dist/commands/__tests__/backend-validate.test.d.ts.map +1 -0
  41. package/dist/commands/backend.d.ts +6 -0
  42. package/dist/commands/backend.d.ts.map +1 -1
  43. package/dist/commands/backend.js +22 -2
  44. package/dist/constants.d.ts +1 -2
  45. package/dist/constants.d.ts.map +1 -1
  46. package/dist/constants.js +10 -6
  47. package/dist/esm/backend/__tests__/ast.test.d.ts +2 -0
  48. package/dist/esm/backend/__tests__/ast.test.d.ts.map +1 -0
  49. package/dist/esm/backend/__tests__/parse.test.d.ts +2 -0
  50. package/dist/esm/backend/__tests__/parse.test.d.ts.map +1 -0
  51. package/dist/esm/backend/__tests__/validate.test.d.ts +2 -0
  52. package/dist/esm/backend/__tests__/validate.test.d.ts.map +1 -0
  53. package/dist/esm/backend/ast.d.ts +41 -0
  54. package/dist/esm/backend/ast.d.ts.map +1 -0
  55. package/dist/esm/backend/ast.mjs +146 -0
  56. package/dist/esm/backend/detect-integrations.d.ts +8 -4
  57. package/dist/esm/backend/detect-integrations.d.ts.map +1 -1
  58. package/dist/esm/backend/detect-integrations.mjs +12 -15
  59. package/dist/esm/backend/handler-declaration.d.ts +17 -0
  60. package/dist/esm/backend/handler-declaration.d.ts.map +1 -0
  61. package/dist/esm/backend/handler-declaration.mjs +78 -0
  62. package/dist/esm/backend/handlers.d.ts +2 -0
  63. package/dist/esm/backend/handlers.d.ts.map +1 -1
  64. package/dist/esm/backend/handlers.mjs +7 -36
  65. package/dist/esm/backend/messages.d.ts +10 -0
  66. package/dist/esm/backend/messages.d.ts.map +1 -0
  67. package/dist/esm/backend/messages.mjs +12 -0
  68. package/dist/esm/backend/parse.d.ts +11 -0
  69. package/dist/esm/backend/parse.d.ts.map +1 -0
  70. package/dist/esm/backend/parse.mjs +64 -0
  71. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts +2 -0
  72. package/dist/esm/backend/rules/__tests__/snowflake-static-sql.test.d.ts.map +1 -0
  73. package/dist/esm/backend/rules/index.d.ts +5 -0
  74. package/dist/esm/backend/rules/index.d.ts.map +1 -0
  75. package/dist/esm/backend/rules/index.mjs +6 -0
  76. package/dist/esm/backend/rules/snowflake-static-sql.d.ts +15 -0
  77. package/dist/esm/backend/rules/snowflake-static-sql.d.ts.map +1 -0
  78. package/dist/esm/backend/rules/snowflake-static-sql.mjs +73 -0
  79. package/dist/esm/backend/rules/types.d.ts +29 -0
  80. package/dist/esm/backend/rules/types.d.ts.map +1 -0
  81. package/dist/esm/backend/rules/types.mjs +1 -0
  82. package/dist/esm/backend/validate.d.ts +23 -0
  83. package/dist/esm/backend/validate.d.ts.map +1 -0
  84. package/dist/esm/backend/validate.mjs +62 -0
  85. package/dist/esm/commands/__tests__/backend-validate.test.d.ts +2 -0
  86. package/dist/esm/commands/__tests__/backend-validate.test.d.ts.map +1 -0
  87. package/dist/esm/commands/backend.d.ts +6 -0
  88. package/dist/esm/commands/backend.d.ts.map +1 -1
  89. package/dist/esm/commands/backend.mjs +23 -4
  90. package/dist/esm/constants.d.ts +1 -2
  91. package/dist/esm/constants.d.ts.map +1 -1
  92. package/dist/esm/constants.mjs +3 -6
  93. package/dist/esm/index.mjs +6 -2
  94. package/dist/index.js +5 -1
  95. package/package.json +4 -2
  96. package/src/backend/__tests__/ast.test.ts +53 -0
  97. package/src/backend/__tests__/detect-integrations.test.ts +12 -30
  98. package/src/backend/__tests__/handlers.test.ts +21 -34
  99. package/src/backend/__tests__/parse.test.ts +25 -0
  100. package/src/backend/__tests__/validate.test.ts +246 -0
  101. package/src/backend/ast.ts +204 -0
  102. package/src/backend/detect-integrations.ts +12 -14
  103. package/src/backend/handler-declaration.ts +104 -0
  104. package/src/backend/handlers.ts +9 -44
  105. package/src/backend/messages.ts +16 -0
  106. package/src/backend/parse.ts +82 -0
  107. package/src/backend/rules/__tests__/snowflake-static-sql.test.ts +175 -0
  108. package/src/backend/rules/index.ts +7 -0
  109. package/src/backend/rules/snowflake-static-sql.ts +78 -0
  110. package/src/backend/rules/types.ts +30 -0
  111. package/src/backend/validate.ts +73 -0
  112. package/src/commands/__tests__/backend-deploy.test.ts +5 -3
  113. package/src/commands/__tests__/backend-validate.test.ts +71 -0
  114. package/src/commands/backend.ts +23 -3
  115. package/src/constants.ts +5 -6
  116. package/src/index.ts +9 -2
@@ -0,0 +1,204 @@
1
+ import type { AnyNode, Expression, Node, Pattern, Program, VariableDeclaration } from 'acorn';
2
+ import { full as walkFull, simple as walkSimple } from 'acorn-walk';
3
+
4
+ /** 1-based line a node starts on; requires the program to be parsed with `locations: true`. */
5
+ export const lineOf = (node: Node): number | undefined => node.loc?.start.line;
6
+
7
+ // ─────────── Bindings ───────────
8
+
9
+ export interface Declaration {
10
+ kind: VariableDeclaration['kind'];
11
+ init: Expression | null | undefined;
12
+ /**
13
+ * Property path from `init` to this binding: `[]` for `const x = init`,
14
+ * `['a', 'b']` for `const { a: { b: x } } = init`, `undefined` when the value
15
+ * is not a plain property read (array/rest/default patterns).
16
+ */
17
+ path: string[] | undefined;
18
+ }
19
+
20
+ export interface Bindings {
21
+ /** Every `var|let|const <name> = …` in the file, by name. */
22
+ declarations: Map<string, Declaration[]>;
23
+ /** Names whose value cannot be pinned: reassigned, updated, or bound as parameters. */
24
+ unpinnable: Set<string>;
25
+ }
26
+
27
+ interface PatternBinding {
28
+ name: string;
29
+ path: string[] | undefined;
30
+ }
31
+
32
+ /**
33
+ * Every identifier a binding pattern introduces, with its property path from the
34
+ * pattern's value. `path` is deliberately not defaulted: callers pass `[]` for
35
+ * the root and `undefined` once the pattern stops being a plain property read.
36
+ */
37
+ function patternBindings(pattern: Pattern, path: string[] | undefined): PatternBinding[] {
38
+ switch (pattern.type) {
39
+ case 'Identifier':
40
+ return [{ name: pattern.name, path }];
41
+ case 'ObjectPattern':
42
+ return pattern.properties.flatMap(p => {
43
+ if (p.type === 'RestElement') {
44
+ return patternBindings(p.argument, undefined);
45
+ }
46
+ const key = propertyKey(p.key, p.computed);
47
+ return patternBindings(p.value, path && key !== undefined ? [...path, key] : undefined);
48
+ });
49
+ case 'ArrayPattern':
50
+ return pattern.elements.flatMap(e => (e ? patternBindings(e, undefined) : []));
51
+ case 'AssignmentPattern':
52
+ return patternBindings(pattern.left, undefined);
53
+ case 'RestElement':
54
+ return patternBindings(pattern.argument, undefined);
55
+ default:
56
+ // MemberExpression targets (`obj.x = …`) bind no names.
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function propertyKey(key: AnyNode, computed: boolean): string | undefined {
62
+ if (key.type === 'Literal' && typeof key.value === 'string') {
63
+ return key.value;
64
+ }
65
+ if (!computed && key.type === 'Identifier') {
66
+ return key.name;
67
+ }
68
+ return undefined;
69
+ }
70
+
71
+ /** Every identifier a binding pattern introduces (`{ a, b: [c] } = …` → a, c). */
72
+ export const patternNames = (pattern: Pattern): string[] => patternBindings(pattern, []).map(b => b.name);
73
+
74
+ export function collectBindings(program: Program): Bindings {
75
+ const declarations = new Map<string, Declaration[]>();
76
+ const unpinnable = new Set<string>();
77
+ const markUnpinnable = (pattern: Pattern) => patternNames(pattern).forEach(n => unpinnable.add(n));
78
+
79
+ walkFull(program, node => {
80
+ switch (node.type) {
81
+ case 'VariableDeclaration':
82
+ for (const declarator of node.declarations) {
83
+ for (const { name, path } of patternBindings(declarator.id, [])) {
84
+ const list = declarations.get(name) ?? [];
85
+ list.push({ kind: node.kind, init: declarator.init, path });
86
+ declarations.set(name, list);
87
+ }
88
+ }
89
+ break;
90
+ case 'AssignmentExpression':
91
+ markUnpinnable(node.left);
92
+ break;
93
+ case 'UpdateExpression':
94
+ if (node.argument.type === 'Identifier') {
95
+ unpinnable.add(node.argument.name);
96
+ }
97
+ break;
98
+ case 'ForInStatement':
99
+ case 'ForOfStatement':
100
+ if (node.left.type !== 'VariableDeclaration') {
101
+ markUnpinnable(node.left);
102
+ }
103
+ break;
104
+ case 'FunctionDeclaration':
105
+ case 'FunctionExpression':
106
+ case 'ArrowFunctionExpression':
107
+ node.params.forEach(markUnpinnable);
108
+ break;
109
+ case 'CatchClause':
110
+ if (node.param) {
111
+ markUnpinnable(node.param);
112
+ }
113
+ break;
114
+ }
115
+ });
116
+
117
+ return { declarations, unpinnable };
118
+ }
119
+
120
+ // ─────────── Member paths ───────────
121
+
122
+ interface MemberChain {
123
+ root: string;
124
+ parts: string[];
125
+ }
126
+
127
+ /** `a.b.c` / `a['b'].c` → root `a`, parts `[b, c]`; `undefined` if any link is dynamic or the root is not an identifier. */
128
+ function memberChain(node: AnyNode): MemberChain | undefined {
129
+ if (node.type === 'ChainExpression') {
130
+ return memberChain(node.expression);
131
+ }
132
+ const parts: string[] = [];
133
+ let cur: AnyNode = node;
134
+ while (cur.type === 'MemberExpression') {
135
+ const key = propertyKey(cur.property, cur.computed);
136
+ if (key === undefined) {
137
+ return undefined;
138
+ }
139
+ parts.unshift(key);
140
+ cur = cur.object;
141
+ }
142
+ return cur.type === 'Identifier' ? { root: cur.name, parts } : undefined;
143
+ }
144
+
145
+ export interface PathResolver {
146
+ /**
147
+ * Dotted path of a member chain, following `const` aliases back to their
148
+ * source: with `const sf = ctx.api.v1.snowflake`, `sf.query` resolves to
149
+ * `ctx.api.v1.snowflake.query`. Destructuring counts too
150
+ * (`const { snowflake: sf } = ctx.api.v1`). `undefined` if any link is dynamic.
151
+ *
152
+ * Only single, never-reassigned, never-shadowed `const` bindings are followed —
153
+ * anything else is left as written, so a `let` alias or a parameter of the same
154
+ * name is never mistaken for the channel. The handler's first parameter is
155
+ * treated as `ctx` even when renamed (`handler(context)` → `context.api.v1.db`
156
+ * resolves as `ctx.api.v1.db`).
157
+ */
158
+ pathOf(node: AnyNode): string | undefined;
159
+ }
160
+
161
+ export function createPathResolver(bindings: Bindings, contextParam?: string): PathResolver {
162
+ function resolveChain(node: AnyNode, resolving: ReadonlySet<string>): string | undefined {
163
+ const chain = memberChain(node);
164
+ if (!chain) {
165
+ return undefined;
166
+ }
167
+ return [resolveRoot(chain.root, resolving), ...chain.parts].join('.');
168
+ }
169
+
170
+ function resolveRoot(name: string, resolving: ReadonlySet<string>): string {
171
+ if (contextParam !== undefined && name === contextParam) {
172
+ return 'ctx';
173
+ }
174
+ if (bindings.unpinnable.has(name) || resolving.has(name)) {
175
+ return name;
176
+ }
177
+ const decls = bindings.declarations.get(name);
178
+ if (!decls || decls.length !== 1) {
179
+ return name;
180
+ }
181
+ const [{ kind, init, path }] = decls;
182
+ if (kind !== 'const' || !init || !path) {
183
+ return name;
184
+ }
185
+ const base = resolveChain(init, new Set(resolving).add(name));
186
+ return base ? [base, ...path].join('.') : name;
187
+ }
188
+
189
+ return { pathOf: node => resolveChain(node, new Set()) };
190
+ }
191
+
192
+ /** Every `ctx.api.v1.<domain>` the program touches (known or not), through `paths`. */
193
+ export function collectApiDomains(program: Program, paths: PathResolver): Set<string> {
194
+ const apiDomains = new Set<string>();
195
+ walkSimple(program, {
196
+ MemberExpression(node) {
197
+ const [obj, api, version, domain] = paths.pathOf(node)?.split('.') ?? [];
198
+ if (obj === 'ctx' && api === 'api' && version === 'v1' && domain) {
199
+ apiDomains.add(domain);
200
+ }
201
+ },
202
+ });
203
+ return apiDomains;
204
+ }
@@ -1,22 +1,20 @@
1
- import { DECLARED_INTEGRATIONS } from '../constants';
1
+ import { DECLARED_INTEGRATIONS, type DeclaredIntegration } from '@mondaydotcomorg/z2h-shared-utils';
2
+
3
+ const declared: ReadonlySet<string> = new Set(DECLARED_INTEGRATIONS);
4
+ const isDeclaredIntegration = (domain: string): domain is DeclaredIntegration => declared.has(domain);
2
5
 
3
6
  /**
4
- * Integrations inferred from handler source at deploy time — whatever
7
+ * Integrations inferred from handler sources at deploy time — whatever
5
8
  * `ctx.api.v1.<name>` the handlers touch (from `DECLARED_INTEGRATIONS`) is
6
9
  * registered with the platform. There is no `integrations.json` and no declare
7
10
  * CLI step.
8
11
  *
9
- * Only OAuth-gated integrations belong in that set. `db`, `llm`, and `snowflake`
10
- * are always-on channels and are never declared.
12
+ * `apiDomains` comes from the handler parse in `validateHandlerSource`, so a
13
+ * domain mentioned only in a comment or string does not count as usage.
14
+ *
15
+ * Only OAuth-gated integrations belong in the result. `db`, `llm`, and
16
+ * `snowflake` are always-on channels and are never declared.
11
17
  */
12
- export function detectIntegrationsFromHandlers(handlers: Record<string, string>): string[] {
13
- const found = new Set<string>();
14
- for (const source of Object.values(handlers)) {
15
- for (const name of DECLARED_INTEGRATIONS) {
16
- if (new RegExp(`\\bctx\\.api\\.v1\\.${name}\\b`).test(source)) {
17
- found.add(name);
18
- }
19
- }
20
- }
21
- return [...found].sort();
18
+ export function detectIntegrations(apiDomains: Iterable<string>): DeclaredIntegration[] {
19
+ return [...new Set(apiDomains)].filter(isDeclaredIntegration).sort();
22
20
  }
@@ -0,0 +1,104 @@
1
+ import type {
2
+ AnyNode,
3
+ ArrowFunctionExpression,
4
+ Expression,
5
+ FunctionDeclaration,
6
+ FunctionExpression,
7
+ ModuleDeclaration,
8
+ Pattern,
9
+ Statement,
10
+ } from 'acorn';
11
+
12
+ /** True for the `handler` identifier specifically — used everywhere a node might name the binding. */
13
+ const isHandlerIdentifier = (candidate: AnyNode | null | undefined): boolean =>
14
+ candidate?.type === 'Identifier' && candidate.name === 'handler';
15
+
16
+ export type HandlerDeclaration =
17
+ | { kind: 'function'; params: FunctionDeclaration['params'] }
18
+ | { kind: 'variable'; init: Expression | null | undefined };
19
+
20
+ /** The top-level declaration that binds `handler`, if any. */
21
+ export function findHandlerDeclaration(
22
+ body: readonly (Statement | ModuleDeclaration)[]
23
+ ): HandlerDeclaration | undefined {
24
+ for (const node of body) {
25
+ if (node.type === 'FunctionDeclaration' && isHandlerIdentifier(node.id)) {
26
+ return { kind: 'function', params: node.params };
27
+ }
28
+ if (node.type === 'VariableDeclaration') {
29
+ const declarator = node.declarations.find(d => isHandlerIdentifier(d.id));
30
+ if (declarator) {
31
+ return { kind: 'variable', init: declarator.init };
32
+ }
33
+ }
34
+ }
35
+ return undefined;
36
+ }
37
+
38
+ /** `export function handler`, `export const handler`, `export { handler }`, `export default handler`. */
39
+ export function exportsHandler(node: Statement | ModuleDeclaration): boolean {
40
+ if (node.type === 'ExportNamedDeclaration') {
41
+ return (
42
+ (node.declaration != null && findHandlerDeclaration([node.declaration]) !== undefined) ||
43
+ node.specifiers.some(s => isHandlerIdentifier(s.local) || isHandlerIdentifier(s.exported))
44
+ );
45
+ }
46
+ if (node.type === 'ExportDefaultDeclaration') {
47
+ const { declaration } = node;
48
+ return (
49
+ (declaration.type === 'FunctionDeclaration' && isHandlerIdentifier(declaration.id)) ||
50
+ isHandlerIdentifier(declaration)
51
+ );
52
+ }
53
+ return false;
54
+ }
55
+
56
+ /** Peel `wrap(fn)`, `(0, fn)`, and the like until a function expression remains. */
57
+ function asFunction(expr: Expression | null | undefined): FunctionExpression | ArrowFunctionExpression | undefined {
58
+ if (!expr) {
59
+ return undefined;
60
+ }
61
+ switch (expr.type) {
62
+ case 'FunctionExpression':
63
+ case 'ArrowFunctionExpression':
64
+ return expr;
65
+ case 'SequenceExpression':
66
+ return asFunction(expr.expressions.at(-1));
67
+ case 'CallExpression': {
68
+ for (const arg of expr.arguments) {
69
+ if (arg.type === 'SpreadElement') {
70
+ continue;
71
+ }
72
+ const fn = asFunction(arg);
73
+ if (fn) {
74
+ return fn;
75
+ }
76
+ }
77
+ return undefined;
78
+ }
79
+ default:
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function firstParamName(params: readonly Pattern[]): string | undefined {
85
+ let first: Pattern | undefined = params[0];
86
+ if (first?.type === 'AssignmentPattern') {
87
+ first = first.left;
88
+ }
89
+ return first?.type === 'Identifier' ? first.name : undefined;
90
+ }
91
+
92
+ /** First positional parameter of `handler`, when it's a plain identifier (`ctx`, `context`, …). */
93
+ export function handlerContextParamName(declaration: HandlerDeclaration): string | undefined {
94
+ const params = declaration.kind === 'function' ? declaration.params : asFunction(declaration.init)?.params;
95
+ return params ? firstParamName(params) : undefined;
96
+ }
97
+
98
+ /** Values that can never be a function, so `const handler = <this>` is caught at build time. */
99
+ export const NEVER_A_FUNCTION: ReadonlySet<string> = new Set([
100
+ 'Literal',
101
+ 'ObjectExpression',
102
+ 'ArrayExpression',
103
+ 'TemplateLiteral',
104
+ ]);
@@ -1,38 +1,15 @@
1
1
  import path from 'node:path';
2
2
  import { readdir, readFile } from 'node:fs/promises';
3
3
  import { pathExists } from 'fs-extra';
4
+ import { validateHandlerSource } from './validate';
4
5
 
5
6
  const HANDLERS_DIR = path.join('backend', 'handlers');
6
7
 
7
8
  export interface ReadHandlersResult {
8
9
  /** handler name (filename without .js) → JS source */
9
10
  handlers: Record<string, string>;
10
- }
11
-
12
- /**
13
- * True if the source defines a `handler` function or variable at the top level.
14
- * Handlers run in a V8 isolate with no module system — `handler` must be a plain
15
- * global, not an export.
16
- */
17
- function definesHandler(source: string): boolean {
18
- return /\bfunction\s+handler\b/.test(source) || /\b(const|let|var)\s+handler\b/.test(source);
19
- }
20
-
21
- /**
22
- * True if `handler` is declared with an `export` prefix. It reads as valid source but
23
- * the isolate has no module system, so an exported handler is simply invisible to the
24
- * runner — caught here rather than failing opaquely at invoke time.
25
- */
26
- function exportsHandler(source: string): boolean {
27
- return /\bexport\s+(default\s+)?(async\s+)?(function\s+handler\b|(?:const|let|var)\s+handler\b)/.test(source);
28
- }
29
-
30
- /**
31
- * True if the source contains any `import` or `require` call. Neither works in
32
- * the V8 isolate — reach the outside world through `ctx` capabilities instead.
33
- */
34
- function hasImports(source: string): boolean {
35
- return /^\s*import\b/m.test(source) || /\brequire\s*\(/.test(source);
11
+ /** Every `ctx.api.v1.<domain>` any handler touches, sorted and unique. */
12
+ apiDomains: string[];
36
13
  }
37
14
 
38
15
  /**
@@ -43,7 +20,7 @@ function hasImports(source: string): boolean {
43
20
  export async function readHandlers(consumerDir: string): Promise<ReadHandlersResult> {
44
21
  const handlersDir = path.join(consumerDir, HANDLERS_DIR);
45
22
  if (!(await pathExists(handlersDir))) {
46
- return { handlers: {} };
23
+ return { handlers: {}, apiDomains: [] };
47
24
  }
48
25
 
49
26
  const entries = await readdir(handlersDir);
@@ -53,30 +30,18 @@ export async function readHandlers(consumerDir: string): Promise<ReadHandlersRes
53
30
  }
54
31
  const jsFiles = entries.filter(f => f.endsWith('.js'));
55
32
  if (jsFiles.length === 0) {
56
- return { handlers: {} };
33
+ return { handlers: {}, apiDomains: [] };
57
34
  }
58
35
 
59
36
  const handlers: Record<string, string> = {};
37
+ const apiDomains = new Set<string>();
60
38
  for (const file of jsFiles) {
61
39
  const handlerName = path.parse(file).name;
62
40
  const source = await readFile(path.join(handlersDir, file), 'utf8');
63
41
 
64
- // Before the definesHandler check: `export const handler` satisfies that one too.
65
- if (exportsHandler(source)) {
66
- throw new Error(
67
- `${file} must not export handler — the isolate has no module system; declare it as a plain global, e.g. async function handler(ctx) { ... }`
68
- );
69
- }
70
- if (!definesHandler(source)) {
71
- throw new Error(`${file} must define a handler — e.g. async function handler(ctx) { ... }`);
72
- }
73
- if (hasImports(source)) {
74
- throw new Error(
75
- `${file} must not import other files or packages — handlers are self-contained; reach the outside world through ctx capabilities`
76
- );
77
- }
78
-
42
+ const validation = validateHandlerSource(file, source);
43
+ validation.apiDomains.forEach(d => apiDomains.add(d));
79
44
  handlers[handlerName] = source;
80
45
  }
81
- return { handlers };
46
+ return { handlers, apiDomains: [...apiDomains].sort() };
82
47
  }
@@ -0,0 +1,16 @@
1
+ const EXAMPLE = 'async function handler(ctx) { ... }';
2
+
3
+ export const messages = {
4
+ mustDefine: (file: string) => `${file} must define a handler — e.g. ${EXAMPLE}`,
5
+ notAFunction: (file: string) => `${file} declares handler but it is not a function — e.g. ${EXAMPLE}`,
6
+ mustNotImport: (file: string) =>
7
+ `${file} must not import other files or packages — handlers are self-contained; reach the outside world through ctx capabilities`,
8
+ mustNotExportHandler: (file: string) =>
9
+ `${file} must not export handler — the isolate has no module system; declare it as a plain global, e.g. ${EXAMPLE}`,
10
+ mustNotExport: (file: string) =>
11
+ `${file} must not use export — the isolate has no module system; declare handler as a plain global, e.g. ${EXAMPLE}`,
12
+ moduleOnlySyntax: (file: string, detail: string) =>
13
+ `${file} uses module-only syntax (${detail}) — handlers run as plain scripts, so top-level await and import.meta are not available`,
14
+ syntaxError: (file: string, line: number, column: number, detail: string) =>
15
+ `${file}:${line}:${column}: not valid JavaScript — ${detail}`,
16
+ };
@@ -0,0 +1,82 @@
1
+ import { parse, type ExportNamedDeclaration, type ModuleDeclaration, type Program, type Statement } from 'acorn';
2
+ import { z } from 'zod';
3
+ import { trackEvent } from '../util/tracker';
4
+ import { exportsHandler } from './handler-declaration';
5
+ import { messages } from './messages';
6
+
7
+ const acornLocSchema = z.object({ line: z.number(), column: z.number() });
8
+
9
+ interface AcornSyntaxError extends SyntaxError {
10
+ loc: { line: number; column: number };
11
+ }
12
+
13
+ function isAcornSyntaxError(err: unknown): err is AcornSyntaxError {
14
+ return err instanceof SyntaxError && acornLocSchema.safeParse((err as { loc?: unknown }).loc).success;
15
+ }
16
+
17
+ type ParseResult = { ok: true; program: Program } | { ok: false; error: AcornSyntaxError };
18
+
19
+ function tryParse(code: string, sourceType: 'script' | 'module'): ParseResult {
20
+ try {
21
+ return { ok: true, program: parse(code, { ecmaVersion: 'latest', sourceType, locations: true }) };
22
+ } catch (err) {
23
+ if (isAcornSyntaxError(err)) {
24
+ return { ok: false, error: err };
25
+ }
26
+ throw err;
27
+ }
28
+ }
29
+
30
+ /** Acorn appends ` (line:col)` to its messages; we report the position ourselves. */
31
+ const stripPosition = (message: string): string => message.replace(/ \(\d+:\d+\)$/, '');
32
+
33
+ type ImportLike = (ModuleDeclaration & { type: 'ImportDeclaration' | 'ExportAllDeclaration' }) | ExportNamedDeclaration;
34
+
35
+ const isImportLike = (node: Statement | ModuleDeclaration): node is ImportLike =>
36
+ node.type === 'ImportDeclaration' ||
37
+ node.type === 'ExportAllDeclaration' ||
38
+ (node.type === 'ExportNamedDeclaration' && node.source != null);
39
+
40
+ type ExportLike = ModuleDeclaration & { type: 'ExportNamedDeclaration' | 'ExportDefaultDeclaration' };
41
+
42
+ const isExport = (node: Statement | ModuleDeclaration): node is ExportLike =>
43
+ node.type === 'ExportNamedDeclaration' || node.type === 'ExportDefaultDeclaration';
44
+
45
+ /** Reports the failure, then returns the error for the caller to throw. */
46
+ function parseError(message: string): Error {
47
+ void trackEvent('z2h_cli_handler_parse_error', { error: message });
48
+ return new Error(message);
49
+ }
50
+
51
+ /**
52
+ * The runner compiles handlers with `isolate.compileScript`, i.e. the classic
53
+ * *script* grammar — so that is the grammar we validate against. `import`/`export`
54
+ * declarations and top-level `await`/`import.meta` are only legal under the
55
+ * *module* grammar, so a handler using any of them fails the script parse; we
56
+ * re-parse it as a module purely to name which of those four things it was,
57
+ * instead of surfacing V8's bare "Unexpected token 'export'".
58
+ */
59
+ export function parseOrThrow(file: string, code: string): Program {
60
+ const asScript = tryParse(code, 'script');
61
+ if (asScript.ok) {
62
+ return asScript.program;
63
+ }
64
+
65
+ const asModule = tryParse(code, 'module');
66
+ if (asModule.ok) {
67
+ const { body } = asModule.program;
68
+ if (body.some(exportsHandler)) {
69
+ throw parseError(messages.mustNotExportHandler(file));
70
+ }
71
+ if (body.some(isImportLike)) {
72
+ throw parseError(messages.mustNotImport(file));
73
+ }
74
+ if (body.some(isExport)) {
75
+ throw parseError(messages.mustNotExport(file));
76
+ }
77
+ throw parseError(messages.moduleOnlySyntax(file, stripPosition(asScript.error.message)));
78
+ }
79
+
80
+ const { line, column } = asScript.error.loc;
81
+ throw parseError(messages.syntaxError(file, line, column, stripPosition(asScript.error.message)));
82
+ }