badmfck-api-server 5.0.3 → 5.0.5

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.
@@ -0,0 +1,277 @@
1
+ # Blue-green в badmfck-api-server
2
+
3
+ Как устроена выкладка без простоя и как приложение узнаёт, работает ли оно сейчас в проде.
4
+
5
+ ---
6
+
7
+ ## 1. Идея в двух словах
8
+
9
+ У проекта **два слота** — `blue` и `green`. Каждый слот — это отдельная папка и отдельный pm2-процесс на своём порту. Трафик в каждый момент идёт **только в один** из них: nginx смотрит на порт активного слота.
10
+
11
+ - **Deploy** выкладывает новую версию в **неактивный** слот. Прод при этом не трогается.
12
+ - **Activate** переключает nginx на выложенный слот. Переключение мгновенное, без простоя.
13
+ - **Rollback** — это тот же activate, но на предыдущий слот. Старая версия всё ещё запущена, поэтому откат занимает секунды.
14
+
15
+ ---
16
+
17
+ ## 2. Что где лежит на сервере
18
+
19
+ | Что | Где | Кто меняет |
20
+ |---|---|---|
21
+ | Код слотов | `<destination>/blue`, `<destination>/green` | деплоер при deploy |
22
+ | pm2-процессы | `<name>-blue`, `<name>-green` | деплоер при deploy |
23
+ | Состояние проекта | `<projects_path>/<name>_state_.json` | деплоер при deploy / activate |
24
+ | Порт активного слота для nginx | `<nginx.config_dir>/<name>.upstream.conf` | деплоер, **переписывает** при каждом activate |
25
+ | Серверный конфиг nginx | `<nginx.config_dir>/<name>.server.conf` | деплоер создаёт **один раз**, дальше им владеет devops (SSL через certbot и т.п.) |
26
+ | Паспорт слота для приложения | `<destination>/<slot>/.bluegreen.json` (права `600`) | деплоер при deploy, пишет в **оба** слота |
27
+
28
+ Файл состояния:
29
+
30
+ ```jsonc
31
+ {
32
+ "active": "blue", // куда сейчас идёт трафик
33
+ "lastDeployed": "green", // куда была последняя выкладка
34
+ "history": [ { "slot": "green", "action": "deploy", "user": "igor", "at": 1789… } ], // последние 200
35
+ "statusToken": "…" // read-only токен для приложений, см. раздел 6
36
+ }
37
+ ```
38
+
39
+ Если `lastDeployed ≠ active`, значит новая версия выложена, но ещё не включена.
40
+
41
+ ---
42
+
43
+ ## 3. Жизненный цикл
44
+
45
+ **Первый деплой проекта:** слот `blue` создаётся **сразу активным**: `active = lastDeployed = blue`, nginx настраивается на него.
46
+
47
+ **Каждый следующий деплой:**
48
+ 1. Деплоер выбирает слот, противоположный активному (active `blue` → выкладка в `green`).
49
+ 2. Распаковывает архив, делает `npm ci`, пишет конфиг, запускает `<name>-green`.
50
+ 3. **nginx не трогается** — трафик продолжает идти в `blue`.
51
+ 4. Можно проверить `green` напрямую по его порту.
52
+
53
+ **Activate:**
54
+ 1. `active` становится `green`.
55
+ 2. `upstream.conf` переписывается на порт `green`.
56
+ 3. Выполняется хук `on_switch`, обычно `nginx -s reload`.
57
+ 4. `blue` **продолжает работать** — это и есть готовый откат.
58
+
59
+ **Rollback:** `badmfck activate blue` возвращает трафик на `blue`.
60
+
61
+ > Если в ответе activate пришло «SLOT FLIPPED BUT nginx RELOAD FAILED», состояние уже переключено, упал только reload. Перезапусти его вручную: `nginx -s reload`.
62
+
63
+ ---
64
+
65
+ ## 4. Настройка проекта на деплоере
66
+
67
+ Конфиг проекта в `projects_path`:
68
+
69
+ ```jsonc
70
+ {
71
+ "name": "gateway",
72
+ "destination": "/var/node/gateway",
73
+ "token": "…",
74
+ "unpack": true, "install": true, "pm2": true,
75
+
76
+ // Общие значения для обоих слотов (БД, домены, флаги)
77
+ "config": { "DB_HOST": "…" },
78
+
79
+ "bluegreen": {
80
+ // Отличия слотов. Слот побеждает при совпадении ключей.
81
+ // Порт обязателен (ключ port / PORT, регистр не важен) и у слотов должен различаться.
82
+ "blue_config": { "port": "8055" },
83
+ "green_config": { "port": "8056" },
84
+
85
+ // Выполняется после каждого переключения. Путь к бинарнику — абсолютный.
86
+ "on_switch": ["/usr/sbin/nginx", "-s", "reload"],
87
+
88
+ // Опционально: разные флаги pm2 для слотов
89
+ "blue_pm2_attributes": ["--cron-restart", "10 0 * * *"],
90
+ "green_pm2_attributes": ["--cron-restart", "10 0 * * *"]
91
+ },
92
+
93
+ "nginx": {
94
+ "config_dir": "/var/node-nginx-configs", // nginx должен делать include /var/node-nginx-configs/*.conf
95
+ "domain": "gateway.example.com"
96
+ }
97
+ }
98
+ ```
99
+
100
+ В `setup.json` деплоера можно указать `status_url` — адрес, по которому приложения спрашивают свой статус. Нужен **только** если деплоер не слушает `127.0.0.1` (подробнее в разделе 6).
101
+
102
+ ---
103
+
104
+ ## 5. CLI в проекте
105
+
106
+ `package.json` проекта:
107
+
108
+ ```json
109
+ "scripts": {
110
+ "deploy": "badmfck deploy",
111
+ "activate": "badmfck activate",
112
+ "rollback:blue": "badmfck activate blue",
113
+ "rollback:green": "badmfck activate green",
114
+ "status": "badmfck status"
115
+ }
116
+ ```
117
+
118
+ | Команда | Что делает |
119
+ |---|---|
120
+ | `npm run deploy` | сборка и выкладка в неактивный слот |
121
+ | `npm run activate` | включить последний выложенный слот |
122
+ | `badmfck activate blue` / `green` | включить конкретный слот (rollback) |
123
+ | `npm run status` | какой слот активен, какой выложен последним, ждёт ли что-то переключения, кто и когда это делал |
124
+
125
+ **Где CLI берёт конфиг.** Сначала папка `deploy/` (если там несколько конфигов, CLI спросит, какой взять), потом файл `deploy.json`. Путь можно указать явно, но через npm нужен разделитель `--`:
126
+
127
+ ```bash
128
+ npm run deploy -- deploy/prod.json
129
+ npm run activate -- deploy/prod.json blue
130
+ ```
131
+
132
+ ---
133
+
134
+ ## 6. Приложение: «я сейчас в проде?»
135
+
136
+ ### Главное понятие — `live`
137
+
138
+ У каждого процесса есть `live`: **должен ли он работать как живой экземпляр**.
139
+
140
+ | Ситуация | `live` |
141
+ |---|---|
142
+ | Проект **не** на blue-green | `true` — единственный экземпляр всегда живой |
143
+ | Blue-green, мой слот активен | `true` |
144
+ | Blue-green, мой слот в резерве (standby) | `false` |
145
+ | Ответа от деплоера ещё не было | `null` — ждать, не угадывать |
146
+
147
+ Логика, написанная через `live`, **не меняется** при переходе проекта на blue-green: сегодня она просто получает `true`.
148
+
149
+ ### Основной способ — `onLiveChange`
150
+
151
+ ```ts
152
+ import { APIService } from "badmfck-api-server";
153
+
154
+ APIService.onLiveChange(live => {
155
+ if (live) startCrons(); // запустить синглтон-работу
156
+ else stopCrons(); // я в резерве — остановиться
157
+ });
158
+ ```
159
+
160
+ - Если ответ уже известен, колбэк вызывается **сразу**. Дальше — только когда `live` **меняется** (activate, rollback). Не важно, подписался код до первой проверки или после.
161
+ - С неизвестным значением колбэк **не вызывается никогда**.
162
+ - Проект не на blue-green: колбэк вызовется **один раз** с `true`.
163
+ - Возвращает функцию отписки.
164
+
165
+ ### Синхронная проверка — для горячего пути
166
+
167
+ ```ts
168
+ if (APIService.isLive) { … } // true / false / null, без I/O
169
+ const s = APIService.blueGreenState; // полное последнее известное состояние или null
170
+ ```
171
+
172
+ ### Прочее
173
+
174
+ ```ts
175
+ // Сырой результат прямо сейчас (может вернуть isActive: null при сбое). Кешируется на 10 с.
176
+ const s = await APIService.getBlueGreenActiveState({ force: true });
177
+
178
+ // Низкоуровневый сигнал: срабатывает при любой смене live / isActive / active
179
+ import { S_BLUEGREEN_STATE_CHANGED } from "badmfck-api-server";
180
+ S_BLUEGREEN_STATE_CHANGED.subscribe(({ current, previous }) => { … });
181
+ ```
182
+
183
+ Поля состояния:
184
+
185
+ | Поле | Значение |
186
+ |---|---|
187
+ | `live` | на что ветвиться (см. выше) |
188
+ | `bluegreen` | выложено ли приложение как blue-green слот |
189
+ | `project`, `slot` | проект и мой слот |
190
+ | `active` | активный слот по данным деплоера |
191
+ | `isActive` | `slot === active` |
192
+ | `lastDeployed`, `readyToSwitch` | последний выложенный слот / ждёт ли он переключения |
193
+ | `stale` | последняя проверка не удалась, показано последнее известное значение |
194
+ | `error` | почему проверка не удалась |
195
+
196
+ ### Настройка
197
+
198
+ ```ts
199
+ new APIService({ …, blueGreenWatchMs: 5000 }) // по умолчанию 5000; 0 — выключить наблюдатель
200
+ ```
201
+
202
+ Наблюдатель запускается сам после старта сервера. Если приложение выложено не через blue-green, он останавливается после первой проверки. Если наблюдатель выключен, `isLive` / `blueGreenState` обновляются только при ручном вызове `getBlueGreenActiveState()`.
203
+
204
+ ### Как это работает под капотом
205
+
206
+ 1. При деплое деплоер кладёт в папку каждого слота `.bluegreen.json`: имя проекта, слот, адрес для запроса статуса и **read-only токен**.
207
+ 2. Приложение ищет этот файл вверх от папки entry-скрипта. Путь можно задать явно через env `BLUEGREEN_FILE`.
208
+ 3. Приложение спрашивает деплоер `POST …/pckg/slot` по `127.0.0.1` (деплоер всегда на той же машине) и получает активный слот.
209
+ 4. Деплоер читает файл состояния на каждый запрос, поэтому **activate виден сразу**, рестарт не нужен.
210
+
211
+ ### Надёжность
212
+
213
+ - **Сбой деплоера не переводит приложение в резерв.** Неудачная проверка сохраняет последнее известное значение (`stale: true`) и **не** вызывает `onLiveChange`.
214
+ - **Битый `.bluegreen.json`** даёт `live: null`, колбэк не вызывается. Считать такой слот живым опасно: синглтон-работа может пойти дважды.
215
+ - Методы **никогда не бросают** исключения.
216
+ - Нагрузка: не больше 1 запроса за 5–10 с на процесс, по loopback. Запросы не пишутся в netlog деплоера и не попадают в статистику исходящих запросов приложения.
217
+
218
+ ### Безопасность
219
+
220
+ - `statusToken` даёт **только** право узнать активный слот. Токен деплоя и логин/пароль деплоера в приложение не попадают.
221
+ - `.bluegreen.json` создаётся с правами `600`.
222
+ - `pckg/slot` отвечает одинаковым 401 на неизвестный проект и на неверный токен (перебором проекты не узнать). Имя проекта строго валидируется, токен сравнивается за константное время.
223
+
224
+ ---
225
+
226
+ ## 7. Что зависит от `live`, а что нет
227
+
228
+ **Запускать только при `live === true`:**
229
+ - кроны и планировщики;
230
+ - рассылки и уведомления;
231
+ - потребители очередей, которые должны быть в единственном экземпляре;
232
+ - фоновые задачи, пишущие общее состояние (прогрев общих кешей, агрегации).
233
+
234
+ **Не зависят от `live`:**
235
+ - HTTP-обработчики: трафик и так идёт только в активный слот через nginx, а резервный слот нужно уметь проверить по его порту;
236
+ - эндпоинты liveness / readiness / мониторинга.
237
+
238
+ **⚠️ База данных общая для обоих слотов.** Новая версия стартует и работает параллельно со старой ещё **до** переключения, а после отката старая версия снова работает с базой. Поэтому:
239
+ - миграции схемы должны быть **обратно совместимыми**: сначала добавить новое, удалить старое — отдельной выкладкой позже;
240
+ - миграцию, которую нельзя запускать дважды, выполняй только на живом слоте или отдельным шагом.
241
+
242
+ ---
243
+
244
+ ## 8. Переход обычного проекта на blue-green (вручную)
245
+
246
+ 1. Задай в `blue_config` / `green_config` порты, **отличные** от текущего порта приложения.
247
+ 2. Добавь в конфиг проекта блоки `bluegreen` и `nginx` (раздел 4).
248
+ 3. Выполни первый деплой. Поднимется `<name>-blue` в `<destination>/blue`, и nginx переключится на него.
249
+ 4. Проверь, что сервис отвечает через домен и что `npm run status` показывает `active: BLUE`.
250
+ 5. **Останови старый процесс:** `pm2 delete <name>`, затем `pm2 save`.
251
+
252
+ > Пятый шаг обязателен. Деплоер старый процесс **не** останавливает. Над ним нет `.bluegreen.json`, поэтому он продолжит считать себя `live: true`, и синглтон-работа будет выполняться **дважды**.
253
+
254
+ Код приложения можно готовить заранее, ещё до перехода: `onLiveChange` на обычном проекте просто вернёт `true`.
255
+
256
+ ---
257
+
258
+ ## 9. Диагностика
259
+
260
+ | Симптом | Причина | Что делать |
261
+ |---|---|---|
262
+ | `bluegreen: false` на blue-green проекте | слот выложен **до** появления этой функции, `.bluegreen.json` нет | выполнить один деплой: файл запишется в оба слота |
263
+ | `live: null`, в `error` «malformed» | `.bluegreen.json` повреждён | передеплоить слот |
264
+ | `live: null`, `error` «deployer unreachable» | деплоер недоступен, а прошлого значения ещё нет | проверить деплоер; если он не слушает loopback — задать `status_url` в его `setup.json` |
265
+ | `stale: true` | деплоер временно недоступен | ничего: работаем на последнем известном значении |
266
+ | `error` «deployer rejected… Invalid project or status token» | токен в файле не совпадает с state-файлом | передеплоить слот |
267
+ | После activate приложение ещё несколько секунд видит старый слот | интервал опроса | нормально; для мгновенной проверки — `getBlueGreenActiveState({ force: true })` |
268
+ | Синглтон-работа выполняется дважды после перехода на blue-green | работает старый процесс без слота | `pm2 delete <name>` (раздел 8) |
269
+
270
+ Проверка на сервере вручную:
271
+
272
+ ```bash
273
+ cat <projects_path>/<name>_state_.json # active / lastDeployed / history
274
+ cat <nginx.config_dir>/<name>.upstream.conf # куда реально смотрит nginx
275
+ cat <destination>/<slot>/.bluegreen.json # паспорт слота
276
+ pm2 list | grep <name> # оба слота запущены?
277
+ ```
@@ -5,6 +5,8 @@ import { HTTPRequestVO, IError, TransferPacketVO } from './structures/Interfaces
5
5
  import Signal, { Req } from "badmfck-signal";
6
6
  import http from 'http';
7
7
  import { IDeployerServiceOptions } from './deployment/DeployerService';
8
+ import { IBlueGreenState, IBlueGreenStateOptions } from './deployment/BlueGreenState';
9
+ import { ILogFilesOptions } from './monitor/LogFiles';
8
10
  export interface IMonitorUser {
9
11
  login: string;
10
12
  password: string;
@@ -52,6 +54,8 @@ export interface APIServiceOptions<TInterceptor = unknown> {
52
54
  fileTempDir: string;
53
55
  fileLimit: number;
54
56
  metricsDir?: string;
57
+ blueGreenWatchMs?: number;
58
+ logFiles?: ILogFilesOptions;
55
59
  }
56
60
  type InferInterceptor<T> = T extends IInterceptor<infer U> ? U : unknown;
57
61
  export declare const getDefaultOptions: <I extends IInterceptor<any> | undefined>(overrides?: Omit<Partial<APIServiceOptions<InferInterceptor<NonNullable<I>>>>, "interceptor"> & {
@@ -69,6 +73,10 @@ export declare const REQ_DOC_USERS: Req<void, IDocumentationUser[]>;
69
73
  export declare const S_APP_STARTED: Signal<void>;
70
74
  export declare function Initializer(services: IBaseService[]): Promise<void>;
71
75
  export declare class APIService<TInterceptorResult = any, TInternalCallParams = any> extends BaseService {
76
+ static getBlueGreenActiveState(opts?: IBlueGreenStateOptions): Promise<IBlueGreenState>;
77
+ static get blueGreenState(): IBlueGreenState | null;
78
+ static get isLive(): boolean | null;
79
+ static onLiveChange(cb: (live: boolean, state: IBlueGreenState) => void): () => void;
72
80
  private version;
73
81
  private options;
74
82
  private monitor;
@@ -60,6 +60,7 @@ const DocumentService_1 = require("./DocumentService");
60
60
  const Documentation_1 = require("./documentation/Documentation");
61
61
  const MicroserviceHostController_1 = require("./external/MicroserviceHostController");
62
62
  const DeployerService_1 = require("./deployment/DeployerService");
63
+ const BlueGreenState_1 = require("./deployment/BlueGreenState");
63
64
  const Deployer_1 = require("./deployment/Deployer");
64
65
  let nextLogID = 0;
65
66
  function defaultOptions() {
@@ -120,6 +121,18 @@ async function Initializer(services) {
120
121
  }
121
122
  }
122
123
  class APIService extends BaseService_1.BaseService {
124
+ static getBlueGreenActiveState(opts) {
125
+ return (0, BlueGreenState_1.getBlueGreenActiveState)(opts);
126
+ }
127
+ static get blueGreenState() {
128
+ return (0, BlueGreenState_1.getBlueGreenStateSync)();
129
+ }
130
+ static get isLive() {
131
+ return (0, BlueGreenState_1.getBlueGreenStateSync)()?.live ?? null;
132
+ }
133
+ static onLiveChange(cb) {
134
+ return (0, BlueGreenState_1.onLiveChange)(cb);
135
+ }
123
136
  version = PACKAGE_VERSION;
124
137
  options;
125
138
  monitor = null;
@@ -158,7 +171,11 @@ class APIService extends BaseService_1.BaseService {
158
171
  this.options.endpoints.push(new ExternalServiceEndpoint_1.ExternalServiceEndpoint());
159
172
  this.options.endpoints.push(new MicroserviceHostController_1.MicroserviceHostController());
160
173
  if (this.options.deployer && this.options.deployer.projects_path && this.options.deployer.setup_path) {
161
- this.deployer = new DeployerService_1.DeployerService(this.options.deployer);
174
+ this.deployer = new DeployerService_1.DeployerService({
175
+ ...this.options.deployer,
176
+ selfPort: this.options.port,
177
+ selfBaseEndPoint: this.options.baseEndPoint,
178
+ });
162
179
  this.deployer.init();
163
180
  this.options.endpoints = [
164
181
  new Deployer_1.Deployer(),
@@ -467,6 +484,7 @@ class APIService extends BaseService_1.BaseService {
467
484
  });
468
485
  server.listen(this.options.port, () => {
469
486
  (0, LogService_1.logAPI)('${APIService.js}', 'API Service started at: ' + this.options.port + ", with base endpoint:" + this.options.baseEndPoint + ", ver.: " + this.version);
487
+ (0, BlueGreenState_1.startBlueGreenWatcher)(this.options.blueGreenWatchMs ?? 5000);
470
488
  });
471
489
  }
472
490
  addNetlog(data, req, created, time) {
@@ -478,11 +496,11 @@ class APIService extends BaseService_1.BaseService {
478
496
  time: time,
479
497
  request: {
480
498
  endpoint: req?.endpoint,
481
- headers: req?.headers,
499
+ headers: LogService_1.LogService.sanitizeDeep(req?.headers),
482
500
  method: req?.method,
483
501
  data: this.checkDataLength(req?.data),
484
- params: req?.params,
485
- interceptorResult: req?.interceptorResult
502
+ params: LogService_1.LogService.sanitizeDeep(req?.params),
503
+ interceptorResult: LogService_1.LogService.sanitizeDeep(req?.interceptorResult)
486
504
  },
487
505
  response: this.checkDataLength(data),
488
506
  };
@@ -600,7 +618,7 @@ class APIService extends BaseService_1.BaseService {
600
618
  return v.slice(0, 255) + `...(${v.length})`;
601
619
  return v;
602
620
  });
603
- json = typeof s === "string" ? s : "";
621
+ json = typeof s === "string" ? LogService_1.LogService.sanitize(s) : "";
604
622
  }
605
623
  catch (e) {
606
624
  json = "[unserializable data]";
@@ -63,8 +63,10 @@ export declare class LogService extends BaseService {
63
63
  leadZero(num: number): string;
64
64
  createText(data: any, level: LOG_LEVEL): string;
65
65
  private static readonly SANITIZERS;
66
+ private static readonly SENSITIVE_KEY_RE;
66
67
  static sanitize(text: string): string;
67
68
  private static readonly PAN_CANDIDATE_REGEX;
68
69
  private static isLuhnValid;
70
+ static sanitizeDeep(value: any, depth?: number): any;
69
71
  static maskPAN(text: string): string;
70
72
  }
@@ -265,7 +265,9 @@ class LogService extends BaseService_1.BaseService {
265
265
  [/;\d{13,19}=\d{4,}[^?]*\?/g, "<REDACTED TRACK2>"],
266
266
  [/("(?:password|passwd|pwd|secret|api[_-]?key|api[_-]?secret|client[_-]?secret|access[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|authorization|cookie|set[_-]?cookie|session[_-]?id|sess[_-]?id|sid|cvv2?|cvc2?|cvn|cav2?|cid|security[_-]?code|card[_-]?verification[_-]?(?:value|code)?|pin|pin[_-]?block|encrypted[_-]?pin|iso[_-]?9564|exp|expiry|expiration|expiration[_-]?date|exp[_-]?date|exp[_-]?month|exp[_-]?year|cardholder[_-]?name|holder[_-]?name|card[_-]?holder)"\s*:\s*)"[^"]*"/gi, '$1"<REDACTED>"'],
267
267
  [/("(?:cvv2?|cvc2?|pin|exp[_-]?month|exp[_-]?year|service[_-]?code)"\s*:\s*)(-?\d+)/gi, '$1"<REDACTED>"'],
268
+ [/([?&;](?:password|passwd|pwd|secret|token|access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|apikey|auth|authorization|session|session[_-]?id|sid|cvv2?|cvc2?|pin|pan|signature|sig)=)[^&\s"'<>;]*/gi, "$1<REDACTED>"],
268
269
  ];
270
+ static SENSITIVE_KEY_RE = /^(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[_-]?key|apikey|password|passwd|pwd|secret|client[_-]?secret|access[_-]?key|private[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|session[_-]?id|sess[_-]?id|sid|cvv2?|cvc2?|cvn|cav2?|security[_-]?code|card[_-]?verification[_-]?(?:value|code)?|pin|pin[_-]?block|pan|card[_-]?number|exp|expiry|expiration|exp[_-]?date|exp[_-]?month|exp[_-]?year|cardholder[_-]?name|holder[_-]?name|card[_-]?holder)$/i;
269
271
  static sanitize(text) {
270
272
  if (!text || text.length < 3)
271
273
  return text;
@@ -295,6 +297,47 @@ class LogService extends BaseService_1.BaseService {
295
297
  }
296
298
  return sum % 10 === 0;
297
299
  }
300
+ static sanitizeDeep(value, depth = 0) {
301
+ if (depth > 8)
302
+ return "<DEPTH LIMIT>";
303
+ if (value === null || value === undefined)
304
+ return value;
305
+ const t = typeof value;
306
+ if (t === "string")
307
+ return LogService.sanitize(value);
308
+ if (t === "number" || t === "boolean")
309
+ return value;
310
+ if (t === "function" || t === "symbol")
311
+ return `[${t}]`;
312
+ if (t === "bigint")
313
+ return value.toString();
314
+ if (Buffer.isBuffer(value))
315
+ return `[Buffer ${value.length} bytes]`;
316
+ if (value instanceof Date)
317
+ return value.toISOString();
318
+ if (Array.isArray(value)) {
319
+ const capped = value.length > 200 ? value.slice(0, 200) : value;
320
+ const out = capped.map(v => LogService.sanitizeDeep(v, depth + 1));
321
+ if (value.length > capped.length)
322
+ out.push(`…(${value.length} items total)`);
323
+ return out;
324
+ }
325
+ if (t === "object") {
326
+ const out = {};
327
+ let n = 0;
328
+ for (const k of Object.keys(value)) {
329
+ if (++n > 200) {
330
+ out["…"] = `(${Object.keys(value).length} keys total)`;
331
+ break;
332
+ }
333
+ out[k] = LogService.SENSITIVE_KEY_RE.test(k)
334
+ ? "<REDACTED>"
335
+ : LogService.sanitizeDeep(value[k], depth + 1);
336
+ }
337
+ return out;
338
+ }
339
+ return String(value);
340
+ }
298
341
  static maskPAN(text) {
299
342
  if (!text || text.length < 13)
300
343
  return text;
@@ -0,0 +1,31 @@
1
+ import Signal from "badmfck-signal";
2
+ export declare const BLUEGREEN_FILE = ".bluegreen.json";
3
+ export type TSlot = "blue" | "green";
4
+ export interface IBlueGreenState {
5
+ live: boolean | null;
6
+ bluegreen: boolean;
7
+ project: string | null;
8
+ slot: TSlot | null;
9
+ active: TSlot | null;
10
+ isActive: boolean | null;
11
+ lastDeployed: TSlot | null;
12
+ readyToSwitch: boolean | null;
13
+ checkedAt: number;
14
+ cached: boolean;
15
+ stale?: boolean;
16
+ error?: string;
17
+ }
18
+ export declare const S_BLUEGREEN_STATE_CHANGED: Signal<{
19
+ current: IBlueGreenState;
20
+ previous: IBlueGreenState | null;
21
+ }>;
22
+ export interface IBlueGreenStateOptions {
23
+ force?: boolean;
24
+ ttlMs?: number;
25
+ timeoutMs?: number;
26
+ }
27
+ export declare function onLiveChange(cb: (live: boolean, state: IBlueGreenState) => void): () => void;
28
+ export declare function getBlueGreenStateSync(): IBlueGreenState | null;
29
+ export declare function startBlueGreenWatcher(intervalMs?: number): void;
30
+ export declare function stopBlueGreenWatcher(): void;
31
+ export declare function getBlueGreenActiveState(opts?: IBlueGreenStateOptions): Promise<IBlueGreenState>;
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.S_BLUEGREEN_STATE_CHANGED = exports.BLUEGREEN_FILE = void 0;
7
+ exports.onLiveChange = onLiveChange;
8
+ exports.getBlueGreenStateSync = getBlueGreenStateSync;
9
+ exports.startBlueGreenWatcher = startBlueGreenWatcher;
10
+ exports.stopBlueGreenWatcher = stopBlueGreenWatcher;
11
+ exports.getBlueGreenActiveState = getBlueGreenActiveState;
12
+ const fs_1 = __importDefault(require("fs"));
13
+ const path_1 = __importDefault(require("path"));
14
+ const badmfck_signal_1 = __importDefault(require("badmfck-signal"));
15
+ exports.BLUEGREEN_FILE = ".bluegreen.json";
16
+ exports.S_BLUEGREEN_STATE_CHANGED = new badmfck_signal_1.default();
17
+ let cache = null;
18
+ let inflight = null;
19
+ let lastKnown = null;
20
+ let watchTimer = null;
21
+ function remember(fresh) {
22
+ const previous = lastKnown;
23
+ if (fresh.live === null && previous && previous.live !== null && fresh.bluegreen) {
24
+ lastKnown = { ...previous, checkedAt: fresh.checkedAt, stale: true, error: fresh.error };
25
+ return;
26
+ }
27
+ lastKnown = { ...fresh, cached: false, stale: false };
28
+ if (!previous || previous.live !== lastKnown.live || previous.isActive !== lastKnown.isActive
29
+ || previous.active !== lastKnown.active) {
30
+ try {
31
+ exports.S_BLUEGREEN_STATE_CHANGED.invoke({ current: lastKnown, previous });
32
+ }
33
+ catch { }
34
+ }
35
+ }
36
+ function onLiveChange(cb) {
37
+ let delivered = null;
38
+ const deliver = (s) => {
39
+ if (!s || s.live === null || s.live === delivered)
40
+ return;
41
+ delivered = s.live;
42
+ try {
43
+ cb(s.live, s);
44
+ }
45
+ catch { }
46
+ };
47
+ const id = exports.S_BLUEGREEN_STATE_CHANGED.subscribe(({ current }) => deliver(current));
48
+ deliver(lastKnown);
49
+ return () => { exports.S_BLUEGREEN_STATE_CHANGED.unsubscribe(id); };
50
+ }
51
+ function getBlueGreenStateSync() {
52
+ return lastKnown;
53
+ }
54
+ function startBlueGreenWatcher(intervalMs = 5000) {
55
+ if (watchTimer || intervalMs <= 0)
56
+ return;
57
+ const tick = async () => {
58
+ const s = await getBlueGreenActiveState({ force: true });
59
+ if (!s.bluegreen)
60
+ stopBlueGreenWatcher();
61
+ };
62
+ watchTimer = setInterval(() => { tick().catch(() => { }); }, intervalMs);
63
+ watchTimer.unref?.();
64
+ tick().catch(() => { });
65
+ }
66
+ function stopBlueGreenWatcher() {
67
+ if (watchTimer)
68
+ clearInterval(watchTimer);
69
+ watchTimer = null;
70
+ }
71
+ function findFile() {
72
+ const override = process.env.BLUEGREEN_FILE;
73
+ if (override)
74
+ return fs_1.default.existsSync(override) ? override : null;
75
+ const main = require.main?.filename ?? process.argv[1];
76
+ if (!main)
77
+ return null;
78
+ let dir = path_1.default.dirname(path_1.default.resolve(main));
79
+ for (let i = 0; i < 6; i++) {
80
+ const candidate = path_1.default.join(dir, exports.BLUEGREEN_FILE);
81
+ if (fs_1.default.existsSync(candidate))
82
+ return candidate;
83
+ const parent = path_1.default.dirname(dir);
84
+ if (parent === dir)
85
+ break;
86
+ dir = parent;
87
+ }
88
+ return null;
89
+ }
90
+ function readFile(file) {
91
+ try {
92
+ const j = JSON.parse(fs_1.default.readFileSync(file, "utf-8"));
93
+ if (j && typeof j.project === "string" && (j.slot === "blue" || j.slot === "green")
94
+ && typeof j.statusUrl === "string" && typeof j.token === "string")
95
+ return j;
96
+ }
97
+ catch { }
98
+ return null;
99
+ }
100
+ async function query(timeoutMs) {
101
+ const base = {
102
+ live: true, bluegreen: false, project: null, slot: null, active: null, isActive: null,
103
+ lastDeployed: null, readyToSwitch: null, checkedAt: Date.now(), cached: false,
104
+ };
105
+ const file = findFile();
106
+ if (!file)
107
+ return base;
108
+ const cfg = readFile(file);
109
+ if (!cfg)
110
+ return { ...base, live: null, bluegreen: true, error: `${exports.BLUEGREEN_FILE} at ${file} is unreadable or malformed` };
111
+ const known = { ...base, live: null, bluegreen: true, project: cfg.project, slot: cfg.slot };
112
+ try {
113
+ const res = await fetch(cfg.statusUrl, {
114
+ method: "POST",
115
+ headers: { "content-type": "application/json" },
116
+ body: JSON.stringify({ name: cfg.project, token: cfg.token, slot: cfg.slot }),
117
+ signal: AbortSignal.timeout(timeoutMs),
118
+ });
119
+ const body = await res.json().catch(() => null);
120
+ const d = body?.data;
121
+ if (!res.ok || body?.error || !d) {
122
+ const reason = body?.error?.message ?? `HTTP ${res.status}`;
123
+ return { ...known, error: `deployer rejected the status query: ${reason}` };
124
+ }
125
+ const slotOrNull = (v) => (v === "blue" || v === "green" ? v : null);
126
+ const active = slotOrNull(d.active);
127
+ return {
128
+ ...known,
129
+ live: active ? active === cfg.slot : null,
130
+ active,
131
+ lastDeployed: slotOrNull(d.lastDeployed),
132
+ readyToSwitch: typeof d.readyToSwitch === "boolean" ? d.readyToSwitch : null,
133
+ isActive: active ? active === cfg.slot : null,
134
+ };
135
+ }
136
+ catch (e) {
137
+ return { ...known, error: `deployer unreachable at ${cfg.statusUrl}: ${e?.message ?? e}` };
138
+ }
139
+ }
140
+ async function getBlueGreenActiveState(opts) {
141
+ const ttl = opts?.ttlMs ?? 10_000;
142
+ if (!opts?.force && cache && Date.now() - cache.at < ttl)
143
+ return { ...cache.value, cached: true };
144
+ if (inflight)
145
+ return inflight;
146
+ inflight = query(opts?.timeoutMs ?? 3000)
147
+ .then(value => { cache = { at: Date.now(), value }; remember(value); return value; })
148
+ .finally(() => { inflight = null; });
149
+ return inflight;
150
+ }
@@ -15,6 +15,8 @@ declare const _ProjectState: {
15
15
  readonly user: "";
16
16
  readonly at: 0;
17
17
  }];
18
+ readonly statusToken: "";
19
+ readonly $__statusToken_optional: true;
18
20
  };
19
21
  export type IProjectState = ValidationModel<typeof _ProjectState>;
20
22
  declare const _TConfig: {
@@ -58,6 +58,8 @@ const _ProjectState = {
58
58
  user: "",
59
59
  at: 0,
60
60
  }],
61
+ statusToken: "",
62
+ $__statusToken_optional: true,
61
63
  };
62
64
  const _TConfig = {
63
65
  name: "",