| 1 | import { stripVTControlCharacters } from "node:util"; |
| 2 | import type * as ConsoleService from "effect/Console"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import { makeLineBuffer } from "./LineBuffer.ts"; |
| 5 | import { ANSI_RESET, ansiFg, colorsEnabled } from "./Terminal.ts"; |
| 6 | import { theme } from "./Theme.ts"; |
| 7 | |
| 8 | export type OutputChannel = "stdout" | "stderr"; |
| 9 | |
| 10 | /** Sigil-themed resource attribution shared by every append-only log path. */ |
| 11 | export const formatResourceTag = ( |
| 12 | id: string, |
| 13 | colors = colorsEnabled(), |
| 14 | ): string => |
| 15 | colors ? `${ansiFg(theme.color.info)}[${id}]${ANSI_RESET}` : `[${id}]`; |
| 16 | |
| 17 | const EFFECT_LOG_PREFIX = |
| 18 | /^\[\d{2}:\d{2}:\d{2}\.\d{3}\] (?:ALL|TRACE|DEBUG|INFO|WARN|ERROR|FATAL|NONE) \(#\d+\)(?: [^:]+)?: /; |
| 19 | |
| 20 | /** Remove a pretty Effect prefix already rendered by a child process. */ |
| 21 | export const stripChildEffectPrefix = (line: string): string => { |
| 22 | const match = stripVTControlCharacters(line).match(EFFECT_LOG_PREFIX); |
| 23 | if (match === null) return line; |
| 24 | |
| 25 | // The match is measured against visible text. Walk the original line so |
| 26 | // ANSI embedded in the child message survives while the prefix's ANSI is |
| 27 | // discarded with the prefix itself. |
| 28 | let visible = 0; |
| 29 | let offset = 0; |
| 30 | while (offset < line.length && visible < match[0].length) { |
| 31 | if (line[offset] === "\x1b") { |
| 32 | const ansi = line.slice(offset).match(/^\x1b\[[0-?]*[ -/]*[@-~]/); |
| 33 | if (ansi !== null) { |
| 34 | offset += ansi[0].length; |
| 35 | continue; |
| 36 | } |
| 37 | } |
| 38 | offset++; |
| 39 | visible++; |
| 40 | } |
| 41 | return line.slice(offset); |
| 42 | }; |
| 43 | |
| 44 | /** |
| 45 | * The single terminal-output pipeline for resource-owned processes. Dev |
| 46 | * servers, local workers, and deploy-time builders all use the same line |
| 47 | * splitting, resource prefix, color policy, and stdout/stderr severity. |
| 48 | */ |
| 49 | export const makeResourceOutput = ( |
| 50 | id: string, |
| 51 | console: Pick<ConsoleService.Console, "log">, |
| 52 | ) => { |
| 53 | const prefix = formatResourceTag(id); |
| 54 | // stderr is a process transport, not a semantic failure: Vite and many |
| 55 | // other tools write warnings, progress, and ordinary diagnostics there. |
| 56 | // Sending it through Console.error makes CLIKit prepend an error glyph to |
| 57 | // every line. Both streams therefore enter the renderer as plain resource |
| 58 | // output; the child text retains its own ANSI severity styling. |
| 59 | const writeLine = (line: string) => |
| 60 | console.log(`${prefix} ${stripChildEffectPrefix(line)}`); |
| 61 | return { |
| 62 | stdout: makeLineBuffer(writeLine), |
| 63 | stderr: makeLineBuffer(writeLine), |
| 64 | }; |
| 65 | }; |
| 66 | |
| 67 | /** |
| 68 | * Effect-native output for resource-owned child processes whose streams are |
| 69 | * already consumed inside an Effect. Both process channels are transport |
| 70 | * details, not semantic severity, so they enter the configured logger at the |
| 71 | * info level. The logger remains responsible for terminal and file sinks. |
| 72 | */ |
| 73 | export const makeResourceLogger = (id: string) => { |
| 74 | const prefix = formatResourceTag(id); |
| 75 | return (_channel: OutputChannel, line: string) => |
| 76 | Effect.logInfo(`${prefix} ${stripChildEffectPrefix(line)}`); |
| 77 | }; |