diff --git a/.changes/deprioritize-initial-store-updates-in-foundation.md b/.changes/deprioritize-initial-store-updates-in-foundation.md new file mode 100644 index 00000000..3aaf5ff9 --- /dev/null +++ b/.changes/deprioritize-initial-store-updates-in-foundation.md @@ -0,0 +1,5 @@ +--- +"@simulacrum/foundation-simulator": patch:bug +--- + +Order initial log dispatching to help avoid the race condition made more prevalent by more direct sync vs async handling in effection v4. diff --git a/.vscode/settings.json b/.vscode/settings.json index 8f7c6ef4..56929129 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,4 @@ { - "typescript.tsdk": "node_modules/typescript/lib", - "oxc.useExecPath": true, - "oxc.fmt.configPath": ".oxfmtrc.json", "editor.defaultFormatter": "oxc.oxc-vscode", "editor.formatOnSave": false, // disable default behavior "editor.codeActionsOnSave": { diff --git a/package.json b/package.json index ec9acba3..060e3613 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,8 @@ "covector": "pnpm dlx covector", "fmt": "oxfmt --write .", "fmt:check": "oxfmt --check .", - "lint": "pnpm -r --if-present run lint", - "lint:fix": "pnpm -r exec oxlint --fix", + "lint": "oxlint", + "lint:fix": "oxlint --fix", "prepack": "pnpm -r --if-present run prepack", "test": "pnpm -r --if-present run test", "tsc": "pnpm -r --if-present run tsc" diff --git a/packages/foundation/src/index.ts b/packages/foundation/src/index.ts index 9ab4e012..659534d7 100644 --- a/packages/foundation/src/index.ts +++ b/packages/foundation/src/index.ts @@ -42,7 +42,7 @@ import { apiProxy } from "./middleware/proxy.ts"; import { delayMiddleware } from "./middleware/delay.ts"; import { generateRoutesHTML } from "./routeTemplate.ts"; import { createAppServer } from "./server.ts"; -import type { IdProp } from "starfx"; +import { sleep, type AnyState, type IdProp } from "starfx"; // for use in the OpenAPI handler functions type SimulationHandlerFunctions = ( @@ -170,6 +170,7 @@ export function createFoundationSimulationServer< app.use(express.urlencoded({ extended: false })); let simulationStore = createSimulationStore(extendStore); + const simulationRoutes = [] as ((s: AnyState) => void)[]; app.use(delayMiddleware(delayResponses)); app.use((req, _res, next) => { @@ -193,7 +194,6 @@ export function createFoundationSimulationServer< .map((stack) => stack.route) .filter((route): route is NonNullable => Boolean(route)); - const simulationRoutes = []; for (let layer of layers) { for (let stack of layer.stack) { simulationRoutes.push( @@ -210,8 +210,6 @@ export function createFoundationSimulationServer< ); } } - - simulationStore.store.dispatch(simulationStore.actions.batchUpdater(simulationRoutes)); } } @@ -224,7 +222,6 @@ export function createFoundationSimulationServer< .sync(); if (jsonFiles.length > 0) { - const simulationRoutes = []; for (let jsonFile of jsonFiles) { const route = `/${jsonFile.slice(0, jsonFile.length - 5)}`; const filename = path.join(serveJsonFiles, jsonFile); @@ -246,8 +243,6 @@ export function createFoundationSimulationServer< }), ); } - - simulationStore.store.dispatch(simulationStore.actions.batchUpdater(simulationRoutes)); } } @@ -324,11 +319,11 @@ export function createFoundationSimulationServer< }, }); - // initalize the backend + // initialize the backend api.init().then((init) => { const router = init.router; const operations = router.getOperations(); - const simulationRoutes = operations.reduce( + const oasSimulationRoutes = operations.reduce( (routes, operation) => { const url = `${router.apiRoot === "/" ? "" : router.apiRoot}${operation.path}`; routes[`${operation.method}:${url}`] = { @@ -343,11 +338,7 @@ export function createFoundationSimulationServer< }, {} as Record, ); - simulationStore.store.dispatch( - simulationStore.actions.batchUpdater([ - simulationStore.schema.simulationRoutes.add(simulationRoutes), - ]), - ); + simulationRoutes.push(simulationStore.schema.simulationRoutes.add(oasSimulationRoutes)); return init; }); app.use((req, res, next) => { @@ -393,7 +384,20 @@ export function createFoundationSimulationServer< // if no extendRouter routes or openapi routes handle this, return 404 app.all("/{*splat}", (_req, res) => res.status(404).json({ error: "not found" })); + // wait to start passing route records to give the store a moment to register + // which is technically race-y but in practice should be fine + // as we don't have a way to wait currently + simulationStore.store + .run(function* () { + // forces some async work to allow children scopes to start, e.g. the thunks + yield* sleep(0); + }) + .then(() => { + simulationStore.store.dispatch(simulationStore.actions.batchUpdater(simulationRoutes)); + }); + const genericAppServer = createAppServer(app, protocol); + return { listen: async (...listenArgs: Parameters | undefined[]) => { // over and above the `net` listen behavior, allow setting: diff --git a/packages/foundation/src/store/index.ts b/packages/foundation/src/store/index.ts index d57358df..d2d190d0 100644 --- a/packages/foundation/src/store/index.ts +++ b/packages/foundation/src/store/index.ts @@ -178,8 +178,9 @@ export function createSimulationStore< const route = yield* select(schema.simulationRoutes.selectById, { id, }); - if (route.url !== "") + if (route.url !== "") { yield* schema.update(schema.simulationRoutes.merge({ [id]: { calls: route.calls + 1 } })); + } yield* next(); }); @@ -202,11 +203,11 @@ export function createSimulationStore< }; const userTasks = inputTasks({ createWebhook, store, schema }); - let inputedActions = inputActions({ thunks, store, schema }); + let inputtedActions = inputActions({ thunks, store, schema }); let actions = { simulationLog, batchUpdater, - ...inputedActions, + ...inputtedActions, ...userTasks.actions, }; diff --git a/packages/server/README.md b/packages/server/README.md index 2ec953b7..ac16c35f 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -1,8 +1,404 @@ # @simulacrum/server -Server capable of running multiple concurrent simulations that can be controlled by test cases, preview apps, and local developer environments. +Define one graph of simulations and processes, then run that same graph from the CLI, your tests, or a local development harness. + +`@simulacrum/server` is for the case where you want to describe a simulation system once and reuse it across: + +- local developer workflows +- integration and end-to-end tests +- preview or test harness environments https://github.com/thefrontside/simulacrum -> [!WARNING] -> The server is undergoing a refactor, and this may not be required for your use case. The refactor includes allow for more simply running single simulators so this package will be primarily useful as a control plane for cases where there are many simulators under test and in use. For the previous iterations, see the `v0` branch which contain the previous functionality. +## Getting Started + +Start by defining a service graph once, then choose how to run it. + +```ts service-graph.ts +#!/usr/bin/env node +import { useServiceGraph, simulationCLI, useSimulation, useService } from "@simulacrum/server"; +import { simulation } from "./sim2.ts"; + +// define your "graph" that can be used through a CLI or as part of a test rig +export const services = useServiceGraph( + { + sim1: { + operation: useSimulation("sim-run-as-child-process", "./sim1.ts"), + }, + sim2: { + operation: useSimulation("sim-run-in-same-process", simulation), + }, + sim3: { + operation: useService("arbitrary-child-process", "node --import tsx ./sim3.ts"), + }, + }, + { globalData: { hello: "world" } }, // passed readonly to each simulator +); + +// this is a helper function which will give you a CLI around this service graph +// if you are calling this file directly, e.g. `node service-graph.ts` +import { fileURLToPath } from "node:url"; +if (process.argv[1] === fileURLToPath(import.meta.url)) { + simulationCLI(services); +} +``` + +Once you have that file, there are two common ways to use it. + +### Run from the shell + +```bash +# start a local service graph defined in ./service-graph.ts +node ./simulators/service-graph.ts +``` + +> [!NOTE] +> We use `node --import tsx file.ts` here to automatically handle the typescript conversion. This is a separate package that you may be interested in using, but it not a hard requirement necessarily. Newer versions of node also handle this for you. The latest v24 will run a typescript file directly. + +### Run from a test + +If you are already working with `effection`, you may use the operation directly. + +```ts +import { beforeEach, test } from "@effectionx/bdd"; +import { until } from "effection"; +import { serviceGraph } from "./simulators/service-graph.ts"; + +let graph: any // TODO get a type +// note that this has an effection scope +beforeEach(function* () { + graph = yield* serviceGraph(); + // or optionally pass a subset of services to run if not all are required for this test + graph = yield* serviceGraph(["sim1"]); + + // when the test completes, this will be shut down automatically as it is tied + // to an effection scope through `@effectionx/bdd` +}); + +test("things", function* () { + // run your assertions against the graph state + // for example, query the sim API + const port = graph.status.get("a")?.port; + const response = yield* until(fetch(`http://localhost:${port}`)); + // use response here +}); +``` + +If you are outside an `effection` scope, we include a convenience method to use the runner's `.task()` helper and `await` it like a promise. + +```ts +import { beforeEach, afterEach } from "node:test"; +import { serviceGraph } from "./simulators/service-graph.ts"; + +let graph: any // TODO get a type +let task; +beforeEach(async () => { + task = serviceGraph.task(); + // or optionally pass a subset of serviceGraph to run if not all are required for this test + task = serviceGraph.task(["sim1"]); + // when the test completes, you need to manually shut down the graph such as in the `afterEach` below + graph = await task.start(); +}); + +afterEach(async () => { + await task.halt(); +}); + +test("things", async () => { + // run your assertions against the graph state + // for example, query the sim API + const port = graph.status.get("a")?.port; + const response = await fetch(`http://localhost:${port}`); + // use response here +}); +``` + +## Building a Service Graph + +The core building blocks are: + +- `useServiceGraph(...)` coordinates startup order, restart behavior, watcher integration, and graph lifecycle +- `useSimulation(...)` starts simulators either in-process or as child processes, see `@simulacrum/foundation-simulator` or simulators built upon it +- `useService(...)` starts arbitrary child processes and can wait for a wellness check before reporting ready + +Define a service graph with a key and each service/simulator operation. + +```ts +const services = useServiceGraph({ + api: { + operation: useService("api", "node --import tsx ./api.ts"), + }, + auth: { + operation: useSimulation("auth", "./auth-simulator.ts"), + }, + app: { + dependsOn: { startup: ["api", "auth"] as const }, + operation: useService("app", "node --import tsx ./app.ts"), + }, +}); +``` + +That gives you a single runner that can be used in multiple places without redefining your system and it's interaction. + +Use `useSimulation(...)` when the thing you are running is a simulator built on `@simulacrum/foundation-simulator`. Use `useService(...)` when you want to spawn a regular external process. You may define any number of dev servers and service required for your workflow as separate items in the graph. + +See `@simulacrum/foundation-simulator` for a basis to build simulators for your services, or packages such as `@simulacrum/auth0-simulator` and `@simulacrum/github-api-simulator` for concrete examples. + +## API reference + +### useServiceGraph(services, options?) + +```ts +useServiceGraph( + services: ServicesMap, + options?: { + globalData?: Record; + watch?: boolean; + watchDebounce?: number; + }, +): ServiceRunner +``` + +Creates a runner for a graph of services, simulators, and supporting processes. + +###### Parameters + +- `services` - a map of service definitions keyed by service name +- `options` - optional graph-level settings for `globalData`, file watching, and watch debounce behavior + +###### Returns + +- `ServiceRunner` - a runner operation factory that starts the graph when invoked + +Call the runner inside an `effection` scope to start the graph: + +```ts +import { type Operation, run, main } from "effection"; + +const graph = useServiceGraph(services, options); + +// within an Operation such as +main(function* () { + const services = yield* graph(subset); // holds while services run, subset is optional +}); +// or as a promise +const services = await run(() => graph(subset)); +``` + +File watching: pass `options.watch = true` and `options.watchDebounce` to enable watching and restart propagation across dependents. This is enabled through the CLI helper. + +#### ServiceDefinition: one service entry in the graph + +Each item in the `ServicesMap` passed as the first argument to `useServiceGraph` is a `ServiceDefinition`. + +```ts +type ServiceDefinition = { + operation: Operation; + watch?: string[]; + watchDebounce?: number; + dependsOn?: { + startup?: string[]; + restart?: string[]; + }; +}; +``` + +##### `operation` + +- In most cases, pass `useSimulation(args)` or `useService(args)`. +- Each service must provide an `operation: Operation` or another long-lived `effection` operation that resolves when the service is ready. +- The operation may also return service metadata such as `{ port: number }` or `{ port: number; pid: number }` to surface runtime information in the graph's `status` map. +- If you are defining your own custom operation, use `try { ... yield* suspend(); } finally { ... }` inside an `effection` operation or `resource()` to run cleanup logic when the service stops. + +##### `dependsOn` + +```ts +dependsOn?: { + startup?: string[]; + restart?: string[]; +} +``` + +- `startup` lists services that must start before this one. +- `restart` lists services whose restart should trigger a restart of this service (useful when using the watcher). + +##### `watch` Watching & restart propagation + +```ts +watch?: string[]; +``` + +To enable file‑watching: pass `{ watch: true }` to the `useServiceGraph` options and add `watch` paths to your `ServiceDefinition` objects. The watcher is only started when you explicitly request it (and when at least one service includes `watch` paths). The watcher computes transitive dependents (using `dependsOn.restart`) and emits restart updates so restarts propagate deterministically. + +#### `globalData`: simulacrum gateway data shared across the graph + +When you call `useServiceGraph(...)` you may pass an optional `globalData` object in the options. The runner starts an HTTP data service, the simulacrum gateway, that serves that object so tests and child simulations can discover configuration or shared fixtures. + +- Endpoints: `GET /data` returns the full `globalData` JSON and `GET /data/` returns a single key, or a `404`/`400` as appropriate. +- Discovery: the gateway registers its listening port on the graph `status` map under the key `"simulacrum"`. +- Service integration: when starting child simulations via `useSimulation` or `simulationCLI`, the runner passes the gateway port to the child so it can fetch `globalData` during startup. + +```ts +const runner = useServiceGraph( + { + child: { operation: useSimulation("child", "./child-main.ts") }, + }, + { globalData: { featureFlag: true } }, +); + +main(function* (): Operation { + const services = yield* runner(); + const simulacrumPort = services.status.get("simulacrum")?.port; + const res = yield* until(fetch(`http://127.0.0.1:${simulacrumPort}/data`)); + const data = yield* until(res.json()); + console.log(data); +}); +``` + +The gateway is intended for local development and tests only. Conceptually, it provides a small orchestration data service for the active graph. + +### ServiceRunner & returned values + +The runner returned by `useServiceGraph` is itself an operation. This allows it to be portable. Define it in one spot, then import it into any CLI, start scripts or test runners of your choosing at start it there. Optionally, it takes an argument, `subset`, to only start part of the graph. + +##### `subset` + +When calling the runner you may pass a subset (e.g. `yield* runner(["serviceA"])`). Any required startup dependencies are included automatically. This is particularly useful when focusing on a specific feature or test case. + +##### returned graph + +The runner operation returns an object with the following shape: + +- `services` — the original service definitions passed to `useServiceGraph` +- `status` — a `Map` with runtime metadata for each service, including optional `port` and `pid` when the operation returns that information +- `serviceUpdates` — a `Stream` of watcher updates when watching is enabled, otherwise `undefined` +- `serviceChanges` — a `Stream` of watcher restart events when watching is enabled, otherwise `undefined` + +If a service operation returns an object like `{ port: number }` or `{ port: number; pid: number }`, that information is recorded on `status` so tests can discover listening endpoints. + +This is still an `effection` operation. If you are not operating within an `effection` scope, make use of the runner's `task()` helper. It returns an awaitable promise-like handle whose `.start()` method resolves with the started graph, exposes the backing running task on `running`, and stays alive until you call `.halt()`. + +### Simulation & process helpers 🔧 + +This package provides a few helpers to run simulations and external processes in common patterns: + +#### useSimulation(name, factoryOrModulePath, options?) + +`useSimulation` is built upon two main code paths. + +##### `useSimulation(name: string, modulePath: string): Operation<{ port: number; pid: number }>` + +Starts a simulator in a fresh child process. This is the preferred form when you want reliable watch-driven restarts and a fresh module graph on each start. + +###### Parameters + +- `name` - human-readable name used in logs and graph status +- `modulePath` - path to the simulator module to execute in the child process + +###### Returns + +- `Operation<{ port: number; pid: number }>` + +```ts +operation: useSimulation("service-key-for-logs", "./simulator/my-simulator.js"); +``` + +##### `useSimulation(name: string, createFactory: (initData?: unknown) => FoundationSimulator): Operation<{ port: number }>` + +Starts a simulator in the current process. This is the simplest form when you do not need subprocess isolation or module reload semantics. If you local development setup has issues with `child_process`, this is the alternative option. + +###### Parameters + +- `name` - human-readable name used in logs and graph status +- `createFactory` - a function that returns a `FoundationSimulator` + +###### Returns + +- `Operation<{ port: number }>` + +```ts +operation: useSimulation("app", (initData) => { + // do something with initData and/or pass it to your simulator through the closure + return createFoundationSimulationServer({ port: 0 }); +}); +``` + +If `globalData` is set on the graph runner, `useSimulation` fetches it from the simulacrum gateway and passes it as `initData` to your factory or child module. + +When the factory form is used, `useSimulation` calls `await simulator.listen()` to obtain `{ port }` and records that port in the graph `status` map. + +> [!WARNING] +> Watching and code reload semantics are only fully supported when the simulator runs as a subprocess. Restarting an in-process simulator does not clear the module cache. + +#### Running child-process simulations + +When the second argument to `useSimulation` is a module path string, it runs the simulator in a fresh child process using `./bin/run-simulation-child.ts`. This mode isolates module cache and is the recommended form for watch-driven restarts. + +The child-process flow looks like this: + +1. `useSimulation` starts the wrapper `./bin/run-simulation-child.ts `. +2. If a simulacrum gateway is running, the wrapper also receives `--simulacrum-port` so the child can fetch `globalData`. +3. The child prints a first ready line like `{ "ready": true, "port": 12345 }` to stdout. +4. `useSimulation` reads that line, captures the port, and records it in the graph `status` map. +5. Non-JSON stdout is forwarded to logs as normal. +6. If the child exits before emitting the ready line, `useSimulation` rejects. + +If you build the simulator with `@simulacrum/foundation-simulator`, this wiring is handled for you. + +Example: + +```ts +operation: useSimulation("service-key-for-logs", "./simulator/my-simulator.js"); +``` + +> [!WARNING] +> TypeScript child modules rely on your runtime setup supporting them, for example via `tsx`. JavaScript modules work as-is. + +#### About `@simulacrum/foundation-simulator` + +Use `createFoundationSimulationServer()` to create a server that returns a `FoundationSimulator`, which is the shape expected by the factory form of `useSimulation`. + +#### useService(name, cmd, options?) + +```ts +useService( + name: string, + cmd: string, + options?: { + wellnessCheck?: { + operation: (stdio: Stream) => Operation>; + timeout?: number; + frequency?: number; + }; + }, +): Operation +``` + +Starts an external process and optionally waits for a wellness check before reporting the service as ready. + +###### Parameters + +- `name` - human-readable name used in logs and graph status +- `cmd` - command to execute for the service process +- `options` - optional process readiness configuration + +###### Returns + +- `Operation` - a long-lived operation that stays active until the service is stopped + +`useService` forwards stdout and stderr to the package logger and keeps the operation alive until it goes out of scope. + +The `options.wellnessCheck` object supports: + +- `operation(stdio)` - an operation that inspects process output and returns an Effection `Result` when the service should be considered ready +- `timeout` - maximum time to wait for the wellness check to succeed +- `frequency` - polling or retry frequency for the wellness check + +#### simulationCLI(serviceGraph) + +- `simulationCLI` wraps the runner in a small CLI loop and provides convenience flags: `--services`, `--watch`, and `--watch-debounce`. +- Use the CLI helper for local development workflows where you want to run your graph directly from a file (see `service-graph.ts` examples above). + +## Development + +The `example` folder contains runnable examples demonstrating `useServiceGraph`. The `test` folder includes tests based on the Node test runner which pull from the `example` folder or create their own fixtures to test the APIs. diff --git a/packages/server/bin/run-simulation-child.ts b/packages/server/bin/run-simulation-child.ts new file mode 100644 index 00000000..c0a36123 --- /dev/null +++ b/packages/server/bin/run-simulation-child.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env node +import { main, suspend, until } from "effection"; +import { pathToFileURL } from "node:url"; +import type { + FoundationSimulator, + FoundationSimulatorListening, +} from "@simulacrum/foundation-simulator"; + +function guardedFactory( + factory: Function, +): (initData?: unknown) => Promise> { + return async function startSimulation(initData?: unknown) { + const sim = await factory(initData); + if ("listen" in sim && typeof sim.listen === "function") { + return sim as FoundationSimulator; + } + throw new Error("factory did not return a simulator instance"); + }; +} + +function* normalizeSimulatorFactory(url: string) { + try { + const mod: unknown = yield* until(import(url)); + + // dynamically import module has to be an object if correctly resolved + if (mod && typeof mod === "object") { + const m = mod; + + // export default as factory + if ("default" in m && typeof m.default === "function") { + const factory = m.default; + return guardedFactory(factory); + } + + // export named 'simulation' as factory + if ("simulation" in m && typeof m.simulation === "function") { + const factory = m.simulation; + return guardedFactory(factory); + } + } + } catch (ignore) { + // no-op - will throw in fall through below + } + throw new Error("no factory or simulator instance found in module"); +} + +main(function* () { + const args = process.argv.slice(2); + if (args.length < 1) { + throw new Error("usage: run-simulation-child.js "); + } + + const modulePath = args[0]; + + // Resolve and import module inside the operation + const url = + modulePath.startsWith("./") || modulePath.startsWith("/") + ? pathToFileURL(modulePath).href + : modulePath; + const factory = yield* normalizeSimulatorFactory(url); + + let simulacrumPort: number | undefined = undefined; + // parse optional flags after modulePath + for (let i = 1; i < args.length; i++) { + if (args[i] === "--simulacrum-port") { + simulacrumPort = Number(args[i + 1]); + i++; + } + } + + // if present fetch the data chunk and pass it to the factory + let initData: JSON | undefined = undefined; + if (typeof simulacrumPort === "number" && !Number.isNaN(simulacrumPort)) { + try { + const res = yield* until(fetch(`http://127.0.0.1:${simulacrumPort}/data`)); + initData = yield* until(res.json()); + } catch (ignore) { + // ignore fetch failures + console.error("failed to fetch simulacrum data:", ignore); + } + } + + // invoke factory; it may return a simulator instance or a Promise thereof + const sim = yield* until(factory(initData)); + + let listening: FoundationSimulatorListening | undefined = undefined; + try { + listening = yield* until(sim.listen()); + const out = JSON.stringify({ + ready: true, + port: listening.port, + pid: process.pid, + }); + console.log(out); + yield* suspend(); + } finally { + try { + if (listening && typeof listening.ensureClose === "function") { + yield* until(listening.ensureClose()); + } + } catch (ignore) { + // ignore + } + } +}); diff --git a/packages/server/example/README.md b/packages/server/example/README.md new file mode 100644 index 00000000..d9edacec --- /dev/null +++ b/packages/server/example/README.md @@ -0,0 +1,38 @@ +# Server package examples + +This folder contains runnable examples demonstrating `useServiceGraph`, `useService`, and `useSimulation`. + +The examples are: + +- `process-graph.ts` — a process-based graph using `useService()` to start each service as a separate command. +- `simulation-graph.ts` — a simulation graph using `useSimulation()` to start each service in a child process with module-path-based simulator factories. +- `concurrency-layers.ts` — a simulation graph with `useSimulation()`, file watching, and restart propagation via `dependsOn.restart`. + +Quick commands: + +Run the simulation-based example (child simulations): + +```bash +cd packages/server +node --import tsx ./example/simulation-graph.ts +``` + +Run the process-based example (spawned processes): + +```bash +cd packages/server +node --import tsx ./example/process-graph.ts +``` + +Run the concurrency example: + +```bash +cd packages/server +node --import tsx ./example/concurrency-layers.ts +``` + +These examples make use of the small service implementations in `./example/services`. + +Notes: the examples now use `dependsOn` with a `{ startup, restart? }` shape. To experiment with restart propagation, add a `watch` entry to a service and include dependents via `dependsOn.restart` — when a watched file changes the watcher will restart the affected service and its transitive dependents. + +Global data: The simulation example (`simulation-graph.ts`) demonstrates the `globalData` option. When provided, the graph starts a small HTTP data service (the "simulacrum gateway") and registers its port on `servicePorts` under the key `"simulacrum"`. Child simulations may query this gateway at `/data` to obtain initialization data. diff --git a/packages/server/example/concurrency-layers.ts b/packages/server/example/concurrency-layers.ts new file mode 100644 index 00000000..da935132 --- /dev/null +++ b/packages/server/example/concurrency-layers.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env node +import { resource } from "effection"; +import { useServiceGraph } from "../src/services.ts"; +import { useSimulation } from "../src/simulation.ts"; +import { simulationCLI } from "../src/cli.ts"; + +const servicesMap = { + dependent: { + dependsOn: { startup: ["fast", "slow"] as const }, + operation: resource(function* (provide) { + try { + console.log("all deps started; running dependent service"); + yield* provide(); + } finally { + console.log("stopping dependent service"); + } + }), + watch: ["./example/services/basic-sim.ts"], + }, + fast: { + operation: useSimulation("fast", "./example/services/basic-sim-1.ts"), + watch: ["./example/services/basic-sim-1.ts"], + }, + slow: { + operation: useSimulation("slow", "./example/services/basic-sim-2.ts"), + watch: ["./example/services/basic-sim-2.ts"], + }, +}; + +export const services = useServiceGraph(servicesMap); + +import { fileURLToPath } from "node:url"; +if (process.argv[1] === fileURLToPath(import.meta.url)) { + simulationCLI(services); +} diff --git a/packages/server/example/process-graph.ts b/packages/server/example/process-graph.ts new file mode 100644 index 00000000..bf53ce52 --- /dev/null +++ b/packages/server/example/process-graph.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env node +import { each, type Stream } from "effection"; +import { useService } from "../src/service.ts"; +import { useServiceGraph } from "../src/services.ts"; +import { simulationCLI } from "../src/cli.ts"; + +const servicesMap = { + A: { + operation: useService("A", "node ./example/services/basic-start-1.ts", { + wellnessCheck: { + frequency: 10, + timeout: 15000, + *operation(stdio: Stream) { + for (let line of yield* each(stdio)) { + if (line.includes("started")) { + console.log("A ready (wellnessCheck)"); + + return { ok: true, value: undefined }; + } + yield* each.next(); + } + // default: return success so the result type is well-formed + return { ok: true, value: undefined }; + }, + }, + }), + }, + B: { + dependsOn: { startup: ["A"] as const }, + operation: useService("B", "node ./example/services/basic-start-2.ts", { + wellnessCheck: { + frequency: 10, + timeout: 15000, + *operation(stdio: Stream) { + for (let line of yield* each(stdio)) { + if (line.includes("started")) { + console.log("B ready (wellnessCheck)"); + + return { ok: true, value: undefined }; + } + yield* each.next(); + } + // default: return success so the result type is well-formed + return { ok: true, value: undefined }; + }, + }, + }), + }, +}; + +export const services = useServiceGraph(servicesMap); + +import { fileURLToPath } from "node:url"; +if (process.argv[1] === fileURLToPath(import.meta.url)) { + // run via CLI when executed directly + simulationCLI(services); +} diff --git a/packages/server/example/services/basic-sim-1.ts b/packages/server/example/services/basic-sim-1.ts new file mode 100644 index 00000000..ede50000 --- /dev/null +++ b/packages/server/example/services/basic-sim-1.ts @@ -0,0 +1,3 @@ +import { simulation as genSimulation } from "./gen-sim-factory.ts"; + +export const simulation = genSimulation(3301, 10); diff --git a/packages/server/example/services/basic-sim-2.ts b/packages/server/example/services/basic-sim-2.ts new file mode 100644 index 00000000..dd5679a1 --- /dev/null +++ b/packages/server/example/services/basic-sim-2.ts @@ -0,0 +1,3 @@ +import { simulation as genSimulation } from "./gen-sim-factory.ts"; + +export const simulation = genSimulation(3302, 15); diff --git a/packages/server/example/services/basic-start-1.ts b/packages/server/example/services/basic-start-1.ts new file mode 100644 index 00000000..ca64c183 --- /dev/null +++ b/packages/server/example/services/basic-start-1.ts @@ -0,0 +1,7 @@ +import { simulation } from "./basic-sim-1.ts"; + +simulation() + .listen(3301) + .then(() => { + console.log("Basic simulation 1 started on port 3301"); + }); diff --git a/packages/server/example/services/basic-start-2.ts b/packages/server/example/services/basic-start-2.ts new file mode 100644 index 00000000..928cad94 --- /dev/null +++ b/packages/server/example/services/basic-start-2.ts @@ -0,0 +1,7 @@ +import { simulation } from "./basic-sim-2.ts"; + +simulation() + .listen(3302) + .then(() => { + console.log("Basic simulation 2 started on port 3302"); + }); diff --git a/packages/server/example/services/gen-sim-factory.ts b/packages/server/example/services/gen-sim-factory.ts new file mode 100644 index 00000000..47e2f1dd --- /dev/null +++ b/packages/server/example/services/gen-sim-factory.ts @@ -0,0 +1,38 @@ +import { + createFoundationSimulationServer, + type FoundationSimulator, +} from "@simulacrum/foundation-simulator"; + +/* + Helper to create a basic foundation simulation server with a configurable + start delay to simulate slow startups. You would export your simulator + more directly instead of wrapping it like this in a real project. +*/ +export function simulation( + port: number = 3301, + startDelay: number = 10, +): (initData?: unknown) => FoundationSimulator { + return (initData?: unknown) => { + const factory = createFoundationSimulationServer({ + port, + extendRouter(router) { + router.get("/status", (_req, res) => { + res.status(200).send("ok"); + }); + router.get("/init-data", (_req, res) => { + res.status(200).json({ data: initData ?? null }); + }); + }, + })(); + + return { + async listen(...args: Parameters["listen"]>): Promise { + if (startDelay > 0) { + await new Promise((resolve) => setTimeout(resolve, startDelay)); + } + // delegate to underlying factory listen + return factory.listen(...args); + }, + }; + }; +} diff --git a/packages/server/example/simulation-graph.ts b/packages/server/example/simulation-graph.ts new file mode 100644 index 00000000..b10eb7f1 --- /dev/null +++ b/packages/server/example/simulation-graph.ts @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { useServiceGraph } from "../src/services.ts"; +import { useSimulation } from "../src/simulation.ts"; +import { simulationCLI } from "../src/cli.ts"; + +const servicesMap = { + A: { + operation: useSimulation("A", "./example/services/basic-sim-1.ts"), + }, + B: { + dependsOn: { startup: ["A"] as const }, + operation: useSimulation("B", "./example/services/basic-sim-2.ts"), + }, +}; + +export const services = useServiceGraph(servicesMap, { + globalData: { exampleKey: "exampleValue" }, +}); + +import { fileURLToPath } from "node:url"; +if (process.argv[1] === fileURLToPath(import.meta.url)) { + simulationCLI(services); +} diff --git a/packages/server/package.json b/packages/server/package.json index 2e6bdfec..bc4f0c3c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -21,6 +21,7 @@ }, "files": [ "README.md", + "bin", "dist", "src" ], @@ -42,6 +43,7 @@ "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, + "./bin/run-simulation-child.ts": "./bin/run-simulation-child.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -50,6 +52,7 @@ "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, + "./bin/run-simulation-child.ts": "./bin/run-simulation-child.ts", "./package.json": "./package.json" } }, @@ -59,14 +62,20 @@ "lint": "oxlint", "prepack": "pnpm run build", "test": "node --test-timeout=60000 --experimental-transform-types --test test/*.test.ts", - "test:service-main": "node --experimental-transform-types ./test/service-main.ts", + "test:service-main": "node --experimental-transform-types ./test/services/service-main.ts", "tsc": "tsc --noEmit" }, "dependencies": { - "@effectionx/context-api": "^0.2.1", - "@effectionx/process": "^0.6.2", - "@effectionx/timebox": "^0.3.1", - "effection": "^4.0.0" + "@effectionx/context-api": "~0.6.0", + "@effectionx/process": "~0.8.0", + "@effectionx/stream-helpers": "~0.8.2", + "@effectionx/timebox": "~0.4.3", + "chokidar": "^5.0.0", + "effection": "^4.0.2", + "picomatch": "^4.0.4" }, - "devDependencies": {} + "devDependencies": { + "@simulacrum/foundation-simulator": "workspace:^", + "@types/picomatch": "^4.0.3" + } } diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts new file mode 100644 index 00000000..b5319ec4 --- /dev/null +++ b/packages/server/src/cli.ts @@ -0,0 +1,88 @@ +import { parseArgs } from "node:util"; +import { main, suspend, type Operation } from "effection"; +import { useAttributes } from "./logging.ts"; +import type { ServiceGraph, ServiceDefinition } from "./services.ts"; +import { Debugging, logger } from "./logging.ts"; + +/** + * CLI operation that parses args and runs a service graph runner. + * + * This operation accepts the runner returned by `useServiceGraph` and starts + * the requested subset of services. It supports `--services` (comma + * separated), `--watch` and `--watch-debounce` options for convenience when + * iterating on local development. + * + * @param serviceGraph - runner factory returned by `useServiceGraph` + */ +export function* simulationCLIOp, T = any>( + serviceGraph: (subset?: Array) => Operation>, +) { + try { + const { values } = parseArgs({ + options: { + services: { type: "string", short: "s" }, + debug: { type: "boolean", short: "d", default: false }, + help: { type: "boolean", short: "h" }, + watch: { type: "boolean" }, + "watch-debounce": { type: "string" }, + }, + allowPositionals: true, + allowNegative: true, + allowUnknown: true, + }); + + function* printUsage() { + process.stdout.write( + `Usage: cli [-s|--services serviceName] [--watch] [--watch-debounce ms]`, + ); + } + + if (values.help) { + return yield* printUsage(); + } + + const subset = values.services + ? (values.services as string) + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + : undefined; + yield* useAttributes({ + name: "cli", + subset: subset ? subset.join(", ") : "", + watch: String(!!values.watch), + watchDebounce: String(values["watch-debounce"] ?? ""), + debug: String(!!values.debug), + }); + + const runOptions: { watch?: boolean; watchDebounce?: number } = { + watch: !!values.watch, + }; + if (values["watch-debounce"]) runOptions.watchDebounce = Number(values["watch-debounce"]); + + yield* Debugging.set(values.debug); + + // Start the graph and fetch the provided info + // subset is a string array from CLI; cast to service key array for strict runner + yield* serviceGraph(subset as unknown as Array); + + yield* suspend(); + } catch (err) { + yield* logger.stderr(`simulationCLI error:`, err instanceof Error ? err.stack : err); + } finally { + yield* logger.debug("simulationCLI finally"); + } +} + +/** + * Run a service graph runner inside an effection main loop suitable for use + * as a Node CLI. This invokes `simulationCLIOp` under `main` and returns the + * resulting promise. + * + * @param serviceGraph - runner factory returned by `useServiceGraph` + */ +export async function simulationCLI>, T>( + serviceGraph: (subset?: Array) => Operation>, +) { + return main(() => simulationCLIOp(serviceGraph)); +} diff --git a/packages/server/src/createReplaySignal.ts b/packages/server/src/createReplaySignal.ts index 61056b31..93fb85c8 100644 --- a/packages/server/src/createReplaySignal.ts +++ b/packages/server/src/createReplaySignal.ts @@ -1,6 +1,13 @@ import type { Resolve, Subscription } from "effection"; import { action, resource } from "effection"; +/** + * Create a replayable signal which exposes `send`/`close` helpers while + * preserving the original subscription semantics. Useful for forwarding + * child process stdio lines for wellness checks and logging. + * + * @returns a Signal-like value with `send` and `close` methods and a `next` operation + */ export function createReplaySignal() { const subscribers = new Set>(); // single shared durable queue storage diff --git a/packages/server/src/data-service.ts b/packages/server/src/data-service.ts new file mode 100644 index 00000000..9da2a4b8 --- /dev/null +++ b/packages/server/src/data-service.ts @@ -0,0 +1,92 @@ +import { call, resource, type Operation } from "effection"; +import { useAttributes } from "./logging.ts"; +import { createServer } from "node:http"; +import { logger } from "./logging.ts"; + +export type DataServiceOptions = Record | undefined; + +/** + * Start a simple local HTTP data service that serves a user-provided object. + * + * This is intended for local testing and to supply a small amount of + * configuration or initialization data to child simulations via the + * "simulacrum" gateway. The operation yields an object with `{ port }` once + * listening. + * + * @param data - Arbitrary JSON-serializable data to serve at `/data` + * @returns an operation that provides `{ port: number }` when ready + */ +export function startDataService(data: DataServiceOptions = {}): Operation<{ port: number }> { + return resource(function* (provide) { + yield* useAttributes({ + name: "dataService", + keys: Object.keys(data).join(", "), + }); + const server = createServer((req, res) => { + try { + const url = new URL(req.url ?? "", `http://127.0.0.1`); + const pathname = url.pathname; + + // GET /data -> whole object + if (req.method === "GET" && (pathname === "/data" || pathname === "/")) { + const body = JSON.stringify(data || {}); + res.writeHead(200, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + // GET /data/ -> value or 404 + if (req.method === "GET" && pathname.startsWith("/data/")) { + const key = decodeURIComponent(pathname.replace(/^\/data\//, "")); + if (!key) { + res.writeHead(400); + res.end(); + return; + } + + const value = (data as Record | undefined)?.[key]; + if (value === undefined) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found"); + return; + } + + const body = JSON.stringify(value); + res.writeHead(200, { + "content-type": "application/json", + "content-length": String(Buffer.byteLength(body)), + }); + res.end(body); + return; + } + + // unknown endpoint + res.writeHead(404, { "content-type": "text/plain" }); + res.end("not found"); + } catch (err) { + res.writeHead(500, { "content-type": "text/plain" }); + res.end(String(err)); + } + }); + + // listen on ephemeral port bound to localhost + yield* call(() => server.listen()); + + const address = server.address(); + const port = + typeof address === "object" && address !== null && "port" in address ? address.port : 0; + + yield* logger.debug(`data service started on port ${port}`); + yield* useAttributes({ name: "dataService", port }); + + try { + yield* provide({ port }); + } finally { + yield* call(() => server.close()); + yield* logger.debug(`data service stopped on port ${port}`); + } + }); +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index d1df1916..ed76ab33 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,2 +1,7 @@ export * from "./logging.ts"; export * from "./service.ts"; +export * from "./services.ts"; +export * from "./simulation.ts"; +export * from "./operation-metadata.ts"; +export * from "./taskable.ts"; +export * from "./cli.ts"; diff --git a/packages/server/src/logging.ts b/packages/server/src/logging.ts index 0ac8c51a..dd9b6237 100644 --- a/packages/server/src/logging.ts +++ b/packages/server/src/logging.ts @@ -1,14 +1,52 @@ -import type { Operation } from "effection"; +import { type Operation, createContext, call } from "effection"; import { createApi } from "@effectionx/context-api"; +export const Debugging = createContext("@simulacrum/debugging", false); + export const stdio = createApi("@simulacrum/logging", { *stdout(line: string): Operation { console.log(line); }, - *stderr(line: string): Operation { - console.log(line); + *stderr(...line: Parameters): Operation { + console.error(...line); + }, + *debug(line: string): Operation { + const isDebugging = yield* Debugging.expect(); + if (isDebugging) console.debug(line); }, }); -export const { stdout } = stdio.operations; -export const { stderr } = stdio.operations; +let useAttributesImpl: undefined | ((attrs: Record) => Operation) = + undefined; + +function* resolveUseAttributes() { + if (typeof useAttributesImpl !== "undefined") { + return; + } + + try { + const effection = yield* call(() => import("effection")); + const maybe = (effection as { useAttributes?: any }).useAttributes; + if (typeof maybe === "function") { + useAttributesImpl = maybe; + } else { + useAttributesImpl = function* () { + return; + } as any; + } + } catch { + // no-op when useAttributes is unavailable in older effection versions + useAttributesImpl = function* () { + return; + } as any; + } +} + +export function* useAttributes(attrs: Record) { + yield* resolveUseAttributes(); + if (useAttributesImpl) { + return yield* useAttributesImpl(attrs); + } +} + +export const logger = stdio.operations; diff --git a/packages/server/src/operation-metadata.ts b/packages/server/src/operation-metadata.ts new file mode 100644 index 00000000..45602090 --- /dev/null +++ b/packages/server/src/operation-metadata.ts @@ -0,0 +1,25 @@ +import type { Operation } from "effection"; + +export type OperationMetadata = { + watchSafe?: boolean; + operationName?: string; +}; + +const OPERATION_METADATA = Symbol.for("@simulacrum/simulacrum/operationMetadata"); + +export type OperationWithMetadata> = T & { + [OPERATION_METADATA]?: OperationMetadata; +}; + +export function withOperationMetadata>( + operation: T, + metadata: OperationMetadata, +): T { + const operationWithMetadata = operation as OperationWithMetadata; + operationWithMetadata[OPERATION_METADATA] = metadata; + return operationWithMetadata; +} + +export function getOperationMetadata(operation: Operation): OperationMetadata | undefined { + return (operation as OperationWithMetadata>)[OPERATION_METADATA]; +} diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index 08e8a1ba..87a751d1 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -1,19 +1,10 @@ -import { - type Operation, - type Result, - type Stream, - each, - lift, - resource, - scoped, - sleep, - spawn, -} from "effection"; +import { type Operation, type Result, type Stream, lift, resource, scoped, sleep } from "effection"; +import { useAttributes } from "./logging.ts"; import { timebox } from "@effectionx/timebox"; -import { exec } from "@effectionx/process"; +import { daemon, Stdio } from "@effectionx/process"; import type { ExecOptions as ProcessOptions } from "@effectionx/process"; -import { stderr, stdout } from "./logging.ts"; import { createReplaySignal } from "./createReplaySignal.ts"; +import { withOperationMetadata } from "./operation-metadata.ts"; type ServiceOptions = { wellnessCheck?: { @@ -24,71 +15,89 @@ type ServiceOptions = { processOptions?: ProcessOptions; }; +/** + * Start a process and return an Operation that represents the running service. + * + * The Operation returned by useService returns when the process has started and, + * if a wellnessCheck is provided, once the wellnessCheck passes. When run in an + * effection scope, the operation remains active in that scope. When the operation + * goes out of scope, effection will automatically shut down the + * process and clean up and shut down the process. + */ export function useService( - _name: string, + name: string, cmd: string, options: ServiceOptions = {}, ): Operation { - return resource(function* (provide) { - if (cmd.startsWith("npm")) { - // see https://github.com/npm/cli/issues/6684 - throw new Error("scripts run with npm don't respect signals to properly shutdown"); - } - const process = yield* exec(cmd, options.processOptions); - const stdio = createReplaySignal(); - const stdioAdd = lift(stdio.send); - - // forward raw stdout for logging in chunk form (no reassembly) - yield* spawn(function* () { - for (let line of yield* each(process.stdout)) { - const buf = Buffer.from(line); - const str = buf.toString(); - stdout(str); - yield* stdioAdd(str); - yield* each.next(); + return withOperationMetadata( + resource(function* (provide) { + yield* useAttributes({ name: `useService ${name}`, cmd: String(cmd) }); + if (cmd.startsWith("npm")) { + // see https://github.com/npm/cli/issues/6684 + throw new Error("scripts run with npm don't respect signals to properly shutdown"); } - }); - yield* spawn(function* () { - for (let line of yield* each(process.stderr)) { - const str = Buffer.from(line).toString(); - stderr(str); - yield* stdioAdd(str); - yield* each.next(); - } - }); + const stdio = createReplaySignal(); + const stdioAdd = lift(stdio.send); + + yield* Stdio.around({ + *stdout(line, next) { + const [bytes] = line; + const str = bytes.toString(); + yield* stdioAdd(str); + return yield* next(bytes); + }, + *stderr(line, next) { + const [bytes] = line; + const str = Buffer.from(bytes).toString(); + yield* stdioAdd(str); + return yield* next(bytes); + }, + }); - yield* sleep(0); // allow stdio forwarding to start + yield* daemon(cmd, options.processOptions); - // if supplied, wellness check to ensure it is running or timeout with result - if (options.wellnessCheck) { - const { operation } = options.wellnessCheck; - const frequency = options.wellnessCheck.frequency ?? 100; - function* untilWell() { - while (true) { - try { - yield* sleep(frequency); - const result = yield* scoped(() => operation(stdio)); - if (result && result.ok) { - break; + // if supplied, wellness check to ensure it is running or timeout with result + if (options.wellnessCheck) { + yield* useAttributes({ + name: `useService ${name}`, + wellnessCheck: String(true), + frequency: String(options.wellnessCheck.frequency ?? ""), + }); + const { operation } = options.wellnessCheck; + const frequency = options.wellnessCheck.frequency ?? 100; + function* untilWell() { + yield* useAttributes({ name: `wellnessCheck` }); + while (true) { + try { + yield* sleep(frequency); + const result = yield* scoped(() => operation(stdio)); + if (result && result.ok) { + break; + } + } catch (ignore) { + // noop, try again } - } catch (ignore) { - // noop, try again } } - } - if (options.wellnessCheck.timeout) { - const checked = yield* timebox(options.wellnessCheck.timeout, untilWell); - if (checked && checked.timeout) { - throw new Error("service wellness check timed out"); + if (options.wellnessCheck.timeout) { + yield* useAttributes({ + name: `useService ${name}`, + timeout: String(options.wellnessCheck.timeout), + }); + const checked = yield* timebox(options.wellnessCheck.timeout, untilWell); + if (checked && checked.timeout) { + throw new Error("service wellness check timed out"); + } + } else { + yield* untilWell(); } - } else { - yield* untilWell(); + yield* lift(stdio.close)(); } - yield* lift(stdio.close)(); - } - yield* provide(); - }); + yield* provide(); + }), + { watchSafe: true, operationName: "useService" }, + ); } diff --git a/packages/server/src/services.ts b/packages/server/src/services.ts new file mode 100644 index 00000000..20d0a32c --- /dev/null +++ b/packages/server/src/services.ts @@ -0,0 +1,370 @@ +import { + type Operation, + type Stream, + type WithResolvers, + resource, + spawn, + withResolvers, + each, + createContext, +} from "effection"; + +import { useAttributes } from "./logging.ts"; +import { type ServiceUpdate, useWatcher } from "./watch.ts"; +import { logger } from "./logging.ts"; +import { startDataService } from "./data-service.ts"; +import { getOperationMetadata } from "./operation-metadata.ts"; +import { taskable, type StartableTask, type TaskableOperation } from "./taskable.ts"; + +/** + * Context key for the Simulacrum gateway listening port. + * + * When `useServiceGraph` starts the optional simulacrum gateway (via the + * `globalData` option) it sets this context value to the listening port so + * operations in the graph (including `useSimulation`) can discover and fetch the `/data` payload. + */ +export const SimulacrumEndpoint = createContext("SimulacrumEndpoint"); + +export type ServiceDefinition = { + operation: Operation; + // folders/files to watch for changes which should cause a restart + watch?: string[]; + // debounce in milliseconds to coalesce rapid changes for this service + watchDebounce?: number; + dependsOn?: { startup: readonly S[]; restart?: readonly S[] }; + options?: { + // Keep an options object for future expansion or hooks; currently unused when operation is present + }; +}; + +type MaybeSimulation = void | { port?: number } | unknown; + +export type ServiceGraph< + S extends Record>, + T extends MaybeSimulation, +> = { + services: { + [service in keyof S]: ServiceDefinition; + }; + serviceUpdates: Stream | undefined; + serviceChanges: Stream | undefined; + status: Map; +}; + +export type ServiceInfo = { + port?: number | undefined; + pid?: number | undefined; +}; + +export type ServiceStatus = { + startup: WithResolvers; + running: WithResolvers; + port?: number | undefined; + pid?: number | undefined; +}; + +export type ServiceGraphTask< + S extends Record>, + T extends MaybeSimulation, +> = StartableTask>; + +export type ServiceGraphOperation< + S extends Record>, + T extends MaybeSimulation, +> = TaskableOperation>; + +export type ServiceGraphRunner< + S extends Record>, + T extends MaybeSimulation, +> = ((subset?: Array) => ServiceGraphOperation) & { + task(subset?: Array): ServiceGraphTask; +}; + +/** + * Start a graph of services with dependency ordering and optional file + * watching/restart behavior. + * + * Each service is defined as a `ServiceDefinition` that includes an + * `operation: Operation` which should return once the service is ready. The + * returned runner function yields the graph resource directly for Effection + * callers and also exposes `.task(subset?)` for callers that want a + * promise-first handle whose `.start()` or `await` resolves when the graph is + * ready and remains running until `.halt()` is called. + * + * @param services - a map of service names to definitions + * @param options - optional configuration: `{ globalData?, watch?, watchDebounce? }` + * @returns a runner function returning the graph operation with added `.task()` helpers on both the operation and the runner + */ +export function useServiceGraph< + S extends Record>, + T extends MaybeSimulation, +>( + services: S, + options?: { + globalData?: Record; + watch?: boolean; + watchDebounce?: number; + }, +): ServiceGraphRunner { + const runner = ((subset?: Array) => { + const graph = taskable( + resource>(function* (provide) { + // detect cycles in the dependency graph + const nodes = Object.keys(services); + // label the root of the service graph operation + yield* useAttributes({ + name: "serviceGraph", + totalServices: String(nodes.length), + }); + const temp = new Set(); + const perm = new Set(); + + function visit(n: string) { + if (perm.has(n)) return; + if (temp.has(n)) throw new Error("Cycle detected in services"); + temp.add(n); + const def = services[n]; + const deps: readonly string[] = def.dependsOn?.startup ?? []; + for (const d of deps) { + if (!(d in services)) continue; + visit(d); + } + temp.delete(n); + perm.add(n); + } + + for (const n of nodes) { + visit(n); + } + + let effectiveServices = services; // {} as typeof services; + if (subset) { + const want = new Set( + subset.map((s) => String(s).trim()).filter((s) => s.length > 0), + ); + const included = new Set(); + function include(name: string) { + if (included.has(name)) return; + if (!(name in services)) throw new Error(`Requested service '${name}' not found`); + included.add(name); + for (const dep of services[name].dependsOn?.startup ?? []) { + include(String(dep)); + } + } + for (const name of want) include(name); + + const picked: Partial = {}; + for (const name of included) { + picked[name as keyof typeof services] = services[name as keyof typeof services]; + } + effectiveServices = picked as typeof services; + + // annotate subset details AFTER calculations to avoid overwriting + yield* useAttributes({ + name: "serviceGraph", + requestedServices: Array.from(want).join(", "), + includedServices: Array.from(included).join(", "), + }); + yield* logger.stdout( + `simulation starting with subset of services: ${Array.from(included).join(", ")}`, + ); + } + + const status = new Map(); + + const dataServiceProvided = yield* startDataService(options?.globalData ?? {}); + yield* useAttributes({ + name: "serviceGraph", + dataServicePort: String(dataServiceProvided.port), + }); + + status.set("simulacrum", { + startup: withResolvers(), + running: withResolvers(), + port: dataServiceProvided.port, + }); + status.get("simulacrum")?.startup.resolve(); + + // set the SimulacrumEndpoint in this operation scope so children started + // in this graph can access the port via context + yield* SimulacrumEndpoint.set(dataServiceProvided.port); + + // start up a watcher only when the CLI or caller explicitly asks for it + // or when at least one of the services has a `watch` configuration. by + // default we avoid spinning up chokidar when not needed since it holds an + // active file descriptor and has been observed to keep the process alive + // even after the root scope has been cancelled. + const shouldWatch = + options?.watch === true || + Object.values(effectiveServices).some((d) => Array.isArray(d.watch)); + + const watcher = shouldWatch + ? yield* useWatcher( + effectiveServices, + options?.watchDebounce ? { watchDebounce: options.watchDebounce } : undefined, + ) + : undefined; + + for (const name of Object.keys(effectiveServices)) { + const def = effectiveServices[name]; + status.set(name, { + startup: withResolvers(), + running: withResolvers(), + }); + if (def.watch && watcher) { + watcher.add(name, def.watch); + } + } + + function* bumpService(service: string) { + yield* useAttributes({ + name: "watcher", + reason: `restarting service ${service}`, + }); + const task = status.get(service); + if (!task) throw new Error(`missing status for service ${service}`); + + const metadata = getOperationMetadata(effectiveServices[service].operation); + if (metadata?.watchSafe === false) { + yield* logger.stderr( + `warning: watched service '${service}' uses ${metadata.operationName ?? "an operation"} which may not reload module cache on restart. Skipping restart for this service.`, + ); + return; + } + + // log so it is clear in the inspector output when a restart is triggered + yield* logger.stdout(`restarting service ${service}`); + // refresh the startup resolver + task.startup = withResolvers(); + + // remove any recorded port/pid for the service; it will be re-registered when it starts again + delete task.port; + delete task.pid; + + // signal the running operation to stop so it can clean up + task.running.resolve(); + } + + if (watcher) { + yield* spawn(function* () { + yield* useAttributes({ + name: "watcher", + reason: "startup", + }); + // restart propagation to dependents is handled by useWatcher + for (let restartService of yield* each(watcher.serviceChanges)) { + yield* bumpService(restartService.service); + yield* each.next(); + } + }); + } + + // small helper to await a service's dependencies + function* waitDeps(name: string, restartCount: number): Operation { + const deps = + restartCount === 0 + ? (effectiveServices[name].dependsOn?.startup ?? []) + : (effectiveServices[name].dependsOn?.restart ?? []); + yield* useAttributes({ + name: `service ${name}`, + depCount: String(deps.length), + }); + for (const dep of deps) { + const r = status.get(dep); + if (!r) throw new Error(`missing readiness resolver for dependency '${dep}'`); + yield* r.startup.operation; + } + } + + function* withRestarts(service: string) { + // start at -1 so the first run is "restarted 0 times" + let restartCount = -1; + yield* useAttributes({ + name: `service ${service}`, + dependencies: JSON.stringify(effectiveServices[service].dependsOn ?? {}), + }); + while (true) { + yield* useAttributes({ + name: `service ${service}`, + status: `restarted ${++restartCount} times`, + }); + yield* waitDeps(service, restartCount); + + const def = effectiveServices[service]; + const task = status.get(service); + if (!task) throw new Error(`missing status for service '${service}'`); + + // each run gets its own running resolver so we can cancel it on demand + task.running = withResolvers(); + + // run the service in a scoped child operation so it can be cleanly + // cancelled when a file change triggers a restart + const serviceTask = yield* spawn(function* () { + // capture any returned listening info (e.g., from useSimulation) + const maybeProvided = yield* def.operation; + if (maybeProvided && typeof maybeProvided === "object") { + if ("port" in maybeProvided && typeof maybeProvided.port === "number") { + yield* useAttributes({ + name: `service ${service}`, + port: String(maybeProvided.port), + }); + task.port = maybeProvided.port; + } + if ("pid" in maybeProvided && typeof maybeProvided.pid === "number") { + task.pid = maybeProvided.pid; + yield* useAttributes({ + name: `service ${service}`, + pid: String(maybeProvided.pid), + }); + } + } + + task.startup.resolve(); + // wait until the watcher asks for this service to be restarted + yield* task.running.operation; + }); + yield* serviceTask; + } + } + + try { + for (let service of Object.keys(effectiveServices)) { + yield* spawn(function* () { + yield* useAttributes({ + name: `service ${service}`, + }); + yield* logger.debug(`service graph: spawning service ${service}`); + yield* withRestarts(service); + }); + } + + // Only resolve the graph after each requested service has completed its + // initial startup so promise callers can use returned ports and other + // runtime metadata immediately. + for (let service of Object.keys(effectiveServices)) { + const started = status.get(service); + if (!started) throw new Error(`missing startup state for service '${service}'`); + yield* started.startup.operation; + } + + yield* provide({ + services: services as S, + serviceUpdates: watcher?.serviceUpdates, + serviceChanges: watcher?.serviceChanges, + status, + }); + } finally { + yield* logger.debug("shutting down service graph"); + } + }), + ) as ServiceGraphOperation; + + return graph; + }) as ServiceGraphRunner; + + runner.task = function serviceGraphRunnerTask(subset?: Array) { + return runner(subset).task(); + }; + + return runner; +} diff --git a/packages/server/src/simulation.ts b/packages/server/src/simulation.ts new file mode 100644 index 00000000..b20646c8 --- /dev/null +++ b/packages/server/src/simulation.ts @@ -0,0 +1,233 @@ +import { resource, until, spawn, withResolvers } from "effection"; +import { useAttributes } from "./logging.ts"; +import type { Operation } from "effection"; +import { daemon, Stdio } from "@effectionx/process"; +import { logger } from "./logging.ts"; +import type { + FoundationSimulator, + FoundationSimulatorListening, +} from "@simulacrum/foundation-simulator"; +import { SimulacrumEndpoint } from "./services.ts"; +import { fileURLToPath } from "node:url"; +import { versions } from "node:process"; +import { withOperationMetadata } from "./operation-metadata.ts"; + +type UseSimulationOptions = { + subprocess?: boolean; +}; + +function useSimulationInProcess>( + name: string, + createFactory: (initData?: unknown) => FoundationSimulator, +): Operation> { + return withOperationMetadata( + resource(function* (provide) { + yield* useAttributes({ name: `useSimulation ${name}` }); + // attempt to read the simulacrum port from context; if not present, continue without it + const simulacrumPort = yield* SimulacrumEndpoint.get(); + + // if present fetch the data chunk and pass it to the factory + let initData: unknown | undefined = undefined; + if (typeof simulacrumPort === "number" && !Number.isNaN(simulacrumPort)) { + try { + const res = yield* until(fetch(`http://127.0.0.1:${simulacrumPort}/data`)); + initData = yield* until(res.json()); + } catch (err) { + // ignore fetch failures + yield* logger.stderr("failed to fetch simulacrum data:", err); + } + } + + const createSim = createFactory(initData); + const listening: FoundationSimulatorListening = yield* until(createSim.listen()); + + yield* logger.stdout(`${name} simulation: port ${listening.port}`); + yield* useAttributes({ + name: `useSimulation ${name}`, + port: String(listening.port), + }); + + try { + yield* provide(listening); + } finally { + yield* until(listening.ensureClose()); + yield* logger.stdout(`${name} simulation: closed port ${listening.port}`); + } + }), + { watchSafe: false, operationName: "useSimulation" }, + ); +} + +// Spawn a child Node process to run a simulation factory in a fresh module +// environment. This avoids sharing module cache and allows restarts to pick up +// new code. The runtime uses `bin/run-simulation-child.ts`. +/** + * Spawn a child Node process to run a simulation factory. + * + * This runs `bin/run-simulation-child.ts ` in a separate Node + * process and reads the first JSON line printed to stdout to discover the + * child's listening port. Optionally the simulacrum gateway port will be + * passed to the child so it can fetch `globalData`. + * + * @param name - human-friendly name for logging + * @param modulePath - path to the module exporting a simulation factory or instance + * @returns an `Operation` that provides `FoundationSimulatorListening` from the child + */ +export function useSimulationChildProcess(name: string, modulePath: string) { + return withOperationMetadata( + resource<{ port: number; pid: number }>(function* (provide) { + yield* useAttributes({ + name: `useSimulation ${name}`, + module: modulePath, + }); + // attempt to read the simulacrum port from context; if not present, continue without it + const contextPort = yield* SimulacrumEndpoint.get(); + + const runnerPath = fileURLToPath(new URL("../bin/run-simulation-child.ts", import.meta.url)); + // TODO config to overwrite the hard coded option here + const parts = ( + Number(versions.node.split(".")[0]) >= 24 + ? ["node"] + : [ + "node", + // safest considering current LTS of >v20 + "--experimental-strip-types", + ] + ).concat([runnerPath, modulePath]); + if (typeof contextPort === "number") { + parts.push("--simulacrum-port", String(contextPort)); + } + const cmd = parts.map((s) => (s.includes(" ") ? `'${s}'` : s)).join(" "); + + // read the first stdout JSON line to get the listening info + let port = undefined as number | undefined; + yield* Stdio.around({ + *stdout(line, _next) { + const [bytes] = line; + const str = bytes.toString(); + if (!port) { + try { + const parsed = JSON.parse(str); + if (parsed && parsed.ready && typeof parsed.port === "number") { + port = parsed.port; + ready.resolve(); + } else { + yield* logger.stdout(str); + } + } catch (ignore) { + // just log lines that are not JSON + yield* logger.stdout(str); + } + } else { + yield* logger.stdout(str); + } + }, + *stderr(line, _next) { + const [bytes] = line; + const str = bytes.toString(); + yield* logger.stderr(str); + }, + }); + + const process = yield* daemon(cmd); + const pid = process.pid; + yield* useAttributes({ + name: `useSimulation ${name}`, + cmd, + pid: String(pid), + }); + + let ready = withResolvers("wait until the port is returned to signal ready"); + + // spawn a watcher to detect if the child exits before printing the listening info + let status: unknown = undefined; + yield* spawn(function* () { + yield* useAttributes({ + name: "childEarlyExitWatcher", + }); + status = yield* process.join(); + if (!port) { + ready.reject( + new Error( + `child process exited before emitting listening info: ${JSON.stringify(status)}`, + ), + ); + } + }); + + // wait to get the listening info from stdout (or reject if the process exited) + yield* ready.operation; + + if (!port) { + throw new Error( + `failed to get listening port from child process: ${JSON.stringify({ + status, + pid, + })}`, + ); + } + + yield* logger.debug(`${name} simulation: port ${port} pid ${pid}`); + + try { + yield* provide({ port, pid }); + } finally { + yield* logger.debug(`${name} simulation: closed on port ${port}`); + } + }), + { watchSafe: true, operationName: "useSimulation" }, + ); +} + +/** + * Run a simulator either in-process or in a child Node subprocess. + * + * When the second argument is a factory, `useSimulation` runs the simulator + * in-process and resolves to the simulator's listening information. + * + * When the second argument is a module path string, `useSimulation` starts the + * simulator in a fresh child process and resolves to the child's listening + * information plus PID. + * + * If `globalData` is configured on the runner, this operation fetches the + * data from the Simulacrum gateway and passes it as `initData` to the factory + * or child module. + * + * @param name - human-friendly name used for logging + * @param createFactory - factory function that returns a `FoundationSimulator` + * @param modulePath - path to a module exporting a simulator factory + * @param options - optional subprocess hint for overload resolution + */ +export function useSimulation>( + name: string, + createFactory: (initData?: unknown) => FoundationSimulator, + options?: { subprocess?: false }, +): Operation>; +export function useSimulation( + name: string, + modulePath: string, + options?: { subprocess?: true }, +): Operation<{ port: number; pid: number }>; +export function useSimulation>( + name: string, + factoryOrModulePath: ((initData?: unknown) => FoundationSimulator) | string, + options: UseSimulationOptions = {}, +) { + if (typeof factoryOrModulePath === "string") { + if (options.subprocess === false) { + throw new Error( + "cannot use subprocess:false when the second argument is a module path string", + ); + } + + return useSimulationChildProcess(name, factoryOrModulePath); + } + + if (options.subprocess === true) { + throw new Error( + "subprocess:true is only supported when using a module path string as the second argument", + ); + } + + return useSimulationInProcess(name, factoryOrModulePath); +} diff --git a/packages/server/src/taskable.ts b/packages/server/src/taskable.ts new file mode 100644 index 00000000..3e20a5c8 --- /dev/null +++ b/packages/server/src/taskable.ts @@ -0,0 +1,50 @@ +import { type Operation, type Task, run, suspend } from "effection"; + +export type StartableTask = Promise & { + start(): Promise; + halt(): Promise; + running: Task; +}; + +export type StartedTask> = Awaited>; + +export type TaskableOperation = Operation & { + task(): StartableTask; +}; + +export function taskable>(operation: O): O & TaskableOperation { + let target = operation as O & TaskableOperation; + + target.task = function taskableOperationTask() { + let running!: Task; + let resolveReady!: (value: T) => void; + let rejectReady!: (reason?: unknown) => void; + + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + running = run(function* () { + try { + resolveReady(yield* operation); + yield* suspend(); + } catch (error) { + rejectReady(error); + throw error; + } + }); + + return Object.assign(ready, { + running, + start() { + return ready; + }, + halt() { + return Promise.resolve(running.halt()); + }, + }); + }; + + return target; +} diff --git a/packages/server/src/watch.ts b/packages/server/src/watch.ts new file mode 100644 index 00000000..36c51d20 --- /dev/null +++ b/packages/server/src/watch.ts @@ -0,0 +1,153 @@ +import { join } from "node:path"; +import chokidar, { type EmitArgs } from "chokidar"; +import { + createChannel, + createSignal, + each, + type Operation, + resource, + spawn, + type Stream, + until, +} from "effection"; +import { useAttributes } from "./logging.ts"; +import picomatch, { type Matcher } from "picomatch"; +import { filter } from "@effectionx/stream-helpers"; + +export type ServiceUpdate = { service: string; path: string }; + +/** + * Start a file watcher for services and provide streams of updates. + * + * This helper wraps `chokidar` and computes optional transitive dependents + * (based on `dependsOn.restart`) so that updates can be propagated to + * dependent services. The returned object exposes: + * + * - `serviceUpdates`: immediate updates for a service (`{service, path}`) + * - `serviceChanges`: debounced updates suitable for restart propagation + * - `add(service, paths)`: add watch paths for a service + * + * @param services - optional service map used to compute transitive dependents + * @param options - optional `{ watchDebounce?: number }` to configure debounce + */ +export function useWatcher( + services?: Record< + string, + { dependsOn?: { restart?: readonly string[] }; watchDebounce?: number } + >, + options?: { watchDebounce?: number }, +): Operation<{ + serviceUpdates: Stream<{ service: string; path: string }, void>; + serviceChanges: Stream<{ service: string; path: string }, void>; + add: (service: string, paths: string[]) => void; +}> { + return resource(function* (provide) { + yield* useAttributes({ + name: "watcher", + serviceCount: String(services ? Object.keys(services).length : 0), + debounce: String(options?.watchDebounce ?? ""), + }); + const changes = createSignal(); + const serviceUpdates = createChannel(); + const serviceList = new Map(); + + const watcher = chokidar.watch([], { + ignoreInitial: true, + }); + + watcher.on("change", (...args) => { + changes.send(args); + }); + + function add(service: string, paths: string[]) { + // Convert directory paths into recursive globs so that picomatch will + // match any files under those directories. Include the original path + // as well so exact matches still work. + const globs = paths.flatMap((p) => [p, join(p, "**")]); + const matchers = globs.map((g) => picomatch(g)); + serviceList.set(service, matchers); + watcher.add(paths); + } + + // precompute transitive dependents map if services are provided. This allows + // the watcher to emit updates not only for the changed service but also for + // any services that declare it in dependsOn.restart, transitively. + const dependentsMap = new Map(); + if (services) { + const restartAdj: Record = {}; + for (const name of Object.keys(services)) restartAdj[name] = []; + for (const name of Object.keys(services)) { + const def = services[name]; + for (const dep of def.dependsOn?.restart ?? []) { + if (!(dep in restartAdj)) continue; + restartAdj[dep].push(name); + } + } + + for (const n of Object.keys(services)) { + const seen = new Set(); + const stack = [...(restartAdj[n] ?? [])]; + while (stack.length) { + const cur = stack.pop()!; + if (seen.has(cur)) continue; + seen.add(cur); + for (const next of restartAdj[cur] ?? []) { + if (!seen.has(next)) stack.push(next); + } + } + dependentsMap.set(n, Array.from(seen)); + } + } + + yield* spawn(function* () { + yield* useAttributes({ name: "handleChange" }); + for (let args of yield* each(changes)) { + const [path] = args as EmitArgs; + for (let [service, matchers] of serviceList.entries()) { + const isAffected = matchers.some((matcher) => { + return matcher(path); + }); + if (isAffected) { + // send update for the service itself + yield* serviceUpdates.send({ service, path }); + // then also send updates for its transitive dependents (if any) + const dependents = dependentsMap.get(service) ?? []; + for (const d of dependents) { + yield* serviceUpdates.send({ service: d, path }); + } + } + } + yield* each.next(); + } + }); + + const debounceMs = options?.watchDebounce !== undefined ? options.watchDebounce : 250; + const serviceTimers = {} as Record; + const debouncedServiceChanges = filter(function* (updateStream) { + yield* useAttributes({ + name: "debounceCheck", + service: updateStream.service, + }); + const now = performance.now(); + if ( + serviceTimers[updateStream.service] && + now - serviceTimers[updateStream.service] < debounceMs + ) { + return false; + } else { + serviceTimers[updateStream.service] = now; + return true; + } + }); + + try { + yield* provide({ + serviceUpdates, + add, + serviceChanges: debouncedServiceChanges(serviceUpdates), + }); + } finally { + yield* until(watcher.close()); + } + }); +} diff --git a/packages/server/test/child-simulation.test.ts b/packages/server/test/child-simulation.test.ts new file mode 100644 index 00000000..b271caf6 --- /dev/null +++ b/packages/server/test/child-simulation.test.ts @@ -0,0 +1,162 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { run, until } from "effection"; +import { useServiceGraph } from "../src/services.ts"; +import { useSimulation } from "../src/simulation.ts"; +import { waitFor } from "./utils.ts"; + +describe("useSimulation (child process)", () => { + it("starts a child and returns port", async () => { + await run(function* () { + const listening = yield* useSimulation("child-test", "./test/fixtures/simple-sim.ts"); + assert(typeof listening.port === "number"); + + // Verify we received a port and the child reported ready. + assert(typeof listening.port === "number", "port should be a number"); + }); + }); + + it("supports module path child simulations via useSimulation", async () => { + await run(function* () { + const listening = yield* useSimulation("child-test", "./test/fixtures/simple-sim.ts"); + assert(typeof listening.port === "number"); + assert(typeof listening.pid === "number"); + }); + }); + + it("handles non-JSON stdout before ready JSON from child", async () => { + await run(function* () { + const listening = yield* useSimulation("non-json", "./test/fixtures/non-json-child.ts"); + assert(typeof listening.port === "number"); + }); + }); + + it("ignores JSON logs without ready/port until real ready JSON is emitted", async () => { + await run(function* () { + const listening = yield* useSimulation( + "json-before-ready", + "./test/fixtures/json-before-ready.ts", + ); + assert(typeof listening.port === "number"); + }); + }); + + describe("globalData forwarding", () => { + it("forwards nested objects as globalData to child simulations", async () => { + await run(function* () { + const data = { a: { b: { c: 3 } }, flag: true }; + + const op = useServiceGraph( + { + child: { + operation: useSimulation("child", "./test/fixtures/init-data-sim.ts"), + }, + }, + { globalData: data }, + ); + + const runGraph = yield* op(); + yield* waitFor(() => typeof runGraph.status?.get("child")?.port === "number", 2000); + const childPort = runGraph.status!.get("child")!.port!; + + const res = yield* until(fetch(`http://127.0.0.1:${childPort}/init`)); + const json = (yield* until(res.json())) as { initData: typeof data }; + assert.deepStrictEqual(json.initData, data); + }); + }); + + it("forwards arrays as globalData to child simulations", async () => { + await run(function* () { + const data = { list: [1, 2, 3], nested: [{ x: 1 }, { x: 2 }] }; + + const op = useServiceGraph( + { + child: { + operation: useSimulation("child", "./test/fixtures/init-data-sim.ts"), + }, + }, + { globalData: data }, + ); + + const runGraph = yield* op(); + yield* waitFor(() => typeof runGraph.status?.get("child")?.port === "number", 2000); + const childPort = runGraph.status!.get("child")!.port!; + + const res = yield* until(fetch(`http://127.0.0.1:${childPort}/init`)); + const json = (yield* until(res.json())) as { initData: typeof data }; + assert.deepStrictEqual(json.initData, data); + }); + }); + + it("forwards deeply nested values and special types to child simulations", async () => { + await run(function* () { + const data = { + users: [ + { + id: 1, + name: "alice", + prefs: { theme: "dark", tags: ["a", "b"] }, + }, + { id: 2, name: "bob", prefs: { theme: "light", tags: [] } }, + ], + meta: { + created: "2026-01-01", + count: 2, + active: true, + nothing: null, + }, + }; + + const op = useServiceGraph( + { + child: { + operation: useSimulation("child", "./test/fixtures/init-data-sim.ts"), + }, + }, + { globalData: data }, + ); + + const runGraph = yield* op(); + yield* waitFor(() => typeof runGraph.status?.get("child")?.port === "number", 3000); + const childPort = runGraph.status!.get("child")!.port!; + + const res = yield* until(fetch(`http://127.0.0.1:${childPort}/init`)); + const json = (yield* until(res.json())) as { initData: typeof data }; + assert.deepStrictEqual(json.initData, data); + }); + }); + }); + + it("child simulation receives globalData via simulacrum gateway and registers its port", async () => { + await run(function* () { + const op = useServiceGraph( + { + child: { + operation: useSimulation("child", "./test/fixtures/init-data-sim.ts"), + }, + }, + { globalData: { hello: "world" } }, + ); + + const runGraph = yield* op(); + + // wait deterministically for the child port to be registered + yield* waitFor(() => typeof runGraph.status?.get("child")?.port === "number", 3000); + const childPort = runGraph.status!.get("child")!.port!; + + const res = yield* until(fetch(`http://127.0.0.1:${childPort}/init`)); + const json = (yield* until(res.json())) as { + initData: { hello: string }; + }; + assert.deepStrictEqual(json.initData, { hello: "world" }); + }); + }); + + it("rejects when child exits before emitting listening info", async () => { + await assert.rejects(async () => { + await run(function* () { + yield* useSimulation("broken", "./test/fixtures/broken-child.ts"); + }); + }, /child process exited before emitting listening info/); + }); +}); diff --git a/packages/server/test/data-service.test.ts b/packages/server/test/data-service.test.ts new file mode 100644 index 00000000..3a0fba67 --- /dev/null +++ b/packages/server/test/data-service.test.ts @@ -0,0 +1,62 @@ +import { it } from "node:test"; +import assert from "node:assert"; +import { run, until } from "effection"; +import { useServiceGraph } from "../src/services.ts"; +import { waitFor } from "./utils.ts"; + +it("starts data service and serves configured data", async () => { + await run(function* () { + const runGraph = yield* useServiceGraph( + {}, + { + globalData: { a: 1, nested: { b: 2 } }, + }, + )(); + + // wait deterministically for the simulacrum port to be registered + yield* waitFor(() => typeof runGraph.status?.get("simulacrum")?.port === "number", 2000); + const port = runGraph.status!.get("simulacrum")!.port!; + + assert.ok(typeof port === "number", "data service port should be registered on serviceStatus"); + + const res = yield* until(fetch(`http://127.0.0.1:${port}/data`)); + const json = yield* until(res.json()); + assert.deepStrictEqual(json, { a: 1, nested: { b: 2 } }); + }); +}); + +it("serves individual keys and appropriate status codes", async () => { + await run(function* () { + const runGraph = yield* useServiceGraph( + {}, + { + globalData: { a: 1, nested: { b: 2 } }, + }, + )(); + + yield* waitFor(() => typeof runGraph.status?.get("simulacrum")?.port === "number", 2000); + const port = runGraph.status!.get("simulacrum")!.port!; + + assert.ok(typeof port === "number"); + + // existing key + const aRes = yield* until(fetch(`http://127.0.0.1:${port}/data/a`)); + assert.strictEqual(aRes.status, 200); + const aJson = yield* until(aRes.json()); + assert.deepStrictEqual(aJson, 1); + + // nested key returns object + const nestedRes = yield* until(fetch(`http://127.0.0.1:${port}/data/nested`)); + assert.strictEqual(nestedRes.status, 200); + const nestedJson = yield* until(nestedRes.json()); + assert.deepStrictEqual(nestedJson, { b: 2 }); + + // missing key returns 404 + const missRes = yield* until(fetch(`http://127.0.0.1:${port}/data/does-not-exist`)); + assert.strictEqual(missRes.status, 404); + + // empty key returns 400 + const emptyRes = yield* until(fetch(`http://127.0.0.1:${port}/data/`)); + assert.strictEqual(emptyRes.status, 400); + }); +}); diff --git a/packages/server/test/examples-smoke.test.ts b/packages/server/test/examples-smoke.test.ts new file mode 100644 index 00000000..5e9b48a1 --- /dev/null +++ b/packages/server/test/examples-smoke.test.ts @@ -0,0 +1,95 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { run, until } from "effection"; +import { waitFor, waitForOperation } from "./utils.ts"; + +import { services as basicServices } from "../example/simulation-graph.ts"; +import { services as concurrencyServices } from "../example/concurrency-layers.ts"; +import { services as processServices } from "../example/process-graph.ts"; + +describe("example as service rig", { concurrency: 1 }, () => { + it("basic example imports and runs", async () => { + await run(function* () { + let provided = yield* basicServices(); + + // wait until all declared services have registered a port + yield* waitFor(() => { + return ["A", "B"].every((name) => typeof provided?.status?.get(name)?.port === "number"); + }, 5000); + + if (!provided.status) { + throw new Error(`expected service status to be available after services started`); + } + + const ps: number[] = []; + for (const name of Object.keys(provided.services)) { + const port = provided.status.get(name)?.port; + if (typeof port === "number") ps.push(port); + } + + assert(ps.length > 0, "expected at least one service port to be registered"); + assert.ok(ps[0], "service A should have a port registered"); + assert.ok(ps[1], "service B should have a port registered"); + + // check each tapped port for healthy status while graph is running + for (const p of ps) { + yield* waitForOperation(function* () { + const status = yield* until(fetch("http://localhost:" + p + "/status")); + return status.ok; + }, 2000); + } + }); + }); + + it("concurrency example imports and runs", async () => { + await run(function* () { + let provided = yield* concurrencyServices(); + + // wait until child simulation services have registered a port + yield* waitFor(() => { + return ["fast", "slow"].every( + (name) => typeof provided?.status?.get(name)?.port === "number", + ); + }, 5000); + + if (!provided.status) { + throw new Error(`expected service status to be available after services started`); + } + + const ps: number[] = []; + for (const name of Object.keys(provided.services)) { + const port = provided.status.get(name)?.port; + if (typeof port === "number") ps.push(port); + } + + assert(ps.length > 0, "expected at least one service port to be registered"); + assert.ok(ps[0], "service fast should have a port registered"); + assert.ok(ps[1], "service slow should have a port registered"); + + // check each tapped port for healthy status while graph is running + for (const p of ps) { + yield* waitForOperation(function* () { + const status = yield* until(fetch("http://localhost:" + p + "/status")); + return status.ok; + }, 2000); + } + }); + }); + + it("process example imports and runs", async () => { + await run(function* () { + let provided = yield* processServices(); + + yield* waitFor(() => { + return ["A", "B"].every((name) => provided?.status?.has(name)); + }, 5000); + + for (const port of [3301, 3302]) { + yield* waitForOperation(function* () { + const status = yield* until(fetch(`http://localhost:${port}/status`)); + return status.ok; + }, 5000); + } + }); + }); +}); diff --git a/packages/server/test/fixtures/broken-child.ts b/packages/server/test/fixtures/broken-child.ts new file mode 100644 index 00000000..baa44203 --- /dev/null +++ b/packages/server/test/fixtures/broken-child.ts @@ -0,0 +1,5 @@ +export default function simulation() { + // deliberately exit immediately so the parent sees a child that dies + // before it can emit its listening-info JSON line + process.exit(1); +} diff --git a/packages/server/test/fixtures/init-data-sim.ts b/packages/server/test/fixtures/init-data-sim.ts new file mode 100644 index 00000000..8f80b12b --- /dev/null +++ b/packages/server/test/fixtures/init-data-sim.ts @@ -0,0 +1,12 @@ +import { createFoundationSimulationServer } from "@simulacrum/foundation-simulator"; + +import type { FoundationSimulator } from "@simulacrum/foundation-simulator"; + +export function simulation(initData?: unknown): FoundationSimulator { + return createFoundationSimulationServer({ + port: 0, + extendRouter(router) { + router.get("/init", (_req, res) => res.json({ initData })); + }, + })(); +} diff --git a/packages/server/test/fixtures/json-before-ready.ts b/packages/server/test/fixtures/json-before-ready.ts new file mode 100644 index 00000000..88f86c5f --- /dev/null +++ b/packages/server/test/fixtures/json-before-ready.ts @@ -0,0 +1,16 @@ +import { createFoundationSimulationServer } from "@simulacrum/foundation-simulator"; + +// Print a JSON payload that does not contain `ready`/`port` fields +// This should be treated as a log line by the parent and ignored for readiness +console.log(JSON.stringify({ foo: "bar" })); + +import type { FoundationSimulator } from "@simulacrum/foundation-simulator"; + +export function simulation(): FoundationSimulator { + return createFoundationSimulationServer({ + port: 0, + extendRouter(router) { + router.get("/info", (_req, res) => res.json({ ok: true })); + }, + })(); +} diff --git a/packages/server/test/fixtures/non-json-child.ts b/packages/server/test/fixtures/non-json-child.ts new file mode 100644 index 00000000..af1ae77c --- /dev/null +++ b/packages/server/test/fixtures/non-json-child.ts @@ -0,0 +1,14 @@ +import { createFoundationSimulationServer } from "@simulacrum/foundation-simulator"; + +import type { FoundationSimulator } from "@simulacrum/foundation-simulator"; + +export function simulation(): FoundationSimulator { + // print some non-JSON diagnostic before the line the parent will JSON-parse + console.log("preflight: starting up"); + return createFoundationSimulationServer({ + port: 0, + extendRouter(router) { + router.get("/info", (_req, res) => res.json({ ok: true })); + }, + })(); +} diff --git a/packages/server/test/fixtures/simple-sim.ts b/packages/server/test/fixtures/simple-sim.ts new file mode 100644 index 00000000..6a0c6168 --- /dev/null +++ b/packages/server/test/fixtures/simple-sim.ts @@ -0,0 +1,10 @@ +import { + createFoundationSimulationServer, + type FoundationSimulator, +} from "@simulacrum/foundation-simulator"; + +export function simulation(port: number = 9999): FoundationSimulator { + return createFoundationSimulationServer({ + port, + })(); +} diff --git a/packages/server/test/replay-signal.test.ts b/packages/server/test/replay-signal.test.ts new file mode 100644 index 00000000..cc795501 --- /dev/null +++ b/packages/server/test/replay-signal.test.ts @@ -0,0 +1,38 @@ +import { it } from "node:test"; +import assert from "node:assert"; +import { run } from "effection"; +import { createReplaySignal } from "../src/createReplaySignal.ts"; + +it("replays queued items to new subscribers and supports close", async () => { + await run(function* () { + const sig = createReplaySignal(); + + // send some items before subscribing + sig.send("a"); + sig.send("b"); + + // subscribe + const sub = yield* sig; + + // expect queued items in order + const first = yield* sub.next(); + assert.strictEqual(first.done, false); + assert.strictEqual(first.value, "a"); + + const second = yield* sub.next(); + assert.strictEqual(second.done, false); + assert.strictEqual(second.value, "b"); + + // send a new item and see it + sig.send("c"); + const third = yield* sub.next(); + assert.strictEqual(third.done, false); + assert.strictEqual(third.value, "c"); + + // close the signal with a value + sig.close("fin"); + const closed = yield* sub.next(); + assert.strictEqual(closed.done, true); + assert.strictEqual(closed.value, "fin"); + }); +}); diff --git a/packages/server/test/service.test.ts b/packages/server/test/service.test.ts index 2d2a9dfa..10a786ec 100644 --- a/packages/server/test/service.test.ts +++ b/packages/server/test/service.test.ts @@ -5,15 +5,28 @@ import { each, Err, Ok, run } from "effection"; // these npm scripts don't work, but this is what we are trying to run // const scriptDoesNotWork = "npm run test:service-main"; -const nodeScriptWorks = "node --experimental-transform-types ./test/service-main.ts"; +const nodeScriptWorks = "node --experimental-transform-types ./test/services/service-main.ts"; -it("test service", async () => { - let assertionCount = 0; +it("test service starts and prints expected startup message", async () => { + let sawStart = false; await run(function* () { - yield* useService("test-service", nodeScriptWorks); - assertionCount++; + yield* useService("test-service", nodeScriptWorks, { + wellnessCheck: { + timeout: 1000, + *operation(stdio) { + for (let line of yield* each(stdio)) { + if (line.includes("test service started")) { + return Ok(void 0); + } + yield* each.next(); + } + return Err(new Error("did not see startup message")); + }, + }, + }); + sawStart = true; }); - assert(assertionCount > 0); + assert.ok(sawStart, "service should have started and passed wellness check"); }); describe("useService with wellness check", () => { @@ -72,7 +85,7 @@ describe("useService with wellness check", () => { await run(function* () { yield* useService("test-service", nodeScriptWorks, { wellnessCheck: { - timeout: 500, + timeout: 1000, frequency: 200, *operation(stdio) { for (let line of yield* each(stdio)) { diff --git a/packages/server/test/services.test.ts b/packages/server/test/services.test.ts new file mode 100644 index 00000000..c3bbbe9e --- /dev/null +++ b/packages/server/test/services.test.ts @@ -0,0 +1,351 @@ +import { afterEach, beforeEach, describe, it } from "node:test"; +import assert from "node:assert"; +import { resource, run, sleep, spawn, suspend } from "effection"; +import { useServiceGraph } from "../src/services.ts"; +import { waitFor, waitForFetchClosed } from "./utils.ts"; +import { useService } from "../src/service.ts"; +import { useSimulation } from "../src/simulation.ts"; +import { type StartedTask } from "../src/taskable.ts"; +import { createFoundationSimulationServer } from "@simulacrum/foundation-simulator"; + +describe("shutdown", () => { + it("runs beforeStop hooks in reverse order", async () => { + const stopOrder: string[] = []; + const startedOrder: string[] = []; + await run(function* () { + // spawn and cancel automatically when run returns + yield* spawn(function* () { + const run = useServiceGraph({ + A: { + operation: resource(function* (provide) { + try { + yield* sleep(20); + startedOrder.push("A"); + yield* provide(); + } finally { + stopOrder.push("A"); + } + }), + }, + B: { + operation: resource(function* (provide) { + try { + yield* sleep(40); + startedOrder.push("B"); + yield* provide(); + } finally { + stopOrder.push("B"); + } + }), + dependsOn: { startup: ["A"] as const }, + }, + }); + yield* run(); + // keep spawned graph alive so beforeStop hooks run on teardown + yield* suspend(); + }); + // let them start + yield* waitFor(() => startedOrder.length === 2, 2000); + }); + assert.strictEqual(startedOrder.join(""), "AB"); + assert.strictEqual(stopOrder.join(""), "BA"); + }); +}); + +describe("dependency handling", () => { + it("starts services in dependency order", async () => { + const startTimes = new Map(); + try { + await run(function* () { + yield* spawn(function* () { + const graph = useServiceGraph({ + A: { + operation: resource(function* (provide) { + yield* sleep(20); + startTimes.set("A", Date.now()); + yield* provide(); + }), + }, + B: { + operation: resource(function* (provide) { + yield* sleep(40); + startTimes.set("B", Date.now()); + yield* provide(); + }), + dependsOn: { startup: ["A"] as const }, + }, + }); + yield* graph(); + // keep spawned graph alive + yield* suspend(); + }); + yield* waitFor(() => startTimes.has("A") && startTimes.has("B"), 2000); + }); + } catch (err) { + console.log("run error:", err instanceof Error ? err.stack : err); + } + + const aStarted = startTimes.get("A"); + const bStarted = startTimes.get("B"); + assert.ok(typeof aStarted === "number", "A started should be recorded"); + assert.ok(typeof bStarted === "number", "B started should be recorded"); + assert(aStarted! <= bStarted!, "A should start before B"); + }); + + it("starts independent services in parallel", async () => { + const startTimes = new Map(); + try { + await run(function* () { + yield* spawn(function* () { + const run = useServiceGraph({ + fast: { + operation: resource(function* (provide) { + yield* sleep(20); + startTimes.set("fast", Date.now()); + yield* provide(); + }), + }, + slow: { + operation: resource(function* (provide) { + yield* sleep(50); + startTimes.set("slow", Date.now()); + yield* provide(); + }), + }, + }); + yield* run(); + // keep spawned graph alive so services continue to run + yield* suspend(); + }); + yield* sleep(250); + }); + const fastStarted = startTimes.get("fast"); + const slowStarted = startTimes.get("slow"); + assert.ok(typeof fastStarted === "number", "fast started should be recorded"); + assert.ok(typeof slowStarted === "number", "slow started should be recorded"); + assert(fastStarted! <= slowStarted!, "fast should start before slow"); + } finally { + // cleanup + } + }); + + it("runs subset of services with dependencies", async () => { + const startTimes = new Map(); + await run(function* () { + yield* spawn(function* () { + const services = { + fast: { + operation: resource(function* (provide) { + console.log("test: fast operation starting"); + yield* sleep(20); + console.log("test: fast operation setting startTimes"); + startTimes.set("fast", Date.now()); + yield* provide(); + }), + }, + slow: { + operation: resource(function* (provide) { + console.log("test: slow operation starting"); + yield* sleep(50); + console.log("test: slow operation setting startTimes"); + startTimes.set("slow", Date.now()); + yield* provide(); + }), + }, + dependent: { + dependsOn: { startup: ["fast", "slow"] as const }, + operation: resource(function* (provide) { + // wait until both dependencies have recorded their start times + while (!startTimes.has("fast") || !startTimes.has("slow")) { + yield* sleep(5); + } + console.log("test: dependent operation starting after deps"); + startTimes.set("dependent", Date.now()); + yield* provide(); + }), + }, + }; + + // only request dependent; fast and slow should be included as deps + const run = useServiceGraph(services); + // request only 'dependent' — this should cause 'fast' and 'slow' to be included as dependencies + yield* run(["dependent"]); + // keep spawned graph alive so services can start and perform startup work + yield* suspend(); + }); + yield* waitFor(() => startTimes.has("fast") && startTimes.has("slow"), 2000); + }); + + const f = startTimes.get("fast"); + const s = startTimes.get("slow"); + const d = startTimes.get("dependent"); + assert.ok(typeof f === "number", "fast should start"); + assert.ok(typeof s === "number", "slow should start"); + assert.ok(typeof d === "number", "dependent should start"); + assert(f! <= d!, "fast should start before dependent"); + assert(s! <= d!, "slow should start before dependent"); + }); + + it("throws on cycles in dependency graph", async () => { + await assert.rejects(async () => { + await run(function* () { + const runGraph = useServiceGraph({ + A: { + operation: useService( + "A", + "node --experimental-transform-types ./test/services/service-a.ts", + ), + dependsOn: { startup: ["B"] as const }, + }, + B: { + operation: useService( + "B", + "node --experimental-transform-types ./test/services/service-b.ts", + ), + dependsOn: { startup: ["A"] as const }, + }, + }); + yield* runGraph(); + }); + }, /Cycle detected in services/); + }); +}); + +describe("service subsets", () => { + it("throws when requested subset includes a missing service", async () => { + await assert.rejects(async () => { + await run(function* () { + const services = { + a: { + operation: resource(function* (provide) { + yield* sleep(10); + yield* provide(); + }), + }, + }; + + const runGraph = useServiceGraph(services); + // request a service that does not exist + yield* runGraph(["missing"] as any); + }); + }, /Requested service 'missing' not found/); + }); + + it("runs subset specified as a string", async () => { + const startTimes = new Map(); + await run(function* () { + yield* spawn(function* () { + const services = { + a: { + operation: resource(function* (provide) { + yield* sleep(20); + startTimes.set("a", Date.now()); + yield* provide(); + }), + }, + b: { + operation: resource(function* (provide) { + yield* sleep(50); + startTimes.set("b", Date.now()); + yield* provide(); + }), + }, + r: { + dependsOn: { startup: ["a", "b"] as const }, + operation: resource(function* (provide) { + while (!startTimes.has("a") || !startTimes.has("b")) { + yield* sleep(5); + } + startTimes.set("r", Date.now()); + yield* provide(); + }), + }, + other: { + operation: resource(function* (provide) { + yield* sleep(10); + startTimes.set("other", Date.now()); + yield* provide(); + }), + }, + }; + + const run = useServiceGraph(services); + yield* run(["r"]); + yield* suspend(); + }); + yield* waitFor(() => startTimes.has("a") && startTimes.has("b") && startTimes.has("r"), 2000); + }); + + const a = startTimes.get("a"); + const b = startTimes.get("b"); + const r = startTimes.get("r"); + const other = startTimes.get("other"); + assert.ok(typeof a === "number", "a should start"); + assert.ok(typeof b === "number", "b should start"); + assert.ok(typeof r === "number", "r should start"); + assert.ok(typeof other === "undefined", "other should NOT start"); + }); +}); + +describe("tasks and promises", () => { + let starts = 0; + const graphRunner = useServiceGraph({ + a: { + operation: useSimulation("a", () => { + starts += 1; + return createFoundationSimulationServer({ + port: 0, + extendRouter(router) { + router.get("/info", (_req, res) => res.json({ ok: true, starts })); + }, + })(); + }), + }, + }); + + let task: ReturnType; + let services: StartedTask; + + beforeEach(async () => { + starts = 0; + task = graphRunner.task(); + services = await task.start(); + }); + + afterEach(async () => { + const port = services.status?.get("a")?.port; + await task.halt(); + if (port) { + await run(function* () { + yield* waitForFetchClosed(`http://127.0.0.1:${port}/info`, 2000); + }); + } + }); + + it("returns a task factory on the service graph runner", async () => { + assert.strictEqual(typeof graphRunner.task, "function"); + assert.strictEqual(typeof task.then, "function"); + assert.strictEqual(typeof task.start, "function"); + assert.strictEqual(typeof task.halt, "function"); + assert.ok(task.running, "task should expose the backing Effection task"); + + assert.notStrictEqual(services.status, undefined); + const port = services.status?.get("a")?.port; + assert.ok(port && port > 0, "service port should be available after beforeEach startup"); + }); + + it("can expose an awaitable task that starts once, stays running, and halts cleanly", async () => { + assert.notStrictEqual(services.status, undefined); + const simPort = services.status?.get("simulacrum")?.port; + assert.ok(simPort && simPort > 10000); + assert.strictEqual(typeof task.halt, "function"); + assert.strictEqual(starts, 1, "task should only start the simulator once before halt"); + + const port = services.status?.get("a")?.port; + assert.ok(port && port > 0, "service port should be available after awaiting the task"); + + const response = await fetch(`http://127.0.0.1:${port}/info`); + assert.strictEqual(response.status, 200); + assert.deepStrictEqual(await response.json(), { ok: true, starts: 1 }); + }); +}); diff --git a/packages/server/test/services/service-a.ts b/packages/server/test/services/service-a.ts new file mode 100644 index 00000000..9fa5d939 --- /dev/null +++ b/packages/server/test/services/service-a.ts @@ -0,0 +1,3 @@ +import { simulation as genSimulation } from "../../example/services/gen-sim-factory.ts"; + +export const simulation = genSimulation(4010, 10); diff --git a/packages/server/test/services/service-b.ts b/packages/server/test/services/service-b.ts new file mode 100644 index 00000000..e47e6f49 --- /dev/null +++ b/packages/server/test/services/service-b.ts @@ -0,0 +1,3 @@ +import { simulation as genSimulation } from "../../example/services/gen-sim-factory.ts"; + +export const simulation = genSimulation(4020, 40); diff --git a/packages/server/test/service-main.ts b/packages/server/test/services/service-main.ts similarity index 100% rename from packages/server/test/service-main.ts rename to packages/server/test/services/service-main.ts diff --git a/packages/server/test/simulation.test.ts b/packages/server/test/simulation.test.ts new file mode 100644 index 00000000..052df628 --- /dev/null +++ b/packages/server/test/simulation.test.ts @@ -0,0 +1,53 @@ +import { it } from "node:test"; +import assert from "node:assert"; +import { run, createScope, suspend, until } from "effection"; +import { useSimulation } from "../src/simulation.ts"; +import { simulation } from "./fixtures/simple-sim.ts"; +import { createFoundationSimulationServer } from "@simulacrum/foundation-simulator"; +import { waitFor, waitForFetchClosed } from "./utils.ts"; + +it("useSimulation returns listening info", async () => { + const port = await run(function* () { + const listening = yield* useSimulation("test", () => simulation(3000)); + return listening.port; + }); + assert(typeof port === "number", "port is a number"); +}); + +it("simulation closes when scope is destroyed", async () => { + await run(function* () { + const [scope, destroy] = createScope(); + + let port: number | undefined; + + // start the simulation in the scope and keep it alive until destroy() + // where we can test it actually shutdown + scope.run(function* () { + const listening = yield* useSimulation( + "inline-test", + createFoundationSimulationServer({ + port: 0, + extendRouter(router) { + router.get("/info", (_req, res) => res.json({ ok: true })); + }, + }), + ); + port = listening.port; + yield* suspend(); + }); + + // wait for the scope-run to set the port + yield* waitFor(() => typeof port === "number", 2000); + + const status = yield* until(fetch(new URL(`http://127.0.0.1:${port}/info`))); + if (!status.ok) { + throw new Error(`expected 200 OK from simulation, got ${status.status}`); + } + + // now destroy the scope and ensure the server stops accepting connections + yield* until(destroy()); + + // server should no longer accept connections + yield* waitForFetchClosed(`http://127.0.0.1:${port}/info`, 2000); + }); +}); diff --git a/packages/server/test/utils.ts b/packages/server/test/utils.ts new file mode 100644 index 00000000..f53446dc --- /dev/null +++ b/packages/server/test/utils.ts @@ -0,0 +1,81 @@ +import { timebox } from "@effectionx/timebox"; +import { sleep, until, type Operation } from "effection"; + +/** + * Wait for `predicate` to become true with a timeboxed timeout. + * Throws on timeout. + */ +export function* waitFor(predicate: () => boolean, timeout = 2000): Operation { + const res = yield* timebox(timeout, function* () { + while (!predicate()) { + yield* sleep(10); + } + }); + + if (res && (res as any).timeout) { + throw new Error("timed out waiting for condition"); + } +} + +/** + * Cast a Node EventEmitter (e.g., `child.stdout`) to an EventTarget-like + * object with `addEventListener`/`removeEventListener`. This is useful for + * using `on()` with Node APIs that emit events. + */ +export function emitterToEventTarget(emitter: NodeJS.EventEmitter) { + return { + addEventListener(name: string, listener: (...args: any[]) => void) { + // Node's event listeners receive chunks or event args; keep signature loose + emitter.on(name as any, listener as any); + }, + removeEventListener(name: string, listener: (...args: any[]) => void) { + emitter.off(name as any, listener as any); + }, + } as EventTarget; +} + +/** + * Wait for an async predicate (returns Promise) to become true. + */ +export function* waitForOperation( + predicate: () => Operation, + timeout = 2000, +): Operation { + const res = yield* timebox(timeout, function* () { + while (true) { + try { + const ok = yield* predicate(); + if (ok) return; + } catch (ignore) { + // ignore and retry + } + yield* sleep(10); + } + }); + + if (res && res.timeout) { + throw new Error("timed out waiting for async condition"); + } +} + +/** + * Wait until fetching the given url fails (connection refused or other error) + * which is commonly used to detect a server shutting down. + */ +export function* waitForFetchClosed(url: string, timeout = 2000) { + const res = yield* timebox(timeout, function* () { + while (true) { + try { + const s = yield* until(fetch(url)); + if (!s.ok) return; + } catch (ignore) { + return; + } + yield* sleep(10); + } + }); + + if (res && (res as any).timeout) { + throw new Error("timed out waiting for fetch to fail"); + } +} diff --git a/packages/server/test/watch.test.ts b/packages/server/test/watch.test.ts new file mode 100644 index 00000000..94d07daf --- /dev/null +++ b/packages/server/test/watch.test.ts @@ -0,0 +1,362 @@ +import { it } from "node:test"; +import assert from "node:assert"; +import { run, suspend, sleep, until, spawn, resource, ensure, withResolvers } from "effection"; +import * as fs from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { useServiceGraph } from "../src/services.ts"; +import { simulation } from "./fixtures/simple-sim.ts"; +import { useSimulation } from "../src/simulation.ts"; +import { waitFor, waitForOperation } from "./utils.ts"; + +it("restarts services on watched file change and restarts dependents", async () => { + const prefix = path.join(os.tmpdir(), "sim-watch-"); + // create a temporary directory to hold test files + const dir = await fs.mkdtemp(prefix); + const trigger = path.join(dir, "trigger.txt"); + + // initial trigger file + await fs.writeFile(trigger, "initial"); + + const updates: string[] = []; + const subscriptionReady = withResolvers(); + + await run(function* () { + yield* spawn(function* () { + // start the graph and enable watch mode + const op = useServiceGraph( + { + a: { + watch: [dir], + operation: useSimulation("test-simulation-a", () => simulation(5500)), + }, + b: { + dependsOn: { startup: ["a"] as const }, + operation: useSimulation("test-simulation-a", () => simulation(5501)), + }, + }, + { watch: true, watchDebounce: 20 }, + ); + + const services = yield* op(); + // subscribe to the immediate raw serviceChanges stream and wait for the first update + if (!services.serviceChanges) throw new Error("serviceChanges not available"); + const subscription = yield* services.serviceChanges; + subscriptionReady.resolve(); + + // wait for the first raw update (will occur after the test touches the file) + const first = yield* subscription.next(); + updates.push(String((first.value as { service: string }).service)); + + yield* suspend(); + }); + + // ensure initial trigger is readable + yield* waitForOperation(function* () { + try { + yield* until(fs.readFile(trigger, "utf8")); + return true; + } catch (ignore) { + return false; + } + }, 2000); + + // wait for the subscription to be ready before touching the file + yield* subscriptionReady.operation; + + // touch the trigger file to cause a restart + yield* until(fs.writeFile(trigger, "changed")); + + // wait for the raw watcher update to be observed + yield* waitFor(() => updates.length >= 1, 5000); + }); + + // remove tmp dir + await fs.rm(dir, { recursive: true, force: true }); + + assert(updates.length >= 1, "expected at least one update"); + assert(updates[0] === "a", "first update is service 'a'"); +}); + +it("restarts dependents when watched service changes", async () => { + const prefix = path.join(os.tmpdir(), "sim-watch-rt-"); + const dir = await fs.mkdtemp(prefix); + const trigger = path.join(dir, "trigger.txt"); + await fs.writeFile(trigger, "initial"); + + const startCounts: Record = { a: 0, b: 0 }; + let provided: { status?: Map } | undefined; + const servicesReady = withResolvers(); + + await run(function* () { + yield* spawn(function* () { + const op = useServiceGraph( + { + a: { + watch: [dir], + operation: resource(function* (provide) { + startCounts.a += 1; + yield* provide(); + }), + }, + b: { + dependsOn: { startup: [] as const, restart: ["a"] as const }, + operation: resource(function* (provide) { + startCounts.b += 1; + yield* provide(); + }), + }, + }, + { watch: true, watchDebounce: 20 }, + ); + + provided = yield* op(); + servicesReady.resolve(); + + yield* suspend(); + }); + + // wait for initial startup + yield* waitFor(() => startCounts.a > 0 && startCounts.b > 0, 2000); + + // ensure we have access to status state from the running graph + yield* servicesReady.operation; + const beforeStartupA = provided?.status?.get("a")?.startup; + + const beforeA = startCounts.a; + const beforeB = startCounts.b; + let observed = false; + for (let attempt = 0; attempt < 6 && !observed; attempt++) { + // trigger a change (unique content each time to ensure chokidar sees an update) + yield* until(fs.writeFile(trigger, `changed-${Date.now()}-${attempt}`)); + + try { + // watcher restart processing rotates the service's startup resolver + yield* waitFor(() => provided?.status?.get("a")?.startup !== beforeStartupA, 2000); + + // wait for restarts to occur + yield* waitFor(() => startCounts.a > beforeA && startCounts.b > beforeB, 2000); + observed = true; + } catch (ignore) { + // retry with another write; CI can occasionally miss/coalesce single events + } + } + + if (!observed) { + throw new Error("timed out waiting for dependent restart after file changes"); + } + }); + + await fs.rm(dir, { recursive: true, force: true }); + + assert(startCounts.a >= 2, "a should have been restarted"); + assert(startCounts.b >= 2, "b should have been restarted as dependent"); +}); + +it("restarts transitive dependents when watched service changes", async () => { + const prefix = path.join(os.tmpdir(), "sim-watch-rt-2-"); + const dir = await fs.mkdtemp(prefix); + const trigger = path.join(dir, "trigger.txt"); + await fs.writeFile(trigger, "initial"); + + const startCounts: Record = { a: 0, b: 0, c: 0 }; + let provided: { status?: Map } | undefined; + const servicesReady = withResolvers(); + + await run(function* () { + yield* spawn(function* () { + const op = useServiceGraph( + { + a: { + watch: [dir], + operation: resource(function* (provide) { + startCounts.a += 1; + yield* provide(); + }), + }, + b: { + dependsOn: { startup: [] as const, restart: ["a"] as const }, + operation: resource(function* (provide) { + startCounts.b += 1; + yield* provide(); + }), + }, + c: { + dependsOn: { startup: [] as const, restart: ["b"] as const }, + operation: resource(function* (provide) { + startCounts.c += 1; + yield* provide(); + }), + }, + }, + { watch: true, watchDebounce: 20 }, + ); + + provided = yield* op(); + servicesReady.resolve(); + + yield* suspend(); + }); + + // wait for initial startup + yield* waitFor(() => startCounts.a > 0 && startCounts.b > 0 && startCounts.c > 0, 2000); + + // ensure we have access to status state from the running graph + yield* servicesReady.operation; + const beforeStartupA = provided?.status?.get("a")?.startup; + + const beforeA = startCounts.a; + const beforeB = startCounts.b; + const beforeC = startCounts.c; + let observed = false; + for (let attempt = 0; attempt < 6 && !observed; attempt++) { + // trigger a change (unique content each time to ensure chokidar sees an update) + yield* until(fs.writeFile(trigger, `changed-${Date.now()}-${attempt}`)); + + try { + // watcher restart processing rotates the service's startup resolver + yield* waitFor(() => provided?.status?.get("a")?.startup !== beforeStartupA, 2000); + + // wait for transitive restarts to occur + yield* waitFor( + () => startCounts.a > beforeA && startCounts.b > beforeB && startCounts.c > beforeC, + 2000, + ); + observed = true; + } catch (ignore) { + // retry with another write; CI can occasionally miss/coalesce single events + } + } + + if (!observed) { + throw new Error("timed out waiting for transitive restart after file changes"); + } + }); + + await fs.rm(dir, { recursive: true, force: true }); + + assert(startCounts.a >= 2, "a should have been restarted"); + assert(startCounts.b >= 2, "b should have been restarted as dependent"); + assert(startCounts.c >= 2, "c should have been restarted as dependent of b"); +}); + +it("updates status ports when a child simulation restarts", async () => { + const prefix = path.join(os.tmpdir(), "sim-port-rt-"); + const dir = await fs.mkdtemp(prefix); + const trigger = path.join(dir, "trigger.txt"); + await fs.writeFile(trigger, "initial"); + + await run(function* () { + const op = useServiceGraph( + { + s: { + watch: [dir], + operation: useSimulation("s", "./test/fixtures/init-data-sim.ts"), + }, + }, + { + watch: true, + watchDebounce: 20, + }, + ); + + const services = yield* op(); + + // wait for initial port to appear + yield* waitFor(() => typeof services.status?.get("s")?.port === "number", 2000); + const initial = services.status!.get("s")!.port!; + + // trigger restart by touching the file + yield* until(fs.writeFile(trigger, "changed")); + + // wait for new port value to be different from initial + yield* waitFor(() => { + const p = services.status?.get("s")?.port; + return typeof p === "number" && p !== initial; + }, 3000); + const updated = services.status!.get("s")!.port!; + + assert.ok(typeof initial === "number", "initial port should be present"); + assert.ok(typeof updated === "number", "updated port should be present"); + assert.notStrictEqual(initial, updated, "port should change after restart"); + }); + + await fs.rm(dir, { recursive: true, force: true }); +}); + +it("debounces rapid changes per service", async () => { + const prefix = path.join(os.tmpdir(), "sim-watch-debounce-"); + const dir = await fs.mkdtemp(prefix); + const trigger = path.join(dir, "trigger.txt"); + await fs.writeFile(trigger, "initial"); + + const updates: string[] = []; + let rawCount = 0; + const watcherReady = withResolvers(); + + await run(function* () { + yield* spawn(function* () { + const op = useServiceGraph( + { + a: { + watch: [dir], + operation: resource(function* (provide) { + yield* provide(); + }), + }, + }, + { watch: true, watchDebounce: 150 }, + ); + + const services = yield* op(); + if (!services.serviceUpdates || !services.serviceChanges) + throw new Error("service streams not available"); + const debSub = yield* services.serviceUpdates; + const rawSub = yield* services.serviceChanges; + + watcherReady.resolve(); + + // collect debounced updates + yield* spawn(function* () { + while (true) { + const n = yield* debSub.next(); + if (n.done) break; + updates.push((n.value as { service: string }).service); + } + }); + + // count raw updates (should reflect every write) + yield* spawn(function* () { + while (true) { + const n = yield* rawSub.next(); + if (n.done) break; + if ((n.value as { service: string }).service === "a") rawCount++; + } + }); + + yield* suspend(); + }); + + // ensure watcher subscribed before triggering writes + yield* watcherReady.operation; + + // write multiple times rapidly + yield* until(fs.writeFile(trigger, "changed-1")); + yield* sleep(10); + yield* until(fs.writeFile(trigger, "changed-2")); + yield* sleep(10); + yield* until(fs.writeFile(trigger, "changed-3")); + + yield* ensure(() => until(fs.rm(dir, { recursive: true, force: true }))); + // wait until some raw/etc updates are observed + yield* waitFor(() => rawCount > 0 && updates.length > 0, 2000); + }); + + // we expect the rapid writes to coalesce: there should be at least one + // raw and at least one debounced update, and debounced updates should be + // fewer than the number of writes (3) + assert(rawCount >= 1, `expected at least 1 raw update, got ${rawCount}`); + assert(updates.length >= 1, "expected at least one debounced update"); + const aCount = updates.filter((u) => u === "a").length; + assert(aCount < 3, `expected debounced updates to be fewer than writes (3), got ${aCount}`); +}); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json index 43e82621..f338bcfd 100644 --- a/packages/server/tsconfig.json +++ b/packages/server/tsconfig.json @@ -4,5 +4,5 @@ "baseUrl": ".", "outDir": "dist" }, - "include": ["src/**/*.ts", "test/**/*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "example/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f013a28..ddaa91b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,17 +184,33 @@ importers: packages/server: dependencies: '@effectionx/context-api': - specifier: ^0.2.1 - version: 0.2.1 + specifier: ~0.6.0 + version: 0.6.0(effection@4.0.2) '@effectionx/process': - specifier: ^0.6.2 - version: 0.6.2 + specifier: ~0.8.0 + version: 0.8.0(effection@4.0.2) + '@effectionx/stream-helpers': + specifier: ~0.8.2 + version: 0.8.2(effection@4.0.2) '@effectionx/timebox': - specifier: ^0.3.1 - version: 0.3.1 + specifier: ~0.4.3 + version: 0.4.3(effection@4.0.2) + chokidar: + specifier: ^5.0.0 + version: 5.0.0 effection: - specifier: ^4.0.0 + specifier: ^4.0.2 version: 4.0.2 + picomatch: + specifier: ^4.0.4 + version: 4.0.4 + devDependencies: + '@simulacrum/foundation-simulator': + specifier: workspace:^ + version: link:../foundation + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 packages/ui: {} @@ -321,17 +337,48 @@ packages: '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} - '@effectionx/context-api@0.2.1': - resolution: {integrity: sha512-UCEmk/uibrx4PvUh/tm1SWy6e6rAaq2BzpEDLu5XJZ/LaRdazv5VxHqg4F3N+G4AcxgPvosprjS0j3dDSTCzFg==} - engines: {node: '>= 16'} + '@effectionx/context-api@0.5.3': + resolution: {integrity: sha512-OqS/7RGZtIoiRsL6dwetKLvS8F3NLiVU3iKlBbqxI+NPKXs/ackKn294eGlHUHx49Y89fUVU6YPalj2UbxwBzA==} + peerDependencies: + effection: ^3 || ^4 - '@effectionx/process@0.6.2': - resolution: {integrity: sha512-U94gqTNXASRw8KBJOtSE+MaWL09Tox7la9/rmJCzUpaLWSmrSOvH28NCv++PKKy8qNCErD+QQip5q+E8lARNEQ==} - engines: {node: '>= 16'} + '@effectionx/context-api@0.6.0': + resolution: {integrity: sha512-t004qvlkJDMB6EhHP1lOQ97PeIn90m7cv4+wsRPnx4YBem+pJzTL+Sm1KWbKMjMeFJz4oqllUWuBJZsCi+nuTw==} + peerDependencies: + effection: ^3 || ^4 - '@effectionx/timebox@0.3.1': - resolution: {integrity: sha512-Ql5PihR56QNTGjoNMqMIZC8WGDnHN/Yh+glucnRr0WpHMkt3He0soTqU0D9mzwk+2F+0hsrlfOPP8ovK6Nbkdg==} - engines: {node: '>= 16'} + '@effectionx/middleware@0.1.1': + resolution: {integrity: sha512-ss/bZRkt/xzJNE59r8NR1+0K/xQcIyCm0y9n8FYC8jKdFn51SPe3m3t7EfPcK8zkdjCoTOU7k1UpIXRl26asYA==} + + '@effectionx/node@0.2.4': + resolution: {integrity: sha512-cPnp3fvfBKjGWekmBHdhZr5ScAr3Mg+x5IXpO8uKFe7AZ8EPAT9Di6skuB4kuGFJtRtS0Z1e5G4+2eJyapKhYA==} + peerDependencies: + effection: ^3 || ^4 + + '@effectionx/process@0.8.0': + resolution: {integrity: sha512-vwbK48JJl+I5t2G+eQyoLCoTp9IU3zkhGd+LmdxIS9PZp/v/j5Kq13eN4FvrZGx/KBYgibr57RBzu3F+Uf+uNw==} + peerDependencies: + effection: ^3 || ^4 + + '@effectionx/scope-eval@0.1.3': + resolution: {integrity: sha512-Acn45lb3H94WYhNVHXYtXOZYzjpBGDPPlsyW1Talb/vYjQzCzus5lkxxOlPyphzvi7d+7mGNXiIVt4JLSZmLnQ==} + peerDependencies: + effection: ^3 || ^4 + + '@effectionx/signals@0.5.3': + resolution: {integrity: sha512-AJvbUeCD5QHJ0Lc+kVtvAOZUJjXCJcOVVkRVTyOE3DPnUNQnbxVpB2o/lmBN7dnjNlGI78F4lWLMjoGZ98XRcQ==} + peerDependencies: + effection: ^3 || ^4 + + '@effectionx/stream-helpers@0.8.2': + resolution: {integrity: sha512-dWgK7ILXX6dQy2WkTajGrF2P2u0ZXUcOFcXOD5srmr8vnOnGSU7eENjnx7XPV13PF9BMzFV/n39VXz7R42FgaA==} + peerDependencies: + effection: ^3 || ^4 + + '@effectionx/timebox@0.4.3': + resolution: {integrity: sha512-cc7SLpL3svAYK8M5NS8kLQuL0lrZNoQb+Hi9NSaWOudzAW1HoewuDfUtfXLemPJnnLqLYhbghRhmpVqCm4Xg3Q==} + peerDependencies: + effection: ^3 || ^4 '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -1383,9 +1430,6 @@ packages: '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} - '@types/cross-spawn@6.0.6': - resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -1431,6 +1475,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -1674,6 +1721,10 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -2653,6 +2704,13 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + remeda@2.33.7: + resolution: {integrity: sha512-cXlyjevWx5AcslOUEETG4o8XYi9UkoCXcJmj7XhPFVbla+ITuOBxv6ijBrmbeg+ZhzmDThkNdO+iXKUfrJep1w==} + remedial@1.0.8: resolution: {integrity: sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg==} @@ -2789,8 +2847,8 @@ packages: resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} engines: {node: '>= 0.4'} - shellwords@1.1.1: - resolution: {integrity: sha512-LzESUkEHUuFbjaE7j8uyIjKvySfSFvCF6G4WOygjwSwQj3VuX8hr+v4M252B3twEct6XTWrrNSFu74mTlx4uAQ==} + shellwords-ts@3.0.1: + resolution: {integrity: sha512-GabK4ApLMqHFRGlpgNqg8dmtHTnYHt0WUUJkIeMd3QaDrUUBEDXHSSNi3I0PzMimg8W+I0EN4TshQxsnHv1cwg==} side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} @@ -3388,19 +3446,50 @@ snapshots: '@braidai/lang@1.1.2': optional: true - '@effectionx/context-api@0.2.1': + '@effectionx/context-api@0.5.3(effection@4.0.2)': + dependencies: + '@effectionx/middleware': 0.1.1 + effection: 4.0.2 + + '@effectionx/context-api@0.6.0(effection@4.0.2)': + dependencies: + '@effectionx/middleware': 0.1.1 + effection: 4.0.2 + + '@effectionx/middleware@0.1.1': {} + + '@effectionx/node@0.2.4(effection@4.0.2)': dependencies: effection: 4.0.2 - '@effectionx/process@0.6.2': + '@effectionx/process@0.8.0(effection@4.0.2)': dependencies: - '@types/cross-spawn': 6.0.6 + '@effectionx/context-api': 0.5.3(effection@4.0.2) + '@effectionx/node': 0.2.4(effection@4.0.2) + '@effectionx/scope-eval': 0.1.3(effection@4.0.2) cross-spawn: 7.0.6 ctrlc-windows: 2.2.0 effection: 4.0.2 - shellwords: 1.1.1 + shellwords-ts: 3.0.1 - '@effectionx/timebox@0.3.1': + '@effectionx/scope-eval@0.1.3(effection@4.0.2)': + dependencies: + effection: 4.0.2 + + '@effectionx/signals@0.5.3(effection@4.0.2)': + dependencies: + effection: 4.0.2 + immutable: 5.1.5 + + '@effectionx/stream-helpers@0.8.2(effection@4.0.2)': + dependencies: + '@effectionx/signals': 0.5.3(effection@4.0.2) + '@effectionx/timebox': 0.4.3(effection@4.0.2) + effection: 4.0.2 + immutable: 5.1.5 + remeda: 2.33.7 + + '@effectionx/timebox@0.4.3(effection@4.0.2)': dependencies: effection: 4.0.2 @@ -4436,10 +4525,6 @@ snapshots: dependencies: '@types/node': 24.12.0 - '@types/cross-spawn@6.0.6': - dependencies: - '@types/node': 24.12.0 - '@types/deep-eql@4.0.2': {} '@types/estree@1.0.8': {} @@ -4489,6 +4574,8 @@ snapshots: undici-types: 7.18.2 optional: true + '@types/picomatch@4.0.3': {} + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -4769,6 +4856,10 @@ snapshots: check-error@2.1.3: {} + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + cjs-module-lexer@1.4.3: optional: true @@ -5774,6 +5865,10 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readdirp@5.0.0: {} + + remeda@2.33.7: {} + remedial@1.0.8: {} remove-trailing-separator@1.1.0: {} @@ -5952,7 +6047,7 @@ snapshots: shell-quote@1.8.3: {} - shellwords@1.1.1: {} + shellwords-ts@3.0.1: {} side-channel-list@1.0.0: dependencies: