UNPKG

15.6 kBJavaScriptView Raw
1/**
2 * Wrapper for built-in http.js to emulate the browser XMLHttpRequest object.
3 *
4 * This can be used with JS designed for browsers to improve reuse of code and
5 * allow the use of existing libraries.
6 *
7 * Usage: include("XMLHttpRequest.js") and use XMLHttpRequest per W3C specs.
8 *
9 * @author Dan DeFelippi <dan@driverdan.com>
10 * @contributor David Ellis <d.f.ellis@ieee.org>
11 * @license MIT
12 */
13
14var Url = require("url")
15 , spawn = require("child_process").spawn
16 , fs = require('fs');
17
18exports.XMLHttpRequest = function() {
19 /**
20 * Private variables
21 */
22 var self = this;
23 var http = require('http');
24 var https = require('https');
25
26 // Holds http.js objects
27 var client;
28 var request;
29 var response;
30
31 // Request settings
32 var settings = {};
33
34 // Disable header blacklist.
35 // Not part of XHR specs.
36 var disableHeaderCheck = false;
37
38 // Set some default headers
39 var defaultHeaders = {
40 "User-Agent": "node-XMLHttpRequest",
41 "Accept": "*/*",
42 };
43
44 var headers = defaultHeaders;
45
46 // These headers are not user setable.
47 // The following are allowed but banned in the spec:
48 // * user-agent
49 var forbiddenRequestHeaders = [
50 "accept-charset",
51 "accept-encoding",
52 "access-control-request-headers",
53 "access-control-request-method",
54 "connection",
55 "content-length",
56 "content-transfer-encoding",
57 "cookie",
58 "cookie2",
59 "date",
60 "expect",
61 "host",
62 "keep-alive",
63 "origin",
64 "referer",
65 "te",
66 "trailer",
67 "transfer-encoding",
68 "upgrade",
69 "via"
70 ];
71
72 // These request methods are not allowed
73 var forbiddenRequestMethods = [
74 "TRACE",
75 "TRACK",
76 "CONNECT"
77 ];
78
79 // Send flag
80 var sendFlag = false;
81 // Error flag, used when errors occur or abort is called
82 var errorFlag = false;
83
84 // Event listeners
85 var listeners = {};
86
87 /**
88 * Constants
89 */
90
91 this.UNSENT = 0;
92 this.OPENED = 1;
93 this.HEADERS_RECEIVED = 2;
94 this.LOADING = 3;
95 this.DONE = 4;
96
97 /**
98 * Public vars
99 */
100
101 // Current state
102 this.readyState = this.UNSENT;
103
104 // default ready state change handler in case one is not set or is set late
105 this.onreadystatechange = null;
106
107 // Result & response
108 this.responseText = "";
109 this.responseXML = "";
110 this.status = null;
111 this.statusText = null;
112
113 /**
114 * Private methods
115 */
116
117 /**
118 * Check if the specified header is allowed.
119 *
120 * @param string header Header to validate
121 * @return boolean False if not allowed, otherwise true
122 */
123 var isAllowedHttpHeader = function(header) {
124 return disableHeaderCheck || (header && forbiddenRequestHeaders.indexOf(header.toLowerCase()) === -1);
125 };
126
127 /**
128 * Check if the specified method is allowed.
129 *
130 * @param string method Request method to validate
131 * @return boolean False if not allowed, otherwise true
132 */
133 var isAllowedHttpMethod = function(method) {
134 return (method && forbiddenRequestMethods.indexOf(method) === -1);
135 };
136
137 /**
138 * Public methods
139 */
140
141 /**
142 * Open the connection. Currently supports local server requests.
143 *
144 * @param string method Connection method (eg GET, POST)
145 * @param string url URL for the connection.
146 * @param boolean async Asynchronous connection. Default is true.
147 * @param string user Username for basic authentication (optional)
148 * @param string password Password for basic authentication (optional)
149 */
150 this.open = function(method, url, async, user, password) {
151 this.abort();
152 errorFlag = false;
153
154 // Check for valid request method
155 if (!isAllowedHttpMethod(method)) {
156 throw "SecurityError: Request method not allowed";
157 return;
158 }
159
160 settings = {
161 "method": method,
162 "url": url.toString(),
163 "async": (typeof async !== "boolean" ? true : async),
164 "user": user || null,
165 "password": password || null
166 };
167
168 setState(this.OPENED);
169 };
170
171 /**
172 * Disables or enables isAllowedHttpHeader() check the request. Enabled by default.
173 * This does not conform to the W3C spec.
174 *
175 * @param boolean state Enable or disable header checking.
176 */
177 this.setDisableHeaderCheck = function(state) {
178 disableHeaderCheck = state;
179 }
180
181 /**
182 * Sets a header for the request.
183 *
184 * @param string header Header name
185 * @param string value Header value
186 */
187 this.setRequestHeader = function(header, value) {
188 if (this.readyState != this.OPENED) {
189 throw "INVALID_STATE_ERR: setRequestHeader can only be called when state is OPEN";
190 }
191 if (!isAllowedHttpHeader(header)) {
192 console.warn('Refused to set unsafe header "' + header + '"');
193 return;
194 }
195 if (sendFlag) {
196 throw "INVALID_STATE_ERR: send flag is true";
197 }
198 headers[header] = value;
199 };
200
201 /**
202 * Gets a header from the server response.
203 *
204 * @param string header Name of header to get.
205 * @return string Text of the header or null if it doesn't exist.
206 */
207 this.getResponseHeader = function(header) {
208 if (typeof header === "string"
209 && this.readyState > this.OPENED
210 && response.headers[header.toLowerCase()]
211 && !errorFlag
212 ) {
213 return response.headers[header.toLowerCase()];
214 }
215
216 return null;
217 };
218
219 /**
220 * Gets all the response headers.
221 *
222 * @return string A string with all response headers separated by CR+LF
223 */
224 this.getAllResponseHeaders = function() {
225 if (this.readyState < this.HEADERS_RECEIVED || errorFlag) {
226 return "";
227 }
228 var result = "";
229
230 for (var i in response.headers) {
231 // Cookie headers are excluded
232 if (i !== "set-cookie" && i !== "set-cookie2") {
233 result += i + ": " + response.headers[i] + "\r\n";
234 }
235 }
236 return result.substr(0, result.length - 2);
237 };
238
239 /**
240 * Gets all the response headers.
241 *
242 * @return string A string with all response headers separated by CR+LF
243 */
244 this.getAllResponseHeadersList = function() {
245 return response.headers;
246 };
247
248 /**
249 * Gets a request header
250 *
251 * @param string name Name of header to get
252 * @return string Returns the request header or empty string if not set
253 */
254 this.getRequestHeader = function(name) {
255 // @TODO Make this case insensitive
256 if (typeof name === "string" && headers[name]) {
257 return headers[name];
258 }
259
260 return "";
261 }
262
263 /**
264 * Sends the request to the server.
265 *
266 * @param string data Optional data to send as request body.
267 */
268 this.send = function(data) {
269 if (this.readyState != this.OPENED) {
270 throw "INVALID_STATE_ERR: connection must be opened before send() is called";
271 }
272
273 if (sendFlag) {
274 throw "INVALID_STATE_ERR: send has already been called";
275 }
276
277 var ssl = false, local = false;
278 var url = Url.parse(settings.url);
279
280 // Determine the server
281 switch (url.protocol) {
282 case 'https:':
283 ssl = true;
284 // SSL & non-SSL both need host, no break here.
285 case 'http:':
286 var host = url.hostname;
287 break;
288
289 case 'file:':
290 local = true;
291 break;
292
293 case undefined:
294 case '':
295 var host = "localhost";
296 break;
297
298 default:
299 throw "Protocol not supported.";
300 }
301
302 // Load files off the local filesystem (file://)
303 if (local) {
304 if (settings.method !== "GET") {
305 throw "XMLHttpRequest: Only GET method is supported";
306 }
307
308 if (settings.async) {
309 fs.readFile(url.pathname, 'utf8', function(error, data) {
310 if (error) {
311 self.handleError(error);
312 } else {
313 self.status = 200;
314 self.responseText = data;
315 setState(self.DONE);
316 }
317 });
318 } else {
319 try {
320 this.responseText = fs.readFileSync(url.pathname, 'utf8');
321 this.status = 200;
322 setState(self.DONE);
323 } catch(e) {
324 this.handleError(e);
325 }
326 }
327
328 return;
329 }
330
331 // Default to port 80. If accessing localhost on another port be sure
332 // to use http://localhost:port/path
333 var port = url.port || (ssl ? 443 : 80);
334 // Add query string if one is used
335 var uri = url.pathname + (url.search ? url.search : '');
336
337 // Set the Host header or the server may reject the request
338 headers["Host"] = host;
339 if (!((ssl && port === 443) || port === 80)) {
340 headers["Host"] += ':' + url.port;
341 }
342
343 // Set Basic Auth if necessary
344 if (settings.user) {
345 if (typeof settings.password == "undefined") {
346 settings.password = "";
347 }
348 var authBuf = new Buffer(settings.user + ":" + settings.password);
349 headers["Authorization"] = "Basic " + authBuf.toString("base64");
350 }
351
352 // Set content length header
353 if (settings.method === "GET" || settings.method === "HEAD") {
354 data = null;
355 } else if (data) {
356 headers["Content-Length"] = Buffer.byteLength(data);
357
358 if (!headers["Content-Type"]) {
359 headers["Content-Type"] = "text/plain;charset=UTF-8";
360 }
361 } else if (settings.method === "POST") {
362 // For a post with no data set Content-Length: 0.
363 // This is required by buggy servers that don't meet the specs.
364 headers["Content-Length"] = 0;
365 }
366
367 var options = {
368 host: host,
369 port: port,
370 path: uri,
371 method: settings.method,
372 headers: headers,
373 agent: false
374 };
375
376 // Reset error flag
377 errorFlag = false;
378
379 // Handle async requests
380 if (settings.async) {
381 // Use the proper protocol
382 var doRequest = ssl ? https.request : http.request;
383
384 // Request is being sent, set send flag
385 sendFlag = true;
386
387 // As per spec, this is called here for historical reasons.
388 self.dispatchEvent("readystatechange");
389
390 // Create the request
391 request = doRequest(options, function(resp) {
392 response = resp;
393 response.setEncoding("utf8");
394
395 setState(self.HEADERS_RECEIVED);
396 self.status = response.statusCode;
397
398 response.on('data', function(chunk) {
399 // Make sure there's some data
400 if (chunk) {
401 self.responseText += chunk;
402 }
403 // Don't emit state changes if the connection has been aborted.
404 if (sendFlag) {
405 setState(self.LOADING);
406 }
407 });
408
409 response.on('end', function() {
410 if (sendFlag) {
411 // Discard the 'end' event if the connection has been aborted
412 setState(self.DONE);
413 sendFlag = false;
414 }
415 });
416
417 response.on('error', function(error) {
418 self.handleError(error);
419 });
420 }).on('error', function(error) {
421 self.handleError(error);
422 });
423
424 // Node 0.4 and later won't accept empty data. Make sure it's needed.
425 if (data) {
426 request.write(data);
427 }
428
429 request.end();
430
431 self.dispatchEvent("loadstart");
432 } else { // Synchronous
433 // Create a temporary file for communication with the other Node process
434 var syncFile = ".node-xmlhttprequest-sync-" + process.pid;
435 fs.writeFileSync(syncFile, "", "utf8");
436 // The async request the other Node process executes
437 var execString = "var http = require('http'), https = require('https'), fs = require('fs');"
438 + "var doRequest = http" + (ssl ? "s" : "") + ".request;"
439 + "var options = " + JSON.stringify(options) + ";"
440 + "var responseText = '';"
441 + "var req = doRequest(options, function(response) {"
442 + "response.setEncoding('utf8');"
443 + "response.on('data', function(chunk) {"
444 + "responseText += chunk;"
445 + "});"
446 + "response.on('end', function() {"
447 + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-STATUS:' + response.statusCode + ',' + responseText, 'utf8');"
448 + "});"
449 + "response.on('error', function(error) {"
450 + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');"
451 + "});"
452 + "}).on('error', function(error) {"
453 + "fs.writeFileSync('" + syncFile + "', 'NODE-XMLHTTPREQUEST-ERROR:' + JSON.stringify(error), 'utf8');"
454 + "});"
455 + (data ? "req.write('" + data.replace(/'/g, "\\'") + "');":"")
456 + "req.end();";
457 // Start the other Node Process, executing this string
458 syncProc = spawn(process.argv[0], ["-e", execString]);
459 while((self.responseText = fs.readFileSync(syncFile, 'utf8')) == "") {
460 // Wait while the file is empty
461 }
462 // Kill the child process once the file has data
463 syncProc.stdin.end();
464 // Remove the temporary file
465 fs.unlinkSync(syncFile);
466 if (self.responseText.match(/^NODE-XMLHTTPREQUEST-ERROR:/)) {
467 // If the file returned an error, handle it
468 var errorObj = self.responseText.replace(/^NODE-XMLHTTPREQUEST-ERROR:/, "");
469 self.handleError(errorObj);
470 } else {
471 // If the file returned okay, parse its data and move to the DONE state
472 self.status = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:([0-9]*),.*/, "$1");
473 self.responseText = self.responseText.replace(/^NODE-XMLHTTPREQUEST-STATUS:[0-9]*,(.*)/, "$1");
474 setState(self.DONE);
475 }
476 }
477 };
478
479 /**
480 * Called when an error is encountered to deal with it.
481 */
482 this.handleError = function(error) {
483 this.status = 503;
484 this.statusText = error;
485 this.responseText = error.stack;
486 errorFlag = true;
487 setState(this.DONE);
488 };
489
490 /**
491 * Aborts a request.
492 */
493 this.abort = function() {
494 if (request) {
495 request.abort();
496 request = null;
497 }
498
499 headers = defaultHeaders;
500 this.responseText = "";
501 this.responseXML = "";
502
503 errorFlag = true;
504
505 if (this.readyState !== this.UNSENT
506 && (this.readyState !== this.OPENED || sendFlag)
507 && this.readyState !== this.DONE) {
508 sendFlag = false;
509 setState(this.DONE);
510 }
511 this.readyState = this.UNSENT;
512 };
513
514 /**
515 * Adds an event listener. Preferred method of binding to events.
516 */
517 this.addEventListener = function(event, callback) {
518 if (!(event in listeners)) {
519 listeners[event] = [];
520 }
521 // Currently allows duplicate callbacks. Should it?
522 listeners[event].push(callback);
523 };
524
525 /**
526 * Remove an event callback that has already been bound.
527 * Only works on the matching funciton, cannot be a copy.
528 */
529 this.removeEventListener = function(event, callback) {
530 if (event in listeners) {
531 // Filter will return a new array with the callback removed
532 listeners[event] = listeners[event].filter(function(ev) {
533 return ev !== callback;
534 });
535 }
536 };
537
538 /**
539 * Dispatch any events, including both "on" methods and events attached using addEventListener.
540 */
541 this.dispatchEvent = function(event) {
542 if (typeof self["on" + event] === "function") {
543 self["on" + event]();
544 }
545 if (event in listeners) {
546 for (var i = 0, len = listeners[event].length; i < len; i++) {
547 listeners[event][i].call(self);
548 }
549 }
550 };
551
552 /**
553 * Changes readyState and calls onreadystatechange.
554 *
555 * @param int state New state
556 */
557 var setState = function(state) {
558 if (self.readyState !== state) {
559 self.readyState = state;
560
561 if (settings.async || self.readyState < self.OPENED || self.readyState === self.DONE) {
562 self.dispatchEvent("readystatechange");
563 }
564
565 if (self.readyState === self.DONE && !errorFlag) {
566 self.dispatchEvent("load");
567 // @TODO figure out InspectorInstrumentation::didLoadXHR(cookie)
568 self.dispatchEvent("loadend");
569 }
570 }
571 };
572};
\No newline at end of file