Skip to content

Commit b0fd26c

Browse files
committed
Refactors server layout, sets up local dev proxy
Moves webhook logic to a dedicated module for clearer organization Adds a development proxy to simplify frontend-backend integration
1 parent 777c9a6 commit b0fd26c

File tree

4 files changed

+244
-210
lines changed

4 files changed

+244
-210
lines changed

.gitignore

401 Bytes
Binary file not shown.

server/src/index.ts

Lines changed: 23 additions & 210 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from "path";
22
import fs from "fs";
33
import express, { Request, Response, NextFunction } from "express";
44
import cors from "cors";
5+
import { registerWebhookRoutes } from './webhook';
56

67
const app = express();
78
// Prefer explicit PORT, then WEBSITES_PORT (used by Azure App Service), then default to 4000
@@ -35,217 +36,9 @@ app.post("/api/logout", (_req: Request, res: Response) => {
3536
res.json({ ok: true });
3637
});
3738

38-
// Event Grid webhook for per-user webhook paths.
39-
// Example: POST /api/webhook/<user-unique-path>
40-
// If WEBHOOK_ALLOWED_KEYS is set (comma-separated), the :userPath must be present there.
41-
app.post("/api/webhook/:userPath", (req: Request, res: Response) => {
42-
const userPath = req.params.userPath;
39+
// Register webhook-related routes (event store, SSE, diagnostics)
40+
registerWebhookRoutes(app);
4341

44-
// Optional allow-list: comma separated keys in env var WEBHOOK_ALLOWED_KEYS
45-
const allowed = process.env.WEBHOOK_ALLOWED_KEYS;
46-
if (allowed) {
47-
const allowedSet = new Set(allowed.split(",").map(s => s.trim()).filter(Boolean));
48-
if (!allowedSet.has(userPath)) {
49-
console.warn(`Rejected webhook for unknown key: ${userPath}`);
50-
return res.status(404).json({ error: "unknown webhook path" });
51-
}
52-
}
53-
54-
// Event Grid usually sends an array, but some proxies or transports may send a single
55-
// object. Normalize to an array for easier handling.
56-
const events = Array.isArray(req.body) ? req.body : req.body ? [req.body] : [];
57-
58-
// Save last request for diagnostics
59-
try {
60-
lastWebhookDiagnostics.set(userPath, { headers: req.headers, body: events });
61-
} catch (err) {
62-
// ignore
63-
}
64-
65-
// Diagnostic: log aeg-event-type header when present (helps Azure debugging)
66-
try {
67-
const aeg = String(req.header('aeg-event-type') || '');
68-
if (aeg) console.log(`aeg-event-type: ${aeg}`);
69-
} catch (err) {
70-
// ignore
71-
}
72-
73-
// Helper: extract validation code from an event object in a forgiving way.
74-
const extractValidationCode = (ev: any): string | undefined => {
75-
if (!ev) return undefined;
76-
// Common places: ev.data.validationCode (case variants)
77-
const data = ev.data || ev.Data || ev.DATA || ev;
78-
if (data && typeof data === 'object') {
79-
for (const key of Object.keys(data)) {
80-
if (key.toLowerCase() === 'validationcode' && data[key]) return String(data[key]);
81-
}
82-
}
83-
// Some transports may put the code at the top-level
84-
for (const key of Object.keys(ev)) {
85-
if (key.toLowerCase() === 'validationcode' && ev[key]) return String(ev[key]);
86-
}
87-
return undefined;
88-
};
89-
90-
// Event Grid subscription validation can arrive as:
91-
// - an event in the array with eventType matching expected strings
92-
// - header-based flows where 'aeg-event-type' tells us it's a validation attempt
93-
// Try to find any validation code in the incoming payloads.
94-
const validationEvent = events.find((e: any) => {
95-
if (!e) return false;
96-
const et = (e.eventType || e.EventType || '').toString();
97-
return (
98-
et === 'Microsoft.EventGrid.SubscriptionValidationEvent' ||
99-
et === 'Microsoft.EventGridSubscriptionValidationEvent' ||
100-
et === 'Microsoft.EventGrid.SubscriptionValidation' ||
101-
et.toLowerCase().includes('subscriptionvalidation')
102-
);
103-
});
104-
105-
if (validationEvent) {
106-
const code = extractValidationCode(validationEvent) || (validationEvent.data && validationEvent.data.validationCode) || undefined;
107-
console.log(`EventGrid subscription validation (event) for ${userPath}:`, validationEvent?.data || validationEvent, '=> code=', code);
108-
if (code) return res.status(200).json({ validationResponse: code });
109-
}
110-
111-
// Also handle the header-based SubscriptionValidation flow: check aeg header and try first event
112-
const aegHeader = (req.header('aeg-event-type') || '').toString();
113-
if (aegHeader && aegHeader.toLowerCase().includes('subscriptionvalidation') && events.length > 0) {
114-
const maybe = events[0] as any;
115-
const code = extractValidationCode(maybe);
116-
console.log(`EventGrid header-based validation for ${userPath}: aeg=${aegHeader} =>`, maybe, '=> code=', code);
117-
if (code) return res.status(200).json({ validationResponse: code });
118-
}
119-
120-
// Normal events: log and ack
121-
try {
122-
console.log(`Received ${events.length} event(s) for webhook ${userPath}`);
123-
// For debugging include a small sample of events
124-
console.log(JSON.stringify(events.map((e: any) => ({ id: e.id, eventType: e.eventType })), null, 2));
125-
} catch (err) {
126-
console.warn('Failed to log events', (err as Error).message);
127-
}
128-
129-
// Append events to in-memory store for this path
130-
try {
131-
const now = new Date().toISOString();
132-
const records: EventRecord[] = events.map((e: any) => ({
133-
id: e.id,
134-
eventType: e.eventType,
135-
timestamp: e.eventTime || now,
136-
data: e.data,
137-
raw: e,
138-
}));
139-
140-
const existing = eventStore.get(userPath) || [];
141-
// Newest at start
142-
const combined = [...records.reverse(), ...existing];
143-
// Trim to cap
144-
const trimmed = combined.slice(0, EVENT_STORE_CAP);
145-
eventStore.set(userPath, trimmed);
146-
} catch (err) {
147-
console.warn('Failed to store events in memory', (err as Error).message);
148-
}
149-
150-
// Notify any SSE clients connected to this webhook path
151-
try {
152-
for (const e of events) {
153-
const payload = {
154-
id: e.id,
155-
eventType: e.eventType,
156-
timestamp: e.eventTime || new Date().toISOString(),
157-
data: e.data,
158-
raw: e,
159-
};
160-
sendSseToPath(userPath, payload);
161-
}
162-
} catch (err) {
163-
console.warn('Failed to broadcast SSE', (err as Error).message);
164-
}
165-
166-
// TODO: integrate with persistence / user properties: look up which user has this key
167-
// and forward/enqueue events appropriately.
168-
169-
return res.status(200).json({ received: events.length });
170-
});
171-
172-
// In-memory event store per webhook path (newest items at start). This is ephemeral and
173-
// will be lost if the server restarts. We keep a modest cap per path.
174-
type EventRecord = {
175-
id?: string;
176-
eventType: string;
177-
timestamp: string; // ISO
178-
data: any;
179-
raw: any;
180-
};
181-
182-
const EVENT_STORE_CAP = 200;
183-
const eventStore = new Map<string, EventRecord[]>();
184-
185-
// For debugging: store last request headers and body per path
186-
const lastWebhookDiagnostics = new Map<string, { headers: any; body: any }>();
187-
188-
// SSE clients per webhook path
189-
const sseClients = new Map<string, Set<Response>>();
190-
191-
function sendSseToPath(userPath: string, payload: any) {
192-
const clients = sseClients.get(userPath);
193-
if (!clients) return;
194-
const data = typeof payload === 'string' ? payload : JSON.stringify(payload);
195-
for (const res of clients) {
196-
try {
197-
res.write(`data: ${data}\n\n`);
198-
} catch (err) {
199-
console.warn('Failed to write SSE to client', (err as Error).message);
200-
}
201-
}
202-
}
203-
204-
// Return events for a specific webhook path (newest first). No auth is enforced here;
205-
// possession of the path acts as the access key. If you want stronger auth, wire this
206-
// up to your user system and verify ownership.
207-
app.get('/api/webhook/:userPath/events', (_req: Request, res: Response) => {
208-
const userPath = _req.params.userPath;
209-
const list = eventStore.get(userPath) || [];
210-
return res.json({ count: list.length, events: list });
211-
});
212-
213-
// Diagnostic endpoint to inspect last webhook request for a path
214-
app.get('/__diag/webhook/:userPath', (_req: Request, res: Response) => {
215-
const userPath = _req.params.userPath;
216-
const diag = lastWebhookDiagnostics.get(userPath) || null;
217-
const stored = eventStore.get(userPath) || [];
218-
return res.json({ lastRequest: diag, storedCount: stored.length });
219-
});
220-
221-
// Server-Sent Events stream for a webhook path.
222-
app.get('/api/webhook/:userPath/stream', (req: Request, res: Response) => {
223-
const userPath = req.params.userPath;
224-
225-
// Set SSE headers
226-
res.writeHead(200, {
227-
'Content-Type': 'text/event-stream',
228-
'Cache-Control': 'no-cache',
229-
Connection: 'keep-alive',
230-
});
231-
232-
// Send an initial comment to establish the stream
233-
res.write(': connected\n\n');
234-
235-
// Register client
236-
const set = sseClients.get(userPath) || new Set<Response>();
237-
set.add(res);
238-
sseClients.set(userPath, set);
239-
240-
req.on('close', () => {
241-
// Remove client when they disconnect
242-
const clients = sseClients.get(userPath);
243-
if (clients) {
244-
clients.delete(res);
245-
if (clients.size === 0) sseClients.delete(userPath);
246-
}
247-
});
248-
});
24942

25043
// Serve static frontend if present in the final image at '../editor-dist'
25144
const staticPath = path.join(__dirname, "..", "editor-dist");
@@ -262,6 +55,26 @@ if (fs.existsSync(staticPath)) {
26255

26356
app.use(express.static(staticPath));
26457

58+
// Lightweight request logging for debugging static asset requests
59+
app.use((req: Request, _res: Response, next: NextFunction) => {
60+
if (req.method === 'GET' && (req.path === '/' || req.path === '/index.html' || req.path === '/env-config.js')) {
61+
console.log(`Static request for ${req.path} (accept: ${req.headers.accept})`);
62+
}
63+
next();
64+
});
65+
66+
// Ensure root and env-config are served directly (helps when clients send non-standard Accept headers)
67+
app.get('/', (_req: Request, res: Response) => {
68+
if (fs.existsSync(FRONTEND_INDEX)) return res.sendFile(FRONTEND_INDEX);
69+
return res.status(500).send('Frontend index.html missing in image.');
70+
});
71+
72+
app.get('/env-config.js', (_req: Request, res: Response) => {
73+
const file = path.join(staticPath, 'env-config.js');
74+
if (fs.existsSync(file)) return res.sendFile(file);
75+
return res.status(404).send('// env-config not found');
76+
});
77+
26578
// SPA fallback: only serve index.html for GET requests that accept HTML,
26679
// and explicitly exclude API and diagnostic routes so API clients get JSON.
26780
app.get("/*", (req: Request, res: Response, next: NextFunction) => {

0 commit comments

Comments
 (0)