char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
a7f08a1
main
1mod common; 2 3use std:: io::{ self , Read , Write }; 4use std:: net::{ TcpListener , TcpStream }; 5use std:: sync:: mpsc; 6use std:: thread:: JoinHandle ; 7use std:: time::{ Duration , Instant }; 8 9struct Request { 10path : String , 11headers : String , 12} 13 14fn request ( stream : & mut TcpStream ) -> io:: Result < Request > { 15 stream 16. set_read_timeout ( Some ( Duration :: from_secs ( 5 ))) 17. unwrap (); 18let mut buffer =Vec :: new (); 19let mut byte =[ 0 ; 1 ]; 20loop { 21match stream. read ( & mut byte) { 22Ok ( 0 ) |Err ( _) =>break , 23Ok ( _) =>{ 24 buffer. push ( byte[ 0 ]); 25if buffer. ends_with ( b"\r\n\r\n" ) { 26break ; 27} 28} 29} 30} 31let request =String :: from_utf8_lossy ( & buffer); 32// Header names are matched case-insensitively; values are not. 33let lines = request 34. lines () 35. map ( |line|match line. split_once ( ':' ) { 36Some (( name, value)) =>format! ( "{}:{value}" , name. to_ascii_lowercase ()), 37None => line. to_owned (), 38}) 39. collect ::< Vec < _ >>(); 40let first = lines. first (). map ( String :: as_str). unwrap_or_default (); 41let mut words = first. split_whitespace (); 42match words. next () { 43None =>return Err ( io:: ErrorKind :: UnexpectedEof . into ()), 44 method =>assert_eq! ( method, Some ( "GET" )), 45} 46Ok ( Request { 47path : words. next (). unwrap_or_default (). to_owned (), 48headers : lines[ 1 ..]. join ( "\n" ), 49}) 50} 51 52fn respond ( stream : & mut TcpStream , status : & str , headers : & str , body : & [ u8 ]) -> io:: Result <()> { 53let mut response =format! ( "HTTP/1.1 {status}\r\n{headers}\r\n" ). into_bytes (); 54 response. extend_from_slice ( body); 55 stream. write_all ( & response) 56} 57 58/// Accept `count` connections; each handler receives the request and may 59/// stream the body in stages before returning, which ends the connection. 60fn server ( 61count : usize , 62handler : impl Fn ( Request , & mut TcpStream ) -> io:: Result <()> +Send +' static , 63) ->( String , JoinHandle <()>) { 64let listener =TcpListener :: bind ( "127.0.0.1:0" ). unwrap (); 65let address = listener. local_addr (). unwrap (). to_string (); 66 listener. set_nonblocking ( true ). unwrap (); 67let thread = std:: thread:: spawn ( move ||{ 68let deadline =Instant :: now () +Duration :: from_secs ( 5 ); 69let mut remaining = count; 70while remaining !=0 { 71let ( mut socket, _) =loop { 72match listener. accept () { 73Ok ( connection) =>break connection, 74Err ( error) if error. kind () == io:: ErrorKind :: WouldBlock =>{ 75assert! ( Instant :: now () < deadline, "Missing EventSource request" ); 76 std:: thread:: sleep ( Duration :: from_millis ( 5 )); 77} 78Err ( error) =>panic! ( "{error}" ), 79} 80}; 81let Ok ( request) =request ( & mut socket) else { 82continue ; 83}; 84 remaining -=1 ; 85if handler ( request, & mut socket). is_err () { 86continue ; 87} 88} 89}); 90( format! ( "http://{address}" ), thread) 91} 92 93fn event_stream_headers () ->&' static str { 94"Content-Type: text/event-stream\r\nConnection: close\r\n" 95} 96 97/// Stream `chunks` with pauses so chunk boundaries land where the test 98/// intends; coalesced reads parse identically either way. 99fn stream_chunks ( stream : & mut TcpStream , chunks : & [ & [ u8 ]]) -> io:: Result <()> { 100respond ( stream, "200 OK" , event_stream_headers (), b"" ) ?; 101for chunkin chunks{ 102 stream. write_all ( chunk) ?; 103 stream. flush () ?; 104 std:: thread:: sleep ( Duration :: from_millis ( 30 )); 105} 106Ok (()) 107} 108 109fn header ( headers : & str , name : & str ) ->Option < String > { 110 headers. lines (). find_map ( |line|{ 111let ( field, value) = line. split_once ( ':' ) ?; 112( field. trim () == name). then ( || value. trim (). to_owned ()) 113}) 114} 115 116# [ test ] 117fn user_agent_defaults_follow_navigator_on_connect_and_reconnect () { 118let ( url, thread) =server ( 3 , |request, stream|{ 119let ua =header ( & request. headers , "user-agent" ). expect ( "Missing User-Agent" ); 120respond ( 121 stream, 122"200 OK" , 123event_stream_headers (), 124format! ( "retry: 0\ndata: {ua}\n\n" ). as_bytes (), 125) 126}); 127let source =r#" 128const original = navigator.userAgent; 129await new Promise((resolve, reject) => { 130const source = new EventSource('URL/default'); 131source.onmessage = event => { 132source.close(); 133if (event.data !== original) reject(Error('Initial UA')); 134else resolve(); 135}; 136source.onerror = () => {source.close(); reject(Error('Initial connection failed'));}; 137}); 138navigator.userAgent = 'custom/1.0'; 139await new Promise((resolve, reject) => { 140const source = new EventSource('URL/custom'); 141let count = 0; 142source.onmessage = event => { 143const expected = count++ === 0 ? 'custom/1.0' : 'reconnected/2.0'; 144if (event.data !== expected) { 145source.close(); 146reject(Error('Expected UA ' + expected + ', got ' + event.data)); 147} else if (count === 1) { 148navigator.userAgent = 'reconnected/2.0'; 149} else { 150source.close(); 151resolve(); 152} 153}; 154}); 155console.log('EventSource UA'); 156"# 157. replace ( "URL" , & url); 158assert_eq! ( common:: run ( & source, & []). stdout , b"EventSource UA\n" ); 159 thread. join (). unwrap (); 160} 161 162# [ test ] 163fn surface_brands_coercion_and_clone_rejection () { 164let out = common:: run ( 165r#" 166const original = 'HTTP://Example.com/plain%20path?query=1#frag'; 167const source = new EventSource(original); 168if (source.url !== 'http://example.com/plain%20path?query=1#frag') throw Error('URL serialization'); 169if (source.readyState !== 0 || source.withCredentials !== false) throw Error('Initial attributes'); 170const credentialed = new EventSource('http://127.0.0.1:1/cred', {withCredentials: true}); 171if (credentialed.withCredentials !== true) throw Error('withCredentials option'); 172const falsy = new EventSource('http://127.0.0.1:1/falsy', {withCredentials: 0}); 173if (falsy.withCredentials !== false) throw Error('Falsy withCredentials'); 174const nulled = new EventSource('http://127.0.0.1:1/n', null); 175if (nulled.withCredentials !== false || nulled.readyState !== 0) throw Error('Null options'); 176for (const constant of ['CONNECTING', 'OPEN', 'CLOSED']) { 177const descriptor = Object.getOwnPropertyDescriptor(EventSource.prototype, constant); 178if (!descriptor.enumerable || descriptor.writable || descriptor.get) throw Error('Prototype constant ' + constant); 179const staticDescriptor = Object.getOwnPropertyDescriptor(EventSource, constant); 180if (!staticDescriptor.enumerable || staticDescriptor.writable) throw Error('Static constant ' + constant); 181if (source[constant] !== staticDescriptor.value) throw Error('Constant value ' + constant); 182} 183if (Object.prototype.toString.call(source) !== '[object EventSource]') throw Error('toStringTag'); 184class Sub extends EventSource {} 185const sub = new Sub('http://127.0.0.1:1/sub'); 186if (!(sub instanceof Sub) || !(sub instanceof EventSource) || !(sub instanceof EventTarget)) throw Error('Subclass'); 187if (sub.readyState !== 0 || sub.withCredentials !== false) throw Error('Subclass attributes'); 188if (source.onopen !== null || source.onmessage !== null || source.onerror !== null) throw Error('Default handlers'); 189const illegal = (operation) => { 190try { 191operation(); 192throw Error('Accepted illegal invocation'); 193} catch (error) { 194if (!(error instanceof TypeError)) throw error; 195} 196}; 197illegal(() => Object.getOwnPropertyDescriptor(EventSource.prototype, 'readyState').get.call({})); 198illegal(() => EventSource.prototype.close.call({})); 199illegal(() => Object.getOwnPropertyDescriptor(EventSource.prototype, 'url').get.call(new EventTarget())); 200illegal(() => Object.getOwnPropertyDescriptor(EventSource.prototype, 'withCredentials').get.call({})); 201for (const bad of [() => new EventSource(), () => new EventSource(Symbol()), () => new EventSource('http://127.0.0.1:1/x', Symbol())]) { 202try { 203bad(); 204throw Error('Accepted invalid constructor argument'); 205} catch (error) { 206if (!(error instanceof TypeError)) throw error; 207} 208} 209const syntax = (operation) => { 210try { 211operation(); 212throw Error('Accepted invalid URL'); 213} catch (error) { 214if (!(error instanceof DOMException) || error.name !== 'SyntaxError') throw error; 215} 216}; 217syntax(() => new EventSource('http://[')); 218const relative = new EventSource('./events'); 219if (relative.url !== new URL('./events', import.meta.url).href) throw Error('Relative URL'); 220relative.close(); 221const marker = {}; 222try { 223new EventSource({toString() { throw marker; }}); 224throw Error('Swallowed coercion failure'); 225} catch (error) { 226if (error !== marker) throw error; 227} 228for (const clone of [ 229() => structuredClone(source), 230() => new MessageChannel().port1.postMessage(source), 231]) { 232try { 233clone(); 234throw Error('Accepted a clone'); 235} catch (error) { 236if (!(error instanceof DOMException) || error.name !== 'DataCloneError') throw error; 237} 238} 239credentialed.close(); 240falsy.close(); 241nulled.close(); 242sub.close(); 243source.close(); 244if (source.readyState !== 2) throw Error('Closed ready state'); 245source.close(); 246if (source.readyState !== 2) throw Error('Second close'); 247console.log('surface ok'); 248"# , 249& [], 250); 251assert_eq! ( out. stdout , b"surface ok\n" ); 252} 253 254# [ test ] 255fn streaming_line_endings_chunk_boundaries_and_fields () { 256let ( url, thread) =server ( 1 , |_, stream|{ 257stream_chunks ( 258 stream, 259& [ 260// CR terminated comment split from its line terminator. 261b": comment\r" , 262b"\ndata: first\n" , 263b"data: second\r\n" , 264b"data:third\n" , 265b"data: spaced\n" , 266b"event: custom\n" , 267b"\n" , 268// An incomplete event that the stream end must discard. 269b"data: dropped" , 270], 271) 272}); 273let out = common:: run ( 274& format! ( 275r#" 276const log = []; 277const es = new EventSource('{url}'); 278es.addEventListener('custom', (event) => {{ 279log.push(`custom:${{JSON.stringify(event.data)}}:${{event.lastEventId}}`); 280}}); 281es.addEventListener('message', () => log.push('unexpected message')); 282es.addEventListener('error', () => {{ 283if (es.readyState !== 0) throw Error('Reconnecting ready state'); 284es.close(); 285console.log(log.join(' | ')); 286}}); 287"# 288) 289. replace ( "{url}" , & url), 290& [], 291); 292assert_eq! ( 293 out. stdout , 294"custom:\"first\\nsecond\\nthird\\n spaced\":\n" . as_bytes () 295); 296 thread. join (). unwrap (); 297} 298 299# [ test ] 300fn byte_order_marks_utf8_splits_and_field_edge_cases () { 301let ( url, thread) =server ( 3 , |request, stream|{ 302match request. path . as_str () { 303"/bom" =>stream_chunks ( 304 stream, 305& [ 306// The BOM must be stripped exactly once even when split. 307b"\xef" , 308b"\xbb\xbfdata: bom\n\n" , 309// A second BOM becomes U+FEFF content. 310b"data: \xef\xbb\xbfkept\n\n" , 311// A UTF-8 sequence split across chunk boundaries. 312b"id: h\xc3" , 313b"\xa9llo\r\ndata: caf\xc3" , 314b"\xa9\n\n" , 315], 316), 317"/ids" =>stream_chunks ( 318 stream, 319& [ 320b"id: a\n\ndata: one\n\n" , 321// NUL rejects the id; the buffer is unchanged. 322b"id: b\x00\ndata: two\n\n" , 323// A blank line commits the id buffer without data. 324b"id: c\n\n" , 325// An empty id resets it for the next commit. 326b"id:\n\ndata: three\n\n" , 327], 328), 329 _ =>stream_chunks ( 330 stream, 331& [ 332// Field names are case-sensitive and unknown fields are ignored. 333b"DATA: upper\neVENT: mixed\nrandom: value\n" , 334b"event: one\nevent: two\ndata: replaced\n\n" , 335// The type buffer resets after a dispatch. 336b"data: back to message\n\n" , 337// Empty data values still dispatch. 338b"data:\n\n" , 339b"data: \n\n" , 340// retry only accepts plain ASCII digits. 341b"retry: +5\nretry: 1 2\nretry: 0x10\nretry: \nretry: -1\n" , 342b"data: after retry\n\n" , 343], 344), 345} 346}); 347let out = common:: run ( 348& format! ( 349r#" 350const log = []; 351const events = (source, types) => {{ 352for (const type of types) {{ 353if (type === 'error') continue; 354source.addEventListener(type, (event) => {{ 355log.push(`${{type}}:${{JSON.stringify(event.data)}}:${{JSON.stringify(event.lastEventId)}}`); 356}}); 357}} 358}}; 359function next(path) {{ 360const source = new EventSource('{url}/' + path); 361events(source, path === 'cases' ? ['message', 'two', 'error'] : ['message', 'error']); 362source.addEventListener('error', () => {{ 363source.close(); 364if (path === 'bom') next('ids'); 365else if (path === 'ids') next('cases'); 366else console.log(log.join(' | ')); 367}}); 368}} 369next('bom'); 370"# 371) 372. replace ( "{url}" , & url), 373& [], 374); 375let expected ="message:\"bom\":\"\" | message:\"\u{feff}kept\":\"\" | message:\"café\":\"héllo\" \ 376| message:\"one\":\"a\" | message:\"two\":\"a\" | message:\"three\":\"\" \ 377| two:\"replaced\":\"\" | message:\"back to message\":\"\" | message:\"\":\"\" \ 378| message:\"\":\"\" | message:\"after retry\":\"\"\n" ; 379assert_eq! ( out. stdout , expected. as_bytes ()); 380 thread. join (). unwrap (); 381} 382 383# [ test ] 384fn eof_discards_incomplete_event_without_dispatching () { 385let ( url, thread) =server ( 1 , |_, stream|{ 386respond ( 387 stream, 388"200 OK" , 389event_stream_headers (), 390b"id: lost\ndata: incomplete\nevent: never" , 391) 392}); 393let out = common:: run ( 394& format! ( 395r#" 396const log = []; 397const es = new EventSource('{url}'); 398es.addEventListener('message', () => log.push('message')); 399es.addEventListener('open', () => log.push('open')); 400es.addEventListener('error', () => {{ 401if (es.readyState !== 0) throw Error('Ready state after EOF'); 402log.push('error'); 403es.close(); 404console.log(log.join(' | ')); 405}}); 406"# 407) 408. replace ( "{url}" , & url), 409& [], 410); 411assert_eq! ( out. stdout , b"open | error\n" ); 412 thread. join (). unwrap (); 413} 414 415# [ test ] 416fn events_run_in_tasks_and_close_suppresses_queued_messages () { 417let ( url, thread) =server ( 1 , |_, stream|{ 418respond ( 419 stream, 420"200 OK" , 421event_stream_headers (), 422b"data: one\n\ndata: two\n\ndata: three\n\n" , 423) 424}); 425let out = common:: run ( 426& format! ( 427r#" 428const log = []; 429queueMicrotask(() => log.push('microtask')); 430Promise.resolve().then(() => log.push('promise')); 431let closed = false; 432const es = new EventSource('{url}'); 433es.onmessage = (event) => {{ 434log.push(`message:${{event.data}}`); 435if (!closed) {{ 436closed = true; 437es.close(); 438if (es.readyState !== 2) throw Error('Ready state inside handler'); 439}} 440}}; 441setTimeout(() => console.log(log.join(' | ')), 300); 442"# 443) 444. replace ( "{url}" , & url), 445& [], 446); 447assert_eq! ( out. stdout , b"microtask | promise | message:one\n" ); 448 thread. join (). unwrap (); 449} 450 451# [ test ] 452fn reconnect_honors_retry_and_sends_last_event_id () { 453let ( times_sender, times) = mpsc:: channel (); 454let ( received_sender, received) = mpsc:: channel (); 455let ( url, thread) ={ 456let times = times_sender. clone (); 457let received = received_sender. clone (); 458server ( 2 , move |request, stream|{ 459 times. send ( Instant :: now ()). unwrap (); 460let id =header ( & request. headers , "last-event-id" ); 461 received. send ( id. clone ()). unwrap (); 462if id. is_none () { 463stream_chunks ( 464 stream, 465& [ b"retry: 100\r\nid: \xf0\x9f\x92\xa9\r\ndata: hello\r\n\r\n" ], 466) 467} else { 468stream_chunks ( stream, & [ b"data: again\r\n\r\n" ]) 469} 470}) 471}; 472let out = common:: run ( 473& format! ( 474r#" 475const log = []; 476const es = new EventSource('{url}'); 477es.addEventListener('message', (event) => {{ 478if (event.origin !== '{url}') throw Error('Origin: ' + event.origin); 479log.push(`message:${{event.data}}:${{event.lastEventId}}`); 480if (event.data === 'again') es.close(); 481}}); 482es.addEventListener('error', () => {{ 483if (es.readyState !== 0) throw Error('Ready state while waiting to reconnect'); 484log.push('error'); 485}}); 486es.addEventListener('open', () => {{ 487log.push(`open:${{es.readyState}}`); 488}}); 489setTimeout(() => console.log(log.join(' | ')), 1500); 490"# 491) 492. replace ( "{url}" , & url), 493& [], 494); 495assert_eq! ( 496 out. stdout , 497"open:1 | message:hello:\u{1f4a9} | error | open:1 | message:again:\u{1f4a9}\n" . as_bytes (), 498); 499 thread. join (). unwrap (); 500assert! ( 501 received. recv (). unwrap (). is_none (), 502"the first request must not carry Last-Event-ID" 503); 504assert_eq! ( 505 received. recv (). unwrap (). unwrap (), 506"\u{1f4a9}" , 507"the reconnect must send the raw UTF-8 Last-Event-ID" 508); 509let first = times. recv (). unwrap (); 510let second = times. recv (). unwrap (); 511let elapsed = second. duration_since ( first); 512assert! ( 513 elapsed >=Duration :: from_millis ( 100 ), 514"reconnected after {elapsed:?}" 515); 516assert! ( 517 elapsed <Duration :: from_millis ( 2500 ), 518"reconnection took {elapsed:?}" 519); 520} 521 522# [ test ] 523fn non_200_bad_mime_and_204_terminate_without_retry () { 524let ( url, thread) =server ( 3 , |request, stream|match request. path . as_str () { 525"/status" =>respond ( stream, "404 Not Found" , "Connection: close\r\n" , b"" ), 526"/mime" =>respond ( 527 stream, 528"200 OK" , 529"Content-Type: text/plain\r\nConnection: close\r\n" , 530b"data: rejected\n\n" , 531), 532 _ =>respond ( stream, "204 No Content" , "Connection: close\r\n" , b"" ), 533}); 534let out = common:: run ( 535& format! ( 536r#" 537const log = []; 538for (const path of ['status', 'mime', 'empty']) {{ 539await new Promise((resolve) => {{ 540const es = new EventSource('{url}/' + path); 541es.addEventListener('open', () => log.push(`${{path}}:open`)); 542es.addEventListener('message', () => log.push(`${{path}}:message`)); 543es.addEventListener('error', (event) => {{ 544if (es.readyState !== 2) throw Error('Ready state after terminal failure'); 545if (!(event instanceof Event) || event instanceof ErrorEvent) throw Error('Error event interface'); 546log.push(`${{path}}:error`); 547resolve(); 548}}); 549}}); 550}} 551console.log(log.join(' | ')); 552"# 553) 554. replace ( "{url}" , & url), 555& [], 556); 557assert_eq! ( out. stdout , b"status:error | mime:error | empty:error\n" ); 558 thread. join (). unwrap (); 559} 560 561# [ test ] 562fn redirects_keep_the_original_url_and_use_the_final_origin () { 563let ( target_url, target_thread) =server ( 1 , |_, stream|{ 564respond ( 565 stream, 566"200 OK" , 567event_stream_headers (), 568b"data: redirected\n\n" , 569) 570}); 571let ( redirect_url, redirect_thread) ={ 572let target_url = target_url. clone (); 573server ( 1 , move |_, stream|{ 574respond ( 575 stream, 576"302 Found" , 577& format! ( "Location: {target_url}\r\nConnection: close\r\n" ), 578b"" , 579) 580}) 581}; 582let out = common:: run ( 583& format! ( 584r#" 585const es = new EventSource('{redirect_url}'); 586if (es.url !== '{redirect_url}/') throw Error('Original URL changed'); 587es.addEventListener('message', (event) => {{ 588if (event.origin !== '{target_url}') throw Error('Final origin: ' + event.origin); 589if (es.url !== '{redirect_url}/') throw Error('URL after redirect'); 590if (event.data !== 'redirected') throw Error('Redirected data'); 591es.close(); 592console.log('redirect ok'); 593}}); 594es.addEventListener('error', (event) => {{ 595throw Error('Unexpected error: ' + event.message); 596}}); 597"# 598) 599. replace ( "{redirect_url}" , & redirect_url) 600. replace ( "{target_url}" , & target_url), 601& [], 602); 603assert_eq! ( out. stdout , b"redirect ok\n" ); 604 redirect_thread. join (). unwrap (); 605 target_thread. join (). unwrap (); 606} 607 608# [ test ] 609fn net_permission_failures_are_asynchronous () { 610let out = common:: run ( 611r#" 612import { pledge } from 'avril:std'; 613pledge({ net: false }); 614const log = []; 615const es = new EventSource('http://127.0.0.1:1/'); 616if (es.readyState !== 0) throw Error('Synchronous failure'); 617log.push('connecting'); 618es.addEventListener('error', (event) => { 619if (es.readyState !== 0) throw Error('Ready state after denied connection'); 620if (event instanceof ErrorEvent) throw Error('Wrong error event'); 621log.push('denied'); 622es.close(); 623console.log(log.join(' | ')); 624}); 625"# , 626& [], 627); 628assert_eq! ( out. stdout , b"connecting | denied\n" ); 629} 630 631# [ test ] 632fn call_time_primordials_survive_guest_monkeypatching () { 633let ( url, thread) =server ( 1 , |_, stream|{ 634stream_chunks ( 635 stream, 636& [ b"event: hello\ndata: bonjour\nid: id\xf0\x9f\x92\xa9x\n\n" ], 637) 638}); 639let out = common:: run ( 640& format! ( 641r#" 642const poison = () => {{ throw Error('Guest replacement invoked'); }}; 643Math.floor = poison; 644Math.min = poison; 645Number.parseInt = poison; 646Promise.prototype.then = poison; 647Object.defineProperty(Promise.prototype, 'constructor', {{ get: poison }}); 648Object.defineProperty(String.prototype, 'indexOf', {{ value: poison }}); 649Object.defineProperty(String.prototype, 'charCodeAt', {{ value: poison }}); 650Object.defineProperty(String.prototype, 'slice', {{ value: poison }}); 651Object.defineProperty(String.prototype, 'toLowerCase', {{ value: poison }}); 652RegExp.prototype.exec = poison; 653Object.defineProperty(URL.prototype, 'href', {{ get: poison }}); 654Object.defineProperty(URL.prototype, 'origin', {{ get: poison }}); 655Object.defineProperty(URL.prototype, 'protocol', {{ get: poison }}); 656Object.defineProperty(Object.getPrototypeOf(Uint8Array.prototype), 'length', {{ get: poison }}); 657TextDecoder.prototype.decode = poison; 658TextEncoder.prototype.encode = poison; 659const addEventListener = EventTarget.prototype.addEventListener; 660EventTarget.prototype.addEventListener = poison; 661const es = new EventSource('{url}'); 662addEventListener.call(es, 'message', () => {{ throw Error('message event should not fire'); }}); 663addEventListener.call(es, 'hello', (event) => {{ 664if (event.data !== 'bonjour') throw Error('Custom event'); 665es.close(); 666console.log('primordials safe'); 667}}); 668"# 669) 670. replace ( "{url}" , & url), 671& [], 672); 673assert_eq! ( out. stdout , b"primordials safe\n" ); 674 thread. join (). unwrap (); 675} 676 677# [ test ] 678fn workers_expose_event_source () { 679let dir = tempfile:: tempdir (). unwrap (); 680let worker = dir. path (). join ( "worker.js" ); 681 std:: fs:: write ( 682& worker, 683r#" 684if (typeof EventSource !== 'function') throw Error('Missing EventSource'); 685const es = new EventSource('http://127.0.0.1:1/worker'); 686if (!(es instanceof EventTarget) || es.readyState !== 0) throw Error('Worker EventSource'); 687es.addEventListener('error', (event) => { 688if (es.readyState !== 0) throw Error('Ready state after denied connection'); 689es.close(); 690postMessage('worker eventsource ok'); 691close(); 692}); 693"# , 694) 695. unwrap (); 696let main =r#" 697if (typeof EventSource !== 'function') throw Error('Missing EventSource in main'); 698const worker = new Worker(new URL('./worker.js', import.meta.url), {type: 'module', name: 'es'}); 699const answer = new Promise((resolve, reject) => {worker.onmessage = e => resolve(e.data); worker.onerror = reject;}); 700console.log(await answer); 701"# ; 702 std:: fs:: write ( dir. path (). join ( "main.js" ), main). unwrap (); 703let output = std:: process:: Command :: new ( env! ( "CARGO_BIN_EXE_avril" )) 704. arg ( "run" ) 705. arg ( dir. path (). join ( "main.js" )) 706. stdout ( std:: process:: Stdio :: piped ()) 707. stderr ( std:: process:: Stdio :: piped ()) 708. output () 709. unwrap (); 710assert! ( 711 output. status . success (), 712"{}" , 713String :: from_utf8_lossy ( & output. stderr ) 714); 715assert_eq! ( output. stdout , b"worker eventsource ok\n" ); 716} 717 718# [ test ] 719fn reconnects_preserve_the_resume_cursor_until_an_explicit_reset () { 720let count = std:: sync:: atomic:: AtomicUsize :: new ( 0 ); 721let ( url, thread) =server ( 4 , move |request, stream|{ 722let attempt = count. fetch_add ( 1 , std:: sync:: atomic:: Ordering :: Relaxed ); 723assert_eq! ( 724header ( & request. headers , "cache-control" ). as_deref (), 725Some ( "no-cache" ) 726); 727assert_eq! ( 728header ( & request. headers , "pragma" ). as_deref (), 729Some ( "no-cache" ) 730); 731assert_eq! ( 732header ( & request. headers , "last-event-id" ). as_deref (), 733if attempt ==1 || attempt ==2 { 734Some ( "cursor" ) 735} else { 736None 737} 738); 739let body: & [ u8 ] =match attempt{ 7400 =>b"retry: 5\nid: cursor\ndata: first\n\n" , 7411 =>b"data: second\n\n" , 7422 =>b"id:\ndata: reset\n\n" , 743 _ =>b"data: final\n\n" , 744}; 745respond ( stream, "200 OK" , event_stream_headers (), body) 746}); 747let output = common:: run ( 748& format! ( 749r#" 750const es = new EventSource('{url}'), seen = []; 751const completed = new Promise(resolve => es.onmessage = event => {{ 752seen.push([event.data, event.lastEventId]); 753if (event.data === 'final') {{ es.close(); resolve(); }} 754}}); 755await completed; 756console.log(JSON.stringify(seen)); 757"# 758), 759& [], 760); 761assert_eq! ( 762 output. stdout , 763b"[[\"first\",\"cursor\"],[\"second\",\"cursor\"],[\"reset\",\"\"],[\"final\",\"\"]]\n" 764); 765 thread. join (). unwrap (); 766} 767 768# [ test ] 769fn long_fragmented_lines_stream_without_rescanning_the_prefix () { 770const LENGTH : usize =16 * 1024 * 1024 ; 771let ( url, thread) =server ( 1 , |_, stream|{ 772respond ( stream, "200 OK" , event_stream_headers (), b"data: " ) ?; 773for _in 0 ..LENGTH /1024 { 774 stream. write_all ( & [ b'x' ; 1024 ]) ?; 775} 776 stream. write_all ( b"\n\n" ) 777}); 778let output = common:: run ( 779& format! ( 780r#" 781const es = new EventSource('{url}'); 782await new Promise(resolve => es.onmessage = event => {{ 783if (event.data.length !== {LENGTH} || event.data[0] !== 'x' || event.data.at(-1) !== 'x') throw Error('Long line'); 784es.close(); resolve(); 785}}); 786console.log('long line'); 787"# 788), 789& [], 790); 791assert_eq! ( output. stdout , b"long line\n" ); 792 thread. join (). unwrap (); 793} 794 795# [ test ] 796fn control_character_ids_do_not_prevent_reconnection () { 797let count = std:: sync:: atomic:: AtomicUsize :: new ( 0 ); 798let ( url, thread) =server ( 5 , move |request, stream|{ 799let attempt = count. fetch_add ( 1 , std:: sync:: atomic:: Ordering :: Relaxed ); 800assert_eq! ( 801header ( & request. headers , "last-event-id" ). as_deref (), 802if attempt ==3 { 803Some ( "valid\tid" ) 804} else { 805None 806} 807); 808let body: & [ u8 ] =match attempt{ 8090 =>b"retry: 5\nid: a\x01b\ndata: first\n\n" , 8101 =>b"data: persisted\n\n" , 8112 =>b"id: valid\tid\ndata: valid\n\n" , 8123 =>b"id: a\x7fb\ndata: second\n\n" , 813 _ =>b"data: final\n\n" , 814}; 815respond ( stream, "200 OK" , event_stream_headers (), body) 816}); 817let output = common:: run ( 818& format! ( 819r#" 820const es = new EventSource('{url}'), ids = []; 821await new Promise(resolve => es.onmessage = event => {{ 822ids.push(event.lastEventId); 823if (event.data === 'final') {{es.close(); resolve();}} 824}}); 825if (JSON.stringify(ids) !== JSON.stringify(['a\x01b','a\x01b','valid\tid','a\x7fb','a\x7fb'])) throw Error('Cursor changed'); 826console.log('controls'); 827"# 828), 829& [], 830); 831assert_eq! ( output. stdout , b"controls\n" ); 832 thread. join (). unwrap (); 833} 834 835# [ test ] 836fn reflected_construction_and_live_handlers_survive_replaced_public_bases () { 837let output = common:: run ( 838r#" 839const Ctor = EventSource, Parent = EventTarget; 840const add = Parent.prototype.addEventListener, dispatch = Parent.prototype.dispatchEvent; 841const poison = () => {throw Error('Guest replacement invoked');}; 842Object.setPrototypeOf(Ctor, function() {throw Error('Dynamic super invoked');}); 843Parent.prototype.addEventListener = Parent.prototype.removeEventListener = poison; 844Event.prototype.preventDefault = poison; 845globalThis.EventTarget = poison; 846class Sub extends Ctor {} 847const order = [], options = { 848get withCredentials() {order.push('options'); return true;} 849}; 850const source = new Sub({toString() {order.push('url'); return 'data:text/plain,events';}}, options); 851source.close(); 852if (order.join('|') !== 'url|options' || !source.withCredentials) throw Error('Conversion order'); 853if (!(source instanceof Sub) || !(source instanceof Ctor) || !(source instanceof Parent)) { 854throw Error('Reflected inheritance'); 855} 856const log = []; 857source.onopen = function(event) { 858if (this !== source || event.target !== source) throw Error('Handler receiver'); 859log.push('first'); 860source.onopen = function() {log.push('second');}; 861return false; 862}; 863add.call(source, 'open', () => log.push('observer')); 864const canceled = new Event('open', {cancelable: true}); 865if (dispatch.call(source, canceled) || !canceled.defaultPrevented) throw Error('Handler cancellation'); 866if (!dispatch.call(source, new Event('open', {cancelable: true}))) throw Error('Replacement handler'); 867source.onopen = {handleEvent: poison}; 868if (source.onopen !== null) throw Error('Non-callable handler'); 869dispatch.call(source, new Event('open')); 870source.onopen = () => log.push('third'); 871dispatch.call(source, new Event('open')); 872if (log.join('|') !== 'first|observer|second|observer|observer|observer|third') { 873throw Error('Handler replacement or removal changed ordering'); 874} 875if (Reflect.ownKeys(source).length !== 0) throw Error('Unexpected instance fields'); 876for (const name of ['CONNECTING', 'OPEN', 'CLOSED']) { 877for (const owner of [Ctor, Ctor.prototype]) { 878const d = Object.getOwnPropertyDescriptor(owner, name); 879if (d.writable || d.configurable || !d.enumerable) throw Error('Constant descriptor'); 880} 881} 882const handler = Object.getOwnPropertyDescriptor(Ctor.prototype, 'onopen'); 883if (Ctor.length !== 1 || fetch.length !== 1 || Ctor.prototype.close.length !== 0 || 884handler.get.length !== 0 || handler.set.length !== 1) throw Error('Callable shape'); 885console.log('reflected construction and handlers'); 886"# , 887& [], 888); 889assert_eq! ( output. stdout , b"reflected construction and handlers\n" ); 890}