File size: 3,520 Bytes
6111b2b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import fs from "node:fs";
import path from "node:path";
import { SQLITE_FILE } from "@/lib/db/core";
import { getSettings } from "@/lib/db/settings";
import { applyRuntimeSettings, type RuntimeReloadChange } from "./runtimeSettings";

const DEFAULT_POLL_INTERVAL_MS = 5_000;
const MIN_POLL_INTERVAL_MS = 1_000;

let activeCheck: Promise<void> | null = null;
let queuedSource: string | null = null;
let pollTimer: ReturnType<typeof setInterval> | null = null;
let sqliteWatcher: fs.FSWatcher | null = null;

function getPollIntervalMs() {
  const parsed = Number.parseInt(process.env.OMNIROUTE_CONFIG_HOT_RELOAD_MS || "", 10);
  if (!Number.isFinite(parsed) || parsed < MIN_POLL_INTERVAL_MS) {
    return DEFAULT_POLL_INTERVAL_MS;
  }
  return parsed;
}

function isRelevantSqliteChange(filename: string | null): boolean {
  if (!filename || !SQLITE_FILE) return false;
  const baseName = path.basename(SQLITE_FILE);
  return filename === baseName || filename.startsWith(`${baseName}-`);
}

function logChanges(source: string, changes: RuntimeReloadChange[]) {
  if (changes.length === 0) return;
  console.log(
    `[HOT_RELOAD] source=${source} reloaded sections: ${changes

      .map((entry) => entry.section)

      .join(", ")}`
  );
}

async function runHotReloadCheck(source: string) {
  const settings = await getSettings();
  const changes = await applyRuntimeSettings(settings, { source });
  logChanges(source, changes);
}

function queueHotReloadCheck(source: string) {
  if (activeCheck) {
    queuedSource = source;
    return;
  }

  activeCheck = runHotReloadCheck(source)
    .catch((error) => {
      console.warn(
        `[HOT_RELOAD] Runtime config reload failed for ${source}:`,
        error instanceof Error ? error.message : error
      );
    })
    .finally(() => {
      activeCheck = null;
      if (!queuedSource) return;
      const nextSource = queuedSource;
      queuedSource = null;
      queueHotReloadCheck(nextSource);
    });
}

export function startRuntimeConfigHotReload(options: { pollIntervalMs?: number } = {}) {
  if (pollTimer || sqliteWatcher) return;

  const pollIntervalMs = Math.max(
    options.pollIntervalMs || getPollIntervalMs(),
    MIN_POLL_INTERVAL_MS
  );

  pollTimer = setInterval(() => {
    queueHotReloadCheck("hot-reload:poll");
  }, pollIntervalMs);
  if (typeof pollTimer === "object" && "unref" in pollTimer) {
    (pollTimer as { unref?: () => void }).unref?.();
  }

  if (SQLITE_FILE) {
    try {
      sqliteWatcher = fs.watch(path.dirname(SQLITE_FILE), (_eventType, filename) => {
        const normalizedFilename = typeof filename === "string" ? filename : filename?.toString();
        if (isRelevantSqliteChange(normalizedFilename || null)) {
          queueHotReloadCheck("hot-reload:fs-watch");
        }
      });
    } catch (error) {
      console.warn(
        "[HOT_RELOAD] SQLite file watch unavailable, polling only:",
        error instanceof Error ? error.message : error
      );
    }
  }

  console.log(
    `[HOT_RELOAD] Runtime config hot-reload started (poll=${pollIntervalMs}ms, fsWatch=${

      sqliteWatcher ? "on" : "off"

    })`
  );

  queueHotReloadCheck("hot-reload:start");
}

export function stopRuntimeConfigHotReloadForTests() {
  if (pollTimer) {
    clearInterval(pollTimer);
    pollTimer = null;
  }
  sqliteWatcher?.close();
  sqliteWatcher = null;
  queuedSource = null;
  activeCheck = null;
}