Skip to content

Commit 57c5717

Browse files
committed
pass data to simulation from simulacrum service
1 parent 689eeff commit 57c5717

File tree

7 files changed

+236
-56
lines changed

7 files changed

+236
-56
lines changed

packages/server/bin/run-simulation-child.ts

Lines changed: 64 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,49 @@
11
#!/usr/bin/env node
2-
import { main, suspend, until } from "effection";
2+
import { main, suspend, until, type Operation } from "effection";
33
import { pathToFileURL } from "node:url";
44
import type {
55
FoundationSimulator,
66
FoundationSimulatorListening,
77
} from "@simulacrum/foundation-simulator";
88

9+
function guardedFactory(
10+
factory: Function
11+
): (initData?: unknown) => Promise<FoundationSimulator<any>> {
12+
return async function startSimulation(initData?: unknown) {
13+
const sim = await factory(initData);
14+
if ("listen" in sim && typeof sim.listen === "function") {
15+
return sim as FoundationSimulator<any>;
16+
}
17+
throw new Error("factory did not return a simulator instance");
18+
};
19+
}
20+
21+
function* normalizeSimulatorFactory(url: string) {
22+
try {
23+
const mod: unknown = yield* until(import(url));
24+
25+
// dynamically import module has to be an object if correctly resolved
26+
if (mod && typeof mod === "object") {
27+
const m = mod;
28+
29+
// export default as factory
30+
if ("default" in m && typeof m.default === "function") {
31+
const factory = m.default;
32+
return guardedFactory(factory);
33+
}
34+
35+
// export named 'simulation' as factory
36+
if ("simulation" in m && typeof m.simulation === "function") {
37+
const factory = m.simulation;
38+
return guardedFactory(factory);
39+
}
40+
}
41+
} catch (err) {
42+
// no-op - will throw in fall through below
43+
}
44+
throw new Error("no factory or simulator instance found in module");
45+
}
46+
947
main(function* () {
1048
const args = process.argv.slice(2);
1149
if (args.length < 1) {
@@ -15,37 +53,37 @@ main(function* () {
1553
const modulePath = args[0];
1654

1755
// Resolve and import module inside the operation
18-
let mod: any;
19-
try {
20-
const url =
21-
modulePath.startsWith("./") || modulePath.startsWith("/")
22-
? pathToFileURL(modulePath).href
23-
: modulePath;
24-
mod = yield* until(import(url));
25-
} catch (err) {
26-
throw new Error(`failed to import module: ${String(err)}`);
27-
}
56+
const url =
57+
modulePath.startsWith("./") || modulePath.startsWith("/")
58+
? pathToFileURL(modulePath).href
59+
: modulePath;
60+
const factory = yield* normalizeSimulatorFactory(url);
2861

29-
const exportNames = ["default", "simulation"];
30-
let factory: Function | undefined = undefined;
31-
for (const name of exportNames) {
32-
if (name in mod && typeof mod[name] === "function") {
33-
factory = mod[name];
34-
break;
62+
let simulacrumPort: number | undefined = undefined;
63+
// parse optional flags after modulePath
64+
for (let i = 1; i < args.length; i++) {
65+
if (args[i] === "--simulacrum-port") {
66+
simulacrumPort = Number(args[i + 1]);
67+
i++;
3568
}
3669
}
37-
// fallback: module itself is a function
38-
if (!factory && typeof mod === "function") factory = mod;
3970

40-
if (!factory) {
41-
throw new Error(`no factory function found in module: ${modulePath}`);
71+
// if present fetch the data chunk and pass it to the factory
72+
let initData: JSON | undefined = undefined;
73+
if (typeof simulacrumPort === "number" && !Number.isNaN(simulacrumPort)) {
74+
try {
75+
const res = yield* until(
76+
fetch(`http://127.0.0.1:${simulacrumPort}/data`)
77+
);
78+
initData = yield* until(res.json());
79+
} catch (err) {
80+
// ignore fetch failures
81+
console.error("failed to fetch simulacrum data:", err);
82+
}
4283
}
4384

44-
let sim = factory() as FoundationSimulator<any>;
45-
46-
if (!sim || typeof sim.listen !== "function") {
47-
throw new Error("factory did not return a simulator with .listen()");
48-
}
85+
// invoke factory; it may return a simulator instance or a Promise thereof
86+
const sim = yield* until(factory(initData));
4987

5088
let listening: FoundationSimulatorListening<any> | undefined = undefined;
5189
try {

packages/server/example/services/gen-sim-factory.ts

Lines changed: 25 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,31 @@ import {
1111
export function simulation(
1212
port: number = 3301,
1313
startDelay: number = 10
14-
): FoundationSimulator<any> {
15-
const factory = createFoundationSimulationServer({
16-
port,
17-
extendRouter(router) {
18-
router.get("/status", (_req, res) => {
19-
res.status(200).send("ok");
20-
});
21-
},
22-
})();
14+
): (initData?: unknown) => FoundationSimulator<any> {
15+
return (initData?: unknown) => {
16+
if (initData) console.log("simulation received init data:", initData);
17+
const factory = createFoundationSimulationServer({
18+
port,
19+
extendRouter(router) {
20+
router.get("/status", (_req, res) => {
21+
res.status(200).send("ok");
22+
});
23+
router.get("/init-data", (_req, res) => {
24+
res.status(200).json({ data: initData ?? null });
25+
});
26+
},
27+
})();
2328

24-
return {
25-
async listen(
26-
...args: Parameters<FoundationSimulator<any>["listen"]>
27-
): Promise<any> {
28-
if (startDelay > 0) {
29-
await new Promise((resolve) => setTimeout(resolve, startDelay));
30-
}
31-
// delegate to underlying factory listen
32-
return factory.listen(...args);
33-
},
29+
return {
30+
async listen(
31+
...args: Parameters<FoundationSimulator<any>["listen"]>
32+
): Promise<any> {
33+
if (startDelay > 0) {
34+
await new Promise((resolve) => setTimeout(resolve, startDelay));
35+
}
36+
// delegate to underlying factory listen
37+
return factory.listen(...args);
38+
},
39+
};
3440
};
3541
}

packages/server/example/simulation-graph.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ const servicesMap = {
1414
},
1515
};
1616

17-
export const services = useServiceGraph(servicesMap);
17+
export const services = useServiceGraph(servicesMap, {
18+
globalData: { exampleKey: "exampleValue" },
19+
});
1820

1921
export function example(opts: { duration?: number } = {}) {
2022
return (function* () {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { call, resource, type Operation } from "effection";
2+
import { createServer } from "node:http";
3+
import { stdout } from "./logging.ts";
4+
5+
export type DataServiceOptions = Record<string, unknown> | undefined;
6+
7+
export function startDataService(
8+
data: DataServiceOptions = {}
9+
): Operation<{ port: number }> {
10+
return resource(function* (provide) {
11+
const server = createServer((req, res) => {
12+
try {
13+
const url = new URL(req.url ?? "", `http://127.0.0.1`);
14+
const pathname = url.pathname;
15+
16+
// GET /data -> whole object
17+
if (
18+
req.method === "GET" &&
19+
(pathname === "/data" || pathname === "/")
20+
) {
21+
const body = JSON.stringify(data || {});
22+
res.writeHead(200, {
23+
"content-type": "application/json",
24+
"content-length": String(Buffer.byteLength(body)),
25+
});
26+
res.end(body);
27+
return;
28+
}
29+
30+
// GET /data/<key> -> value or 404
31+
if (req.method === "GET" && pathname.startsWith("/data/")) {
32+
const key = decodeURIComponent(pathname.replace(/^\/data\//, ""));
33+
if (!key) {
34+
res.writeHead(400);
35+
res.end();
36+
return;
37+
}
38+
39+
const value = (data as any)?.[key];
40+
if (value === undefined) {
41+
res.writeHead(404, { "content-type": "text/plain" });
42+
res.end("not found");
43+
return;
44+
}
45+
46+
const body = JSON.stringify(value);
47+
res.writeHead(200, {
48+
"content-type": "application/json",
49+
"content-length": String(Buffer.byteLength(body)),
50+
});
51+
res.end(body);
52+
return;
53+
}
54+
55+
// unknown endpoint
56+
res.writeHead(404, { "content-type": "text/plain" });
57+
res.end("not found");
58+
} catch (err) {
59+
res.writeHead(500, { "content-type": "text/plain" });
60+
res.end(String(err));
61+
}
62+
});
63+
64+
// listen on ephemeral port bound to localhost
65+
yield* call(() => server.listen());
66+
67+
const address = server.address();
68+
const port =
69+
typeof address === "object" && address !== null && "port" in address
70+
? address.port
71+
: 0;
72+
73+
yield* stdout(`data service: started on port ${port}`);
74+
75+
try {
76+
yield* provide({ port });
77+
} finally {
78+
yield* call(() => server.close());
79+
yield* stdout(`data service: stopped on port ${port}`);
80+
}
81+
});
82+
}

packages/server/src/services.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
11
import {
22
type Operation,
3+
type Stream,
4+
type WithResolvers,
35
resource,
46
spawn,
57
withResolvers,
68
each,
7-
type Stream,
8-
type WithResolvers,
9+
createContext,
910
} from "effection";
1011

1112
import { type ServiceUpdate, useWatcher } from "./watch.ts";
1213
import { stdout } from "./logging.ts";
14+
import { startDataService } from "./data-service.ts";
15+
16+
export const SimulacrumEndpoint = createContext<number>("SimulacrumEndpoint");
1317

1418
export type ServiceDefinition<
1519
S,
@@ -64,7 +68,11 @@ export function useServiceGraph<
6468
T extends MaybeSimulation
6569
>(
6670
services: S,
67-
options?: { watch?: boolean; watchDebounce?: number }
71+
options?: {
72+
globalData?: Record<string, unknown>;
73+
watch?: boolean;
74+
watchDebounce?: number;
75+
}
6876
): (subset?: string[] | string) => Operation<ServiceGraph<S, T>> {
6977
return (subset?: string[] | string) =>
7078
resource(function* (provide) {
@@ -123,6 +131,16 @@ export function useServiceGraph<
123131
)}`
124132
);
125133
}
134+
// track service ports (when services expose one)
135+
const servicePorts = new Map<string, number>();
136+
137+
const dataServiceProvided = yield* startDataService(
138+
options?.globalData ?? {}
139+
);
140+
servicePorts.set("simulacrum", dataServiceProvided.port);
141+
// set the SimulacrumEndpoint in this operation scope so children started
142+
// in this graph can access the port via context
143+
yield* SimulacrumEndpoint.set(dataServiceProvided.port);
126144

127145
const watcher = yield* useWatcher(
128146
effectiveServices,
@@ -147,9 +165,6 @@ export function useServiceGraph<
147165
}
148166
}
149167

150-
// track service ports (when services expose one)
151-
const servicePorts = new Map<string, number>();
152-
153168
function bumpService(service: string) {
154169
const task = status.get(service);
155170
if (!task) throw new Error(`missing status for service '${service}'`);

packages/server/src/simulation.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
FoundationSimulator,
77
FoundationSimulatorListening,
88
} from "@simulacrum/foundation-simulator";
9+
import { SimulacrumEndpoint } from "./services.ts";
910

1011
/**
1112
* Helper to start a foundation simulation server factory
@@ -42,15 +43,20 @@ export function useChildSimulation<L extends object = Record<string, unknown>>(
4243
modulePath: string
4344
): Operation<FoundationSimulatorListening<L>> {
4445
return resource(function* (provide) {
45-
const cmd = [
46+
// attempt to read the simulacrum port from context; if not present, continue without it
47+
const port = yield* SimulacrumEndpoint.get();
48+
49+
const parts = [
4650
"node",
4751
"--import",
4852
"tsx",
4953
"./bin/run-simulation-child.ts",
5054
modulePath,
51-
]
52-
.map((s) => (s.includes(" ") ? `'${s}'` : s))
53-
.join(" ");
55+
];
56+
if (typeof port === "number") {
57+
parts.push("--simulacrum-port", String(port));
58+
}
59+
const cmd = parts.map((s) => (s.includes(" ") ? `'${s}'` : s)).join(" ");
5460

5561
const process = yield* exec(cmd);
5662

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { it } from "node:test";
2+
import assert from "node:assert";
3+
import { run, sleep, until } from "effection";
4+
import { useServiceGraph } from "../src/services.ts";
5+
6+
it("starts data service and serves configured data", async () => {
7+
await run(function* () {
8+
const runGraph = yield* useServiceGraph(
9+
{},
10+
{ globalData: { a: 1, nested: { b: 2 } } }
11+
)();
12+
13+
let port: string | number | undefined = undefined;
14+
for (let i = 0; i < 50 && !port; i++) {
15+
if (runGraph && runGraph.servicePorts) {
16+
port = runGraph.servicePorts.get("simulacrum");
17+
if (typeof port === "number") break;
18+
}
19+
yield* sleep(10);
20+
}
21+
22+
assert.ok(
23+
typeof port === "number",
24+
"data service port should be registered on servicePorts"
25+
);
26+
27+
const res = yield* until(fetch(`http://127.0.0.1:${port}/data`));
28+
const json = yield* until(res.json());
29+
assert.deepStrictEqual(json, { a: 1, nested: { b: 2 } });
30+
});
31+
});

0 commit comments

Comments
 (0)