socket-function 0.14.0 → 0.15.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/SocketFunction.ts CHANGED
@@ -25,6 +25,18 @@ setImmediate(async () => {
25
25
 
26
26
  setFlag(require, "cbor-x", "allowclient", true);
27
27
  let cborxInstance = new cborx.Encoder({ structuredClone: true });
28
+ if (isNode()) {
29
+ // Do not crash on unhandled errors. SocketFunction is made to run a webserver,
30
+ // which will run perfectly after 99.9% of errors. Crashing the process is
31
+ // not a good alternative to proper error log and notifications. Do you guys
32
+ // not get automated emails when unexpected errors are logged? I do.
33
+ process.on("unhandledRejection", (e) => {
34
+ console.error("Unhandled rejection", e);
35
+ });
36
+ process.on("uncaughtException", (e) => {
37
+ console.error("Uncaught exception", e);
38
+ });
39
+ }
28
40
 
29
41
  module.allowclient = true;
30
42
 
@@ -72,6 +84,7 @@ export class SocketFunction {
72
84
  return caller;
73
85
  }
74
86
 
87
+ private static getShapeHotReloadable = new Map<string, () => SocketExposedShape<SocketExposedInterface>>();
75
88
  // NOTE: We use callbacks we don't run into issues with cyclic dependencies
76
89
  // (ex, using a hook in a controller where the hook also calls the controller).
77
90
  public static register<
@@ -107,7 +120,9 @@ export class SocketFunction {
107
120
 
108
121
  for (let value of Object.values(shape)) {
109
122
  if (!value) continue;
110
- value.clientHooks = [...(defaultHooks?.clientHooks || []), ...(value.clientHooks || [])];
123
+ if (!value.noClientHooks) {
124
+ value.clientHooks = [...(defaultHooks?.clientHooks || []), ...(value.clientHooks || [])];
125
+ }
111
126
  if (value.noDefaultHooks) {
112
127
  value.hooks = [...(value.hooks || [])];
113
128
  } else {
@@ -118,6 +133,9 @@ export class SocketFunction {
118
133
  return shape as any as SocketExposedShape;
119
134
  });
120
135
 
136
+ // Wait, so any constants referenced by the base shapeFnc will be fully resolved
137
+ // by now. This is IMPORTANT, as it allows permissions functions to be moved
138
+ // to a common module, instead of all being inline.
121
139
  void Promise.resolve().then(() => {
122
140
  registerClass(classGuid, instance as SocketExposedInterface, getShape(), {
123
141
  noFunctionMeasure: config?.noFunctionMeasure,
@@ -38,6 +38,7 @@ export type SocketExposedShape<ExposedType extends SocketExposedInterface = Sock
38
38
  hooks?: SocketFunctionHook<ExposedType>[];
39
39
  clientHooks?: SocketFunctionClientHook<ExposedType>[];
40
40
  noDefaultHooks?: boolean;
41
+ noClientHooks?: boolean;
41
42
  };
42
43
  };
43
44
 
@@ -44,12 +44,17 @@ declare global {
44
44
  noserverhotreload?: boolean;
45
45
  }
46
46
  }
47
+ var isHotReloading: (() => boolean) | undefined;
47
48
  }
48
49
 
49
50
  let isHotReloadingValue = false;
50
51
  export function isHotReloading() {
51
52
  return isHotReloadingValue;
52
53
  }
54
+ globalThis.isHotReloading = isHotReloading;
55
+ export function hotReloadingGuard(): true {
56
+ return !isHotReloading() as any;
57
+ }
53
58
  export function setExternalHotReloading(value: boolean) {
54
59
  isHotReloadingValue = value;
55
60
  }
@@ -86,9 +91,14 @@ const hotReloadModule = cache((module: NodeJS.Module) => {
86
91
  console.error(red(`Error hot reloading ${module.id}`));
87
92
  console.error(e);
88
93
  } finally {
89
- isHotReloadingValue = false;
94
+ setTimeout(() => {
95
+ isHotReloadingValue = false;
96
+ }, 1000);
90
97
  }
91
98
  }
99
+ for (let callback of hotReloadCallbacks) {
100
+ callback([module]);
101
+ }
92
102
  }
93
103
  triggerClientSideReload({
94
104
  files: [module.filename],
@@ -149,7 +159,14 @@ class HotReloadControllerBase {
149
159
  for (let module of modules) {
150
160
  module.loaded = false;
151
161
  }
152
- await Promise.all(modules.map(module => module.load(module.filename)));
162
+ isHotReloadingValue = true;
163
+ try {
164
+ await Promise.all(modules.map(module => module.load(module.filename)));
165
+ } finally {
166
+ setTimeout(() => {
167
+ isHotReloadingValue = false;
168
+ }, 1000);
169
+ }
153
170
 
154
171
  for (let callback of hotReloadCallbacks) {
155
172
  callback(modules);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "socket-function",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "main": "index.js",
5
5
  "license": "MIT",
6
6
  "note1": "note on node-forge fork, see https://github.com/digitalbazaar/forge/issues/744 for details",
@@ -8,7 +8,6 @@
8
8
  "@types/pako": "^2.0.3",
9
9
  "@types/ws": "^8.5.3",
10
10
  "cbor-x": "^1.5.6",
11
- "cookie": "^0.5.0",
12
11
  "mobx": "^6.6.2",
13
12
  "node-forge": "https://github.com/sliftist/forge#name",
14
13
  "pako": "^2.1.0",
@@ -21,7 +20,8 @@
21
20
  },
22
21
  "scripts": {
23
22
  "test": "yarn typenode ./test/server.ts",
24
- "type": "yarn tsc --noEmit"
23
+ "type": "yarn tsc --noEmit",
24
+ "testsni": "yarn typenode ./src/sniTest.ts"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/cookie": "^0.5.1",
@@ -69,6 +69,10 @@ export interface SerializedModule {
69
69
 
70
70
  size?: number;
71
71
  version?: number;
72
+
73
+ flags?: {
74
+ [flag: string]: true;
75
+ };
72
76
  }
73
77
 
74
78
  let nextModuleSeqNum = 1;
@@ -203,7 +207,13 @@ class RequireControllerBase {
203
207
  size: module.size,
204
208
  version: module.version,
205
209
  asyncRequests: module.asyncRequires,
210
+ flags: {},
206
211
  };
212
+ for (let [flag, value] of Object.entries(module)) {
213
+ if (value === true) {
214
+ modules[module.filename].flags![flag] = value;
215
+ }
216
+ }
207
217
  let moduleObj = modules[module.filename];
208
218
  if (moduleObj.allowclient) {
209
219
  moduleObj.source = module.moduleContents;
@@ -3,6 +3,9 @@
3
3
 
4
4
  let startTime = Date.now();
5
5
 
6
+ Symbol.dispose = Symbol.dispose || Symbol("dispose");
7
+ Symbol.asyncDispose = Symbol.asyncDispose || Symbol("asyncDispose");
8
+
6
9
  // Globals
7
10
  Object.assign(window, {
8
11
  process: {
@@ -397,6 +400,9 @@
397
400
  module.exports = {};
398
401
  module.exports.default = module.exports;
399
402
  module.children = [];
403
+ for (let key in serializedModule.flags || {}) {
404
+ module[key] = true;
405
+ }
400
406
 
401
407
  module.load = load;
402
408
 
@@ -69,7 +69,7 @@ export function isDataImmutable(call: CallType) {
69
69
  export function registerClass(classGuid: string, controller: SocketExposedInterface, shape: SocketExposedShape, config?: {
70
70
  noFunctionMeasure?: boolean;
71
71
  }) {
72
- if (classes[classGuid]) {
72
+ if (!isHotReloading?.() && classes[classGuid]) {
73
73
  throw new Error(`Class ${classGuid} already registered`);
74
74
  }
75
75
 
@@ -154,9 +154,15 @@ export function formatNumber(count: number | undefined, maxAbsoluteValue?: numbe
154
154
  } else if (maxAbsoluteValue < 1000 * 1000 * 1000 * extraFactor) {
155
155
  suffix = "M";
156
156
  divisor = 1000 * 1000;
157
- } else {
157
+ } else if (maxAbsoluteValue < 1000 * 1000 * 1000 * 1000 * extraFactor) {
158
158
  suffix = "B";
159
159
  divisor = 1000 * 1000 * 1000;
160
+ } else if (maxAbsoluteValue < 1000 * 1000 * 1000 * 1000 * 1000 * extraFactor) {
161
+ suffix = "T";
162
+ divisor = 1000 * 1000 * 1000 * 1000;
163
+ } else {
164
+ suffix = "Q";
165
+ divisor = 1000 * 1000 * 1000 * 1000 * 1000;
160
166
  }
161
167
  count /= divisor;
162
168
  maxAbsoluteValue /= divisor;
@@ -14,5 +14,6 @@ export const red = ansiHSL.bind(null, 0, 100, lightness);
14
14
  export const green = (text: string) => `\x1b[32m${text}\x1b[0m`;
15
15
  export const yellow = (text: string) => `\x1b[33m${text}\x1b[0m`;
16
16
  export const white = ansiHSL.bind(null, 0, 0, 80);
17
+ export const gray = ansiHSL.bind(null, 0, 0, 50);
17
18
 
18
19
  export const magenta = (text: string) => `\x1b[35m${text}\x1b[0m`;
@@ -0,0 +1,157 @@
1
+ import * as dgram from "dgram";
2
+ import os from "os";
3
+
4
+ const SSDP_DISCOVER_MX = 2;
5
+ const SSDP_DISCOVER_MSG = `M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: "ssdp:discover"\r\nMX: ${SSDP_DISCOVER_MX}\r\nST: urn:schemas-upnp-org:device:InternetGatewayDevice:1\r\n\r\n`;
6
+
7
+ export async function forwardPort(config: {
8
+ externalPort: number;
9
+ internalPort: number;
10
+ }) {
11
+ const { externalPort, internalPort } = config;
12
+
13
+ const localObj = getLocalInterfaceAddress();
14
+ if (!localObj) throw new Error("Could not find the local address / gateway");
15
+
16
+ const { internalIP, gatewayIP } = localObj;
17
+ let gateway = await discoverGateway(internalIP);
18
+ let controlURLs = await getControlPaths(gateway);
19
+ let controlPort = Number(new URL(gateway).port);
20
+
21
+ for (let controlURL of controlURLs) {
22
+ try {
23
+ await createPortMapping({
24
+ externalPort, internalPort,
25
+ gatewayIP,
26
+ controlPort,
27
+ controlPath: controlURL,
28
+ internalIP,
29
+ });
30
+ return;
31
+ } catch (e) {
32
+ console.error(e);
33
+ }
34
+ }
35
+ console.error("Failed to create port mapping, could not find controlURL");
36
+ }
37
+
38
+ function getLocalInterfaceAddress(): { internalIP: string; gatewayIP: string; } | undefined {
39
+ const interfaces = os.networkInterfaces() as any;
40
+ for (const name of Object.keys(interfaces)) {
41
+ for (const iface of interfaces[name]) {
42
+ if (iface.family === "IPv4" && !iface.internal) {
43
+ // TOOD: Correctly resolve the cidr?
44
+ let gatewayIP = iface.cidr.split(".").slice(0, 3).join(".") + ".1";
45
+ // TOOD: We try discovery on all gateways, so we can know for sure which one it is
46
+ // (and maybe even port forward all gateway, if multiple respond?)
47
+ if (gatewayIP.startsWith("10.0.0") || gatewayIP.startsWith("10.0.1") || gatewayIP.startsWith("192.168.0")) {
48
+ return { internalIP: iface.address, gatewayIP };
49
+ }
50
+ }
51
+ }
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ function discoverGateway(localAddress: string): Promise<string> {
57
+ return new Promise((resolve, reject) => {
58
+ const socket = dgram.createSocket("udp4");
59
+ let isResolved = false;
60
+
61
+ if (!localAddress) {
62
+ reject(new Error("Could not find a suitable local address"));
63
+ return;
64
+ }
65
+
66
+ socket.on("message", (msg) => {
67
+ const response = msg.toString();
68
+ const location = response.match(/LOCATION: (.*)\r\n/i);
69
+ if (location && location[1]) {
70
+ isResolved = true;
71
+ socket.close();
72
+ resolve(location[1]);
73
+ }
74
+ });
75
+
76
+ socket.on("error", (err) => {
77
+ socket.close();
78
+ if (!isResolved) {
79
+ reject(err);
80
+ }
81
+ });
82
+
83
+ socket.on("listening", () => {
84
+ socket.addMembership("239.255.255.250", localAddress);
85
+ });
86
+
87
+ socket.bind({ address: localAddress }, () => {
88
+ socket.setBroadcast(true);
89
+ socket.send(SSDP_DISCOVER_MSG, 0, SSDP_DISCOVER_MSG.length, 1900, "239.255.255.250");
90
+ });
91
+
92
+ setTimeout(() => {
93
+ if (!isResolved) {
94
+ socket.close();
95
+ reject(new Error(`SSDP discovery timeout. Search on ${localAddress}`));
96
+ }
97
+ }, SSDP_DISCOVER_MX * 1000);
98
+ });
99
+ }
100
+
101
+ async function getControlPaths(gateway: string) {
102
+ let xml = await (await fetch(gateway)).text();
103
+ const controlURLRegex = /<controlURL>(.*?)<\/controlURL>/g;
104
+ const matches = [];
105
+ let match;
106
+ while ((match = controlURLRegex.exec(xml)) !== null) {
107
+ matches.push(match[1]);
108
+ }
109
+ matches.reverse();
110
+ return matches;
111
+ }
112
+
113
+ async function createPortMapping(config: {
114
+ externalPort: number;
115
+ internalPort: number;
116
+ gatewayIP: string;
117
+ controlPort: number;
118
+ controlPath: string;
119
+ internalIP: string;
120
+
121
+ }): Promise<void> {
122
+ const { externalPort, internalPort, internalIP, controlPath, controlPort, gatewayIP } = config;
123
+ const action = "\"urn:schemas-upnp-org:service:WANIPConnection:1#AddPortMapping\"";
124
+
125
+ const soapBody = `
126
+ <?xml version="1.0"?>
127
+ <s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
128
+ <s:Body>
129
+ <u:AddPortMapping xmlns:u="urn:schemas-upnp-org:service:WANIPConnection:1">
130
+ <NewRemoteHost></NewRemoteHost>
131
+ <NewExternalPort>${externalPort}</NewExternalPort>
132
+ <NewProtocol>TCP</NewProtocol>
133
+ <NewInternalPort>${internalPort}</NewInternalPort>
134
+ <NewInternalClient>${internalIP}</NewInternalClient>
135
+ <NewEnabled>1</NewEnabled>
136
+ <NewPortMappingDescription>My Port Mapping</NewPortMappingDescription>
137
+ <NewLeaseDuration>0</NewLeaseDuration>
138
+ </u:AddPortMapping>
139
+ </s:Body>
140
+ </s:Envelope>
141
+ `;
142
+
143
+ const res = await fetch(`http://${gatewayIP}:${controlPort}${controlPath}`, {
144
+ method: "POST",
145
+ headers: {
146
+ "Content-Type": "text/xml; charset=\"utf-8\"",
147
+ "SOAPAction": action,
148
+ "Content-Length": Buffer.byteLength(soapBody) + "",
149
+ },
150
+ body: soapBody
151
+ });
152
+
153
+ if (res.status !== 200) {
154
+ const data = await res.text();
155
+ throw new Error(`Failed to create port mapping: ${data}`);
156
+ }
157
+ }
package/src/https.ts ADDED
@@ -0,0 +1,115 @@
1
+ import * as https from "https";
2
+ import * as http from "http";
3
+ import { isNode } from "./misc";
4
+
5
+ const textEncoder = new TextEncoder();
6
+ const textDecoder = new TextDecoder();
7
+
8
+ export function httpsRequest(
9
+ url: string,
10
+ payload?: Buffer | Buffer[],
11
+ method = "GET",
12
+ sendSessionCookies = true,
13
+ config?: {
14
+ headers?: { [key: string]: string },
15
+ }
16
+ ): Promise<Buffer> {
17
+ if (isNode()) {
18
+ return (async () => {
19
+ let urlObj = new URL(url);
20
+
21
+ let requestor = url.startsWith("https") ? https : http;
22
+ let port = url.startsWith("https") ? 443 : 80;
23
+ if (urlObj.port) {
24
+ port = +urlObj.port;
25
+ }
26
+
27
+ return new Promise<Buffer>((resolve, reject) => {
28
+ let httpRequest = requestor.request(
29
+ urlObj + "",
30
+ {
31
+ method,
32
+ headers: config?.headers,
33
+ },
34
+ async httpResponse => {
35
+ let data: Buffer[] = [];
36
+ httpResponse.on("data", chunk => {
37
+ data.push(chunk);
38
+ });
39
+
40
+ await new Promise<void>(resolve => {
41
+ httpResponse.on("end", () => {
42
+ resolve();
43
+ });
44
+ });
45
+
46
+ if (!httpResponse.statusCode?.toString().startsWith("2")) {
47
+ reject(new Error(`Error for ${url}, ${httpResponse.statusCode} ${httpResponse.statusMessage}\n` + Buffer.concat(data).toString()));
48
+ } else {
49
+ resolve(Buffer.concat(data));
50
+ }
51
+ }
52
+ );
53
+ httpRequest.on("error", reject);
54
+
55
+ if (payload) {
56
+ if (Array.isArray(payload)) {
57
+ payload = Buffer.concat(payload);
58
+ }
59
+ httpRequest.write(payload);
60
+ }
61
+ httpRequest.end();
62
+ });
63
+ })();
64
+
65
+ } else {
66
+ var request = new XMLHttpRequest();
67
+ request.open(method, url, true);
68
+ if (config?.headers) {
69
+ for (let key in config.headers) {
70
+ request.setRequestHeader(key, config.headers[key]);
71
+ }
72
+ }
73
+ request.responseType = "arraybuffer";
74
+ request.withCredentials = sendSessionCookies;
75
+ if (payload) {
76
+ if (Array.isArray(payload)) {
77
+ payload = Buffer.concat(payload);
78
+ }
79
+ request.send(payload);
80
+ } else {
81
+ request.send();
82
+ }
83
+ return new Promise((resolve, reject) => {
84
+ request.onload = () => {
85
+ if (request.status !== 200) {
86
+ try {
87
+ // It should be an error.stack. But if it isn't... just throw the status text...
88
+ let responseText = textDecoder.decode(request.response);
89
+ let message = responseText.split("\n")[0];
90
+
91
+ let error = new Error(`For ${url}, ` + message);
92
+ error.stack = `For ${url}, ` + responseText;
93
+
94
+ reject(error);
95
+
96
+ } catch (e: any) {
97
+ reject(new Error(`For ${url}, ` + request.statusText));
98
+ }
99
+ } else {
100
+ resolve(Buffer.from(request.response));
101
+ }
102
+ };
103
+
104
+ request.onerror = (e) => {
105
+ reject(new Error(`Network error for request at ${url}`));
106
+ };
107
+ request.ontimeout = (e) => {
108
+ reject(new Error(`Network timeout for request at ${url}`));
109
+ };
110
+ request.onabort = (e) => {
111
+ reject(new Error(`Network abort for request at ${url}`));
112
+ };
113
+ });
114
+ }
115
+ }
package/src/misc.ts CHANGED
@@ -126,13 +126,6 @@ export function getStringKeys<T extends {}>(obj: T): ((keyof T) & string)[] {
126
126
  return Object.keys(obj) as any;
127
127
  }
128
128
 
129
- if (isNode()) {
130
- // TODO: Find a better place for this...
131
- process.on("unhandledRejection", async (reason: any, promise) => {
132
- console.error(`Uncaught promise rejection: ${String(reason.stack || reason)}`);
133
- });
134
- }
135
-
136
129
  export function keyBy<T, K>(arr: T[], getKey: (value: T) => K): Map<K, T> {
137
130
  let map = new Map<K, T>();
138
131
  for (let item of arr) {
@@ -159,43 +152,44 @@ export function deepCloneJSON<T>(obj: T): T {
159
152
  return JSON.parse(JSON.stringify(obj));
160
153
  }
161
154
 
155
+ export class PromiseObj<T = void> {
156
+ public promise: Promise<T>;
157
+ public value: { value?: T; error?: string } | undefined;
158
+ /** Resolve called does not mean the value is ready, as it may be resolved with a promise. */
159
+ public resolveCalled?: boolean;
162
160
 
161
+ public resolve(value: T | Promise<T>) {
162
+ this.resolveCalled = true;
163
+ if (typeof value === "object" && value !== null && value instanceof Promise) {
164
+ value.then(
165
+ value => this.value = { value },
166
+ error => this.value = { error },
167
+ );
168
+ } else {
169
+ this.value = { value };
170
+ }
171
+ this.baseResolve(value);
172
+ }
173
+ public reject(error: any) {
174
+ this.baseReject(error);
175
+ }
163
176
 
164
- export interface PromiseObj<T = void> {
165
- resolve: (value: T | Promise<T>) => void;
166
- reject: (error: any) => void;
167
- promise: Promise<T>;
168
- value: { value?: T; error?: string } | undefined;
169
- /** Resolve called does not mean the value is ready, as it may be resolved with a promise. */
170
- resolveCalled?: boolean;
177
+ private baseResolve!: (value: T | Promise<T>) => void;
178
+ private baseReject!: (error: any) => void;
179
+ constructor() {
180
+ this.promise = new Promise<T>((resolve, reject) => {
181
+ this.baseResolve = resolve;
182
+ this.baseReject = reject;
183
+ });
184
+ this.promise.then(
185
+ value => this.value = { value },
186
+ error => this.value = { error }
187
+ );
188
+ }
171
189
  }
172
190
 
173
191
  export function promiseObj<T = void>(): PromiseObj<T> {
174
- let resolve!: (value: T | Promise<T>) => void;
175
- let reject!: (error: any) => void;
176
- let promise = new Promise<T>((_resolve, _reject) => {
177
- resolve = _resolve;
178
- reject = _reject;
179
- });
180
- let obj: PromiseObj<T> = {
181
- resolve(value: T | Promise<T>) {
182
- obj.resolveCalled = true;
183
- if (typeof value === "object" && value !== null && value instanceof Promise) {
184
- value.then(
185
- value => obj.value = { value },
186
- error => obj.value = { error },
187
- );
188
- } else {
189
- obj.value = { value };
190
- }
191
- resolve(value);
192
- },
193
- reject,
194
- promise,
195
- value: undefined
196
- };
197
- promise.then(value => obj.value = { value }, error => obj.value = { error });
198
- return obj;
192
+ return new PromiseObj<T>();
199
193
  }
200
194
 
201
195
 
@@ -291,13 +285,29 @@ export function entries<Obj extends { [key: string]: unknown }>(obj: Obj): [keyo
291
285
  return Object.entries(obj) as any;
292
286
  }
293
287
 
288
+ export function keys<Obj extends { [key: string]: unknown }>(obj: Obj): (keyof Obj)[] {
289
+ return Object.keys(obj) as any;
290
+ }
291
+
294
292
  export function sort<T>(arr: T[], sortKey: (obj: T) => unknown) {
295
293
  if (arr.length <= 1) return arr;
296
294
  arr.sort((a, b) => compare(sortKey(a), sortKey(b)));
297
295
  return arr;
298
296
  }
299
297
 
300
- // NOTE: If there are duplicates, returns the first match.
298
+ export function binarySearchBasic<T, V>(array: T[], getVal: (val: T) => V, searchValue: V): number {
299
+ return binarySearchIndex(array.length, i => compare(getVal(array[i]), searchValue));
300
+ }
301
+
302
+ /**
303
+ * Searches indexes, allowing you to query structures that aren't arrays. To search an array, use:
304
+ * `binarySearchIndex(array.length, i => compare(array[i], searchValue))`
305
+ *
306
+ * NOTE: If there are duplicates, returns the first match.
307
+ *
308
+ * NOTE: If the value can't be found, returns the bitwise negation of the index where it should be inserted.
309
+ * - If you just want the index which is >=, use `if(index < 0) index = ~index;`
310
+ */
301
311
  export function binarySearchIndex(listCount: number, compare: (lhsIndex: number) => number): number {
302
312
  if (listCount === 0) {
303
313
  return ~0;
@@ -0,0 +1,44 @@
1
+ import net from "net";
2
+ import { lazy } from "./caching";
3
+ import { httpsRequest } from "./https";
4
+ import { measureWrap } from "./profiling/measure";
5
+
6
+ export const testTCPIsListening = measureWrap(async function testTCPIsListening(host: string, port: number): Promise<boolean> {
7
+ // We need to establish a TCP connection, then close it? Yeah... so it is
8
+ // not even a SocketFunction call, because it can't be, because that woule be TLS,
9
+ // which we can't do with an ip!
10
+ let socket = net.connect({ host, port });
11
+ return new Promise((resolve) => {
12
+ socket.on("connect", () => {
13
+ socket.end();
14
+ resolve(true);
15
+ });
16
+ socket.on("error", () => {
17
+ resolve(false);
18
+ });
19
+ setTimeout(() => {
20
+ socket.end();
21
+ resolve(false);
22
+ }, 1000 * 60);
23
+ });
24
+ });
25
+
26
+
27
+ const ipServers = [
28
+ "http://quentinbrooks.com:4283",
29
+ "https://ipinfo.io/ip",
30
+ "https://api.ipify.org"
31
+ ];
32
+
33
+ export const getExternalIP = lazy(measureWrap(async function getExternalIP(): Promise<string> {
34
+ for (let server of ipServers) {
35
+ try {
36
+ return (await httpsRequest(server)).toString();
37
+ } catch (e) {
38
+ console.warn(`Failed to get external ip from ${server}: ${e}`);
39
+ }
40
+ }
41
+ throw new Error(`Failed to get external ip from any server`);
42
+ }));
43
+
44
+ export const getPublicIP = getExternalIP;
@@ -62,12 +62,14 @@ export function measureWrap<T extends (...args: any[]) => any>(fnc: T, name?: st
62
62
  return fnc;
63
63
  }
64
64
  let usedName = name || fnc.name || fnc.toString().slice(0, 100).replaceAll(/\s/g, " ");
65
- return nameFunction(usedName, (function (this: any, ...args: unknown[]): unknown {
65
+ let output = nameFunction(usedName, (function (this: any, ...args: unknown[]): unknown {
66
66
  if (outstandingProfiles.length === 0) {
67
67
  return fnc.apply(this, args);
68
68
  }
69
69
  return getOwnTime(usedName, () => fnc.apply(this, args), recordOwnTime);
70
70
  })) as T;
71
+ (output as any).originalFnc = fnc;
72
+ return output;
71
73
  }
72
74
  export function measureBlock<T extends (...args: any[]) => any>(fnc: T, name?: string): ReturnType<T> {
73
75
  return measureWrap(fnc, name)();
package/src/sniTest.ts ADDED
@@ -0,0 +1,101 @@
1
+ import { parseSNIExtension, parseTLSHello, SNIType } from "./tlsParsing";
2
+
3
+ let tlsExtensionLookup: { [type: number]: string } = {
4
+ 0: "server_name",
5
+ 1: "max_fragment_length",
6
+ 2: "client_certificate_url",
7
+ 3: "trusted_ca_keys",
8
+ 4: "truncated_hmac",
9
+ 5: "status_request",
10
+ 6: "user_mapping",
11
+ 7: "client_authz",
12
+ 8: "server_authz",
13
+ 9: "cert_type",
14
+ 10: "supported_groups",
15
+ 11: "ec_point_formats",
16
+ 12: "srp",
17
+ 13: "signature_algorithms",
18
+ 14: "use_srtp",
19
+ 15: "heartbeat",
20
+ 16: "application_layer_protocol_negotiation",
21
+ 17: "status_request_v2",
22
+ 18: "signed_certificate_timestamp",
23
+ 19: "client_certificate_type",
24
+ 20: "server_certificate_type",
25
+ 21: "padding",
26
+ 22: "encrypt_then_mac",
27
+ 23: "extended_master_secret",
28
+ 24: "token_binding",
29
+ 25: "cached_info",
30
+ 26: "tls_lts",
31
+ 27: "compress_certificate",
32
+ 28: "record_size_limit",
33
+ 29: "pwd_protect",
34
+ 30: "pwd_clear",
35
+ 31: "password_salt",
36
+ 32: "ticket_pinning",
37
+ 33: "tls_cert_with_extern_psk",
38
+ 34: "delegated_credential",
39
+ 35: "session_ticket",
40
+ 36: "TLMSP",
41
+ 37: "TLMSP_proxying",
42
+ 38: "TLMSP_delegate",
43
+ 39: "supported_ekt_ciphers",
44
+ 40: "Reserved",
45
+ 41: "pre_shared_key",
46
+ 42: "early_data",
47
+ 43: "supported_versions",
48
+ 44: "cookie",
49
+ 45: "psk_key_exchange_modes",
50
+ 46: "Reserved",
51
+ 47: "certificate_authorities",
52
+ 48: "oid_filters",
53
+ 49: "post_handshake_auth",
54
+ 50: "signature_algorithms_cert",
55
+ 51: "key_share",
56
+ 52: "transparency_info",
57
+ 53: "connection_id",
58
+ 54: "connection_id",
59
+ 55: "external_id_hash",
60
+ 56: "external_session_id",
61
+ 57: "quic_transport_parameters",
62
+ 58: "ticket_request",
63
+ 59: "dnssec_chain",
64
+ 60: "sequence_number_encryption_algorithms",
65
+ 61: "rrc",
66
+ 2570: "encrypted_client_hello?",
67
+ 17513: "application_settings",
68
+ 6682: "generated_random_extensions_and_sustain_extensibility",
69
+ 10794: "generated_random_extensions_and_sustain_extensibility",
70
+ 14906: "generated_random_extensions_and_sustain_extensibility",
71
+ 19018: "generated_random_extensions_and_sustain_extensibility",
72
+ 23130: "generated_random_extensions_and_sustain_extensibility",
73
+ 27242: "generated_random_extensions_and_sustain_extensibility",
74
+ 31354: "generated_random_extensions_and_sustain_extensibility",
75
+ 35466: "generated_random_extensions_and_sustain_extensibility",
76
+ 39578: "generated_random_extensions_and_sustain_extensibility",
77
+ 43690: "generated_random_extensions_and_sustain_extensibility",
78
+ 47802: "generated_random_extensions_and_sustain_extensibility",
79
+ 51914: "generated_random_extensions_and_sustain_extensibility",
80
+ 56026: "generated_random_extensions_and_sustain_extensibility",
81
+ 60138: "generated_random_extensions_and_sustain_extensibility",
82
+ 64250: "generated_random_extensions_and_sustain_extensibility",
83
+ 65037: "encrypted_client_hello",
84
+ 65281: "renegotiation_info" // 0xFF01
85
+ };
86
+
87
+ async function main() {
88
+ const packet = Buffer.from(
89
+ `FgMBBwgBAAcEAwNWbpKnOzI9CRp2ESvA5QzCXg5FYncEObckUkNoG3+/DyAwI4HL0havBXKvPlJtZJBgtZ+I/FqBlKGek8NGJcgdqQAgiooTARMCEwPAK8AvwCzAMMypzKjAE8AUAJwAnQAvADUBAAabSkoAAAAbAAMCAAIACgAMAArq6mOZAB0AFwAYAAUABQEAAAAAAC0AAgEB/wEAAQAAIwAAAAsAAgEAABIAAAANABIAEAQDCAQEAQUDCAUFAQgGBgEAFwAA/g0AugAAAQABCwAgIRJsXQHGN5cbIp9ucgXJ824RS/6hqIikNiMKw7/gsDAAkK8j0YYXYsSnpX4siEVnB/AxzQCPaGMoT4y1i63f4mzV0Sa6puc4sZqkxY7TyQMJLrGPL00HaJZh6ReT/ggPbCwg5f5/2hUCdZSx4aUpYMb7lAx9VVJjhrquZIRRVrk8yV0OjEivreTN02u1mgIgMY/0P8RzOea+iQluEF9ASOYxgNIU20xoKK608ktQrTafSQAQAAsACQhodHRwLzEuMQAzBO8E7erqAAEAY5kEwB8fuK+qumVLi67+BmQ5uQ556Opa122UZFmnfuoKQY1tiXkzM7Z+wmWOLTg7l3Ncu8ECL/Z9G6ZLP4N5OKGjiHE307sEvNRqdEnFYoQMVotF6hNbt3F4GrEBa6CuCEohg9HKg3RtIfMFYNc1lDC4rJCLwNETijo5MlOFnYVGpWOMcBZbdLsGv5JxN7idRUGvxsttaya3GYxSI1yFW9dZaysTsYXA2IzPp7M950GkJzBK5Zy3wBiXoZYGYKJGy5EA1XIE88Y6X2VNghPIatHHjdav+PpsLZCyUowEsAN49gh/yRrBNZxo6gNbfOIds/UpjlYtIEo1lch2ygWzVLS55qxm35IQUVKQjEQiPDNUI6g9RZZD+VSH5vB1ouMwTqbDAAN9EsUxigYmQggjz1jGFEm+DecEQTkXoQOIwWsAq+M39Eq7M3CwYVQ6yDQI+rQD9xxin8I9PrdVoXGaTVwnY9GFKFCYZyewSGeuPGSaPcdKwvBb/IgfFVRt2aU24XNmFsCkgLSzHEkBg1uYqjd+uIY7zTWo5WIevniL4SCIvRyvTQB7DICAZ+BxSUxio9x19OWQCitnoXdoGGwuTIQqSYuimFBedKa2b4Yk3nkPIRqa3jldbGMRwQmpaQFnsOJml/pEr3sQIuEcQ2shaIuqW+Zo86yamflqpCmCbQWNiipRYwQ/AmG7oOeS1iJomPiRJDwVNaoDZzI2ApYIcQckNPNrI9qOrGwww6x1T9EwKadz/1OcHQcbWtej5QkLm5Ss/9paxqYsu1kuohlvPOSd4txqKOGNvOhzxqqOweZ7p8l/H/JhnMkEUCZYvOW4JhthXsYMfnWn8rciE7KuMskf65mqI1tI5SZqzxAfH4MidyUJRNwR6mwWUgzMi7kB3eM3IPUbCDpUIpQBipt2SyenIMXNH9ZjnYtdCwVDe7m6mshYAJVV7oJDw5yn2pYH5nsZ+gM93vNWBvZVQjZZmRdZQ2YqhKaUyQgpOEhlE1w7XAXJp+um2kA5qElw9sNbxvOziNSPLyi17xgy1CSBSzB+o8JAfZamxUHFXEGSRYoR8qekMnlJeVRXlojEoaxMsspFX3itMOqIHNUL9Nltswaqggu7s+NQfshoLEg5gkfM/tok4sISLZlX7ACgcWZBQudMPTyn3FsQIbzNgXBzWTdiCZg9iZttoJtX8FygYbzEF9kFJyc5fIBA4NACVZhOf+onlMcFEbHCu6ilqMuzwQxf6vgV`
90
+ .replace(/\s/g, "")
91
+ , "base64"
92
+ );
93
+
94
+ let data = parseTLSHello(packet);
95
+ let sni = data.extensions.filter(x => x.type === SNIType).flatMap(x => parseSNIExtension(x.data))[0];
96
+ console.log(`Packet size ${packet.byteLength}, missing bytes ${data.missingBytes}`);
97
+ for (let ext of data.extensions) {
98
+ console.log(`Extension: ${tlsExtensionLookup[ext.type] || ext.type}, bytes length: ${ext.data.length}`);
99
+ }
100
+ }
101
+ main().catch(e => console.error(e)).finally(() => process.exit());
package/src/tlsParsing.ts CHANGED
@@ -45,7 +45,6 @@ export function parseTLSHello(buffer: Buffer): {
45
45
  let sessionIdLength = buffer[pos++];
46
46
  pos += sessionIdLength;
47
47
 
48
-
49
48
  let cipherSuiteLength = readShort();
50
49
  pos += cipherSuiteLength;
51
50
 
@@ -53,11 +52,17 @@ export function parseTLSHello(buffer: Buffer): {
53
52
  pos += compressionLength;
54
53
 
55
54
  let extensionsLength = readShort();
56
- output.missingBytes = contentLength - (pos + extensionsLength);
57
55
  let extensionsEnd = pos + extensionsLength;
58
56
  while (pos < extensionsEnd) {
59
57
  let extensionType = readShort();
60
58
  let length = readShort();
59
+ // Break if we only have part of the extension
60
+ if (Number.isNaN(extensionType) || Number.isNaN(length)) {
61
+ break;
62
+ }
63
+ if (pos + length > buffer.length) {
64
+ break;
65
+ }
61
66
 
62
67
  output.extensions.push({
63
68
  type: extensionType, data: viewSliceBuffer(buffer, pos, length)
@@ -65,6 +70,7 @@ export function parseTLSHello(buffer: Buffer): {
65
70
 
66
71
  pos += length;
67
72
  }
73
+ output.missingBytes = contentLength - pos;
68
74
  } catch { }
69
75
 
70
76
  return output;
@@ -11,12 +11,14 @@ import { parseSNIExtension, parseTLSHello, SNIType } from "./tlsParsing";
11
11
  import debugbreak from "debugbreak";
12
12
  import { getNodeId } from "./nodeCache";
13
13
  import crypto from "crypto";
14
- import { Watchable } from "./misc";
15
- import { delay, runInfinitePoll } from "./batching";
16
- import { magenta } from "./formatting/logColors";
14
+ import { Watchable, timeInHour } from "./misc";
15
+ import { delay, runInfinitePoll, runInfinitePollCallAtStart } from "./batching";
16
+ import { magenta, red } from "./formatting/logColors";
17
17
  import { yellow } from "./formatting/logColors";
18
18
  import { green } from "./formatting/logColors";
19
19
  import { formatTime } from "./formatting/format";
20
+ import { getExternalIP, testTCPIsListening } from "./networking";
21
+ import { forwardPort } from "./forwardPort";
20
22
 
21
23
  export type SocketServerConfig = (
22
24
  https.ServerOptions & {
@@ -30,6 +32,12 @@ export type SocketServerConfig = (
30
32
  // public sets ip to "0.0.0.0", otherwise it defaults to "127.0.0.1", which
31
33
  // causes the server to only accept local connections.
32
34
  public?: boolean;
35
+ /** Tries forwarding ports (using UPnP), if we detect they aren't externally reachable.
36
+ * - This causes an extra request and delay during startup, so should only be used
37
+ * during development.
38
+ * - Ignored if public is false
39
+ */
40
+ autoForwardPort?: boolean;
33
41
  ip?: string;
34
42
 
35
43
  // NOTE: Any same origin accesses are allowed (header.origin === header.host)
@@ -187,6 +195,10 @@ export async function startSocketServer(
187
195
  if (!SocketFunction.silent) {
188
196
  console.log(`Received TCP connection with SNI ${JSON.stringify(sni)}`);
189
197
  }
198
+ if (!sni) {
199
+ console.warn(`No SNI found in TLS hello, using main server. Packets ${packetCount}`);
200
+ console.log(buffer.toString("base64"));
201
+ }
190
202
  server = sniServers.get(sni) || mainHTTPSServer;
191
203
  }
192
204
 
@@ -214,49 +226,78 @@ export async function startSocketServer(
214
226
  });
215
227
 
216
228
 
217
- let listenPromise = new Promise<void>((resolve, error) => {
218
- realServer.on("listening", () => {
219
- resolve();
220
- });
221
- realServer.on("error", e => {
222
- error(e);
223
- });
224
- });
225
-
226
229
  let host = config.public ? "0.0.0.0" : "127.0.0.1";
227
230
  if (config.ip) {
228
231
  host = config.ip;
229
232
  }
230
233
 
231
234
  let port = config.port;
232
- async function isPortInUse(port: number): Promise<boolean> {
233
- return new Promise<boolean>((resolve, reject) => {
234
- let server = net.createServer();
235
- server.listen(port, host)
236
- .on("listening", function () {
237
- server.close();
238
- resolve(false);
239
- }).on("close", function () {
240
- resolve(true);
241
- }).on("error", function (e) {
242
- resolve(true);
243
- });
235
+ if (!SocketFunction.silent) {
236
+ console.log(yellow(`Trying to listening on ${host}:${port}`));
237
+ }
238
+
239
+ let listeningPromise = waitUntilListening();
240
+ listeningPromise.catch(e => { });
241
+
242
+ // Return true if we are listening, false if the address is in use, and throws on other errors
243
+ async function waitUntilListening() {
244
+ return await new Promise<boolean>((resolve, reject) => {
245
+ realServer.once("error", e => {
246
+ reject(e);
247
+ });
248
+ realServer.once("listening", () => {
249
+ resolve(false);
250
+ });
244
251
  });
245
252
  }
253
+
246
254
  if (config.useAvailablePortIfPortInUse && port) {
247
- if (await isPortInUse(port)) {
255
+ realServer.listen(port, host);
256
+ let isListening = await new Promise<boolean>((resolve, reject) => {
257
+ if (realServer.listening) {
258
+ resolve(true);
259
+ return;
260
+ }
261
+ realServer.once("error", e => {
262
+ if (e.message.includes("EADDRINUSE")) {
263
+ resolve(true);
264
+ } else {
265
+ reject(e);
266
+ }
267
+ });
268
+ realServer.once("listening", () => {
269
+ resolve(false);
270
+ });
271
+ });
272
+ if (!isListening) {
248
273
  port = 0;
274
+ realServer.listen(port, host);
275
+ listeningPromise = waitUntilListening();
249
276
  }
277
+ } else {
278
+ realServer.listen(port, host);
250
279
  }
251
280
 
252
- if (!SocketFunction.silent) {
253
- console.log(yellow(`Trying to listening on ${host}:${port}`));
254
- }
255
- realServer.listen(port, host);
281
+ await listeningPromise;
282
+ port = (realServer.address() as net.AddressInfo).port;
256
283
 
257
- await listenPromise;
284
+ if (config.autoForwardPort && config.public) {
285
+ // let externalIP = await getExternalIP();
286
+ // let isListening = await testTCPIsListening(externalIP, port);
287
+ // if (!isListening) {
288
+ // console.log(magenta(`Port ${port} is not externally reachable, trying to forward it`));
289
+ // await forwardPort({ externalPort: port, internalPort: port });
290
+ // }
291
+ // Even if they are listening, they might not stay listening. Forward every 8 hours
292
+ // (including at the start, in case the forward is about to expire).
293
+ async function forward() {
294
+ await forwardPort({ externalPort: port, internalPort: port });
295
+ console.log(magenta(`Forwarded port ${port} to our machine`));
296
+ }
297
+ // Every hour, in case our network configuration changes
298
+ runInfinitePollCallAtStart(timeInHour * 1, forward).catch(e => console.error(red(`Error in port forwarding ${e.stack}`)));
299
+ }
258
300
 
259
- port = (realServer.address() as net.AddressInfo).port;
260
301
  let nodeId = getNodeId(getCommonName(config.cert), port);
261
302
  console.log(green(`Started Listening on ${nodeId} after ${formatTime(process.uptime() * 1000)}`));
262
303
 
package/test.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { getExternalIP } from "./src/networking";
2
+ import http from "http";
3
+ import * as dgram from "dgram";
4
+ import os from "os";
5
+ import debugbreak from "debugbreak";
6
+ import { forwardPort } from "./src/forwardPort";
7
+
8
+
9
+ // Usage example:
10
+ async function main() {
11
+ const externalPort = 8088;
12
+ const internalPort = externalPort;
13
+
14
+ await forwardPort({ externalPort, internalPort });
15
+
16
+ // Listen on the external port
17
+ const server = http.createServer((req, res) => {
18
+ console.log("Request received");
19
+ res.end("Hello, world!");
20
+ });
21
+ server.listen(externalPort, "0.0.0.0");
22
+
23
+ {
24
+ const externalIP = await getExternalIP();
25
+ let test = await fetch(`http://${externalIP}:${externalPort}`);
26
+ console.log(await test.text());
27
+ }
28
+
29
+
30
+ //await createPortMapping({ externalPort, internalPort, gateWayIP, internalIP, });
31
+ }
32
+
33
+ main().catch(e => console.error(e));
@@ -128,7 +128,9 @@ async function updateTimeOffset() {
128
128
  || Math.abs(offset) > 300 && yellow(offsetRound + "ms")
129
129
  || green(offsetRound + "ms")
130
130
  );
131
- console.log(`${blue("Synchronized time")}, local clock was ${offset > 0 ? "behind" : "ahead"} by ${offsetColored}`);
131
+ if (Math.abs(offset) > 500) {
132
+ console.log(`${blue("Synchronized time")}, local clock was ${offset > 0 ? "behind" : "ahead"} by ${offsetColored} @ ${blue(Date.now() + "")}`);
133
+ }
132
134
  for (let i = 0; i < currentSmearCount; i++) {
133
135
  let fraction = (i + 1) / currentSmearCount;
134
136
  trueTimeOffset = prevOffset * (1 - fraction) + offset * fraction;