bare-stow 0.1.7 → 0.1.9

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 CHANGED
@@ -66,8 +66,12 @@ ipc.on('exit', (code) => {
66
66
  ipc.destroy()
67
67
  ```
68
68
 
69
+ The harness imports its runtime by name, `bare-stow/host` along with `bare-sidecar` or `bare-worker` depending on the target, so the package that the harness is written into must depend on both. Relying on another package to bring them in only works while a package manager happens to hoist them, and not at all under an isolated layout such as that of pnpm. `stow()` resolves every import of the harness from `out` before writing anything and throws if one of them can't be found.
70
+
69
71
  For the `bare-sidecar` target, `start()` accepts the options of `bare-sidecar`. The sidecar inherits the standard streams of the host unless `stdio` says otherwise.
70
72
 
73
+ `start()` also accepts `timeout`, the number of milliseconds to wait for the worker to signal ready before rejecting and terminating it, and `grace`, the number of milliseconds a terminating worker gets to run `stop()` and exit before it is killed. `timeout` defaults to waiting indefinitely and `grace` to 5 seconds. The worker holds itself to the same `grace` whenever it shuts down, including when it receives `SIGTERM` or the host goes away without terminating it. On Windows, a sidecar is killed along with its host instead, so `stop()` only runs when the host terminates it.
74
+
71
75
  ## Protocol
72
76
 
73
77
  The harness multiplexes a control channel and a user data channel over the underlying binary duplex stream. The `ipc` returned from `start()` is itself a duplex stream carrying the user data; control frames ride alongside it on the same handle. RPC libraries (such as `bare-rpc`) bind to `ipc` just like a raw stream.
@@ -87,7 +91,7 @@ The `ipc` handle exposes the lifecycle as events and methods on the same duplex:
87
91
 
88
92
  - `ipc.ready`: A promise that resolves once the worker has signaled ready. Rejects if the worker errors before ready.
89
93
  - `ipc.on('exit', code)`: Emitted when the worker signals exit. `code` carries the exit code.
90
- - `ipc.on('error', err)`: Emitted when the worker reports a fault, or when the underlying transport errors. Either way the `ipc` is destroyed.
94
+ - `ipc.on('error', err)`: Emitted when the worker reports a fault, when it exits without saying why, such as through `Bare.exit()`, a signal, or a crash, or when the underlying transport errors. Either way the `ipc` is destroyed. For a worker that exited without saying why, `err.exitCode` and `err.signalCode` carry the reason.
91
95
  - `ipc.on('close')`: Emitted when the underlying transport closes.
92
96
  - `ipc.destroy()`: Standard stream destroy. Sends a `terminate` control frame to the worker and waits for the underlying transport to close.
93
97
  - `ipc.terminate()`: Like `destroy()` but resolves with the worker's exit code.
@@ -114,43 +118,7 @@ const ipc = wrap(stream) // Any duplex byte stream
114
118
  await ipc.ready
115
119
  ```
116
120
 
117
- ## API
118
-
119
- #### `const artifacts = stow(entry, target, out[, opts])`
120
-
121
- Bundle the module graph rooted at `entry` for a `target` runtime and write the resulting artifacts to disk at and alongside `out`. Returns an async generator that yields `{ url }` objects as each artifact is written, allowing callers to observe progress.
122
-
123
- `entry` is a `URL` (or `URL`-coercible string) pointing at the entry module. `target` selects the target runtime, given either as a built-in name (`'bare-sidecar'` or `'bare-worker'`) or as a [target provider](#targets-and-rpc-providers) object. Other targets ship as their own packages; require one and pass it as the provider. The target determines the harness format, bundle extension, host triples, and whether assets and addons are linked into the bundle or offloaded as sibling files. `out` is the output `URL` of the harness; the bundle is written next to it with the target's extension.
124
-
125
- The harness artifacts are yielded first: The harness itself at `out`, followed by a TypeScript declaration (`.d.ts`) alongside it so hosts importing the harness are typed. The bundle is yielded next, then any offloaded assets and native addons when the target supports offloading.
126
-
127
- Options include:
128
-
129
- ```js
130
- opts = {
131
- client,
132
- server,
133
- base,
134
- hosts,
135
- resolveTarget,
136
- resolveRPC
137
- }
138
- ```
139
-
140
- - `client`: The RPC library to wire into the harness as a client, given either as a built-in name (`'bare-rpc'`) or as an [RPC provider](#targets-and-rpc-providers) object.
141
- - `server`: The RPC library to wire into the bundle entry shim as a server, given either as a built-in name (`'bare-rpc'`) or as an [RPC provider](#targets-and-rpc-providers) object.
142
- - `base`: The base `URL` of the module graph, which must contain every module in the bundle. Defaults to the closest directory that does.
143
- - `hosts`: An array of host triples to build for. Must be a subset of the host triples supported by the target; passing a host the target does not support throws. Defaults to all host triples supported by the target.
144
- - `resolveTarget`: A function mapping a target name to a [target provider](#targets-and-rpc-providers) object, called when `target` is a name not in the built-in registry (which knows `'bare-sidecar'` and `'bare-worker'`).
145
- - `resolveRPC`: A function mapping an RPC library name to an [RPC provider](#targets-and-rpc-providers) object, called when `client` or `server` is a name not in the built-in registry (which only knows `'bare-rpc'`).
146
-
147
- Any additional options are forwarded to `bare-pack`. See <https://github.com/holepunchto/bare-pack> for the full set, including `builtins`, `imports`, `defer`, and `resolve`.
148
-
149
- #### TypeScript
150
-
151
- TypeScript modules are supported out of the box: `.ts`, `.mts`, and `.cts` sources are stripped of their type syntax with [`bare-type-stripper`](https://github.com/holepunchto/bare-type-stripper) as they are read, and their extensions are aliased to `.js`, `.mjs`, and `.cjs` respectively for module-type detection. The stripper only erases types, so the module system follows the aliased JavaScript extension. Because stripping is purely lexical, non-erasable constructs (`enum`, `namespace` with a body, parameter properties) throw, and JSX (`.tsx`) is not supported.
152
-
153
- ### Targets and RPC providers
121
+ ## Targets and RPC providers
154
122
 
155
123
  The harness and RPC code generators are decoupled from the bundler: `target`, `client`, and `server` each accept a provider object directly, so generators can live outside `bare-stow`. Passing a name instead resolves it through `resolveTarget` / `resolveRPC`, which default to the built-in registries.
156
124
 
@@ -169,7 +137,7 @@ A target provider describes the runtime profile it owns and generates the harnes
169
137
  target = {
170
138
  name, // The target's name, used in diagnostics.
171
139
  linked, // Whether assets and addons are linked into the bundle.
172
- offload, // Whether (and which) assets and addons are offloaded as sibling files.
140
+ offload, // Whether (and which) assets and addons are offloaded to a `<bundle>.d` directory next to the bundle.
173
141
  format, // The bundle encoding: 'bundle', 'bundle.cjs', 'bundle.mjs', or 'bundle.json'.
174
142
  encoding, // The string encoding for encoded formats, or null for raw bytes.
175
143
  extension, // The bundle file extension, e.g. '.bundle'.
package/index.d.ts CHANGED
@@ -1,63 +1,158 @@
1
1
  import URL from 'bare-url'
2
2
  import { PackOptions } from 'bare-pack'
3
3
 
4
+ /** A single generated source artifact, such as a harness or a type declaration. */
4
5
  interface Artifact {
6
+ /**
7
+ * The file extension the artifact should be written with alongside the primary output, or unset
8
+ * to write it to the primary output path itself.
9
+ */
5
10
  extension?: string
11
+ /** The artifact's source text. */
6
12
  source: string
7
13
  }
8
14
 
9
15
  type TargetName = 'bare-sidecar' | 'bare-worker'
10
16
 
17
+ /**
18
+ * The context passed to a target's `generate()`, describing the bundle to embed and the RPC wiring
19
+ * to splice in.
20
+ */
11
21
  interface TargetContext {
22
+ /** The module specifier the harness uses to load the bundle, relative to the harness itself. */
12
23
  bundleSpecifier: string
24
+ /** The identifier bound to the IPC stream in generated harness code. */
13
25
  ipc: string
26
+ /** The identifier bound to the RPC client/server in generated harness code. */
14
27
  rpc: string
28
+ /** The module system the harness is generated in, `'esm'` or `'cjs'`. */
15
29
  module: 'esm' | 'cjs'
30
+ /**
31
+ * The resolved client RPC wiring to splice into the harness, or `null` when no client RPC was
32
+ * requested.
33
+ */
16
34
  client: RPCClient | null
17
35
  }
18
36
 
37
+ /**
38
+ * A bundling target, such as `bare-sidecar` or `bare-worker`, describing how to package and boot a
39
+ * bundle on a given host runtime.
40
+ */
19
41
  interface Target {
42
+ /** The target's name. */
20
43
  name: string
44
+ /** Whether the bundle links against its dependencies rather than inlining them. */
21
45
  linked: boolean
46
+ /**
47
+ * Whether native addons and/or assets are written out as sibling files instead of being inlined
48
+ * into the bundle.
49
+ */
22
50
  offload: boolean | { addons?: boolean; assets?: boolean }
51
+ /** The encoding format of the written bundle artifact. */
23
52
  format: 'bundle' | 'bundle.cjs' | 'bundle.mjs' | 'bundle.json'
53
+ /**
54
+ * The text encoding used when the bundle format wraps the bundle as a string, or `null` when the
55
+ * bundle is written as raw bytes.
56
+ */
24
57
  encoding: string | null
58
+ /** The file extension used for the target's harness artifact. */
25
59
  extension: string
60
+ /**
61
+ * The module system the harness is generated in, `'esm'` or `'cjs'`. When unset, it follows the
62
+ * output path's extension or nearest `package.json` `type`.
63
+ */
26
64
  module?: 'esm' | 'cjs'
65
+ /** The host triples the target supports. */
27
66
  hosts: string[]
67
+ /**
68
+ * Generate the harness artifacts for `context`.
69
+ * @param context - The target context describing the bundle to embed and the RPC wiring to splice
70
+ * in.
71
+ */
28
72
  generate(context: TargetContext): Artifact[]
29
73
  }
30
74
 
31
75
  type RPCName = 'bare-rpc'
32
76
 
77
+ /** The context passed to an RPC adapter's `generate()`. */
33
78
  interface RPCContext {
79
+ /** The identifier bound to the IPC stream in generated code. */
34
80
  ipc: string
81
+ /** The identifier bound to the RPC client/server in generated code. */
35
82
  rpc: string
83
+ /** The module system the generated code targets, `'esm'` or `'cjs'`. */
36
84
  module: 'esm' | 'cjs'
85
+ /**
86
+ * Whether the generated wiring is for the `'client'` (harness side) or `'server'` (bundle entry
87
+ * shim side).
88
+ */
37
89
  role: 'client' | 'server'
38
90
  }
39
91
 
92
+ /**
93
+ * An RPC library adapter that generates the wiring code spliced into a stowed bundle's harness or
94
+ * entry shim.
95
+ */
40
96
  interface RPC {
97
+ /** The RPC adapter's name. */
41
98
  name: string
99
+ /**
100
+ * Generate the RPC wiring artifacts for `context`.
101
+ * @param context - The RPC context describing the identifiers, module system, and role to
102
+ * generate wiring for.
103
+ */
42
104
  generate(context: RPCContext): Artifact[]
43
105
  }
44
106
 
107
+ /**
108
+ * The resolved client RPC wiring, carrying both the runtime source to splice into the harness and
109
+ * the type declaration to splice into the harness's `.d.ts`.
110
+ */
45
111
  interface RPCClient {
112
+ /** The generated runtime source for the client RPC wiring. */
46
113
  source: string
114
+ /** The generated type declaration for the client RPC wiring. */
47
115
  type: string
48
116
  }
49
117
 
118
+ /** Options for `stow()`. */
50
119
  interface StowOptions extends Omit<PackOptions, 'offload' | 'linked' | 'resolve' | 'aliases'> {
120
+ /** The RPC library (or its name) to wire into the harness. */
51
121
  client?: RPC | RPCName
122
+ /** The RPC library (or its name) to wire into the bundle's entry shim. */
52
123
  server?: RPC | RPCName
124
+ /**
125
+ * Resolve a target name to a `Target`, overriding the default target resolution.
126
+ * @param name - The target name to resolve to a `Target`.
127
+ */
53
128
  resolveTarget?(name: string): Target
129
+ /**
130
+ * Resolve an RPC name to an `RPC`, overriding the default RPC resolution.
131
+ * @param name - The RPC name to resolve to an `RPC`.
132
+ */
54
133
  resolveRPC?(name: string): RPC
55
134
  }
56
135
 
136
+ /** An artifact written by `stow()`, yielded once its file has been written. */
57
137
  interface StowArtifact {
138
+ /** The file URL the artifact was written to. */
58
139
  url: URL
59
140
  }
60
141
 
142
+ /**
143
+ * Bundle the module graph rooted at `entry` for `target`, writing a harness plus bundle to `out`.
144
+ * @param entry - The entry module to bundle, as a `file:` URL or path string.
145
+ * @param target - The bundling target: a `Target` object, or a target name resolved to one (the
146
+ * built-in `bare-sidecar` and `bare-worker`, or a `bare-stow-target-<name>` package).
147
+ * @param out - The path to write the harness to, as a `file:` URL or path string; the bundle is
148
+ * written alongside it.
149
+ * @param opts - Options; see [`StowOptions`](#stowoptions).
150
+ * @returns An async generator that yields each written artifact's `url` as it is produced, starting
151
+ * with the harness, then the bundle, then any offloaded addon or asset files.
152
+ * @throws The `target` argument is missing.
153
+ * @throws The `out` argument is missing.
154
+ * @throws A host in `opts.hosts` is not supported by the resolved target.
155
+ */
61
156
  declare function stow(
62
157
  entry: URL | string,
63
158
  target: Target | TargetName,
package/index.js CHANGED
@@ -4,6 +4,7 @@ const pack = require('bare-pack')
4
4
  const id = require('bare-bundle-id')
5
5
  const strip = require('bare-type-stripper')
6
6
  const { resolve } = require('bare-module-traverse')
7
+ const lex = require('bare-module-lexer')
7
8
  const fs = require('./lib/fs')
8
9
  const shim = require('./lib/shim')
9
10
  const harness = require('./lib/target')
@@ -19,7 +20,7 @@ module.exports = async function* stow(entry, target, out, opts = {}) {
19
20
 
20
21
  const targetName = typeof target === 'string' ? target : t.name
21
22
 
22
- entry = new URL(entry)
23
+ entry = await fs.resolveModule(new URL(entry))
23
24
  out = new URL(out)
24
25
 
25
26
  // The target may pin a module system; otherwise it follows the output path.
@@ -82,12 +83,13 @@ module.exports = async function* stow(entry, target, out, opts = {}) {
82
83
  },
83
84
  readModule,
84
85
  fs.listPrefix,
86
+ fs.resolveModule,
85
87
  writeFile
86
88
  )
87
89
 
88
90
  base = resolveBase(base, bundle)
89
91
 
90
- bundle = rerootOffloaded(bundle.unmount(base), base, out, offloaded)
92
+ bundle = rerootOffloaded(bundle.unmount(base), base, bundleURL, offloaded)
91
93
 
92
94
  bundle.id = id(bundle).toString('hex')
93
95
 
@@ -118,6 +120,10 @@ module.exports = async function* stow(entry, target, out, opts = {}) {
118
120
  client: clientSetup
119
121
  })
120
122
 
123
+ for (const artifact of artifacts) {
124
+ if (!artifact.extension) await assertImportsResolve(artifact.source, out)
125
+ }
126
+
121
127
  // The first artifact is the harness written to `out`; any further artifacts
122
128
  // are siblings named after `out` with the artifact's own extension.
123
129
  for (const artifact of artifacts) {
@@ -130,6 +136,10 @@ module.exports = async function* stow(entry, target, out, opts = {}) {
130
136
  await fs.writeFile(bundleURL, encodeBundle(bundle, t.format, t.encoding ?? 'utf8'))
131
137
  yield { url: bundleURL }
132
138
 
139
+ // Offloaded files are collected in the order their reads complete, so they
140
+ // are sorted to keep the output stable.
141
+ offloaded.sort((a, b) => (a.url.href < b.url.href ? -1 : a.url.href > b.url.href ? 1 : 0))
142
+
133
143
  for (const artifact of offloaded) {
134
144
  await fs.writeFile(artifact.url, artifact.source)
135
145
  yield { url: artifact.url }
@@ -203,20 +213,22 @@ function collectOffloaded(sink) {
203
213
  }
204
214
  }
205
215
 
206
- // Offloaded files are written next to the harness, so their resolutions are
207
- // rewritten to point beside the bundle, along with the directories that hold
208
- // them, as `bare-pack` does for a bundle packed with a base.
209
- function rerootOffloaded(bundle, base, out, offloaded) {
216
+ // Offloaded files are written to a directory named after the bundle rather than
217
+ // next to the harness, as a mirrored `node_modules` beside the harness would
218
+ // shadow the packages it imports. Their resolutions are rewritten to point into
219
+ // that directory, along with the directories that hold them.
220
+ function rerootOffloaded(bundle, base, bundleURL, offloaded) {
210
221
  if (offloaded.length === 0) return bundle
211
222
 
212
- const dir = new URL('./', out)
223
+ const name = path.basename(bundleURL.pathname) + '.d'
224
+ const dir = new URL(name + '/', bundleURL)
213
225
  const rewrites = new Map()
214
226
 
215
227
  for (const file of offloaded) {
216
228
  const relative = offloadedPath(file.url, base)
217
229
 
218
230
  let key = '/' + path.posix.relative(base.pathname, file.url.pathname)
219
- let value = '/../' + relative
231
+ let value = '/../' + name + '/' + relative
220
232
 
221
233
  rewrites.set(key, value)
222
234
 
@@ -242,6 +254,8 @@ function rerootOffloaded(bundle, base, out, offloaded) {
242
254
  }
243
255
 
244
256
  bundle.resolutions = resolutions
257
+ bundle.addons = bundle.addons.map((href) => rewrites.get(href) || href)
258
+ bundle.assets = bundle.assets.map((href) => rewrites.get(href) || href)
245
259
 
246
260
  return bundle
247
261
  }
@@ -274,6 +288,44 @@ function isTerminator(input) {
274
288
  return input === '' || input.endsWith('/') || input.endsWith(':')
275
289
  }
276
290
 
291
+ async function assertImportsResolve(source, out) {
292
+ for (const entry of lex(source).imports) {
293
+ const { specifier } = entry
294
+
295
+ if (specifier.startsWith('.') || specifier.startsWith('/')) continue
296
+
297
+ if (await resolves(entry, out)) continue
298
+
299
+ throw new Error(
300
+ `Harness '${fileURLToPath(out)}' imports '${specifier}', which the package containing it does not depend on`
301
+ )
302
+ }
303
+ }
304
+
305
+ async function resolves(entry, parentURL) {
306
+ const resolver = resolve.bare(entry, parentURL)
307
+
308
+ let next = resolver.next()
309
+
310
+ while (next.done !== true) {
311
+ const value = next.value
312
+
313
+ if (value.package) {
314
+ const source = await fs.readModule(value.package)
315
+
316
+ next = resolver.next(source === null ? null : JSON.parse(source))
317
+ } else {
318
+ const exists = await fs.isFile(value.resolution)
319
+
320
+ if (exists) return true
321
+
322
+ next = resolver.next(false)
323
+ }
324
+ }
325
+
326
+ return false
327
+ }
328
+
277
329
  async function resolveModule(out) {
278
330
  switch (path.extname(out.pathname)) {
279
331
  case '.mjs':
package/lib/fs.js CHANGED
@@ -76,3 +76,7 @@ exports.listPrefix = async function* listPrefix(url) {
76
76
  }
77
77
  }
78
78
  }
79
+
80
+ exports.isFile = isFile
81
+
82
+ exports.resolveModule = require('bare-pack/fs').resolveModule
package/lib/host.d.ts CHANGED
@@ -1,19 +1,64 @@
1
1
  import { Duplex } from 'bare-stream'
2
2
  import { Protocol } from './protocol'
3
3
 
4
+ /** Options for `wrap()`. */
5
+ interface IPCOptions {
6
+ /**
7
+ * Stop the worker once it has ignored a `terminate` control frame for `grace` milliseconds.
8
+ * Defaults to destroying `stream`.
9
+ */
10
+ kill?: () => unknown
11
+ /**
12
+ * The number of milliseconds to wait for the worker to signal ready before rejecting `ready` and
13
+ * terminating the worker, or `null` to wait indefinitely. Defaults to `null`.
14
+ */
15
+ timeout?: number | null
16
+ /**
17
+ * The number of milliseconds to wait for the worker to exit after a `terminate` control frame
18
+ * before calling `kill`. Defaults to `5000`.
19
+ */
20
+ grace?: number
21
+ }
22
+
4
23
  interface IPC extends Protocol {
24
+ /**
25
+ * A promise that resolves once the worker has signaled ready, and rejects if the worker errors,
26
+ * exits, or times out before then. The worker is terminated when it rejects.
27
+ */
5
28
  readonly ready: Promise<void>
29
+ /**
30
+ * Send a `terminate` control frame to the worker and resolve once it exits, killing it if it has
31
+ * not exited within the grace period.
32
+ * @returns The worker's exit `code`, or `undefined` if it closed without signaling one.
33
+ */
6
34
  terminate(): Promise<number | undefined>
35
+ /**
36
+ * Report that the worker has exited, as observed by the transport. If the worker exited without
37
+ * saying why and was not asked to, the handle is destroyed with an error carrying the reason as
38
+ * `exitCode` and `signalCode`.
39
+ * @param code - The exit code of the worker, or `null` if it was killed by a signal.
40
+ * @param signal - The signal that killed the worker, or `null` (default `null`).
41
+ */
42
+ exited(code: number | null, signal?: string | null): void
7
43
  }
8
44
 
9
45
  declare class IPC {
10
- constructor(stream: Duplex)
46
+ /**
47
+ * Wrap the host side of a stowed bundle's transport `stream`.
48
+ * @param stream - The host side of a stowed bundle's transport (any duplex byte stream).
49
+ * @param opts - Options; see [`IPCOptions`](#ipcoptions).
50
+ */
51
+ constructor(stream: Duplex, opts?: IPCOptions)
11
52
  }
12
53
 
13
- declare function wrap(stream: Duplex): IPC
14
-
15
- declare namespace wrap {
16
- export { IPC }
17
- }
54
+ /**
55
+ * Wrap the host side of a stowed bundle's transport `stream`, returning an `IPC` handle with
56
+ * lifecycle helpers layered on top of the `Protocol`.
57
+ * @param stream - The host side of a stowed bundle's transport (any duplex byte stream).
58
+ * @param opts - Options; see [`IPCOptions`](#ipcoptions).
59
+ * @returns An `IPC` handle wrapping `stream`, with the `ready` promise and `terminate()` layered on
60
+ * top of `Protocol`.
61
+ */
62
+ declare function wrap(stream: Duplex, opts?: IPCOptions): IPC
18
63
 
19
- export = wrap
64
+ export { IPC, type IPCOptions, wrap }
package/lib/host.js CHANGED
@@ -1,11 +1,30 @@
1
- const { Protocol } = require('./protocol')
1
+ const { Protocol, DEFAULT_GRACE } = require('./protocol')
2
2
 
3
3
  class IPC extends Protocol {
4
- constructor(stream) {
4
+ constructor(stream, opts = {}) {
5
5
  super(stream)
6
6
 
7
+ const { kill = () => stream.destroy(), timeout = null, grace = DEFAULT_GRACE } = opts
8
+
9
+ this._kill = kill
10
+ this._grace = grace
11
+ this._terminating = null
12
+ this._exitReported = false
13
+
14
+ this.once('exit', () => {
15
+ this._exitReported = true
16
+ })
17
+
18
+ this.send('configure', { grace })
19
+
7
20
  this._ready = new Promise((resolve, reject) => {
21
+ const timer =
22
+ timeout === null
23
+ ? null
24
+ : setTimeout(() => onerror(new Error('Worker timed out before ready')), timeout)
25
+
8
26
  const cleanup = () => {
27
+ clearTimeout(timer)
9
28
  this.off('ready', onready).off('error', onerror).off('exit', onexit).off('close', onclose)
10
29
  }
11
30
 
@@ -30,7 +49,7 @@ class IPC extends Protocol {
30
49
  this.on('ready', onready).on('error', onerror).on('exit', onexit).on('close', onclose)
31
50
  })
32
51
 
33
- this._ready.catch(() => {})
52
+ this._ready.catch(() => this.destroy())
34
53
  }
35
54
 
36
55
  get ready() {
@@ -38,37 +57,58 @@ class IPC extends Protocol {
38
57
  }
39
58
 
40
59
  terminate() {
41
- return new Promise((resolve) => {
42
- const cleanup = () => {
43
- this.off('exit', onexit)
44
- this._stream.off('close', onclose)
45
- }
60
+ if (this._stream.destroyed) return Promise.resolve(undefined)
46
61
 
47
- const onexit = (code) => {
48
- cleanup()
49
- resolve(code)
50
- }
62
+ return new Promise((resolve) => {
63
+ this.once('exit', resolve)
51
64
 
52
- const onclose = () => {
53
- cleanup()
65
+ this._terminate().then(() => {
66
+ this.off('exit', resolve)
54
67
  resolve(undefined)
55
- }
56
-
57
- this.once('exit', onexit).send('terminate')
58
- this._stream.once('close', onclose)
68
+ })
59
69
  })
60
70
  }
61
71
 
72
+ exited(code, signal = null) {
73
+ // The worker either said why it exited or was asked to.
74
+ if (this._exitReported || this._terminating !== null || this.destroying) return
75
+
76
+ const err = new Error(
77
+ signal === null ? `Worker exited with code ${code}` : `Worker was killed by ${signal}`
78
+ )
79
+
80
+ err.exitCode = code
81
+ err.signalCode = signal
82
+
83
+ this.destroy(err)
84
+ }
85
+
62
86
  _destroy(err, cb) {
63
87
  if (this._stream.destroyed) return cb(null)
64
88
 
89
+ this._terminate().then(() => cb(null))
90
+ }
91
+
92
+ _terminate() {
93
+ if (this._terminating !== null) return this._terminating
94
+
95
+ const timer = setTimeout(() => this._kill(), this._grace)
96
+
65
97
  this.send('terminate')
66
- this._stream.once('close', () => cb(null))
98
+
99
+ this._terminating = new Promise((resolve) => {
100
+ this._stream.once('close', () => {
101
+ clearTimeout(timer)
102
+ resolve()
103
+ })
104
+ })
105
+
106
+ return this._terminating
67
107
  }
68
108
  }
69
109
 
70
110
  exports.IPC = IPC
71
111
 
72
- exports.wrap = function wrap(stream) {
73
- return new IPC(stream)
112
+ exports.wrap = function wrap(stream, opts) {
113
+ return new IPC(stream, opts)
74
114
  }
package/lib/protocol.d.ts CHANGED
@@ -1,22 +1,46 @@
1
1
  import { Duplex, DuplexEvents } from 'bare-stream'
2
2
 
3
+ /** The frame type marker identifying a control-channel frame. */
3
4
  declare const CONTROL: number
5
+ /** The frame type marker identifying a user-data-channel frame. */
4
6
  declare const USER: number
7
+ /** The default number of milliseconds a terminating worker gets to stop and exit. */
8
+ declare const DEFAULT_GRACE: number
5
9
 
10
+ /** The events emitted by a `Protocol`, in addition to the standard duplex stream events. */
6
11
  interface ProtocolEvents extends DuplexEvents {
12
+ /** Emitted when the worker signals it is ready. */
7
13
  ready: []
14
+ /** Emitted when a `terminate` control frame is received. */
8
15
  terminate: []
16
+ /** Emitted when the worker signals exit, carrying its exit `code`. */
9
17
  exit: [code: number]
10
18
  }
11
19
 
12
20
  interface Protocol<E extends ProtocolEvents = ProtocolEvents> extends Duplex<E> {
21
+ /**
22
+ * Send a control frame of `type` with an optional JSON-serializable `payload`.
23
+ * @param type - The control frame type, for example `'ready'`, `'exit'`, `'error'`, or
24
+ * `'terminate'`.
25
+ * @param payload - An optional JSON-serializable payload carried with the frame.
26
+ */
13
27
  send(type: string, payload?: object): Promise<void>
14
28
  }
15
29
 
16
30
  declare class Protocol {
31
+ /**
32
+ * Attach a `Protocol` to the given underlying duplex byte `stream`.
33
+ * @param stream - The underlying duplex byte stream to multiplex the control and user-data
34
+ * channels over.
35
+ */
17
36
  constructor(stream: Duplex)
18
37
  }
19
38
 
39
+ /**
40
+ * Attach a `Protocol` to `stream`, an underlying duplex byte stream.
41
+ * @param stream - Any duplex byte stream to attach the protocol to.
42
+ * @returns A `Protocol` multiplexing control and user-data frames over `stream`.
43
+ */
20
44
  declare function attach(stream: Duplex): Protocol
21
45
 
22
- export { type Protocol, ProtocolEvents, attach, CONTROL, USER }
46
+ export { type Protocol, ProtocolEvents, attach, CONTROL, USER, DEFAULT_GRACE }
package/lib/protocol.js CHANGED
@@ -7,6 +7,8 @@ const USER = 0x01
7
7
  exports.CONTROL = CONTROL
8
8
  exports.USER = USER
9
9
 
10
+ exports.DEFAULT_GRACE = 5000
11
+
10
12
  const HEADER_LENGTH = 5
11
13
 
12
14
  class Protocol extends Duplex {
@@ -22,7 +24,7 @@ class Protocol extends Duplex {
22
24
  .on('data', (chunk) => this._ondata(chunk))
23
25
  .on('end', () => this.push(null))
24
26
  .on('close', () => this.destroy())
25
- .on('error', (err) => this.destroy(err))
27
+ .on('error', (err) => this._onerror(err))
26
28
  }
27
29
 
28
30
  send(type, payload) {
@@ -48,6 +50,13 @@ class Protocol extends Duplex {
48
50
  cb(null)
49
51
  }
50
52
 
53
+ _onerror(err) {
54
+ // On Linux, a peer that goes away with data still unread resets the
55
+ // connection rather than ending it, which is no different from it closing.
56
+ if (err.code === 'ECONNRESET') this.destroy()
57
+ else this.destroy(err)
58
+ }
59
+
51
60
  _ondata(chunk) {
52
61
  this._buffer.push(chunk)
53
62
  this._buffered += chunk.byteLength
@@ -0,0 +1,29 @@
1
+ import { Duplex } from 'bare-stream'
2
+ import { Protocol } from './protocol'
3
+
4
+ interface Runtime extends Protocol {
5
+ /**
6
+ * Load the bundle entry and start it with `arg`, signaling ready once its `start()` resolves.
7
+ * @param load - A function importing the bundle entry module.
8
+ * @param arg - The value passed to the entry's `start()`.
9
+ */
10
+ run(load: () => Promise<{ default: Function }>, arg: unknown): Promise<void>
11
+ }
12
+
13
+ declare class Runtime {
14
+ /**
15
+ * Attach the worker side of a stowed bundle's lifecycle to `stream`.
16
+ * @param stream - The worker side of a stowed bundle's transport, typically `Bare.IPC`.
17
+ */
18
+ constructor(stream: Duplex)
19
+ }
20
+
21
+ /**
22
+ * Attach the worker side of a stowed bundle's lifecycle to `stream`, shutting the worker down on
23
+ * `terminate`, `SIGTERM`, runtime exit, uncaught errors, or loss of the host.
24
+ * @param stream - The worker side of a stowed bundle's transport, typically `Bare.IPC`.
25
+ * @returns A `Runtime` multiplexing control and user-data frames over `stream`.
26
+ */
27
+ declare function attach(stream: Duplex): Runtime
28
+
29
+ export { Runtime, attach }
package/lib/runtime.js ADDED
@@ -0,0 +1,92 @@
1
+ const { Protocol, DEFAULT_GRACE } = require('./protocol')
2
+
3
+ class Runtime extends Protocol {
4
+ constructor(stream) {
5
+ super(stream)
6
+
7
+ this._stop = null
8
+ this._grace = DEFAULT_GRACE
9
+ this._readied = false
10
+ this._exitCode = null
11
+ this._connected = true
12
+
13
+ const ondisconnect = () => this._ondisconnect()
14
+
15
+ this.on('configure', ({ grace }) => {
16
+ this._grace = grace
17
+ }).on('terminate', () => this._shutdown(0))
18
+
19
+ stream.on('end', ondisconnect).on('close', ondisconnect)
20
+
21
+ Bare.on('beforeExit', (code) => this._shutdown(code))
22
+ .on('uncaughtException', (err) => this._shutdown(1, err))
23
+ .on('unhandledRejection', (err) => this._shutdown(1, err))
24
+
25
+ // Signals are delivered to the whole process, so a worker thread must leave
26
+ // them to the host.
27
+ if (Bare.Thread.isMainThread) {
28
+ const Signal = require('bare-signals')
29
+
30
+ new Signal('SIGTERM')
31
+ .on('signal', () => this._onsigterm())
32
+ .start()
33
+ .unref()
34
+ }
35
+ }
36
+
37
+ async run(load, arg) {
38
+ try {
39
+ const entry = await load()
40
+
41
+ this._stop = await entry.default(arg, () => this._ready())
42
+
43
+ this._ready()
44
+ } catch (err) {
45
+ this._shutdown(1, err)
46
+ }
47
+ }
48
+
49
+ _ready() {
50
+ if (this._readied) return
51
+ this._readied = true
52
+
53
+ this.send('ready')
54
+ }
55
+
56
+ async _shutdown(code, err = null) {
57
+ if (this._exitCode !== null) return
58
+ this._exitCode = code
59
+
60
+ setTimeout(() => Bare.exit(code), this._grace).unref()
61
+
62
+ try {
63
+ if (this._stop) await this._stop()
64
+ } catch (e) {
65
+ err = err || e
66
+ }
67
+
68
+ if (this._connected) {
69
+ if (err) this.send('error', { message: err.message, stack: err.stack })
70
+
71
+ await this.send('exit', { code })
72
+ }
73
+
74
+ Bare.exit(code)
75
+ }
76
+
77
+ _ondisconnect() {
78
+ this._connected = false
79
+ this._shutdown(0)
80
+ }
81
+
82
+ _onsigterm() {
83
+ if (this._exitCode !== null) Bare.exit(this._exitCode)
84
+ else this._shutdown(0)
85
+ }
86
+ }
87
+
88
+ exports.Runtime = Runtime
89
+
90
+ exports.attach = function attach(stream) {
91
+ return new Runtime(stream)
92
+ }
package/lib/shim.js CHANGED
@@ -9,60 +9,14 @@ module.exports = exports = function shim(entry, shimURL, opts = {}) {
9
9
 
10
10
  const specifier = relativeSpecifier(shimURL, entry)
11
11
 
12
- const setup = server
13
-
14
- const arg = setup ? '{ rpc, ipc }' : 'ipc'
12
+ const arg = server ? '{ rpc, ipc }' : 'ipc'
15
13
 
16
14
  return `\
17
- import protocol from 'bare-stow/protocol'
18
-
19
- const ipc = protocol.attach(Bare.IPC)
20
- ${setup ? '\n' + setup : ''}
21
- let stop = null
22
- let readied = false
23
- let exited = false
24
-
25
- function ready() {
26
- if (readied) return
27
- readied = true
28
-
29
- ipc.send('ready')
30
- }
31
-
32
- async function shutdown(code, err = null) {
33
- if (exited) return
34
- exited = true
35
-
36
- try {
37
- if (stop) await stop()
38
- } catch (e) {
39
- err = err || e
40
- }
41
-
42
- if (err) ipc.send('error', { message: err.message, stack: err.stack })
15
+ import runtime from 'bare-stow/runtime'
43
16
 
44
- await ipc.send('exit', { code })
45
-
46
- Bare.exit(code)
47
- }
48
-
49
- ipc.on('terminate', () => shutdown(0))
50
-
51
- Bare
52
- .on('beforeExit', (exitCode) => shutdown(exitCode))
53
- .on('uncaughtException', (err) => shutdown(1, err))
54
- .on('unhandledRejection', (err) => shutdown(1, err))
55
-
56
- try {
57
- const entry = await import(${JSON.stringify(specifier)})
58
- const start = entry.default
59
-
60
- stop = await start(${arg}, ready) || null
61
-
62
- ready()
63
- } catch (err) {
64
- shutdown(1, err)
65
- }
17
+ const ipc = runtime.attach(Bare.IPC)
18
+ ${server ? '\n' + server : ''}
19
+ ipc.run(() => import(${JSON.stringify(specifier)}), ${arg})
66
20
  `
67
21
  }
68
22
 
@@ -28,14 +28,15 @@ exports.generate = function generate(opts) {
28
28
  ? `\
29
29
  import Sidecar from 'bare-sidecar'
30
30
  import stow from 'bare-stow/host'
31
- import { fileURLToPath } from 'url'
32
31
 
33
- const bundle = fileURLToPath(new URL(${JSON.stringify(bundleSpecifier)}, import.meta.url))
32
+ const bundle = new URL(import.meta.resolve(${JSON.stringify(bundleSpecifier)}))
34
33
 
35
- export async function start(opts = {}) {
34
+ export async function start({ timeout, grace, ...opts } = {}) {
36
35
  const sidecar = new Sidecar(bundle, { stdio: 'inherit', ...opts })
37
36
 
38
- const ${ipc} = stow.wrap(sidecar)
37
+ const ${ipc} = stow.wrap(sidecar, { kill: () => sidecar.kill('SIGKILL'), timeout, grace })
38
+
39
+ sidecar.on('exit', (code, signal) => ${ipc}.exited(code, signal))
39
40
  ${client ? '\n' + indent(client.source, 2) : ''}
40
41
  await ${ipc}.ready
41
42
 
@@ -43,17 +44,18 @@ ${client ? '\n' + indent(client.source, 2) : ''}
43
44
  }
44
45
  `
45
46
  : `\
46
- const path = require('path')
47
47
  const Sidecar = require('bare-sidecar')
48
48
  const stow = require('bare-stow/host')
49
49
 
50
- const bundle = path.join(__dirname, ${JSON.stringify(bundleSpecifier)})
50
+ const bundle = require.resolve(${JSON.stringify(bundleSpecifier)})
51
51
 
52
52
  module.exports = {
53
- async start(opts = {}) {
53
+ async start({ timeout, grace, ...opts } = {}) {
54
54
  const sidecar = new Sidecar(bundle, { stdio: 'inherit', ...opts })
55
55
 
56
- const ${ipc} = stow.wrap(sidecar)
56
+ const ${ipc} = stow.wrap(sidecar, { kill: () => sidecar.kill('SIGKILL'), timeout, grace })
57
+
58
+ sidecar.on('exit', (code, signal) => ${ipc}.exited(code, signal))
57
59
  ${client ? '\n' + indent(client.source, 4) : ''}
58
60
  await ${ipc}.ready
59
61
 
@@ -67,7 +69,9 @@ ${client ? '\n' + indent(client.source, 4) : ''}
67
69
  {
68
70
  extension: '.d.ts',
69
71
  source: `\
70
- export function start(opts?: import('bare-sidecar').SidecarOptions): Promise<${handle}>
72
+ export function start(
73
+ opts?: import('bare-sidecar').SidecarOptions & Omit<import('bare-stow/host').IPCOptions, 'kill'>
74
+ ): Promise<${handle}>
71
75
  `
72
76
  }
73
77
  ]
@@ -28,14 +28,18 @@ exports.generate = function generate(opts) {
28
28
  ? `\
29
29
  import Worker from 'bare-worker'
30
30
  import stow from 'bare-stow/host'
31
- import { fileURLToPath } from 'bare-url'
32
31
 
33
- const bundle = fileURLToPath(new URL(${JSON.stringify(bundleSpecifier)}, import.meta.url))
32
+ const bundle = import.meta.resolve(${JSON.stringify(bundleSpecifier)})
34
33
 
35
- export async function start(opts = {}) {
34
+ export async function start({ timeout, grace, ...opts } = {}) {
36
35
  const worker = new Worker(bundle, opts)
37
36
 
38
- const ${ipc} = stow.wrap(worker.IPC)
37
+ const ${ipc} = stow.wrap(worker.IPC, { kill: () => worker.terminate(), timeout, grace })
38
+
39
+ // The IPC stream of a worker ends, but never closes, when its thread exits.
40
+ worker.IPC.on('end', () => worker.IPC.destroy())
41
+
42
+ worker.on('exit', (code) => ${ipc}.exited(code))
39
43
  ${client ? '\n' + indent(client.source, 2) : ''}
40
44
  await ${ipc}.ready
41
45
 
@@ -43,17 +47,21 @@ ${client ? '\n' + indent(client.source, 2) : ''}
43
47
  }
44
48
  `
45
49
  : `\
46
- const path = require('bare-path')
47
50
  const Worker = require('bare-worker')
48
51
  const stow = require('bare-stow/host')
49
52
 
50
- const bundle = path.join(__dirname, ${JSON.stringify(bundleSpecifier)})
53
+ const bundle = require.resolve(${JSON.stringify(bundleSpecifier)})
51
54
 
52
55
  module.exports = {
53
- async start(opts = {}) {
56
+ async start({ timeout, grace, ...opts } = {}) {
54
57
  const worker = new Worker(bundle, opts)
55
58
 
56
- const ${ipc} = stow.wrap(worker.IPC)
59
+ const ${ipc} = stow.wrap(worker.IPC, { kill: () => worker.terminate(), timeout, grace })
60
+
61
+ // The IPC stream of a worker ends, but never closes, when its thread exits.
62
+ worker.IPC.on('end', () => worker.IPC.destroy())
63
+
64
+ worker.on('exit', (code) => ${ipc}.exited(code))
57
65
  ${client ? '\n' + indent(client.source, 4) : ''}
58
66
  await ${ipc}.ready
59
67
 
@@ -67,7 +75,9 @@ ${client ? '\n' + indent(client.source, 4) : ''}
67
75
  {
68
76
  extension: '.d.ts',
69
77
  source: `\
70
- export function start(opts?: import('bare-worker').WorkerOptions): Promise<${handle}>
78
+ export function start(
79
+ opts?: import('bare-worker').WorkerOptions & Omit<import('bare-stow/host').IPCOptions, 'kill'>
80
+ ): Promise<${handle}>
71
81
  `
72
82
  }
73
83
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bare-stow",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Module bundling and harness generation for Bare",
5
5
  "exports": {
6
6
  "./package": "./package.json",
@@ -15,6 +15,10 @@
15
15
  "./host": {
16
16
  "types": "./lib/host.d.ts",
17
17
  "default": "./lib/host.js"
18
+ },
19
+ "./runtime": {
20
+ "types": "./lib/runtime.d.ts",
21
+ "default": "./lib/runtime.js"
18
22
  }
19
23
  },
20
24
  "imports": {
@@ -68,15 +72,17 @@
68
72
  "bare-fs": "^4.2.1",
69
73
  "bare-module-lexer": "^1.6.0",
70
74
  "bare-module-traverse": "^2.2.0",
71
- "bare-pack": "^2.2.0",
75
+ "bare-pack": "^3.0.0",
72
76
  "bare-path": "^3.0.0",
77
+ "bare-signals": "^5.0.0",
73
78
  "bare-stream": "^2.13.1",
74
79
  "bare-type-stripper": "^0.1.2",
75
80
  "paparam": "^1.5.0"
76
81
  },
77
82
  "devDependencies": {
83
+ "bare-os": "^3.0.0",
78
84
  "bare-rpc": "^1.3.2",
79
- "bare-sidecar": "^0.5.8",
85
+ "bare-sidecar": "^0.5.11",
80
86
  "bare-worker": "^4.3.0",
81
87
  "brittle": "^4.0.2",
82
88
  "lunte": "^1.7.0",