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