Fossil SCM

fossil-scm / src / fossil.fetch.js
Blame History Raw 318 lines
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

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button