@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
JavaScript
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