uncontainerizable 0.1.0 → 0.1.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/README.md ADDED
@@ -0,0 +1,226 @@
1
+ # uncontainerizable
2
+
3
+ > Graceful process lifecycle for programs that can't be put in real containers.
4
+
5
+ A supervisor for the apps you can't put in Docker: browsers, GUI apps,
6
+ and anything that needs the user's window server, keychain, or display.
7
+ Built on a pure-Rust core with Node bindings via
8
+ [napi-rs](https://napi.rs); the published binary is prebuilt for every
9
+ supported target.
10
+
11
+ If the program can run in a real sandbox — namespaces, seccomp, landlock
12
+ — use a real container runtime. `uncontainerizable` is for everything else.
13
+
14
+ ## Features
15
+
16
+ - **Staged quit ladder.** Each platform escalates from its polite quit
17
+ channel to a guaranteed kill, with per-stage timeouts and skippable
18
+ stages.
19
+ - **Tree-aware teardown.** Helper processes get reaped alongside the root;
20
+ the container is "empty" only when no member remains.
21
+ - **Identity-based preemption.** Spawning with an `identity` preempts
22
+ earlier matching instances. Linux and Windows are identity-scoped;
23
+ macOS `.app` launches use bundle-scoped preemption on the Launch
24
+ Services path.
25
+ - **Adapter hooks.** Per-app lifecycle callbacks suppress "didn't shut
26
+ down correctly" dialogs after force-kill.
27
+ - **Infallible destroy.** `destroy()` aggregates errors into the result
28
+ so `finally` blocks never throw.
29
+ - **Async first.** `Promise`-based API, Tokio-backed core.
30
+
31
+ ## Platform support
32
+
33
+ | Platform | Preemption primitive | Quit ladder |
34
+ | ----------------- | -------------------- | ------------------------------------------ |
35
+ | Linux (x64/arm64) | cgroup v2 | `SIGTERM` → `SIGKILL` (race-free via freeze) |
36
+ | macOS (x64/arm64) | `argv[0]` tag scan / bundle-exec `ps` scan | `aevt/quit` → `SIGTERM` → `SIGKILL` |
37
+ | Windows (x64/arm64) | named Job Object | `WM_CLOSE` → `TerminateJobObject` |
38
+
39
+ Linux musl is shipped via `cargo-zigbuild`. Identity strings are
40
+ namespaced by an app-level prefix (conventionally reverse-DNS) so
41
+ libraries using `uncontainerizable` cannot collide.
42
+
43
+ On macOS, direct-exec launches use `argv[0]` tag scanning. Launch
44
+ Services `.app` launches instead match by bundle executable path via
45
+ `ps comm=`, so supplying `identity` there kills any running instance of
46
+ that bundle before relaunch, regardless of which identity started it.
47
+ Launch Services therefore does not support keeping two instances of the
48
+ same `.app` alive concurrently through this route. If you need that for
49
+ an app bundle, first make sure the app itself supports concurrent
50
+ instances, then pass the inner executable path
51
+ (`Foo.app/Contents/MacOS/Foo`) so the launch goes through direct-exec
52
+ instead of Launch Services.
53
+
54
+ ## Installation
55
+
56
+ ```sh
57
+ npm install uncontainerizable
58
+ # or
59
+ pnpm add uncontainerizable
60
+ # or
61
+ yarn add uncontainerizable
62
+ ```
63
+
64
+ The package pulls in `@uncontainerizable/native`, which resolves to a
65
+ prebuilt `.node` binary for the current platform. No native toolchain
66
+ is needed at install time.
67
+
68
+ > [!IMPORTANT]
69
+ > Node.js ≥ 24 is required (current LTS). The package ships ESM only.
70
+
71
+ ## Quick start
72
+
73
+ ```ts
74
+ import { App, defaultAdapters } from "uncontainerizable";
75
+
76
+ const app = new App("com.example.my-supervisor");
77
+
78
+ const container = await app.contain("chromium", {
79
+ args: ["--user-data-dir=/tmp/browser-profile"],
80
+ identity: "browser-main", // preempts any previous "browser-main"
81
+ adapters: [...defaultAdapters],
82
+ });
83
+
84
+ // ... later, when you want to shut it down cleanly:
85
+ const result = await container.destroy();
86
+
87
+ if (result.errors.length > 0) {
88
+ console.warn("teardown surfaced recoverable errors:", result.errors);
89
+ }
90
+
91
+ console.log(`exited at ${result.quit.exitedAtStage}`);
92
+ ```
93
+
94
+ A second call to `app.contain(..., { identity: "browser-main" })` will
95
+ kill the running instance before launching the new one. On macOS `.app`
96
+ launches, the same option acts as a bundle-scoped clean-slate switch and
97
+ clears any running instance of that bundle. Omit `identity` to skip
98
+ preemption entirely. If you need concurrent instances of a bundled app on
99
+ macOS, pass the executable inside the bundle rather than the `.app`
100
+ directory, and only do that if the app itself supports multiple
101
+ instances.
102
+
103
+ ## API
104
+
105
+ ### `new App(prefix)`
106
+
107
+ Namespaced handle for spawning contained processes. The `prefix`
108
+ (conventionally reverse-DNS, e.g. `"com.example.my-supervisor"`)
109
+ namespaces identity strings so unrelated libraries can't collide.
110
+
111
+ Throws `INVALID_IDENTITY` if `prefix` contains characters outside
112
+ `[A-Za-z0-9._:-]`.
113
+
114
+ ### `app.contain(command, options?) → Promise<Container>`
115
+
116
+ Spawns a contained process. Options:
117
+
118
+ | Field | Type | Notes |
119
+ | ----------------- | ---------------- | ---------------------------------------------------------------- |
120
+ | `args` | `string[]` | Command-line arguments. |
121
+ | `env` | `Record<…>` | Environment overrides. |
122
+ | `cwd` | `string` | Working directory. |
123
+ | `identity` | `string` | Enables preemption; macOS `.app` launches match by bundle, other routes by identity. Use the inner executable path, not the `.app` path, if the app supports concurrent instances and you need more than one at once. |
124
+ | `adapters` | `Adapter[]` | Per-app lifecycle hooks. |
125
+ | `darwinTagArgv0` | `boolean` | macOS direct-exec only; set `false` if the managed program misreads argv[0] (ignored for `.app` bundle launches). |
126
+
127
+ ### `container.quit(options?) → Promise<QuitResult>`
128
+
129
+ Runs the staged quit ladder without releasing platform resources. Use
130
+ when you want to wait for the process to drain but keep the container
131
+ handle alive.
132
+
133
+ ### `container.destroy(options?) → Promise<DestroyResult>`
134
+
135
+ Runs the quit ladder and releases platform resources. Always resolves:
136
+ recoverable errors appear in `result.errors`, never as a thrown
137
+ exception.
138
+
139
+ ### `coreVersion() → string`
140
+
141
+ Returns the Rust core's version string — handy for logging and support.
142
+
143
+ ## Built-in adapters
144
+
145
+ Adapters match by probe (bundle ID, executable path, platform). Their
146
+ `clearCrashState` hook runs after a terminal-stage teardown to suppress
147
+ restart dialogs.
148
+
149
+ | Adapter | Purpose |
150
+ | --------------- | -------------------------------------------------------------------- |
151
+ | `appkit` | Deletes AppKit's "Saved Application State" directory on macOS. |
152
+ | `crashReporter` | Clears per-app entries from macOS's user-level CrashReporter archive.|
153
+ | `chromium` | Matches Chrome/Chromium/Brave/Edge. Crash-state cleanup is stubbed. |
154
+ | `firefox` | Matches Firefox. Crash-state cleanup is stubbed. |
155
+
156
+ Import individually or as a bundle:
157
+
158
+ ```ts
159
+ import {
160
+ appkit,
161
+ chromium,
162
+ crashReporter,
163
+ defaultAdapters,
164
+ firefox,
165
+ } from "uncontainerizable";
166
+ ```
167
+
168
+ ## Custom adapters
169
+
170
+ An adapter is any object matching the `Adapter` shape. Every hook except
171
+ `name` and `matches` is optional; unimplemented hooks are skipped. Hooks
172
+ may be sync or async — the wrapper normalizes both forms before crossing
173
+ the napi boundary.
174
+
175
+ ```ts
176
+ import type { Adapter } from "uncontainerizable";
177
+
178
+ const logger: Adapter = {
179
+ name: "logger",
180
+ matches: () => true,
181
+ beforeStage(probe, stageName) {
182
+ console.log(`[${probe.pid}] entering stage ${stageName}`);
183
+ },
184
+ afterStage(_probe, result) {
185
+ if (result.exited) {
186
+ console.log(`drained at ${result.stageName}`);
187
+ }
188
+ },
189
+ };
190
+ ```
191
+
192
+ Hook surface:
193
+
194
+ - `beforeQuit(probe)` — before the ladder starts.
195
+ - `beforeStage(probe, stageName)` / `afterStage(probe, result)` — around
196
+ each stage.
197
+ - `afterQuit(probe, result)` — after the ladder ends, terminal or not.
198
+ - `clearCrashState(probe)` — only after a terminal-stage teardown.
199
+
200
+ > [!TIP]
201
+ > Adapter hooks are **advisory**: errors are collected into
202
+ > `QuitResult.adapterErrors` and never abort the quit ladder. A
203
+ > misbehaving adapter cannot prevent teardown.
204
+
205
+ ## TypeScript
206
+
207
+ All public types are exported from the package root:
208
+
209
+ ```ts
210
+ import type {
211
+ Adapter,
212
+ ContainOptions,
213
+ Container,
214
+ DestroyOptions,
215
+ DestroyResult,
216
+ Probe,
217
+ QuitOptions,
218
+ QuitResult,
219
+ StageResult,
220
+ SupportedPlatform,
221
+ } from "uncontainerizable";
222
+ ```
223
+
224
+ ## License
225
+
226
+ [MIT](https://github.com/inakineitor/uncontainerizable/blob/main/LICENSE)
package/dist/index.d.mts CHANGED
@@ -17,10 +17,15 @@ declare class App {
17
17
  constructor(prefix: string);
18
18
  get prefix(): string;
19
19
  /**
20
- * Spawn a contained process. If `options.identity` is set, any previous
21
- * instance with the same (prefix, identity) pair is killed before this
22
- * one launches. If `options.adapters` is non-empty the Rust
23
- * orchestrator drives their lifecycle hooks around the quit ladder.
20
+ * Spawn a contained process. If `options.identity` is set, a prior
21
+ * matching instance is killed before launch. On macOS Launch Services
22
+ * `.app` launches, matching is bundle-scoped rather than
23
+ * `(prefix, identity)`-scoped, so this route cannot keep two instances
24
+ * of the same app alive concurrently. If the app itself supports
25
+ * multiple concurrent instances, pass the inner executable path
26
+ * (`Foo.app/Contents/MacOS/Foo`) to use the direct-exec route instead.
27
+ * If `options.adapters` is non-empty the Rust orchestrator drives their
28
+ * lifecycle hooks around the quit ladder.
24
29
  */
25
30
  contain(command: string, options?: ContainOptions): Promise<Container>;
26
31
  }
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;AAwBA;;;;;;;;;cAAa,GAAA;EAAA;cAGC,MAAA;EAAA,IAIR,MAAA,CAAA;EAUI;;;;;;EAAR,OAAA,CAAQ,OAAA,UAAiB,OAAA,GAAS,cAAA,GAAsB,OAAA,CAAQ,SAAA;AAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;;AAwBA;;;;;;;;;cAAa,GAAA;EAAA;cAGC,MAAA;EAAA,IAIR,MAAA,CAAA;EAeI;;;;;;;;;;;EAAR,OAAA,CAAQ,OAAA,UAAiB,OAAA,GAAS,cAAA,GAAsB,OAAA,CAAQ,SAAA;AAAA"}
package/dist/index.mjs CHANGED
@@ -20,10 +20,15 @@ var App = class {
20
20
  return this.#inner.prefix;
21
21
  }
22
22
  /**
23
- * Spawn a contained process. If `options.identity` is set, any previous
24
- * instance with the same (prefix, identity) pair is killed before this
25
- * one launches. If `options.adapters` is non-empty the Rust
26
- * orchestrator drives their lifecycle hooks around the quit ladder.
23
+ * Spawn a contained process. If `options.identity` is set, a prior
24
+ * matching instance is killed before launch. On macOS Launch Services
25
+ * `.app` launches, matching is bundle-scoped rather than
26
+ * `(prefix, identity)`-scoped, so this route cannot keep two instances
27
+ * of the same app alive concurrently. If the app itself supports
28
+ * multiple concurrent instances, pass the inner executable path
29
+ * (`Foo.app/Contents/MacOS/Foo`) to use the direct-exec route instead.
30
+ * If `options.adapters` is non-empty the Rust orchestrator drives their
31
+ * lifecycle hooks around the quit ladder.
27
32
  */
28
33
  contain(command, options = {}) {
29
34
  const { adapters, ...nativeOpts } = options;
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":["#inner"],"sources":["../src/index.ts"],"sourcesContent":["import { type JsAdapter, NodeApp } from \"@uncontainerizable/native\";\n\nimport type { Adapter, ContainOptions, Container } from \"#/types.js\";\n\nexport { coreVersion } from \"@uncontainerizable/native\";\n\nexport {\n appkit,\n chromium,\n crashReporter,\n defaultAdapters,\n firefox,\n} from \"#/adapters/index.js\";\n\n/**\n * Namespaced handle for spawning contained processes.\n *\n * Construct once per application, typically with a reverse-DNS string like\n * `\"com.example.my-supervisor\"`. The prefix namespaces identity strings so\n * two unrelated libraries using `uncontainerizable` cannot collide.\n *\n * Throws `INVALID_IDENTITY` if the prefix contains characters outside\n * `[A-Za-z0-9._:-]`.\n */\nexport class App {\n readonly #inner: NodeApp;\n\n constructor(prefix: string) {\n this.#inner = new NodeApp(prefix);\n }\n\n get prefix(): string {\n return this.#inner.prefix;\n }\n\n /**\n * Spawn a contained process. If `options.identity` is set, any previous\n * instance with the same (prefix, identity) pair is killed before this\n * one launches. If `options.adapters` is non-empty the Rust\n * orchestrator drives their lifecycle hooks around the quit ladder.\n */\n contain(command: string, options: ContainOptions = {}): Promise<Container> {\n const { adapters, ...nativeOpts } = options;\n return this.#inner.contain(command, {\n ...nativeOpts,\n adapters: adapters?.map(normalizeAdapter),\n });\n }\n}\n\n/**\n * Normalize a user-provided `Adapter` (which may declare sync methods)\n * into the always-async shape the napi bridge expects. Every optional\n * hook is only forwarded if defined, so the Rust side keeps its\n * \"undefined means skip\" semantics.\n */\nfunction normalizeAdapter(adapter: Adapter): JsAdapter {\n return {\n name: adapter.name,\n matches: async (probe) => Boolean(await adapter.matches(probe)),\n beforeQuit: adapter.beforeQuit\n ? async (probe) => {\n await adapter.beforeQuit?.(probe);\n }\n : undefined,\n beforeStage: adapter.beforeStage\n ? async (probe, stageName) => {\n await adapter.beforeStage?.(probe, stageName);\n }\n : undefined,\n afterStage: adapter.afterStage\n ? async (probe, result) => {\n await adapter.afterStage?.(probe, result);\n }\n : undefined,\n afterQuit: adapter.afterQuit\n ? async (probe, result) => {\n await adapter.afterQuit?.(probe, result);\n }\n : undefined,\n clearCrashState: adapter.clearCrashState\n ? async (probe) => {\n await adapter.clearCrashState?.(probe);\n }\n : undefined,\n };\n}\n\nexport type {\n Adapter,\n ContainOptions,\n Container,\n DestroyOptions,\n DestroyResult,\n Probe,\n QuitOptions,\n QuitResult,\n StageResult,\n SupportedPlatform,\n} from \"#/types.js\";\n"],"mappings":";;;;;;;;;;;;;AAwBA,IAAa,MAAb,MAAiB;CACf;CAEA,YAAY,QAAgB;AAC1B,QAAA,QAAc,IAAI,QAAQ,OAAO;;CAGnC,IAAI,SAAiB;AACnB,SAAO,MAAA,MAAY;;;;;;;;CASrB,QAAQ,SAAiB,UAA0B,EAAE,EAAsB;EACzE,MAAM,EAAE,UAAU,GAAG,eAAe;AACpC,SAAO,MAAA,MAAY,QAAQ,SAAS;GAClC,GAAG;GACH,UAAU,UAAU,IAAI,iBAAiB;GAC1C,CAAC;;;;;;;;;AAUN,SAAS,iBAAiB,SAA6B;AACrD,QAAO;EACL,MAAM,QAAQ;EACd,SAAS,OAAO,UAAU,QAAQ,MAAM,QAAQ,QAAQ,MAAM,CAAC;EAC/D,YAAY,QAAQ,aAChB,OAAO,UAAU;AACf,SAAM,QAAQ,aAAa,MAAM;MAEnC,KAAA;EACJ,aAAa,QAAQ,cACjB,OAAO,OAAO,cAAc;AAC1B,SAAM,QAAQ,cAAc,OAAO,UAAU;MAE/C,KAAA;EACJ,YAAY,QAAQ,aAChB,OAAO,OAAO,WAAW;AACvB,SAAM,QAAQ,aAAa,OAAO,OAAO;MAE3C,KAAA;EACJ,WAAW,QAAQ,YACf,OAAO,OAAO,WAAW;AACvB,SAAM,QAAQ,YAAY,OAAO,OAAO;MAE1C,KAAA;EACJ,iBAAiB,QAAQ,kBACrB,OAAO,UAAU;AACf,SAAM,QAAQ,kBAAkB,MAAM;MAExC,KAAA;EACL"}
1
+ {"version":3,"file":"index.mjs","names":["#inner"],"sources":["../src/index.ts"],"sourcesContent":["import { type JsAdapter, NodeApp } from \"@uncontainerizable/native\";\n\nimport type { Adapter, ContainOptions, Container } from \"#/types.js\";\n\nexport { coreVersion } from \"@uncontainerizable/native\";\n\nexport {\n appkit,\n chromium,\n crashReporter,\n defaultAdapters,\n firefox,\n} from \"#/adapters/index.js\";\n\n/**\n * Namespaced handle for spawning contained processes.\n *\n * Construct once per application, typically with a reverse-DNS string like\n * `\"com.example.my-supervisor\"`. The prefix namespaces identity strings so\n * two unrelated libraries using `uncontainerizable` cannot collide.\n *\n * Throws `INVALID_IDENTITY` if the prefix contains characters outside\n * `[A-Za-z0-9._:-]`.\n */\nexport class App {\n readonly #inner: NodeApp;\n\n constructor(prefix: string) {\n this.#inner = new NodeApp(prefix);\n }\n\n get prefix(): string {\n return this.#inner.prefix;\n }\n\n /**\n * Spawn a contained process. If `options.identity` is set, a prior\n * matching instance is killed before launch. On macOS Launch Services\n * `.app` launches, matching is bundle-scoped rather than\n * `(prefix, identity)`-scoped, so this route cannot keep two instances\n * of the same app alive concurrently. If the app itself supports\n * multiple concurrent instances, pass the inner executable path\n * (`Foo.app/Contents/MacOS/Foo`) to use the direct-exec route instead.\n * If `options.adapters` is non-empty the Rust orchestrator drives their\n * lifecycle hooks around the quit ladder.\n */\n contain(command: string, options: ContainOptions = {}): Promise<Container> {\n const { adapters, ...nativeOpts } = options;\n return this.#inner.contain(command, {\n ...nativeOpts,\n adapters: adapters?.map(normalizeAdapter),\n });\n }\n}\n\n/**\n * Normalize a user-provided `Adapter` (which may declare sync methods)\n * into the always-async shape the napi bridge expects. Every optional\n * hook is only forwarded if defined, so the Rust side keeps its\n * \"undefined means skip\" semantics.\n */\nfunction normalizeAdapter(adapter: Adapter): JsAdapter {\n return {\n name: adapter.name,\n matches: async (probe) => Boolean(await adapter.matches(probe)),\n beforeQuit: adapter.beforeQuit\n ? async (probe) => {\n await adapter.beforeQuit?.(probe);\n }\n : undefined,\n beforeStage: adapter.beforeStage\n ? async (probe, stageName) => {\n await adapter.beforeStage?.(probe, stageName);\n }\n : undefined,\n afterStage: adapter.afterStage\n ? async (probe, result) => {\n await adapter.afterStage?.(probe, result);\n }\n : undefined,\n afterQuit: adapter.afterQuit\n ? async (probe, result) => {\n await adapter.afterQuit?.(probe, result);\n }\n : undefined,\n clearCrashState: adapter.clearCrashState\n ? async (probe) => {\n await adapter.clearCrashState?.(probe);\n }\n : undefined,\n };\n}\n\nexport type {\n Adapter,\n ContainOptions,\n Container,\n DestroyOptions,\n DestroyResult,\n Probe,\n QuitOptions,\n QuitResult,\n StageResult,\n SupportedPlatform,\n} from \"#/types.js\";\n"],"mappings":";;;;;;;;;;;;;AAwBA,IAAa,MAAb,MAAiB;CACf;CAEA,YAAY,QAAgB;AAC1B,QAAA,QAAc,IAAI,QAAQ,OAAO;;CAGnC,IAAI,SAAiB;AACnB,SAAO,MAAA,MAAY;;;;;;;;;;;;;CAcrB,QAAQ,SAAiB,UAA0B,EAAE,EAAsB;EACzE,MAAM,EAAE,UAAU,GAAG,eAAe;AACpC,SAAO,MAAA,MAAY,QAAQ,SAAS;GAClC,GAAG;GACH,UAAU,UAAU,IAAI,iBAAiB;GAC1C,CAAC;;;;;;;;;AAUN,SAAS,iBAAiB,SAA6B;AACrD,QAAO;EACL,MAAM,QAAQ;EACd,SAAS,OAAO,UAAU,QAAQ,MAAM,QAAQ,QAAQ,MAAM,CAAC;EAC/D,YAAY,QAAQ,aAChB,OAAO,UAAU;AACf,SAAM,QAAQ,aAAa,MAAM;MAEnC,KAAA;EACJ,aAAa,QAAQ,cACjB,OAAO,OAAO,cAAc;AAC1B,SAAM,QAAQ,cAAc,OAAO,UAAU;MAE/C,KAAA;EACJ,YAAY,QAAQ,aAChB,OAAO,OAAO,WAAW;AACvB,SAAM,QAAQ,aAAa,OAAO,OAAO;MAE3C,KAAA;EACJ,WAAW,QAAQ,YACf,OAAO,OAAO,WAAW;AACvB,SAAM,QAAQ,YAAY,OAAO,OAAO;MAE1C,KAAA;EACJ,iBAAiB,QAAQ,kBACrB,OAAO,UAAU;AACf,SAAM,QAAQ,kBAAkB,MAAM;MAExC,KAAA;EACL"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uncontainerizable",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Graceful process lifecycle for programs that can't be containerized",
5
5
  "keywords": [
6
6
  "lifecycle",
@@ -41,7 +41,7 @@
41
41
  "provenance": true
42
42
  },
43
43
  "dependencies": {
44
- "@uncontainerizable/native": "0.1.0"
44
+ "@uncontainerizable/native": "0.1.2"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^24",