tina4-nodejs 3.11.17 → 3.11.19

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,114 @@
1
+ /**
2
+ * Background tasks — periodic callbacks that run alongside the HTTP server.
3
+ *
4
+ * Mirrors Python's `tina4_python.core.server.background(fn, interval=1.0)`.
5
+ * Use this instead of `setInterval` directly, so timers integrate with the
6
+ * server lifecycle and clear cleanly on graceful shutdown (SIGTERM/SIGINT)
7
+ * or when `stopAllBackgroundTasks()` is called.
8
+ *
9
+ * import { background } from "@tina4/core";
10
+ *
11
+ * background(() => processQueue(), 2); // every 2 seconds
12
+ * background(async () => await healthCheck(), 30); // async also fine
13
+ *
14
+ * Errors thrown from a callback are caught and logged so a single failing
15
+ * task cannot bring down the rest of the timer wheel.
16
+ */
17
+
18
+ import { Log } from "./logger.js";
19
+
20
+ /** A registered background task — kept so `stopAllBackgroundTasks()` can clear them. */
21
+ interface BackgroundTask {
22
+ callback: () => unknown | Promise<unknown>;
23
+ intervalSeconds: number;
24
+ timer: NodeJS.Timeout;
25
+ }
26
+
27
+ const _tasks: BackgroundTask[] = [];
28
+ let _signalsBound = false;
29
+
30
+ /**
31
+ * Register signal handlers exactly once so SIGTERM/SIGINT during a long-running
32
+ * process clears all background timers before the runtime exits. The handler
33
+ * is additive — it does not call `process.exit()` or interfere with other
34
+ * shutdown logic registered by the CLI or user code.
35
+ */
36
+ function _bindSignalsOnce(): void {
37
+ if (_signalsBound) return;
38
+ _signalsBound = true;
39
+ const cleanup = () => stopAllBackgroundTasks();
40
+ process.on("SIGTERM", cleanup);
41
+ process.on("SIGINT", cleanup);
42
+ }
43
+
44
+ /**
45
+ * Register a callback to run periodically alongside the HTTP server.
46
+ *
47
+ * @param callback Function to call (sync or async, no arguments).
48
+ * @param intervalSeconds Seconds between invocations (default: 1).
49
+ * @returns A handle whose `stop()` clears just this one task.
50
+ */
51
+ export function background(
52
+ callback: () => unknown | Promise<unknown>,
53
+ intervalSeconds = 1,
54
+ ): { stop: () => void } {
55
+ if (typeof callback !== "function") {
56
+ throw new TypeError("background(callback, interval): callback must be a function");
57
+ }
58
+ if (typeof intervalSeconds !== "number" || !isFinite(intervalSeconds) || intervalSeconds <= 0) {
59
+ throw new RangeError(
60
+ `background(callback, interval): interval must be a positive number (got ${intervalSeconds})`,
61
+ );
62
+ }
63
+
64
+ _bindSignalsOnce();
65
+
66
+ const ms = Math.max(1, Math.round(intervalSeconds * 1000));
67
+ const timer = setInterval(() => {
68
+ try {
69
+ const result = callback();
70
+ if (result && typeof (result as Promise<unknown>).then === "function") {
71
+ (result as Promise<unknown>).catch((err) => {
72
+ Log.error?.(`background task error: ${err instanceof Error ? err.message : String(err)}`);
73
+ });
74
+ }
75
+ } catch (err) {
76
+ Log.error?.(`background task error: ${err instanceof Error ? err.message : String(err)}`);
77
+ }
78
+ }, ms);
79
+
80
+ // Don't keep the event loop alive solely for background tasks — this matches
81
+ // Python's behaviour, where background tasks live in the server's loop and
82
+ // exit with it rather than blocking shutdown.
83
+ if (typeof timer.unref === "function") {
84
+ timer.unref();
85
+ }
86
+
87
+ const task: BackgroundTask = { callback, intervalSeconds, timer };
88
+ _tasks.push(task);
89
+
90
+ return {
91
+ stop: () => {
92
+ clearInterval(task.timer);
93
+ const idx = _tasks.indexOf(task);
94
+ if (idx !== -1) _tasks.splice(idx, 1);
95
+ },
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Clear every registered background task. Called automatically on SIGTERM/SIGINT;
101
+ * also called from the server's `close()` so a manual server shutdown stops
102
+ * the timer wheel along with HTTP listeners.
103
+ */
104
+ export function stopAllBackgroundTasks(): void {
105
+ while (_tasks.length > 0) {
106
+ const task = _tasks.pop()!;
107
+ clearInterval(task.timer);
108
+ }
109
+ }
110
+
111
+ /** Number of currently-registered background tasks (test helper). */
112
+ export function backgroundTaskCount(): number {
113
+ return _tasks.length;
114
+ }
@@ -565,6 +565,12 @@ export class DevAdmin {
565
565
  { method: "GET", pattern: "/__dev/api/index/search", handler: handleIndexSearch },
566
566
  { method: "GET", pattern: "/__dev/api/index/file", handler: handleIndexFile },
567
567
  { method: "GET", pattern: "/__dev/api/index/overview", handler: handleIndexOverview },
568
+ // Live API RAG (Docs) — see plan/v3/22-LIVE-API-RAG.md
569
+ { method: "GET", pattern: "/__dev/api/docs/search", handler: handleDocsSearch },
570
+ { method: "GET", pattern: "/__dev/api/docs/class", handler: handleDocsClass },
571
+ { method: "GET", pattern: "/__dev/api/docs/method", handler: handleDocsMethod },
572
+ { method: "GET", pattern: "/__dev/api/docs/index", handler: handleDocsIndex },
573
+ { method: "GET", pattern: "/__dev/api/docs/.well-known.json", handler: handleDocsWellKnown },
568
574
  // JS asset
569
575
  { method: "GET", pattern: "/__dev/js/tina4-dev-admin.min.js", handler: handleDevAdminJs },
570
576
  ];
@@ -1869,6 +1875,73 @@ const handleIndexOverview: RouteHandler = async (_req, res) => {
1869
1875
  res.json(ProjectIndex.overview());
1870
1876
  };
1871
1877
 
1878
+ // ---------------------------------------------------------------------------
1879
+ // Live API RAG (Docs) — plan/v3/22-LIVE-API-RAG.md
1880
+ // ---------------------------------------------------------------------------
1881
+
1882
+ const handleDocsSearch: RouteHandler = async (req, res) => {
1883
+ const { Docs } = await import("./docs.js");
1884
+ const url = new URL(req.url ?? "/", "http://localhost");
1885
+ const q = url.searchParams.get("q") ?? "";
1886
+ const k = parseInt(url.searchParams.get("k") ?? "5", 10);
1887
+ const source = url.searchParams.get("source") ?? "all";
1888
+ const includePrivate = isTruthy(url.searchParams.get("include_private") ?? "false");
1889
+ const start = Date.now();
1890
+ const results = Docs.mcpSearch(q, k, undefined, source, includePrivate);
1891
+ res.json({ ok: true, query: q, results, took_ms: Date.now() - start });
1892
+ };
1893
+
1894
+ const handleDocsClass: RouteHandler = async (req, res) => {
1895
+ const { Docs } = await import("./docs.js");
1896
+ const url = new URL(req.url ?? "/", "http://localhost");
1897
+ const name = url.searchParams.get("name") ?? "";
1898
+ const spec = Docs.mcpClass(name);
1899
+ if (!spec) {
1900
+ res.json({ ok: false, error: `class not found: ${name}` }, 404);
1901
+ return;
1902
+ }
1903
+ res.json({ ok: true, class: spec });
1904
+ };
1905
+
1906
+ const handleDocsMethod: RouteHandler = async (req, res) => {
1907
+ const { Docs } = await import("./docs.js");
1908
+ const url = new URL(req.url ?? "/", "http://localhost");
1909
+ const cls = url.searchParams.get("class") ?? "";
1910
+ const name = url.searchParams.get("name") ?? "";
1911
+ const spec = Docs.mcpMethod(cls, name);
1912
+ if (!spec) {
1913
+ res.json({ ok: false, error: `method not found: ${cls}.${name}` }, 404);
1914
+ return;
1915
+ }
1916
+ res.json({ ok: true, method: spec });
1917
+ };
1918
+
1919
+ const handleDocsIndex: RouteHandler = async (req, res) => {
1920
+ const { Docs } = await import("./docs.js");
1921
+ const url = new URL(req.url ?? "/", "http://localhost");
1922
+ const source = url.searchParams.get("source") ?? "all";
1923
+ const docs = new (Docs as any)(process.cwd());
1924
+ let entries: any[] = docs.index();
1925
+ if (source !== "all") entries = entries.filter((e) => e.source === source);
1926
+ res.json({ ok: true, count: entries.length, entries });
1927
+ };
1928
+
1929
+ const handleDocsWellKnown: RouteHandler = async (_req, res) => {
1930
+ res.json({
1931
+ ok: true,
1932
+ name: "tina4-live-docs",
1933
+ description: "Live API docs for this Tina4 project (framework + user code)",
1934
+ spec: "plan/v3/22-LIVE-API-RAG.md",
1935
+ endpoints: {
1936
+ search: "/__dev/api/docs/search?q=<query>&k=<int>&source=<framework|user|all>&include_private=<bool>",
1937
+ class: "/__dev/api/docs/class?name=<fqn>",
1938
+ method: "/__dev/api/docs/method?class=<fqn>&name=<method>",
1939
+ index: "/__dev/api/docs/index?source=<framework|user|all>",
1940
+ },
1941
+ mcp_tools: ["api_search", "api_class", "api_method"],
1942
+ });
1943
+ };
1944
+
1872
1945
  // ---------------------------------------------------------------------------
1873
1946
  // Dev Admin JS handler — serves the shared JS file
1874
1947
  // ---------------------------------------------------------------------------
@@ -1934,10 +2007,55 @@ function renderToolbarHtml(ctx: {
1934
2007
  <span style="color:#ffeb3b;">req:${ctx.requestId}</span>
1935
2008
  <span style="color:#90caf9;">${ctx.routeCount} routes</span>
1936
2009
  <span style="color:#888;">Node.js ${nodeVersion}</span>
1937
- <a href="#" onclick="(function(e){e.preventDefault();var p=document.getElementById('tina4-dev-panel');if(p){p.style.display=p.style.display==='none'?'block':'none';return;}var c=document.createElement('div');c.id='tina4-dev-panel';c.style.cssText='position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all 0.2s';var f=document.createElement('iframe');f.src='/__dev';f.style.cssText='width:100%;height:100%;border:1px solid #2e7d32;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';c.appendChild(f);document.body.appendChild(c);})(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
2010
+ <a href="#" onclick="window.__tina4ToggleOverlay(event)" style="color:#ef9a9a;margin-left:auto;text-decoration:none;cursor:pointer;">Dashboard &#8599;</a>
1938
2011
  <span onclick="this.parentElement.style.display='none'" style="cursor:pointer;color:#888;margin-left:8px;">&#10005;</span>
1939
2012
  </div>
1940
2013
  <script>
2014
+ // Overlay open/toggle helper + auto-restore. Persist the dev-admin
2015
+ // iframe's open/closed state across parent reloads so saving a file
2016
+ // doesn't lose the user's dev-admin context. Cross-framework parity
2017
+ // with PHP / Python / Ruby — same localStorage key.
2018
+ (function(){
2019
+ var STATE_KEY = 'tina4_dev_overlay_open';
2020
+ function buildOverlay() {
2021
+ var c = document.createElement('div');
2022
+ c.id = 'tina4-dev-panel';
2023
+ c.style.cssText = 'position:fixed;top:3rem;left:0;right:0;bottom:2rem;z-index:99998;transition:all 0.2s';
2024
+ var f = document.createElement('iframe');
2025
+ f.src = '/__dev';
2026
+ f.style.cssText = 'width:100%;height:100%;border:1px solid #2e7d32;border-radius:0.5rem;box-shadow:0 8px 32px rgba(0,0,0,0.5);background:#0f172a';
2027
+ c.appendChild(f);
2028
+ document.body.appendChild(c);
2029
+ return c;
2030
+ }
2031
+ window.__tina4ToggleOverlay = function(e) {
2032
+ if (e) e.preventDefault();
2033
+ var p = document.getElementById('tina4-dev-panel');
2034
+ if (p) {
2035
+ var hide = p.style.display !== 'none';
2036
+ p.style.display = hide ? 'none' : 'block';
2037
+ try { localStorage.setItem(STATE_KEY, hide ? '0' : '1'); } catch (_) {}
2038
+ return;
2039
+ }
2040
+ buildOverlay();
2041
+ try { localStorage.setItem(STATE_KEY, '1'); } catch (_) {}
2042
+ };
2043
+ function restoreIfOpen() {
2044
+ try {
2045
+ if (location.pathname.indexOf('/__dev') === 0) return;
2046
+ if (localStorage.getItem(STATE_KEY) === '1' && !document.getElementById('tina4-dev-panel')) {
2047
+ buildOverlay();
2048
+ }
2049
+ } catch (_) {}
2050
+ }
2051
+ if (document.readyState === 'loading') {
2052
+ document.addEventListener('DOMContentLoaded', restoreIfOpen);
2053
+ } else {
2054
+ restoreIfOpen();
2055
+ }
2056
+ })();
2057
+ </script>
2058
+ <script>
1941
2059
  function tina4VersionModal(){
1942
2060
  var m=document.getElementById('tina4-ver-modal');
1943
2061
  if(m.style.display==='block'){m.style.display='none';return;}