envio 3.14.0 → 3.14.1-resolvers.2

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 (42) hide show
  1. package/evm.schema.json +8 -1
  2. package/fuel.schema.json +7 -0
  3. package/index.d.ts +258 -5
  4. package/index.js +10 -0
  5. package/package.json +6 -6
  6. package/src/Bin.res +48 -0
  7. package/src/Bin.res.mjs +34 -1
  8. package/src/Config.res +12 -2
  9. package/src/Config.res.mjs +7 -0
  10. package/src/Env.res +141 -1
  11. package/src/Env.res.mjs +133 -1
  12. package/src/HandlerLoader.res +7 -13
  13. package/src/HandlerLoader.res.mjs +7 -14
  14. package/src/HandlerTypeCheck.res +207 -0
  15. package/src/HandlerTypeCheck.res.mjs +262 -0
  16. package/src/bindings/NodeJs.res +31 -0
  17. package/src/bindings/NodeJs.res.mjs +3 -0
  18. package/src/resolvers/ResolverProcess.res +805 -0
  19. package/src/resolvers/ResolverProcess.res.mjs +662 -0
  20. package/src/resolvers/ResolverQuery.res +313 -0
  21. package/src/resolvers/ResolverQuery.res.mjs +220 -0
  22. package/src/resolvers/collisions.js +108 -0
  23. package/src/resolvers/db.js +363 -0
  24. package/src/resolvers/dispatch.js +259 -0
  25. package/src/resolvers/errors.js +30 -0
  26. package/src/resolvers/graphqlSelection.js +292 -0
  27. package/src/resolvers/hasuraAction.js +84 -0
  28. package/src/resolvers/hasuraApply.js +333 -0
  29. package/src/resolvers/hasuraMetadata.js +137 -0
  30. package/src/resolvers/index.js +123 -0
  31. package/src/resolvers/manifest.js +376 -0
  32. package/src/resolvers/server.js +304 -0
  33. package/src/sources/EvmRpcClient.res +2 -0
  34. package/src/sources/RpcSource.res +17 -4
  35. package/src/sources/RpcSource.res.mjs +23 -5
  36. package/src/sources/SourceManager.res +63 -40
  37. package/src/sources/SourceManager.res.mjs +32 -20
  38. package/src/tui/Tui.res +1 -1
  39. package/src/tui/Tui.res.mjs +1 -1
  40. package/svm.schema.json +7 -0
  41. package/src/HandlerTypeCheck.mjs +0 -14
  42. package/src/HandlerTypeCheckWorker.mjs +0 -68
package/src/Env.res CHANGED
@@ -167,6 +167,141 @@ module ClickHouse = {
167
167
  let databaseEngine = () => read("ENVIO_CLICKHOUSE_DATABASE_ENGINE")
168
168
  }
169
169
 
170
+ // The resolver process runs on its own, so its database connection comes from
171
+ // the same ENVIO_PG_* vars the indexer's does -- point them wherever reads
172
+ // should go. Only what genuinely differs from the indexer's shape gets a var
173
+ // of its own.
174
+ //
175
+ // Read at call time rather than module load, for the same reason ClickHouse's
176
+ // are: `envio dev` injects them after this module has been evaluated.
177
+ module Resolvers = {
178
+ %%private(
179
+ let read: string => option<string> = %raw(`(k) => {
180
+ const v = process.env[k];
181
+ return v === undefined || v === "" ? undefined : v;
182
+ }`)
183
+
184
+ // `Int.fromString` is `parseInt`: it takes the leading digits and drops the
185
+ // rest, so "60s" reads as 60 and "1e3" as 1. For a duration that is a
186
+ // thousandfold error in the quiet direction -- "60s" meaning a minute
187
+ // becomes a 60ms loop -- so the parse only accepts what it can write back
188
+ // unchanged.
189
+ let parseWhole = raw => {
190
+ let trimmed = raw->String.trim
191
+ switch trimmed->Int.fromString {
192
+ | Some(value) if trimmed === value->Int.toString => Some(value)
193
+ | _ => None
194
+ }
195
+ }
196
+
197
+ let readInt = (key, ~fallback) =>
198
+ switch read(key) {
199
+ | None => fallback
200
+ | Some(raw) =>
201
+ switch raw->parseWhole {
202
+ | Some(value) if value > 0 => value
203
+ | _ =>
204
+ JsError.throwWithMessage(
205
+ `Invalid ${key} value: "${raw}". Expected a positive whole number.`,
206
+ )
207
+ }
208
+ }
209
+ )
210
+
211
+ let port = () =>
212
+ switch readInt("ENVIO_RESOLVERS_PORT", ~fallback=9900) {
213
+ | value if value <= 65535 => value
214
+ | value =>
215
+ // `readInt` only rejects zero and below, so an out-of-range port reached
216
+ // `listen` and failed there instead, naming neither the variable nor why.
217
+ JsError.throwWithMessage(
218
+ `Invalid ENVIO_RESOLVERS_PORT value: "${value->Int.toString}". A TCP port is 1 to 65535.`,
219
+ )
220
+ }
221
+
222
+ // Sized as concurrent heavy requests x per-request fan-out, not as the
223
+ // indexer's two long-lived connections: one resolver request can hold four
224
+ // at once.
225
+ let poolSize = () => readInt("ENVIO_RESOLVERS_POOL_SIZE", ~fallback=25)
226
+
227
+ let poolWaitTimeoutMs = () => readInt("ENVIO_RESOLVERS_POOL_WAIT_TIMEOUT_MS", ~fallback=10_000)
228
+
229
+ // `envio dev` sets this on the resolver process it spawns: locally the
230
+ // caller is the person who wrote the resolver, so an unexpected error's own
231
+ // message is what they need. Deployed, the caller may be the public internet
232
+ // and the message can carry a connection string.
233
+ let exposeErrors = () =>
234
+ switch read("ENVIO_RESOLVERS_EXPOSE_ERRORS") {
235
+ | None
236
+ | Some("false") => false
237
+ | Some("true") => true
238
+ | Some(other) =>
239
+ // Refused rather than read as false: a typo here silently keeps a
240
+ // resolver's own error messages off the wire, which is the opposite of
241
+ // what the person who set it wanted, and nothing would say so.
242
+ JsError.throwWithMessage(
243
+ `Invalid ENVIO_RESOLVERS_EXPOSE_ERRORS value: "${other}". Expected "true" or "false".`,
244
+ )
245
+ }
246
+
247
+ // The URL *Hasura* posts to, which is not the address this process binds:
248
+ // it is baked into every action, so Hasura has no other way to reach the
249
+ // resolvers.
250
+ let publicUrl = () => read("ENVIO_RESOLVERS_PUBLIC_URL")
251
+
252
+ // How often the metadata is re-asserted. 0 disables it, for a deployment
253
+ // where something else owns Hasura's metadata.
254
+ let metadataIntervalMs = () =>
255
+ switch read("ENVIO_RESOLVERS_METADATA_INTERVAL_MS") {
256
+ | None => 60_000
257
+ | Some(raw) =>
258
+ switch raw->parseWhole {
259
+ | Some(value) if value >= 0 => value
260
+ | _ =>
261
+ JsError.throwWithMessage(
262
+ `Invalid ENVIO_RESOLVERS_METADATA_INTERVAL_MS value: "${raw}". Expected a whole number of milliseconds, or 0 to disable.`,
263
+ )
264
+ }
265
+ }
266
+
267
+ // Required on every request to either route. Unset, the service cannot tell
268
+ // its callers apart from anything else that reaches the socket, and both
269
+ // routes take the role from the request body -- so anyone can claim `admin`.
270
+ let actionSecret = () => read("ENVIO_RESOLVERS_ACTION_SECRET")
271
+
272
+ // Unlocks resolvers declared `private`. Comma-separated so a rotation can
273
+ // present the old and new key at once; a private resolver with none of these
274
+ // configured is unreachable rather than open.
275
+ let privateKeys = () =>
276
+ switch read("ENVIO_RESOLVERS_PRIVATE_KEYS") {
277
+ | None => []
278
+ | Some(raw) =>
279
+ raw
280
+ ->String.split(",")
281
+ ->Array.map(String.trim)
282
+ ->Array.filter(key => key !== "")
283
+ }
284
+
285
+ // Read raw rather than through `Env.Hasura`, whose dev fallbacks would have
286
+ // this process apply metadata to a localhost Hasura nobody asked for. Only
287
+ // an endpoint someone set means "there is a Hasura to register with".
288
+ let hasuraEndpoint = () => read("HASURA_GRAPHQL_ENDPOINT")
289
+ let hasuraAdminSecret = () => read("HASURA_GRAPHQL_ADMIN_SECRET")
290
+
291
+ // Set where a transaction-mode pooler is the only way in. Those reject
292
+ // named prepared statements, so this gives up plan reuse.
293
+ let poolerBacked = () =>
294
+ switch read("ENVIO_RESOLVERS_POOLER_BACKED") {
295
+ | None
296
+ | Some("false") => false
297
+ | Some("true") => true
298
+ | Some(other) =>
299
+ JsError.throwWithMessage(
300
+ `Invalid ENVIO_RESOLVERS_POOLER_BACKED value: "${other}". Expected "true" or "false".`,
301
+ )
302
+ }
303
+ }
304
+
170
305
  module Hasura = {
171
306
  // Disable it on HS indexer run, since we don't have Hasura credentials anyways
172
307
  // Also, it might be useful for some users who don't care about Hasura
@@ -184,7 +319,12 @@ module Hasura = {
184
319
  ~devFallback="http://localhost:8080/v1/metadata",
185
320
  )
186
321
 
187
- let url = graphqlEndpoint->String.slice(~start=0, ~end=-("/v1/metadata"->String.length))
322
+ // Lazy on purpose. `graphqlEndpoint` is required in production, and a missing
323
+ // one arrives here as undefined -- slicing it threw a bare TypeError at import
324
+ // and killed the process before `EnvSafe.close` could say which variable was
325
+ // missing. Only the dev TUI reads this, so there is nothing to compute up
326
+ // front.
327
+ let url = () => graphqlEndpoint->String.slice(~start=0, ~end=-("/v1/metadata"->String.length))
188
328
 
189
329
  let role = envSafe->EnvSafe.get("HASURA_GRAPHQL_ROLE", S.string, ~devFallback="admin")
190
330
 
package/src/Env.res.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import * as EnvSafe from "./EnvSafe.res.mjs";
4
4
  import * as Logging from "./Logging.res.mjs";
5
5
  import * as Postgres from "./bindings/Postgres.res.mjs";
6
+ import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
6
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
8
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
9
  import * as HyperSyncClient from "./sources/HyperSyncClient.res.mjs";
@@ -147,6 +148,134 @@ let ClickHouse = {
147
148
  databaseEngine: databaseEngine
148
149
  };
149
150
 
151
+ let read$1 = ((k) => {
152
+ const v = process.env[k];
153
+ return v === undefined || v === "" ? undefined : v;
154
+ });
155
+
156
+ function parseWhole(raw) {
157
+ let trimmed = raw.trim();
158
+ let value = Stdlib_Int.fromString(trimmed, undefined);
159
+ if (value !== undefined && trimmed === value.toString()) {
160
+ return value;
161
+ }
162
+ }
163
+
164
+ function readInt(key, fallback) {
165
+ let raw = read$1(key);
166
+ if (raw === undefined) {
167
+ return fallback;
168
+ }
169
+ let value = parseWhole(raw);
170
+ if (value !== undefined && value > 0) {
171
+ return value;
172
+ }
173
+ return Stdlib_JsError.throwWithMessage(`Invalid ` + key + ` value: "` + raw + `". Expected a positive whole number.`);
174
+ }
175
+
176
+ function port$1() {
177
+ let value = readInt("ENVIO_RESOLVERS_PORT", 9900);
178
+ if (value <= 65535) {
179
+ return value;
180
+ } else {
181
+ return Stdlib_JsError.throwWithMessage(`Invalid ENVIO_RESOLVERS_PORT value: "` + value.toString() + `". A TCP port is 1 to 65535.`);
182
+ }
183
+ }
184
+
185
+ function poolSize() {
186
+ return readInt("ENVIO_RESOLVERS_POOL_SIZE", 25);
187
+ }
188
+
189
+ function poolWaitTimeoutMs() {
190
+ return readInt("ENVIO_RESOLVERS_POOL_WAIT_TIMEOUT_MS", 10000);
191
+ }
192
+
193
+ function exposeErrors() {
194
+ let other = read$1("ENVIO_RESOLVERS_EXPOSE_ERRORS");
195
+ if (other === undefined) {
196
+ return false;
197
+ }
198
+ switch (other) {
199
+ case "false" :
200
+ return false;
201
+ case "true" :
202
+ return true;
203
+ default:
204
+ return Stdlib_JsError.throwWithMessage(`Invalid ENVIO_RESOLVERS_EXPOSE_ERRORS value: "` + other + `". Expected "true" or "false".`);
205
+ }
206
+ }
207
+
208
+ function publicUrl() {
209
+ return read$1("ENVIO_RESOLVERS_PUBLIC_URL");
210
+ }
211
+
212
+ function metadataIntervalMs() {
213
+ let raw = read$1("ENVIO_RESOLVERS_METADATA_INTERVAL_MS");
214
+ if (raw === undefined) {
215
+ return 60000;
216
+ }
217
+ let value = parseWhole(raw);
218
+ if (value !== undefined) {
219
+ if (value >= 0) {
220
+ return value;
221
+ } else {
222
+ return Stdlib_JsError.throwWithMessage(`Invalid ENVIO_RESOLVERS_METADATA_INTERVAL_MS value: "` + raw + `". Expected a whole number of milliseconds, or 0 to disable.`);
223
+ }
224
+ } else {
225
+ return Stdlib_JsError.throwWithMessage(`Invalid ENVIO_RESOLVERS_METADATA_INTERVAL_MS value: "` + raw + `". Expected a whole number of milliseconds, or 0 to disable.`);
226
+ }
227
+ }
228
+
229
+ function actionSecret() {
230
+ return read$1("ENVIO_RESOLVERS_ACTION_SECRET");
231
+ }
232
+
233
+ function privateKeys() {
234
+ let raw = read$1("ENVIO_RESOLVERS_PRIVATE_KEYS");
235
+ if (raw !== undefined) {
236
+ return raw.split(",").map(prim => prim.trim()).filter(key => key !== "");
237
+ } else {
238
+ return [];
239
+ }
240
+ }
241
+
242
+ function hasuraEndpoint() {
243
+ return read$1("HASURA_GRAPHQL_ENDPOINT");
244
+ }
245
+
246
+ function hasuraAdminSecret() {
247
+ return read$1("HASURA_GRAPHQL_ADMIN_SECRET");
248
+ }
249
+
250
+ function poolerBacked() {
251
+ let other = read$1("ENVIO_RESOLVERS_POOLER_BACKED");
252
+ if (other === undefined) {
253
+ return false;
254
+ }
255
+ switch (other) {
256
+ case "false" :
257
+ return false;
258
+ case "true" :
259
+ return true;
260
+ default:
261
+ return Stdlib_JsError.throwWithMessage(`Invalid ENVIO_RESOLVERS_POOLER_BACKED value: "` + other + `". Expected "true" or "false".`);
262
+ }
263
+ }
264
+
265
+ let Resolvers = {
266
+ port: port$1,
267
+ poolSize: poolSize,
268
+ poolWaitTimeoutMs: poolWaitTimeoutMs,
269
+ exposeErrors: exposeErrors,
270
+ publicUrl: publicUrl,
271
+ metadataIntervalMs: metadataIntervalMs,
272
+ actionSecret: actionSecret,
273
+ privateKeys: privateKeys,
274
+ hasuraEndpoint: hasuraEndpoint,
275
+ hasuraAdminSecret: hasuraAdminSecret,
276
+ poolerBacked: poolerBacked
277
+ };
278
+
150
279
  let enabled = EnvSafe.get(envSafe, "ENVIO_HASURA", S$RescriptSchema.bool, undefined, true, undefined, undefined);
151
280
 
152
281
  let s = EnvSafe.get(envSafe, "ENVIO_HASURA_RESPONSE_LIMIT", S$RescriptSchema.option(S$RescriptSchema.int), undefined, undefined, undefined, undefined);
@@ -155,7 +284,9 @@ let responseLimit = s !== undefined ? s : EnvSafe.get(envSafe, "HASURA_RESPONSE_
155
284
 
156
285
  let graphqlEndpoint = EnvSafe.get(envSafe, "HASURA_GRAPHQL_ENDPOINT", S$RescriptSchema.string, undefined, undefined, "http://localhost:8080/v1/metadata", undefined);
157
286
 
158
- let url = graphqlEndpoint.slice(0, -"/v1/metadata".length | 0);
287
+ function url() {
288
+ return graphqlEndpoint.slice(0, -"/v1/metadata".length | 0);
289
+ }
159
290
 
160
291
  let role = EnvSafe.get(envSafe, "HASURA_GRAPHQL_ROLE", S$RescriptSchema.string, undefined, undefined, "admin", undefined);
161
292
 
@@ -240,6 +371,7 @@ export {
240
371
  logStrategy,
241
372
  Db,
242
373
  ClickHouse,
374
+ Resolvers,
243
375
  Hasura,
244
376
  Configurable,
245
377
  ThrottleWrites,
@@ -77,15 +77,6 @@ let getAutoLoadFiles = async (~config: Config.t) => {
77
77
  }
78
78
  }
79
79
 
80
- type typeCheckResult = {
81
- skipped?: string,
82
- errors?: string,
83
- }
84
-
85
- @module("./HandlerTypeCheck.mjs")
86
- external checkTypesInWorker: (~cwd: string, ~files: array<string>) => promise<typeCheckResult> =
87
- "check"
88
-
89
80
  let typeCheck = async (~config: Config.t, ~autoLoadFiles) => {
90
81
  // A contract's `handler:` may also be an auto-loaded file.
91
82
  let files =
@@ -96,10 +87,13 @@ let typeCheck = async (~config: Config.t, ~autoLoadFiles) => {
96
87
  ->Utils.Set.fromArray
97
88
  ->Utils.Set.toArray
98
89
  if files->Array.length > 0 {
99
- switch await checkTypesInWorker(~cwd=NodeJs.Process.cwd(), ~files) {
100
- | {errors} => JsError.throwWithMessage(`Handler files have type errors:\n\n${errors}`)
101
- | {skipped} => Logging.warn(skipped)
102
- | _ => ()
90
+ switch await HandlerTypeCheck.check(~cwd=NodeJs.Process.cwd(), ~files) {
91
+ | TypeErrors(errors) =>
92
+ JsError.throwWithMessage(
93
+ `The handler type check found errors. Fix them to start the indexer:\n\n${errors}`,
94
+ )
95
+ | Skipped(warning) => Logging.warn(warning)
96
+ | Passed => ()
103
97
  }
104
98
  }
105
99
  }
@@ -10,10 +10,10 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
10
10
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
11
11
  import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
12
12
  import * as HandlerRegister from "./HandlerRegister.res.mjs";
13
+ import * as HandlerTypeCheck from "./HandlerTypeCheck.res.mjs";
13
14
  import * as Promises from "node:fs/promises";
14
15
  import * as TsModuleHooksMjs from "./TsModuleHooks.mjs";
15
16
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
16
- import * as HandlerTypeCheckMjs from "./HandlerTypeCheck.mjs";
17
17
 
18
18
  function registerTsHooksWith(prim) {
19
19
  TsModuleHooksMjs.register(prim);
@@ -70,24 +70,18 @@ async function getAutoLoadFiles(config) {
70
70
  }
71
71
  }
72
72
 
73
- function checkTypesInWorker(prim0, prim1) {
74
- return HandlerTypeCheckMjs.check(prim0, prim1);
75
- }
76
-
77
73
  async function typeCheck(config, autoLoadFiles) {
78
74
  let files = Array.from(new Set(autoLoadFiles.concat(Stdlib_Array.filterMap(config.contractHandlers, param => param.handler)).filter(isTypeScript).map(toAbsolutePath)));
79
75
  if (files.length === 0) {
80
76
  return;
81
77
  }
82
- let match = await HandlerTypeCheckMjs.check(Process.cwd(), files);
83
- let skipped = match.skipped;
84
- let errors = match.errors;
85
- if (errors !== undefined) {
86
- return Stdlib_JsError.throwWithMessage(`Handler files have type errors:\n\n` + errors);
87
- } else if (skipped !== undefined) {
88
- return Logging.warn(skipped);
89
- } else {
78
+ let errors = await HandlerTypeCheck.check(Process.cwd(), files);
79
+ if (typeof errors !== "object") {
90
80
  return;
81
+ } else if (errors.TAG === "TypeErrors") {
82
+ return Stdlib_JsError.throwWithMessage(`The handler type check found errors. Fix them to start the indexer:\n\n` + errors._0);
83
+ } else {
84
+ return Logging.warn(errors._0);
91
85
  }
92
86
  }
93
87
 
@@ -111,7 +105,6 @@ export {
111
105
  importHandler,
112
106
  registerContractHandlers,
113
107
  getAutoLoadFiles,
114
- checkTypesInWorker,
115
108
  typeCheck,
116
109
  registerAllHandlers,
117
110
  }
@@ -0,0 +1,207 @@
1
+ type result =
2
+ | Passed
3
+ | TypeErrors(string)
4
+ | Skipped(string)
5
+
6
+ let timeoutSeconds = 30
7
+
8
+ // tsc's pretty output reads the same from TypeScript 5 to 7: a diagnostic
9
+ // starts unindented with its location, its code frame and related locations
10
+ // follow indented, and `--listFiles` prints the program's files after the
11
+ // last one, before the closing summary.
12
+ let fileDiagnostic = /^(.+):\d+:\d+ - error TS(\d+): /
13
+ let globalDiagnostic = /^error TS(\d+): /
14
+ let summary = /^Found \d+ errors?\b/
15
+ let thrownError = /^\w*Error: /
16
+
17
+ let lines = output => output->String.replaceAll("\r\n", "\n")->String.split("\n")
18
+
19
+ let rec findUp = (directory, name) => {
20
+ let candidate = NodeJs.Path.resolve([directory, name])->NodeJs.Path.toString
21
+ if NodeJs.Fs.existsSync(candidate) {
22
+ Some(candidate)
23
+ } else {
24
+ let parent = NodeJs.Path.dirname(directory)->NodeJs.Path.toString
25
+ parent === directory ? None : findUp(parent, name)
26
+ }
27
+ }
28
+
29
+ // The `tsc` the project's own scripts run. TypeScript 7 has no JavaScript API
30
+ // to call instead, only this command.
31
+ let resolveTsc = (~cwd) =>
32
+ try {
33
+ let manifest =
34
+ NodeJs.Module.createRequire(
35
+ NodeJs.Path.resolve([cwd, "package.json"])->NodeJs.Path.toString,
36
+ )->NodeJs.Module.resolve("typescript/package.json")
37
+ let bin = switch NodeJs.Fs.readFileSync(manifest)->JSON.parseOrThrow {
38
+ | JSON.Object(fields) =>
39
+ switch fields->Dict.get("bin") {
40
+ | Some(JSON.String(bin)) => Some(bin)
41
+ | Some(JSON.Object(bins)) =>
42
+ switch bins->Dict.get("tsc") {
43
+ | Some(JSON.String(bin)) => Some(bin)
44
+ | _ => None
45
+ }
46
+ | _ => None
47
+ }
48
+ | _ => None
49
+ }
50
+ bin->Option.map(bin =>
51
+ NodeJs.Path.resolve([
52
+ NodeJs.Path.dirname(manifest)->NodeJs.Path.toString,
53
+ bin,
54
+ ])->NodeJs.Path.toString
55
+ )
56
+ } catch {
57
+ | _ => None
58
+ }
59
+
60
+ type run = {error: option<NodeJs.ChildProcess.execFileError>, output: string}
61
+
62
+ let runTsc = (~tsc, ~cwd, args) =>
63
+ Promise.make((resolve, _) =>
64
+ NodeJs.ChildProcess.execFile(
65
+ NodeJs.Process.process->NodeJs.Process.execPath,
66
+ [tsc]->Array.concat(args),
67
+ {cwd, timeout: timeoutSeconds * 1000, maxBuffer: Float.Constants.positiveInfinity},
68
+ (error, stdout, stderr) =>
69
+ resolve({
70
+ error: error->Null.toOption,
71
+ output: NodeJs.Util.stripVTControlCharacters(stdout ++ stderr),
72
+ }),
73
+ )
74
+ )
75
+
76
+ type diagnostic = {file: option<string>, code: int, lines: array<string>}
77
+
78
+ // tsc type-checks nothing after a syntax error, an options error or a global
79
+ // error, and an options error is reported at its line in tsconfig.json.
80
+ let canStopTypeChecking = ({file, code}) =>
81
+ file->Option.isNone || code < 2000 || (code >= 5000 && code < 7000)
82
+
83
+ // Paths compare relative to the project, which on Windows also ignores the
84
+ // case of a drive letter tsc may print differently.
85
+ let parse = (output, ~cwd) => {
86
+ let relative = file =>
87
+ NodeJs.Path.relative(cwd, NodeJs.Path.resolve([cwd, file])->NodeJs.Path.toString)
88
+ let diagnostics = []
89
+ let programFiles = Utils.Set.make()
90
+ let current = ref(None)
91
+ let start = (~file, ~code, line) => {
92
+ let diagnostic = {
93
+ file,
94
+ code: code->Int.fromString->Option.getOr(0),
95
+ lines: [line],
96
+ }
97
+ diagnostics->Array.push(diagnostic)
98
+ current := Some(diagnostic)
99
+ }
100
+ output
101
+ ->lines
102
+ ->Array.forEach(line =>
103
+ switch (
104
+ fileDiagnostic->RegExp.exec(line)->Option.map(RegExp.Result.matches),
105
+ globalDiagnostic->RegExp.exec(line)->Option.map(RegExp.Result.matches),
106
+ ) {
107
+ | (Some([Some(file), Some(code)]), _) => start(~file=Some(relative(file)), ~code, line)
108
+ | (_, Some([Some(code)])) => start(~file=None, ~code, line)
109
+ | _ if NodeJs.Path.isAbsolute(line) =>
110
+ programFiles->Utils.Set.add(relative(line))->ignore
111
+ current := None
112
+ | _ if summary->RegExp.test(line) => current := None
113
+ | _ => current.contents->Option.forEach(diagnostic => diagnostic.lines->Array.push(line))
114
+ }
115
+ )
116
+ (diagnostics, programFiles)
117
+ }
118
+
119
+ // Exit codes say nothing here: TypeScript 5 and 6 exit with 2 on type errors,
120
+ // 7 with 1, and 1 is also how its launcher fails when the native compiler
121
+ // isn't installed.
122
+ let failure = ({error, output}) =>
123
+ switch error {
124
+ | Some({killed: true}) =>
125
+ `Skipped the handler type check: tsc didn't finish within ${timeoutSeconds->Int.toString} seconds.`
126
+ | _ =>
127
+ let reason =
128
+ output
129
+ ->lines
130
+ ->Array.find(line => thrownError->RegExp.test(line))
131
+ ->Option.getOr(output->String.trim)
132
+ `Skipped the handler type check: tsc failed to run:\n\n${reason}`
133
+ }
134
+
135
+ let check = async (~cwd, ~files) => {
136
+ // The ReScript compiler checks a ReScript project's handlers, so it has no
137
+ // reason to set up TypeScript, and the warning would only be noise there.
138
+ // `rescript.json` at the root is what makes codegen treat it as ReScript.
139
+ let skip = warning =>
140
+ NodeJs.Fs.existsSync(NodeJs.Path.resolve([cwd, "rescript.json"])->NodeJs.Path.toString)
141
+ ? Passed
142
+ : Skipped(warning)
143
+
144
+ switch (resolveTsc(~cwd), findUp(cwd, "tsconfig.json")) {
145
+ | (None, _) =>
146
+ skip(
147
+ "Skipped the handler type check: typescript isn't installed. Add it to devDependencies to type-check handlers on start.",
148
+ )
149
+ | (_, None) =>
150
+ skip(
151
+ "Skipped the handler type check: no tsconfig.json found. Add one, like the one envio init creates, to type-check handlers on start.",
152
+ )
153
+ | (Some(tsc), Some(tsconfig)) =>
154
+ // The project's own `tsc --noEmit`, minus the build info it would write.
155
+ // `explainFiles` would replace the listed files' absolute paths with
156
+ // relative ones and the reasons each is included.
157
+ let run = await runTsc(
158
+ ~tsc,
159
+ ~cwd,
160
+ [
161
+ "--project",
162
+ tsconfig,
163
+ "--noEmit",
164
+ "--incremental",
165
+ "false",
166
+ "--composite",
167
+ "false",
168
+ "--listFiles",
169
+ "--explainFiles",
170
+ "false",
171
+ "--pretty",
172
+ ],
173
+ )
174
+ let (diagnostics, programFiles) = run.output->parse(~cwd)
175
+ let report = diagnostics =>
176
+ diagnostics
177
+ ->Array.map(({lines}) => lines->Array.join("\n")->String.trimEnd)
178
+ ->Array.join("\n\n")
179
+ let handlers = files->Array.map(file => NodeJs.Path.relative(cwd, file))
180
+ let isHandler = ({file}) => file->Option.mapOr(false, file => handlers->Array.includes(file))
181
+ let errors = diagnostics->Array.filter(isHandler)
182
+ let stopping =
183
+ diagnostics->Array.filter(diagnostic =>
184
+ !isHandler(diagnostic) && canStopTypeChecking(diagnostic)
185
+ )
186
+ let unchecked = handlers->Array.filter(file => !(programFiles->Utils.Set.has(file)))
187
+ if run.error->Option.isSome && diagnostics->Array.length === 0 {
188
+ Skipped(failure(run))
189
+ } else if errors->Array.length > 0 {
190
+ TypeErrors(report(errors))
191
+ } else if stopping->Array.length > 0 {
192
+ let reason = report(stopping)
193
+ Skipped(
194
+ `Skipped the handler type check: tsc can't check the handlers until these errors are fixed:\n\n${reason}`,
195
+ )
196
+ } else if unchecked->Array.length > 0 {
197
+ let names = unchecked->Array.join(", ")
198
+ let tsconfig = NodeJs.Path.relative(cwd, tsconfig)
199
+ let pronoun = unchecked->Array.length === 1 ? "it" : "them"
200
+ Skipped(
201
+ `Skipped the handler type check: ${tsconfig} doesn't include ${names}. Add ${pronoun} to "include" to type-check ${pronoun} on start.`,
202
+ )
203
+ } else {
204
+ Passed
205
+ }
206
+ }
207
+ }