@quark-fw/core 0.1.26 → 0.1.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -30,10 +30,52 @@ await quark.destroy(); // runs onDestroy hooks in reverse order
30
30
  - `quark.registerPlugin(plugin)` — registers a [`QuarkPlugin`](https://www.npmjs.com/package/@quark-fw/plugin); registrations are queued, `dependsOn` is enforced
31
31
  - `quark.plugins` — loaded plugins
32
32
  - `quark.ee` — an [atom](https://www.npmjs.com/package/@quark-fw/atom) holding the typed event emitter (`quark.ee.value.on/emit`)
33
- - `quark.ready()` / `quark.destroy()` — lifecycle
33
+ - `quark.ready()` / `quark.destroy()` — lifecycle; `destroy()` runs every plugin's `onDestroy` even if one of them throws and is single-flight (concurrent calls share one run)
34
+
35
+ ## Logging and request context
36
+
37
+ ```ts
38
+ import { log } from "@quark-fw/core/src/log.js";
39
+
40
+ const logger = log.child({ scope: "billing" });
41
+ logger.info("invoice sent", { invoiceId });
42
+ logger.error("charge failed", { err }); // Error → { name, message, stack }
43
+ ```
44
+
45
+ - `LOG_LEVEL` — `debug | info | warn | error` (default `info`)
46
+ - `LOG_FORMAT` — `json` (default in production) or `pretty` (default otherwise); `warn`/`error` go to stderr
47
+ - Every line written inside an HTTP request carries `requestId` — `@quark-fw/plugin-react` opens the request context (`X-Request-Id` from the proxy or a fresh uuid, echoed on the response). `runWithRequestContext` / `getRequestContext` are exported for custom hosts.
48
+ - `createLogger({ level, format, sink })` builds an independent logger for tests.
49
+
50
+ ## Shutdown
51
+
52
+ Importing core installs `SIGTERM`/`SIGINT` handlers: the first signal runs `quark.destroy()` and exits with `0`, a second signal exits with `1` immediately. If plugins do not stop within `QUARK_SHUTDOWN_TIMEOUT_MS` (default 8000, below Docker's 10 s `SIGKILL` grace) the process exits with `1`. `installShutdownHandlers` is exported for custom hosts and tests.
34
53
 
35
54
  Plugins extend `quark` and the global `Events` map through TypeScript declaration merging — see the `Quark` and `Events` interfaces shipped in this package's `global.d.ts`.
36
55
 
56
+ ## Testing
57
+
58
+ `resetQuark()` destroys the registered plugins, removes the signal handlers and deletes the global so the next import of core builds a fresh `quark`. Plugin modules are cached by ESM, so re-import them after a reset:
59
+
60
+ ```ts
61
+ import { afterEach, test, vi } from "vitest";
62
+
63
+ afterEach(async () => {
64
+ const { resetQuark } = await import("@quark-fw/core");
65
+ await resetQuark();
66
+ vi.resetModules();
67
+ });
68
+
69
+ test("boots with a fake redis", async () => {
70
+ await import("@quark-fw/core");
71
+ await quark.registerPlugin({ name: "redis", extend(q) { q.redis = fakeRedis; } });
72
+ await import("@quark-fw/plugin-cron"); // dependsOn: ["redis"] is satisfied by the fake
73
+ await quark.ready();
74
+ });
75
+ ```
76
+
77
+ Plugin objects can also be passed to `registerPlugin` directly, without importing the plugin module.
78
+
37
79
  ## Ecosystem
38
80
 
39
81
  - [`@quark-fw/plugin`](https://www.npmjs.com/package/@quark-fw/plugin) — plugin contract + HTTP middleware registry
@@ -0,0 +1,27 @@
1
+ export type LogLevel = "debug" | "info" | "warn" | "error";
2
+ export type LogFields = Record<string, unknown>;
3
+ export type Logger = {
4
+ readonly level: LogLevel;
5
+ debug(msg: string, fields?: LogFields): void;
6
+ info(msg: string, fields?: LogFields): void;
7
+ warn(msg: string, fields?: LogFields): void;
8
+ error(msg: string, fields?: LogFields): void;
9
+ /** логгер с постоянными полями, например `{ scope: "cron" }` */
10
+ child(bindings: LogFields): Logger;
11
+ };
12
+ export type CreateLoggerArgs = {
13
+ level?: LogLevel;
14
+ /** json — строка на запись для сборщиков логов; pretty — для терминала */
15
+ format?: "json" | "pretty";
16
+ /** куда писать строку; по умолчанию stdout, warn/error — stderr */
17
+ sink?: (line: string, level: LogLevel) => void;
18
+ bindings?: LogFields;
19
+ now?: () => Date;
20
+ };
21
+ export declare const isLogLevel: (value: unknown) => value is LogLevel;
22
+ /**
23
+ * Логгер без зависимостей: уровни, JSON-строки для прода, request id из
24
+ * контекста запроса. Плагины писали console.log с ручными префиксами —
25
+ * без уровней их нельзя было ни выключить, ни распарсить в сборщике логов.
26
+ */
27
+ export declare function createLogger({ level, format, sink, bindings, now, }?: CreateLoggerArgs): Logger;
@@ -0,0 +1,83 @@
1
+ import { getRequestContext } from "./requestContext.js";
2
+ const ORDER = {
3
+ debug: 0,
4
+ info: 1,
5
+ warn: 2,
6
+ error: 3,
7
+ };
8
+ export const isLogLevel = (value) => typeof value === "string" && value in ORDER;
9
+ const defaultSink = (line, level) => {
10
+ (ORDER[level] >= ORDER.warn ? process.stderr : process.stdout).write(line + "\n");
11
+ };
12
+ // Error в JSON.stringify даёт {} — раскладываем руками
13
+ const serializeField = (value) => value instanceof Error
14
+ ? { name: value.name, message: value.message, stack: value.stack }
15
+ : value;
16
+ const pad = (n) => String(n).padStart(2, "0");
17
+ const prettyLine = (time, level, msg, fields) => {
18
+ const { scope, requestId, ...rest } = fields;
19
+ const parts = [
20
+ `${pad(time.getHours())}:${pad(time.getMinutes())}:${pad(time.getSeconds())}`,
21
+ level.toUpperCase().padEnd(5),
22
+ ];
23
+ if (scope !== undefined)
24
+ parts.push(`[${String(scope)}]`);
25
+ parts.push(msg);
26
+ if (requestId !== undefined)
27
+ parts.push(`req=${String(requestId)}`);
28
+ // стеки ошибок — отдельными строками после полей
29
+ const errors = [];
30
+ for (const [key, value] of Object.entries(rest)) {
31
+ if (value instanceof Error) {
32
+ errors.push(`\n ${key}: ${value.stack ?? value.message}`);
33
+ }
34
+ else if (typeof value === "string") {
35
+ parts.push(`${key}=${value}`);
36
+ }
37
+ else {
38
+ parts.push(`${key}=${JSON.stringify(value)}`);
39
+ }
40
+ }
41
+ return parts.join(" ") + errors.join("");
42
+ };
43
+ /**
44
+ * Логгер без зависимостей: уровни, JSON-строки для прода, request id из
45
+ * контекста запроса. Плагины писали console.log с ручными префиксами —
46
+ * без уровней их нельзя было ни выключить, ни распарсить в сборщике логов.
47
+ */
48
+ export function createLogger({ level = "info", format = "json", sink = defaultSink, bindings = {}, now = () => new Date(), } = {}) {
49
+ const write = (lvl, msg, fields) => {
50
+ if (ORDER[lvl] < ORDER[level])
51
+ return;
52
+ const time = now();
53
+ const requestId = getRequestContext()?.requestId;
54
+ const all = {
55
+ ...(requestId ? { requestId } : {}),
56
+ ...bindings,
57
+ ...fields,
58
+ };
59
+ if (format === "pretty") {
60
+ sink(prettyLine(time, lvl, msg, all), lvl);
61
+ return;
62
+ }
63
+ const record = { time: time.toISOString(), level: lvl, msg };
64
+ for (const [key, value] of Object.entries(all)) {
65
+ record[key] = serializeField(value);
66
+ }
67
+ sink(JSON.stringify(record), lvl);
68
+ };
69
+ return {
70
+ level,
71
+ debug: (msg, fields) => write("debug", msg, fields),
72
+ info: (msg, fields) => write("info", msg, fields),
73
+ warn: (msg, fields) => write("warn", msg, fields),
74
+ error: (msg, fields) => write("error", msg, fields),
75
+ child: (more) => createLogger({
76
+ level,
77
+ format,
78
+ sink,
79
+ bindings: { ...bindings, ...more },
80
+ now,
81
+ }),
82
+ };
83
+ }
@@ -1,4 +1,6 @@
1
1
  import { EventEmitter } from "node:events";
2
+ import { log as rootLog } from "../log.js";
3
+ const log = rootLog.child({ scope: "ee" });
2
4
  export function createTypedEventEmitter({ debug, } = {
3
5
  debug: false,
4
6
  }) {
@@ -12,7 +14,7 @@ export function createTypedEventEmitter({ debug, } = {
12
14
  // Логируем emit
13
15
  if (prop === "emit") {
14
16
  return (event, ...args) => {
15
- console.log(`[emit] ${event}`, ...args);
17
+ log.debug("emit", { event, args });
16
18
  return original.call(target, event, ...args);
17
19
  };
18
20
  }
@@ -23,14 +25,14 @@ export function createTypedEventEmitter({ debug, } = {
23
25
  prop === "prependListener" ||
24
26
  prop === "prependOnceListener") {
25
27
  return (event, listener) => {
26
- console.log(`[on] ${event}`, listener.toString());
28
+ log.debug("on", { event, listener: listener.toString() });
27
29
  return original.call(target, event, listener);
28
30
  };
29
31
  }
30
32
  // Логируем удаление подписок
31
33
  if (prop === "off" || prop === "removeListener") {
32
34
  return (event, listener) => {
33
- console.log(`[off] ${event}`, listener.toString());
35
+ log.debug("off", { event, listener: listener.toString() });
34
36
  return original.call(target, event, listener);
35
37
  };
36
38
  }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,14 @@
1
1
  /// <reference path="global.d.ts" />
2
- export {};
2
+ export { installShutdownHandlers } from "./installShutdownHandlers.js";
3
+ export { log } from "./log.js";
4
+ export type { LogFields, Logger, LogLevel } from "./log.js";
5
+ export { createLogger } from "./createLogger.js";
6
+ export { getRequestContext, runWithRequestContext, type RequestContext, } from "./requestContext.js";
7
+ /**
8
+ * Для тестов: останавливает плагины, снимает обработчики сигналов и удаляет
9
+ * глобальный quark, чтобы следующий импорт core собрал его заново. Живёт
10
+ * здесь, а не в отдельном файле: сбрасывает состояние этого модуля (очередь
11
+ * регистраций, loaded). Модули плагинов кэшируются ESM — после сброса их
12
+ * нужно импортировать заново (vi.resetModules() + динамический import).
13
+ */
14
+ export declare function resetQuark(): Promise<void>;
package/dist/index.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import atom from "@quark-fw/atom";
2
2
  import { createTypedEventEmitter } from "./ee/createTypedEventEmitter.js";
3
+ import { installShutdownHandlers } from "./installShutdownHandlers.js";
4
+ export { installShutdownHandlers } from "./installShutdownHandlers.js";
5
+ export { log } from "./log.js";
6
+ export { createLogger } from "./createLogger.js";
7
+ export { getRequestContext, runWithRequestContext, } from "./requestContext.js";
3
8
  // создаём emitter
4
9
  const eeAtom = atom("ee", createTypedEventEmitter());
5
10
  const loaded = new Set();
@@ -42,6 +47,32 @@ const register = async (plugin) => {
42
47
  quark.plugins.push(plugin);
43
48
  await plugin.onReady?.(quark);
44
49
  };
50
+ // Остановка плагинов в обратном порядке регистрации. Ошибка одного плагина
51
+ // не прерывает остальных: иначе упавший onDestroy оставлял живыми воркеры
52
+ // threads и соединение с Redis, и процесс добивал SIGKILL.
53
+ const runDestroy = async () => {
54
+ const errors = [];
55
+ for (const plugin of [...quark.plugins].reverse()) {
56
+ try {
57
+ await plugin.onDestroy?.(quark);
58
+ }
59
+ catch (error) {
60
+ errors.push(error);
61
+ }
62
+ }
63
+ quark.plugins.length = 0;
64
+ loaded.clear();
65
+ if (errors.length === 1)
66
+ throw errors[0];
67
+ if (errors.length)
68
+ throw new AggregateError(errors, "quark.destroy");
69
+ };
70
+ // Повторный destroy во время идущего возвращает тот же promise: воркер
71
+ // threads получает quark:shutdown по IPC и SIGTERM от родителя, оператор
72
+ // может нажать Ctrl+C дважды — onDestroy плагинов должен отработать один раз.
73
+ let destroying;
74
+ const created = !global.quark;
75
+ let uninstallShutdown;
45
76
  // создаём глобальный quark
46
77
  global.quark =
47
78
  global.quark ||
@@ -57,15 +88,37 @@ global.quark =
57
88
  // дождаться завершения всех уже поставленных в очередь регистраций —
58
89
  // нужно модулям приложения, выполняющимся конкурентно с плагинами
59
90
  ready: () => registrationQueue,
60
- destroy: async () => {
61
- // в обратном порядке регистрации
62
- for (const plugin of [...quark.plugins].reverse()) {
63
- await plugin.onDestroy?.(quark);
91
+ destroy: () => {
92
+ if (!destroying) {
93
+ destroying = runDestroy().finally(() => {
94
+ destroying = undefined;
95
+ });
64
96
  }
65
- quark.plugins.length = 0;
66
- loaded.clear();
97
+ return destroying;
67
98
  },
68
99
  // Quark дополняется плагинами через declaration merging,
69
100
  // поэтому базовый объект заведомо неполон
70
101
  };
102
+ // Обработчики сигналов ставит тот модуль, который создал quark: при
103
+ // повторной загрузке core (другой specifier) второго набора быть не должно
104
+ if (created) {
105
+ uninstallShutdown = installShutdownHandlers({
106
+ quark,
107
+ timeoutMs: Number(process.env.QUARK_SHUTDOWN_TIMEOUT_MS) || undefined,
108
+ });
109
+ }
110
+ /**
111
+ * Для тестов: останавливает плагины, снимает обработчики сигналов и удаляет
112
+ * глобальный quark, чтобы следующий импорт core собрал его заново. Живёт
113
+ * здесь, а не в отдельном файле: сбрасывает состояние этого модуля (очередь
114
+ * регистраций, loaded). Модули плагинов кэшируются ESM — после сброса их
115
+ * нужно импортировать заново (vi.resetModules() + динамический import).
116
+ */
117
+ export async function resetQuark() {
118
+ await quark.destroy().catch(() => { });
119
+ uninstallShutdown?.();
120
+ uninstallShutdown = undefined;
121
+ registrationQueue = Promise.resolve();
122
+ delete global.quark;
123
+ }
71
124
  quark.ee.value.emit("lifecycle:init", "app started");
@@ -0,0 +1,22 @@
1
+ import { type Logger } from "./log.js";
2
+ export type InstallShutdownHandlersArgs = {
3
+ quark: Pick<Quark, "destroy">;
4
+ /**
5
+ * Сколько ждать onDestroy плагинов, прежде чем выйти принудительно.
6
+ * Docker/Coolify шлют SIGKILL через 10 с после SIGTERM — укладываемся
7
+ * с запасом, иначе процесс убивают посреди записи.
8
+ */
9
+ timeoutMs?: number;
10
+ signals?: NodeJS.Signals[];
11
+ exit?: (code: number) => void;
12
+ log?: Pick<Logger, "info" | "error">;
13
+ };
14
+ /**
15
+ * Один обработчик сигналов на процесс: по SIGTERM/SIGINT останавливает
16
+ * плагины через quark.destroy() и выходит сам. Без него деплой убивал
17
+ * контейнер посреди cron-задачи или записи файла, а Ctrl+C в dev оставлял
18
+ * открытые соединения с Redis и базой.
19
+ *
20
+ * Возвращает функцию снятия обработчиков (нужна тестам).
21
+ */
22
+ export declare function installShutdownHandlers({ quark, timeoutMs, signals, exit, log, }: InstallShutdownHandlersArgs): () => void;
@@ -0,0 +1,45 @@
1
+ import { log as rootLog } from "./log.js";
2
+ /**
3
+ * Один обработчик сигналов на процесс: по SIGTERM/SIGINT останавливает
4
+ * плагины через quark.destroy() и выходит сам. Без него деплой убивал
5
+ * контейнер посреди cron-задачи или записи файла, а Ctrl+C в dev оставлял
6
+ * открытые соединения с Redis и базой.
7
+ *
8
+ * Возвращает функцию снятия обработчиков (нужна тестам).
9
+ */
10
+ export function installShutdownHandlers({ quark, timeoutMs = 8_000, signals = ["SIGTERM", "SIGINT"], exit = (code) => process.exit(code), log = rootLog.child({ scope: "quark" }), }) {
11
+ let shuttingDown = false;
12
+ const onSignal = (signal) => {
13
+ // второй сигнал — оператор не хочет ждать плагины
14
+ if (shuttingDown) {
15
+ log.error("повторный сигнал — выходим немедленно", { signal });
16
+ exit(1);
17
+ return;
18
+ }
19
+ shuttingDown = true;
20
+ log.info("останавливаем плагины", { signal, timeoutMs });
21
+ // unref: таймер не должен сам держать процесс живым, если плагины
22
+ // остановились и event loop пуст
23
+ const timer = setTimeout(() => {
24
+ log.error("плагины не остановились — выходим принудительно", {
25
+ timeoutMs,
26
+ });
27
+ exit(1);
28
+ }, timeoutMs);
29
+ timer.unref();
30
+ quark.destroy().then(() => {
31
+ clearTimeout(timer);
32
+ exit(0);
33
+ }, (error) => {
34
+ clearTimeout(timer);
35
+ log.error("ошибка при остановке плагинов", { err: error });
36
+ exit(1);
37
+ });
38
+ };
39
+ for (const signal of signals)
40
+ process.on(signal, onSignal);
41
+ return () => {
42
+ for (const signal of signals)
43
+ process.off(signal, onSignal);
44
+ };
45
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export type { LogFields, Logger, LogLevel } from "./createLogger.js";
2
+ export declare const log: import("./createLogger.js").Logger;
package/dist/log.js ADDED
@@ -0,0 +1,13 @@
1
+ import { createLogger, isLogLevel } from "./createLogger.js";
2
+ // Один логгер на процесс, настраивается окружением:
3
+ // LOG_LEVEL — debug | info | warn | error (по умолчанию info)
4
+ // LOG_FORMAT — json | pretty (по умолчанию json в production, иначе pretty)
5
+ // Плагины берут свой scope через log.child({ scope: "cron" }).
6
+ export const log = createLogger({
7
+ level: isLogLevel(process.env.LOG_LEVEL) ? process.env.LOG_LEVEL : "info",
8
+ format: process.env.LOG_FORMAT === "json" || process.env.LOG_FORMAT === "pretty"
9
+ ? process.env.LOG_FORMAT
10
+ : process.env.NODE_ENV === "production"
11
+ ? "json"
12
+ : "pretty",
13
+ });
@@ -0,0 +1,5 @@
1
+ export type RequestContext = {
2
+ requestId: string;
3
+ };
4
+ export declare function runWithRequestContext<T>(context: RequestContext, fn: () => T): T;
5
+ export declare function getRequestContext(): RequestContext | undefined;
@@ -0,0 +1,12 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ // Одно хранилище на процесс: обе функции — его стороны, поэтому живут
3
+ // в одном модуле. Контекст открывает HTTP-middleware (plugin-react), а
4
+ // читают его логгер и всё, что исполняется внутри запроса: процедуры tRPC,
5
+ // SSR in-process, кэш.
6
+ const storage = new AsyncLocalStorage();
7
+ export function runWithRequestContext(context, fn) {
8
+ return storage.run(context, fn);
9
+ }
10
+ export function getRequestContext() {
11
+ return storage.getStore();
12
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quark-fw/core",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "Core runtime of the Quark framework: global quark object, lifecycle and typed event emitter",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -19,7 +19,8 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "dev": "tsx watch src/index.ts",
22
- "build": "tsc -p tsconfig.build.json && node ../../scripts/postbuild.mjs"
22
+ "build": "node ../../node_modules/tsgo/bin/tsc -p tsconfig.build.json && node ../../scripts/postbuild.mjs",
23
+ "test": "vitest run"
23
24
  },
24
25
  "keywords": [
25
26
  "quark"
@@ -30,7 +31,10 @@
30
31
  "access": "public"
31
32
  },
32
33
  "dependencies": {
33
- "@quark-fw/atom": "0.1.26",
34
- "@quark-fw/plugin": "0.1.26"
34
+ "@quark-fw/atom": "0.1.27",
35
+ "@quark-fw/plugin": "0.1.27"
36
+ },
37
+ "devDependencies": {
38
+ "vitest": "^5.0.0"
35
39
  }
36
40
  }