metergraph-cli 0.0.0-stage → 0.2.0-preview.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/src/http.js ADDED
@@ -0,0 +1,129 @@
1
+ import http from "node:http";
2
+ import https from "node:https";
3
+
4
+ import { MAX_BODY_BYTES, PACKAGE_NAME, VERSION } from "./constants.js";
5
+
6
+ const USER_AGENT = `${PACKAGE_NAME}/${VERSION}`;
7
+
8
+ const DNS_ERRORS = new Set(["ENOTFOUND", "EAI_AGAIN", "EAI_FAIL", "EAI_NONAME"]);
9
+ const REFUSED_ERRORS = new Set(["ECONNREFUSED"]);
10
+ const RESET_ERRORS = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED"]);
11
+ const UNREACHABLE_ERRORS = new Set(["EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT"]);
12
+
13
+ // Sends one unauthenticated GET and resolves (never rejects) with either
14
+ // { kind: "response", status, headers, body, tooLarge }
15
+ // { kind: "error", reason, status }
16
+ // where reason is a fixed token from classifyError, and status is the HTTP
17
+ // status when the headers arrived before the failure (a body timeout, reset or
18
+ // truncation), otherwise null. Redirects are returned as ordinary responses
19
+ // and never followed. Only fixed headers are sent: no cookies, no
20
+ // authorization and nothing taken from the environment.
21
+ // The body is read only when readBody is true and the status is 200. Any
22
+ // other response is settled as soon as its headers arrive and its body is
23
+ // discarded, so a slow body cannot hide a status that is already known.
24
+ export function get(url, { signal, readBody }) {
25
+ return new Promise((resolve) => {
26
+ let settled = false;
27
+ let status = null;
28
+ const finish = (value) => {
29
+ if (!settled) {
30
+ settled = true;
31
+ resolve(value);
32
+ }
33
+ };
34
+ const fail = (error) =>
35
+ finish({ kind: "error", reason: classifyError(error, signal), status });
36
+
37
+ const transport = url.protocol === "https:" ? https : http;
38
+ let request;
39
+ try {
40
+ request = transport.request(url, {
41
+ method: "GET",
42
+ agent: false,
43
+ signal,
44
+ headers: {
45
+ accept: "application/json",
46
+ "accept-encoding": "identity",
47
+ "user-agent": USER_AGENT,
48
+ },
49
+ });
50
+ } catch (error) {
51
+ fail(error);
52
+ return;
53
+ }
54
+
55
+ request.on("error", fail);
56
+ request.on("response", (response) => {
57
+ response.on("error", fail);
58
+ status = response.statusCode;
59
+ const headers = response.headers;
60
+ const respond = (body, tooLarge) =>
61
+ finish({ kind: "response", status, headers, body, tooLarge });
62
+
63
+ if (!readBody || status !== 200) {
64
+ respond(null, false);
65
+ response.destroy();
66
+ return;
67
+ }
68
+
69
+ const declared = headers["content-length"];
70
+ if (declared !== undefined && Number(declared) > MAX_BODY_BYTES) {
71
+ respond(null, true);
72
+ response.destroy();
73
+ return;
74
+ }
75
+
76
+ const chunks = [];
77
+ let size = 0;
78
+ response.on("data", (chunk) => {
79
+ size += chunk.length;
80
+ if (size > MAX_BODY_BYTES) {
81
+ respond(null, true);
82
+ response.destroy();
83
+ return;
84
+ }
85
+ chunks.push(chunk);
86
+ });
87
+ response.on("end", () => respond(Buffer.concat(chunks), false));
88
+ // A close without end means the body was cut off.
89
+ response.on("close", () => fail(null));
90
+ });
91
+ request.end();
92
+ });
93
+ }
94
+
95
+ export function classifyError(error, signal) {
96
+ if (signal?.aborted) return "timeout";
97
+ const code = typeof error?.code === "string" ? error.code : "";
98
+ if (DNS_ERRORS.has(code)) return "dns_lookup_failed";
99
+ if (REFUSED_ERRORS.has(code)) return "connection_refused";
100
+ if (RESET_ERRORS.has(code)) return "connection_reset";
101
+ if (UNREACHABLE_ERRORS.has(code)) return "host_unreachable";
102
+ if (code.startsWith("HPE_")) return "invalid_http_response";
103
+ if (code.startsWith("ERR_TLS") || code.startsWith("ERR_SSL") || code.includes("CERT")) {
104
+ return "tls_error";
105
+ }
106
+ return "network_error";
107
+ }
108
+
109
+ // Decodes a bounded body as a JSON object. Returns the object, or null when
110
+ // the content type, encoding, text or top-level shape is not acceptable.
111
+ export function parseJsonObject(response) {
112
+ if (response.body === null || response.tooLarge) return null;
113
+ const encoding = response.headers["content-encoding"];
114
+ if (encoding !== undefined && encoding.trim().toLowerCase() !== "identity") return null;
115
+ const contentType = response.headers["content-type"];
116
+ if (typeof contentType !== "string") return null;
117
+ const mediaType = contentType.split(";")[0].trim().toLowerCase();
118
+ if (mediaType !== "application/json") return null;
119
+
120
+ let value;
121
+ try {
122
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(response.body);
123
+ value = JSON.parse(text);
124
+ } catch {
125
+ return null;
126
+ }
127
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
128
+ return value;
129
+ }
package/src/origin.js ADDED
@@ -0,0 +1,37 @@
1
+ const MAX_INPUT_LENGTH = 2048;
2
+
3
+ // A bare authority with an optional trailing slash: no userinfo, path, query,
4
+ // fragment or backslash. Checked on the raw text because the URL parser
5
+ // silently normalizes some of these away.
6
+ const HTTPS_ORIGIN = /^https:\/\/[^/?#@\\]+\/?$/i;
7
+
8
+ // Plaintext HTTP is only for these exact loopback spellings. Alternate forms
9
+ // such as 127.1 or 0x7f000001 are rejected even though they resolve locally.
10
+ const HTTP_LOOPBACK_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?\/?$/i;
11
+ const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
12
+
13
+ // Returns the normalized origin (for example "https://example.com") or null.
14
+ // The caller must not echo the raw input when this returns null.
15
+ export function parseOrigin(raw) {
16
+ if (typeof raw !== "string" || raw.length === 0 || raw.length > MAX_INPUT_LENGTH) {
17
+ return null;
18
+ }
19
+ // Printable ASCII only. Hosts with non-ASCII labels use their xn-- form.
20
+ if (/[^\x21-\x7e]/.test(raw)) return null;
21
+ if (!HTTPS_ORIGIN.test(raw) && !HTTP_LOOPBACK_ORIGIN.test(raw)) return null;
22
+
23
+ let url;
24
+ try {
25
+ url = new URL(raw);
26
+ } catch {
27
+ return null;
28
+ }
29
+
30
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
31
+ if (url.username !== "" || url.password !== "") return null;
32
+ if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null;
33
+ if (url.hostname === "" || url.port === "0") return null;
34
+ if (url.protocol === "http:" && !LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
35
+
36
+ return url.origin;
37
+ }