1
- import { NativeEventSource , EventSourcePolyfill } from "event-source-polyfill " ;
1
+ import { fetchEventSource } from "@microsoft/fetch- event-source" ;
2
2
import { EventIterator } from "event-iterator" ;
3
3
4
- // Use native browser EventSource if available, and use the polyfill if not available
5
- const EventSource = NativeEventSource || EventSourcePolyfill ;
4
+ function _getXSRFToken ( ) {
5
+ // from @jupyterlab /services
6
+ // https://github.com/jupyterlab/jupyterlab/blob/69223102d717f3d3e9f976d32e657a4e2456e85d/packages/services/src/contents/index.ts#L1178-L1184
7
+ let cookie = "" ;
8
+ try {
9
+ cookie = document . cookie ;
10
+ } catch ( e ) {
11
+ // e.g. SecurityError in case of CSP Sandbox
12
+ return null ;
13
+ }
14
+ // extracts the value of the cookie named `_xsrf`
15
+ // by picking up everything between `_xsrf=` and the next semicolon or end-of-line
16
+ // `\b` ensures word boundaries, so it doesn't pick up `something_xsrf=`...
17
+ const xsrfTokenMatch = cookie . match ( "\\b_xsrf=([^;]*)\\b" ) ;
18
+ if ( xsrfTokenMatch ) {
19
+ return xsrfTokenMatch [ 1 ] ;
20
+ }
21
+ return null ;
22
+ }
23
+
24
+ /* throw this to close the event stream */
25
+ class EventStreamClose extends Error { }
26
+ /* throw this to close the event stream */
27
+ class EventStreamRetry extends Error { }
6
28
7
29
/**
8
30
* Build (and optionally launch) a repository by talking to a BinderHub API endpoint
@@ -12,10 +34,14 @@ export class BinderRepository {
12
34
*
13
35
* @param {string } providerSpec Spec of the form <provider>/<repo>/<ref> to pass to the binderhub API.
14
36
* @param {URL } buildEndpointUrl API URL of the build endpoint to talk to
15
- * @param {string } [buildToken] Optional JWT based build token if this binderhub installation requires using build tokens
16
- * @param {boolean } [buildOnly] Opt out of launching built image by default by passing `build_only` param
37
+ * @param {Object } [options] - optional arguments
38
+ * @param {string } [options.buildToken] Optional JWT based build token if this binderhub installation requires using build tokens
39
+ * @param {boolean } [options.buildOnly] Opt out of launching built image by default by passing `build_only` param
40
+ * @param {string } [options.apiToken] Optional Bearer token for authenticating requests
17
41
*/
18
- constructor ( providerSpec , buildEndpointUrl , buildToken , buildOnly ) {
42
+ constructor ( providerSpec , buildEndpointUrl , options ) {
43
+ const { apiToken, buildToken, buildOnly } = options || { } ;
44
+
19
45
this . providerSpec = providerSpec ;
20
46
// Make sure that buildEndpointUrl is a real URL - this ensures hostname is properly set
21
47
if ( ! ( buildEndpointUrl instanceof URL ) ) {
@@ -40,8 +66,10 @@ export class BinderRepository {
40
66
if ( buildOnly ) {
41
67
this . buildUrl . searchParams . append ( "build_only" , "true" ) ;
42
68
}
69
+ this . apiToken = apiToken ;
43
70
44
71
this . eventIteratorQueue = null ;
72
+ this . abortSignal = null ;
45
73
}
46
74
47
75
/**
@@ -67,26 +95,100 @@ export class BinderRepository {
67
95
* @returns {AsyncIterable<Line> } An async iterator yielding responses from the API as they come in
68
96
*/
69
97
fetch ( ) {
70
- this . eventSource = new EventSource ( this . buildUrl ) ;
98
+ const headers = { } ;
99
+ this . abortController = new AbortController ( ) ;
100
+
101
+ if ( this . apiToken && this . apiToken . length > 0 ) {
102
+ headers [ "Authorization" ] = `Bearer ${ this . apiToken } ` ;
103
+ } else {
104
+ const xsrf = _getXSRFToken ( ) ;
105
+ if ( xsrf ) {
106
+ headers [ "X-Xsrftoken" ] = xsrf ;
107
+ }
108
+ }
109
+ // setTimeout(() => this.close(), 1000);
71
110
return new EventIterator ( ( queue ) => {
72
111
this . eventIteratorQueue = queue ;
73
- this . eventSource . onerror = ( ) => {
74
- queue . push ( {
75
- phase : "failed" ,
76
- message : "Failed to connect to event stream\n" ,
77
- } ) ;
78
- queue . stop ( ) ;
79
- } ;
80
-
81
- this . eventSource . addEventListener ( "message" , ( event ) => {
82
- // console.log("message received")
83
- // console.log(event)
84
- const data = JSON . parse ( event . data ) ;
85
- // FIXME: fix case of phase/state upstream
86
- if ( data . phase ) {
87
- data . phase = data . phase . toLowerCase ( ) ;
88
- }
89
- queue . push ( data ) ;
112
+ fetchEventSource ( this . buildUrl , {
113
+ headers,
114
+ // signal used for closing
115
+ signal : this . abortController . signal ,
116
+ // openWhenHidden leaves connection open (matches default)
117
+ // otherwise fetch-event closes connections,
118
+ // which would be nice if our javascript handled restarting messages better
119
+ openWhenHidden : true ,
120
+ onopen : ( response ) => {
121
+ if ( response . ok ) {
122
+ return ; // everything's good
123
+ } else if (
124
+ response . status >= 400 &&
125
+ response . status < 500 &&
126
+ response . status !== 429
127
+ ) {
128
+ queue . push ( {
129
+ phase : "failed" ,
130
+ message : `Failed to connect to event stream: ${ response . status } - ${ response . text } \n` ,
131
+ } ) ;
132
+ throw new EventStreamClose ( ) ;
133
+ } else {
134
+ queue . push ( {
135
+ phase : "unknown" ,
136
+ message : `Error connecting to event stream, retrying: ${ response . status } - ${ response . text } \n` ,
137
+ } ) ;
138
+ throw new EventStreamRetry ( ) ;
139
+ }
140
+ } ,
141
+
142
+ onclose : ( ) => {
143
+ if ( ! queue . isStopped ) {
144
+ // close called before queue finished
145
+ queue . push ( {
146
+ phase : "failed" ,
147
+ message : `Event stream closed unexpectedly\n` ,
148
+ } ) ;
149
+ queue . stop ( ) ;
150
+ // throw new EventStreamClose();
151
+ }
152
+ } ,
153
+ onerror : ( error ) => {
154
+ console . log ( "Event stream error" , error ) ;
155
+ if ( error . name === "EventStreamRetry" ) {
156
+ // if we don't re-raise, connection will be retried;
157
+ queue . push ( {
158
+ phase : "unknown" ,
159
+ message : `Error in event stream: ${ error } \n` ,
160
+ } ) ;
161
+ return ;
162
+ }
163
+ if (
164
+ ! ( error . name === "EventStreamClose" || error . name === "AbortError" )
165
+ ) {
166
+ // errors _other_ than EventStreamClose get displayed
167
+ queue . push ( {
168
+ phase : "failed" ,
169
+ message : `Error in event stream: ${ error } \n` ,
170
+ } ) ;
171
+ }
172
+ queue . stop ( ) ;
173
+ // need to rethrow to prevent reconnection
174
+ throw error ;
175
+ } ,
176
+
177
+ onmessage : ( event ) => {
178
+ if ( ! event . data || event . data === "" ) {
179
+ // onmessage is called for the empty lines
180
+ return ;
181
+ }
182
+ const data = JSON . parse ( event . data ) ;
183
+ // FIXME: fix case of phase/state upstream
184
+ if ( data . phase ) {
185
+ data . phase = data . phase . toLowerCase ( ) ;
186
+ }
187
+ queue . push ( data ) ;
188
+ if ( data . phase === "failed" ) {
189
+ throw new EventStreamClose ( ) ;
190
+ }
191
+ } ,
90
192
} ) ;
91
193
} ) ;
92
194
}
@@ -95,12 +197,15 @@ export class BinderRepository {
95
197
* Close the EventSource connection to the BinderHub API if it is open
96
198
*/
97
199
close ( ) {
98
- if ( this . eventSource !== undefined ) {
99
- this . eventSource . close ( ) ;
100
- }
101
- if ( this . eventIteratorQueue !== null ) {
200
+ if ( this . eventIteratorQueue ) {
102
201
// Stop any currently running fetch() iterations
103
202
this . eventIteratorQueue . stop ( ) ;
203
+ this . eventIteratorQueue = null ;
204
+ }
205
+ if ( this . abortController ) {
206
+ // close event source
207
+ this . abortController . abort ( ) ;
208
+ this . abortController = null ;
104
209
}
105
210
}
106
211
0 commit comments