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.
- package/evm.schema.json +8 -1
- package/fuel.schema.json +7 -0
- package/index.d.ts +258 -5
- package/index.js +10 -0
- package/package.json +6 -6
- package/src/Bin.res +48 -0
- package/src/Bin.res.mjs +34 -1
- package/src/Config.res +12 -2
- package/src/Config.res.mjs +7 -0
- package/src/Env.res +141 -1
- package/src/Env.res.mjs +133 -1
- package/src/HandlerLoader.res +7 -13
- package/src/HandlerLoader.res.mjs +7 -14
- package/src/HandlerTypeCheck.res +207 -0
- package/src/HandlerTypeCheck.res.mjs +262 -0
- package/src/bindings/NodeJs.res +31 -0
- package/src/bindings/NodeJs.res.mjs +3 -0
- package/src/resolvers/ResolverProcess.res +805 -0
- package/src/resolvers/ResolverProcess.res.mjs +662 -0
- package/src/resolvers/ResolverQuery.res +313 -0
- package/src/resolvers/ResolverQuery.res.mjs +220 -0
- package/src/resolvers/collisions.js +108 -0
- package/src/resolvers/db.js +363 -0
- package/src/resolvers/dispatch.js +259 -0
- package/src/resolvers/errors.js +30 -0
- package/src/resolvers/graphqlSelection.js +292 -0
- package/src/resolvers/hasuraAction.js +84 -0
- package/src/resolvers/hasuraApply.js +333 -0
- package/src/resolvers/hasuraMetadata.js +137 -0
- package/src/resolvers/index.js +123 -0
- package/src/resolvers/manifest.js +376 -0
- package/src/resolvers/server.js +304 -0
- package/src/sources/EvmRpcClient.res +2 -0
- package/src/sources/RpcSource.res +17 -4
- package/src/sources/RpcSource.res.mjs +23 -5
- package/src/sources/SourceManager.res +63 -40
- package/src/sources/SourceManager.res.mjs +32 -20
- package/src/tui/Tui.res +1 -1
- package/src/tui/Tui.res.mjs +1 -1
- package/svm.schema.json +7 -0
- package/src/HandlerTypeCheck.mjs +0 -14
- package/src/HandlerTypeCheckWorker.mjs +0 -68
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
// What `envio resolvers` runs once the Rust CLI hands over: find the module
|
|
2
|
+
// named by `resolvers:` in config.yaml, import it so its declarations register,
|
|
3
|
+
// then either write the artefacts the image carries or serve them.
|
|
4
|
+
//
|
|
5
|
+
// Declaring is registering, so importing the module is the whole of "loading
|
|
6
|
+
// the resolvers" -- the same way importing a handler module registers its
|
|
7
|
+
// handlers.
|
|
8
|
+
|
|
9
|
+
type manifestBundle = {manifest: JSON.t, sdl: string}
|
|
10
|
+
|
|
11
|
+
@module("./index.js")
|
|
12
|
+
external buildRegisteredManifest: unit => manifestBundle = "buildRegisteredManifest"
|
|
13
|
+
|
|
14
|
+
@module("./index.js")
|
|
15
|
+
external getRegisteredResolvers: unit => array<unknown> = "getRegisteredResolvers"
|
|
16
|
+
|
|
17
|
+
type hasuraMetadataOptions = {handlerUrl: string, actionSecret?: string}
|
|
18
|
+
|
|
19
|
+
@module("./hasuraMetadata.js")
|
|
20
|
+
external buildHasuraMetadata: (JSON.t, hasuraMetadataOptions) => JSON.t = "buildHasuraMetadata"
|
|
21
|
+
|
|
22
|
+
type applyOptions = {endpoint: string, adminSecret: string, metadata: JSON.t}
|
|
23
|
+
type applyResult = {applied: bool, reasons: array<string>}
|
|
24
|
+
|
|
25
|
+
@module("./hasuraApply.js")
|
|
26
|
+
external applyResolverMetadata: applyOptions => promise<applyResult> = "applyResolverMetadata"
|
|
27
|
+
|
|
28
|
+
type reassertOptions = {
|
|
29
|
+
endpoint: string,
|
|
30
|
+
adminSecret: string,
|
|
31
|
+
metadata: JSON.t,
|
|
32
|
+
intervalMs: int,
|
|
33
|
+
onApplied: array<string> => unit,
|
|
34
|
+
onError: exn => unit,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@module("./hasuraApply.js")
|
|
38
|
+
external startMetadataReassert: reassertOptions => unit => unit = "startMetadataReassert"
|
|
39
|
+
|
|
40
|
+
type schemaNames = {entities: array<string>, enums: array<string>}
|
|
41
|
+
|
|
42
|
+
@module("./collisions.js")
|
|
43
|
+
external checkCollisions: (JSON.t, schemaNames) => unit = "checkCollisions"
|
|
44
|
+
|
|
45
|
+
// Only the entities and enums serve builds its schema from, which is what
|
|
46
|
+
// decides whether a custom name is already taken.
|
|
47
|
+
let schemaNamesOf = (config: Config.t) => {
|
|
48
|
+
entities: config->Config.getPgUserEntities->Array.map(entity => entity.name),
|
|
49
|
+
enums: config.allEnums->Array.map(enumConfig => enumConfig.name),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type pool
|
|
53
|
+
|
|
54
|
+
type poolOptions = {
|
|
55
|
+
entities: dict<Internal.entityConfig>,
|
|
56
|
+
pgSchema: string,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@module("./db.js")
|
|
60
|
+
external createResolverPoolFromEnv: poolOptions => pool = "createResolverPoolFromEnv"
|
|
61
|
+
|
|
62
|
+
@send external endPool: pool => promise<unit> = "end"
|
|
63
|
+
|
|
64
|
+
type server = {port: int, close: unit => promise<unit>}
|
|
65
|
+
|
|
66
|
+
type serverOptions = {
|
|
67
|
+
resolvers: array<unknown>,
|
|
68
|
+
pool: pool,
|
|
69
|
+
port?: int,
|
|
70
|
+
exposeErrors?: bool,
|
|
71
|
+
actionSecret?: string,
|
|
72
|
+
privateKeys?: array<string>,
|
|
73
|
+
// `null` means ready. A string is the reason it is not, and reaches the
|
|
74
|
+
// probe's body, so an operator reads it wherever the failed probe surfaces
|
|
75
|
+
// rather than by correlating logs.
|
|
76
|
+
checkCompatible?: unit => promise<Nullable.t<string>>,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
@module("./server.js")
|
|
80
|
+
external startResolverServer: serverOptions => promise<server> = "startResolverServer"
|
|
81
|
+
|
|
82
|
+
@module("./server.js") external readyzProbeMs: int = "READYZ_PROBE_MS"
|
|
83
|
+
|
|
84
|
+
let toImportUrl = (~projectRoot, ~relativePath) =>
|
|
85
|
+
NodeJs.Url.pathToFileURL(
|
|
86
|
+
NodeJs.Path.resolve([projectRoot, relativePath])->NodeJs.Path.toString,
|
|
87
|
+
)->NodeJs.Url.toString
|
|
88
|
+
|
|
89
|
+
type stats
|
|
90
|
+
@module("node:fs") external statSync: string => stats = "statSync"
|
|
91
|
+
@send external isDirectory: stats => bool = "isDirectory"
|
|
92
|
+
|
|
93
|
+
type globOptions = {cwd: string}
|
|
94
|
+
@module("node:fs/promises")
|
|
95
|
+
external globIn: (string, globOptions) => Utils.asyncIterator<string> = "glob"
|
|
96
|
+
|
|
97
|
+
// `resolvers:` may name a directory as well as a file, because the reference
|
|
98
|
+
// implementation keeps its resolvers as `resolvers/<name>/index.ts` -- a
|
|
99
|
+
// parent of subdirectories -- and `handlers:` has auto-loaded exactly that
|
|
100
|
+
// shape since before this feature existed. Same glob, same exclusions, so the
|
|
101
|
+
// two config fields do not need separate explanations.
|
|
102
|
+
let sourceFilesIn = async (~absoluteDir, ~relativePath) => {
|
|
103
|
+
let files = try {
|
|
104
|
+
let iterator = globIn("**/*.{js,mjs,ts}", {cwd: absoluteDir})
|
|
105
|
+
await iterator->Utils.Array.fromAsyncIterator
|
|
106
|
+
} catch {
|
|
107
|
+
| exn =>
|
|
108
|
+
JsError.throwWithMessage(
|
|
109
|
+
`Failed to read the resolvers directory '${relativePath}'. Node 22 or newer is required for directory auto-loading. Cause: ${exn
|
|
110
|
+
->Utils.prettifyExn
|
|
111
|
+
->Obj.magic}`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
files
|
|
115
|
+
// Specs live beside the code they cover in the reference layout, and
|
|
116
|
+
// importing one runs `describe` outside a test runner. Same exclusions as
|
|
117
|
+
// `handlers:`, plus declaration files, which have nothing to execute.
|
|
118
|
+
->Array.filter(file =>
|
|
119
|
+
!(
|
|
120
|
+
file->String.includes(".test.") ||
|
|
121
|
+
file->String.includes(".spec.") ||
|
|
122
|
+
file->String.includes("_test.") ||
|
|
123
|
+
file->String.endsWith(".d.ts")
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
// Sorted, so the manifest and the SDL derived from it are the same on every
|
|
127
|
+
// machine rather than following whatever order the filesystem returns.
|
|
128
|
+
->Array.toSorted(String.compare)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let loadOrThrow = async (~projectRoot, ~relativePath) => {
|
|
132
|
+
let absolute = NodeJs.Path.resolve([projectRoot, relativePath])->NodeJs.Path.toString
|
|
133
|
+
// A missing path deliberately falls through to the single-module import, so
|
|
134
|
+
// the error still names the file and says what Node made of it.
|
|
135
|
+
let isDir = switch statSync(absolute) {
|
|
136
|
+
| stats => stats->isDirectory
|
|
137
|
+
| exception _ => false
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let relativePaths = if isDir {
|
|
141
|
+
let files = await sourceFilesIn(~absoluteDir=absolute, ~relativePath)
|
|
142
|
+
if files->Utils.Array.isEmpty {
|
|
143
|
+
Logging.warn(
|
|
144
|
+
`'${relativePath}' is a directory with no .js, .mjs or .ts files in it, so no resolvers were loaded.`,
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
// Kept relative, so a load failure names the path the user wrote in
|
|
148
|
+
// config.yaml plus the file under it, not an absolute machine path.
|
|
149
|
+
files->Array.map(file => `${relativePath}/${file}`)
|
|
150
|
+
} else {
|
|
151
|
+
[relativePath]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// The resolver module is the user's TypeScript, loaded through the same
|
|
155
|
+
// hooks as the handlers.
|
|
156
|
+
HandlerLoader.registerTsHooks()
|
|
157
|
+
for index in 0 to relativePaths->Array.length - 1 {
|
|
158
|
+
let path = relativePaths->Array.getUnsafe(index)
|
|
159
|
+
switch await Utils.importPath(toImportUrl(~projectRoot, ~relativePath=path)) {
|
|
160
|
+
| _ => ()
|
|
161
|
+
| exception exn =>
|
|
162
|
+
// `anyToExnInternal`, not `prettifyExn`: the latter hands back the raw JS
|
|
163
|
+
// error cast to `exn`, so matching `JsExn(_)` on it never fires and every
|
|
164
|
+
// cause reads "unknown error".
|
|
165
|
+
let cause = switch exn->JsExn.anyToExnInternal {
|
|
166
|
+
| JsExn(e) => e->JsExn.message->Option.getOr("no message")
|
|
167
|
+
| _ => "unknown error"
|
|
168
|
+
}
|
|
169
|
+
JsError.throwWithMessage(
|
|
170
|
+
`Failed to load the resolvers module '${path}' named by \`resolvers:\` in config.yaml. Cause: ${cause}`,
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Emitted whether or not the project declares any, so nothing downstream needs
|
|
177
|
+
// a "file missing" branch. An empty manifest is still a manifest serve can
|
|
178
|
+
// parse -- it registers no custom fields rather than refusing to start.
|
|
179
|
+
let emptyManifest: JSON.t = %raw(`{ schemaVersion: 1, resolvers: [], types: [] }`)
|
|
180
|
+
|
|
181
|
+
let writeManifest = async (~config: Config.t, ~projectRoot) => {
|
|
182
|
+
let {manifest, sdl} = switch config.resolvers {
|
|
183
|
+
| None => {manifest: emptyManifest, sdl: ""}
|
|
184
|
+
| Some(relativePath) =>
|
|
185
|
+
await loadOrThrow(~projectRoot, ~relativePath)
|
|
186
|
+
let bundle = buildRegisteredManifest()
|
|
187
|
+
checkCollisions(bundle.manifest, schemaNamesOf(config))
|
|
188
|
+
bundle
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let dir = NodeJs.Path.resolve([projectRoot, ".envio"])
|
|
192
|
+
await NodeJs.Fs.Promises.mkdir(~path=dir, ~options={recursive: true})
|
|
193
|
+
// Pretty-printed: `envio dev` diffs it, and so do the people reviewing what
|
|
194
|
+
// an image is about to publish.
|
|
195
|
+
await NodeJs.Fs.Promises.writeFile(
|
|
196
|
+
~filepath=dir->NodeJs.Path.join("resolvers.json"),
|
|
197
|
+
~content=JSON.stringify(manifest, ~space=2) ++ "\n",
|
|
198
|
+
)
|
|
199
|
+
await NodeJs.Fs.Promises.writeFile(
|
|
200
|
+
~filepath=dir->NodeJs.Path.join("resolvers.graphql"),
|
|
201
|
+
~content=sdl,
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/// Where Hasura's metadata API lives and what may write to it, or a message
|
|
206
|
+
/// naming what is missing. Shared by `serve` and `migrate` so the two cannot
|
|
207
|
+
/// disagree about what "configured for Hasura" means.
|
|
208
|
+
let hasuraTarget = () =>
|
|
209
|
+
switch (Env.Resolvers.hasuraEndpoint(), Env.Resolvers.hasuraAdminSecret()) {
|
|
210
|
+
| (Some(endpoint), Some(adminSecret)) => Ok((endpoint, adminSecret))
|
|
211
|
+
| (None, _) =>
|
|
212
|
+
Error(
|
|
213
|
+
"HASURA_GRAPHQL_ENDPOINT is not set, so there is no Hasura to update. It is the full metadata URL, ending in /v1/metadata.",
|
|
214
|
+
)
|
|
215
|
+
| (_, None) =>
|
|
216
|
+
Error(
|
|
217
|
+
"HASURA_GRAPHQL_ADMIN_SECRET is not set. Writing metadata is an admin operation, so Hasura will refuse without it.",
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let handlerUrlOrThrow = () =>
|
|
222
|
+
switch Env.Resolvers.publicUrl() {
|
|
223
|
+
| Some(url) => url
|
|
224
|
+
| None =>
|
|
225
|
+
JsError.throwWithMessage(
|
|
226
|
+
"ENVIO_RESOLVERS_PUBLIC_URL is not set. It is the URL Hasura posts to, which is not the address the resolver process binds -- Hasura has no other way to reach the resolvers, and it is baked into every action.",
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/// The Hasura metadata for this project's resolvers, as `envio resolvers
|
|
231
|
+
/// metadata` prints it.
|
|
232
|
+
let metadataJson = async (~config: Config.t, ~projectRoot, ~handlerUrl) => {
|
|
233
|
+
let manifest = switch config.resolvers {
|
|
234
|
+
| None => emptyManifest
|
|
235
|
+
| Some(relativePath) =>
|
|
236
|
+
await loadOrThrow(~projectRoot, ~relativePath)
|
|
237
|
+
let bundle = buildRegisteredManifest()
|
|
238
|
+
checkCollisions(bundle.manifest, schemaNamesOf(config))
|
|
239
|
+
bundle.manifest
|
|
240
|
+
}
|
|
241
|
+
buildHasuraMetadata(manifest, {handlerUrl, actionSecret: ?Env.Resolvers.actionSecret()})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/// `envio resolvers migrate`: brings a running indexer's Hasura metadata in
|
|
245
|
+
/// line with these resolvers, then exits. The same shape as `db-migrate up` --
|
|
246
|
+
/// reconcile and stop -- for the metadata rather than the schema.
|
|
247
|
+
///
|
|
248
|
+
/// Strict where `serve` is forgiving. Serving keeps answering when an apply
|
|
249
|
+
/// fails, because Hasura simply sends it nothing until the metadata lands; a
|
|
250
|
+
/// deploy step that cannot land it has to be a failure the pipeline sees.
|
|
251
|
+
let migrate = async (~config: Config.t, ~projectRoot) => {
|
|
252
|
+
let (endpoint, adminSecret) = switch hasuraTarget() {
|
|
253
|
+
| Ok(target) => target
|
|
254
|
+
| Error(message) => JsError.throwWithMessage(message)
|
|
255
|
+
}
|
|
256
|
+
let handlerUrl = handlerUrlOrThrow()
|
|
257
|
+
let metadata = await metadataJson(~config, ~projectRoot, ~handlerUrl)
|
|
258
|
+
await applyResolverMetadata({endpoint, adminSecret, metadata})
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
@val @scope("process") external onSignal: (string, unit => unit) => unit = "on"
|
|
262
|
+
|
|
263
|
+
// Node closes the server but leaves keep-alive connections to time out on
|
|
264
|
+
// their own, and serve holds those open by design. Bounded so a rolling
|
|
265
|
+
// update can't be held up by an idle socket.
|
|
266
|
+
let closeGracePeriodMs = 5000
|
|
267
|
+
|
|
268
|
+
@module("node:fs") external copyFileSync: (string, string) => unit = "copyFileSync"
|
|
269
|
+
@module("node:fs") external existsSync: string => bool = "existsSync"
|
|
270
|
+
|
|
271
|
+
type spawnOptions = {env: dict<string>, stdio: string, detached: bool, cwd?: string}
|
|
272
|
+
type child
|
|
273
|
+
@module("node:child_process")
|
|
274
|
+
external spawn: (string, array<string>, spawnOptions) => child = "spawn"
|
|
275
|
+
@send external unref: child => unit = "unref"
|
|
276
|
+
@get external childPid: child => option<int> = "pid"
|
|
277
|
+
@send external killChild: (child, string) => bool = "kill"
|
|
278
|
+
@send
|
|
279
|
+
external onChildExit: (child, string, (Nullable.t<int>, Nullable.t<string>) => unit) => unit = "on"
|
|
280
|
+
@val external processEnv: dict<string> = "process.env"
|
|
281
|
+
|
|
282
|
+
@module("node:path") external pathDirname: string => string = "dirname"
|
|
283
|
+
|
|
284
|
+
type httpResponse
|
|
285
|
+
type fetchInit = {method: string}
|
|
286
|
+
@val external fetchUrl: (string, fetchInit) => promise<httpResponse> = "fetch"
|
|
287
|
+
@get external responseOk: httpResponse => bool = "ok"
|
|
288
|
+
|
|
289
|
+
// The resolver process is started by re-invoking this package's own `bin.mjs`,
|
|
290
|
+
// resolved from this module rather than from `process.argv` -- dev is not
|
|
291
|
+
// always what argv names (a test runner isn't), and `node_modules/.bin/envio`
|
|
292
|
+
// is a shim into whichever version is published rather than this one.
|
|
293
|
+
let cliEntry = () =>
|
|
294
|
+
NodeJs.Path.resolve([
|
|
295
|
+
pathDirname(NodeJs.Url.fileURLToPath(NodeJs.ImportMeta.importMeta.url)),
|
|
296
|
+
"..",
|
|
297
|
+
"..",
|
|
298
|
+
"bin.mjs",
|
|
299
|
+
])->NodeJs.Path.toString
|
|
300
|
+
|
|
301
|
+
@val @scope("process") external execPath: string = "execPath"
|
|
302
|
+
|
|
303
|
+
type dbHandle
|
|
304
|
+
type sqlFn
|
|
305
|
+
type resolverRef = {name: string, timeoutMs: int}
|
|
306
|
+
@send external forResolver: (pool, resolverRef) => dbHandle = "forResolver"
|
|
307
|
+
@get external sqlOf: dbHandle => sqlFn = "sql"
|
|
308
|
+
@send
|
|
309
|
+
external unsafe: (sqlFn, string, array<JSON.t>) => promise<array<{"n": int}>> = "unsafe"
|
|
310
|
+
@send
|
|
311
|
+
external readConfigRows: (sqlFn, string, array<JSON.t>) => promise<array<{"config": string}>> =
|
|
312
|
+
"unsafe"
|
|
313
|
+
|
|
314
|
+
// The compat check the indexer runs on resume, run instead against the database
|
|
315
|
+
// a resolver process was pointed at.
|
|
316
|
+
//
|
|
317
|
+
// It reads on every probe rather than once at startup because the mismatch it
|
|
318
|
+
// exists for arrives later: the indexer is redeployed with a new config while
|
|
319
|
+
// this process keeps serving, and readiness is the only thing that can still
|
|
320
|
+
// take it out of rotation.
|
|
321
|
+
let compatibilityOf = (~pool, ~pgSchema, ~envioInfo) =>
|
|
322
|
+
async () => {
|
|
323
|
+
// The probe's bound, not its own: this runs inside the same readiness check
|
|
324
|
+
// and shares its budget.
|
|
325
|
+
let sql = pool->forResolver({name: "readyz", timeoutMs: readyzProbeMs})->sqlOf
|
|
326
|
+
switch await readConfigRows(
|
|
327
|
+
sql,
|
|
328
|
+
`SELECT "config" FROM "${pgSchema}"."${InternalTable.EnvioInfo.table.tableName}" LIMIT 1;`,
|
|
329
|
+
[],
|
|
330
|
+
) {
|
|
331
|
+
| rows =>
|
|
332
|
+
switch rows->Array.get(0) {
|
|
333
|
+
| None =>
|
|
334
|
+
Nullable.make(
|
|
335
|
+
"The database records no config, so it cannot be checked against this build. It was initialized by an envio version that predates the check.",
|
|
336
|
+
)
|
|
337
|
+
| Some(row) =>
|
|
338
|
+
switch Config.diffPaths(~stored=row["config"]->JSON.parseOrThrow, ~current=envioInfo) {
|
|
339
|
+
| [] => Nullable.null
|
|
340
|
+
| changedPaths =>
|
|
341
|
+
Nullable.make(
|
|
342
|
+
`The database was indexed with a different config: ${changedPaths->Array.join(
|
|
343
|
+
", ",
|
|
344
|
+
)}. This build must not answer for it.`,
|
|
345
|
+
)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
| exception exn =>
|
|
349
|
+
InternalTable.isUndefinedTable(exn)
|
|
350
|
+
? Nullable.make(
|
|
351
|
+
"The database has no envio_info table, so it was not initialized by this indexer.",
|
|
352
|
+
)
|
|
353
|
+
: throw(exn)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
type running = {server: server, pool: pool, shutdown: unit => promise<unit>}
|
|
358
|
+
|
|
359
|
+
let serve = async (
|
|
360
|
+
~config: Config.t,
|
|
361
|
+
~projectRoot,
|
|
362
|
+
~port=?,
|
|
363
|
+
~exposeErrors=?,
|
|
364
|
+
~pgSchema=Env.Db.publicSchema,
|
|
365
|
+
~envioInfo=Config.getPublicConfigJson()->Config.toEnvioInfo,
|
|
366
|
+
) => {
|
|
367
|
+
let exposeErrors = exposeErrors->Option.getOr(Env.Resolvers.exposeErrors())
|
|
368
|
+
let relativePath = switch config.resolvers {
|
|
369
|
+
| Some(relativePath) => relativePath
|
|
370
|
+
| None =>
|
|
371
|
+
JsError.throwWithMessage(
|
|
372
|
+
"There is nothing to serve: config.yaml has no `resolvers:` entry. Add one naming the module that declares them, e.g. `resolvers: src/Resolvers.ts`.",
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
await loadOrThrow(~projectRoot, ~relativePath)
|
|
376
|
+
|
|
377
|
+
// Checked here too, not only at manifest time: `envio dev` never writes the
|
|
378
|
+
// artefacts, and a collision it doesn't catch becomes a schema serve refuses.
|
|
379
|
+
let manifest = buildRegisteredManifest().manifest
|
|
380
|
+
checkCollisions(manifest, schemaNamesOf(config))
|
|
381
|
+
|
|
382
|
+
let resolvers = getRegisteredResolvers()
|
|
383
|
+
if resolvers->Utils.Array.isEmpty {
|
|
384
|
+
Logging.warn(
|
|
385
|
+
`'${relativePath}' declared no resolvers, so this process will answer nothing. Every resolver has to be created with createResolver at module scope to register.`,
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Only the Postgres-backed entities: an entity kept elsewhere has no table
|
|
390
|
+
// for a loader to read, and leaving it out is what makes the unknown-entity
|
|
391
|
+
// error name what is actually queryable.
|
|
392
|
+
let entities = Dict.make()
|
|
393
|
+
config->Config.getPgUserEntities->Array.forEach(entity => entities->Dict.set(entity.name, entity))
|
|
394
|
+
|
|
395
|
+
// Read before the socket opens. It is a misconfiguration, not a runtime
|
|
396
|
+
// failure, so it should stop the process before anything can route to it.
|
|
397
|
+
// The interval belongs here for the same reason, and for one more: read
|
|
398
|
+
// after the bind, a malformed one throws from a `serve` that has already
|
|
399
|
+
// opened a port and a pool and returns neither, so nothing can close them.
|
|
400
|
+
let hasuraTargetOrNone = hasuraTarget()
|
|
401
|
+
switch hasuraTargetOrNone {
|
|
402
|
+
| Ok(_) =>
|
|
403
|
+
handlerUrlOrThrow()->ignore
|
|
404
|
+
Env.Resolvers.metadataIntervalMs()->ignore
|
|
405
|
+
| Error(_) => ()
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let pool = createResolverPoolFromEnv({entities, pgSchema})
|
|
409
|
+
// The pool is opened before the socket, and a bind that fails -- the port is
|
|
410
|
+
// already taken -- returns nothing, so no caller can reach `shutdown` to
|
|
411
|
+
// release it later. It has to go back here or not at all.
|
|
412
|
+
let server = try await startResolverServer({
|
|
413
|
+
resolvers,
|
|
414
|
+
pool,
|
|
415
|
+
?port,
|
|
416
|
+
exposeErrors,
|
|
417
|
+
actionSecret: ?Env.Resolvers.actionSecret(),
|
|
418
|
+
privateKeys: Env.Resolvers.privateKeys(),
|
|
419
|
+
checkCompatible: compatibilityOf(~pool, ~pgSchema, ~envioInfo),
|
|
420
|
+
}) catch {
|
|
421
|
+
| exn =>
|
|
422
|
+
try await pool->endPool catch {
|
|
423
|
+
| _ => ()
|
|
424
|
+
}
|
|
425
|
+
throw(exn)
|
|
426
|
+
}
|
|
427
|
+
Logging.info(
|
|
428
|
+
`Serving ${resolvers
|
|
429
|
+
->Array.length
|
|
430
|
+
->Int.toString} custom resolvers on port ${server.port->Int.toString}`,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
// After the socket is listening, never before: an action Hasura knows about
|
|
434
|
+
// is one it will send traffic to.
|
|
435
|
+
let stopReassert = switch hasuraTargetOrNone {
|
|
436
|
+
| Ok((endpoint, adminSecret)) =>
|
|
437
|
+
let handlerUrl = handlerUrlOrThrow()
|
|
438
|
+
let metadata = buildHasuraMetadata(
|
|
439
|
+
manifest,
|
|
440
|
+
{handlerUrl, actionSecret: ?Env.Resolvers.actionSecret()},
|
|
441
|
+
)
|
|
442
|
+
let intervalMs = Env.Resolvers.metadataIntervalMs()
|
|
443
|
+
|
|
444
|
+
// Best effort, deliberately. Replicas rolling out together all find the
|
|
445
|
+
// same empty Hasura and all try to create the actions; the losers are told
|
|
446
|
+
// `already-exists`. That is a race to converge out of, not a reason to
|
|
447
|
+
// exit -- a process that dies here crash-loops through the rollout, while
|
|
448
|
+
// the metadata it wanted is already correct. Serving continues either way:
|
|
449
|
+
// until the metadata lands, Hasura simply sends nothing here.
|
|
450
|
+
switch await applyResolverMetadata({endpoint, adminSecret, metadata}) {
|
|
451
|
+
| {applied, reasons} =>
|
|
452
|
+
Logging.info(
|
|
453
|
+
applied
|
|
454
|
+
? `Registered custom resolvers with Hasura: ${reasons->Array.join("; ")}`
|
|
455
|
+
: "Hasura already publishes these custom resolvers; nothing to register",
|
|
456
|
+
)
|
|
457
|
+
| exception exn =>
|
|
458
|
+
Logging.error(
|
|
459
|
+
`Failed to register the custom resolvers with Hasura, so Hasura will not route to this process yet: ${exn
|
|
460
|
+
->Utils.prettifyExn
|
|
461
|
+
->Obj.magic}. ${intervalMs === 0
|
|
462
|
+
? "ENVIO_RESOLVERS_METADATA_INTERVAL_MS is 0, so this will not be retried."
|
|
463
|
+
: `Retrying every ${intervalMs->Int.toString}ms.`}`,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if intervalMs === 0 {
|
|
468
|
+
() => ()
|
|
469
|
+
} else {
|
|
470
|
+
startMetadataReassert({
|
|
471
|
+
endpoint,
|
|
472
|
+
adminSecret,
|
|
473
|
+
metadata,
|
|
474
|
+
intervalMs,
|
|
475
|
+
// Not defensive: `Hasura.trackDatabase` opens with a wholesale
|
|
476
|
+
// `clear_metadata`, so a re-initialised indexer deletes these actions
|
|
477
|
+
// while this process keeps running.
|
|
478
|
+
onApplied: reasons =>
|
|
479
|
+
Logging.warn(
|
|
480
|
+
`Hasura had lost the custom resolvers and they were restored: ${reasons->Array.join(
|
|
481
|
+
"; ",
|
|
482
|
+
)}`,
|
|
483
|
+
),
|
|
484
|
+
onError: exn =>
|
|
485
|
+
Logging.error(
|
|
486
|
+
`Failed to re-assert the custom resolver metadata: ${exn
|
|
487
|
+
->Utils.prettifyExn
|
|
488
|
+
->Obj.magic}`,
|
|
489
|
+
),
|
|
490
|
+
})
|
|
491
|
+
}
|
|
492
|
+
| Error(message) =>
|
|
493
|
+
Logging.info(
|
|
494
|
+
`${message} Nothing was registered with Hasura; this process still answers /hasura-action and /resolve.`,
|
|
495
|
+
)
|
|
496
|
+
() => ()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let stopped = ref(None)
|
|
500
|
+
let shutdown = () =>
|
|
501
|
+
switch stopped.contents {
|
|
502
|
+
| Some(promise) => promise
|
|
503
|
+
| None =>
|
|
504
|
+
let promise = (
|
|
505
|
+
async () => {
|
|
506
|
+
stopReassert()
|
|
507
|
+
// In-flight requests finish; the grace period only bounds how long
|
|
508
|
+
// an idle connection can delay the exit.
|
|
509
|
+
switch await Promise.race([server.close(), Utils.delay(closeGracePeriodMs)]) {
|
|
510
|
+
| () => ()
|
|
511
|
+
| exception _ => ()
|
|
512
|
+
}
|
|
513
|
+
switch await pool->endPool {
|
|
514
|
+
| () => ()
|
|
515
|
+
| exception _ => ()
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
)()
|
|
519
|
+
stopped := Some(promise)
|
|
520
|
+
promise
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
{server, pool, shutdown}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/// Wires SIGTERM/SIGINT to `shutdown`, so a rolling update drains rather than
|
|
527
|
+
/// severs. Separate from `serve` because a caller embedding it -- a test, or
|
|
528
|
+
/// `envio dev` -- owns the process's signals itself.
|
|
529
|
+
let handleSignals = (running: running) =>
|
|
530
|
+
["SIGTERM", "SIGINT"]->Array.forEach(signal =>
|
|
531
|
+
onSignal(signal, () => {
|
|
532
|
+
Logging.info(`Received ${signal}, draining custom resolvers...`)
|
|
533
|
+
let _ = (
|
|
534
|
+
async () => {
|
|
535
|
+
await running.shutdown()
|
|
536
|
+
NodeJs.process->NodeJs.exitWithCode(Success)
|
|
537
|
+
}
|
|
538
|
+
)()
|
|
539
|
+
})
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
// `envio dev` only. `envio start` never calls it — a deployment runs the
|
|
543
|
+
// resolvers as their own service.
|
|
544
|
+
//
|
|
545
|
+
// Dev spawns `envio resolvers` as a child rather than serving in-process, so
|
|
546
|
+
// local and hosted run the same command over the same HTTP seam, with a pool
|
|
547
|
+
// and a lifetime of their own. It also makes a resolver edit a restart of that
|
|
548
|
+
// process alone: the indexer keeps its place, and a resolver that crashes
|
|
549
|
+
// takes nothing with it.
|
|
550
|
+
//
|
|
551
|
+
// `ENVIO_SERVE_BIN` points at a local envio-serve build and this starts it;
|
|
552
|
+
// without it, everything else still runs and the hint says what is missing.
|
|
553
|
+
type devResolvers = {port: int, pid: option<int>, stop: unit => promise<unit>}
|
|
554
|
+
|
|
555
|
+
// Bounded: a resolver module that throws on import never binds the port, and
|
|
556
|
+
// waiting forever would leave `envio dev` looking hung rather than saying so.
|
|
557
|
+
let healthzTimeoutMs = 20_000
|
|
558
|
+
|
|
559
|
+
let waitForHealthz = async (~port) => {
|
|
560
|
+
let deadline = Date.now() +. healthzTimeoutMs->Int.toFloat
|
|
561
|
+
let rec attempt = async () => {
|
|
562
|
+
let answered = switch await fetchUrl(
|
|
563
|
+
`http://127.0.0.1:${port->Int.toString}/healthz`,
|
|
564
|
+
{method: "GET"},
|
|
565
|
+
) {
|
|
566
|
+
| response => response->responseOk
|
|
567
|
+
| exception _ => false
|
|
568
|
+
}
|
|
569
|
+
if answered {
|
|
570
|
+
true
|
|
571
|
+
} else if Date.now() > deadline {
|
|
572
|
+
false
|
|
573
|
+
} else {
|
|
574
|
+
await Utils.delay(200)
|
|
575
|
+
await attempt()
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
await attempt()
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
let stopChild = (child): promise<unit> =>
|
|
582
|
+
Promise.make((resolve, _) => {
|
|
583
|
+
switch child->childPid {
|
|
584
|
+
| None => resolve()
|
|
585
|
+
| Some(_) =>
|
|
586
|
+
child->onChildExit("exit", (_, _) => resolve())
|
|
587
|
+
let _ = child->killChild("SIGTERM")
|
|
588
|
+
// SIGTERM runs the child's drain; the race only bounds how long an idle
|
|
589
|
+
// connection can hold the exit up.
|
|
590
|
+
let _ = (
|
|
591
|
+
async () => {
|
|
592
|
+
await Utils.delay(closeGracePeriodMs)
|
|
593
|
+
resolve()
|
|
594
|
+
}
|
|
595
|
+
)()
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
let startForDev = async (~config: Config.t, ~projectRoot) => {
|
|
600
|
+
switch config.resolvers {
|
|
601
|
+
| None => None
|
|
602
|
+
// Set when you are running `envio resolvers` and envio-serve yourself —
|
|
603
|
+
// under a debugger, or with env of your own.
|
|
604
|
+
| Some(_) if processEnv->Dict.get("ENVIO_RESOLVERS_EXTERNAL") == Some("true") =>
|
|
605
|
+
Logging.info(
|
|
606
|
+
"ENVIO_RESOLVERS_EXTERNAL=true — not starting resolvers here. Run `envio resolvers` in another terminal, and envio-serve against .envio/serve-project.",
|
|
607
|
+
)
|
|
608
|
+
None
|
|
609
|
+
| Some(_) =>
|
|
610
|
+
let configFile = processEnv->Dict.get("ENVIO_CONFIG")->Option.getOr("config.yaml")
|
|
611
|
+
let port = Env.Resolvers.port()
|
|
612
|
+
|
|
613
|
+
let childEnv = Dict.copy(processEnv)
|
|
614
|
+
childEnv->Dict.set("ENVIO_RESOLVERS_PORT", port->Int.toString)
|
|
615
|
+
// Locally the caller is the person who wrote the resolver, so an
|
|
616
|
+
// unexpected error's own message is what they need to see.
|
|
617
|
+
childEnv->Dict.set("ENVIO_RESOLVERS_EXPOSE_ERRORS", "true")
|
|
618
|
+
childEnv->Dict.set("ENVIO_CONFIG", configFile)
|
|
619
|
+
|
|
620
|
+
// Point the child at the Hasura `envio dev` is already running, so a
|
|
621
|
+
// resolver shows up in the same endpoint as the generated entity fields.
|
|
622
|
+
// Without this, dev serves the resolvers on their own port and the schema
|
|
623
|
+
// a developer actually opens knows nothing about them.
|
|
624
|
+
//
|
|
625
|
+
// Read here rather than from `Env.Hasura`, whose values are fixed when that
|
|
626
|
+
// module loads -- dev sets its own port and secret and this has to follow
|
|
627
|
+
// them.
|
|
628
|
+
if Env.Hasura.enabled {
|
|
629
|
+
let endpoint = switch Env.Resolvers.hasuraEndpoint() {
|
|
630
|
+
| Some(endpoint) => endpoint
|
|
631
|
+
| None =>
|
|
632
|
+
let hasuraPort = processEnv->Dict.get("HASURA_EXTERNAL_PORT")->Option.getOr("8080")
|
|
633
|
+
`http://localhost:${hasuraPort}/v1/metadata`
|
|
634
|
+
}
|
|
635
|
+
childEnv->Dict.set("HASURA_GRAPHQL_ENDPOINT", endpoint)
|
|
636
|
+
childEnv->Dict.set(
|
|
637
|
+
"HASURA_GRAPHQL_ADMIN_SECRET",
|
|
638
|
+
Env.Resolvers.hasuraAdminSecret()->Option.getOr("testing"),
|
|
639
|
+
)
|
|
640
|
+
// Hasura is a container and this process is on the host, so the handler
|
|
641
|
+
// it registers is the host's address rather than the one bound here.
|
|
642
|
+
switch Env.Resolvers.publicUrl() {
|
|
643
|
+
| Some(_) => ()
|
|
644
|
+
| None =>
|
|
645
|
+
childEnv->Dict.set(
|
|
646
|
+
"ENVIO_RESOLVERS_PUBLIC_URL",
|
|
647
|
+
`http://host.docker.internal:${port->Int.toString}/hasura-action`,
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
let resolverChild = spawn(
|
|
653
|
+
execPath,
|
|
654
|
+
[cliEntry(), "resolvers"],
|
|
655
|
+
{env: childEnv, stdio: "inherit", detached: false, cwd: projectRoot},
|
|
656
|
+
)
|
|
657
|
+
// The indexer owns this process's lifetime; a resolver exiting is reported,
|
|
658
|
+
// never fatal.
|
|
659
|
+
resolverChild->onChildExit("exit", (code, signal) =>
|
|
660
|
+
switch (code->Nullable.toOption, signal->Nullable.toOption) {
|
|
661
|
+
| (Some(0), _) => ()
|
|
662
|
+
| (_, Some(s)) => Logging.info(`Custom resolvers stopped (${s}).`)
|
|
663
|
+
| (Some(c), _) =>
|
|
664
|
+
Logging.error(
|
|
665
|
+
`The custom resolvers process exited with code ${c->Int.toString}. The indexer keeps running; fix the resolver and run \`envio dev\` again.`,
|
|
666
|
+
)
|
|
667
|
+
| _ => Logging.info("Custom resolvers stopped.")
|
|
668
|
+
}
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
if !(await waitForHealthz(~port)) {
|
|
672
|
+
Logging.warn(
|
|
673
|
+
`The custom resolvers process did not answer /healthz on port ${port->Int.toString} within ${(healthzTimeoutMs / 1000)
|
|
674
|
+
->Int.toString}s. Its own output above says why.`,
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Rewritten first: the copy below hands serve whatever `.envio/resolvers.json`
|
|
679
|
+
// holds, and that is whatever the last codegen left. A resolver edited since
|
|
680
|
+
// then would be served against a manifest describing the previous shape.
|
|
681
|
+
await writeManifest(~config, ~projectRoot)
|
|
682
|
+
|
|
683
|
+
let serveDir = NodeJs.Path.resolve([projectRoot, ".envio", "serve-project"])
|
|
684
|
+
await NodeJs.Fs.Promises.mkdir(~path=serveDir, ~options={recursive: true})
|
|
685
|
+
let envioDir = NodeJs.Path.resolve([projectRoot, ".envio"])->NodeJs.Path.toString
|
|
686
|
+
let root = NodeJs.Path.resolve([projectRoot])->NodeJs.Path.toString
|
|
687
|
+
[
|
|
688
|
+
(NodeJs.Path.resolve([root, configFile])->NodeJs.Path.toString, "config.yaml"),
|
|
689
|
+
(NodeJs.Path.resolve([root, "schema.graphql"])->NodeJs.Path.toString, "schema.graphql"),
|
|
690
|
+
(`${envioDir}/resolvers.json`, "resolvers.json"),
|
|
691
|
+
]->Array.forEach(((from, to_)) =>
|
|
692
|
+
if existsSync(from) {
|
|
693
|
+
copyFileSync(from, `${serveDir->NodeJs.Path.toString}/${to_}`)
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
let serveChild = ref(None)
|
|
698
|
+
switch processEnv->Dict.get("ENVIO_SERVE_BIN") {
|
|
699
|
+
| Some(bin) if existsSync(bin) =>
|
|
700
|
+
let servePort = processEnv->Dict.get("ENVIO_SERVE_PORT")->Option.getOr("8080")
|
|
701
|
+
let startServe = () => {
|
|
702
|
+
let serveEnv = Dict.copy(processEnv)
|
|
703
|
+
serveEnv->Dict.set("ENVIO_SERVE_RESOLVERS_URL", `http://127.0.0.1:${port->Int.toString}`)
|
|
704
|
+
let child = spawn(
|
|
705
|
+
bin,
|
|
706
|
+
[
|
|
707
|
+
"--directory",
|
|
708
|
+
serveDir->NodeJs.Path.toString,
|
|
709
|
+
"--config",
|
|
710
|
+
"config.yaml",
|
|
711
|
+
"--port",
|
|
712
|
+
servePort,
|
|
713
|
+
],
|
|
714
|
+
{env: serveEnv, stdio: "inherit", detached: false},
|
|
715
|
+
)
|
|
716
|
+
child->unref
|
|
717
|
+
serveChild := Some(child)
|
|
718
|
+
Logging.info(`Custom resolvers ready — GraphQL with custom fields on port ${servePort}`)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Serve refuses to boot against a database with no tables, and the
|
|
722
|
+
// indexer is what creates them — but the indexer only starts once this
|
|
723
|
+
// function returns. So the wait runs detached: awaiting it here would
|
|
724
|
+
// block the very thing it is waiting for.
|
|
725
|
+
let pool = createResolverPoolFromEnv({
|
|
726
|
+
entities: Dict.make(),
|
|
727
|
+
pgSchema: Env.Db.publicSchema,
|
|
728
|
+
})
|
|
729
|
+
let sql = pool->forResolver({name: "dev-wait", timeoutMs: 5000})->sqlOf
|
|
730
|
+
let rec waitForTables = async attempt =>
|
|
731
|
+
if attempt >= 120 {
|
|
732
|
+
Logging.warn("Timed out waiting for the indexer's tables; starting envio-serve anyway.")
|
|
733
|
+
startServe()
|
|
734
|
+
await pool->endPool
|
|
735
|
+
} else {
|
|
736
|
+
let count = switch await sql->unsafe(
|
|
737
|
+
"SELECT count(*)::int AS n FROM information_schema.tables WHERE table_schema = $1;",
|
|
738
|
+
[JSON.Encode.string(Env.Db.publicSchema)],
|
|
739
|
+
) {
|
|
740
|
+
| rows => rows->Array.get(0)->Option.mapOr(0, row => row["n"])
|
|
741
|
+
| exception _ => 0
|
|
742
|
+
}
|
|
743
|
+
if count > 0 {
|
|
744
|
+
startServe()
|
|
745
|
+
await pool->endPool
|
|
746
|
+
} else {
|
|
747
|
+
await Utils.delay(1000)
|
|
748
|
+
await waitForTables(attempt + 1)
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
// Detached on purpose -- it polls in the background while dev carries on
|
|
752
|
+
// -- so its rejection has to be caught here or it is unhandled.
|
|
753
|
+
waitForTables(0)
|
|
754
|
+
->Promise.catch(exn => {
|
|
755
|
+
Logging.warn(
|
|
756
|
+
`Stopped waiting for the resolver tables: ${exn->Utils.prettifyExn->Obj.magic}`,
|
|
757
|
+
)
|
|
758
|
+
Promise.resolve()
|
|
759
|
+
})
|
|
760
|
+
->ignore
|
|
761
|
+
| _ =>
|
|
762
|
+
// Not a warning when Hasura is running: it publishes the resolvers as
|
|
763
|
+
// actions, so they are already in the endpoint a developer opens.
|
|
764
|
+
if Env.Hasura.enabled {
|
|
765
|
+
Logging.info(`Custom resolvers are published by Hasura as actions. Set ENVIO_SERVE_BIN to serve them through envio-serve instead.`)
|
|
766
|
+
} else {
|
|
767
|
+
Logging.warn(
|
|
768
|
+
`Resolvers are declared, but ENVIO_HASURA=false and ENVIO_SERVE_BIN is unset, so nothing publishes them. ` ++
|
|
769
|
+
`Drop ENVIO_HASURA=false to have Hasura serve them as actions, or point ENVIO_SERVE_BIN at an envio-serve binary and run it against ${serveDir->NodeJs.Path.toString}.`,
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
let stop = async () => {
|
|
775
|
+
switch serveChild.contents {
|
|
776
|
+
| Some(child) => await stopChild(child)
|
|
777
|
+
| None => ()
|
|
778
|
+
}
|
|
779
|
+
await stopChild(resolverChild)
|
|
780
|
+
}
|
|
781
|
+
// Ctrl-C reaches the children through the process group, but a bare
|
|
782
|
+
// SIGTERM to `envio dev` does not — without this the resolver process
|
|
783
|
+
// outlives it and the next run cannot bind the port.
|
|
784
|
+
let killChildren = () => {
|
|
785
|
+
switch serveChild.contents {
|
|
786
|
+
| Some(child) => ignore(child->killChild("SIGTERM"))
|
|
787
|
+
| None => ()
|
|
788
|
+
}
|
|
789
|
+
ignore(resolverChild->killChild("SIGTERM"))
|
|
790
|
+
}
|
|
791
|
+
onSignal("exit", killChildren)
|
|
792
|
+
// Registering a signal listener replaces Node's default handler, which is
|
|
793
|
+
// what terminates the process — so having taken the signal, this has to
|
|
794
|
+
// finish the job the default would have done. Nothing else in the indexer
|
|
795
|
+
// handles these.
|
|
796
|
+
["SIGTERM", "SIGINT"]->Array.forEach(signal =>
|
|
797
|
+
onSignal(signal, () => {
|
|
798
|
+
killChildren()
|
|
799
|
+
NodeJs.process->NodeJs.exitWithCode(Success)
|
|
800
|
+
})
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
Some({port, pid: resolverChild->childPid, stop})
|
|
804
|
+
}
|
|
805
|
+
}
|