@quark-fw/plugin-threads 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@quark-fw/plugin-threads",
3
+ "version": "0.1.13",
4
+ "main": "src/index.ts",
5
+ "types": "src/index.ts",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "tsx watch src/index.ts",
9
+ "build": "echo raw-src package, no build"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "keywords": [
15
+ "quark"
16
+ ],
17
+ "author": "",
18
+ "license": "ISC",
19
+ "description": "Worker-process plugin for the Quark framework: forks each src/**/*.quark.ts, bridges quark.ee over Redis pub/sub (published as raw TS sources; consumers run tsx)",
20
+ "publishConfig": {
21
+ "access": "public"
22
+ },
23
+ "dependencies": {
24
+ "@quark-fw/atom": "0.1.13",
25
+ "@quark-fw/plugin": "0.1.13",
26
+ "@quark-fw/plugin-env": "0.1.13",
27
+ "@quark-fw/plugin-redis": "0.1.13",
28
+ "chokidar": "^5.0.0"
29
+ },
30
+ "devDependencies": {
31
+ "ioredis": "^5.9.3"
32
+ },
33
+ "peerDependencies": {
34
+ "@quark-fw/core": "0.1.13"
35
+ }
36
+ }
package/src/bridge.ts ADDED
@@ -0,0 +1,81 @@
1
+ import crypto from "node:crypto";
2
+ import { quitClient } from "./helpers.js";
3
+
4
+ const CHANNEL = "quark:ee";
5
+
6
+ // события, которые никогда не покидают процесс
7
+ const LOCAL_EVENTS = new Set(["lifecycle:init", "plugin:init", "atom:update"]);
8
+
9
+ export type EventBridge = {
10
+ close(): Promise<void>;
11
+ };
12
+
13
+ type BridgeMessage = {
14
+ origin: string;
15
+ event: string;
16
+ args: unknown[];
17
+ };
18
+
19
+ // ВАЖНО: мост подключается ПОСЛЕ любых обёрток над emitter'ом (например
20
+ // debug-Proxy из createTypedEventEmitter) — он захватывает текущий emit
21
+ // и подменяет его собственным.
22
+ export async function attachEventBridge(
23
+ quark: Quark,
24
+ opts: { localEvents?: string[] } = {},
25
+ ): Promise<EventBridge> {
26
+ const origin = crypto.randomUUID();
27
+ const local = new Set([...LOCAL_EVENTS, ...(opts.localEvents ?? [])]);
28
+
29
+ const pub = quark.redis.client.value.duplicate();
30
+ const sub = quark.redis.client.value.duplicate();
31
+ await sub.subscribe(CHANNEL);
32
+
33
+ const ee = quark.ee.value;
34
+ // захватываем оригинал ДО подмены, иначе входящее сообщение
35
+ // зациклится через собственный патч
36
+ const originalEmit = ee.emit.bind(ee) as unknown as (
37
+ event: string,
38
+ ...args: unknown[]
39
+ ) => boolean;
40
+
41
+ sub.on("message", (_channel: string, raw: string) => {
42
+ try {
43
+ const msg = JSON.parse(raw) as BridgeMessage;
44
+ if (msg.origin === origin) return; // эхо собственного publish
45
+ originalEmit(msg.event, ...msg.args);
46
+ } catch (e) {
47
+ console.error("[threads] bad bridge message", e);
48
+ }
49
+ });
50
+
51
+ // emit подменяется намеренно: события уходят ещё и в Redis. Тип эмиттера
52
+ // разбирает событие по имени, поэтому здесь он берётся структурно.
53
+ type EmitHost = { emit: (event: string, ...args: unknown[]) => boolean };
54
+ (ee as unknown as EmitHost).emit = (event: string, ...args: unknown[]) => {
55
+ if (!local.has(event)) {
56
+ try {
57
+ const payload = JSON.stringify({
58
+ origin,
59
+ event,
60
+ args,
61
+ } satisfies BridgeMessage);
62
+ void pub.publish(CHANNEL, payload).catch((e) => {
63
+ console.error("[threads] publish failed", e);
64
+ });
65
+ } catch {
66
+ // несериализуемые аргументы — событие остаётся локальным
67
+ }
68
+ }
69
+ return originalEmit(event, ...args);
70
+ };
71
+
72
+ return {
73
+ async close() {
74
+ (ee as unknown as EmitHost).emit = originalEmit;
75
+ sub.removeAllListeners("message");
76
+ await sub.unsubscribe(CHANNEL).catch(() => {});
77
+ await quitClient(pub);
78
+ await quitClient(sub);
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,32 @@
1
+ import "@quark-fw/core/src/global.d.ts";
2
+ import { Atom } from "@quark-fw/atom";
3
+
4
+ export {};
5
+
6
+ export type ThreadInfo = {
7
+ file: string;
8
+ instance: number;
9
+ pid?: number;
10
+ };
11
+
12
+ declare global {
13
+ interface Events {
14
+ "thread:online": [file: string, instance: number];
15
+ "thread:exit": [file: string, instance: number, code: number | null];
16
+ "thread:restart": [file: string, instance: number];
17
+ }
18
+
19
+ interface Quark {
20
+ threads: {
21
+ list: Atom<ThreadInfo[]>;
22
+ spawn(file: string): Promise<void>;
23
+ stop(file: string): Promise<void>;
24
+ };
25
+ // присутствует только внутри .quark.ts-процесса
26
+ thread?: {
27
+ file: string;
28
+ instance: number;
29
+ instances: number;
30
+ };
31
+ }
32
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,6 @@
1
+ // Хелперы threads-плагина.
2
+ import type { Redis } from "ioredis";
3
+
4
+ export async function quitClient(client: Redis) {
5
+ await client.quit().catch(() => client.disconnect());
6
+ }
package/src/index.ts ADDED
@@ -0,0 +1,200 @@
1
+ import atom from "@quark-fw/atom";
2
+ import { QuarkPlugin } from "@quark-fw/plugin";
3
+ import { ChildProcess, fork } from "node:child_process";
4
+ import { globSync } from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import chokidar, { FSWatcher } from "chokidar";
8
+ import { attachEventBridge, EventBridge } from "./bridge.js";
9
+ import { ThreadInfo } from "@quark-fw/plugin-threads/src/global.js";
10
+
11
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+ const workerEntry = path.join(__dirname, "worker-entry.ts");
13
+ const isProd = process.env.NODE_ENV === "production";
14
+
15
+ const RESTART_RESET_MS = 60_000;
16
+ const RESTART_MAX_DELAY_MS = 30_000;
17
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
18
+
19
+ type WorkerHandle = {
20
+ file: string;
21
+ instance: number;
22
+ child: ChildProcess;
23
+ startedAt: number;
24
+ stopping: boolean;
25
+ };
26
+
27
+ let bridge: EventBridge | undefined;
28
+ let watcher: FSWatcher | undefined;
29
+ let destroyed = false;
30
+
31
+ await quark.registerPlugin({
32
+ name: "threads",
33
+ dependsOn: ["redis"],
34
+ extend(quark) {
35
+ const list = atom<ThreadInfo[]>("threads.list", []);
36
+
37
+ // file -> живые инстансы
38
+ const handles = new Map<string, WorkerHandle[]>();
39
+ // "file#instance" -> число рестартов подряд
40
+ const restarts = new Map<string, number>();
41
+
42
+ const updateList = () => {
43
+ list.value = [...handles.values()].flat().map((h) => ({
44
+ file: h.file,
45
+ instance: h.instance,
46
+ pid: h.child.pid,
47
+ }));
48
+ };
49
+
50
+ const forkInstance = (file: string, instance: number) => {
51
+ const child = fork(workerEntry, [file, String(instance)], {
52
+ execArgv: ["--import", "tsx"],
53
+ });
54
+ const handle: WorkerHandle = {
55
+ file,
56
+ instance,
57
+ child,
58
+ startedAt: Date.now(),
59
+ stopping: false,
60
+ };
61
+ const all = handles.get(file) ?? [];
62
+ all.push(handle);
63
+ handles.set(file, all);
64
+ updateList();
65
+
66
+ child.on(
67
+ "message",
68
+ (msg: { type?: string; config?: { instances?: number } }) => {
69
+ if (msg?.type !== "quark:online") return;
70
+ console.log(
71
+ `[threads] ${path.relative(process.cwd(), file)} #${instance}/${Number(msg.config?.instances ?? 1)} online (pid ${child.pid})`,
72
+ );
73
+ quark.ee.value.emit("thread:online", file, instance);
74
+ restarts.delete(`${file}#${instance}`);
75
+
76
+ // инстанс 0 знает config — доспавниваем недостающие инстансы
77
+ if (instance === 0) {
78
+ const wanted = Number(msg.config?.instances ?? 1);
79
+ const running = new Set(
80
+ (handles.get(file) ?? []).map((h) => h.instance),
81
+ );
82
+ for (let i = 1; i < wanted; i++) {
83
+ if (!running.has(i)) forkInstance(file, i);
84
+ }
85
+ }
86
+ },
87
+ );
88
+
89
+ // рестарт работает и для воркера, упавшего до handshake
90
+ child.on("exit", (code) => {
91
+ handles.set(
92
+ file,
93
+ (handles.get(file) ?? []).filter((h) => h !== handle),
94
+ );
95
+ updateList();
96
+ quark.ee.value.emit("thread:exit", file, instance, code);
97
+
98
+ if (handle.stopping || destroyed) return;
99
+
100
+ const key = `${file}#${instance}`;
101
+ const uptime = Date.now() - handle.startedAt;
102
+ const n =
103
+ uptime > RESTART_RESET_MS ? 0 : (restarts.get(key) ?? 0);
104
+ restarts.set(key, n + 1);
105
+ const delay = Math.min(1000 * 2 ** n, RESTART_MAX_DELAY_MS);
106
+
107
+ setTimeout(() => {
108
+ if (destroyed) return;
109
+ quark.ee.value.emit("thread:restart", file, instance);
110
+ forkInstance(file, instance);
111
+ }, delay).unref();
112
+ });
113
+ };
114
+
115
+ const stopHandle = (handle: WorkerHandle) =>
116
+ new Promise<void>((resolve) => {
117
+ handle.stopping = true;
118
+ if (handle.child.exitCode !== null) return resolve();
119
+
120
+ const timeout = setTimeout(() => {
121
+ handle.child.kill();
122
+ }, SHUTDOWN_TIMEOUT_MS);
123
+ handle.child.once("exit", () => {
124
+ clearTimeout(timeout);
125
+ resolve();
126
+ });
127
+ try {
128
+ handle.child.send({ type: "quark:shutdown" });
129
+ } catch {
130
+ handle.child.kill();
131
+ }
132
+ });
133
+
134
+ quark.threads = {
135
+ list,
136
+ async spawn(file) {
137
+ const abs = path.resolve(file);
138
+ if ((handles.get(abs) ?? []).length) return;
139
+ forkInstance(abs, 0);
140
+ },
141
+ async stop(file) {
142
+ const abs = path.resolve(file);
143
+ const all = handles.get(abs) ?? [];
144
+ await Promise.all(all.map(stopHandle));
145
+ handles.delete(abs);
146
+ updateList();
147
+ },
148
+ };
149
+ },
150
+ async onReady(quark) {
151
+ const files = globSync("src/**/*.quark.ts", {
152
+ cwd: process.cwd(),
153
+ exclude: (p) =>
154
+ String(p).includes("node_modules") ||
155
+ String(p).includes("frontend"),
156
+ });
157
+ if (!files.length) return;
158
+
159
+ // мост нужен только когда есть воркеры
160
+ bridge = await attachEventBridge(quark);
161
+
162
+ for (const file of files) {
163
+ await quark.threads.spawn(path.resolve(file));
164
+ }
165
+
166
+ if (!isProd) {
167
+ watcher = chokidar.watch("src", {
168
+ ignoreInitial: true,
169
+ ignored: (p) =>
170
+ p.includes("node_modules") ||
171
+ p.includes(`src${path.sep}frontend`),
172
+ });
173
+ watcher.on("all", (event, changedPath) => {
174
+ if (!changedPath.endsWith(".quark.ts")) return;
175
+ const abs = path.resolve(changedPath);
176
+ if (event === "change") {
177
+ void quark.threads
178
+ .stop(abs)
179
+ .then(() => quark.threads.spawn(abs));
180
+ } else if (event === "add") {
181
+ void quark.threads.spawn(abs);
182
+ } else if (event === "unlink") {
183
+ void quark.threads.stop(abs);
184
+ }
185
+ });
186
+ }
187
+ },
188
+ async onDestroy(quark) {
189
+ destroyed = true;
190
+ await watcher?.close();
191
+ watcher = undefined;
192
+ // мост закрываем до остановки воркеров: thread:exit при выключении —
193
+ // локальное событие, publish в закрывающийся клиент не нужен
194
+ await bridge?.close();
195
+ bridge = undefined;
196
+ for (const info of [...quark.threads.list.value]) {
197
+ await quark.threads.stop(info.file);
198
+ }
199
+ },
200
+ } satisfies QuarkPlugin);
@@ -0,0 +1,36 @@
1
+ // Точка входа дочернего процесса: собирает лёгкий quark-контекст
2
+ // (core + env + redis + мост событий) и импортирует целевой .quark.ts файл.
3
+ import "@quark-fw/core";
4
+ import "@quark-fw/plugin-env";
5
+ import "@quark-fw/plugin-redis";
6
+ import { pathToFileURL } from "node:url";
7
+ import { attachEventBridge } from "./bridge.js";
8
+ import {} from "@quark-fw/plugin-threads/src/global.js";
9
+
10
+ const file = process.argv[2]!;
11
+ const instance = Number(process.argv[3] ?? 0);
12
+
13
+ // до импорта файла, чтобы тело воркера уже видело quark.thread
14
+ quark.thread = { file, instance, instances: 1 };
15
+
16
+ const bridge = await attachEventBridge(quark);
17
+
18
+ process.on("message", (msg) => {
19
+ if ((msg as { type?: string })?.type === "quark:shutdown") {
20
+ void (async () => {
21
+ await quark.destroy().catch(console.error);
22
+ await bridge.close().catch(() => {});
23
+ process.exit(0);
24
+ })();
25
+ }
26
+ });
27
+
28
+ const mod = (await import(pathToFileURL(file).href)) as {
29
+ config?: { instances?: number };
30
+ };
31
+ quark.thread.instances = Number(mod.config?.instances ?? 1);
32
+
33
+ process.send?.({
34
+ type: "quark:online",
35
+ config: { instances: quark.thread.instances },
36
+ });