Compare commits

...

5 Commits

Author SHA1 Message Date
Omri Maya 14810a5090 feat(providers): agent-surfaces capability seam
Host-side registry where a provider can declare, by capability rather than
by name, that it owns its agent surfaces (project doc, skills). Default
providers keep the standard surfaces; a surfaces-owning provider suppresses
them. Dormant until a provider registers — no change for existing installs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:10 +03:00
github-actions[bot] 36cbf17e10 chore: bump version to 2.1.11 2026-06-11 17:16:51 +00:00
gavrielc 4459ab2e54 Merge pull request #2739 from nanocoai/feat/raw-webhook-registry
feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
2026-06-11 20:16:33 +03:00
gavrielc 9e6238d28f Merge main (channel instances): keep both webhook suites as separate files
The instance route-split suite (from #2733) keeps src/webhook-server.test.ts;
this branch's raw-route suite moves to src/webhook-server-raw.test.ts —
incompatible lifecycle setups (fixed port + afterEach vs random port +
afterAll) make a single merged file wrong. webhook-server.ts auto-merge
verified: raw routes take dispatch priority, stop clears both maps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:07:30 +03:00
gavrielc f69af07c57 feat(webhook-server): raw-route registry — non-Chat-SDK webhooks become an append
Add a RawWebhookHandler registry alongside the Chat SDK adapter routes
so modules can mount plain Node handlers at /webhook/{path} on the
shared server instead of editing webhook-server.ts or standing up a
second HTTP server on another port. Raw routes dispatch ahead of
adapter routes, handler throws surface as a 500, and stopWebhookServer
clears the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:58:51 +03:00
7 changed files with 419 additions and 68 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "2.1.10",
"version": "2.1.11",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"packageManager": "pnpm@10.33.0",
+49 -34
View File
@@ -36,6 +36,7 @@ import { validateAdditionalMounts } from './modules/mount-security/index.js';
import './providers/index.js';
import {
getProviderContainerConfig,
providerProvidesAgentSurfaces,
type ProviderContainerContribution,
type VolumeMount,
} from './providers/provider-container-registry.js';
@@ -127,12 +128,19 @@ async function spawnContainer(session: Session): Promise<void> {
// and buildContainerArgs so we don't re-read.
const containerConfig = materializeContainerJson(agentGroup.id);
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before. Runs before the provider
// contribution so a surfaces-providing provider finds the group dir ready.
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
initGroupFilesystem(agentGroup, { provider: providerName });
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
const mounts = buildMounts(agentGroup, session, containerConfig, provider, contribution);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
// OneCLI agent identifier is always the agent group id — stable across
// sessions and reversible via getAgentGroup() for approval routing.
@@ -234,32 +242,37 @@ function resolveProviderContribution(
? fn({
sessionDir: sessionDir(agentGroup.id, session.id),
agentGroupId: agentGroup.id,
groupDir: path.resolve(GROUPS_DIR, agentGroup.folder),
selectedSkills: selectedSkillNames(containerConfig),
hostEnv: process.env,
})
: {};
return { provider, contribution };
}
function buildMounts(
export function buildMounts(
agentGroup: AgentGroup,
session: Session,
containerConfig: import('./container-config.js').ContainerConfig,
provider: string,
providerContribution: ProviderContainerContribution,
): VolumeMount[] {
const projectRoot = process.cwd();
// Per-group filesystem state lives forever after first creation. Init is
// idempotent: it only writes paths that don't already exist, so this call
// is a no-op for groups that have spawned before.
initGroupFilesystem(agentGroup);
// Default agent surfaces (composed project doc, skill links, provider state
// dir) apply unless the provider's registration declares it provides its
// own — a capability, never a provider name. See provider-container-registry.
const defaultSurfaces = !providerProvidesAgentSurfaces(provider);
// Sync skill symlinks based on container.json selection before mounting.
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
syncSkillSymlinks(claudeDir, containerConfig);
if (defaultSurfaces) {
// Sync skill symlinks based on container.json selection before mounting.
syncSkillSymlinks(claudeDir, containerConfig);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
}
const mounts: VolumeMount[] = [];
const sessDir = sessionDir(agentGroup.id, session.id);
@@ -286,11 +299,11 @@ function buildMounts(
// already RO-mounted, so writes through it fail regardless — no need for
// a nested mount there.
const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
if (fs.existsSync(composedClaudeMd)) {
if (defaultSurfaces && fs.existsSync(composedClaudeMd)) {
mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
}
const fragmentsDir = path.join(groupDir, '.claude-fragments');
if (fs.existsSync(fragmentsDir)) {
if (defaultSurfaces && fs.existsSync(fragmentsDir)) {
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
@@ -303,13 +316,15 @@ function buildMounts(
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
if (fs.existsSync(sharedClaudeMd)) {
if (defaultSurfaces && fs.existsSync(sharedClaudeMd)) {
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
}
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
// skill symlinks)
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
if (defaultSurfaces) {
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
}
// Shared agent-runner source — read-only, same code for all groups.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
@@ -346,25 +361,7 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
fs.mkdirSync(skillsDir, { recursive: true });
}
// Determine desired skill set
const projectRoot = process.cwd();
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
let desired: string[];
if (containerConfig.skills === 'all') {
// Recompute from shared dir — newly-added upstream skills appear automatically
desired = fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
} else {
desired = containerConfig.skills;
}
const desired = selectedSkillNames(containerConfig);
const desiredSet = new Set(desired);
// Remove symlinks not in the desired set
@@ -397,6 +394,24 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
}
}
/**
* Resolve the group's skill selection to concrete names — `'all'` recomputes
* from `container/skills/` so newly-added upstream skills appear automatically.
*/
function selectedSkillNames(containerConfig: import('./container-config.js').ContainerConfig): string[] {
if (containerConfig.skills !== 'all') return containerConfig.skills;
const sharedSkillsDir = path.join(process.cwd(), 'container', 'skills');
return fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
+32 -20
View File
@@ -4,6 +4,7 @@ import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
import type { AgentGroup } from './types.js';
const DEFAULT_SETTINGS_JSON =
@@ -46,9 +47,18 @@ const DEFAULT_SETTINGS_JSON =
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
* per-group instructions (if provided) seed `CLAUDE.local.md`.
*/
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
export function initGroupFilesystem(
group: AgentGroup,
opts?: { instructions?: string; provider?: string | null },
): void {
const initialized: string[] = [];
// Default agent surfaces apply unless the group's provider declares (at
// registration) that it provides its own. Callers that don't know the
// provider omit it — unregistered/unknown names report no capabilities,
// so the default surfaces are written, exactly as before this seam.
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
// 1. groups/<folder>/ — group memory + working dir
const groupDir = path.resolve(GROUPS_DIR, group.folder);
if (!fs.existsSync(groupDir)) {
@@ -59,7 +69,7 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
// Claude Code. Seeded with caller-provided instructions on first creation.
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
if (!fs.existsSync(claudeLocalFile)) {
if (defaultSurfaces && !fs.existsSync(claudeLocalFile)) {
const body = opts?.instructions ? opts.instructions + '\n' : '';
fs.writeFileSync(claudeLocalFile, body);
initialized.push('CLAUDE.local.md');
@@ -71,26 +81,28 @@ export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: s
initialized.push('container_configs');
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
if (defaultSurfaces) {
const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared');
if (!fs.existsSync(claudeDir)) {
fs.mkdirSync(claudeDir, { recursive: true });
initialized.push('.claude-shared');
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
}
}
if (initialized.length > 0) {
+143
View File
@@ -0,0 +1,143 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-provider-surfaces-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
const DATA_DIR = path.join(TEST_ROOT, 'data');
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-provider-surfaces-test/data',
GROUPS_DIR: '/tmp/nanoclaw-provider-surfaces-test/groups',
}));
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
import { buildMounts } from './container-runner.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { initGroupFilesystem } from './group-init.js';
import { registerProviderContainerConfig } from './providers/provider-container-registry.js';
import type { ContainerConfig } from './container-config.js';
import type { AgentGroup, Session } from './types.js';
// A provider that declares (at registration) that it owns its agent surfaces.
// Registered once — the registry is module-global and rejects duplicates.
registerProviderContainerConfig('surfaces-test-provider', () => ({}), { providesAgentSurfaces: true });
function group(id: string, folder: string): AgentGroup {
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
}
function session(id: string, agentGroupId: string): Session {
return { id, agent_group_id: agentGroupId } as Session;
}
function containerConfig(): ContainerConfig {
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('initGroupFilesystem agent surfaces', () => {
it('writes the default surfaces when no provider is given (todays behavior)', () => {
const ag = group('ag-default', 'default-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const claudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
expect(fs.readFileSync(path.join(groupDir, 'CLAUDE.local.md'), 'utf-8')).toBe('hello\n');
expect(fs.existsSync(path.join(claudeDir, 'settings.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'skills'))).toBe(true);
});
it('skips the default surfaces for a provider that provides its own', () => {
const ag = group('ag-surfy', 'surfy-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello', provider: 'surfaces-test-provider' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const sessionRoot = path.join(DATA_DIR, 'v2-sessions', ag.id);
expect(fs.existsSync(groupDir)).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'CLAUDE.local.md'))).toBe(false);
expect(fs.existsSync(path.join(sessionRoot, '.claude-shared'))).toBe(false);
});
it('treats an unregistered provider name as default surfaces', () => {
const ag = group('ag-unknown', 'unknown-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { provider: 'not-registered' });
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.local.md'))).toBe(true);
});
});
describe('buildMounts agent surfaces', () => {
it('mounts the default surfaces for an unregistered provider (todays behavior)', () => {
const ag = group('ag-mounts-default', 'mounts-default');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, {});
const mounts = buildMounts(ag, session('s1', ag.id), containerConfig(), 'claude', {});
const byContainerPath = new Map(mounts.map((m) => [m.containerPath, m]));
expect(byContainerPath.has('/home/node/.claude')).toBe(true);
expect(byContainerPath.has('/app/CLAUDE.md')).toBe(true);
expect(byContainerPath.has('/workspace/agent/CLAUDE.md')).toBe(true);
// Composer ran: the generated project doc exists on disk.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(true);
});
it('suppresses the default surfaces and keeps contributed mounts for a surfaces-providing provider', () => {
const ag = group('ag-mounts-surfy', 'mounts-surfy');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, { provider: 'surfaces-test-provider' });
const contributed = {
mounts: [
{
hostPath: path.join(GROUPS_DIR, ag.folder),
containerPath: '/workspace/agent/OWN-DOC.md',
readonly: true,
},
],
};
const mounts = buildMounts(ag, session('s2', ag.id), containerConfig(), 'surfaces-test-provider', contributed);
const containerPaths = mounts.map((m) => m.containerPath);
expect(containerPaths).not.toContain('/home/node/.claude');
expect(containerPaths).not.toContain('/app/CLAUDE.md');
expect(containerPaths).not.toContain('/workspace/agent/CLAUDE.md');
// Composer did NOT run for this group.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(false);
// Core mounts and the provider's own contribution are intact.
expect(containerPaths).toContain('/workspace');
expect(containerPaths).toContain('/workspace/agent');
expect(containerPaths).toContain('/app/src');
expect(containerPaths).toContain('/workspace/agent/OWN-DOC.md');
});
});
+54 -4
View File
@@ -27,6 +27,19 @@ export interface ProviderContainerContext {
sessionDir: string;
/** Agent group ID, for any per-group logic. */
agentGroupId: string;
/**
* Per-group host directory: `<GROUPS_DIR>/<folder>` (mounted RW at
* `/workspace/agent`). Exists by the time the config fn runs — group
* filesystem init happens first. Surfaces-providing providers compose
* their project doc and skill links here.
*/
groupDir: string;
/**
* Skill names selected by the group's container config, with `'all'`
* already resolved against `container/skills/`. Surfaces-providing
* providers use this to sync their own skill-discovery links.
*/
selectedSkills: string[];
/** `process.env` at spawn time — pull passthrough values from here. */
hostEnv: NodeJS.ProcessEnv;
}
@@ -38,19 +51,56 @@ export interface ProviderContainerContribution {
env?: Record<string, string>;
}
/**
* Static capabilities a provider declares at registration time — knowable
* without a spawn context, so any host path (group init, spawn, creation
* flows) can consult them by name.
*/
export interface ProviderHostCapabilities {
/**
* Optional. When true, this provider owns its agent-facing surfaces — the
* composed project doc, skill-discovery links, and provider state dir —
* and the host must NOT compose or mount the default ones (composed
* CLAUDE.md, `.claude-fragments`, `/app/CLAUDE.md`, `/home/node/.claude`,
* `CLAUDE.local.md` seeding). The provider's config fn does its own
* composing and returns its own mounts. Default off — providers that omit
* this get the default surfaces, which is today's behavior.
*/
readonly providesAgentSurfaces?: boolean;
}
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
const registry = new Map<string, ProviderContainerConfigFn>();
interface RegistryEntry {
fn: ProviderContainerConfigFn;
capabilities: ProviderHostCapabilities;
}
export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void {
const registry = new Map<string, RegistryEntry>();
export function registerProviderContainerConfig(
name: string,
fn: ProviderContainerConfigFn,
capabilities: ProviderHostCapabilities = {},
): void {
if (registry.has(name)) {
throw new Error(`Provider container config already registered: ${name}`);
}
registry.set(name, fn);
registry.set(name, { fn, capabilities });
}
export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined {
return registry.get(name);
return registry.get(name)?.fn;
}
/**
* Capability lookup by provider name. Unregistered providers (including the
* baked-in default) report no capabilities — the host applies its default
* surfaces, exactly as before this seam existed.
*/
export function providerProvidesAgentSurfaces(name: string | null | undefined): boolean {
if (!name) return false;
return registry.get(name)?.capabilities.providesAgentSurfaces === true;
}
export function listProviderContainerConfigNames(): string[] {
+97
View File
@@ -0,0 +1,97 @@
/**
* Guard for the raw-route half of src/webhook-server.ts —
* registerWebhookHandler + the rawRoutes dispatch branch.
*
* Drives the REAL shared HTTP server on an ephemeral WEBHOOK_PORT (no
* mocking of the routing layer): a registered raw route must dispatch,
* unknown paths must 404, a throwing handler must surface as 500,
* raw routes must coexist with Chat SDK adapter routes on the same
* server, and stopWebhookServer must clear them.
*/
import { afterAll, describe, expect, it, vi } from 'vitest';
import type { Chat } from 'chat';
import { registerWebhookAdapter, registerWebhookHandler, stopWebhookServer } from './webhook-server.js';
const PORT = 21000 + Math.floor(Math.random() * 20000);
async function post(path: string, body = '{}'): Promise<globalThis.Response> {
for (let attempt = 0; ; attempt++) {
try {
return await fetch(`http://127.0.0.1:${PORT}/webhook/${path}`, { method: 'POST', body });
} catch (err) {
if (attempt >= 40) throw err;
await new Promise((r) => setTimeout(r, 50));
}
}
}
afterAll(async () => {
await stopWebhookServer();
delete process.env.WEBHOOK_PORT;
});
describe('webhook server raw routes', () => {
it('dispatches a registered raw route to its handler', async () => {
process.env.WEBHOOK_PORT = String(PORT);
const methods: string[] = [];
registerWebhookHandler('ping', (req, res) => {
methods.push(req.method || '');
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('pong');
});
const res = await post('ping');
expect(res.status).toBe(200);
expect(await res.text()).toBe('pong');
expect(methods).toEqual(['POST']);
});
it('returns 404 for paths with no registered route', async () => {
const res = await post('nope');
expect(res.status).toBe(404);
});
it('turns a throwing handler into a 500 response', async () => {
registerWebhookHandler('boom', () => {
throw new Error('handler exploded');
});
const res = await post('boom');
expect(res.status).toBe(500);
expect(await res.text()).toBe('Internal Server Error');
});
it('coexists with Chat SDK adapter routes on the same server', async () => {
const handler = vi.fn(async () => new Response('ok-chat', { status: 200 }));
const chat = { webhooks: { fake: handler } } as unknown as Chat;
registerWebhookAdapter(chat, 'fake');
const chatRes = await post('fake');
expect(chatRes.status).toBe(200);
expect(await chatRes.text()).toBe('ok-chat');
expect(handler).toHaveBeenCalledTimes(1);
// The raw route registered earlier is still live alongside it.
const rawRes = await post('ping');
expect(rawRes.status).toBe(200);
});
it('clears raw routes on stopWebhookServer', async () => {
await stopWebhookServer();
// Restart the server with a fresh route; the old raw routes must be gone.
registerWebhookHandler('fresh', (_req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('fresh');
});
const stale = await post('ping');
expect(stale.status).toBe(404);
const fresh = await post('fresh');
expect(fresh.status).toBe(200);
expect(await fresh.text()).toBe('fresh');
});
});
+43 -9
View File
@@ -3,9 +3,12 @@
*
* Starts lazily on first adapter registration. Routes requests by path:
* /webhook/{adapterName} → chat.webhooks[adapterName](request)
* /webhook/{path} → raw handler from registerWebhookHandler(path, ...)
*
* Multiple Chat instances can register adapters — each adapter name maps
* to its owning Chat instance.
* to its owning Chat instance. Raw routes let modules receive non-Chat-SDK
* webhooks (GitHub, payment providers, health checks) on the same server
* without editing this file or opening a second port.
*/
import http from 'http';
@@ -20,7 +23,11 @@ interface WebhookEntry {
adapterName: string;
}
/** Node-style handler for raw (non-Chat-SDK) webhook routes. */
export type RawWebhookHandler = (req: http.IncomingMessage, res: http.ServerResponse) => void | Promise<void>;
const routes = new Map<string, WebhookEntry>();
const rawRoutes = new Map<string, RawWebhookHandler>();
let server: http.Server | null = null;
/** Convert Node.js IncomingMessage to a Web API Request. */
@@ -84,6 +91,22 @@ export function registerWebhookAdapter(chat: Chat, adapterName: string, routingP
log.info('Webhook adapter registered', { adapter: adapterName, path: `/webhook/${routingPath}` });
}
/**
* Register a raw Node-style handler at /webhook/{path} on the shared server.
*
* For webhooks that don't flow through a Chat SDK adapter (GitHub, payment
* providers, health checks): modules register their endpoint here instead of
* editing this file or standing up a second HTTP server on another port.
* The handler owns the request/response directly.
*
* Starts the server lazily on first call.
*/
export function registerWebhookHandler(path: string, handler: RawWebhookHandler): void {
rawRoutes.set(path, handler);
ensureServer();
log.info('Webhook handler registered', { path: `/webhook/${path}` });
}
function ensureServer(): void {
if (server) return;
@@ -101,14 +124,22 @@ function ensureServer(): void {
}
const adapterName = match[1];
const entry = routes.get(adapterName);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Unknown adapter: ${adapterName}`);
return;
}
try {
// Raw routes take priority — the handler writes the response itself.
const rawHandler = rawRoutes.get(adapterName);
if (rawHandler) {
await rawHandler(req, res);
return;
}
const entry = routes.get(adapterName);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end(`Unknown adapter: ${adapterName}`);
return;
}
const webReq = await toWebRequest(req);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const webhooks = entry.chat.webhooks as Record<string, (r: Request, opts?: any) => Promise<Response>>;
@@ -121,8 +152,10 @@ function ensureServer(): void {
await fromWebResponse(webRes, res);
} catch (err) {
log.error('Webhook handler error', { adapter: adapterName, url: req.url, err });
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
if (!res.headersSent) {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
}
});
@@ -137,6 +170,7 @@ export async function stopWebhookServer(): Promise<void> {
await new Promise<void>((resolve) => server!.close(() => resolve()));
server = null;
routes.clear();
rawRoutes.clear();
log.info('Webhook server stopped');
}
}