pi-freeflow 1.2.0 → 1.2.1
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/LICENSE +21 -0
- package/README.md +7 -11
- package/extensions/index.ts +4 -2137
- package/package.json +25 -5
- package/src/catalog.ts +255 -0
- package/src/commands.ts +448 -0
- package/src/config.ts +145 -0
- package/src/deploy.ts +126 -0
- package/src/index.ts +243 -0
- package/src/logger.ts +327 -0
- package/src/models.ts +343 -0
- package/src/normalizer.ts +173 -0
- package/src/proxy.ts +467 -0
- package/src/rate-limiter.ts +148 -0
- package/src/relay-state.ts +208 -0
- package/src/relay.ts +198 -0
- package/src/stream-pipe.ts +154 -0
- package/src/types.ts +164 -0
package/src/deploy.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automated Vercel Edge Relay deployer for pi-freeflow
|
|
3
|
+
*
|
|
4
|
+
* Deploys a private 3-file Vercel edge proxy with strict target domain whitelisting.
|
|
5
|
+
* The provided API token is held in-memory only and never persisted to disk or logs.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { VERCEL_API } from "./config.ts";
|
|
9
|
+
import { log, logError } from "./logger.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hardened Edge Worker code deployed to Vercel.
|
|
13
|
+
* Strictly whitelists OpenCode Zen and KiloCode Gateway endpoints to prevent open proxy abuse.
|
|
14
|
+
*/
|
|
15
|
+
export const VERCEL_RELAY_WORKER = `// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
|
|
16
|
+
const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
|
|
17
|
+
export const config = { runtime: "edge" };
|
|
18
|
+
export default async function handler(req) {
|
|
19
|
+
const target = req.headers.get("x-relay-target");
|
|
20
|
+
const relayPath = req.headers.get("x-relay-path") || "/";
|
|
21
|
+
if (!target) return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
22
|
+
const cleanTarget = target.replace(/\\/$/, "");
|
|
23
|
+
if (!ALLOWED_TARGETS.includes(cleanTarget)) return new Response(JSON.stringify({ error: "Forbidden target" }), { status: 403, headers: { "content-type": "application/json" } });
|
|
24
|
+
if (!relayPath.startsWith("/")) return new Response(JSON.stringify({ error: "Bad path" }), { status: 400, headers: { "content-type": "application/json" } });
|
|
25
|
+
const targetUrl = cleanTarget + relayPath;
|
|
26
|
+
const headers = new Headers(req.headers);
|
|
27
|
+
headers.delete("x-relay-target"); headers.delete("x-relay-path"); headers.delete("host");
|
|
28
|
+
const response = await fetch(targetUrl, { method: req.method, headers, body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined, duplex: "half" });
|
|
29
|
+
return new Response(response.body, { status: response.status, headers: response.headers });
|
|
30
|
+
}`;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Deploy a fresh Vercel Edge Relay project in-memory.
|
|
34
|
+
*
|
|
35
|
+
* @param token Vercel personal access token (used in-memory only)
|
|
36
|
+
* @param name Unique project/deployment name (e.g. pi-freeflow-relay-abc123)
|
|
37
|
+
* @param onProgress Optional callback for user-facing progress updates
|
|
38
|
+
* @returns Deployed HTTPS relay URL
|
|
39
|
+
*/
|
|
40
|
+
export async function deployVercelRelay(
|
|
41
|
+
token: string,
|
|
42
|
+
name: string,
|
|
43
|
+
onProgress?: (msg: string) => void,
|
|
44
|
+
): Promise<string> {
|
|
45
|
+
const auth = {
|
|
46
|
+
Authorization: `Bearer ${token}`,
|
|
47
|
+
"Content-Type": "application/json",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 1. Create deployment (3 inline files, no git repository required)
|
|
51
|
+
onProgress?.("Uploading relay files to Vercel…");
|
|
52
|
+
log("info", `Starting Vercel deployment: ${name}`);
|
|
53
|
+
|
|
54
|
+
const dep = await fetch(`${VERCEL_API}/v13/deployments`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: auth,
|
|
57
|
+
body: JSON.stringify({
|
|
58
|
+
name,
|
|
59
|
+
files: [
|
|
60
|
+
{ file: "api/relay.js", data: VERCEL_RELAY_WORKER },
|
|
61
|
+
{
|
|
62
|
+
file: "package.json",
|
|
63
|
+
data: JSON.stringify({ name, version: "1.0.0" }),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
file: "vercel.json",
|
|
67
|
+
data: JSON.stringify({
|
|
68
|
+
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
projectSettings: { framework: null },
|
|
73
|
+
target: "production",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (!dep.ok) {
|
|
78
|
+
const e = (await dep
|
|
79
|
+
.json()
|
|
80
|
+
.catch(() => ({}))) as { error?: { message?: string } };
|
|
81
|
+
const errMsg = e?.error?.message || `Vercel deploy failed (HTTP ${dep.status})`;
|
|
82
|
+
logError(`Vercel deployment failed to create: ${errMsg}`);
|
|
83
|
+
throw new Error(errMsg);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const depJson = (await dep.json()) as { id?: string; uid?: string; projectId?: string };
|
|
87
|
+
const depId = depJson.id || depJson.uid;
|
|
88
|
+
const projectId = depJson.projectId || name;
|
|
89
|
+
|
|
90
|
+
// 2. Make the deployment public (disable SSO protection if enabled on team)
|
|
91
|
+
try {
|
|
92
|
+
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
|
93
|
+
method: "PATCH",
|
|
94
|
+
headers: auth,
|
|
95
|
+
body: JSON.stringify({ ssoProtection: null }),
|
|
96
|
+
});
|
|
97
|
+
} catch {}
|
|
98
|
+
|
|
99
|
+
// 3. Poll until READY state (3s interval, 120s maximum timeout)
|
|
100
|
+
onProgress?.("Waiting for Edge deployment to go live…");
|
|
101
|
+
const deadline = Date.now() + 120_000;
|
|
102
|
+
|
|
103
|
+
while (Date.now() < deadline) {
|
|
104
|
+
const s = await fetch(`${VERCEL_API}/v13/deployments/${depId}`, {
|
|
105
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
106
|
+
});
|
|
107
|
+
if (s.ok) {
|
|
108
|
+
const j = (await s.json()) as { readyState?: string; url?: string };
|
|
109
|
+
if (j.readyState === "READY" && j.url) {
|
|
110
|
+
const deployedUrl = `https://${j.url}`;
|
|
111
|
+
log("info", `Vercel relay successfully deployed: ${deployedUrl}`);
|
|
112
|
+
return deployedUrl;
|
|
113
|
+
}
|
|
114
|
+
if (j.readyState === "ERROR" || j.readyState === "CANCELED") {
|
|
115
|
+
const err = `Deployment failed with state: ${j.readyState}`;
|
|
116
|
+
logError(err);
|
|
117
|
+
throw new Error(err);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
await new Promise<void>((r) => setTimeout(r, 3000));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const timeoutErr = "Deployment timed out (120s)";
|
|
124
|
+
logError(timeoutErr);
|
|
125
|
+
throw new Error(timeoutErr);
|
|
126
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-freeflow — Modular, high-resiliency LLM extension for Pi & Oh My Pi (OMP)
|
|
3
|
+
*
|
|
4
|
+
* Provides access to 23 free models (9 OpenCode Zen + 14 KiloCode Gateway) with:
|
|
5
|
+
* - Single-port daemon reuse on 18080 across concurrent subagents
|
|
6
|
+
* - Multi-cloud rolling egress relays (Vercel Edge, Cloudflare, Deno)
|
|
7
|
+
* - 0ms instant startup with verified static catalog and background live health checks
|
|
8
|
+
* - Per-model thinking/reasoning translation and streaming SSE pass-through
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type * as http from "node:http";
|
|
12
|
+
import {
|
|
13
|
+
getAliveCatalog,
|
|
14
|
+
readCatalogCache,
|
|
15
|
+
refreshCatalog,
|
|
16
|
+
setAliveCatalog,
|
|
17
|
+
} from "./catalog.ts";
|
|
18
|
+
import { createCommandSpec, updateStatusBar } from "./commands.ts";
|
|
19
|
+
import { DEFAULT_HOST, HOST, PORT } from "./config.ts";
|
|
20
|
+
import { log, logInfo, logWarn } from "./logger.ts";
|
|
21
|
+
import { ALL_MODELS, KILO_MODEL_IDS } from "./models.ts";
|
|
22
|
+
import { isProxyAlive, startProxy } from "./proxy.ts";
|
|
23
|
+
import { resetRateLimits } from "./rate-limiter.ts";
|
|
24
|
+
import {
|
|
25
|
+
getActiveRelayState,
|
|
26
|
+
resolveRelayState,
|
|
27
|
+
setActiveRelayState,
|
|
28
|
+
setStatusUi,
|
|
29
|
+
} from "./relay-state.ts";
|
|
30
|
+
import type {
|
|
31
|
+
ExtensionAPI,
|
|
32
|
+
ExtensionContext,
|
|
33
|
+
ProviderConfig,
|
|
34
|
+
RegisteredModel,
|
|
35
|
+
} from "./types.ts";
|
|
36
|
+
|
|
37
|
+
// Re-export all sub-modules for clean library and programmatic usage
|
|
38
|
+
export * from "./types.ts";
|
|
39
|
+
export * from "./config.ts";
|
|
40
|
+
export * from "./logger.ts";
|
|
41
|
+
export * from "./rate-limiter.ts";
|
|
42
|
+
export * from "./models.ts";
|
|
43
|
+
export * from "./catalog.ts";
|
|
44
|
+
export * from "./relay-state.ts";
|
|
45
|
+
export * from "./relay.ts";
|
|
46
|
+
export * from "./deploy.ts";
|
|
47
|
+
export * from "./normalizer.ts";
|
|
48
|
+
export * from "./stream-pipe.ts";
|
|
49
|
+
export * from "./proxy.ts";
|
|
50
|
+
export * from "./commands.ts";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Construct standard ProviderConfig for pi-ai / OMP registration.
|
|
54
|
+
*/
|
|
55
|
+
export function buildProviderConfig(
|
|
56
|
+
models: RegisteredModel[],
|
|
57
|
+
port: number = PORT,
|
|
58
|
+
): ProviderConfig {
|
|
59
|
+
return {
|
|
60
|
+
baseUrl: `http://${HOST}:${port}/v1`,
|
|
61
|
+
apiKey: "placeholder",
|
|
62
|
+
api: "openai-completions",
|
|
63
|
+
compat: { supportsDeveloperRole: false },
|
|
64
|
+
models: models.map((m) => {
|
|
65
|
+
const efforts = m.thinkingLevelMap
|
|
66
|
+
? (Object.keys(m.thinkingLevelMap) as (keyof typeof m.thinkingLevelMap)[]).filter(
|
|
67
|
+
(k) => m.thinkingLevelMap![k] !== null && k !== "off",
|
|
68
|
+
)
|
|
69
|
+
: ["minimal", "low", "medium", "high", "xhigh"];
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
id: m.id,
|
|
73
|
+
name: m.name,
|
|
74
|
+
api: m.api,
|
|
75
|
+
reasoning: m.reasoning,
|
|
76
|
+
thinking: m.reasoning
|
|
77
|
+
? {
|
|
78
|
+
mode: "effort",
|
|
79
|
+
efforts: efforts.length > 0 ? efforts : ["low", "high", "max"],
|
|
80
|
+
}
|
|
81
|
+
: undefined,
|
|
82
|
+
thinkingLevelMap: m.thinkingLevelMap,
|
|
83
|
+
input: m.input ?? ["text"],
|
|
84
|
+
contextWindow: m.contextWindow,
|
|
85
|
+
maxTokens: m.maxTokens,
|
|
86
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
87
|
+
compat: m.thinkingFormat
|
|
88
|
+
? {
|
|
89
|
+
supportsDeveloperRole: false,
|
|
90
|
+
thinkingFormat: m.thinkingFormat,
|
|
91
|
+
}
|
|
92
|
+
: m.api === "openai-responses"
|
|
93
|
+
? { sessionAffinityFormat: "openai-nosession" }
|
|
94
|
+
: m.source === "kilo"
|
|
95
|
+
? {
|
|
96
|
+
supportsDeveloperRole: false,
|
|
97
|
+
supportsReasoningEffort: false,
|
|
98
|
+
}
|
|
99
|
+
: {
|
|
100
|
+
supportsDeveloperRole: false,
|
|
101
|
+
supportsReasoningEffort: true,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Main extension entrypoint
|
|
110
|
+
*/
|
|
111
|
+
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
112
|
+
logInfo("pi-freeflow extension initializing...");
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
let server: http.Server | null = null;
|
|
116
|
+
let actualPort = PORT;
|
|
117
|
+
|
|
118
|
+
// 2. Single-Port Shared Pattern: Check if daemon is already running (e.g. parent session)
|
|
119
|
+
const alreadyRunning = await isProxyAlive(PORT);
|
|
120
|
+
if (alreadyRunning) {
|
|
121
|
+
logInfo(
|
|
122
|
+
`Reusing existing pi-freeflow proxy daemon on http://${HOST}:${PORT}`,
|
|
123
|
+
);
|
|
124
|
+
actualPort = PORT;
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
const r = await startProxy();
|
|
128
|
+
server = r.server;
|
|
129
|
+
actualPort = r.port;
|
|
130
|
+
} catch (e) {
|
|
131
|
+
log(
|
|
132
|
+
"error",
|
|
133
|
+
"extension inactive — could not bind proxy port. resolve the port conflict and restart pi.",
|
|
134
|
+
{ error: String(e) },
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// 3. Instant 0ms Static Catalog Registration
|
|
141
|
+
// Register static models immediately on boot so Pi/OMP picker is populated with zero latency!
|
|
142
|
+
const initialCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
|
|
143
|
+
...m,
|
|
144
|
+
source: KILO_MODEL_IDS.has(m.id) ? "kilo" : "opencode",
|
|
145
|
+
}));
|
|
146
|
+
setAliveCatalog(initialCatalog);
|
|
147
|
+
pi.registerProvider(
|
|
148
|
+
"freeflow",
|
|
149
|
+
buildProviderConfig(initialCatalog, actualPort),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// 4. Background Catalog Refresh
|
|
153
|
+
// Asynchronously probe live upstreams and update the provider if alive model list changes
|
|
154
|
+
refreshCatalog(false)
|
|
155
|
+
.then((aliveModels) => {
|
|
156
|
+
if (aliveModels.length > 0) {
|
|
157
|
+
setAliveCatalog(aliveModels);
|
|
158
|
+
pi.registerProvider(
|
|
159
|
+
"freeflow",
|
|
160
|
+
buildProviderConfig(aliveModels, actualPort),
|
|
161
|
+
);
|
|
162
|
+
logInfo(
|
|
163
|
+
`Catalog refreshed: ${aliveModels.length} models verified active`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
.catch((err) => {
|
|
168
|
+
logWarn("Background catalog refresh failed; retaining static catalog", {
|
|
169
|
+
error: String(err),
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// 5. Register slash command
|
|
174
|
+
const commandSpec = createCommandSpec(pi, (updatedModels) => {
|
|
175
|
+
pi.registerProvider(
|
|
176
|
+
"freeflow",
|
|
177
|
+
buildProviderConfig(updatedModels, actualPort),
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
pi.registerCommand("freeflow", commandSpec);
|
|
181
|
+
|
|
182
|
+
// 6. Lifecycle Listeners
|
|
183
|
+
pi.on?.("session_start", async (_event, ctx: ExtensionContext) => {
|
|
184
|
+
const freshRelayState = resolveRelayState();
|
|
185
|
+
setActiveRelayState(freshRelayState, false);
|
|
186
|
+
setStatusUi(ctx.ui);
|
|
187
|
+
|
|
188
|
+
let provider: string | undefined;
|
|
189
|
+
let modelId: string | undefined;
|
|
190
|
+
if (ctx && typeof ctx === "object" && "model" in ctx && ctx.model && typeof ctx.model === "object") {
|
|
191
|
+
const m = ctx.model;
|
|
192
|
+
if ("provider" in m && typeof m.provider === "string") {
|
|
193
|
+
provider = m.provider;
|
|
194
|
+
}
|
|
195
|
+
if ("id" in m && typeof m.id === "string") {
|
|
196
|
+
modelId = m.id;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const isFreeFlow =
|
|
200
|
+
provider === "freeflow" ||
|
|
201
|
+
Boolean(modelId && getAliveCatalog().some((m) => m.id === modelId));
|
|
202
|
+
|
|
203
|
+
if (isFreeFlow) {
|
|
204
|
+
updateStatusBar(ctx.ui);
|
|
205
|
+
} else {
|
|
206
|
+
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on?.("model_select", async (event, ctx: ExtensionContext) => {
|
|
211
|
+
setStatusUi(ctx.ui);
|
|
212
|
+
let provider: string | undefined;
|
|
213
|
+
let modelId: string | undefined;
|
|
214
|
+
if (event && typeof event === "object" && "model" in event && event.model && typeof event.model === "object") {
|
|
215
|
+
const m = event.model;
|
|
216
|
+
if ("provider" in m && typeof m.provider === "string") {
|
|
217
|
+
provider = m.provider;
|
|
218
|
+
}
|
|
219
|
+
if ("id" in m && typeof m.id === "string") {
|
|
220
|
+
modelId = m.id;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const isFreeFlow =
|
|
224
|
+
provider === "freeflow" ||
|
|
225
|
+
Boolean(modelId && getAliveCatalog().some((m) => m.id === modelId));
|
|
226
|
+
|
|
227
|
+
if (isFreeFlow) {
|
|
228
|
+
updateStatusBar(ctx.ui);
|
|
229
|
+
} else {
|
|
230
|
+
ctx.ui?.setStatus?.("freeflow", undefined);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
pi.on?.("session_shutdown", () => {
|
|
235
|
+
if (server) {
|
|
236
|
+
logInfo("shutting down proxy daemon...");
|
|
237
|
+
server.close();
|
|
238
|
+
server = null;
|
|
239
|
+
resetRateLimits();
|
|
240
|
+
logInfo("shutdown complete");
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured, leveled, rotating, request-aware logger for pi-freeflow
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
DEBUG_STATE_FILE,
|
|
10
|
+
LOG_FILE,
|
|
11
|
+
LOG_MAX_BYTES,
|
|
12
|
+
LOG_MAX_FILES,
|
|
13
|
+
} from "./config.ts";
|
|
14
|
+
import type { DebugState, LogLevel } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
|
17
|
+
debug: 0,
|
|
18
|
+
info: 1,
|
|
19
|
+
warn: 2,
|
|
20
|
+
error: 3,
|
|
21
|
+
audit: 4,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let cachedDebugState: DebugState | null | undefined = undefined;
|
|
25
|
+
let cachedDebugMtime = 0;
|
|
26
|
+
let cachedDebugAt = 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Load persisted debug state from disk with a 1-second in-memory mtime cache.
|
|
30
|
+
*/
|
|
31
|
+
export function loadDebugState(): DebugState | null {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
if (cachedDebugState !== undefined && now - cachedDebugAt < 1000) {
|
|
34
|
+
return cachedDebugState;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(DEBUG_STATE_FILE)) {
|
|
39
|
+
cachedDebugState = null;
|
|
40
|
+
cachedDebugAt = now;
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const stat = fs.statSync(DEBUG_STATE_FILE);
|
|
45
|
+
if (stat.mtimeMs === cachedDebugMtime && cachedDebugState !== undefined) {
|
|
46
|
+
cachedDebugAt = now;
|
|
47
|
+
return cachedDebugState;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const raw = fs.readFileSync(DEBUG_STATE_FILE, "utf8");
|
|
51
|
+
const parsed = JSON.parse(raw) as DebugState;
|
|
52
|
+
if (typeof parsed?.debug === "boolean") {
|
|
53
|
+
cachedDebugState = parsed;
|
|
54
|
+
cachedDebugMtime = stat.mtimeMs;
|
|
55
|
+
cachedDebugAt = now;
|
|
56
|
+
return parsed;
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
// Non-fatal if parsing or reading fails
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
cachedDebugState = null;
|
|
63
|
+
cachedDebugAt = now;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Atomically persist debug state to disk.
|
|
69
|
+
*/
|
|
70
|
+
export function saveDebugState(s: DebugState): void {
|
|
71
|
+
try {
|
|
72
|
+
const dir = path.dirname(DEBUG_STATE_FILE);
|
|
73
|
+
if (!fs.existsSync(dir)) {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
const tmp = `${DEBUG_STATE_FILE}.${randomUUID()}.tmp`;
|
|
77
|
+
fs.writeFileSync(tmp, JSON.stringify(s, null, 2), "utf8");
|
|
78
|
+
fs.renameSync(tmp, DEBUG_STATE_FILE);
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const stat = fs.statSync(DEBUG_STATE_FILE);
|
|
82
|
+
cachedDebugState = s;
|
|
83
|
+
cachedDebugMtime = stat.mtimeMs;
|
|
84
|
+
cachedDebugAt = Date.now();
|
|
85
|
+
} catch {
|
|
86
|
+
cachedDebugState = s;
|
|
87
|
+
cachedDebugAt = Date.now();
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
// Avoid recursive logger calls on save failure
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Calculate the active minimum log level threshold based on state and env.
|
|
96
|
+
*/
|
|
97
|
+
export function getMinLogLevel(): number {
|
|
98
|
+
const dbg = loadDebugState();
|
|
99
|
+
if (dbg?.debug) {
|
|
100
|
+
return LOG_LEVEL_ORDER.debug;
|
|
101
|
+
}
|
|
102
|
+
if (dbg?.level && dbg.level in LOG_LEVEL_ORDER) {
|
|
103
|
+
return LOG_LEVEL_ORDER[dbg.level];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const raw = (process.env.FREEFLOW_LOG_LEVEL || "info").toLowerCase();
|
|
107
|
+
|
|
108
|
+
if (raw in LOG_LEVEL_ORDER) {
|
|
109
|
+
return LOG_LEVEL_ORDER[raw as LogLevel];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isEnvDebug =
|
|
113
|
+
process.env.FREEFLOW_DEBUG === "1" ||
|
|
114
|
+
process.env.FREEFLOW_DEBUG === "true";
|
|
115
|
+
|
|
116
|
+
if (isEnvDebug) {
|
|
117
|
+
return LOG_LEVEL_ORDER.debug;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return LOG_LEVEL_ORDER.info;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function shouldLog(level: LogLevel): boolean {
|
|
124
|
+
return LOG_LEVEL_ORDER[level] >= getMinLogLevel();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function isDebugEnabled(): boolean {
|
|
128
|
+
return LOG_LEVEL_ORDER.debug >= getMinLogLevel();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rotate log files if current log file size exceeds LOG_MAX_BYTES.
|
|
133
|
+
* Rotates: log -> log.1 -> log.2 -> log.3 ... up to LOG_MAX_FILES.
|
|
134
|
+
*/
|
|
135
|
+
export function rotateLogsIfNeeded(targetFile: string = LOG_FILE): void {
|
|
136
|
+
try {
|
|
137
|
+
if (!fs.existsSync(targetFile)) return;
|
|
138
|
+
const stat = fs.statSync(targetFile);
|
|
139
|
+
if (stat.size <= LOG_MAX_BYTES) return;
|
|
140
|
+
|
|
141
|
+
for (let i = LOG_MAX_FILES; i >= 1; i--) {
|
|
142
|
+
const src = i === 1 ? targetFile : `${targetFile}.${i - 1}`;
|
|
143
|
+
const dst = `${targetFile}.${i}`;
|
|
144
|
+
try {
|
|
145
|
+
if (fs.existsSync(src)) {
|
|
146
|
+
if (i === LOG_MAX_FILES && fs.existsSync(dst)) {
|
|
147
|
+
fs.unlinkSync(dst);
|
|
148
|
+
} else if (fs.existsSync(dst)) {
|
|
149
|
+
fs.unlinkSync(dst);
|
|
150
|
+
}
|
|
151
|
+
fs.renameSync(src, dst);
|
|
152
|
+
}
|
|
153
|
+
} catch {
|
|
154
|
+
// Ignore rotation step error and continue
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
} catch {
|
|
158
|
+
// Ignore rotation errors
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Safely format metadata object and optional requestId for log output.
|
|
164
|
+
*/
|
|
165
|
+
export function formatLogMeta(
|
|
166
|
+
meta?: Record<string, unknown>,
|
|
167
|
+
reqId?: string,
|
|
168
|
+
): string {
|
|
169
|
+
const parts: string[] = [];
|
|
170
|
+
if (reqId) {
|
|
171
|
+
parts.push(`req=${reqId}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (meta && Object.keys(meta).length > 0) {
|
|
175
|
+
const safe: Record<string, unknown> = {};
|
|
176
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
177
|
+
if (typeof v === "string" && v.length > 800) {
|
|
178
|
+
safe[k] = `${v.slice(0, 800)}…(${v.length})`;
|
|
179
|
+
} else if (v instanceof Error) {
|
|
180
|
+
safe[k] = {
|
|
181
|
+
name: v.name,
|
|
182
|
+
message: v.message,
|
|
183
|
+
stack: v.stack?.split("\n").slice(0, 3).join(" | "),
|
|
184
|
+
};
|
|
185
|
+
} else {
|
|
186
|
+
safe[k] = v;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
parts.push(JSON.stringify(safe));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return parts.length > 0 ? ` ${parts.join(" ")}` : "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Write a structured log entry to disk if level passes threshold.
|
|
197
|
+
*/
|
|
198
|
+
export function log(
|
|
199
|
+
level: LogLevel,
|
|
200
|
+
message: string,
|
|
201
|
+
meta?: Record<string, unknown>,
|
|
202
|
+
reqId?: string,
|
|
203
|
+
): void {
|
|
204
|
+
if (!shouldLog(level)) return;
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
const ts = new Date().toISOString();
|
|
208
|
+
const reqTag = reqId ? ` [${reqId}]` : "";
|
|
209
|
+
const line = `[${ts}] [${level.toUpperCase()}]${reqTag} ${message}${formatLogMeta(meta)}\n`;
|
|
210
|
+
|
|
211
|
+
rotateLogsIfNeeded(LOG_FILE);
|
|
212
|
+
|
|
213
|
+
const dir = path.dirname(LOG_FILE);
|
|
214
|
+
if (!fs.existsSync(dir)) {
|
|
215
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
216
|
+
}
|
|
217
|
+
fs.appendFileSync(LOG_FILE, line, "utf8");
|
|
218
|
+
} catch {
|
|
219
|
+
// Fallback silent failure
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function logDebug(
|
|
224
|
+
message: string,
|
|
225
|
+
meta?: Record<string, unknown>,
|
|
226
|
+
reqId?: string,
|
|
227
|
+
): void {
|
|
228
|
+
log("debug", message, meta, reqId);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function logInfo(
|
|
232
|
+
message: string,
|
|
233
|
+
meta?: Record<string, unknown>,
|
|
234
|
+
reqId?: string,
|
|
235
|
+
): void {
|
|
236
|
+
log("info", message, meta, reqId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function logWarn(
|
|
240
|
+
message: string,
|
|
241
|
+
meta?: Record<string, unknown>,
|
|
242
|
+
reqId?: string,
|
|
243
|
+
): void {
|
|
244
|
+
log("warn", message, meta, reqId);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function logError(
|
|
248
|
+
message: string,
|
|
249
|
+
meta?: Record<string, unknown>,
|
|
250
|
+
reqId?: string,
|
|
251
|
+
): void {
|
|
252
|
+
log("error", message, meta, reqId);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function logAudit(
|
|
256
|
+
message: string,
|
|
257
|
+
meta?: Record<string, unknown>,
|
|
258
|
+
reqId?: string,
|
|
259
|
+
): void {
|
|
260
|
+
log("audit", message, meta, reqId);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface ReadRecentLogsResult {
|
|
264
|
+
lines: string[];
|
|
265
|
+
totalMatched: number;
|
|
266
|
+
totalLines: number;
|
|
267
|
+
logFile: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Read recent log entries from log file and its rotated archives.
|
|
272
|
+
*/
|
|
273
|
+
export function readRecentLogs(
|
|
274
|
+
filterLevel?: LogLevel | null,
|
|
275
|
+
filterReqId?: string | null,
|
|
276
|
+
count = 25,
|
|
277
|
+
): ReadRecentLogsResult {
|
|
278
|
+
const files: string[] = [
|
|
279
|
+
LOG_FILE,
|
|
280
|
+
`${LOG_FILE}.1`,
|
|
281
|
+
`${LOG_FILE}.2`,
|
|
282
|
+
`${LOG_FILE}.3`,
|
|
283
|
+
].filter((f) => fs.existsSync(f));
|
|
284
|
+
|
|
285
|
+
if (files.length === 0) {
|
|
286
|
+
return {
|
|
287
|
+
lines: [],
|
|
288
|
+
totalMatched: 0,
|
|
289
|
+
totalLines: 0,
|
|
290
|
+
logFile: LOG_FILE,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
let allLines: string[] = [];
|
|
295
|
+
for (const f of files) {
|
|
296
|
+
try {
|
|
297
|
+
const content = fs.readFileSync(f, "utf8");
|
|
298
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
299
|
+
allLines = lines.concat(allLines);
|
|
300
|
+
} catch {
|
|
301
|
+
// Skip unreadable rotated files
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let filtered = allLines;
|
|
306
|
+
if (filterLevel) {
|
|
307
|
+
const levelTag = `[${filterLevel.toUpperCase()}]`;
|
|
308
|
+
filtered = filtered.filter((l) => l.includes(levelTag));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (filterReqId) {
|
|
312
|
+
const cleanedReqId = filterReqId.replace(/^req=/, "");
|
|
313
|
+
filtered = filtered.filter(
|
|
314
|
+
(l) => l.includes(cleanedReqId) || l.includes(`[${cleanedReqId}]`),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const clampedCount = Math.min(200, Math.max(1, count));
|
|
319
|
+
const resultLines = filtered.slice(-clampedCount);
|
|
320
|
+
|
|
321
|
+
return {
|
|
322
|
+
lines: resultLines,
|
|
323
|
+
totalMatched: filtered.length,
|
|
324
|
+
totalLines: allLines.length,
|
|
325
|
+
logFile: LOG_FILE,
|
|
326
|
+
};
|
|
327
|
+
}
|