diff --git a/.vscode/settings.json b/.vscode/settings.json index 764a1867..b91082dc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "apps/cli/cli-core", "apps/cli/cli-web", "apps/docs", - "apps/marketing" + "apps/marketing", + "packages/logger" ] } diff --git a/apps/cli/cli-core/package.json b/apps/cli/cli-core/package.json index 307ae4da..df6f023d 100644 --- a/apps/cli/cli-core/package.json +++ b/apps/cli/cli-core/package.json @@ -13,6 +13,7 @@ "format:check": "prettier --check --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore" }, "dependencies": { + "@captain/logger": "workspace:*", "@trpc/client": "10.9.0", "@trpc/server": "10.9.0", "node-fetch": "^3.3.0", diff --git a/apps/cli/cli-core/src/get-sample-hooks.ts b/apps/cli/cli-core/src/get-sample-hooks.ts index e997c19b..77edae9c 100644 --- a/apps/cli/cli-core/src/get-sample-hooks.ts +++ b/apps/cli/cli-core/src/get-sample-hooks.ts @@ -2,14 +2,14 @@ import path from "path"; import fs from "fs"; import fsPromise from "fs/promises"; import fetch from "node-fetch"; + import { HOOK_PATH } from "./constants"; +import logger from "@captain/logger"; export async function getSampleHooks() { // Create the directory if it doesn't exist if (!fs.existsSync(HOOK_PATH)) { - console.log( - `\x1b[33m[WARNING] Could not find .thing directory, creating it now!\x1b[0m` - ); + logger.warn(`Could not find .thing directory, creating it now!`); fs.mkdirSync(HOOK_PATH, { recursive: true }); } @@ -20,10 +20,10 @@ export async function getSampleHooks() { download_url: string; }[]; - console.log(`[INFO] Downloading ${files.length} sample hooks.`); + logger.info(`Downloading ${files.length} sample hooks.`); const promiseMap = files.map(async (file) => { - console.log(`[INFO] Downloading ${file.name}`); + logger.info(`Downloading ${file.name}`); const fileContent = await fetch(file.download_url).then((res) => res.text() ); @@ -32,8 +32,8 @@ export async function getSampleHooks() { try { return await fsPromise.writeFile(newFilePath, fileContent); } catch (e) { - console.log(`[ERROR] Could not write file ${file.name}`); - console.log(e); + logger.error(`Could not write file ${file.name}`); + logger.error(e); throw e; } }); diff --git a/apps/cli/cli-core/src/open-folder.ts b/apps/cli/cli-core/src/open-folder.ts index 477b40f7..f69f9567 100644 --- a/apps/cli/cli-core/src/open-folder.ts +++ b/apps/cli/cli-core/src/open-folder.ts @@ -3,6 +3,8 @@ import { promisify } from "util"; import os from "os"; import fs from "fs"; +import logger from "@captain/logger"; + const promisifiedExecFile = promisify(childProcess.execFile); const getCommand = () => { @@ -23,9 +25,7 @@ const getCommand = () => { export async function openInExplorer(path: string) { // Create the directory if it doesn't exist if (!fs.existsSync(path)) { - console.log( - `\x1b[33m[WARNING] Could not find .thing directory, creating it now!\x1b[0m` - ); + logger.warn(`Could not find .thing directory, creating it now!`); fs.mkdirSync(path, { recursive: true }); } diff --git a/apps/cli/cli-core/src/templateSubstitution.ts b/apps/cli/cli-core/src/templateSubstitution.ts index 6ab37af2..d27c1352 100644 --- a/apps/cli/cli-core/src/templateSubstitution.ts +++ b/apps/cli/cli-core/src/templateSubstitution.ts @@ -1,3 +1,5 @@ +import logger from "@captain/logger"; + export const substituteTemplate = (input: { template: string; sanitize?: boolean; @@ -13,7 +15,7 @@ export const substituteTemplate = (input: { const variable = match[1]?.trim(); if (!variable) { - console.log(`\u001b[31m[ERROR] Invalid template configuration`); + logger.error(`Invalid template configuration`); throw new Error(`Invalid template configuration`); } const sanitizedString = `%%${variable}%%`; @@ -21,9 +23,7 @@ export const substituteTemplate = (input: { const fromEnv = process.env[variable]; if (!fromEnv) { - console.log( - `\u001b[31m[ERROR] Environment variable ${variable} not found` - ); + logger.error(`Environment variable ${variable} not found`); throw new Error(`Environment variable ${variable} not found`); } diff --git a/apps/cli/cli-core/src/trpc.ts b/apps/cli/cli-core/src/trpc.ts index ac9827b3..54f7c896 100644 --- a/apps/cli/cli-core/src/trpc.ts +++ b/apps/cli/cli-core/src/trpc.ts @@ -15,10 +15,29 @@ import { substituteTemplate } from "./templateSubstitution"; export type { ConfigValidatorType } from "./update-config"; +import logger from "@captain/logger"; +import { observable } from "@trpc/server/observable"; + +import type { LogLevels } from "@captain/logger"; + export const t = initTRPC.create({ transformer: superjson, }); export const cliApiRouter = t.router({ + onLog: t.procedure.subscription(() => { + return observable<{ message: string; level: LogLevels }>((emit) => { + const onLog = (m: { message: string; level: LogLevels }) => { + emit.next(m); + }; + + logger.subscribe(onLog); + + return () => { + logger.unsubscribe(onLog); + }; + }); + }), + getBlobs: t.procedure.query(async () => { if (!fs.existsSync(HOOK_PATH)) { // TODO: this should probably be an error, and the frontend should handle it @@ -87,8 +106,8 @@ export const cliApiRouter = t.router({ try { await openInExplorer(path.join(HOOK_PATH, input.path)); } catch (e) { - console.log( - "[ERROR] Failed to open folder (unless you're on Windows, then this just happens)", + logger.error( + "Failed to open folder (unless you're on Windows, then this just happens)", e ); } @@ -108,7 +127,7 @@ export const cliApiRouter = t.router({ .mutation(async ({ input }) => { const { file, url } = input; let hasCustomConfig = false; - console.log(`[INFO] Reading file ${file}`); + logger.info(`Reading file ${file}`); let config = { url, @@ -128,7 +147,7 @@ export const cliApiRouter = t.router({ if (fs.existsSync(path.join(HOOK_PATH, configName))) { hasCustomConfig = true; - console.log(`[INFO] Found ${configName}, reading it`); + logger.info(`Found ${configName}, reading it`); const configFileContents = await fsPromises.readFile( path.join(HOOK_PATH, configName) ); @@ -154,8 +173,8 @@ export const cliApiRouter = t.router({ const data = await fsPromises.readFile(path.join(HOOK_PATH, file)); try { - console.log( - `[INFO] Sending to ${config.url} ${ + logger.info( + `Sending to ${config.url} ${ hasCustomConfig ? `with custom config from ${configName}` : "" }\n` ); @@ -166,18 +185,16 @@ export const cliApiRouter = t.router({ body: config.method !== "GET" ? data.toString() : undefined, }).then((res) => res.json()); - console.log( - `[INFO] Got response: \n\n${JSON.stringify(fetchedResult, null, 2)}\n` + logger.info( + `Got response: \n\n${JSON.stringify(fetchedResult, null, 2)}\n` ); return fetchedResult; } catch (e) { - console.log("\u001b[31m[ERROR] FAILED TO SEND"); + logger.error("FAILED TO SEND"); if ((e as { code: string }).code === "ECONNREFUSED") { - console.log( - "\u001b[31m[ERROR] Connection refused. Is the server running?" - ); + logger.error("Connection refused. Is the server running?"); } else { - console.log("\u001b[31m[ERROR] Unknown error", e); + logger.error("Unknown error", e); } throw new Error("Connection refused. Is the server running?"); } @@ -193,11 +210,11 @@ export const cliApiRouter = t.router({ ) .mutation(async ({ input }) => { const { name, body, config } = input; - console.log(`[INFO] Creating ${name}.json`); + logger.info(`Creating ${name}.json`); await fsPromises.writeFile(path.join(HOOK_PATH, `${name}.json`), body); if (config?.url || config?.query || config?.headers) { - console.log(`[INFO] Config specified, creating ${name}.config.json`); + logger.info(`Config specified, creating ${name}.config.json`); return await updateConfig({ name, config }); } }), @@ -216,7 +233,7 @@ export const cliApiRouter = t.router({ if (!name) throw new Error("No name"); - console.log(`[INFO] updating ${name}.json`); + logger.info(`Updating ${name}.json`); const existingBody = await fsPromises.readFile( path.join(HOOK_PATH, `${name}.json`), @@ -244,7 +261,7 @@ export const cliApiRouter = t.router({ config?.headers || fs.existsSync(path.join(HOOK_PATH, `${name}.config.json`)) ) { - console.log(`[INFO] Config specified, updating ${name}.config.json`); + logger.info(`Config specified, updating ${name}.config.json`); return await updateConfig({ name, config }); } }), diff --git a/apps/cli/cli-web/package.json b/apps/cli/cli-web/package.json index 8de949fd..cae2ba5f 100644 --- a/apps/cli/cli-web/package.json +++ b/apps/cli/cli-web/package.json @@ -13,6 +13,7 @@ "format:check": "prettier --check --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore" }, "dependencies": { + "@captain/logger": "workspace:*", "@headlessui/react": "^1.7.8", "@heroicons/react": "^2.0.12", "@hookform/resolvers": "^2.9.10", diff --git a/apps/cli/cli-web/src/App.tsx b/apps/cli/cli-web/src/App.tsx index 050e1029..bfe49891 100644 --- a/apps/cli/cli-web/src/App.tsx +++ b/apps/cli/cli-web/src/App.tsx @@ -4,6 +4,7 @@ import { Toaster } from "react-hot-toast"; import { JsonBlobs } from "./components/jsonblobs"; import { EndpointSetting } from "./components/endpointsetting"; import { useConnectionStateToasts } from "./utils/useConnectionStateToasts"; +import { Logs } from "./components/logs"; const SubscriptionsHelper = () => { useConnectionStateToasts(); @@ -52,6 +53,7 @@ export default function Example() {
+
diff --git a/apps/cli/cli-web/src/components/logs.tsx b/apps/cli/cli-web/src/components/logs.tsx new file mode 100644 index 00000000..fa35c0c4 --- /dev/null +++ b/apps/cli/cli-web/src/components/logs.tsx @@ -0,0 +1,47 @@ +import { ReactNode, useState } from "react"; +import { cliApi } from "../utils/api"; + +import type { LogLevels } from "@captain/logger"; + +const colorMap = { + trace: "text-gray-600", // gray + debug: `text-cyan-600`, // cyan + info: `text-white`, // white + warn: `text-yellow-600`, // yellow + error: `text-red-600`, // red +} as const; + +const webFormat = (input: { level: LogLevels; message: string }) => { + const { level, message } = input; + return ( + + {`[${level.toUpperCase()}]`}{" "} + {message} + + ); +}; + +export const Logs = () => { + const [messages, setMessages] = useState< + { level: LogLevels; message: string }[] + >([]); + cliApi.onLog.useSubscription(undefined, { + onData: (data) => { + setMessages((messages) => { + return [...messages, data]; + }); + }, + }); + + return ( +
+ {messages.map((message, index) => { + return ( +
+ {webFormat(message)} +
+ ); + })} +
+ ); +}; diff --git a/apps/cli/cli-web/src/utils/api-wrapper.tsx b/apps/cli/cli-web/src/utils/api-wrapper.tsx index 4e009ef2..fa7e2551 100644 --- a/apps/cli/cli-web/src/utils/api-wrapper.tsx +++ b/apps/cli/cli-web/src/utils/api-wrapper.tsx @@ -1,8 +1,16 @@ -import { httpBatchLink } from "@trpc/client"; +import { createWSClient, httpBatchLink, splitLink, wsLink } from "@trpc/client"; import superjson from "superjson"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PropsWithChildren, useState } from "react"; + +const WS_PORT = 2034; + +// create persistent WebSocket connection +const wsClient = createWSClient({ + url: `ws://localhost:${WS_PORT}`, +}); + import { cliApi } from "./api"; export const ApiTRPCProvider = (props: PropsWithChildren) => { @@ -11,6 +19,18 @@ export const ApiTRPCProvider = (props: PropsWithChildren) => { cliApi.createClient({ transformer: superjson, links: [ + splitLink({ + // subscriptions are handled by WebSocket, everything else is handled by HTTP + condition(op) { + return op.type === "subscription"; + }, + true: wsLink({ + client: wsClient, + }), + false: httpBatchLink({ + url: "http://localhost:2033/trpc", + }), + }), httpBatchLink({ url: "http://localhost:2033/trpc", }), diff --git a/apps/cli/cli/package.json b/apps/cli/cli/package.json index b5d013b2..40ca3311 100644 --- a/apps/cli/cli/package.json +++ b/apps/cli/cli/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@captain/cli-core": "*", + "@captain/logger": "workspace:*", "@fastify/cors": "^8.2.0", "@fastify/http-proxy": "^8.4.0", "@fastify/static": "^6.6.0", @@ -34,7 +35,8 @@ "fs-extra": "^11.1.0", "graceful-fs": "^4.2.10", "gradient-string": "^2.0.2", - "open": "^8.4.0" + "open": "^8.4.0", + "ws": "^8.12.1" }, "devDependencies": { "@captain/cli-web": "*", @@ -43,6 +45,7 @@ "@types/gradient-string": "^1.1.2", "@types/inquirer": "^9.0.2", "@types/node": "^18.8.0", + "@types/ws": "^8.5.4", "@vercel/ncc": "^0.36.0", "prettier": "^2.7.1", "prettier-plugin-tailwindcss": "^0.1.13", diff --git a/apps/cli/cli/src/index.ts b/apps/cli/cli/src/index.ts index 6b7fbe8a..632b9550 100644 --- a/apps/cli/cli/src/index.ts +++ b/apps/cli/cli/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { startServer } from "./server"; +import { startServer, startSocketServer } from "./server"; import { renderTitle } from "./utils/renderTitle"; const DOCS_LINK = "https://docs.webhookthing.com"; @@ -7,16 +7,16 @@ const DOCS_LINK = "https://docs.webhookthing.com"; const main = async () => { renderTitle(); - // link to docs - console.log( - `\x1b[36m%s\x1b[0m`, - `Questions? Check out the docs: ${DOCS_LINK}` - ); + // eslint-disable-next-line no-console + console.log(`\x1b[36mQuestions? Check out the docs: ${DOCS_LINK}\x1b[0m`); + + startSocketServer(); await startServer(); }; main().catch((err) => { + // eslint-disable-next-line no-console console.error(err); process.exit(1); }); diff --git a/apps/cli/cli/src/server.ts b/apps/cli/cli/src/server.ts index 59acee25..0b3e0fdb 100644 --- a/apps/cli/cli/src/server.ts +++ b/apps/cli/cli/src/server.ts @@ -8,6 +8,11 @@ import { cliApiRouter } from "@captain/cli-core"; import { fastifyStatic } from "@fastify/static"; import path from "path"; +import logger from "@captain/logger"; + +const PORT = 2033; +const WS_PORT = 2034; + const createServer = async () => { const server = fastify({ maxParamLength: 5000, @@ -47,40 +52,64 @@ import open from "open"; const openInBrowser = async () => { try { - await open("http://localhost:2033"); + await open(`http://localhost:${PORT}`); } catch (_err) { - console.log("\x1b[31m[ERROR] Failed to open browser automatically\x1b[0m"); - console.log( - `[INFO] You can still manually open the web UI here: http://localhost:2033` + logger.error("Failed to open browser automatically"); + logger.info( + `You can still manually open the web UI here: http://localhost:${PORT}` ); } }; +import { applyWSSHandler } from "@trpc/server/adapters/ws"; +import ws from "ws"; + +export const startSocketServer = () => { + const wss = new ws.Server({ + port: WS_PORT, + }); + const handler = applyWSSHandler({ wss, router: cliApiRouter }); + + // Debug logging for dev + if (process.env.NODE_ENV === "development") { + logger.debug(`WebSocket Server listening on ws://localhost:${WS_PORT}`); + wss.on("connection", (ws) => { + logger.debug(`Websocket Connection ++ (${wss.clients.size})`); + ws.once("close", () => { + logger.debug(`Websocket Connection -- (${wss.clients.size})`); + }); + }); + } + + process.on("SIGTERM", () => { + handler.broadcastReconnectNotification(); + wss.close(); + }); +}; + export const startServer = async () => { const server = await createServer(); - server.listen({ port: 2033 }, (err) => { + server.listen({ port: PORT }, (err) => { if (err) { - console.error(err); + logger.error(err); process.exit(1); } if (process.env.NODE_ENV === "development") { - console.log( - `\x1b[33m[WARNING] Running in development mode, you can access the web UI at http://localhost:5173\x1b[0m` + logger.warn( + `Running in development mode, you can access the web UI at http://localhost:5173` ); } // eslint-disable-next-line turbo/no-undeclared-env-vars else if (!process.env.CI && !process.env.CODESPACES) { // Dont try to open the browser in CI, or on Codespaces, it will crash - console.log( - `[INFO] Opening webhookthing at address: http://localhost:2033` - ); + logger.info(`Opening webhookthing at address: http://localhost:${PORT}`); void openInBrowser(); } else { - console.log( - `[INFO] Running webhookthing at address: http://localhost:2033` - ); + logger.info(`Running webhookthing at address: http://localhost:${PORT}`); } - return; + process.on("SIGTERM", () => { + void server.close(); + }); }); }; diff --git a/apps/cli/cli/src/utils/renderTitle.ts b/apps/cli/cli/src/utils/renderTitle.ts index cc41b1a2..737c6f36 100644 --- a/apps/cli/cli/src/utils/renderTitle.ts +++ b/apps/cli/cli/src/utils/renderTitle.ts @@ -14,5 +14,6 @@ const poimandresTheme = { export const renderTitle = () => { const captGradient = gradient(Object.values(poimandresTheme)); + // eslint-disable-next-line no-console console.log(captGradient.multiline(TITLE_TEXT)); }; diff --git a/apps/cli/cli/tsup.config.ts b/apps/cli/cli/tsup.config.ts index ddcafb7d..7ee5bfdf 100644 --- a/apps/cli/cli/tsup.config.ts +++ b/apps/cli/cli/tsup.config.ts @@ -18,5 +18,5 @@ export default defineConfig({ shims: true, - noExternal: ["@captain/cli-core"], + noExternal: ["@captain/cli-core", "@captain/logger"], }); diff --git a/apps/docs/.eslintrc.js b/apps/docs/.eslintrc.js new file mode 100644 index 00000000..6db54e02 --- /dev/null +++ b/apps/docs/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ["custom"], + rules: { + "no-console": "off", + }, +}; diff --git a/apps/marketing/.eslintrc.js b/apps/marketing/.eslintrc.js new file mode 100644 index 00000000..6db54e02 --- /dev/null +++ b/apps/marketing/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ["custom"], + rules: { + "no-console": "off", + }, +}; diff --git a/apps/marketing/src/env/client.mjs b/apps/marketing/src/env/client.mjs index ab5f17f9..dc09e6d1 100644 --- a/apps/marketing/src/env/client.mjs +++ b/apps/marketing/src/env/client.mjs @@ -1,4 +1,5 @@ // @ts-check + import { clientEnv, clientSchema } from "./schema.mjs"; const _clientEnv = clientSchema.safeParse(clientEnv); diff --git a/apps/marketing/src/env/server.mjs b/apps/marketing/src/env/server.mjs index d590c942..9e18c9c0 100644 --- a/apps/marketing/src/env/server.mjs +++ b/apps/marketing/src/env/server.mjs @@ -1,4 +1,5 @@ // @ts-check + /** * This file is included in `/next.config.mjs` which ensures the app isn't built with invalid env vars. * It has to be a `.mjs`-file to be imported there. diff --git a/packages/eslint-config-custom/index.js b/packages/eslint-config-custom/index.js index 523f7307..7a5492b5 100644 --- a/packages/eslint-config-custom/index.js +++ b/packages/eslint-config-custom/index.js @@ -25,6 +25,7 @@ module.exports = { }, }, ], + "no-console": "warn", // Allows for _ prefixed variables to be unused // Ripped from https://stackoverflow.com/questions/64052318/how-to-disable-warn-about-some-unused-params-but-keep-typescript-eslint-no-un diff --git a/packages/logger/.eslintrc.js b/packages/logger/.eslintrc.js new file mode 100644 index 00000000..6db54e02 --- /dev/null +++ b/packages/logger/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: ["custom"], + rules: { + "no-console": "off", + }, +}; diff --git a/packages/logger/.gitignore b/packages/logger/.gitignore new file mode 100644 index 00000000..db204cc3 --- /dev/null +++ b/packages/logger/.gitignore @@ -0,0 +1,3 @@ +/dist + +node_modules \ No newline at end of file diff --git a/packages/logger/package.json b/packages/logger/package.json new file mode 100644 index 00000000..fb335ead --- /dev/null +++ b/packages/logger/package.json @@ -0,0 +1,23 @@ +{ + "name": "@captain/logger", + "version": "0.0.1", + "main": "./src/index.ts", + "types": "./src/index.ts", + "license": "MIT", + "private": true, + "scripts": { + "clean": "rm -rf .turbo node_modules", + "typecheck": "tsc", + "lint": "eslint --ext .ts,tsx --ignore-path .gitignore src", + "lint:fix": "eslint --ext .ts,tsx --ignore-path .gitignore --fix src", + "format": "prettier --write --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore", + "format:check": "prettier --check --plugin-search-dir=. src/**/*.{cjs,mjs,ts,tsx,md,json} --ignore-path ../.gitignore" + }, + "dependencies": {}, + "devDependencies": { + "@captain/tsconfig": "workspace:*", + "eslint": "^7.32.0", + "eslint-config-custom": "workspace:*", + "typescript": "^4.9.4" + } +} diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts new file mode 100644 index 00000000..7687f4c7 --- /dev/null +++ b/packages/logger/src/index.ts @@ -0,0 +1,141 @@ +/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + +interface Logger { + trace(message: string, ...optionalParams: any[]): void; + debug(message: string, ...optionalParams: any[]): void; + info(message: string, ...optionalParams: any[]): void; + warn(message: string, ...optionalParams: any[]): void; + error(message: string, ...optionalParams: any[]): void; + [x: string]: any; +} + +const levels = ["trace", "debug", "info", "warn", "error"] as const; +export type LogLevels = (typeof levels)[number]; + +const prefixColors = { + trace: `\x1b[90m`, // gray + debug: `\x1b[36m`, // cyan + info: `\x1b[97m`, // white + warn: `\x1b[33m`, // yellow + error: `\x1b[31m`, // red +}; +const colorReset = `\x1b[0m`; + +class logger implements Logger { + private subscriptions: { + fn: (m: { message: string; level: LogLevels }) => void; + level: LogLevels; + }[]; + + constructor() { + this.subscriptions = []; + } + + trace(message: string | Error | unknown, ...optionalParams: any[]) { + if (message instanceof Error) { + this.log("trace", message.message, [ + optionalParams, + message.stack, + message.cause, + ]); + } else if (typeof message === "string") { + this.log("trace", message, optionalParams); + } else { + this.log("trace", JSON.stringify(message), optionalParams); + } + } + + debug(message: string | Error | unknown, ...optionalParams: any[]) { + if (message instanceof Error) { + this.log("debug", message.message, [ + optionalParams, + message.stack, + message.cause, + ]); + } else if (typeof message === "string") { + this.log("debug", message, optionalParams); + } else { + this.log("debug", JSON.stringify(message), optionalParams); + } + } + + info(message: string | Error | unknown, ...optionalParams: any[]) { + if (message instanceof Error) { + this.log("info", message.message, [ + optionalParams, + message.stack, + message.cause, + ]); + } else if (typeof message === "string") { + this.log("info", message, optionalParams); + } else { + this.log("info", JSON.stringify(message), optionalParams); + } + } + + warn(message: string | Error | unknown, ...optionalParams: any[]) { + if (message instanceof Error) { + this.log("warn", message.message, [ + optionalParams, + message.stack, + message.cause, + ]); + } else if (typeof message === "string") { + this.log("warn", message, optionalParams); + } else { + this.log("warn", JSON.stringify(message), optionalParams); + } + } + + error(message: string | Error | unknown, ...optionalParams: any[]) { + if (message instanceof Error) { + this.log("error", message.message, [ + optionalParams, + message.stack, + message.cause, + ]); + } else if (typeof message === "string") { + this.log("error", message, optionalParams); + } else { + this.log("error", JSON.stringify(message), optionalParams); + } + } + + subscribe( + fn: (m: { message: string; level: LogLevels }) => void, + level?: LogLevels + ) { + if (!level) level = "info"; + this.subscriptions.push({ fn, level }); + } + + unsubscribe(fn: (m: { message: string; level: LogLevels }) => void) { + this.subscriptions = this.subscriptions.filter( + ({ fn: subFn }) => subFn !== fn + ); + } + + private log(level: LogLevels, message: string, optionalParams: any[]) { + console[level](consoleFormat(level, message), ...optionalParams); + + this.subscriptions.forEach(({ fn, level: subLevel }) => { + if (getLogLevels(subLevel).includes(level)) fn({ message, level }); + }); + } +} + +// get all log levels above and including the given level +const getLogLevels = (level: LogLevels): LogLevels[] => { + return levels.slice(levels.indexOf(level)); +}; + +// formatting +const consoleFormat = (level: LogLevels, message: string) => { + return `${ + prefixColors[level] + }[${level.toUpperCase()}] ${message}${colorReset}`; +}; + +const loggerInstance = new logger(); + +export default loggerInstance; diff --git a/packages/logger/tsconfig.json b/packages/logger/tsconfig.json new file mode 100644 index 00000000..51dc7ca5 --- /dev/null +++ b/packages/logger/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@captain/tsconfig/cli.json", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 198f34f4..c0fc2d04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,7 @@ importers: specifiers: '@captain/cli-core': '*' '@captain/cli-web': '*' + '@captain/logger': workspace:* '@captain/tsconfig': workspace:* '@fastify/cors': ^8.2.0 '@fastify/http-proxy': ^8.4.0 @@ -31,6 +32,7 @@ importers: '@types/gradient-string': ^1.1.2 '@types/inquirer': ^9.0.2 '@types/node': ^18.8.0 + '@types/ws': ^8.5.4 '@vercel/ncc': ^0.36.0 fastify: ^4.11.0 fs-extra: ^11.1.0 @@ -42,8 +44,10 @@ importers: tsup: ^6.5.0 type-fest: ^3.0.0 typescript: ^4.9.4 + ws: ^8.12.1 dependencies: '@captain/cli-core': link:../cli-core + '@captain/logger': link:../../../packages/logger '@fastify/cors': 8.2.0 '@fastify/http-proxy': 8.4.0 '@fastify/static': 6.7.0 @@ -53,6 +57,7 @@ importers: graceful-fs: 4.2.10 gradient-string: 2.0.2 open: 8.4.0 + ws: 8.12.1 devDependencies: '@captain/cli-web': link:../cli-web '@captain/tsconfig': link:../../../packages/tsconfig @@ -60,6 +65,7 @@ importers: '@types/gradient-string': 1.1.2 '@types/inquirer': 9.0.3 '@types/node': 18.11.18 + '@types/ws': 8.5.4 '@vercel/ncc': 0.36.1 prettier: 2.8.3 prettier-plugin-tailwindcss: 0.1.13_prettier@2.8.3 @@ -70,6 +76,7 @@ importers: apps/cli/cli-core: specifiers: + '@captain/logger': workspace:* '@captain/tsconfig': workspace:* '@trpc/client': 10.9.0 '@trpc/server': 10.9.0 @@ -80,6 +87,7 @@ importers: typescript: ^4.9.4 zod: ^3.19.1 dependencies: + '@captain/logger': link:../../../packages/logger '@trpc/client': 10.9.0_@trpc+server@10.9.0 '@trpc/server': 10.9.0 node-fetch: 3.3.0 @@ -94,6 +102,7 @@ importers: apps/cli/cli-web: specifiers: '@captain/cli-core': '*' + '@captain/logger': workspace:* '@captain/tailwind-config': '*' '@captain/tsconfig': workspace:* '@headlessui/react': ^1.7.8 @@ -124,6 +133,7 @@ importers: vite: ^4.0.0 zod: ^3.19.1 dependencies: + '@captain/logger': link:../../../packages/logger '@headlessui/react': 1.7.8_biqbaboplfbrettd7655fr4n2y '@heroicons/react': 2.0.14_react@18.2.0 '@hookform/resolvers': 2.9.10_react-hook-form@7.43.0 @@ -256,6 +266,18 @@ importers: eslint-plugin-react: 7.28.0 next: 13.1.5 + packages/logger: + specifiers: + '@captain/tsconfig': workspace:* + eslint: ^7.32.0 + eslint-config-custom: workspace:* + typescript: ^4.9.4 + devDependencies: + '@captain/tsconfig': link:../tsconfig + eslint: 7.32.0 + eslint-config-custom: link:../eslint-config-custom + typescript: 4.9.4 + packages/tailwind-config: specifiers: tailwindcss: ^3.2.4 @@ -961,7 +983,7 @@ packages: resolution: {integrity: sha512-H8nwsmawFtKKRE6uhh1BtF1gQi/l147SmLsDGxB0HdYTHzjXz6uSQO3lEVmY7unKMzbArRjdoJQkEGpScszdSw==} dependencies: '@fastify/reply-from': 8.3.1 - ws: 8.12.0 + ws: 8.12.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -1815,6 +1837,12 @@ packages: resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} dev: false + /@types/ws/8.5.4: + resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + dependencies: + '@types/node': 18.11.18 + dev: true + /@typescript-eslint/eslint-plugin/5.52.0_t6eclxjmd5f54roe4wwsj3rtvy: resolution: {integrity: sha512-lHazYdvYVsBokwCdKOppvYJKaJ4S41CgKBcPvyd0xjZNbvQdhn/pnJlGtQksQ/NhInzdaeaSarlBjDXHuclEbg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -7679,8 +7707,8 @@ packages: /wrappy/1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - /ws/8.12.0: - resolution: {integrity: sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==} + /ws/8.12.1: + resolution: {integrity: sha512-1qo+M9Ba+xNhPB+YTWUlK6M17brTut5EXbcBaMRN5pH5dFrXz7lzz1ChFSUq3bOUl8yEvSenhHmYUNJxFzdJew==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1