11import http from 'node:http' ;
2- import { randomUUID } from 'node:crypto' ;
2+ import { createHash , randomUUID } from 'node:crypto' ;
3+ import { gzipSync } from 'node:zlib' ;
34
45import {
56 createOpaqueToken ,
@@ -89,7 +90,7 @@ export class CaseApiServer {
8990 } else {
9091 this . logger ?. debug ?. ( 'API request rejected' , details ) ;
9192 }
92- this . sendError ( response , error ) ;
93+ this . sendError ( request , response , error ) ;
9394 } ) ;
9495 } ) ;
9596 }
@@ -125,14 +126,14 @@ export class CaseApiServer {
125126 }
126127
127128 if ( request . method === 'GET' && url . pathname === '/live' ) {
128- return this . send ( response , 200 , { status : 'live' } ) ;
129+ return this . send ( request , response , 200 , { status : 'live' } ) ;
129130 }
130131 if ( request . method === 'GET' && url . pathname === '/health' ) {
131132 const status = this . status ( ) ;
132- return this . send ( response , status . status === 'healthy' ? 200 : 503 , status ) ;
133+ return this . send ( request , response , status . status === 'healthy' ? 200 : 503 , status ) ;
133134 }
134135 if ( request . method === 'GET' && url . pathname === `${ API_PREFIX } /config` ) {
135- return this . send ( response , 200 , {
136+ return this . send ( request , response , 200 , {
136137 network : this . network ,
137138 maxSequencers : this . maxSequencers ,
138139 notifications : {
@@ -147,29 +148,35 @@ export class CaseApiServer {
147148 } ) ;
148149 }
149150 if ( request . method === 'GET' && url . pathname === `${ API_PREFIX } /status` ) {
150- return this . send ( response , 200 , this . status ( ) ) ;
151+ return this . send ( request , response , 200 , this . status ( ) ) ;
151152 }
152153 if ( request . method === 'GET' && url . pathname === `${ API_PREFIX } /network` ) {
153- return this . send ( response , 200 , {
154- ...this . repository . getNetworkSummary ( this . network ) ,
155- sources : this . status ( ) . sources ,
156- } ) ;
154+ return this . send (
155+ request ,
156+ response ,
157+ 200 ,
158+ this . repository . getNetworkSummary ( this . network ) ,
159+ { revalidate : true } ,
160+ ) ;
157161 }
158162
159163 const sequencerMatch = / ^ \/ a p i \/ s e q u e n c e r s \/ ( 0 x [ 0 - 9 a - f A - F ] { 40 } ) $ / . exec (
160164 url . pathname ,
161165 ) ;
162166 if ( request . method === 'GET' && sequencerMatch ) {
163- return this . send ( response , 200 , this . repository . getSequencerRecord (
164- sequencerMatch [ 1 ] ,
165- this . network ,
166- ) ) ;
167+ return this . send (
168+ request ,
169+ response ,
170+ 200 ,
171+ this . repository . getSequencerRecord ( sequencerMatch [ 1 ] , this . network ) ,
172+ { revalidate : true } ,
173+ ) ;
167174 }
168175 const caseMatch = / ^ \/ a p i \/ c a s e s \/ ( [ ^ / ] + ) $ / . exec ( url . pathname ) ;
169176 if ( request . method === 'GET' && caseMatch ) {
170177 const item = this . repository . getCase ( decodeURIComponent ( caseMatch [ 1 ] ) ) ;
171178 if ( ! item ) throw new InputError ( 'case_not_found' , 'Slashing case not found' , 404 ) ;
172- return this . send ( response , 200 , item ) ;
179+ return this . send ( request , response , 200 , item , { revalidate : true } ) ;
173180 }
174181
175182 if ( request . method === 'POST' && url . pathname === `${ API_PREFIX } /watches` ) {
@@ -186,7 +193,7 @@ export class CaseApiServer {
186193 addresses,
187194 now : this . now ( ) ,
188195 } ) ;
189- return this . send ( response , 201 , {
196+ return this . send ( request , response , 201 , {
190197 watch : publicWatch ( watch , this . repository ) ,
191198 managementToken,
192199 } ) ;
@@ -196,7 +203,7 @@ export class CaseApiServer {
196203 if ( watchMatch ) {
197204 const watch = this . authorizeWatch ( request , watchMatch [ 1 ] ) ;
198205 if ( request . method === 'GET' ) {
199- return this . send ( response , 200 , publicWatch ( watch , this . repository ) ) ;
206+ return this . send ( request , response , 200 , publicWatch ( watch , this . repository ) ) ;
200207 }
201208 this . limitMutation ( request ) ;
202209 if ( request . method === 'PATCH' ) {
@@ -208,7 +215,7 @@ export class CaseApiServer {
208215 addresses,
209216 now : this . now ( ) ,
210217 } ) ;
211- return this . send ( response , 200 , publicWatch ( updated , this . repository ) ) ;
218+ return this . send ( request , response , 200 , publicWatch ( updated , this . repository ) ) ;
212219 }
213220 if ( request . method === 'DELETE' ) {
214221 this . repository . deleteWatch ( watch . id ) ;
@@ -242,7 +249,7 @@ export class CaseApiServer {
242249 configJson : JSON . stringify ( subscription ) ,
243250 now : this . now ( ) ,
244251 } ) ;
245- return this . send ( response , 200 , publicWatch ( updated , this . repository ) ) ;
252+ return this . send ( request , response , 200 , publicWatch ( updated , this . repository ) ) ;
246253 }
247254 if ( request . method === 'DELETE' ) {
248255 this . repository . deleteEndpoint ( watch . id , 'web_push' ) ;
@@ -274,7 +281,7 @@ export class CaseApiServer {
274281 expiresAt,
275282 now : this . now ( ) ,
276283 } ) ;
277- return this . send ( response , 201 , {
284+ return this . send ( request , response , 201 , {
278285 url : `https://t.me/${ this . telegramBotUsername } ?start=${ token } ` ,
279286 expiresAt : new Date ( expiresAt ) . toISOString ( ) ,
280287 } ) ;
@@ -295,7 +302,7 @@ export class CaseApiServer {
295302 409 ,
296303 ) ;
297304 }
298- return this . send ( response , 202 , { queued } ) ;
305+ return this . send ( request , response , 202 , { queued } ) ;
299306 }
300307
301308 throw new InputError ( 'not_found' , 'Route not found' , 404 ) ;
@@ -421,25 +428,46 @@ export class CaseApiServer {
421428 response . setHeader ( 'access-control-allow-origin' , this . corsOrigin ) ;
422429 response . setHeader ( 'access-control-allow-methods' , 'GET,POST,PATCH,PUT,DELETE,OPTIONS' ) ;
423430 response . setHeader ( 'access-control-allow-headers' , 'authorization,content-type' ) ;
424- response . setHeader ( 'vary' , 'Origin' ) ;
431+ response . setHeader ( 'vary' , 'Origin, Accept-Encoding ' ) ;
425432 }
426433
427- send ( response , status , value ) {
434+ // Public data endpoints send `cache-control: no-cache` plus a weak ETag so
435+ // browsers revalidate every poll and receive a bodyless 304 while nothing
436+ // changed. Private and mutating responses stay `no-store`. Bodies are
437+ // gzipped at the origin: the network path to the CDN edge is metered.
438+ send ( request , response , status , value , { revalidate = false } = { } ) {
428439 const body = JSON . stringify ( value ) ;
429- response . writeHead ( status , {
440+ const headers = {
430441 'content-type' : 'application/json; charset=utf-8' ,
431- 'cache-control' : 'no-store' ,
432- 'content-length' : Buffer . byteLength ( body ) ,
433- } ) ;
434- response . end ( body ) ;
442+ 'cache-control' : revalidate ? 'no-cache' : 'no-store' ,
443+ } ;
444+ if ( revalidate && status === 200 ) {
445+ const etag = `W/"${ createHash ( 'sha256' ) . update ( body ) . digest ( 'base64url' ) } "` ;
446+ headers . etag = etag ;
447+ const ifNoneMatch = request . headers [ 'if-none-match' ] ;
448+ if ( typeof ifNoneMatch === 'string' && ifNoneMatch . includes ( etag ) ) {
449+ response . writeHead ( 304 , headers ) ;
450+ response . end ( ) ;
451+ return ;
452+ }
453+ }
454+ const acceptsGzip = / (?: ^ | [ , \s ] ) g z i p (?: $ | [ ; , ] ) /
455+ . test ( String ( request . headers [ 'accept-encoding' ] ?? '' ) ) ;
456+ const payload = acceptsGzip && Buffer . byteLength ( body ) > 1_024
457+ ? gzipSync ( body )
458+ : body ;
459+ if ( payload !== body ) headers [ 'content-encoding' ] = 'gzip' ;
460+ headers [ 'content-length' ] = Buffer . byteLength ( payload ) ;
461+ response . writeHead ( status , headers ) ;
462+ response . end ( payload ) ;
435463 }
436464
437- sendError ( response , error ) {
465+ sendError ( request , response , error ) {
438466 const safeStatus = errorStatus ( error ) ;
439467 if ( error ?. retryAfterMs ) {
440468 response . setHeader ( 'retry-after' , String ( Math . ceil ( error . retryAfterMs / 1_000 ) ) ) ;
441469 }
442- this . send ( response , safeStatus , {
470+ this . send ( request , response , safeStatus , {
443471 error : {
444472 code : error ?. code ?? 'internal_error' ,
445473 message : safeStatus === 500
0 commit comments