@playfast/muxmaxing-config 0.1.1 → 0.2.0

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
@@ -13,7 +13,7 @@ host-side in a Bun Worker, only for repos the user has explicitly approved.
13
13
  import { defineConfig } from '@playfast/muxmaxing-config'
14
14
  import { Effect, Schema as S } from 'effect'
15
15
 
16
- const TokenJson = S.parseJson(S.Struct({ key: S.String, slug: S.String }))
16
+ const TokenJson = S.parseJson(S.Struct({ token: S.String, slug: S.String }))
17
17
 
18
18
  export default defineConfig({
19
19
  hostInput: S.Struct({ dopplerToken: S.String, tokenSlug: S.String }),
@@ -25,7 +25,7 @@ export default defineConfig({
25
25
  `doppler configs tokens create mux-${unit.id} --project my-app --config dev --max-age 24h --json`,
26
26
  )
27
27
  const token = yield* S.decode(TokenJson)(minted.stdout)
28
- return { dopplerToken: token.key, tokenSlug: token.slug }
28
+ return { dopplerToken: token.token, tokenSlug: token.slug }
29
29
  }),
30
30
 
31
31
  // Runs when the unit is up: `ctx.unit.bash` execs INSIDE the unit.
package/package.json CHANGED
@@ -1,50 +1,48 @@
1
1
  {
2
2
  "name": "@playfast/muxmaxing-config",
3
- "version": "0.1.1",
4
- "type": "module",
3
+ "version": "0.2.0",
5
4
  "description": "Author API for muxmaxing.config.ts — a repo-committed hook bridge between the host device and muxmaxing work units: typed host-input collection plus Effect-native lifecycle hooks (machine created, new terminal, removed) with bash bridged into the unit.",
6
5
  "keywords": [
7
- "muxmaxing",
6
+ "devcontainer",
8
7
  "effect",
9
8
  "hooks",
10
- "devcontainer",
9
+ "muxmaxing",
11
10
  "provisioning"
12
11
  ],
12
+ "bugs": {
13
+ "url": "https://github.com/playfast/reform/issues"
14
+ },
13
15
  "license": "MIT",
14
16
  "repository": {
15
17
  "type": "git",
16
18
  "url": "https://github.com/playfast/reform.git",
17
19
  "directory": "packages/muxmaxing-config"
18
20
  },
19
- "bugs": {
20
- "url": "https://github.com/playfast/reform/issues"
21
- },
21
+ "files": [
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "type": "module",
22
26
  "sideEffects": false,
23
27
  "exports": {
24
28
  "./package.json": "./package.json",
25
29
  ".": "./src/index.ts",
26
30
  "./*": "./src/*.ts"
27
31
  },
28
- "files": [
29
- "src",
30
- "README.md"
31
- ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
32
35
  "scripts": {
33
36
  "clean": "rm -rf dist .tsbuildinfo",
34
37
  "check": "tsc --noEmit",
35
38
  "build": "tsc -p tsconfig.build.json",
36
- "test": "vitest run",
37
- "test:watch": "vitest",
38
39
  "lint": "oxlint src",
39
40
  "lint:fix": "oxlint --fix src"
40
41
  },
41
- "peerDependencies": {
42
- "effect": "*"
43
- },
44
42
  "devDependencies": {
45
43
  "@types/bun": "^1.3.14"
46
44
  },
47
- "publishConfig": {
48
- "access": "public"
45
+ "peerDependencies": {
46
+ "effect": "*"
49
47
  }
50
48
  }
package/src/index.ts CHANGED
@@ -1,27 +1,12 @@
1
1
  import { type Cause, Data, type Effect, Schema as S } from 'effect'
2
2
 
3
- // The author API for a repo-committed `muxmaxing.config.ts` — a HOOK BRIDGE between the
4
- // host device (the machine running mux) and a work unit (the pod/VM muxmaxing created for
5
- // the repo). The author declares a typed `hostInput` payload, collects it ON THE HOST
6
- // (read host env, exec host CLIs — e.g. mint an ephemeral Doppler token with the user's
7
- // own login), and acts on unit lifecycle moments with it (`ctx.bash` execs IN the unit).
8
- //
9
- // Hooks are Effect-native: each returns an `Effect`, never a Promise. They run host-side
10
- // in a Bun Worker — a THREAD boundary, not a security sandbox — so muxmaxing only runs
11
- // them for repos the user has explicitly approved.
12
-
13
- /** The captured streams + exit code of a bridged bash execution (unit or host). A
14
- * non-zero exit is NOT an error — it's a successful run whose `exitCode` the hook
15
- * interprets; only a transport failure (the command could not RUN) fails the Effect. */
16
3
  export interface BashResult {
17
4
  readonly stdout: string
18
5
  readonly stderr: string
19
6
  readonly exitCode: number
20
7
  }
21
8
 
22
- /** The constructor shape `Data.TaggedError(tag)<A>` produces, named so the generated
23
- * `.d.ts` can describe the `extends` base under `isolatedDeclarations` (which forbids
24
- * an inferred expression in an extends clause) — the reform-db errors pattern. */
9
+ // isolatedDeclarations forbids an inferred expression in an extends clause.
25
10
  type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = new (
26
11
  args: A,
27
12
  ) => Cause.YieldableError & { readonly _tag: Tag } & Readonly<A>
@@ -29,48 +14,33 @@ type TaggedErrorClass<Tag extends string, A extends Record<string, unknown>> = n
29
14
  const HookBashErrorBase: TaggedErrorClass<'HookBashError', { readonly reason: string }> =
30
15
  Data.TaggedError('HookBashError')<{ readonly reason: string }>
31
16
 
32
- /** Transport failure of a bridged bash call — the command could not be executed at all
33
- * (unit unreachable, host spawn failed, bridge torn down). */
34
17
  export class HookBashError extends HookBashErrorBase {
35
- // Surface `reason` wherever the error is rendered (`Cause.pretty` would otherwise
36
- // print the generic "An error has occurred" — the reform-db errors gotcha).
18
+ // Cause.pretty would otherwise print the generic "An error has occurred".
37
19
  override get message(): string {
38
20
  return this.reason
39
21
  }
40
22
  }
41
23
 
42
- /** The host-device side of the bridge: exec on the machine running mux + its env. */
43
24
  export interface HostBridge {
44
- /** Run a bash command ON THE HOST DEVICE. */
45
25
  readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
46
- /** The host process env (hooks share the host process — no sandbox). */
47
26
  readonly env: Readonly<Record<string, string | undefined>>
48
27
  }
49
28
 
50
- /** The work unit a hook invocation is about. */
51
29
  export interface UnitInfo {
52
30
  readonly id: string
53
- /** The unit-side folder the repo is checked out under (bash cwd for unit execs). */
54
31
  readonly workspaceFolder: string
55
32
  }
56
33
 
57
- /** The work-unit side of the bridge: the unit's identity + exec INSIDE it — the mirror
58
- * of `HostBridge`, so hooks read as `ctx.host.bash(...)` / `ctx.unit.bash(...)`. */
59
34
  export interface UnitBridge extends UnitInfo {
60
- /** Run a bash command INSIDE the work unit. */
61
35
  readonly bash: (command: string) => Effect.Effect<BashResult, HookBashError>
62
36
  }
63
37
 
64
- /** Ctx for `collectHostDeviceInput` — host-only (the payload doesn't exist yet). */
65
38
  export interface CollectCtx {
66
39
  readonly host: HostBridge
67
40
  readonly unit: UnitInfo
68
- /** Surface a line in the unit's creation pane. */
69
41
  readonly log: (line: string) => Effect.Effect<void>
70
42
  }
71
43
 
72
- /** Ctx for unit-lifecycle hooks: the decoded `hostInput` + both sides of the bridge
73
- * (`ctx.unit.bash` execs in the unit, `ctx.host.bash` on the host device). */
74
44
  export interface HookCtx<A> {
75
45
  readonly hostInput: A
76
46
  readonly host: HostBridge
@@ -78,8 +48,6 @@ export interface HookCtx<A> {
78
48
  readonly log: (line: string) => Effect.Effect<void>
79
49
  }
80
50
 
81
- /** Ctx for `onWorkUnitRemoved` — the unit may already be gone, so there is no unit
82
- * bash; cleanup acts on the host (e.g. revoke the token minted at collect time). */
83
51
  export interface RemovedCtx<A> {
84
52
  readonly hostInput: A
85
53
  readonly host: HostBridge
@@ -87,36 +55,19 @@ export interface RemovedCtx<A> {
87
55
  readonly log: (line: string) => Effect.Effect<void>
88
56
  }
89
57
 
90
- /** The `muxmaxing.config.ts` contract. `A` is the decoded host-input payload; its
91
- * ENCODED side `I` must be plain JSON — it crosses the worker boundary and is cached
92
- * (memory-only) by the engine between hooks.
93
- *
94
- * `ExternalApi` postfix: this shape is authored by EXTERNAL repos (their
95
- * `muxmaxing.config.ts`), where optional hook fields are the ergonomic contract. */
58
+ // ExternalApi: optional hooks are the ergonomic contract for external repos.
96
59
  export interface MuxmaxingConfigExternalApi<A, I> {
97
- /** The Schema of the payload that crosses host → hooks (encoded side: plain JSON). */
98
60
  readonly hostInput: S.Schema<A, I, never>
99
- /** Runs ON THE HOST when the unit machine comes up; produces the `hostInput` every
100
- * later hook receives. Collected ONCE per unit and cached by the engine. */
101
61
  readonly collectHostDeviceInput: (ctx: CollectCtx) => Effect.Effect<A, unknown>
102
- /** Fired when the unit is up and reachable (after devcontainer lifecycle hooks). */
103
62
  readonly onWorkUnitMachineCreated?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
104
- /** Fired when a terminal opens in the unit. */
105
63
  readonly onNewTerminal?: (ctx: HookCtx<A>) => Effect.Effect<void, unknown>
106
- /** Fired at unit teardown — host-side cleanup (revoke what collect minted). */
107
64
  readonly onWorkUnitRemoved?: (ctx: RemovedCtx<A>) => Effect.Effect<void, unknown>
108
65
  }
109
66
 
110
- /** The public name for the config contract. */
111
67
  export type MuxmaxingConfig<A, I> = MuxmaxingConfigExternalApi<A, I>
112
68
 
113
- /** Identity with types the `defineConfig` a repo's `muxmaxing.config.ts` default-exports. */
114
- export const defineConfig = <A, I>(config: MuxmaxingConfig<A, I>): MuxmaxingConfig<A, I> =>
115
- config
69
+ export const defineConfig = <A, I>(config: MuxmaxingConfig<A, I>): MuxmaxingConfig<A, I> => config
116
70
 
117
- /** Runtime narrowing for the worker entry: the default export of a fetched config module,
118
- * checked structurally (schema + functions). The `hostInput` Schema remains the real
119
- * gate for the DATA; this predicate gates the SHAPE. */
120
71
  export const isMuxmaxingConfig = (
121
72
  candidate: unknown,
122
73
  ): candidate is MuxmaxingConfig<unknown, unknown> => {
package/src/protocol.ts CHANGED
@@ -1,15 +1,6 @@
1
1
  import { Schema as S } from 'effect'
2
2
 
3
- // The postMessage wire protocol between the engine (main thread) and the per-invocation
4
- // hook worker. Every message is encoded before `postMessage` and decoded on receipt via
5
- // these schemas — plain JSON only (structured clone fast path, no class prototypes on
6
- // the wire). One worker serves exactly ONE `RunHook`; `Bash` requests stream back from
7
- // the worker while its hook runs, each answered by a `BashReply`.
8
- //
9
- // Annotated `TaggedStruct` consts (not `S.TaggedClass`) so the declarations survive
10
- // `isolatedDeclarations` — the same reason reform-db's errors annotate their base.
11
-
12
- /** Which hook the worker should run. `collect` produces the hostInput; the rest consume it. */
3
+ // TaggedStruct consts (not TaggedClass) so declarations survive isolatedDeclarations.
13
4
  export const HookName: S.Literal<['collect', 'machineCreated', 'newTerminal', 'removed']> =
14
5
  S.Literal('collect', 'machineCreated', 'newTerminal', 'removed')
15
6
  export type HookName = typeof HookName.Type
@@ -18,10 +9,6 @@ export const UnitRef: S.Struct<{ id: typeof S.String; workspaceFolder: typeof S.
18
9
  S.Struct({ id: S.String, workspaceFolder: S.String })
19
10
  export type UnitRef = typeof UnitRef.Type
20
11
 
21
- // ── engine → worker ───────────────────────────────────────────────────────────
22
-
23
- /** The single invocation a worker serves. `configPath` is the materialised config module
24
- * to import; `hostInput` is the ENCODED payload (`null` for `collect`, which creates it). */
25
12
  export const RunHookRequest: S.TaggedStruct<
26
13
  'RunHook',
27
14
  {
@@ -44,11 +31,12 @@ export const BashOk: S.TaggedStruct<
44
31
  > = S.TaggedStruct('BashOk', { stdout: S.String, stderr: S.String, exitCode: S.Number })
45
32
  export type BashOk = typeof BashOk.Type
46
33
 
47
- export const BashFailed: S.TaggedStruct<'BashFailed', { reason: typeof S.String }> =
48
- S.TaggedStruct('BashFailed', { reason: S.String })
34
+ export const BashFailed: S.TaggedStruct<'BashFailed', { reason: typeof S.String }> = S.TaggedStruct(
35
+ 'BashFailed',
36
+ { reason: S.String },
37
+ )
49
38
  export type BashFailed = typeof BashFailed.Type
50
39
 
51
- /** The engine's answer to one `Bash` request, correlated by `id`. */
52
40
  export const BashReply: S.TaggedStruct<
53
41
  'BashReply',
54
42
  { id: typeof S.Number; result: S.Union<[typeof BashOk, typeof BashFailed]> }
@@ -61,10 +49,6 @@ export const EngineToWorker: S.Union<[typeof RunHookRequest, typeof BashReply]>
61
49
  )
62
50
  export type EngineToWorker = typeof EngineToWorker.Type
63
51
 
64
- // ── worker → engine ───────────────────────────────────────────────────────────
65
-
66
- /** A bridged bash execution request: `unit` execs inside the work unit, `host` on the
67
- * host device. The engine replies with a `BashReply` carrying the same `id`. */
68
52
  export const BashRequest: S.TaggedStruct<
69
53
  'Bash',
70
54
  {
@@ -79,22 +63,19 @@ export const BashRequest: S.TaggedStruct<
79
63
  })
80
64
  export type BashRequest = typeof BashRequest.Type
81
65
 
82
- /** A line for the unit's creation pane (the hook's `ctx.log`). */
83
- export const LogMessage: S.TaggedStruct<'Log', { line: typeof S.String }> = S.TaggedStruct(
84
- 'Log',
85
- { line: S.String },
86
- )
66
+ export const LogMessage: S.TaggedStruct<'Log', { line: typeof S.String }> = S.TaggedStruct('Log', {
67
+ line: S.String,
68
+ })
87
69
  export type LogMessage = typeof LogMessage.Type
88
70
 
89
- /** The hook finished. `hostInput` carries the freshly ENCODED payload for `collect`
90
- * (the engine caches it, memory-only) and is `null` for every other hook. */
91
71
  export const HookDone: S.TaggedStruct<'HookDone', { hostInput: S.NullOr<typeof S.Unknown> }> =
92
72
  S.TaggedStruct('HookDone', { hostInput: S.NullOr(S.Unknown) })
93
73
  export type HookDone = typeof HookDone.Type
94
74
 
95
- /** The hook (or the config module itself) failed best-effort reporting, never retried. */
96
- export const HookFailed: S.TaggedStruct<'HookFailed', { reason: typeof S.String }> =
97
- S.TaggedStruct('HookFailed', { reason: S.String })
75
+ export const HookFailed: S.TaggedStruct<'HookFailed', { reason: typeof S.String }> = S.TaggedStruct(
76
+ 'HookFailed',
77
+ { reason: S.String },
78
+ )
98
79
  export type HookFailed = typeof HookFailed.Type
99
80
 
100
81
  export const WorkerToEngine: S.Union<
@@ -1,4 +1,5 @@
1
1
  import { Cause, Data, Effect, Either, Match, Option, Ref, Schema as S } from 'effect'
2
+ import { parentPort } from 'node:worker_threads'
2
3
  import {
3
4
  type BashReply,
4
5
  BashRequest,
@@ -8,7 +9,7 @@ import {
8
9
  LogMessage,
9
10
  type RunHookRequest,
10
11
  WorkerToEngine,
11
- } from './protocol'
12
+ } from '@playfast/muxmaxing-config/protocol'
12
13
  import {
13
14
  type BashResult,
14
15
  HookBashError,
@@ -16,35 +17,37 @@ import {
16
17
  isMuxmaxingConfig,
17
18
  type MuxmaxingConfig,
18
19
  type UnitInfo,
19
- } from './index'
20
-
21
- // The per-invocation hook worker: serves exactly ONE `RunHook`, bridging the hook's
22
- // `ctx.bash`/`ctx.host.bash` back to the engine over postMessage (the engine owns the
23
- // actual pod-exec / host-spawn). When the hook settles, the worker reports `HookDone`
24
- // (or `HookFailed`), drops its message listener so the event loop drains, and exits;
25
- // the engine's scope finalizer additionally calls `worker.terminate()`.
20
+ } from '@playfast/muxmaxing-config'
26
21
 
27
22
  declare const self: Worker
28
23
 
24
+ const hasWebWorkerScope = typeof self !== 'undefined'
25
+
29
26
  class WorkerHookError extends Data.TaggedError('WorkerHookError')<{
30
27
  readonly reason: string
31
28
  }> {
32
- // Without this getter `Cause.pretty` renders the generic "An error has occurred",
33
- // hiding the actual failure from the HookFailed report (the reform-db errors gotcha).
29
+ // Cause.pretty would otherwise print the generic "An error has occurred".
34
30
  override get message(): string {
35
31
  return this.reason
36
32
  }
37
33
  }
38
34
 
39
35
  const send = (message: WorkerToEngine): void => {
40
- self.postMessage(S.encodeSync(WorkerToEngine)(message))
36
+ const encoded = S.encodeSync(WorkerToEngine)(message)
37
+ if (hasWebWorkerScope) {
38
+ self.postMessage(encoded)
39
+ return
40
+ }
41
+ if (parentPort !== null) {
42
+ parentPort.postMessage(encoded)
43
+ return
44
+ }
45
+ Effect.runSync(Effect.dieMessage('worker entry has no parent message port'))
41
46
  }
42
47
 
43
48
  const describe = (cause: unknown): string =>
44
49
  cause instanceof Error ? cause.message : String(cause)
45
50
 
46
- // One in-flight resolver per bridged bash `id` — the engine answers each `Bash` with a
47
- // correlated `BashReply`. Hooks run their bash calls through `Effect.async` below.
48
51
  const pendingBash = new Map<number, (reply: BashReply) => void>()
49
52
 
50
53
  const makeBridgedBash =
@@ -63,8 +66,7 @@ const makeBridgedBash =
63
66
  stderr: ok.stderr,
64
67
  exitCode: ok.exitCode,
65
68
  }),
66
- BashFailed: (failed) =>
67
- Effect.fail(new HookBashError({ reason: failed.reason })),
69
+ BashFailed: (failed) => Effect.fail(new HookBashError({ reason: failed.reason })),
68
70
  }),
69
71
  Match.exhaustive,
70
72
  ),
@@ -83,18 +85,14 @@ const loadConfig = (
83
85
  ): Effect.Effect<MuxmaxingConfig<unknown, unknown>, WorkerHookError> =>
84
86
  Effect.tryPromise({
85
87
  try: (): Promise<unknown> => import(configPath),
86
- catch: (cause) =>
87
- new WorkerHookError({ reason: `config import failed: ${describe(cause)}` }),
88
+ catch: (cause) => new WorkerHookError({ reason: `config import failed: ${describe(cause)}` }),
88
89
  }).pipe(
89
90
  Effect.flatMap((moduleExports) => {
90
91
  const record: Record<string, unknown> =
91
- typeof moduleExports === 'object' && moduleExports !== null
92
- ? { ...moduleExports }
93
- : {}
92
+ typeof moduleExports === 'object' && moduleExports !== null ? { ...moduleExports } : {}
94
93
  const defaulted = Option.fromNullable(record['default'])
95
94
  return Option.match(defaulted, {
96
- onNone: () =>
97
- Effect.fail(new WorkerHookError({ reason: 'config has no default export' })),
95
+ onNone: () => Effect.fail(new WorkerHookError({ reason: 'config has no default export' })),
98
96
  onSome: (config) => {
99
97
  if (isMuxmaxingConfig(config)) {
100
98
  return Effect.succeed(config)
@@ -125,80 +123,80 @@ const hookFailure =
125
123
  (cause: unknown): WorkerHookError =>
126
124
  new WorkerHookError({ reason: `${hookName} failed: ${describe(cause)}` })
127
125
 
128
- const runRequested = Effect.fn('runRequested')(
129
- function* (request: RunHookRequest): Effect.fn.Return<HookDone, WorkerHookError> {
130
- const config = yield* loadConfig(request.configPath)
131
- const idRef = yield* Ref.make(0)
132
- const unit: UnitInfo = {
133
- id: request.unit.id,
134
- workspaceFolder: request.unit.workspaceFolder,
135
- }
136
- const host: HostBridge = { bash: makeBridgedBash(idRef, 'host'), env: Bun.env }
137
- const unitBridge = { ...unit, bash: makeBridgedBash(idRef, 'unit') }
138
-
139
- // A consuming hook that the config leaves undefined is a silent no-op success.
140
- const runOptional = <Ctx>(
141
- hookName: string,
142
- hook: ((ctx: Ctx) => Effect.Effect<void, unknown>) | undefined,
143
- makeCtx: (hostInput: unknown) => Ctx,
144
- ): Effect.Effect<HookDone, WorkerHookError> =>
145
- Option.match(Option.fromNullable(hook), {
146
- onNone: () => Effect.succeed(HookDone.make({ hostInput: null })),
147
- onSome: (run) =>
148
- decodeHostInput(config, request.hostInput).pipe(
149
- Effect.flatMap((hostInput) =>
150
- run(makeCtx(hostInput)).pipe(Effect.mapError(hookFailure(hookName))),
151
- ),
152
- Effect.map(() => HookDone.make({ hostInput: null })),
126
+ const runRequested = Effect.fn('runRequested')(function* (
127
+ request: RunHookRequest,
128
+ ): Effect.fn.Return<HookDone, WorkerHookError> {
129
+ const config = yield* loadConfig(request.configPath)
130
+ const idRef = yield* Ref.make(0)
131
+ const unit: UnitInfo = {
132
+ id: request.unit.id,
133
+ workspaceFolder: request.unit.workspaceFolder,
134
+ }
135
+ const workerEnv = typeof Bun === 'undefined' ? process.env : Bun.env
136
+ const host: HostBridge = { bash: makeBridgedBash(idRef, 'host'), env: workerEnv }
137
+ const unitBridge = { ...unit, bash: makeBridgedBash(idRef, 'unit') }
138
+
139
+ // Undefined consuming hooks are intentional silent no-ops.
140
+ const runOptional = <Ctx>(
141
+ hookName: string,
142
+ hook: ((ctx: Ctx) => Effect.Effect<void, unknown>) | undefined,
143
+ makeCtx: (hostInput: unknown) => Ctx,
144
+ ): Effect.Effect<HookDone, WorkerHookError> =>
145
+ Option.match(Option.fromNullable(hook), {
146
+ onNone: () => Effect.succeed(HookDone.make({ hostInput: null })),
147
+ onSome: (run) =>
148
+ decodeHostInput(config, request.hostInput).pipe(
149
+ Effect.flatMap((hostInput) =>
150
+ run(makeCtx(hostInput)).pipe(Effect.mapError(hookFailure(hookName))),
153
151
  ),
154
- })
155
-
156
- return yield* Match.value(request.hook).pipe(
157
- Match.when('collect', () =>
158
- config
159
- .collectHostDeviceInput({ host, unit, log })
160
- .pipe(
161
- Effect.mapError(hookFailure('collectHostDeviceInput')),
162
- Effect.flatMap((collected) =>
163
- S.encodeUnknown(config.hostInput)(collected).pipe(
164
- Effect.mapError(
165
- (issue) =>
166
- new WorkerHookError({
167
- reason: `hostInput encode failed: ${String(issue)}`,
168
- }),
169
- ),
170
- ),
152
+ Effect.map(() => HookDone.make({ hostInput: null })),
153
+ ),
154
+ })
155
+
156
+ return yield* Match.value(request.hook).pipe(
157
+ Match.when('collect', () =>
158
+ config.collectHostDeviceInput({ host, unit, log }).pipe(
159
+ Effect.mapError(hookFailure('collectHostDeviceInput')),
160
+ Effect.flatMap((collected) =>
161
+ S.encodeUnknown(config.hostInput)(collected).pipe(
162
+ Effect.mapError(
163
+ (issue) =>
164
+ new WorkerHookError({
165
+ reason: `hostInput encode failed: ${String(issue)}`,
166
+ }),
171
167
  ),
172
- Effect.map((encoded) => HookDone.make({ hostInput: encoded ?? null })),
173
168
  ),
169
+ ),
170
+ Effect.map((encoded) => HookDone.make({ hostInput: encoded ?? null })),
174
171
  ),
175
- Match.when('machineCreated', () =>
176
- runOptional('onWorkUnitMachineCreated', config.onWorkUnitMachineCreated, (hostInput) => ({
177
- hostInput,
178
- host,
179
- unit: unitBridge,
180
- log,
181
- })),
182
- ),
183
- Match.when('newTerminal', () =>
184
- runOptional('onNewTerminal', config.onNewTerminal, (hostInput) => ({
185
- hostInput,
186
- host,
187
- unit: unitBridge,
188
- log,
189
- })),
190
- ),
191
- Match.when('removed', () =>
192
- runOptional('onWorkUnitRemoved', config.onWorkUnitRemoved, (hostInput) => ({
193
- hostInput,
194
- host,
195
- unit,
196
- log,
197
- })),
198
- ),
199
- Match.exhaustive,
200
- )
201
- })
172
+ ),
173
+ Match.when('machineCreated', () =>
174
+ runOptional('onWorkUnitMachineCreated', config.onWorkUnitMachineCreated, (hostInput) => ({
175
+ hostInput,
176
+ host,
177
+ unit: unitBridge,
178
+ log,
179
+ })),
180
+ ),
181
+ Match.when('newTerminal', () =>
182
+ runOptional('onNewTerminal', config.onNewTerminal, (hostInput) => ({
183
+ hostInput,
184
+ host,
185
+ unit: unitBridge,
186
+ log,
187
+ })),
188
+ ),
189
+ Match.when('removed', () =>
190
+ runOptional('onWorkUnitRemoved', config.onWorkUnitRemoved, (hostInput) => ({
191
+ hostInput,
192
+ host,
193
+ unit,
194
+ log,
195
+ })),
196
+ ),
197
+ Match.exhaustive,
198
+ )
199
+ })
202
200
 
203
201
  const handleRunHook = (request: RunHookRequest): void => {
204
202
  void Effect.runPromise(
@@ -207,19 +205,24 @@ const handleRunHook = (request: RunHookRequest): void => {
207
205
  Effect.catchAllCause((cause) =>
208
206
  Effect.sync(() => send(HookFailed.make({ reason: Cause.pretty(cause) }))),
209
207
  ),
210
- // Drop the listener so the event loop drains and the worker exits on its own;
211
- // the engine's scope finalizer also terminates it (belt and suspenders).
208
+ // Drop the listener so the event loop drains and the worker exits.
212
209
  Effect.ensuring(
213
210
  Effect.sync(() => {
214
- self.onmessage = null
211
+ if (hasWebWorkerScope) {
212
+ self.onmessage = null
213
+ return
214
+ }
215
+ if (parentPort !== null) {
216
+ parentPort.close()
217
+ }
215
218
  }),
216
219
  ),
217
220
  ),
218
221
  )
219
222
  }
220
223
 
221
- self.onmessage = (event: MessageEvent): void => {
222
- Either.match(S.decodeUnknownEither(EngineToWorker)(event.data), {
224
+ const handleMessage = (payload: unknown): void => {
225
+ Either.match(S.decodeUnknownEither(EngineToWorker)(payload), {
223
226
  onLeft: () => send(HookFailed.make({ reason: 'undecodable engine message' })),
224
227
  onRight: (message) =>
225
228
  Match.value(message).pipe(
@@ -235,3 +238,13 @@ self.onmessage = (event: MessageEvent): void => {
235
238
  ),
236
239
  })
237
240
  }
241
+
242
+ if (hasWebWorkerScope) {
243
+ self.onmessage = (event: MessageEvent): void => handleMessage(event.data)
244
+ }
245
+ if (!hasWebWorkerScope && parentPort !== null) {
246
+ parentPort.on('message', handleMessage)
247
+ }
248
+ if (!hasWebWorkerScope && parentPort === null) {
249
+ Effect.runSync(Effect.dieMessage('worker entry has no parent message port'))
250
+ }
@@ -1,35 +0,0 @@
1
- import { Effect, Schema as S, String as Str } from 'effect'
2
- import { defineConfig, type MuxmaxingConfig } from '@playfast/muxmaxing-config'
3
-
4
- // The worker-entry test fixture — shaped like a real repo's `muxmaxing.config.ts`
5
- // (imports the package by name, exercises host bash, unit bash, log, and hostInput).
6
- // The explicit annotation is only for THIS package's `isolatedDeclarations` check —
7
- // a real repo's config just default-exports `defineConfig({...})`.
8
-
9
- interface SampleInput {
10
- readonly token: string
11
- readonly slug: string
12
- }
13
-
14
- const config: MuxmaxingConfig<SampleInput, SampleInput> = defineConfig({
15
- hostInput: S.Struct({ token: S.String, slug: S.String }),
16
-
17
- collectHostDeviceInput: ({ host, unit }) =>
18
- Effect.gen(function* () {
19
- const minted = yield* host.bash(`fake-mint ${unit.id}`)
20
- return { token: Str.trim(minted.stdout), slug: `slug-${unit.id}` }
21
- }),
22
-
23
- onWorkUnitMachineCreated: (ctx) =>
24
- Effect.gen(function* () {
25
- yield* ctx.log('configuring')
26
- const configured = yield* ctx.unit.bash(`configure ${ctx.hostInput.token}`)
27
- if (configured.exitCode !== 0) {
28
- return yield* Effect.fail(`configure exited ${configured.exitCode}`)
29
- }
30
- }),
31
-
32
- onWorkUnitRemoved: (ctx) => Effect.asVoid(ctx.host.bash(`revoke ${ctx.hostInput.slug}`)),
33
- })
34
-
35
- export default config
@@ -1,159 +0,0 @@
1
- import { describe, expect, it } from 'vitest'
2
- import { Schema as S } from 'effect'
3
- import { fileURLToPath } from 'node:url'
4
- import {
5
- BashReply,
6
- EngineToWorker,
7
- type HookName,
8
- RunHookRequest,
9
- WorkerToEngine,
10
- } from './protocol'
11
-
12
- // Drives the REAL worker entry end-to-end: spawn a Bun Worker on `worker-entry.ts`,
13
- // point it at the fixture config (which imports the package by name, like a real repo),
14
- // answer its bridged Bash requests with canned results, and assert the final message.
15
- // Needs the Bun runtime (`bun --bun run vitest`) for the `Worker` global.
16
-
17
- const workerUrl = new URL('./worker-entry.ts', import.meta.url)
18
- // fileURLToPath so the SPACE in this repo's path survives (URL.pathname would %20 it).
19
- const fixtureConfigPath = fileURLToPath(new URL('./fixtures/sample.config.ts', import.meta.url))
20
-
21
- const encodeToWorker = S.encodeSync(EngineToWorker)
22
- const decodeFromWorker = S.decodeUnknownSync(WorkerToEngine)
23
-
24
- interface DrivenHook {
25
- /** Every worker→engine message, in arrival order (Bash/Log/HookDone/HookFailed). */
26
- readonly messages: ReadonlyArray<WorkerToEngine>
27
- }
28
-
29
- /** Run ONE hook through a fresh worker, answering each `Bash` via `answerBash`. */
30
- const driveHook = (input: {
31
- readonly hook: HookName
32
- readonly hostInput: unknown
33
- readonly answerBash: (request: { target: string; command: string }) => BashReply['result']
34
- }): Promise<DrivenHook> =>
35
- new Promise((resolve, reject) => {
36
- const worker = new Worker(workerUrl)
37
- const seen: Array<WorkerToEngine> = []
38
- const finish = (outcome: DrivenHook): void => {
39
- worker.terminate()
40
- resolve(outcome)
41
- }
42
- const timer = setTimeout(() => {
43
- worker.terminate()
44
- reject(new Error(`hook ${input.hook} timed out; saw ${JSON.stringify(seen)}`))
45
- }, 15_000)
46
- worker.onmessage = (event: MessageEvent): void => {
47
- const message = decodeFromWorker(event.data)
48
- seen.push(message)
49
- if (message._tag === 'Bash') {
50
- worker.postMessage(
51
- encodeToWorker(
52
- BashReply.make({
53
- id: message.id,
54
- result: input.answerBash({ target: message.target, command: message.command }),
55
- }),
56
- ),
57
- )
58
- return
59
- }
60
- if (message._tag === 'HookDone' || message._tag === 'HookFailed') {
61
- clearTimeout(timer)
62
- finish({ messages: seen })
63
- }
64
- }
65
- worker.postMessage(
66
- encodeToWorker(
67
- RunHookRequest.make({
68
- hook: input.hook,
69
- configPath: fixtureConfigPath,
70
- hostInput: input.hostInput,
71
- unit: { id: 'unit-1', workspaceFolder: '/workspace/repo' },
72
- }),
73
- ),
74
- )
75
- })
76
-
77
- const okBash = (stdout: string): BashReply['result'] => ({
78
- _tag: 'BashOk',
79
- stdout,
80
- stderr: '',
81
- exitCode: 0,
82
- })
83
-
84
- describe('worker-entry', () => {
85
- it('collect: runs collectHostDeviceInput on the host bridge and returns encoded hostInput', async () => {
86
- const driven = await driveHook({
87
- hook: 'collect',
88
- hostInput: null,
89
- answerBash: (request) => {
90
- expect(request.target).toBe('host')
91
- expect(request.command).toBe('fake-mint unit-1')
92
- return okBash('tok-123\n')
93
- },
94
- })
95
- const done = driven.messages.at(-1)
96
- expect(done?._tag).toBe('HookDone')
97
- if (done?._tag === 'HookDone') {
98
- expect(done.hostInput).toEqual({ token: 'tok-123', slug: 'slug-unit-1' })
99
- }
100
- })
101
-
102
- it('machineCreated: decodes hostInput, logs, and bashes into the unit', async () => {
103
- const driven = await driveHook({
104
- hook: 'machineCreated',
105
- hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
106
- answerBash: (request) => {
107
- expect(request.target).toBe('unit')
108
- expect(request.command).toBe('configure tok-123')
109
- return okBash('')
110
- },
111
- })
112
- const tags = driven.messages.map((message) => message._tag)
113
- expect(tags).toEqual(['Log', 'Bash', 'HookDone'])
114
- })
115
-
116
- it('newTerminal: a hook the config leaves undefined is a silent no-op success', async () => {
117
- const driven = await driveHook({
118
- hook: 'newTerminal',
119
- hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
120
- answerBash: () => okBash(''),
121
- })
122
- expect(driven.messages.map((message) => message._tag)).toEqual(['HookDone'])
123
- })
124
-
125
- it('removed: cleanup runs on the HOST bridge with the cached hostInput', async () => {
126
- const driven = await driveHook({
127
- hook: 'removed',
128
- hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
129
- answerBash: (request) => {
130
- expect(request.target).toBe('host')
131
- expect(request.command).toBe('revoke slug-unit-1')
132
- return okBash('')
133
- },
134
- })
135
- expect(driven.messages.at(-1)?._tag).toBe('HookDone')
136
- })
137
-
138
- it('malformed hostInput fails the hook (Schema is the gate), reported as HookFailed', async () => {
139
- const driven = await driveHook({
140
- hook: 'machineCreated',
141
- hostInput: { token: 42 },
142
- answerBash: () => okBash(''),
143
- })
144
- expect(driven.messages.at(-1)?._tag).toBe('HookFailed')
145
- })
146
-
147
- it('a failing unit bash surfaces the hook error as HookFailed', async () => {
148
- const driven = await driveHook({
149
- hook: 'machineCreated',
150
- hostInput: { token: 'tok-123', slug: 'slug-unit-1' },
151
- answerBash: () => ({ _tag: 'BashFailed', reason: 'pod unreachable' }),
152
- })
153
- const failed = driven.messages.at(-1)
154
- expect(failed?._tag).toBe('HookFailed')
155
- if (failed?._tag === 'HookFailed') {
156
- expect(failed.reason).toContain('pod unreachable')
157
- }
158
- })
159
- })