@cloudron/superagent 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +20 -0
  2. package/superagent.js +230 -0
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@cloudron/superagent",
3
+ "version": "1.0.0",
4
+ "description": "Superagent Replacement",
5
+ "main": "superagent.js",
6
+ "scripts": {
7
+ "test": "echo \"Error: no test specified\" && exit 1"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "ssh://git@git.cloudron.io:6000/utils/superagent.git"
12
+ },
13
+ "author": "Cloudron",
14
+ "private": false,
15
+ "license": "ISC",
16
+ "dependencies": {
17
+ "debug": "^4.4.1",
18
+ "safetydance": "^2.5.1"
19
+ }
20
+ }
package/superagent.js ADDED
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+
3
+ exports = module.exports = {
4
+ head,
5
+ get,
6
+ put,
7
+ post,
8
+ patch,
9
+ del,
10
+ options,
11
+ request
12
+ };
13
+
14
+ // IMPORTANT: do not require box code here . This is used by migration scripts
15
+ const assert = require('assert'),
16
+ consumers = require('node:stream/consumers'),
17
+ debug = require('debug')('@cloudron/superagent'),
18
+ fs = require('fs'),
19
+ http = require('http'),
20
+ https = require('https'),
21
+ path = require('path'),
22
+ safe = require('safetydance');
23
+
24
+ class Request {
25
+ #boundary;
26
+ #redirectCount;
27
+ #retryCount;
28
+ #timer;
29
+ #body;
30
+ #okFunc;
31
+ #options;
32
+ #url;
33
+ #cookies;
34
+
35
+ constructor(method, url) {
36
+ assert.strictEqual(typeof url, 'string');
37
+
38
+ this.#url = new URL(url);
39
+ this.#options = {
40
+ method,
41
+ headers: {},
42
+ signal: null // set for timeouts
43
+ };
44
+ this.#okFunc = ({ status }) => status >=200 && status <= 299;
45
+ this.#timer = { timeout: 0, id: null, controller: null };
46
+ this.#retryCount = 0;
47
+ this.#body = Buffer.alloc(0);
48
+ this.#redirectCount = 5;
49
+ this.#boundary = null; // multipart only
50
+ this.#cookies = null;
51
+ }
52
+
53
+ async _handleResponse(url, response) {
54
+ // const contentLength = response.headers['content-length'];
55
+ // if (!contentLength || contentLength > 5*1024*1024*1024) throw new Error(`Response size unknown or too large: ${contentLength}`);
56
+ const [consumeError, data] = await safe(consumers.buffer(response)); // have to drain response
57
+ if (consumeError) throw new Error(`Error consuming body stream: ${consumeError.message}`);
58
+ if (!response.complete) throw new Error('Incomplete response');
59
+ const contentType = response.headers['content-type'];
60
+
61
+ const setCookie = response.headers['set-cookie'];
62
+ if (setCookie) this.#options.headers['cookie'] = setCookie.map(cookie => cookie.split(';')[0]).join('; ');
63
+
64
+ const result = {
65
+ url: new URL(response.headers['location'] || '', url),
66
+ status: response.statusCode,
67
+ headers: response.headers,
68
+ body: null,
69
+ text: null
70
+ };
71
+
72
+ if (contentType?.includes('application/json')) {
73
+ result.text = data.toString('utf8');
74
+ if (data.byteLength !== 0) result.body = safe.JSON.parse(result.text) || {};
75
+ } else if (contentType?.includes('application/x-www-form-urlencoded')) {
76
+ result.text = data.toString('utf8');
77
+ const searchParams = new URLSearchParams(data);
78
+ result.body = Object.fromEntries(searchParams.entries());
79
+ } else if (!contentType || contentType.startsWith('text/')) {
80
+ result.body = data;
81
+ result.text = result.body.toString('utf8');
82
+ } else {
83
+ result.body = data;
84
+ result.text = `<binary data (${data.byteLength} bytes)>`;
85
+ }
86
+
87
+ return result;
88
+ }
89
+
90
+ async _makeRequest(url) {
91
+ return new Promise((resolve, reject) => {
92
+ const proto = url.protocol === 'https:' ? https : http;
93
+ const request = proto.request(url, this.#options); // ClientRequest
94
+
95
+ request.on('error', reject); // network error, dns error
96
+ request.on('response', async (response) => {
97
+ const [error, result] = await safe(this._handleResponse(url, response));
98
+ if (error) reject(error); else resolve(result);
99
+ });
100
+
101
+ request.write(this.#body);
102
+
103
+ request.end();
104
+ });
105
+ }
106
+
107
+ async _start() {
108
+ let error;
109
+
110
+ for (let i = 0; i < this.#retryCount+1; i++) {
111
+ if (this.#timer.timeout) this.#timer.id = setTimeout(() => this.#timer.controller.abort(), this.#timer.timeout);
112
+ // debug(`${this.#options.method} ${this.#url.toString()}` + (i ? ` try ${i+1}` : ''));
113
+
114
+ let response, url = this.#url;
115
+ for (let redirects = 0; redirects < this.#redirectCount+1; redirects++) {
116
+ [error, response] = await safe(this._makeRequest(url));
117
+ if (error || (response.status < 300 || response.status > 399) || (this.#options.method !== 'GET')) break;
118
+ url = response.url; // follow
119
+ }
120
+
121
+ if (!error && !this.#okFunc({ status: response.status })) {
122
+ error = new Error(`${response.status} ${http.STATUS_CODES[response.status]}`);
123
+ Object.assign(error, response);
124
+ }
125
+
126
+ if (error) debug(`${this.#options.method} ${this.#url.toString()} ${error.message}`);
127
+ if (this.#timer.timeout) clearTimeout(this.#timer.id);
128
+ if (!error) return response;
129
+ }
130
+
131
+ throw error;
132
+ }
133
+
134
+ set(name, value) {
135
+ this.#options.headers[name.toLowerCase()] = value;
136
+ return this;
137
+ }
138
+
139
+ query(data) {
140
+ Object.entries(data).forEach(([key, value]) => this.#url.searchParams.append(key, value));
141
+ return this;
142
+ }
143
+
144
+ redirects(count) {
145
+ this.#redirectCount = count;
146
+ return this;
147
+ }
148
+
149
+ send(data) {
150
+ const contentType = this.#options.headers['content-type'];
151
+ if (!contentType || contentType === 'application/json') {
152
+ this.#options.headers['content-type'] = 'application/json';
153
+ this.#body = Buffer.from(JSON.stringify(data), 'utf8');
154
+ this.#options.headers['content-length'] = this.#body.byteLength;
155
+ } else if (contentType === 'application/x-www-form-urlencoded') {
156
+ this.#body = Buffer.from((new URLSearchParams(data)).toString(), 'utf8');
157
+ this.#options.headers['content-length'] = this.#body.byteLength;
158
+ }
159
+ return this;
160
+ }
161
+
162
+ timeout(msecs) {
163
+ this.#timer.controller = new AbortController();
164
+ this.#timer.timeout = msecs;
165
+ this.#options.signal = this.#timer.controller.signal;
166
+ return this;
167
+ }
168
+
169
+ retry(count) {
170
+ this.#retryCount = Math.max(0, count);
171
+ return this;
172
+ }
173
+
174
+ ok(func) {
175
+ this.#okFunc = func;
176
+ return this;
177
+ }
178
+
179
+ disableTLSCerts() {
180
+ this.#options.rejectUnauthorized = false;
181
+ return this;
182
+ }
183
+
184
+ auth(username, password) {
185
+ this.set('Authorization', 'Basic ' + btoa(`${username}:${password}`));
186
+ return this;
187
+ }
188
+
189
+ field(name, value) {
190
+ if (!this.#boundary) this.#boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
191
+
192
+ const partHeader = Buffer.from(`--${this.#boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n`, 'utf8');
193
+ const partData = Buffer.from(value, 'utf8');
194
+ this.#body = Buffer.concat([this.#body, partHeader, partData, Buffer.from('\r\n', 'utf8')]);
195
+
196
+ return this;
197
+ }
198
+
199
+ attach(name, filepathOrBuffer) { // this is only used in tests and thus simplistic
200
+ if (!this.#boundary) this.#boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2);
201
+
202
+ const filename = Buffer.isBuffer(filepathOrBuffer) ? name : path.basename(filepathOrBuffer);
203
+ const partHeader = Buffer.from(`--${this.#boundary}\r\nContent-Disposition: form-data; name="${name}" filename="${filename}"\r\n\r\n`, 'utf8');
204
+ const partData = Buffer.isBuffer(filepathOrBuffer) ? filepathOrBuffer : fs.readFileSync(filepathOrBuffer);
205
+ this.#body = Buffer.concat([this.#body, partHeader, partData, Buffer.from('\r\n', 'utf8')]);
206
+
207
+ return this;
208
+ }
209
+
210
+ then(onFulfilled, onRejected) {
211
+ if (this.#boundary) {
212
+ const partTrailer = Buffer.from(`--${this.#boundary}--\r\n`, 'utf8');
213
+ this.#body = Buffer.concat([this.#body, partTrailer]);
214
+
215
+ this.#options.headers['content-type'] = `multipart/form-data; boundary=${this.#boundary}`;
216
+ this.#options.headers['content-length'] = this.#body.byteLength;
217
+ }
218
+
219
+ this._start().then(onFulfilled, onRejected);
220
+ }
221
+ }
222
+
223
+ function head(url) { return new Request('GET', url); }
224
+ function get(url) { return new Request('GET', url); }
225
+ function put(url) { return new Request('PUT', url); }
226
+ function post(url) { return new Request('POST', url); }
227
+ function patch(url) { return new Request('PATCH', url); }
228
+ function del(url) { return new Request('DELETE', url); }
229
+ function options(url) { return new Request('OPTIONS', url); }
230
+ function request(method, url) { return new Request(method, url); }