@@ -2,6 +2,7 @@ import path from "path";
22import fs from "fs" ;
33import express , { Request , Response , NextFunction } from "express" ;
44import cors from "cors" ;
5+ import { registerWebhookRoutes } from './webhook' ;
56
67const 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'
25144const 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