@amxts/core 0.1.0 → 0.2.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.
- package/README.md +18 -23
- package/README.ru.md +19 -22
- package/as/amxts.d.ts +63 -3
- package/as/constants.ts +684 -116
- package/as/entities.ts +1905 -1364
- package/as/events.ts +1694 -905
- package/as/facade.ts +1240 -220
- package/as/fetch.ts +1519 -0
- package/as/flags.ts +159 -145
- package/as/hlds.ts +1071 -0
- package/as/hooks.ts +5226 -5379
- package/as/kit.ts +15 -5
- package/as/lib/check.ts +1 -1
- package/as/natives.ts +2658 -2499
- package/as/remote.ts +39 -15
- package/as/tsconfig.json +6 -2
- package/as/vector.ts +12 -9
- package/includes/order.txt +22 -29
- package/includes/sources.json +0 -8
- package/node_modules/assemblyscript/std/assembly/index.d.ts +20 -11
- package/package.json +29 -7
- package/runtime/deps/assemblyscript/dist/asc.js +86 -16
- package/runtime/deps/assemblyscript/dist/assemblyscript.js +29 -29
- package/runtime/licenses/MPL-2.0.txt +373 -0
- package/runtime/licenses/README.md +5 -1
- package/runtime/licenses/curl-COPYING +22 -0
- package/runtime/licenses/libssh2-COPYING +43 -0
- package/runtime/licenses/mbedTLS-LICENSE +553 -0
- package/runtime/natives.txt +108 -105
- package/runtime/patches/assemblyscript-0.28.20-amxts.patch +752 -296
- package/runtime/patches/wamr-2.4.5-amxts.patch +148 -0
- package/scripts/apply-docs.ts +2 -2
- package/scripts/auto-imports.ts +2 -2
- package/scripts/build-identity.ts +115 -0
- package/scripts/build-wasm.ts +101 -50
- package/scripts/check.ts +17 -3
- package/scripts/client-messages.ts +246 -0
- package/scripts/compile-pool.ts +128 -0
- package/scripts/compile-worker.ts +23 -0
- package/scripts/compile.ts +156 -59
- package/scripts/docker-server.ts +13 -3
- package/scripts/docs/as/amxts.ts +86 -0
- package/scripts/docs/as/facade.ts +688 -188
- package/scripts/docs/as/fetch.ts +805 -0
- package/scripts/docs/entities.ts +43 -144
- package/scripts/docs/events.ts +15 -26
- package/scripts/docs/game.ts +68 -22
- package/scripts/docs/messages.ts +330 -16
- package/scripts/editor-api.ts +101 -0
- package/scripts/hlds-events.ts +370 -0
- package/scripts/includes.ts +12 -10
- package/scripts/player-fields.ts +16 -15
- package/scripts/prebuilt.ts +65 -23
- package/scripts/prepare.ts +39 -28
- package/scripts/project.ts +120 -20
- package/scripts/reapi-events.ts +98 -0
- package/scripts/shared-modules.ts +85 -23
- package/scripts/source-map.ts +242 -0
- package/scripts/system.ts +1 -1
- package/scripts/typed-commands.ts +263 -0
- package/scripts/typed-configs.ts +23 -12
- package/scripts/ui.ts +3 -1
- package/scripts/upgrade-menus.ts +274 -0
- package/scripts/upgrade-names.ts +384 -0
- package/scripts/upgrade.ts +830 -0
- package/src/cli-api.mjs +37 -4
- package/src/system.mjs +74 -3
- package/src/testing/compile.ts +8 -5
- package/src/testing/natives.ts +199 -5
- package/src/testing/network.ts +531 -0
- package/src/testing/server.ts +565 -47
- package/src/testing/tables.ts +25 -0
- package/as/modules/http.ts +0 -184
- package/scripts/docs/as/modules/http.ts +0 -66
package/as/fetch.ts
ADDED
|
@@ -0,0 +1,1519 @@
|
|
|
1
|
+
// fetch, URL and useFetch: HTTP the browser's way, over the module's network
|
|
2
|
+
// client - libcurl on a worker thread of the module, so a request never holds
|
|
3
|
+
// the game up. The module hands each ended request back on a server frame
|
|
4
|
+
// (runtime/src/network.h), where __netDone settles its promise.
|
|
5
|
+
//
|
|
6
|
+
// const { data, error } = await useFetch<Weather>("https://example.com/weather", { query: { city } });
|
|
7
|
+
// const response = await fetch("https://example.com/", { method: "POST", body: "hi" });
|
|
8
|
+
//
|
|
9
|
+
// The facade exports all of it, so a plugin uses these names without an
|
|
10
|
+
// import, as globals - all but `request`, the one path every request takes:
|
|
11
|
+
// the network client as it is, for a module that speaks FTP, FTPS or SFTP
|
|
12
|
+
// (the kit gives it; scripts/auto-imports.ts keeps it out of the globals).
|
|
13
|
+
//
|
|
14
|
+
// AssemblyScript has no unions of classes, so where the browser takes one of
|
|
15
|
+
// several kinds the hood picks by the type at compile time (`fetch(input)`:
|
|
16
|
+
// a string, a URL or a Request) or takes the one an author writes most
|
|
17
|
+
// (`headers` as an object of names and values, a body as text).
|
|
18
|
+
|
|
19
|
+
import "./promise";
|
|
20
|
+
|
|
21
|
+
// @ts-ignore: decorator
|
|
22
|
+
@external("env", "net_open") declare function _open(url: string): i32;
|
|
23
|
+
// @ts-ignore: decorator
|
|
24
|
+
@external("env", "net_option") declare function _option(id: i32, name: string, value: string): i32;
|
|
25
|
+
// @ts-ignore: decorator
|
|
26
|
+
@external("env", "net_body") declare function _body(id: i32, data: usize, length: i32): void;
|
|
27
|
+
// @ts-ignore: decorator
|
|
28
|
+
@external("env", "net_send") declare function _send(id: i32, fn: i32): i32;
|
|
29
|
+
// @ts-ignore: decorator
|
|
30
|
+
@external("env", "net_cancel") declare function _cancel(id: i32): void;
|
|
31
|
+
// @ts-ignore: decorator
|
|
32
|
+
@external("env", "net_close") declare function _close(id: i32): void;
|
|
33
|
+
// @ts-ignore: decorator
|
|
34
|
+
@external("env", "net_status") declare function _status(id: i32): i32;
|
|
35
|
+
// @ts-ignore: decorator
|
|
36
|
+
@external("env", "net_redirects") declare function _redirects(id: i32): i32;
|
|
37
|
+
// @ts-ignore: decorator
|
|
38
|
+
@external("env", "net_text") declare function _text(id: i32, what: i32, out: usize, max: i32): i32;
|
|
39
|
+
// @ts-ignore: decorator
|
|
40
|
+
@external("env", "net_size") declare function _size(id: i32): i32;
|
|
41
|
+
// @ts-ignore: decorator
|
|
42
|
+
@external("env", "net_read") declare function _read(id: i32, out: usize, max: i32): i32;
|
|
43
|
+
// @ts-ignore: decorator
|
|
44
|
+
@external("env", "net_reply") declare function _reply(id: i32): i32;
|
|
45
|
+
|
|
46
|
+
// What net_text reads. Must match NET_TEXT_* in runtime/src/network.h.
|
|
47
|
+
const TEXT_ERROR: i32 = 0;
|
|
48
|
+
const TEXT_STATUS: i32 = 1;
|
|
49
|
+
const TEXT_URL: i32 = 2;
|
|
50
|
+
const TEXT_HEADERS: i32 = 3;
|
|
51
|
+
const TEXT_KIND: i32 = 4;
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- URL
|
|
54
|
+
|
|
55
|
+
/** The schemes whose URLs have a host and a path of segments, with their default ports. */
|
|
56
|
+
function defaultPort(scheme: string): string {
|
|
57
|
+
if (scheme == "http:" || scheme == "ws:") return "80";
|
|
58
|
+
if (scheme == "https:" || scheme == "wss:") return "443";
|
|
59
|
+
if (scheme == "ftp:") return "21";
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isSpecial(scheme: string): bool {
|
|
64
|
+
return scheme == "http:" || scheme == "https:" || scheme == "ws:" || scheme == "wss:" || scheme == "ftp:" || scheme == "file:";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isAlpha(c: i32): bool {
|
|
68
|
+
return (c >= 0x61 && c <= 0x7A) || (c >= 0x41 && c <= 0x5A);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function isDigit(c: i32): bool {
|
|
72
|
+
return c >= 0x30 && c <= 0x39;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const HEX = "0123456789ABCDEF";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Percent-encodes the bytes of `text` that `keep` does not let through as
|
|
79
|
+
* they are; a `%` already followed by two hex digits stays.
|
|
80
|
+
*/
|
|
81
|
+
function encodeSet(text: string, keep: (c: i32) => bool): string {
|
|
82
|
+
const bytes = Uint8Array.wrap(String.UTF8.encode(text));
|
|
83
|
+
let out = "";
|
|
84
|
+
for (let i: i32 = 0; i < bytes.length; i++) {
|
|
85
|
+
const c = <i32>bytes[i];
|
|
86
|
+
if (c < 0x80 && keep(c)) out += String.fromCharCode(c);
|
|
87
|
+
else out += "%" + HEX.charAt(c >> 4) + HEX.charAt(c & 15);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// What a path, a query and a fragment keep: the URL standard's encode sets.
|
|
93
|
+
function keepPath(c: i32): bool {
|
|
94
|
+
return c > 0x20 && c != 0x22 && c != 0x23 && c != 0x3C && c != 0x3E && c != 0x3F && c != 0x60 && c != 0x7B && c != 0x7D && c < 0x7F;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function keepQuery(c: i32): bool {
|
|
98
|
+
return c > 0x20 && c != 0x22 && c != 0x23 && c != 0x3C && c != 0x3E && c < 0x7F;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function keepFragment(c: i32): bool {
|
|
102
|
+
return c > 0x20 && c != 0x22 && c != 0x3C && c != 0x3E && c != 0x60 && c < 0x7F;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function keepUserinfo(c: i32): bool {
|
|
106
|
+
return keepPath(c) && c != 0x2F && c != 0x3A && c != 0x3B && c != 0x3D && c != 0x40 && c != 0x5B && c != 0x5C && c != 0x5D && c != 0x5E && c != 0x7C;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// application/x-www-form-urlencoded: letters, digits and *-._ as they are.
|
|
110
|
+
function keepForm(c: i32): bool {
|
|
111
|
+
return isAlpha(c) || isDigit(c) || c == 0x2A || c == 0x2D || c == 0x2E || c == 0x5F;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hexValue(c: i32): i32 {
|
|
115
|
+
if (isDigit(c)) return c - 0x30;
|
|
116
|
+
if (c >= 0x41 && c <= 0x46) return c - 0x41 + 10;
|
|
117
|
+
if (c >= 0x61 && c <= 0x66) return c - 0x61 + 10;
|
|
118
|
+
return -1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Text with its `%XX` turned back into bytes - and `+` into a space, in a form. A stray `%` stays. */
|
|
122
|
+
function decodeText(text: string, form: bool): string {
|
|
123
|
+
const bytes = Uint8Array.wrap(String.UTF8.encode(text));
|
|
124
|
+
const out = new Uint8Array(bytes.length);
|
|
125
|
+
let n: i32 = 0;
|
|
126
|
+
for (let i: i32 = 0; i < bytes.length; i++) {
|
|
127
|
+
const c = <i32>bytes[i];
|
|
128
|
+
if (c == 0x25 && i + 2 < bytes.length && hexValue(bytes[i + 1]) >= 0 && hexValue(bytes[i + 2]) >= 0) {
|
|
129
|
+
out[n++] = <u8>(hexValue(bytes[i + 1]) * 16 + hexValue(bytes[i + 2]));
|
|
130
|
+
i += 2;
|
|
131
|
+
}
|
|
132
|
+
else out[n++] = form && c == 0x2B ? 0x20 : <u8>c;
|
|
133
|
+
}
|
|
134
|
+
return String.UTF8.decodeUnsafe(out.dataStart, <usize>n);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `/a/./b/../c` as `/a/c`: the dots of a special URL's path resolved. */
|
|
138
|
+
function normalizePath(path: string): string {
|
|
139
|
+
const segments = path.split("/");
|
|
140
|
+
const out: string[] = [];
|
|
141
|
+
for (let i: i32 = 1; i < segments.length; i++) {
|
|
142
|
+
const segment = segments[i];
|
|
143
|
+
const last = i == segments.length - 1;
|
|
144
|
+
if (segment == "..") {
|
|
145
|
+
if (out.length > 0) out.pop();
|
|
146
|
+
if (last) out.push("");
|
|
147
|
+
}
|
|
148
|
+
else if (segment == ".") {
|
|
149
|
+
if (last) out.push("");
|
|
150
|
+
}
|
|
151
|
+
else out.push(segment);
|
|
152
|
+
}
|
|
153
|
+
return "/" + out.join("/");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A URL's parts, as URL keeps them. */
|
|
157
|
+
class __UrlParts {
|
|
158
|
+
protocol: string = "";
|
|
159
|
+
username: string = "";
|
|
160
|
+
password: string = "";
|
|
161
|
+
hostname: string = "";
|
|
162
|
+
port: string = "";
|
|
163
|
+
pathname: string = "";
|
|
164
|
+
search: string = "";
|
|
165
|
+
hash: string = "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The scheme at the start of `input` with its colon (`"https:"`), or `""` when there is none. */
|
|
169
|
+
function schemeOf(input: string): string {
|
|
170
|
+
const colon = input.indexOf(":");
|
|
171
|
+
if (colon <= 0 || !isAlpha(input.charCodeAt(0))) return "";
|
|
172
|
+
for (let i: i32 = 1; i < colon; i++) {
|
|
173
|
+
const c = input.charCodeAt(i);
|
|
174
|
+
if (!isAlpha(c) && !isDigit(c) && c != 0x2B && c != 0x2D && c != 0x2E) return "";
|
|
175
|
+
}
|
|
176
|
+
return input.substring(0, colon + 1).toLowerCase();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** An absolute URL taken apart, or null when it is not one. */
|
|
180
|
+
function parseAbsolute(input: string): __UrlParts | null {
|
|
181
|
+
const scheme = schemeOf(input);
|
|
182
|
+
if (scheme.length == 0) return null;
|
|
183
|
+
const parts = new __UrlParts();
|
|
184
|
+
parts.protocol = scheme;
|
|
185
|
+
let rest = input.substring(scheme.length);
|
|
186
|
+
|
|
187
|
+
let hashAt = rest.indexOf("#");
|
|
188
|
+
if (hashAt >= 0) {
|
|
189
|
+
parts.hash = "#" + encodeSet(rest.substring(hashAt + 1), keepFragment);
|
|
190
|
+
rest = rest.substring(0, hashAt);
|
|
191
|
+
}
|
|
192
|
+
if (parts.hash == "#") parts.hash = "";
|
|
193
|
+
|
|
194
|
+
const special = isSpecial(scheme);
|
|
195
|
+
if (special) rest = rest.replaceAll("\\", "/");
|
|
196
|
+
|
|
197
|
+
const queryAt = rest.indexOf("?");
|
|
198
|
+
if (queryAt >= 0) {
|
|
199
|
+
parts.search = "?" + encodeSet(rest.substring(queryAt + 1), keepQuery);
|
|
200
|
+
rest = rest.substring(0, queryAt);
|
|
201
|
+
}
|
|
202
|
+
if (parts.search == "?") parts.search = "";
|
|
203
|
+
|
|
204
|
+
if (!rest.startsWith("//")) {
|
|
205
|
+
// mailto:, data: and their kind: a path and nothing more.
|
|
206
|
+
if (special) return null;
|
|
207
|
+
parts.pathname = encodeSet(rest, keepQuery);
|
|
208
|
+
return parts;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
rest = rest.substring(2);
|
|
212
|
+
let slash = rest.indexOf("/");
|
|
213
|
+
if (slash < 0) slash = rest.length;
|
|
214
|
+
let authority = rest.substring(0, slash);
|
|
215
|
+
const path = rest.substring(slash);
|
|
216
|
+
|
|
217
|
+
const at = authority.lastIndexOf("@");
|
|
218
|
+
if (at >= 0) {
|
|
219
|
+
const userinfo = authority.substring(0, at);
|
|
220
|
+
authority = authority.substring(at + 1);
|
|
221
|
+
const colon = userinfo.indexOf(":");
|
|
222
|
+
parts.username = encodeSet(colon >= 0 ? userinfo.substring(0, colon) : userinfo, keepUserinfo);
|
|
223
|
+
parts.password = colon >= 0 ? encodeSet(userinfo.substring(colon + 1), keepUserinfo) : "";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let host = authority;
|
|
227
|
+
let port = "";
|
|
228
|
+
const close = authority.lastIndexOf("]");
|
|
229
|
+
const colon = authority.lastIndexOf(":");
|
|
230
|
+
if (colon > close) {
|
|
231
|
+
host = authority.substring(0, colon);
|
|
232
|
+
port = authority.substring(colon + 1);
|
|
233
|
+
}
|
|
234
|
+
for (let i: i32 = 0; i < port.length; i++) {
|
|
235
|
+
if (!isDigit(port.charCodeAt(i))) return null;
|
|
236
|
+
}
|
|
237
|
+
if (port.length > 0) {
|
|
238
|
+
const value = <i32>parseInt(port);
|
|
239
|
+
if (value > 65535) return null;
|
|
240
|
+
port = value.toString();
|
|
241
|
+
}
|
|
242
|
+
if (port == defaultPort(scheme)) port = "";
|
|
243
|
+
|
|
244
|
+
host = decodeText(host, false).toLowerCase();
|
|
245
|
+
for (let i: i32 = 0; i < host.length; i++) {
|
|
246
|
+
const c = host.charCodeAt(i);
|
|
247
|
+
if (c <= 0x20 || c == 0x23 || c == 0x25 || c == 0x2F || c == 0x3C || c == 0x3E || c == 0x3F || c == 0x40 || c == 0x5C || c == 0x5E || c == 0x7C) return null;
|
|
248
|
+
}
|
|
249
|
+
if (special && scheme != "file:" && host.length == 0) return null;
|
|
250
|
+
parts.hostname = host;
|
|
251
|
+
parts.port = port;
|
|
252
|
+
|
|
253
|
+
const encoded = encodeSet(path, keepPath);
|
|
254
|
+
parts.pathname = special ? normalizePath(encoded.length > 0 ? encoded : "/") : encoded;
|
|
255
|
+
return parts;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** `input` resolved against `base`, as an absolute URL's text. */
|
|
259
|
+
function resolveAgainst(input: string, base: URL): string {
|
|
260
|
+
if (input.startsWith("//")) return base.protocol + input;
|
|
261
|
+
if (input.startsWith("/")) return base.protocol + "//" + base.__authority() + input;
|
|
262
|
+
if (input.startsWith("?")) return base.protocol + "//" + base.__authority() + base.pathname + input;
|
|
263
|
+
if (input.startsWith("#")) return base.protocol + "//" + base.__authority() + base.pathname + base.search + input;
|
|
264
|
+
if (input.length == 0) return base.protocol + "//" + base.__authority() + base.pathname + base.search;
|
|
265
|
+
const path = base.pathname;
|
|
266
|
+
return base.protocol + "//" + base.__authority() + path.substring(0, path.lastIndexOf("/") + 1) + input;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function parseUrl(input: string, base: string | null): __UrlParts | null {
|
|
270
|
+
const text = input.trim();
|
|
271
|
+
if (schemeOf(text).length > 0 || base === null) return parseAbsolute(text);
|
|
272
|
+
const parsedBase = parseAbsolute((base as string).trim());
|
|
273
|
+
if (!parsedBase) return null;
|
|
274
|
+
return parseAbsolute(resolveAgainst(text, URL.__of(parsedBase as __UrlParts)));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* A web address taken apart: its protocol, host, path and query.
|
|
279
|
+
*
|
|
280
|
+
* ```ts
|
|
281
|
+
* const url = new URL("https://example.com/api/stats?id=7");
|
|
282
|
+
* url.searchParams.set("map", server.map);
|
|
283
|
+
* const response = await fetch(url);
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
export class URL {
|
|
287
|
+
private __parts: __UrlParts;
|
|
288
|
+
private __params: URLSearchParams | null = null;
|
|
289
|
+
|
|
290
|
+
/** Parses `url`, relative to `base` when it is given; throws a TypeError when it is not a URL. */
|
|
291
|
+
constructor(url: string, base: string | null = null) {
|
|
292
|
+
this.__parts = new __UrlParts();
|
|
293
|
+
const parts = parseUrl(url, base);
|
|
294
|
+
if (!parts) throw new TypeError(`Invalid URL: ${url}`);
|
|
295
|
+
this.__parts = parts;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** `true` when `url`, relative to `base` when given, is a URL `new URL` would take. */
|
|
299
|
+
static canParse(url: string, base: string | null = null): bool {
|
|
300
|
+
return parseUrl(url, base) !== null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The URL, or `null` when `url` is not one - `new URL` without the throw. */
|
|
304
|
+
static parse(url: string, base: string | null = null): URL | null {
|
|
305
|
+
const parts = parseUrl(url, base);
|
|
306
|
+
return parts ? URL.__of(parts) : null;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** @hidden */
|
|
310
|
+
static __of(parts: __UrlParts): URL {
|
|
311
|
+
const url = new URL("about:blank");
|
|
312
|
+
url.__parts = parts;
|
|
313
|
+
return url;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** The whole URL as text, e.g. `"https://example.com/api?id=7"`. */
|
|
317
|
+
get href(): string {
|
|
318
|
+
const parts = this.__parts;
|
|
319
|
+
const host = isSpecial(parts.protocol) || parts.hostname.length > 0 ? "//" + this.__authority() : "";
|
|
320
|
+
return parts.protocol + host + parts.pathname + parts.search + parts.hash;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
set href(value: string) {
|
|
324
|
+
const parts = parseUrl(value, null);
|
|
325
|
+
if (!parts) throw new TypeError(`Invalid URL: ${value}`);
|
|
326
|
+
this.__parts = parts;
|
|
327
|
+
this.__syncParams();
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** The scheme with its colon, e.g. `"https:"`. */
|
|
331
|
+
get protocol(): string {
|
|
332
|
+
return this.__parts.protocol;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The user name before the host, e.g. `"admin"` in `ftp://admin:secret@example.com`; `""` when there is none. */
|
|
336
|
+
get username(): string {
|
|
337
|
+
return this.__parts.username;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** The password before the host; `""` when there is none. */
|
|
341
|
+
get password(): string {
|
|
342
|
+
return this.__parts.password;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The host with its port when the URL names one, e.g. `"example.com:8080"`. */
|
|
346
|
+
get host(): string {
|
|
347
|
+
const parts = this.__parts;
|
|
348
|
+
return parts.port.length > 0 ? parts.hostname + ":" + parts.port : parts.hostname;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The host without the port, e.g. `"example.com"`. */
|
|
352
|
+
get hostname(): string {
|
|
353
|
+
return this.__parts.hostname;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The port as text, e.g. `"8080"`; `""` for the protocol's default one. */
|
|
357
|
+
get port(): string {
|
|
358
|
+
return this.__parts.port;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** The path, e.g. `"/api/stats"`. */
|
|
362
|
+
get pathname(): string {
|
|
363
|
+
return this.__parts.pathname;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
set pathname(value: string) {
|
|
367
|
+
const path = encodeSet(value.startsWith("/") ? value : "/" + value, keepPath);
|
|
368
|
+
this.__parts.pathname = isSpecial(this.__parts.protocol) ? normalizePath(path) : path;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The query with its `?`, e.g. `"?id=7"`; `""` when there is none. */
|
|
372
|
+
get search(): string {
|
|
373
|
+
return this.__parts.search;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
set search(value: string) {
|
|
377
|
+
const query = value.startsWith("?") ? value.substring(1) : value;
|
|
378
|
+
this.__parts.search = query.length > 0 ? "?" + encodeSet(query, keepQuery) : "";
|
|
379
|
+
this.__syncParams();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The fragment with its `#`, e.g. `"#top"`; `""` when there is none. */
|
|
383
|
+
get hash(): string {
|
|
384
|
+
return this.__parts.hash;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
set hash(value: string) {
|
|
388
|
+
const fragment = value.startsWith("#") ? value.substring(1) : value;
|
|
389
|
+
this.__parts.hash = fragment.length > 0 ? "#" + encodeSet(fragment, keepFragment) : "";
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The scheme, host and port, e.g. `"https://example.com"`; `"null"` for a URL without a host. */
|
|
393
|
+
get origin(): string {
|
|
394
|
+
const parts = this.__parts;
|
|
395
|
+
return isSpecial(parts.protocol) && parts.protocol != "file:" ? parts.protocol + "//" + this.host : "null";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** The query as names and values; a change to them changes the URL. */
|
|
399
|
+
get searchParams(): URLSearchParams {
|
|
400
|
+
let params = this.__params;
|
|
401
|
+
if (!params) {
|
|
402
|
+
params = new URLSearchParams();
|
|
403
|
+
params.__read(this.__parts.search);
|
|
404
|
+
params.__url = this;
|
|
405
|
+
this.__params = params;
|
|
406
|
+
}
|
|
407
|
+
return params;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** The whole URL as text, as `href`. */
|
|
411
|
+
toString(): string {
|
|
412
|
+
return this.href;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The whole URL as text: what `JSON.stringify` writes for it. */
|
|
416
|
+
toJSON(): string {
|
|
417
|
+
return this.href;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** @hidden the user, password and host, as they stand between `//` and the path. */
|
|
421
|
+
__authority(): string {
|
|
422
|
+
const parts = this.__parts;
|
|
423
|
+
const user = parts.username.length > 0 || parts.password.length > 0
|
|
424
|
+
? parts.username + (parts.password.length > 0 ? ":" + parts.password : "") + "@"
|
|
425
|
+
: "";
|
|
426
|
+
return user + this.host;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** @hidden the query, written back by its URLSearchParams. */
|
|
430
|
+
__setQuery(query: string): void {
|
|
431
|
+
this.__parts.search = query.length > 0 ? "?" + query : "";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private __syncParams(): void {
|
|
435
|
+
const params = this.__params;
|
|
436
|
+
if (params) params.__read(this.__parts.search);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* A query's names and values, such as `id=7&map=de_dust2`.
|
|
442
|
+
*
|
|
443
|
+
* ```ts
|
|
444
|
+
* const query = new URLSearchParams({ id: "7", map: server.map });
|
|
445
|
+
* const response = await fetch(`https://example.com/api?${query}`);
|
|
446
|
+
* ```
|
|
447
|
+
*/
|
|
448
|
+
export class URLSearchParams {
|
|
449
|
+
private __names: string[] = [];
|
|
450
|
+
private __values: string[] = [];
|
|
451
|
+
/** @hidden the URL whose query this is. */
|
|
452
|
+
__url: URL | null = null;
|
|
453
|
+
|
|
454
|
+
/** The names and values of `init`, in its order. */
|
|
455
|
+
constructor(init: Record<string, string> = {}) {
|
|
456
|
+
const names = Object.keys(init);
|
|
457
|
+
for (let i: i32 = 0; i < names.length; i++) {
|
|
458
|
+
this.__names.push(names[i]);
|
|
459
|
+
this.__values.push(init[names[i]] as string);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** The number of name and value pairs, a name given twice counted twice. */
|
|
464
|
+
get size(): number {
|
|
465
|
+
return this.__names.length;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/** Adds a pair; a name already there keeps its value too. */
|
|
469
|
+
append(name: string, value: string): void {
|
|
470
|
+
this.__names.push(name);
|
|
471
|
+
this.__values.push(value);
|
|
472
|
+
this.__update();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Removes every pair of `name`. */
|
|
476
|
+
delete(name: string): void {
|
|
477
|
+
for (let i: i32 = this.__names.length - 1; i >= 0; i--) {
|
|
478
|
+
if (this.__names[i] == name) {
|
|
479
|
+
this.__names.splice(i, 1);
|
|
480
|
+
this.__values.splice(i, 1);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
this.__update();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** The first value of `name`, or `null` when there is none. */
|
|
487
|
+
get(name: string): string | null {
|
|
488
|
+
const at = this.__names.indexOf(name);
|
|
489
|
+
return at >= 0 ? this.__values[at] : null;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Every value of `name`, in order. */
|
|
493
|
+
getAll(name: string): string[] {
|
|
494
|
+
const values: string[] = [];
|
|
495
|
+
for (let i: i32 = 0; i < this.__names.length; i++) {
|
|
496
|
+
if (this.__names[i] == name) values.push(this.__values[i]);
|
|
497
|
+
}
|
|
498
|
+
return values;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** `true` when there is a pair of `name`. */
|
|
502
|
+
has(name: string): bool {
|
|
503
|
+
return this.__names.includes(name);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Sets `name` to `value`: the first pair takes it, the other pairs of the name go. */
|
|
507
|
+
set(name: string, value: string): void {
|
|
508
|
+
const at = this.__names.indexOf(name);
|
|
509
|
+
if (at < 0) {
|
|
510
|
+
this.append(name, value);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.__values[at] = value;
|
|
514
|
+
for (let i: i32 = this.__names.length - 1; i > at; i--) {
|
|
515
|
+
if (this.__names[i] == name) {
|
|
516
|
+
this.__names.splice(i, 1);
|
|
517
|
+
this.__values.splice(i, 1);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
this.__update();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Orders the pairs by name; pairs of one name keep their order. */
|
|
524
|
+
sort(): void {
|
|
525
|
+
const order: i32[] = [];
|
|
526
|
+
for (let i: i32 = 0; i < this.__names.length; i++) order.push(i);
|
|
527
|
+
const names = this.__names;
|
|
528
|
+
order.sort((a: i32, b: i32): i32 => (names[a] < names[b] ? -1 : names[a] > names[b] ? 1 : a - b));
|
|
529
|
+
this.__names = order.map<string>((i: i32): string => names[i]);
|
|
530
|
+
const values = this.__values;
|
|
531
|
+
this.__values = order.map<string>((i: i32): string => values[i]);
|
|
532
|
+
this.__update();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Calls `callback` with each value and its name, in order. */
|
|
536
|
+
forEach(callback: (value: string, name: string) => void): void {
|
|
537
|
+
for (let i: i32 = 0; i < this.__names.length; i++) callback(this.__values[i], this.__names[i]);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Every name, in order; a name given twice is there twice. */
|
|
541
|
+
keys(): string[] {
|
|
542
|
+
return this.__names.slice(0);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Every value, in order. */
|
|
546
|
+
values(): string[] {
|
|
547
|
+
return this.__values.slice(0);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Every pair as `[name, value]`, in order. */
|
|
551
|
+
entries(): string[][] {
|
|
552
|
+
const pairs: string[][] = [];
|
|
553
|
+
for (let i: i32 = 0; i < this.__names.length; i++) pairs.push([this.__names[i], this.__values[i]]);
|
|
554
|
+
return pairs;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The query as text, without the `?`: `id=7&map=de_dust2`. */
|
|
558
|
+
toString(): string {
|
|
559
|
+
const pairs: string[] = [];
|
|
560
|
+
for (let i: i32 = 0; i < this.__names.length; i++) {
|
|
561
|
+
pairs.push(encodeForm(this.__names[i]) + "=" + encodeForm(this.__values[i]));
|
|
562
|
+
}
|
|
563
|
+
return pairs.join("&");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** @hidden takes the pairs of a query, `?` or not. */
|
|
567
|
+
__read(query: string): void {
|
|
568
|
+
this.__names = [];
|
|
569
|
+
this.__values = [];
|
|
570
|
+
const text = query.startsWith("?") ? query.substring(1) : query;
|
|
571
|
+
if (text.length == 0) return;
|
|
572
|
+
const pairs = text.split("&");
|
|
573
|
+
for (let i: i32 = 0; i < pairs.length; i++) {
|
|
574
|
+
const pair = pairs[i];
|
|
575
|
+
if (pair.length == 0) continue;
|
|
576
|
+
const equals = pair.indexOf("=");
|
|
577
|
+
this.__names.push(decodeText(equals >= 0 ? pair.substring(0, equals) : pair, true));
|
|
578
|
+
this.__values.push(equals >= 0 ? decodeText(pair.substring(equals + 1), true) : "");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
private __update(): void {
|
|
583
|
+
const url = this.__url;
|
|
584
|
+
if (url) url.__setQuery(this.toString());
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function encodeForm(text: string): string {
|
|
589
|
+
return encodeSet(text, keepForm).replaceAll("%20", "+");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// ---------------------------------------------------------------- Headers
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* A request's or a response's HTTP headers. Names are matched without
|
|
596
|
+
* regard to case.
|
|
597
|
+
*
|
|
598
|
+
* ```ts
|
|
599
|
+
* const type = response.headers.get("content-type");
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
export class Headers {
|
|
603
|
+
private __names: string[] = [];
|
|
604
|
+
private __values: string[] = [];
|
|
605
|
+
|
|
606
|
+
/** The headers of `init`: an object of names and values. */
|
|
607
|
+
constructor(init: Record<string, string> = {}) {
|
|
608
|
+
const names = Object.keys(init);
|
|
609
|
+
for (let i: i32 = 0; i < names.length; i++) this.append(names[i], init[names[i]] as string);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** Adds a value to `name`; one already there stays, and `get` joins them. */
|
|
613
|
+
append(name: string, value: string): void {
|
|
614
|
+
this.__names.push(name.toLowerCase());
|
|
615
|
+
this.__values.push(value.trim());
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Removes `name` and all its values. */
|
|
619
|
+
delete(name: string): void {
|
|
620
|
+
const key = name.toLowerCase();
|
|
621
|
+
for (let i: i32 = this.__names.length - 1; i >= 0; i--) {
|
|
622
|
+
if (this.__names[i] == key) {
|
|
623
|
+
this.__names.splice(i, 1);
|
|
624
|
+
this.__values.splice(i, 1);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** The value of `name` - its values joined with `", "` when it has several - or `null` when there is none. */
|
|
630
|
+
get(name: string): string | null {
|
|
631
|
+
const key = name.toLowerCase();
|
|
632
|
+
const values: string[] = [];
|
|
633
|
+
for (let i: i32 = 0; i < this.__names.length; i++) {
|
|
634
|
+
if (this.__names[i] == key) values.push(this.__values[i]);
|
|
635
|
+
}
|
|
636
|
+
return values.length > 0 ? values.join(", ") : null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Every `Set-Cookie` header of a response, each its own text. */
|
|
640
|
+
getSetCookie(): string[] {
|
|
641
|
+
const cookies: string[] = [];
|
|
642
|
+
for (let i: i32 = 0; i < this.__names.length; i++) {
|
|
643
|
+
if (this.__names[i] == "set-cookie") cookies.push(this.__values[i]);
|
|
644
|
+
}
|
|
645
|
+
return cookies;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** `true` when there is a header of `name`. */
|
|
649
|
+
has(name: string): bool {
|
|
650
|
+
return this.__names.includes(name.toLowerCase());
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** Sets `name` to `value`, in place of every value it had. */
|
|
654
|
+
set(name: string, value: string): void {
|
|
655
|
+
this.delete(name);
|
|
656
|
+
this.append(name, value);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Calls `callback` with each header's value and its name in lower case, ordered by name. */
|
|
660
|
+
forEach(callback: (value: string, name: string) => void): void {
|
|
661
|
+
const pairs = this.entries();
|
|
662
|
+
for (let i: i32 = 0; i < pairs.length; i++) callback(pairs[i][1], pairs[i][0]);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/** Every header's name in lower case, ordered by name. */
|
|
666
|
+
keys(): string[] {
|
|
667
|
+
return this.entries().map<string>((pair: string[]): string => pair[0]);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/** Every header's value, ordered by name. */
|
|
671
|
+
values(): string[] {
|
|
672
|
+
return this.entries().map<string>((pair: string[]): string => pair[1]);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Every header as `[name, value]`, ordered by name; the values of a name joined, but each `set-cookie` on its own. */
|
|
676
|
+
entries(): string[][] {
|
|
677
|
+
const names: string[] = [];
|
|
678
|
+
for (let i: i32 = 0; i < this.__names.length; i++) {
|
|
679
|
+
if (!names.includes(this.__names[i])) names.push(this.__names[i]);
|
|
680
|
+
}
|
|
681
|
+
names.sort();
|
|
682
|
+
const pairs: string[][] = [];
|
|
683
|
+
for (let i: i32 = 0; i < names.length; i++) {
|
|
684
|
+
if (names[i] == "set-cookie") {
|
|
685
|
+
const cookies = this.getSetCookie();
|
|
686
|
+
for (let c: i32 = 0; c < cookies.length; c++) pairs.push(["set-cookie", cookies[c]]);
|
|
687
|
+
}
|
|
688
|
+
else pairs.push([names[i], this.get(names[i]) as string]);
|
|
689
|
+
}
|
|
690
|
+
return pairs;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/** @hidden a copy. */
|
|
694
|
+
__copy(): Headers {
|
|
695
|
+
const copy = new Headers();
|
|
696
|
+
copy.__names = this.__names.slice(0);
|
|
697
|
+
copy.__values = this.__values.slice(0);
|
|
698
|
+
return copy;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/** @hidden the header lines of a response, `name: value` each. */
|
|
702
|
+
static __parse(lines: string): Headers {
|
|
703
|
+
const headers = new Headers();
|
|
704
|
+
const each = lines.split("\n");
|
|
705
|
+
for (let i: i32 = 0; i < each.length; i++) {
|
|
706
|
+
const line = each[i];
|
|
707
|
+
const colon = line.indexOf(":");
|
|
708
|
+
if (colon > 0) headers.append(line.substring(0, colon).trim(), line.substring(colon + 1));
|
|
709
|
+
}
|
|
710
|
+
return headers;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// ---------------------------------------------------------------- Request and Response
|
|
715
|
+
|
|
716
|
+
/** The check of a server's certificate, for HTTPS. */
|
|
717
|
+
export interface TlsOptions {
|
|
718
|
+
/**
|
|
719
|
+
* The certificate authorities a server's certificate must come from, as
|
|
720
|
+
* PEM text - for a server with a certificate of its own making. Left out,
|
|
721
|
+
* the authorities a browser trusts.
|
|
722
|
+
*/
|
|
723
|
+
ca?: string;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** A request's options: `fetch`'s second argument. Every field is optional. */
|
|
727
|
+
export interface RequestInit {
|
|
728
|
+
/** The request's method, e.g. `"POST"`; `"GET"` by default. */
|
|
729
|
+
method?: string;
|
|
730
|
+
/** The request's headers, as an object of names and values: `{ Authorization: "Bearer abc" }`. */
|
|
731
|
+
headers?: Record<string, string>;
|
|
732
|
+
/** The text sent with the request, such as JSON. A GET or HEAD request has none. */
|
|
733
|
+
body?: string;
|
|
734
|
+
/**
|
|
735
|
+
* The request's answer to a redirect: one of `"follow"` (the default) - on
|
|
736
|
+
* to its address, `"manual"` - the redirect itself as the response, `"error"` - a rejection.
|
|
737
|
+
*/
|
|
738
|
+
redirect?: "follow" | "manual" | "error";
|
|
739
|
+
/** A signal that cancels the request; the promise then rejects with the signal's reason. */
|
|
740
|
+
signal?: AbortSignal | null;
|
|
741
|
+
/** A proxy the request goes through, e.g. `"http://proxy.example.com:3128"`. */
|
|
742
|
+
proxy?: string;
|
|
743
|
+
/** The check of the server's certificate, for HTTPS. */
|
|
744
|
+
tls?: TlsOptions;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* A request: its address, method, headers and body - what `fetch` sends.
|
|
749
|
+
*
|
|
750
|
+
* ```ts
|
|
751
|
+
* const request = new Request("https://example.com/api", { method: "POST", body: "hi" });
|
|
752
|
+
* const response = await fetch(request);
|
|
753
|
+
* ```
|
|
754
|
+
*/
|
|
755
|
+
export class Request {
|
|
756
|
+
private __url: string;
|
|
757
|
+
private __method: string;
|
|
758
|
+
private __headers: Headers;
|
|
759
|
+
private __body: string | null;
|
|
760
|
+
private __redirect: string;
|
|
761
|
+
private __signal: AbortSignal;
|
|
762
|
+
private __proxy: string;
|
|
763
|
+
private __ca: string;
|
|
764
|
+
|
|
765
|
+
/** A request to `url` with the options of `init`. */
|
|
766
|
+
constructor(url: string, init: RequestInit = {}) {
|
|
767
|
+
this.__url = url;
|
|
768
|
+
this.__method = methodName(init.method ?? "GET");
|
|
769
|
+
this.__headers = new Headers(init.headers ?? {});
|
|
770
|
+
const body = init.body;
|
|
771
|
+
this.__body = body !== undefined ? body : null;
|
|
772
|
+
this.__redirect = init.redirect ?? "follow";
|
|
773
|
+
const signal = init.signal;
|
|
774
|
+
this.__signal = signal ? signal : new AbortSignal();
|
|
775
|
+
this.__proxy = init.proxy ?? "";
|
|
776
|
+
const tls = init.tls;
|
|
777
|
+
this.__ca = tls ? tls.ca ?? "" : "";
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The address the request goes to. */
|
|
781
|
+
get url(): string {
|
|
782
|
+
return this.__url;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** The request's method, e.g. `"GET"`. */
|
|
786
|
+
get method(): string {
|
|
787
|
+
return this.__method;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** The request's headers. */
|
|
791
|
+
get headers(): Headers {
|
|
792
|
+
return this.__headers;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/** The request's answer to a redirect: one of `"follow"`, `"manual"` or `"error"`. */
|
|
796
|
+
get redirect(): string {
|
|
797
|
+
return this.__redirect;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/** The signal that cancels the request. */
|
|
801
|
+
get signal(): AbortSignal {
|
|
802
|
+
return this.__signal;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** A copy of the request, to send again. */
|
|
806
|
+
clone(): Request {
|
|
807
|
+
return this.__with({});
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** @hidden the request with what `init` gives in place of its own. */
|
|
811
|
+
__with(init: RequestInit): Request {
|
|
812
|
+
const copy = new Request(this.__url);
|
|
813
|
+
copy.__method = init.method !== undefined ? methodName(init.method as string) : this.__method;
|
|
814
|
+
copy.__headers = init.headers !== undefined ? new Headers(init.headers as Record<string, string>) : this.__headers.__copy();
|
|
815
|
+
copy.__body = init.body !== undefined ? init.body as string : this.__body;
|
|
816
|
+
copy.__redirect = init.redirect ?? this.__redirect;
|
|
817
|
+
const signal = init.signal;
|
|
818
|
+
copy.__signal = signal ? signal : this.__signal;
|
|
819
|
+
copy.__proxy = init.proxy ?? this.__proxy;
|
|
820
|
+
const tls = init.tls;
|
|
821
|
+
copy.__ca = tls ? tls.ca ?? "" : this.__ca;
|
|
822
|
+
return copy;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** @hidden */
|
|
826
|
+
__bodyText(): string | null {
|
|
827
|
+
return this.__body;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** @hidden */
|
|
831
|
+
__proxyUrl(): string {
|
|
832
|
+
return this.__proxy;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** @hidden */
|
|
836
|
+
__authorities(): string {
|
|
837
|
+
return this.__ca;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** The standard methods in capitals, as fetch writes them; any other as given. */
|
|
842
|
+
function methodName(method: string): string {
|
|
843
|
+
const upper = method.toUpperCase();
|
|
844
|
+
return upper == "DELETE" || upper == "GET" || upper == "HEAD" || upper == "OPTIONS" || upper == "POST" || upper == "PUT" || upper == "PATCH" ? upper : method;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** A response's options: `new Response`'s second argument. */
|
|
848
|
+
export interface ResponseInit {
|
|
849
|
+
/** The response's status; `200` by default. */
|
|
850
|
+
status?: number;
|
|
851
|
+
/** The words after the status, e.g. `"Not Found"`; `""` by default. */
|
|
852
|
+
statusText?: string;
|
|
853
|
+
/** The response's headers, as an object of names and values. */
|
|
854
|
+
headers?: Record<string, string>;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* The server's answer to a request: its status, headers and body.
|
|
859
|
+
*
|
|
860
|
+
* ```ts
|
|
861
|
+
* const response = await fetch("https://example.com/api/stats");
|
|
862
|
+
* if (!response.ok) return console.log(`HTTP ${response.status}`);
|
|
863
|
+
* const stats = await response.json<Stats>();
|
|
864
|
+
* ```
|
|
865
|
+
*/
|
|
866
|
+
export class Response {
|
|
867
|
+
private __status: number;
|
|
868
|
+
private __statusText: string;
|
|
869
|
+
private __headers: Headers;
|
|
870
|
+
private __body: ArrayBuffer;
|
|
871
|
+
private __url: string = "";
|
|
872
|
+
private __redirected: bool = false;
|
|
873
|
+
private __used: bool = false;
|
|
874
|
+
|
|
875
|
+
/** A response whose body is `body`, with the status and headers of `init` - for a test, say. */
|
|
876
|
+
constructor(body: string | null = null, init: ResponseInit = {}) {
|
|
877
|
+
this.__status = init.status ?? 200;
|
|
878
|
+
this.__statusText = init.statusText ?? "";
|
|
879
|
+
this.__headers = new Headers(init.headers ?? {});
|
|
880
|
+
this.__body = body !== null ? String.UTF8.encode(body as string) : new ArrayBuffer(0);
|
|
881
|
+
if (body !== null && !this.__headers.has("content-type")) this.__headers.set("content-type", "text/plain;charset=UTF-8");
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
/** The response's HTTP status, e.g. `200` or `404`. */
|
|
885
|
+
get status(): number {
|
|
886
|
+
return this.__status;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The words after the status, e.g. `"Not Found"`; `""` when the server sent none. */
|
|
890
|
+
get statusText(): string {
|
|
891
|
+
return this.__statusText;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/** `true` for a status from `200` to `299`. */
|
|
895
|
+
get ok(): bool {
|
|
896
|
+
return this.__status >= 200 && this.__status <= 299;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** The response's headers. */
|
|
900
|
+
get headers(): Headers {
|
|
901
|
+
return this.__headers;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/** The address the response came from, after any redirects. */
|
|
905
|
+
get url(): string {
|
|
906
|
+
return this.__url;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** `true` when the request was redirected on the way. */
|
|
910
|
+
get redirected(): bool {
|
|
911
|
+
return this.__redirected;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** `true` once the body has been read: it can be read once. */
|
|
915
|
+
get bodyUsed(): bool {
|
|
916
|
+
return this.__used;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/** The body as text, decoded as UTF-8. */
|
|
920
|
+
text(): Promise<string> {
|
|
921
|
+
const promise = __co_promise<string>();
|
|
922
|
+
const used = this.__take();
|
|
923
|
+
if (used) __co_reject(promise, used);
|
|
924
|
+
else __co_resolve(promise, String.UTF8.decode(this.__body));
|
|
925
|
+
return promise;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* The body read as JSON into `T`, an interface of the fields the JSON
|
|
930
|
+
* has; rejects with a SyntaxError when it is not JSON.
|
|
931
|
+
*
|
|
932
|
+
* ```ts
|
|
933
|
+
* interface Stats {
|
|
934
|
+
* kills: number;
|
|
935
|
+
* }
|
|
936
|
+
* const stats = await response.json<Stats>();
|
|
937
|
+
* ```
|
|
938
|
+
*/
|
|
939
|
+
json<T>(): Promise<T> {
|
|
940
|
+
const promise = __co_promise<T>();
|
|
941
|
+
const used = this.__take();
|
|
942
|
+
if (used) {
|
|
943
|
+
__co_reject(promise, used);
|
|
944
|
+
return promise;
|
|
945
|
+
}
|
|
946
|
+
try {
|
|
947
|
+
__co_resolve<T>(promise, JSON.parse<T>(String.UTF8.decode(this.__body)));
|
|
948
|
+
}
|
|
949
|
+
catch (error) {
|
|
950
|
+
__co_reject(promise, error as Error);
|
|
951
|
+
}
|
|
952
|
+
return promise;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
/** The body as bytes. */
|
|
956
|
+
arrayBuffer(): Promise<ArrayBuffer> {
|
|
957
|
+
const promise = __co_promise<ArrayBuffer>();
|
|
958
|
+
const used = this.__take();
|
|
959
|
+
if (used) __co_reject(promise, used);
|
|
960
|
+
else __co_resolve(promise, this.__body);
|
|
961
|
+
return promise;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** A copy of the response, whose body can be read apart from this one's. */
|
|
965
|
+
clone(): Response {
|
|
966
|
+
const copy = new Response();
|
|
967
|
+
copy.__status = this.__status;
|
|
968
|
+
copy.__statusText = this.__statusText;
|
|
969
|
+
copy.__headers = this.__headers.__copy();
|
|
970
|
+
copy.__body = this.__body.slice(0);
|
|
971
|
+
copy.__url = this.__url;
|
|
972
|
+
copy.__redirected = this.__redirected;
|
|
973
|
+
copy.__used = this.__used;
|
|
974
|
+
return copy;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
/** @hidden what the network client handed back. */
|
|
978
|
+
static __received(status: number, statusText: string, headers: Headers, body: ArrayBuffer, url: string, redirected: bool): Response {
|
|
979
|
+
const response = new Response();
|
|
980
|
+
response.__status = status;
|
|
981
|
+
response.__statusText = statusText;
|
|
982
|
+
response.__headers = headers;
|
|
983
|
+
response.__body = body;
|
|
984
|
+
response.__url = url;
|
|
985
|
+
response.__redirected = redirected;
|
|
986
|
+
return response;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The error of a second read, or null the first time. */
|
|
990
|
+
private __take(): Error | null {
|
|
991
|
+
if (this.__used) return new TypeError("Body is unusable: Body has already been read");
|
|
992
|
+
this.__used = true;
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
// ---------------------------------------------------------------- request
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* Why a request ended without its answer, as `request` says it: `""` - it
|
|
1001
|
+
* got one (an HTTP status such as `404` is an answer); `"login"` - the user,
|
|
1002
|
+
* password or key was refused; `"denied"` - the account may not do it (FTP
|
|
1003
|
+
* cannot enter a folder, SFTP says permission denied); `"notFound"` - no
|
|
1004
|
+
* such remote file; `"refused"` - no connection: the host is unknown or
|
|
1005
|
+
* refuses it; `"timeout"` - `timeout` ran out; `"tls"` - the secure
|
|
1006
|
+
* connection failed: a certificate, or an SSH host key, not trusted;
|
|
1007
|
+
* `"aborted"` - the signal aborted it; `"other"` - anything else, said in
|
|
1008
|
+
* `errorText`.
|
|
1009
|
+
*/
|
|
1010
|
+
export type RequestErrorKind = "" | "login" | "denied" | "notFound" | "refused" | "timeout" | "tls" | "aborted" | "other";
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* `request`'s options: what to send and how. Every field is optional; the
|
|
1014
|
+
* URL's scheme - `http:`, `https:`, `ftp:`, `ftps:` or `sftp:` - says which
|
|
1015
|
+
* of them apply.
|
|
1016
|
+
*/
|
|
1017
|
+
export interface RequestOptions {
|
|
1018
|
+
/** HTTP's method, e.g. `"POST"`; `"GET"` by default. `"HEAD"` transfers nothing - with `quote`, only the commands run. */
|
|
1019
|
+
method?: string;
|
|
1020
|
+
/** HTTP's headers, as an object of names and values. */
|
|
1021
|
+
headers?: Record<string, string>;
|
|
1022
|
+
/** The text sent: HTTP's body, or - with `upload` - the file's content. */
|
|
1023
|
+
body?: string;
|
|
1024
|
+
/** The user to log in as; the URL's own (`ftp://user@host`) when left out. */
|
|
1025
|
+
user?: string;
|
|
1026
|
+
/** The password to log in with. */
|
|
1027
|
+
password?: string;
|
|
1028
|
+
/**
|
|
1029
|
+
* SFTP: the private key to log in with, a file of the game folder
|
|
1030
|
+
* (`addons/amxmodx/data/id_rsa`) - an RSA key in PEM
|
|
1031
|
+
* (`ssh-keygen -t rsa -m PEM`).
|
|
1032
|
+
*/
|
|
1033
|
+
keyFile?: string;
|
|
1034
|
+
/** SFTP: the passphrase `keyFile` is encrypted with. */
|
|
1035
|
+
keyPassphrase?: string;
|
|
1036
|
+
/**
|
|
1037
|
+
* SFTP: the server's host key, its SHA-256 fingerprint in base64 as
|
|
1038
|
+
* `ssh-keygen -lf` prints it without `SHA256:` - a server with another key
|
|
1039
|
+
* is refused (`"tls"`). Left out, any host key is taken.
|
|
1040
|
+
*/
|
|
1041
|
+
hostKey?: string;
|
|
1042
|
+
/**
|
|
1043
|
+
* FTP: TLS asked for with `AUTH TLS` on a plain `ftp:` connection - one of
|
|
1044
|
+
* `"try"` (when the server has it), `"control"` (the commands at least)
|
|
1045
|
+
* or `"all"` (the commands and the files, or fail). `ftps:` is TLS from
|
|
1046
|
+
* the first byte without it.
|
|
1047
|
+
*/
|
|
1048
|
+
ssl?: "try" | "control" | "all";
|
|
1049
|
+
/** Sends `body` - or `file` - to the URL's path, in place of reading it: an FTP or SFTP upload, an HTTP PUT. */
|
|
1050
|
+
upload?: boolean;
|
|
1051
|
+
/** FTP and SFTP: the names in the URL's folder, one a line, in place of a listing with sizes and dates. */
|
|
1052
|
+
list?: boolean;
|
|
1053
|
+
/** FTP or SFTP commands run before the transfer, in order: `["DELE old.txt"]`, `["rename a.txt b.txt"]`. */
|
|
1054
|
+
quote?: string[];
|
|
1055
|
+
/** FTP and SFTP: an upload makes the folders of its path that are not there. */
|
|
1056
|
+
createDirs?: boolean;
|
|
1057
|
+
/** Milliseconds the whole request may take; past them it ends as `"timeout"`. No limit by default. */
|
|
1058
|
+
timeout?: number;
|
|
1059
|
+
/** A signal that aborts the request: it then ends at once as `"aborted"`. */
|
|
1060
|
+
signal?: AbortSignal | null;
|
|
1061
|
+
/**
|
|
1062
|
+
* A file of the game folder (`maps/de_dust2.bsp`) the answer is written
|
|
1063
|
+
* into - or, with `upload`, the upload is read from - in place of `body`.
|
|
1064
|
+
* The bytes go between the network and the disk without passing through
|
|
1065
|
+
* the plugin. A download that fails leaves an older file of that name as
|
|
1066
|
+
* it was. A path outside the game folder (absolute, or with `..`) is
|
|
1067
|
+
* refused.
|
|
1068
|
+
*/
|
|
1069
|
+
file?: string;
|
|
1070
|
+
/** HTTP: whether a redirect is followed; `true` by default. */
|
|
1071
|
+
follow?: boolean;
|
|
1072
|
+
/** A proxy the request goes through, e.g. `"http://proxy.example.com:3128"`. */
|
|
1073
|
+
proxy?: string;
|
|
1074
|
+
/** The certificate authorities a server's certificate must come from, as PEM text; the ones a browser trusts by default. */
|
|
1075
|
+
ca?: string;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** The end of a request: its answer, or why there is none. */
|
|
1079
|
+
export class RequestResult {
|
|
1080
|
+
/** HTTP's status, or FTP's last reply once the transfer is done (`226`); `0` for SFTP; `-1` when the request failed. */
|
|
1081
|
+
status: number = -1;
|
|
1082
|
+
/** Why there is no answer, or `""`. */
|
|
1083
|
+
errorKind: RequestErrorKind = "";
|
|
1084
|
+
/** The failure, in words; `""` when there was none. */
|
|
1085
|
+
errorText: string = "";
|
|
1086
|
+
/** The protocol's last reply code, a failed request's too: HTTP's status, FTP's reply (`530`, `550`), SFTP's status (`2` no such file); `0` when there was none. */
|
|
1087
|
+
replyCode: number = 0;
|
|
1088
|
+
/** The words after HTTP's status, e.g. `"Not Found"`. */
|
|
1089
|
+
statusText: string = "";
|
|
1090
|
+
/** HTTP's response headers. */
|
|
1091
|
+
headers: Headers = new Headers();
|
|
1092
|
+
/** The answer's bytes - a download, a listing; empty when it went into `file`. */
|
|
1093
|
+
body: ArrayBuffer = new ArrayBuffer(0);
|
|
1094
|
+
/** The address the answer came from, after any redirects. */
|
|
1095
|
+
url: string = "";
|
|
1096
|
+
/** `true` when an HTTP redirect was followed on the way. */
|
|
1097
|
+
redirected: bool = false;
|
|
1098
|
+
/** @hidden the signal's reason, for an aborted request. */
|
|
1099
|
+
__reason: Error | null = null;
|
|
1100
|
+
|
|
1101
|
+
/** The answer as text, decoded as UTF-8. */
|
|
1102
|
+
text(): string {
|
|
1103
|
+
return String.UTF8.decode(this.body);
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** @hidden a request that ended before it was sent, or was aborted. */
|
|
1107
|
+
static __failed(kind: RequestErrorKind, text: string, reason: Error | null = null): RequestResult {
|
|
1108
|
+
const result = new RequestResult();
|
|
1109
|
+
result.errorKind = kind;
|
|
1110
|
+
result.errorText = text;
|
|
1111
|
+
result.__reason = reason;
|
|
1112
|
+
return result;
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** A request under way: what cancels it when a signal aborts, and what it ends with. */
|
|
1117
|
+
class __NetTask extends __AbortWatch {
|
|
1118
|
+
id: i32 = 0;
|
|
1119
|
+
guard: __AbortGuard | null = null;
|
|
1120
|
+
|
|
1121
|
+
/** Called once, with what the request ended with. */
|
|
1122
|
+
finish(result: RequestResult): void {}
|
|
1123
|
+
|
|
1124
|
+
run(reason: Error): void {
|
|
1125
|
+
if (!__requests.has(this.id)) return;
|
|
1126
|
+
__requests.delete(this.id);
|
|
1127
|
+
_cancel(this.id);
|
|
1128
|
+
this.release();
|
|
1129
|
+
this.finish(RequestResult.__failed("aborted", reason.message, reason));
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
release(): void {
|
|
1133
|
+
const guard = this.guard;
|
|
1134
|
+
if (guard) guard.release();
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
class __RequestTask extends __NetTask {
|
|
1139
|
+
constructor(public promise: Promise<RequestResult>) {
|
|
1140
|
+
super();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
finish(result: RequestResult): void {
|
|
1144
|
+
__co_resolve(this.promise, result);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// @ts-ignore: decorator
|
|
1149
|
+
@lazy const __requests = new Map<i32, __NetTask>();
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Sets a request up and sends it; `task` is finished at once when it cannot
|
|
1153
|
+
* be sent or its signal has aborted already, else on the frame it ends.
|
|
1154
|
+
*/
|
|
1155
|
+
function __netStart(task: __NetTask, url: string, options: RequestOptions): void {
|
|
1156
|
+
const guard = new __AbortGuard(task, options.signal ?? null);
|
|
1157
|
+
task.guard = guard;
|
|
1158
|
+
const aborted = guard.aborted;
|
|
1159
|
+
if (aborted) {
|
|
1160
|
+
guard.release();
|
|
1161
|
+
task.finish(RequestResult.__failed("aborted", aborted.message, aborted));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const id = _open(url);
|
|
1166
|
+
task.id = id;
|
|
1167
|
+
const refused = __netOptions(id, options);
|
|
1168
|
+
if (refused.length > 0 || _send(id, __netDone.index) == 0) {
|
|
1169
|
+
_close(id);
|
|
1170
|
+
guard.release();
|
|
1171
|
+
task.finish(RequestResult.__failed("other", refused.length > 0 ? refused : "the server's network client did not start"));
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
__requests.set(id, task);
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Gives the network client a request's options: what it refused, or `""`. */
|
|
1178
|
+
function __netOptions(id: i32, options: RequestOptions): string {
|
|
1179
|
+
_option(id, "method", options.method ?? "GET");
|
|
1180
|
+
const headers = options.headers;
|
|
1181
|
+
if (headers !== undefined) {
|
|
1182
|
+
const names = Object.keys(headers as Record<string, string>);
|
|
1183
|
+
for (let i: i32 = 0; i < names.length; i++) _option(id, "header", `${names[i]}: ${(headers as Record<string, string>)[names[i]]}`);
|
|
1184
|
+
}
|
|
1185
|
+
const body = options.body;
|
|
1186
|
+
if (body !== undefined) {
|
|
1187
|
+
const bytes = String.UTF8.encode(body as string);
|
|
1188
|
+
_body(id, changetype<usize>(bytes), bytes.byteLength);
|
|
1189
|
+
}
|
|
1190
|
+
const texts = [
|
|
1191
|
+
["user", options.user ?? ""],
|
|
1192
|
+
["password", options.password ?? ""],
|
|
1193
|
+
["keyPassphrase", options.keyPassphrase ?? ""],
|
|
1194
|
+
["hostKey", options.hostKey ?? ""],
|
|
1195
|
+
["ssl", options.ssl ?? ""],
|
|
1196
|
+
["proxy", options.proxy ?? ""],
|
|
1197
|
+
["ca", options.ca ?? ""],
|
|
1198
|
+
];
|
|
1199
|
+
for (let i: i32 = 0; i < texts.length; i++) {
|
|
1200
|
+
if (texts[i][1].length > 0) _option(id, texts[i][0], texts[i][1]);
|
|
1201
|
+
}
|
|
1202
|
+
const timeout = options.timeout;
|
|
1203
|
+
if (timeout !== undefined) _option(id, "timeout", (<i64>(timeout as number)).toString());
|
|
1204
|
+
if (options.upload ?? false) _option(id, "upload", "1");
|
|
1205
|
+
if (options.list ?? false) _option(id, "list", "1");
|
|
1206
|
+
if (options.createDirs ?? false) _option(id, "createDirs", "1");
|
|
1207
|
+
_option(id, "follow", (options.follow ?? true) ? "1" : "0");
|
|
1208
|
+
const quote = options.quote;
|
|
1209
|
+
if (quote !== undefined) {
|
|
1210
|
+
for (let i: i32 = 0; i < (quote as string[]).length; i++) _option(id, "quote", (quote as string[])[i]);
|
|
1211
|
+
}
|
|
1212
|
+
// Paths of the game folder: the client refuses one that leaves it.
|
|
1213
|
+
const keyFile = options.keyFile;
|
|
1214
|
+
if (keyFile !== undefined && _option(id, "keyFile", keyFile as string) == 0) return `keyFile ${keyFile as string} is not a path inside the game folder`;
|
|
1215
|
+
const file = options.file;
|
|
1216
|
+
if (file !== undefined && _option(id, "file", file as string) == 0) return `file ${file as string} is not a path inside the game folder`;
|
|
1217
|
+
return "";
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/** A text of an ended request: its error, status words, address, headers or kind of error. */
|
|
1221
|
+
function netText(id: i32, what: i32): string {
|
|
1222
|
+
const length = _text(id, what, 0, 0);
|
|
1223
|
+
if (length <= 0) return "";
|
|
1224
|
+
const bytes = new ArrayBuffer(length);
|
|
1225
|
+
_text(id, what, changetype<usize>(bytes), length);
|
|
1226
|
+
return String.UTF8.decode(bytes);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** The module calls this on the frame a request has ended. */
|
|
1230
|
+
function __netDone(id: i32): void {
|
|
1231
|
+
if (!__requests.has(id)) {
|
|
1232
|
+
_close(id);
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
const task = __requests.get(id);
|
|
1236
|
+
__requests.delete(id);
|
|
1237
|
+
task.release();
|
|
1238
|
+
|
|
1239
|
+
const result = new RequestResult();
|
|
1240
|
+
result.status = _status(id);
|
|
1241
|
+
result.replyCode = _reply(id);
|
|
1242
|
+
result.errorKind = netText(id, TEXT_KIND) as RequestErrorKind;
|
|
1243
|
+
result.errorText = netText(id, TEXT_ERROR);
|
|
1244
|
+
result.statusText = netText(id, TEXT_STATUS);
|
|
1245
|
+
result.headers = Headers.__parse(netText(id, TEXT_HEADERS));
|
|
1246
|
+
result.url = netText(id, TEXT_URL);
|
|
1247
|
+
result.redirected = _redirects(id) > 0;
|
|
1248
|
+
const size = _size(id);
|
|
1249
|
+
result.body = new ArrayBuffer(size);
|
|
1250
|
+
if (size > 0) _read(id, changetype<usize>(result.body), size);
|
|
1251
|
+
_close(id);
|
|
1252
|
+
task.finish(result);
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
/**
|
|
1256
|
+
* Sends a request to `url` - HTTP, HTTPS, FTP, FTPS or SFTP - with the
|
|
1257
|
+
* server's network client, and gives what it ended with. It never rejects:
|
|
1258
|
+
* a failure is `errorKind` and `errorText`.
|
|
1259
|
+
*
|
|
1260
|
+
* ```ts
|
|
1261
|
+
* import { request } from "@amxts/core/kit";
|
|
1262
|
+
*
|
|
1263
|
+
* const result = await request("sftp://example.com/maps/de_dust2.bsp", {
|
|
1264
|
+
* user: "admin",
|
|
1265
|
+
* keyFile: "addons/amxmodx/data/id_rsa",
|
|
1266
|
+
* upload: true,
|
|
1267
|
+
* file: "maps/de_dust2.bsp",
|
|
1268
|
+
* });
|
|
1269
|
+
* if (result.errorKind != "") console.error(`${result.errorKind}: ${result.errorText}`);
|
|
1270
|
+
* ```
|
|
1271
|
+
*
|
|
1272
|
+
* The promise settles on a later server frame: the game does not wait. In an
|
|
1273
|
+
* async command handler or a player's event, the player leaving aborts it.
|
|
1274
|
+
*/
|
|
1275
|
+
export function request(url: string, options: RequestOptions = {}): Promise<RequestResult> {
|
|
1276
|
+
const promise = __co_promise<RequestResult>();
|
|
1277
|
+
__netStart(new __RequestTask(promise), url, options);
|
|
1278
|
+
return promise;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
// ---------------------------------------------------------------- fetch
|
|
1282
|
+
|
|
1283
|
+
/** A fetch under way: its promise, settled from what the request ended with. */
|
|
1284
|
+
class __FetchTask extends __NetTask {
|
|
1285
|
+
constructor(public promise: Promise<Response>, public redirect: string) {
|
|
1286
|
+
super();
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
finish(result: RequestResult): void {
|
|
1290
|
+
const reason = result.__reason;
|
|
1291
|
+
if (reason) {
|
|
1292
|
+
__co_reject(this.promise, reason);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
if (result.status < 0) {
|
|
1296
|
+
__co_reject(this.promise, new TypeError(`fetch failed: ${result.errorText}`));
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
const status = result.status;
|
|
1300
|
+
if (this.redirect == "error" && status >= 300 && status <= 399 && result.headers.has("location")) {
|
|
1301
|
+
__co_reject(this.promise, new TypeError("fetch failed: unexpected redirect"));
|
|
1302
|
+
return;
|
|
1303
|
+
}
|
|
1304
|
+
__co_resolve(this.promise, Response.__received(status, result.statusText, result.headers, result.body, result.url, result.redirected));
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
/**
|
|
1309
|
+
* Sends a request and gives its response, as the browser's `fetch` does.
|
|
1310
|
+
*
|
|
1311
|
+
* ```ts
|
|
1312
|
+
* const response = await fetch("https://example.com/api/stats");
|
|
1313
|
+
* const stats = await response.json<Stats>();
|
|
1314
|
+
* ```
|
|
1315
|
+
*
|
|
1316
|
+
* The promise is fulfilled once the response's headers and body have come,
|
|
1317
|
+
* on a later server frame: the game does not wait for it. A status such as
|
|
1318
|
+
* `404` is a response too - check `response.ok`. The promise rejects with a
|
|
1319
|
+
* TypeError when there is no response (no connection, a bad address) and
|
|
1320
|
+
* with the signal's reason when `init.signal` aborts. In an async command
|
|
1321
|
+
* handler or a player's event, the player leaving aborts it too.
|
|
1322
|
+
*/
|
|
1323
|
+
export function fetch(input: string, init?: RequestInit): Promise<Response>;
|
|
1324
|
+
/** Sends a request to a URL made with `new URL`. */
|
|
1325
|
+
export function fetch(input: URL, init?: RequestInit): Promise<Response>;
|
|
1326
|
+
/** Sends a request made with `new Request`; what `init` gives takes the place of the request's own. */
|
|
1327
|
+
export function fetch(input: Request, init?: RequestInit): Promise<Response>;
|
|
1328
|
+
export function fetch<I>(input: I, init: RequestInit = {}): Promise<Response> {
|
|
1329
|
+
if (isString<I>()) return send(new Request(changetype<string>(input), init));
|
|
1330
|
+
if (idof<I>() == idof<URL>()) return send(new Request(changetype<URL>(input).href, init));
|
|
1331
|
+
return send(changetype<Request>(input).__with(init));
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function rejected(reason: Error): Promise<Response> {
|
|
1335
|
+
const promise = __co_promise<Response>();
|
|
1336
|
+
__co_reject(promise, reason);
|
|
1337
|
+
return promise;
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function send(request: Request): Promise<Response> {
|
|
1341
|
+
const url = URL.parse(request.url);
|
|
1342
|
+
if (!url) return rejected(new TypeError(`Failed to parse URL from ${request.url}`));
|
|
1343
|
+
if (url.protocol != "http:" && url.protocol != "https:") return rejected(new TypeError(`fetch failed: ${url.protocol} is not http: or https:`));
|
|
1344
|
+
const method = request.method;
|
|
1345
|
+
const body = request.__bodyText();
|
|
1346
|
+
if (body !== null && (method == "GET" || method == "HEAD")) return rejected(new TypeError("Request with GET/HEAD method cannot have body."));
|
|
1347
|
+
|
|
1348
|
+
const headers: Record<string, string> = {};
|
|
1349
|
+
const entries = request.headers.entries();
|
|
1350
|
+
for (let i: i32 = 0; i < entries.length; i++) headers[entries[i][0]] = entries[i][1];
|
|
1351
|
+
const options: RequestOptions = { method, headers, follow: request.redirect == "follow", signal: request.signal, proxy: request.__proxyUrl(), ca: request.__authorities() };
|
|
1352
|
+
if (body !== null) {
|
|
1353
|
+
if (!request.headers.has("content-type")) headers["content-type"] = "text/plain;charset=UTF-8";
|
|
1354
|
+
options.body = body as string;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
const promise = __co_promise<Response>();
|
|
1358
|
+
__netStart(new __FetchTask(promise, request.redirect), url.href, options);
|
|
1359
|
+
return promise;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// ---------------------------------------------------------------- useFetch
|
|
1363
|
+
|
|
1364
|
+
/** `useFetch`'s options. Every field is optional. */
|
|
1365
|
+
export interface UseFetchOptions<B = string> {
|
|
1366
|
+
/** The request's method, e.g. `"POST"`; `"GET"` by default. */
|
|
1367
|
+
method?: string;
|
|
1368
|
+
/** Names and values added to the URL's query: `{ page: "2" }` adds `?page=2`. */
|
|
1369
|
+
query?: Record<string, string>;
|
|
1370
|
+
/** The request's headers, as an object of names and values. */
|
|
1371
|
+
headers?: Record<string, string>;
|
|
1372
|
+
/**
|
|
1373
|
+
* The request's body: text as it is, or an object of the type given second
|
|
1374
|
+
* (`useFetch<Answer, Report>`) as JSON.
|
|
1375
|
+
*/
|
|
1376
|
+
body?: B;
|
|
1377
|
+
/** The number of further tries when a request fails on the way or the server answers `5xx`, `408` or `429`; `0` by default. */
|
|
1378
|
+
retry?: number;
|
|
1379
|
+
/** Milliseconds between two tries; `500` by default. */
|
|
1380
|
+
retryDelay?: number;
|
|
1381
|
+
/** Milliseconds a try may take before it is given up with a TimeoutError; no limit by default. */
|
|
1382
|
+
timeout?: number;
|
|
1383
|
+
/** A signal that cancels the request. */
|
|
1384
|
+
signal?: AbortSignal | null;
|
|
1385
|
+
/** A proxy the request goes through, e.g. `"http://proxy.example.com:3128"`. */
|
|
1386
|
+
proxy?: string;
|
|
1387
|
+
/** The check of the server's certificate, for HTTPS. */
|
|
1388
|
+
tls?: TlsOptions;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/** The answer of `useFetch`: the data or the error, and the status. */
|
|
1392
|
+
export interface FetchResult<T> {
|
|
1393
|
+
/** The response's JSON read into `T`; `null` when there was an error or no body. */
|
|
1394
|
+
data: T | null;
|
|
1395
|
+
/** Why there is no data: a FetchError for a status that is not `2xx`, a TypeError for no response, the signal's reason for an abort, a SyntaxError for a body that is not JSON. */
|
|
1396
|
+
error: Error | null;
|
|
1397
|
+
/** The response's HTTP status; `0` when there was no response. */
|
|
1398
|
+
status: number;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/** A response whose status is not `2xx`, as `useFetch` reports it. */
|
|
1402
|
+
export class FetchError extends Error {
|
|
1403
|
+
/** A response's error, e.g. `404 Not Found`. */
|
|
1404
|
+
constructor(
|
|
1405
|
+
/** The response's HTTP status, e.g. `404`. */
|
|
1406
|
+
public status: number,
|
|
1407
|
+
/** The words after the status, e.g. `"Not Found"`. */
|
|
1408
|
+
public statusText: string,
|
|
1409
|
+
/** The response's body as text: often what the server says went wrong. */
|
|
1410
|
+
public body: string
|
|
1411
|
+
) {
|
|
1412
|
+
super(`${status} ${statusText}`.trim());
|
|
1413
|
+
this.name = "FetchError";
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// The statuses worth a second try: the server was busy or failed for a moment.
|
|
1418
|
+
function retryable(status: number): bool {
|
|
1419
|
+
return status == 408 || status == 409 || status == 425 || status == 429 || status == 500 || status == 502 || status == 503 || status == 504;
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** `url` with `query` added to its query. */
|
|
1423
|
+
function withQuery(url: string, query: Record<string, string> | undefined): string {
|
|
1424
|
+
if (query === undefined || Object.keys(query as Record<string, string>).length == 0) return url;
|
|
1425
|
+
const text = new URLSearchParams(query as Record<string, string>).toString();
|
|
1426
|
+
const hash = url.indexOf("#");
|
|
1427
|
+
const head = hash >= 0 ? url.substring(0, hash) : url;
|
|
1428
|
+
const tail = hash >= 0 ? url.substring(hash) : "";
|
|
1429
|
+
return head + (head.includes("?") ? "&" : "?") + text + tail;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* Sends a request and reads its JSON into `T`, an interface of the fields
|
|
1434
|
+
* the JSON has. It never throws: what went wrong is `error`.
|
|
1435
|
+
*
|
|
1436
|
+
* ```ts
|
|
1437
|
+
* interface Weather {
|
|
1438
|
+
* temperature: number;
|
|
1439
|
+
* }
|
|
1440
|
+
*
|
|
1441
|
+
* const { data, error } = await useFetch<Weather>("https://example.com/weather", { query: { city: "Paris" } });
|
|
1442
|
+
* if (error) return console.error(error.message);
|
|
1443
|
+
* print(player, `${data!.temperature} °C`);
|
|
1444
|
+
* ```
|
|
1445
|
+
*
|
|
1446
|
+
* A body that is an object goes as JSON: `useFetch<Answer, Report>(url, { method: "POST", body: report })`.
|
|
1447
|
+
*/
|
|
1448
|
+
export async function useFetch<T, B = string>(url: string, options: UseFetchOptions<B> = {}): Promise<FetchResult<T>> {
|
|
1449
|
+
const target = withQuery(url, options.query);
|
|
1450
|
+
const headers: Record<string, string> = {};
|
|
1451
|
+
const given = options.headers;
|
|
1452
|
+
if (given !== undefined) {
|
|
1453
|
+
const names = Object.keys(given as Record<string, string>);
|
|
1454
|
+
for (let i: i32 = 0; i < names.length; i++) headers[names[i]] = (given as Record<string, string>)[names[i]];
|
|
1455
|
+
}
|
|
1456
|
+
const lower = Object.keys(headers).map<string>((name: string): string => name.toLowerCase());
|
|
1457
|
+
if (!lower.includes("accept")) headers["accept"] = isString<T>() ? "*/*" : "application/json";
|
|
1458
|
+
|
|
1459
|
+
let body: string | null = null;
|
|
1460
|
+
const value = options.body;
|
|
1461
|
+
if (value !== undefined) {
|
|
1462
|
+
if (isString<B>()) body = changetype<string>(value);
|
|
1463
|
+
else {
|
|
1464
|
+
body = JSON.stringify(value);
|
|
1465
|
+
if (!lower.includes("content-type")) headers["content-type"] = "application/json";
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
const tries = <i32>(options.retry ?? 0) + 1;
|
|
1470
|
+
const delay = options.retryDelay ?? 500;
|
|
1471
|
+
const signal = options.signal ?? null;
|
|
1472
|
+
let error: Error | null = null;
|
|
1473
|
+
let status: number = 0;
|
|
1474
|
+
|
|
1475
|
+
for (let attempt: i32 = 0; attempt < tries; attempt++) {
|
|
1476
|
+
if (attempt > 0) {
|
|
1477
|
+
try {
|
|
1478
|
+
await __co_sleep(delay, signal);
|
|
1479
|
+
}
|
|
1480
|
+
catch (aborted) {
|
|
1481
|
+
return { data: null, error: aborted as Error, status: 0 };
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
const timeout = options.timeout;
|
|
1486
|
+
const limit = timeout !== undefined ? AbortSignal.timeout(timeout as number) : null;
|
|
1487
|
+
const both = limit && signal ? AbortSignal.any([limit, signal as AbortSignal]) : limit ? limit : signal;
|
|
1488
|
+
let text = "";
|
|
1489
|
+
try {
|
|
1490
|
+
const init: RequestInit = { method: options.method ?? "GET", headers, signal: both, proxy: options.proxy ?? "", tls: options.tls };
|
|
1491
|
+
if (body !== null) init.body = body as string;
|
|
1492
|
+
const response = await fetch(target, init);
|
|
1493
|
+
status = response.status;
|
|
1494
|
+
text = await response.text();
|
|
1495
|
+
if (!response.ok) {
|
|
1496
|
+
error = new FetchError(response.status, response.statusText, text);
|
|
1497
|
+
if (retryable(response.status)) continue;
|
|
1498
|
+
return { data: null, error, status };
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
catch (failed) {
|
|
1502
|
+
error = failed as Error;
|
|
1503
|
+
status = 0;
|
|
1504
|
+
// An abort is the author's: no second try.
|
|
1505
|
+
if (error.name == "AbortError" || (signal && (signal as AbortSignal).aborted)) return { data: null, error, status };
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
if (isString<T>()) return { data: changetype<T>(text), error: null, status };
|
|
1510
|
+
if (text.length == 0) return { data: null, error: null, status };
|
|
1511
|
+
try {
|
|
1512
|
+
return { data: JSON.parse<T>(text), error: null, status };
|
|
1513
|
+
}
|
|
1514
|
+
catch (invalid) {
|
|
1515
|
+
return { data: null, error: invalid as Error, status };
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
return { data: null, error, status };
|
|
1519
|
+
}
|