UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

219 lines (218 loc) 6.79 kB
#!/usr/bin/env node import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import express from "express"; import crypto from "crypto"; import { LinearSyncService } from "./sync-service.js"; import { IntegrationError, ErrorCode } from "../../core/errors/index.js"; import { logger } from "../../core/monitoring/logger.js"; import chalk from "chalk"; function getEnv(key, defaultValue) { const value = process.env[key]; if (value === void 0) { if (defaultValue !== void 0) return defaultValue; throw new IntegrationError( `Environment variable ${key} is required`, ErrorCode.LINEAR_WEBHOOK_FAILED ); } return value; } function getOptionalEnv(key) { return process.env[key]; } class LinearWebhookServer { app; server = null; // Using singleton logger from monitoring syncService; config; eventQueue = []; isProcessing = false; constructor(config) { this.app = express(); this.syncService = new LinearSyncService(); this.config = { port: config?.port || parseInt(process.env["WEBHOOK_PORT"] || "3456"), host: config?.host || process.env["WEBHOOK_HOST"] || "localhost", webhookSecret: config?.webhookSecret || process.env["LINEAR_WEBHOOK_SECRET"], maxPayloadSize: config?.maxPayloadSize || "10mb", rateLimit: { windowMs: config?.rateLimit?.windowMs || 6e4, max: config?.rateLimit?.max || 100 } }; this.setupMiddleware(); this.setupRoutes(); } setupMiddleware() { this.app.use( express.raw({ type: "application/json", limit: this.config.maxPayloadSize }) ); this.app.use((req, res, next) => { res.setHeader("X-Powered-By", "StackMemory"); next(); }); } setupRoutes() { this.app.get("/health", (req, res) => { res.json({ status: "healthy", service: "linear-webhook", timestamp: (/* @__PURE__ */ new Date()).toISOString(), queue: this.eventQueue.length, processing: this.isProcessing }); }); this.app.post("/webhook/linear", async (req, res) => { try { if (!this.verifyWebhookSignature(req)) { logger.warn("Invalid webhook signature"); return res.status(401).json({ error: "Unauthorized" }); } const payload = JSON.parse(req.body.toString()); logger.info(`Received webhook: ${payload.type} - ${payload.action}`); this.eventQueue.push(payload); this.processQueue(); return res.status(200).json({ status: "accepted", queued: true }); } catch (error) { logger.error("Webhook processing error:", error); return res.status(500).json({ error: "Internal server error" }); } }); this.app.use((req, res) => { res.status(404).json({ error: "Not found" }); }); } verifyWebhookSignature(req) { if (!this.config.webhookSecret) { logger.warn("No webhook secret configured, accepting all webhooks"); return true; } const signature = req.headers["linear-signature"]; if (!signature) { return false; } const hash = crypto.createHmac("sha256", this.config.webhookSecret).update(req.body).digest("hex"); return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(hash)); } async processQueue() { if (this.isProcessing || this.eventQueue.length === 0) { return; } this.isProcessing = true; while (this.eventQueue.length > 0) { const event = this.eventQueue.shift(); try { await this.handleWebhookEvent(event); } catch (error) { logger.error(`Failed to process event: ${event.type}`, error); } } this.isProcessing = false; } async handleWebhookEvent(payload) { const { type, action, data } = payload; switch (type) { case "Issue": await this.handleIssueEvent(action, data); break; case "Comment": await this.handleCommentEvent(action, data); break; case "Project": await this.handleProjectEvent(action, data); break; default: logger.debug(`Unhandled event type: ${type}`); } } async handleIssueEvent(action, data) { const issue = data; switch (action) { case "create": logger.info(`New issue created: ${issue.identifier} - ${issue.title}`); await this.syncService.syncIssueToLocal(issue); break; case "update": logger.info(`Issue updated: ${issue.identifier} - ${issue.title}`); await this.syncService.syncIssueToLocal(issue); break; case "remove": logger.info(`Issue removed: ${issue.identifier}`); await this.syncService.removeLocalIssue(issue.identifier); break; default: logger.debug(`Unhandled issue action: ${action}`); } } async handleCommentEvent(action, data) { logger.debug(`Comment event: ${action}`, { issueId: data.issue?.id }); } async handleProjectEvent(action, data) { logger.debug(`Project event: ${action}`, { projectId: data.id }); } async start() { return new Promise((resolve) => { this.server = this.app.listen( this.config.port, this.config.host, () => { console.log( chalk.green("\u2713") + chalk.bold(" Linear Webhook Server Started") ); console.log( chalk.cyan(" URL: ") + `http://${this.config.host}:${this.config.port}/webhook/linear` ); console.log( chalk.cyan(" Health: ") + `http://${this.config.host}:${this.config.port}/health` ); if (!this.config.webhookSecret) { console.log( chalk.yellow( " \u26A0 Warning: No webhook secret configured (insecure)" ) ); } resolve(); } ); }); } async stop() { return new Promise((resolve) => { if (this.server) { this.server.close(() => { logger.info("Webhook server stopped"); resolve(); }); } else { resolve(); } }); } } if (process.argv[1] === new URL(import.meta.url).pathname) { const server = new LinearWebhookServer(); server.start().catch((error) => { console.error(chalk.red("Failed to start webhook server:"), error); process.exit(1); }); process.on("SIGINT", async () => { console.log(chalk.yellow("\n\nShutting down webhook server...")); await server.stop(); process.exit(0); }); } export { LinearWebhookServer }; //# sourceMappingURL=webhook-server.js.map