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 +226 -0
- package/dist/index.d.mts +9 -4
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +9 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
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,
|
|
21
|
-
* instance
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
}
|
package/dist/index.d.mts.map
CHANGED
|
@@ -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;
|
|
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,
|
|
24
|
-
* instance
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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;
|
package/dist/index.mjs.map
CHANGED
|
@@ -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,
|
|
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.
|
|
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.
|
|
44
|
+
"@uncontainerizable/native": "0.1.2"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
47
|
"@types/node": "^24",
|