@chainlink/cre-sdk 1.1.4 → 1.2.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.
- package/README.md +12 -1
- package/bin/cre-compile.ts +6 -1
- package/dist/index.d.ts +3 -0
- package/dist/sdk/types/global.d.ts +146 -2
- package/dist/sdk/types/restricted-apis.d.ts +18 -23
- package/dist/sdk/types/restricted-node-modules.d.ts +462 -0
- package/dist/sdk/utils/capabilities/blockchain/blockchain-helpers.d.ts +2 -2
- package/dist/sdk/utils/prepare-runtime.d.ts +1 -1
- package/dist/sdk/utils/prepare-runtime.js +9 -2
- package/dist/sdk/wasm/host-bindings.d.ts +4 -4
- package/package.json +2 -2
- package/scripts/run.ts +7 -1
- package/scripts/src/build-types.ts +31 -1
- package/scripts/src/compile-to-js.ts +5 -6
- package/scripts/src/compile-workflow.ts +1 -11
- package/scripts/src/validate-workflow-runtime-compat.test.ts +433 -0
- package/scripts/src/validate-workflow-runtime-compat.ts +631 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Runtime Compatibility Validator
|
|
3
|
+
*
|
|
4
|
+
* CRE (Compute Runtime Environment) workflows are compiled from TypeScript to
|
|
5
|
+
* WebAssembly and executed inside a Javy/QuickJS sandbox — NOT a full Node.js
|
|
6
|
+
* runtime. This means many APIs that developers take for granted (filesystem,
|
|
7
|
+
* network sockets, crypto, child processes, etc.) are simply not available at
|
|
8
|
+
* runtime and will silently fail or crash.
|
|
9
|
+
*
|
|
10
|
+
* This module performs **static analysis** on workflow source code to catch
|
|
11
|
+
* these issues at build time, before the workflow is compiled to WASM. It
|
|
12
|
+
* operates in two passes:
|
|
13
|
+
*
|
|
14
|
+
* 1. **Module import analysis** — walks the AST of every reachable source file
|
|
15
|
+
* (starting from the workflow entry point) and flags imports from restricted
|
|
16
|
+
* Node.js built-in modules (e.g. `node:fs`, `node:crypto`, `node:http`).
|
|
17
|
+
* This catches `import`, `export ... from`, `require()`, and dynamic
|
|
18
|
+
* `import()` syntax.
|
|
19
|
+
*
|
|
20
|
+
* 2. **Global API analysis** — uses the TypeScript type-checker to detect
|
|
21
|
+
* references to browser/Node globals that don't exist in QuickJS (e.g.
|
|
22
|
+
* `fetch`, `setTimeout`, `window`, `document`). Only flags identifiers
|
|
23
|
+
* that resolve to non-local declarations, so user-defined variables with
|
|
24
|
+
* the same name (e.g. `const fetch = cre.capabilities.HTTPClient`) are
|
|
25
|
+
* not flagged.
|
|
26
|
+
*
|
|
27
|
+
* The validator follows relative imports transitively so that violations in
|
|
28
|
+
* helper files reachable from the entry point are also caught.
|
|
29
|
+
*
|
|
30
|
+
* ## How it's used
|
|
31
|
+
*
|
|
32
|
+
* This validator runs automatically as part of the `cre-compile` build pipeline:
|
|
33
|
+
*
|
|
34
|
+
* ```
|
|
35
|
+
* cre-compile <path/to/workflow.ts> [path/to/output.wasm]
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* The pipeline is: `cre-compile` (CLI) -> `compile-workflow` -> `compile-to-js`
|
|
39
|
+
* -> **`assertWorkflowRuntimeCompatibility()`** -> bundle -> compile to WASM.
|
|
40
|
+
*
|
|
41
|
+
* The validation happens before any bundling or WASM compilation, so developers
|
|
42
|
+
* get fast, actionable error messages pointing to exact file:line:column
|
|
43
|
+
* locations instead of cryptic WASM runtime failures.
|
|
44
|
+
*
|
|
45
|
+
* ## Layers of protection
|
|
46
|
+
*
|
|
47
|
+
* This validator is one of two complementary mechanisms that prevent usage of
|
|
48
|
+
* unavailable APIs:
|
|
49
|
+
*
|
|
50
|
+
* 1. **Compile-time types** (`restricted-apis.d.ts` and `restricted-node-modules.d.ts`)
|
|
51
|
+
* — mark restricted APIs as `never` so the TypeScript compiler flags them
|
|
52
|
+
* with red squiggles in the IDE. This gives instant feedback while coding.
|
|
53
|
+
*
|
|
54
|
+
* 2. **Build-time validation** (this module) — performs AST-level static
|
|
55
|
+
* analysis during `cre-compile`. This catches cases that type-level
|
|
56
|
+
* restrictions can't cover, such as `require()` calls, dynamic `import()`,
|
|
57
|
+
* and usage inside plain `.js` files that don't go through `tsc`.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { assertWorkflowRuntimeCompatibility } from './validate-workflow-runtime-compat'
|
|
62
|
+
*
|
|
63
|
+
* // Throws WorkflowRuntimeCompatibilityError if violations are found
|
|
64
|
+
* assertWorkflowRuntimeCompatibility('./src/workflow.ts')
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
import { existsSync, readFileSync, statSync } from 'node:fs'
|
|
71
|
+
import path from 'node:path'
|
|
72
|
+
import * as ts from 'typescript'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A single detected violation: a location in the source code where a
|
|
76
|
+
* restricted API is referenced.
|
|
77
|
+
*/
|
|
78
|
+
type Violation = {
|
|
79
|
+
/** Absolute path to the file containing the violation. */
|
|
80
|
+
filePath: string
|
|
81
|
+
/** 1-based line number. */
|
|
82
|
+
line: number
|
|
83
|
+
/** 1-based column number. */
|
|
84
|
+
column: number
|
|
85
|
+
/** Human-readable description of the violation. */
|
|
86
|
+
message: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Node.js built-in module specifiers that are not available in the QuickJS
|
|
91
|
+
* runtime. Both bare (`fs`) and prefixed (`node:fs`) forms are included
|
|
92
|
+
* because TypeScript/bundlers accept either.
|
|
93
|
+
*/
|
|
94
|
+
const restrictedModuleSpecifiers = new Set([
|
|
95
|
+
'crypto',
|
|
96
|
+
'node:crypto',
|
|
97
|
+
'fs',
|
|
98
|
+
'node:fs',
|
|
99
|
+
'fs/promises',
|
|
100
|
+
'node:fs/promises',
|
|
101
|
+
'net',
|
|
102
|
+
'node:net',
|
|
103
|
+
'http',
|
|
104
|
+
'node:http',
|
|
105
|
+
'https',
|
|
106
|
+
'node:https',
|
|
107
|
+
'child_process',
|
|
108
|
+
'node:child_process',
|
|
109
|
+
'os',
|
|
110
|
+
'node:os',
|
|
111
|
+
'stream',
|
|
112
|
+
'node:stream',
|
|
113
|
+
'worker_threads',
|
|
114
|
+
'node:worker_threads',
|
|
115
|
+
'dns',
|
|
116
|
+
'node:dns',
|
|
117
|
+
'zlib',
|
|
118
|
+
'node:zlib',
|
|
119
|
+
])
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Global identifiers (browser and Node.js) that do not exist in the QuickJS
|
|
123
|
+
* runtime. For network requests, workflows should use `cre.capabilities.HTTPClient`;
|
|
124
|
+
* for scheduling, `cre.capabilities.CronCapability`.
|
|
125
|
+
*/
|
|
126
|
+
const restrictedGlobalApis = new Set([
|
|
127
|
+
'fetch',
|
|
128
|
+
'window',
|
|
129
|
+
'document',
|
|
130
|
+
'XMLHttpRequest',
|
|
131
|
+
'localStorage',
|
|
132
|
+
'sessionStorage',
|
|
133
|
+
'setTimeout',
|
|
134
|
+
'setInterval',
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
/** File extensions treated as scannable source code. */
|
|
138
|
+
const sourceExtensions = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs']
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Error thrown when one or more runtime-incompatible API usages are detected.
|
|
142
|
+
* The message includes a docs link and a formatted list of every violation
|
|
143
|
+
* with file path, line, column, and description.
|
|
144
|
+
*/
|
|
145
|
+
class WorkflowRuntimeCompatibilityError extends Error {
|
|
146
|
+
constructor(violations: Violation[]) {
|
|
147
|
+
const sortedViolations = [...violations].sort((a, b) => {
|
|
148
|
+
if (a.filePath !== b.filePath) return a.filePath.localeCompare(b.filePath)
|
|
149
|
+
if (a.line !== b.line) return a.line - b.line
|
|
150
|
+
return a.column - b.column
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const formattedViolations = sortedViolations
|
|
154
|
+
.map((violation) => {
|
|
155
|
+
const relativePath = path.relative(process.cwd(), violation.filePath)
|
|
156
|
+
return `- ${relativePath}:${violation.line}:${violation.column} ${violation.message}`
|
|
157
|
+
})
|
|
158
|
+
.join('\n')
|
|
159
|
+
|
|
160
|
+
super(
|
|
161
|
+
`Unsupported API usage found in workflow source.
|
|
162
|
+
CRE workflows run on Javy (QuickJS), not full Node.js.
|
|
163
|
+
Use CRE capabilities instead (for example, HTTPClient instead of fetch/node:http).
|
|
164
|
+
See https://docs.chain.link/cre/concepts/typescript-wasm-runtime
|
|
165
|
+
|
|
166
|
+
${formattedViolations}`,
|
|
167
|
+
)
|
|
168
|
+
this.name = 'WorkflowRuntimeCompatibilityError'
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Resolves a file path to an absolute path using the current working directory. */
|
|
173
|
+
const toAbsolutePath = (filePath: string) => path.resolve(filePath)
|
|
174
|
+
|
|
175
|
+
const defaultValidationCompilerOptions: ts.CompilerOptions = {
|
|
176
|
+
allowJs: true,
|
|
177
|
+
checkJs: true,
|
|
178
|
+
noEmit: true,
|
|
179
|
+
skipLibCheck: true,
|
|
180
|
+
target: ts.ScriptTarget.ESNext,
|
|
181
|
+
module: ts.ModuleKind.ESNext,
|
|
182
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const formatDiagnostic = (diagnostic: ts.Diagnostic) => {
|
|
186
|
+
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')
|
|
187
|
+
if (!diagnostic.file || diagnostic.start == null) {
|
|
188
|
+
return message
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start)
|
|
192
|
+
return `${toAbsolutePath(diagnostic.file.fileName)}:${line + 1}:${character + 1} ${message}`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Loads compiler options from the nearest tsconfig.json so validation runs
|
|
197
|
+
* against the same ambient/type environment as the workflow project.
|
|
198
|
+
*/
|
|
199
|
+
const loadClosestTsconfigCompilerOptions = (entryFilePath: string): ts.CompilerOptions | null => {
|
|
200
|
+
const configPath = ts.findConfigFile(
|
|
201
|
+
path.dirname(entryFilePath),
|
|
202
|
+
ts.sys.fileExists,
|
|
203
|
+
'tsconfig.json',
|
|
204
|
+
)
|
|
205
|
+
if (!configPath) {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
let unrecoverableDiagnostic: ts.Diagnostic | null = null
|
|
210
|
+
const parsed = ts.getParsedCommandLineOfConfigFile(
|
|
211
|
+
configPath,
|
|
212
|
+
{},
|
|
213
|
+
{
|
|
214
|
+
...ts.sys,
|
|
215
|
+
onUnRecoverableConfigFileDiagnostic: (diagnostic) => {
|
|
216
|
+
unrecoverableDiagnostic = diagnostic
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (!parsed) {
|
|
222
|
+
if (unrecoverableDiagnostic) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`Failed to parse TypeScript config for workflow validation.\n${formatDiagnostic(unrecoverableDiagnostic)}`,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
return null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return parsed.options
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Maps a file extension to the appropriate TypeScript {@link ts.ScriptKind}
|
|
235
|
+
* so the parser handles JSX, CommonJS, and ESM files correctly.
|
|
236
|
+
*/
|
|
237
|
+
const getScriptKind = (filePath: string): ts.ScriptKind => {
|
|
238
|
+
switch (path.extname(filePath).toLowerCase()) {
|
|
239
|
+
case '.js':
|
|
240
|
+
return ts.ScriptKind.JS
|
|
241
|
+
case '.jsx':
|
|
242
|
+
return ts.ScriptKind.JSX
|
|
243
|
+
case '.mjs':
|
|
244
|
+
return ts.ScriptKind.JS
|
|
245
|
+
case '.cjs':
|
|
246
|
+
return ts.ScriptKind.JS
|
|
247
|
+
case '.tsx':
|
|
248
|
+
return ts.ScriptKind.TSX
|
|
249
|
+
case '.mts':
|
|
250
|
+
return ts.ScriptKind.TS
|
|
251
|
+
case '.cts':
|
|
252
|
+
return ts.ScriptKind.TS
|
|
253
|
+
default:
|
|
254
|
+
return ts.ScriptKind.TS
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Creates a {@link Violation} with 1-based line and column numbers derived
|
|
260
|
+
* from a character position in the source file.
|
|
261
|
+
*/
|
|
262
|
+
const createViolation = (
|
|
263
|
+
filePath: string,
|
|
264
|
+
pos: number,
|
|
265
|
+
sourceFile: ts.SourceFile,
|
|
266
|
+
message: string,
|
|
267
|
+
): Violation => {
|
|
268
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(pos)
|
|
269
|
+
return {
|
|
270
|
+
filePath: toAbsolutePath(filePath),
|
|
271
|
+
line: line + 1,
|
|
272
|
+
column: character + 1,
|
|
273
|
+
message,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Returns `true` if the specifier looks like a relative or absolute file path. */
|
|
278
|
+
const isRelativeImport = (specifier: string) => {
|
|
279
|
+
return specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Attempts to resolve a relative import specifier to an absolute file path.
|
|
284
|
+
* Tries the path as-is first, then appends each known source extension, then
|
|
285
|
+
* looks for an index file inside the directory. Returns `null` if nothing is
|
|
286
|
+
* found on disk.
|
|
287
|
+
*/
|
|
288
|
+
const resolveRelativeImport = (fromFilePath: string, specifier: string): string | null => {
|
|
289
|
+
const basePath = specifier.startsWith('/')
|
|
290
|
+
? path.resolve(specifier)
|
|
291
|
+
: path.resolve(path.dirname(fromFilePath), specifier)
|
|
292
|
+
|
|
293
|
+
if (existsSync(basePath) && statSync(basePath).isFile()) {
|
|
294
|
+
return toAbsolutePath(basePath)
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
for (const extension of sourceExtensions) {
|
|
298
|
+
const withExtension = `${basePath}${extension}`
|
|
299
|
+
if (existsSync(withExtension)) {
|
|
300
|
+
return toAbsolutePath(withExtension)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for (const extension of sourceExtensions) {
|
|
305
|
+
const asIndex = path.join(basePath, `index${extension}`)
|
|
306
|
+
if (existsSync(asIndex)) {
|
|
307
|
+
return toAbsolutePath(asIndex)
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Extracts a string literal from the first argument of a call expression.
|
|
316
|
+
* Used for `require('node:fs')` and `import('node:fs')` patterns.
|
|
317
|
+
* Returns `null` if the first argument is not a static string literal.
|
|
318
|
+
*/
|
|
319
|
+
const getStringLiteralFromCall = (node: ts.CallExpression): string | null => {
|
|
320
|
+
const [firstArg] = node.arguments
|
|
321
|
+
if (!firstArg || !ts.isStringLiteral(firstArg)) return null
|
|
322
|
+
return firstArg.text
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* **Pass 1 — Module import analysis.**
|
|
327
|
+
*
|
|
328
|
+
* Walks the AST of a single source file and:
|
|
329
|
+
* - Flags any import/export/require/dynamic-import of a restricted module.
|
|
330
|
+
* - Enqueues relative imports for recursive scanning so the validator
|
|
331
|
+
* transitively covers the entire local dependency graph.
|
|
332
|
+
*
|
|
333
|
+
* Handles all module import syntaxes:
|
|
334
|
+
* - `import ... from 'node:fs'`
|
|
335
|
+
* - `export ... from 'node:fs'`
|
|
336
|
+
* - `import fs = require('node:fs')`
|
|
337
|
+
* - `require('node:fs')`
|
|
338
|
+
* - `import('node:fs')`
|
|
339
|
+
*/
|
|
340
|
+
const collectModuleUsage = (
|
|
341
|
+
sourceFile: ts.SourceFile,
|
|
342
|
+
filePath: string,
|
|
343
|
+
violations: Violation[],
|
|
344
|
+
enqueueFile: (nextFile: string) => void,
|
|
345
|
+
) => {
|
|
346
|
+
const checkModuleSpecifier = (specifier: string, pos: number) => {
|
|
347
|
+
if (restrictedModuleSpecifiers.has(specifier)) {
|
|
348
|
+
violations.push(
|
|
349
|
+
createViolation(
|
|
350
|
+
filePath,
|
|
351
|
+
pos,
|
|
352
|
+
sourceFile,
|
|
353
|
+
`'${specifier}' is not available in CRE workflow runtime.`,
|
|
354
|
+
),
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!isRelativeImport(specifier)) return
|
|
359
|
+
const resolved = resolveRelativeImport(filePath, specifier)
|
|
360
|
+
if (resolved) {
|
|
361
|
+
enqueueFile(resolved)
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const visit = (node: ts.Node) => {
|
|
366
|
+
// import ... from 'specifier'
|
|
367
|
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
|
368
|
+
checkModuleSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// export ... from 'specifier'
|
|
372
|
+
if (
|
|
373
|
+
ts.isExportDeclaration(node) &&
|
|
374
|
+
node.moduleSpecifier &&
|
|
375
|
+
ts.isStringLiteral(node.moduleSpecifier)
|
|
376
|
+
) {
|
|
377
|
+
checkModuleSpecifier(node.moduleSpecifier.text, node.moduleSpecifier.getStart(sourceFile))
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// import fs = require('specifier')
|
|
381
|
+
if (
|
|
382
|
+
ts.isImportEqualsDeclaration(node) &&
|
|
383
|
+
ts.isExternalModuleReference(node.moduleReference) &&
|
|
384
|
+
node.moduleReference.expression &&
|
|
385
|
+
ts.isStringLiteral(node.moduleReference.expression)
|
|
386
|
+
) {
|
|
387
|
+
checkModuleSpecifier(
|
|
388
|
+
node.moduleReference.expression.text,
|
|
389
|
+
node.moduleReference.expression.getStart(sourceFile),
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (ts.isCallExpression(node)) {
|
|
394
|
+
// require('specifier')
|
|
395
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === 'require') {
|
|
396
|
+
const requiredModule = getStringLiteralFromCall(node)
|
|
397
|
+
if (requiredModule) {
|
|
398
|
+
checkModuleSpecifier(requiredModule, node.arguments[0].getStart(sourceFile))
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// import('specifier')
|
|
403
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
404
|
+
const importedModule = getStringLiteralFromCall(node)
|
|
405
|
+
if (importedModule) {
|
|
406
|
+
checkModuleSpecifier(importedModule, node.arguments[0].getStart(sourceFile))
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
ts.forEachChild(node, visit)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
visit(sourceFile)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Checks whether an identifier AST node is the **name being declared** (as
|
|
419
|
+
* opposed to a reference/usage). For example, in `const fetch = ...` the
|
|
420
|
+
* `fetch` token is a declaration name, while in `fetch(url)` it is a usage.
|
|
421
|
+
*
|
|
422
|
+
* This distinction is critical so that user-defined variables that shadow
|
|
423
|
+
* restricted global names are not flagged as violations.
|
|
424
|
+
*/
|
|
425
|
+
const isDeclarationName = (identifier: ts.Identifier): boolean => {
|
|
426
|
+
const parent = identifier.parent
|
|
427
|
+
|
|
428
|
+
// Variable, function, class, interface, type alias, enum, module,
|
|
429
|
+
// type parameter, parameter, binding element, import names, enum member,
|
|
430
|
+
// property/method declarations, property assignments, and labels.
|
|
431
|
+
if (
|
|
432
|
+
(ts.isFunctionDeclaration(parent) && parent.name === identifier) ||
|
|
433
|
+
(ts.isFunctionExpression(parent) && parent.name === identifier) ||
|
|
434
|
+
(ts.isClassDeclaration(parent) && parent.name === identifier) ||
|
|
435
|
+
(ts.isClassExpression(parent) && parent.name === identifier) ||
|
|
436
|
+
(ts.isInterfaceDeclaration(parent) && parent.name === identifier) ||
|
|
437
|
+
(ts.isTypeAliasDeclaration(parent) && parent.name === identifier) ||
|
|
438
|
+
(ts.isEnumDeclaration(parent) && parent.name === identifier) ||
|
|
439
|
+
(ts.isModuleDeclaration(parent) && parent.name === identifier) ||
|
|
440
|
+
(ts.isTypeParameterDeclaration(parent) && parent.name === identifier) ||
|
|
441
|
+
(ts.isVariableDeclaration(parent) && parent.name === identifier) ||
|
|
442
|
+
(ts.isParameter(parent) && parent.name === identifier) ||
|
|
443
|
+
(ts.isBindingElement(parent) && parent.name === identifier) ||
|
|
444
|
+
(ts.isImportClause(parent) && parent.name === identifier) ||
|
|
445
|
+
(ts.isImportSpecifier(parent) && parent.name === identifier) ||
|
|
446
|
+
(ts.isNamespaceImport(parent) && parent.name === identifier) ||
|
|
447
|
+
(ts.isImportEqualsDeclaration(parent) && parent.name === identifier) ||
|
|
448
|
+
(ts.isNamespaceExport(parent) && parent.name === identifier) ||
|
|
449
|
+
(ts.isEnumMember(parent) && parent.name === identifier) ||
|
|
450
|
+
(ts.isPropertyDeclaration(parent) && parent.name === identifier) ||
|
|
451
|
+
(ts.isPropertySignature(parent) && parent.name === identifier) ||
|
|
452
|
+
(ts.isMethodDeclaration(parent) && parent.name === identifier) ||
|
|
453
|
+
(ts.isMethodSignature(parent) && parent.name === identifier) ||
|
|
454
|
+
(ts.isGetAccessorDeclaration(parent) && parent.name === identifier) ||
|
|
455
|
+
(ts.isSetAccessorDeclaration(parent) && parent.name === identifier) ||
|
|
456
|
+
(ts.isPropertyAssignment(parent) && parent.name === identifier) ||
|
|
457
|
+
(ts.isShorthandPropertyAssignment(parent) && parent.name === identifier) ||
|
|
458
|
+
(ts.isLabeledStatement(parent) && parent.label === identifier)
|
|
459
|
+
) {
|
|
460
|
+
return true
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Property access (obj.fetch), qualified names (Ns.fetch), and type
|
|
464
|
+
// references (SomeType) — the right-hand identifier is not a standalone
|
|
465
|
+
// usage of the global name.
|
|
466
|
+
if (
|
|
467
|
+
(ts.isPropertyAccessExpression(parent) && parent.name === identifier) ||
|
|
468
|
+
(ts.isQualifiedName(parent) && parent.right === identifier) ||
|
|
469
|
+
(ts.isTypeReferenceNode(parent) && parent.typeName === identifier)
|
|
470
|
+
) {
|
|
471
|
+
return true
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return false
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* **Pass 2 — Global API analysis.**
|
|
479
|
+
*
|
|
480
|
+
* Uses the TypeScript type-checker to find references to restricted global
|
|
481
|
+
* identifiers (e.g. `fetch`, `setTimeout`, `window`). An identifier is only
|
|
482
|
+
* flagged if:
|
|
483
|
+
* - It matches a name in {@link restrictedGlobalApis}.
|
|
484
|
+
* - It is **not** a declaration name (see {@link isDeclarationName}).
|
|
485
|
+
* - Its symbol resolves to a declaration outside the local source files,
|
|
486
|
+
* meaning it comes from the global scope rather than user code.
|
|
487
|
+
*
|
|
488
|
+
* This also catches `globalThis.fetch`-style access patterns.
|
|
489
|
+
*/
|
|
490
|
+
const collectGlobalApiUsage = (
|
|
491
|
+
program: ts.Program,
|
|
492
|
+
localSourceFiles: Set<string>,
|
|
493
|
+
violations: Violation[],
|
|
494
|
+
) => {
|
|
495
|
+
const checker = program.getTypeChecker()
|
|
496
|
+
|
|
497
|
+
for (const sourceFile of program.getSourceFiles()) {
|
|
498
|
+
const resolvedSourcePath = toAbsolutePath(sourceFile.fileName)
|
|
499
|
+
if (!localSourceFiles.has(resolvedSourcePath)) continue
|
|
500
|
+
|
|
501
|
+
const visit = (node: ts.Node) => {
|
|
502
|
+
// Direct usage: fetch(...), setTimeout(...)
|
|
503
|
+
if (
|
|
504
|
+
ts.isIdentifier(node) &&
|
|
505
|
+
restrictedGlobalApis.has(node.text) &&
|
|
506
|
+
!isDeclarationName(node)
|
|
507
|
+
) {
|
|
508
|
+
const symbol = checker.getSymbolAtLocation(node)
|
|
509
|
+
const hasLocalDeclaration =
|
|
510
|
+
symbol?.declarations?.some((declaration) =>
|
|
511
|
+
localSourceFiles.has(toAbsolutePath(declaration.getSourceFile().fileName)),
|
|
512
|
+
) ?? false
|
|
513
|
+
|
|
514
|
+
if (!hasLocalDeclaration) {
|
|
515
|
+
violations.push(
|
|
516
|
+
createViolation(
|
|
517
|
+
resolvedSourcePath,
|
|
518
|
+
node.getStart(sourceFile),
|
|
519
|
+
sourceFile,
|
|
520
|
+
`'${node.text}' is not available in CRE workflow runtime.`,
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Property access on globalThis: globalThis.fetch(...)
|
|
527
|
+
if (
|
|
528
|
+
ts.isPropertyAccessExpression(node) &&
|
|
529
|
+
ts.isIdentifier(node.expression) &&
|
|
530
|
+
node.expression.text === 'globalThis' &&
|
|
531
|
+
restrictedGlobalApis.has(node.name.text)
|
|
532
|
+
) {
|
|
533
|
+
violations.push(
|
|
534
|
+
createViolation(
|
|
535
|
+
resolvedSourcePath,
|
|
536
|
+
node.name.getStart(sourceFile),
|
|
537
|
+
sourceFile,
|
|
538
|
+
`'globalThis.${node.name.text}' is not available in CRE workflow runtime.`,
|
|
539
|
+
),
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
ts.forEachChild(node, visit)
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
visit(sourceFile)
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Validates that a workflow entry file (and all local files it transitively
|
|
552
|
+
* imports) only uses APIs available in the CRE QuickJS/WASM runtime.
|
|
553
|
+
*
|
|
554
|
+
* The check runs in two passes:
|
|
555
|
+
*
|
|
556
|
+
* 1. **Module import scan** — starting from `entryFilePath`, recursively
|
|
557
|
+
* parses every reachable local source file and flags imports from
|
|
558
|
+
* restricted Node.js built-in modules.
|
|
559
|
+
*
|
|
560
|
+
* 2. **Global API scan** — creates a TypeScript program from the collected
|
|
561
|
+
* source files and uses the type-checker to flag references to restricted
|
|
562
|
+
* global identifiers that resolve to non-local (i.e. global) declarations.
|
|
563
|
+
*
|
|
564
|
+
* @param entryFilePath - Path to the workflow entry file (absolute or relative).
|
|
565
|
+
* @throws {WorkflowRuntimeCompatibilityError} If any violations are found.
|
|
566
|
+
* The error message includes a link to the CRE runtime docs and a formatted
|
|
567
|
+
* list of every violation with file:line:column and description.
|
|
568
|
+
*
|
|
569
|
+
* @example
|
|
570
|
+
* ```ts
|
|
571
|
+
* // During the cre-compile build step:
|
|
572
|
+
* assertWorkflowRuntimeCompatibility('./src/workflow.ts')
|
|
573
|
+
* // Throws if the workflow (or any file it imports) uses fetch, node:fs, etc.
|
|
574
|
+
* ```
|
|
575
|
+
*
|
|
576
|
+
* @see https://docs.chain.link/cre/concepts/typescript-wasm-runtime
|
|
577
|
+
*/
|
|
578
|
+
export const assertWorkflowRuntimeCompatibility = (entryFilePath: string) => {
|
|
579
|
+
const rootFile = toAbsolutePath(entryFilePath)
|
|
580
|
+
const projectCompilerOptions = loadClosestTsconfigCompilerOptions(rootFile) ?? {}
|
|
581
|
+
const filesToScan = [rootFile]
|
|
582
|
+
const scannedFiles = new Set<string>()
|
|
583
|
+
const localSourceFiles = new Set<string>()
|
|
584
|
+
const violations: Violation[] = []
|
|
585
|
+
|
|
586
|
+
// Pass 1: Walk the local import graph and collect module-level violations.
|
|
587
|
+
while (filesToScan.length > 0) {
|
|
588
|
+
const currentFile = filesToScan.pop()
|
|
589
|
+
if (!currentFile || scannedFiles.has(currentFile)) continue
|
|
590
|
+
scannedFiles.add(currentFile)
|
|
591
|
+
|
|
592
|
+
if (!existsSync(currentFile)) continue
|
|
593
|
+
localSourceFiles.add(currentFile)
|
|
594
|
+
|
|
595
|
+
const fileContents = readFileSync(currentFile, 'utf-8')
|
|
596
|
+
const sourceFile = ts.createSourceFile(
|
|
597
|
+
currentFile,
|
|
598
|
+
fileContents,
|
|
599
|
+
ts.ScriptTarget.Latest,
|
|
600
|
+
true,
|
|
601
|
+
getScriptKind(currentFile),
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
collectModuleUsage(sourceFile, currentFile, violations, (nextFile) => {
|
|
605
|
+
if (!scannedFiles.has(nextFile)) {
|
|
606
|
+
filesToScan.push(nextFile)
|
|
607
|
+
}
|
|
608
|
+
})
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Pass 2: Use the type-checker to detect restricted global API usage.
|
|
612
|
+
const program = ts.createProgram({
|
|
613
|
+
rootNames: [...localSourceFiles],
|
|
614
|
+
options: {
|
|
615
|
+
...defaultValidationCompilerOptions,
|
|
616
|
+
...projectCompilerOptions,
|
|
617
|
+
allowJs: true,
|
|
618
|
+
checkJs: true,
|
|
619
|
+
noEmit: true,
|
|
620
|
+
skipLibCheck: true,
|
|
621
|
+
},
|
|
622
|
+
})
|
|
623
|
+
|
|
624
|
+
collectGlobalApiUsage(program, localSourceFiles, violations)
|
|
625
|
+
|
|
626
|
+
if (violations.length > 0) {
|
|
627
|
+
throw new WorkflowRuntimeCompatibilityError(violations)
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export { WorkflowRuntimeCompatibilityError }
|