|
1
|
"use strict"; |
|
2
|
/** |
|
3
|
Requires that window.fossil has already been set up. |
|
4
|
*/ |
|
5
|
(function(namespace){ |
|
6
|
const fossil = namespace; |
|
7
|
/** |
|
8
|
fetch() is an HTTP request/response mini-framework |
|
9
|
similar (but not identical) to the not-quite-ubiquitous |
|
10
|
window.fetch(). |
|
11
|
|
|
12
|
JS usages: |
|
13
|
|
|
14
|
fossil.fetch( URI [, onLoadCallback] ); |
|
15
|
|
|
16
|
fossil.fetch( URI [, optionsObject = {}] ); |
|
17
|
|
|
18
|
Noting that URI must be relative to the top of the repository and |
|
19
|
should not start with a slash (if it does, it is stripped). It gets |
|
20
|
the equivalent of "%R/" prepended to it. |
|
21
|
|
|
22
|
The optionsObject may be an onload callback or an object with any |
|
23
|
of these properties: |
|
24
|
|
|
25
|
- onload: callback(responseData) (default = output response to the |
|
26
|
console). In the context of the callback, the options object is |
|
27
|
"this", noting that this call may have amended the options object |
|
28
|
with state other than what the caller provided. |
|
29
|
|
|
30
|
- onerror: callback(Error object) (default = output error message |
|
31
|
to console.error() and fossil.error()). Triggered if the request |
|
32
|
generates any response other than HTTP 200, or if the beforesend() |
|
33
|
or onload() handler throws an exception. In the context of the |
|
34
|
callback, the options object is "this". This function is intended |
|
35
|
to be used solely for error reporting, not error recovery. Special |
|
36
|
cases for the Error object: |
|
37
|
|
|
38
|
1. Timeouts unfortunately show up as a series of 2 events: an |
|
39
|
HTTP 0 followed immediately by an XHR.ontimeout(). The former |
|
40
|
cannot(?) be unambiguously identified as the trigger for the |
|
41
|
pending timeout, so we have no option but to pass it on as-is |
|
42
|
instead of flagging it as a timeout response. The latter will |
|
43
|
trigger the client-provided ontimeout() if it's available (see |
|
44
|
below), else it calls the onerror() callback. An error object |
|
45
|
passed to ontimeout() by fetch() will have (.name='timeout', |
|
46
|
.status=XHR.status). |
|
47
|
|
|
48
|
2. Else if the response contains a JSON-format exception on the |
|
49
|
server, it will have (.name='json-error', |
|
50
|
status=XHR.status). Any JSON-format result object which has a |
|
51
|
property named "error" is considered to be a server-generated |
|
52
|
error. |
|
53
|
|
|
54
|
3. Else if it gets a non 2xx HTTP code then it will have |
|
55
|
(.name='http',.status=XHR.status). |
|
56
|
|
|
57
|
4. If onerror() throws, the exception is suppressed but may |
|
58
|
generate a console error message. |
|
59
|
|
|
60
|
- ontimeout: callback(Error object). If set, timeout errors are |
|
61
|
reported here, else they are reported through onerror(). |
|
62
|
Unfortunately, XHR fires two events for a timeout: an |
|
63
|
onreadystatechange() and an ontimeout(), in that order. From the |
|
64
|
former, however, we cannot unambiguously identify the error as |
|
65
|
having been caused by a timeout, so clients which set ontimeout() |
|
66
|
will get _two_ callback calls: one with an HTTP error response |
|
67
|
followed immediately by an ontimeout() response. Error objects |
|
68
|
passed to this will have (.name='timeout', .status=xhr.HttpStatus). |
|
69
|
In the context of the callback, the options object is "this", Like |
|
70
|
onerror(), any exceptions thrown by the ontimeout() handler are |
|
71
|
suppressed, but may generate a console error message. The onerror() |
|
72
|
handler is _not_ called in this case. |
|
73
|
|
|
74
|
- method: 'POST' | 'GET' (default = 'GET'). CASE SENSITIVE! |
|
75
|
|
|
76
|
- payload: anything acceptable by XHR2.send(ARG) (DOMString, |
|
77
|
Document, FormData, Blob, File, ArrayBuffer), or a plain object or |
|
78
|
array, either of which gets JSON.stringify()'d. If payload is set |
|
79
|
then the method is automatically set to 'POST'. By default XHR2 |
|
80
|
will set the content type based on the payload type. If an |
|
81
|
object/array is converted to JSON, the contentType option is |
|
82
|
automatically set to 'application/json', and if JSON.stringify() of |
|
83
|
that value fails then the exception is propagated to this |
|
84
|
function's caller. (beforesend(), aftersend(), and onerror() are |
|
85
|
NOT triggered in that case.) |
|
86
|
|
|
87
|
- contentType: Optional request content type when POSTing. Ignored |
|
88
|
if the method is not 'POST'. |
|
89
|
|
|
90
|
- responseType: optional string. One of ("text", "arraybuffer", |
|
91
|
"blob", or "document") (as specified by XHR2). Default = "text". |
|
92
|
As an extension, it supports "json", which tells it that the |
|
93
|
response is expected to be text and that it should be JSON.parse()d |
|
94
|
before passing it on to the onload() callback. If parsing of such |
|
95
|
an object fails, the onload callback is not called, and the |
|
96
|
onerror() callback is passed the exception from the parsing error. |
|
97
|
If the parsed JSON object has an "error" property, it is assumed to |
|
98
|
be an error string, which is used to populate a new Error object, |
|
99
|
which will gets (.name="json") set on it. |
|
100
|
|
|
101
|
- urlParams: string|object. If a string, it is assumed to be a |
|
102
|
URI-encoded list of params in the form "key1=val1&key2=val2...", |
|
103
|
with NO leading '?'. If it is an object, all of its properties get |
|
104
|
converted to that form. Either way, the parameters get appended to |
|
105
|
the URL before submitting the request. |
|
106
|
|
|
107
|
- responseHeaders: If true, the onload() callback is passed an |
|
108
|
additional argument: a map of all of the response headers. If it's |
|
109
|
a string value, the 2nd argument passed to onload() is instead the |
|
110
|
value of that single header. If it's an array, it's treated as a |
|
111
|
list of headers to return, and the 2nd argument is a map of those |
|
112
|
header values. When a map is passed on, all of its keys are |
|
113
|
lower-cased. When a given header is requested and that header is |
|
114
|
set multiple times, their values are (per the XHR docs) |
|
115
|
concatenated together with "," between them. |
|
116
|
|
|
117
|
- beforesend/aftersend: optional callbacks which are called |
|
118
|
without arguments immediately before the request is submitted |
|
119
|
and immediately after it is received, regardless of success or |
|
120
|
error. In the context of the callback, the options object is |
|
121
|
the "this". These can be used to, e.g., keep track of in-flight |
|
122
|
requests and update the UI accordingly, e.g. disabling/enabling |
|
123
|
DOM elements. Any exceptions thrown in an beforesend are passed |
|
124
|
to the onerror() handler and cause the fetch() to prematurely |
|
125
|
abort. Exceptions thrown in aftersend are currently silently |
|
126
|
ignored (feature or bug?). |
|
127
|
|
|
128
|
- timeout: integer in milliseconds specifying the XHR timeout |
|
129
|
duration. Default = fossil.fetch.timeout. |
|
130
|
|
|
131
|
When an options object does not provide |
|
132
|
onload/onerror/beforesend/aftersend handlers of its own, this |
|
133
|
function falls back to defaults which are member properties of this |
|
134
|
function with the same name, e.g. fossil.fetch.onload(). The |
|
135
|
default onload/onerror implementations route the data through the |
|
136
|
dev console and (for onerror()) through fossil.error(). The default |
|
137
|
beforesend/aftersend are no-ops. Individual pages may overwrite |
|
138
|
those members to provide default implementations suitable for the |
|
139
|
page's use, e.g. keeping track of how many in-flight ajax requests |
|
140
|
are pending. |
|
141
|
|
|
142
|
Note that this routine may add properties to the 2nd argument, so |
|
143
|
that instance should not be kept around for later use. |
|
144
|
|
|
145
|
Returns this object, noting that the XHR request is asynchronous, |
|
146
|
and still in transit (or has yet to be sent) when that happens. |
|
147
|
*/ |
|
148
|
fossil.fetch = function f(uri,opt){ |
|
149
|
const F = fossil; |
|
150
|
if(!f.onload){ |
|
151
|
f.onload = (r)=>console.debug('fossil.fetch() XHR response:',r); |
|
152
|
} |
|
153
|
if(!f.onerror){ |
|
154
|
f.onerror = function(e/*exception*/){ |
|
155
|
console.error("fossil.fetch() XHR error:",e); |
|
156
|
if(e instanceof Error) F.error('Exception:',e); |
|
157
|
else F.error("Unknown error in handling of XHR request."); |
|
158
|
}; |
|
159
|
} |
|
160
|
if(!f.parseResponseHeaders){ |
|
161
|
f.parseResponseHeaders = function(h){ |
|
162
|
const rc = {}; |
|
163
|
if(!h) return rc; |
|
164
|
const ar = h.trim().split(/[\r\n]+/); |
|
165
|
ar.forEach(function(line) { |
|
166
|
const parts = line.split(': '); |
|
167
|
const header = parts.shift(); |
|
168
|
const value = parts.join(': '); |
|
169
|
rc[header.toLowerCase()] = value; |
|
170
|
}); |
|
171
|
return rc; |
|
172
|
}; |
|
173
|
} |
|
174
|
if('/'===uri[0]) uri = uri.substr(1); |
|
175
|
if(!opt) opt = {}/* should arguably be Object.create(null) */; |
|
176
|
else if('function'===typeof opt) opt={onload:opt}; |
|
177
|
if(!opt.onload) opt.onload = f.onload; |
|
178
|
if(!opt.onerror) opt.onerror = f.onerror; |
|
179
|
if(!opt.beforesend) opt.beforesend = f.beforesend; |
|
180
|
if(!opt.aftersend) opt.aftersend = f.aftersend; |
|
181
|
let payload = opt.payload, jsonResponse = false; |
|
182
|
if(undefined!==payload){ |
|
183
|
opt.method = 'POST'; |
|
184
|
if(!(payload instanceof FormData) |
|
185
|
&& !(payload instanceof Document) |
|
186
|
&& !(payload instanceof Blob) |
|
187
|
&& !(payload instanceof File) |
|
188
|
&& !(payload instanceof ArrayBuffer) |
|
189
|
&& ('object'===typeof payload |
|
190
|
|| payload instanceof Array)){ |
|
191
|
payload = JSON.stringify(payload); |
|
192
|
opt.contentType = 'application/json'; |
|
193
|
} |
|
194
|
} |
|
195
|
const url=[f.urlTransform(uri,opt.urlParams)], |
|
196
|
x=new XMLHttpRequest(); |
|
197
|
if('json'===opt.responseType){ |
|
198
|
/* 'json' is an extension to the supported XHR.responseType |
|
199
|
list. We use it as a flag to tell us to JSON.parse() |
|
200
|
the response. */ |
|
201
|
jsonResponse = true; |
|
202
|
x.responseType = 'text'; |
|
203
|
}else{ |
|
204
|
x.responseType = opt.responseType||'text'; |
|
205
|
} |
|
206
|
x.ontimeout = function(ev){ |
|
207
|
try{opt.aftersend()}catch(e){/*ignore*/} |
|
208
|
const err = new Error("XHR timeout of "+x.timeout+"ms expired."); |
|
209
|
err.status = x.status; |
|
210
|
err.name = 'timeout'; |
|
211
|
//console.warn("fetch.ontimeout",ev); |
|
212
|
try{ |
|
213
|
(opt.ontimeout || opt.onerror)(err); |
|
214
|
}catch(e){ |
|
215
|
/*ignore*/ |
|
216
|
console.error("fossil.fetch()'s ontimeout() handler threw",e); |
|
217
|
} |
|
218
|
}; |
|
219
|
/* Ensure that if onerror() throws, it's ignored. */ |
|
220
|
const origOnError = opt.onerror; |
|
221
|
opt.onerror = (arg)=>{ |
|
222
|
try{ origOnError.call(this, arg) } |
|
223
|
catch(e){ |
|
224
|
/*ignored*/ |
|
225
|
console.error("fossil.fetch()'s onerror() threw",e); |
|
226
|
} |
|
227
|
}; |
|
228
|
x.onreadystatechange = function(ev){ |
|
229
|
//console.warn("onreadystatechange", x.readyState, ev.target.responseText); |
|
230
|
if(XMLHttpRequest.DONE !== x.readyState) return; |
|
231
|
try{opt.aftersend()}catch(e){/*ignore*/} |
|
232
|
if(false && 0===x.status){ |
|
233
|
/* For reasons unknown, we _sometimes_ trigger x.status==0 in FF |
|
234
|
when the /chat page starts up, but not in Chrome nor in other |
|
235
|
apps. Insofar as has been determined, this happens before a |
|
236
|
request is actually sent and it appears to have no |
|
237
|
side-effects on the app other than to generate an error |
|
238
|
(i.e. no requests/responses are missing). This is a silly |
|
239
|
workaround which may or may not bite us later. If so, it can |
|
240
|
be removed at the cost of an unsightly console error message |
|
241
|
in FF. |
|
242
|
|
|
243
|
2025-04-10: that behavior is now also in Chrome and enabling |
|
244
|
this workaround causes our timeout errors to never arrive. |
|
245
|
*/ |
|
246
|
return; |
|
247
|
} |
|
248
|
if(200!==x.status){ |
|
249
|
//console.warn("Error response",ev.target); |
|
250
|
let err; |
|
251
|
try{ |
|
252
|
const j = JSON.parse(x.response); |
|
253
|
if(j.error){ |
|
254
|
err = new Error(j.error); |
|
255
|
err.name = 'json.error'; |
|
256
|
} |
|
257
|
}catch(ex){/*ignore*/} |
|
258
|
if( !err ){ |
|
259
|
/* We can't tell from here whether this was a timeout-capable |
|
260
|
request which timed out on our end or was one which is a |
|
261
|
genuine error. We also don't know whether the server timed |
|
262
|
out the connection before we did. */ |
|
263
|
err = new Error("HTTP response status "+x.status+".") |
|
264
|
err.name = 'http'; |
|
265
|
} |
|
266
|
err.status = x.status; |
|
267
|
opt.onerror(err); |
|
268
|
return; |
|
269
|
} |
|
270
|
const orh = opt.responseHeaders; |
|
271
|
let head; |
|
272
|
if(true===orh){ |
|
273
|
head = f.parseResponseHeaders(x.getAllResponseHeaders()); |
|
274
|
}else if('string'===typeof orh){ |
|
275
|
head = x.getResponseHeader(orh); |
|
276
|
}else if(orh instanceof Array){ |
|
277
|
head = {}; |
|
278
|
orh.forEach((s)=>{ |
|
279
|
if('string' === typeof s) head[s.toLowerCase()] = x.getResponseHeader(s); |
|
280
|
}); |
|
281
|
} |
|
282
|
try{ |
|
283
|
const args = [(jsonResponse && x.response) |
|
284
|
? JSON.parse(x.response) : x.response]; |
|
285
|
if(head) args.push(head); |
|
286
|
opt.onload.apply(opt, args); |
|
287
|
}catch(err){ |
|
288
|
opt.onerror(err); |
|
289
|
} |
|
290
|
}/*onreadystatechange()*/; |
|
291
|
try{opt.beforesend()} |
|
292
|
catch(err){ |
|
293
|
opt.onerror(err); |
|
294
|
return; |
|
295
|
} |
|
296
|
x.open(opt.method||'GET', url.join(''), true); |
|
297
|
if('POST'===opt.method && 'string'===typeof opt.contentType){ |
|
298
|
x.setRequestHeader('Content-Type',opt.contentType); |
|
299
|
} |
|
300
|
x.timeout = +opt.timeout || f.timeout; |
|
301
|
if(undefined!==payload) x.send(payload); |
|
302
|
else x.send(); |
|
303
|
return this; |
|
304
|
}; |
|
305
|
|
|
306
|
/** |
|
307
|
urlTransform() must refer to a function which accepts a relative path |
|
308
|
to the same site as fetch() is served from and an optional set of |
|
309
|
URL parameters to pass with it (in the form a of a string |
|
310
|
("a=b&c=d...") or an object of key/value pairs (which it converts |
|
311
|
to such a string), and returns the resulting URL or URI as a string. |
|
312
|
*/ |
|
313
|
fossil.fetch.urlTransform = (u,p)=>fossil.repoUrl(u,p); |
|
314
|
fossil.fetch.beforesend = function(){}; |
|
315
|
fossil.fetch.aftersend = function(){}; |
|
316
|
fossil.fetch.timeout = 15000/* Default timeout, in ms. */; |
|
317
|
})(window.fossil); |
|
318
|
|