Implement JQHTML function cache ID system and fix bundle compilation Implement underscore prefix for system tables Fix JS syntax linter to support decorators and grant exception to Task system SPA: Update planning docs and wishlists with remaining features SPA: Document Navigation API abandonment and future enhancements Implement SPA browser integration with History API (Phase 1) Convert contacts view page to SPA action Convert clients pages to SPA actions and document conversion procedure SPA: Merge GET parameters and update documentation Implement SPA route URL generation in JavaScript and PHP Implement SPA bootstrap controller architecture Add SPA routing manual page (rsx:man spa) Add SPA routing documentation to CLAUDE.md Phase 4 Complete: Client-side SPA routing implementation Update get_routes() consumers for unified route structure Complete SPA Phase 3: PHP-side route type detection and is_spa flag Restore unified routes structure and Manifest_Query class Refactor route indexing and add SPA infrastructure Phase 3 Complete: SPA route registration in manifest Implement SPA Phase 2: Extract router code and test decorators Rename Jqhtml_Component to Component and complete SPA foundation setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
495 lines
14 KiB
JavaScript
Executable File
495 lines
14 KiB
JavaScript
Executable File
'use strict';
|
||
|
||
var CombinedStream = require('combined-stream');
|
||
var util = require('util');
|
||
var path = require('path');
|
||
var http = require('http');
|
||
var https = require('https');
|
||
var parseUrl = require('url').parse;
|
||
var fs = require('fs');
|
||
var Stream = require('stream').Stream;
|
||
var crypto = require('crypto');
|
||
var mime = require('mime-types');
|
||
var asynckit = require('asynckit');
|
||
var setToStringTag = require('es-set-tostringtag');
|
||
var hasOwn = require('hasown');
|
||
var populate = require('./populate.js');
|
||
|
||
/**
|
||
* Create readable "multipart/form-data" streams.
|
||
* Can be used to submit forms
|
||
* and file uploads to other web applications.
|
||
*
|
||
* @constructor
|
||
* @param {object} options - Properties to be added/overriden for FormData and CombinedStream
|
||
*/
|
||
function FormData(options) {
|
||
if (!(this instanceof FormData)) {
|
||
return new FormData(options);
|
||
}
|
||
|
||
this._overheadLength = 0;
|
||
this._valueLength = 0;
|
||
this._valuesToMeasure = [];
|
||
|
||
CombinedStream.call(this);
|
||
|
||
options = options || {}; // eslint-disable-line no-param-reassign
|
||
for (var option in options) { // eslint-disable-line no-restricted-syntax
|
||
this[option] = options[option];
|
||
}
|
||
}
|
||
|
||
// make it a Stream
|
||
util.inherits(FormData, CombinedStream);
|
||
|
||
FormData.LINE_BREAK = '\r\n';
|
||
FormData.DEFAULT_CONTENT_TYPE = 'application/octet-stream';
|
||
|
||
FormData.prototype.append = function (field, value, options) {
|
||
options = options || {}; // eslint-disable-line no-param-reassign
|
||
|
||
// allow filename as single option
|
||
if (typeof options === 'string') {
|
||
options = { filename: options }; // eslint-disable-line no-param-reassign
|
||
}
|
||
|
||
var append = CombinedStream.prototype.append.bind(this);
|
||
|
||
// all that streamy business can't handle numbers
|
||
if (typeof value === 'number' || value == null) {
|
||
value = String(value); // eslint-disable-line no-param-reassign
|
||
}
|
||
|
||
// https://github.com/felixge/node-form-data/issues/38
|
||
if (Array.isArray(value)) {
|
||
/*
|
||
* Please convert your array into string
|
||
* the way web server expects it
|
||
*/
|
||
this._error(new Error('Arrays are not supported.'));
|
||
return;
|
||
}
|
||
|
||
var header = this._multiPartHeader(field, value, options);
|
||
var footer = this._multiPartFooter();
|
||
|
||
append(header);
|
||
append(value);
|
||
append(footer);
|
||
|
||
// pass along options.knownLength
|
||
this._trackLength(header, value, options);
|
||
};
|
||
|
||
FormData.prototype._trackLength = function (header, value, options) {
|
||
var valueLength = 0;
|
||
|
||
/*
|
||
* used w/ getLengthSync(), when length is known.
|
||
* e.g. for streaming directly from a remote server,
|
||
* w/ a known file a size, and not wanting to wait for
|
||
* incoming file to finish to get its size.
|
||
*/
|
||
if (options.knownLength != null) {
|
||
valueLength += Number(options.knownLength);
|
||
} else if (Buffer.isBuffer(value)) {
|
||
valueLength = value.length;
|
||
} else if (typeof value === 'string') {
|
||
valueLength = Buffer.byteLength(value);
|
||
}
|
||
|
||
this._valueLength += valueLength;
|
||
|
||
// @check why add CRLF? does this account for custom/multiple CRLFs?
|
||
this._overheadLength += Buffer.byteLength(header) + FormData.LINE_BREAK.length;
|
||
|
||
// empty or either doesn't have path or not an http response or not a stream
|
||
if (!value || (!value.path && !(value.readable && hasOwn(value, 'httpVersion')) && !(value instanceof Stream))) {
|
||
return;
|
||
}
|
||
|
||
// no need to bother with the length
|
||
if (!options.knownLength) {
|
||
this._valuesToMeasure.push(value);
|
||
}
|
||
};
|
||
|
||
FormData.prototype._lengthRetriever = function (value, callback) {
|
||
if (hasOwn(value, 'fd')) {
|
||
// take read range into a account
|
||
// `end` = Infinity –> read file till the end
|
||
//
|
||
// TODO: Looks like there is bug in Node fs.createReadStream
|
||
// it doesn't respect `end` options without `start` options
|
||
// Fix it when node fixes it.
|
||
// https://github.com/joyent/node/issues/7819
|
||
if (value.end != undefined && value.end != Infinity && value.start != undefined) {
|
||
// when end specified
|
||
// no need to calculate range
|
||
// inclusive, starts with 0
|
||
callback(null, value.end + 1 - (value.start ? value.start : 0)); // eslint-disable-line callback-return
|
||
|
||
// not that fast snoopy
|
||
} else {
|
||
// still need to fetch file size from fs
|
||
fs.stat(value.path, function (err, stat) {
|
||
if (err) {
|
||
callback(err);
|
||
return;
|
||
}
|
||
|
||
// update final size based on the range options
|
||
var fileSize = stat.size - (value.start ? value.start : 0);
|
||
callback(null, fileSize);
|
||
});
|
||
}
|
||
|
||
// or http response
|
||
} else if (hasOwn(value, 'httpVersion')) {
|
||
callback(null, Number(value.headers['content-length'])); // eslint-disable-line callback-return
|
||
|
||
// or request stream http://github.com/mikeal/request
|
||
} else if (hasOwn(value, 'httpModule')) {
|
||
// wait till response come back
|
||
value.on('response', function (response) {
|
||
value.pause();
|
||
callback(null, Number(response.headers['content-length']));
|
||
});
|
||
value.resume();
|
||
|
||
// something else
|
||
} else {
|
||
callback('Unknown stream'); // eslint-disable-line callback-return
|
||
}
|
||
};
|
||
|
||
FormData.prototype._multiPartHeader = function (field, value, options) {
|
||
/*
|
||
* custom header specified (as string)?
|
||
* it becomes responsible for boundary
|
||
* (e.g. to handle extra CRLFs on .NET servers)
|
||
*/
|
||
if (typeof options.header === 'string') {
|
||
return options.header;
|
||
}
|
||
|
||
var contentDisposition = this._getContentDisposition(value, options);
|
||
var contentType = this._getContentType(value, options);
|
||
|
||
var contents = '';
|
||
var headers = {
|
||
// add custom disposition as third element or keep it two elements if not
|
||
'Content-Disposition': ['form-data', 'name="' + field + '"'].concat(contentDisposition || []),
|
||
// if no content type. allow it to be empty array
|
||
'Content-Type': [].concat(contentType || [])
|
||
};
|
||
|
||
// allow custom headers.
|
||
if (typeof options.header === 'object') {
|
||
populate(headers, options.header);
|
||
}
|
||
|
||
var header;
|
||
for (var prop in headers) { // eslint-disable-line no-restricted-syntax
|
||
if (hasOwn(headers, prop)) {
|
||
header = headers[prop];
|
||
|
||
// skip nullish headers.
|
||
if (header == null) {
|
||
continue; // eslint-disable-line no-restricted-syntax, no-continue
|
||
}
|
||
|
||
// convert all headers to arrays.
|
||
if (!Array.isArray(header)) {
|
||
header = [header];
|
||
}
|
||
|
||
// add non-empty headers.
|
||
if (header.length) {
|
||
contents += prop + ': ' + header.join('; ') + FormData.LINE_BREAK;
|
||
}
|
||
}
|
||
}
|
||
|
||
return '--' + this.getBoundary() + FormData.LINE_BREAK + contents + FormData.LINE_BREAK;
|
||
};
|
||
|
||
FormData.prototype._getContentDisposition = function (value, options) { // eslint-disable-line consistent-return
|
||
var filename;
|
||
|
||
if (typeof options.filepath === 'string') {
|
||
// custom filepath for relative paths
|
||
filename = path.normalize(options.filepath).replace(/\\/g, '/');
|
||
} else if (options.filename || (value && (value.name || value.path))) {
|
||
/*
|
||
* custom filename take precedence
|
||
* formidable and the browser add a name property
|
||
* fs- and request- streams have path property
|
||
*/
|
||
filename = path.basename(options.filename || (value && (value.name || value.path)));
|
||
} else if (value && value.readable && hasOwn(value, 'httpVersion')) {
|
||
// or try http response
|
||
filename = path.basename(value.client._httpMessage.path || '');
|
||
}
|
||
|
||
if (filename) {
|
||
return 'filename="' + filename + '"';
|
||
}
|
||
};
|
||
|
||
FormData.prototype._getContentType = function (value, options) {
|
||
// use custom content-type above all
|
||
var contentType = options.contentType;
|
||
|
||
// or try `name` from formidable, browser
|
||
if (!contentType && value && value.name) {
|
||
contentType = mime.lookup(value.name);
|
||
}
|
||
|
||
// or try `path` from fs-, request- streams
|
||
if (!contentType && value && value.path) {
|
||
contentType = mime.lookup(value.path);
|
||
}
|
||
|
||
// or if it's http-reponse
|
||
if (!contentType && value && value.readable && hasOwn(value, 'httpVersion')) {
|
||
contentType = value.headers['content-type'];
|
||
}
|
||
|
||
// or guess it from the filepath or filename
|
||
if (!contentType && (options.filepath || options.filename)) {
|
||
contentType = mime.lookup(options.filepath || options.filename);
|
||
}
|
||
|
||
// fallback to the default content type if `value` is not simple value
|
||
if (!contentType && value && typeof value === 'object') {
|
||
contentType = FormData.DEFAULT_CONTENT_TYPE;
|
||
}
|
||
|
||
return contentType;
|
||
};
|
||
|
||
FormData.prototype._multiPartFooter = function () {
|
||
return function (next) {
|
||
var footer = FormData.LINE_BREAK;
|
||
|
||
var lastPart = this._streams.length === 0;
|
||
if (lastPart) {
|
||
footer += this._lastBoundary();
|
||
}
|
||
|
||
next(footer);
|
||
}.bind(this);
|
||
};
|
||
|
||
FormData.prototype._lastBoundary = function () {
|
||
return '--' + this.getBoundary() + '--' + FormData.LINE_BREAK;
|
||
};
|
||
|
||
FormData.prototype.getHeaders = function (userHeaders) {
|
||
var header;
|
||
var formHeaders = {
|
||
'content-type': 'multipart/form-data; boundary=' + this.getBoundary()
|
||
};
|
||
|
||
for (header in userHeaders) { // eslint-disable-line no-restricted-syntax
|
||
if (hasOwn(userHeaders, header)) {
|
||
formHeaders[header.toLowerCase()] = userHeaders[header];
|
||
}
|
||
}
|
||
|
||
return formHeaders;
|
||
};
|
||
|
||
FormData.prototype.setBoundary = function (boundary) {
|
||
if (typeof boundary !== 'string') {
|
||
throw new TypeError('FormData boundary must be a string');
|
||
}
|
||
this._boundary = boundary;
|
||
};
|
||
|
||
FormData.prototype.getBoundary = function () {
|
||
if (!this._boundary) {
|
||
this._generateBoundary();
|
||
}
|
||
|
||
return this._boundary;
|
||
};
|
||
|
||
FormData.prototype.getBuffer = function () {
|
||
var dataBuffer = new Buffer.alloc(0); // eslint-disable-line new-cap
|
||
var boundary = this.getBoundary();
|
||
|
||
// Create the form content. Add Line breaks to the end of data.
|
||
for (var i = 0, len = this._streams.length; i < len; i++) {
|
||
if (typeof this._streams[i] !== 'function') {
|
||
// Add content to the buffer.
|
||
if (Buffer.isBuffer(this._streams[i])) {
|
||
dataBuffer = Buffer.concat([dataBuffer, this._streams[i]]);
|
||
} else {
|
||
dataBuffer = Buffer.concat([dataBuffer, Buffer.from(this._streams[i])]);
|
||
}
|
||
|
||
// Add break after content.
|
||
if (typeof this._streams[i] !== 'string' || this._streams[i].substring(2, boundary.length + 2) !== boundary) {
|
||
dataBuffer = Buffer.concat([dataBuffer, Buffer.from(FormData.LINE_BREAK)]);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add the footer and return the Buffer object.
|
||
return Buffer.concat([dataBuffer, Buffer.from(this._lastBoundary())]);
|
||
};
|
||
|
||
FormData.prototype._generateBoundary = function () {
|
||
// This generates a 50 character boundary similar to those used by Firefox.
|
||
|
||
// They are optimized for boyer-moore parsing.
|
||
this._boundary = '--------------------------' + crypto.randomBytes(12).toString('hex');
|
||
};
|
||
|
||
// Note: getLengthSync DOESN'T calculate streams length
|
||
// As workaround one can calculate file size manually and add it as knownLength option
|
||
FormData.prototype.getLengthSync = function () {
|
||
var knownLength = this._overheadLength + this._valueLength;
|
||
|
||
// Don't get confused, there are 3 "internal" streams for each keyval pair so it basically checks if there is any value added to the form
|
||
if (this._streams.length) {
|
||
knownLength += this._lastBoundary().length;
|
||
}
|
||
|
||
// https://github.com/form-data/form-data/issues/40
|
||
if (!this.hasKnownLength()) {
|
||
/*
|
||
* Some async length retrievers are present
|
||
* therefore synchronous length calculation is false.
|
||
* Please use getLength(callback) to get proper length
|
||
*/
|
||
this._error(new Error('Cannot calculate proper length in synchronous way.'));
|
||
}
|
||
|
||
return knownLength;
|
||
};
|
||
|
||
// Public API to check if length of added values is known
|
||
// https://github.com/form-data/form-data/issues/196
|
||
// https://github.com/form-data/form-data/issues/262
|
||
FormData.prototype.hasKnownLength = function () {
|
||
var hasKnownLength = true;
|
||
|
||
if (this._valuesToMeasure.length) {
|
||
hasKnownLength = false;
|
||
}
|
||
|
||
return hasKnownLength;
|
||
};
|
||
|
||
FormData.prototype.getLength = function (cb) {
|
||
var knownLength = this._overheadLength + this._valueLength;
|
||
|
||
if (this._streams.length) {
|
||
knownLength += this._lastBoundary().length;
|
||
}
|
||
|
||
if (!this._valuesToMeasure.length) {
|
||
process.nextTick(cb.bind(this, null, knownLength));
|
||
return;
|
||
}
|
||
|
||
asynckit.parallel(this._valuesToMeasure, this._lengthRetriever, function (err, values) {
|
||
if (err) {
|
||
cb(err);
|
||
return;
|
||
}
|
||
|
||
values.forEach(function (length) {
|
||
knownLength += length;
|
||
});
|
||
|
||
cb(null, knownLength);
|
||
});
|
||
};
|
||
|
||
FormData.prototype.submit = function (params, cb) {
|
||
var request;
|
||
var options;
|
||
var defaults = { method: 'post' };
|
||
|
||
// parse provided url if it's string or treat it as options object
|
||
if (typeof params === 'string') {
|
||
params = parseUrl(params); // eslint-disable-line no-param-reassign
|
||
/* eslint sort-keys: 0 */
|
||
options = populate({
|
||
port: params.port,
|
||
path: params.pathname,
|
||
host: params.hostname,
|
||
protocol: params.protocol
|
||
}, defaults);
|
||
} else { // use custom params
|
||
options = populate(params, defaults);
|
||
// if no port provided use default one
|
||
if (!options.port) {
|
||
options.port = options.protocol === 'https:' ? 443 : 80;
|
||
}
|
||
}
|
||
|
||
// put that good code in getHeaders to some use
|
||
options.headers = this.getHeaders(params.headers);
|
||
|
||
// https if specified, fallback to http in any other case
|
||
if (options.protocol === 'https:') {
|
||
request = https.request(options);
|
||
} else {
|
||
request = http.request(options);
|
||
}
|
||
|
||
// get content length and fire away
|
||
this.getLength(function (err, length) {
|
||
if (err && err !== 'Unknown stream') {
|
||
this._error(err);
|
||
return;
|
||
}
|
||
|
||
// add content length
|
||
if (length) {
|
||
request.setHeader('Content-Length', length);
|
||
}
|
||
|
||
this.pipe(request);
|
||
if (cb) {
|
||
var onResponse;
|
||
|
||
var callback = function (error, responce) {
|
||
request.removeListener('error', callback);
|
||
request.removeListener('response', onResponse);
|
||
|
||
return cb.call(this, error, responce);
|
||
};
|
||
|
||
onResponse = callback.bind(this, null);
|
||
|
||
request.on('error', callback);
|
||
request.on('response', onResponse);
|
||
}
|
||
}.bind(this));
|
||
|
||
return request;
|
||
};
|
||
|
||
FormData.prototype._error = function (err) {
|
||
if (!this.error) {
|
||
this.error = err;
|
||
this.pause();
|
||
this.emit('error', err);
|
||
}
|
||
};
|
||
|
||
FormData.prototype.toString = function () {
|
||
return '[object FormData]';
|
||
};
|
||
setToStringTag(FormData.prototype, 'FormData');
|
||
|
||
// Public API
|
||
module.exports = FormData;
|