geonix 1.23.8 → 1.30.2

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/Util.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "crypto";
2
- import { URL, fileURLToPath } from "url";
3
- import { readFile } from "fs/promises";
2
+ import { URL } from "url";
3
+ import { createRequire } from "module";
4
4
  import { join } from "path";
5
5
  import { Transform } from "node:stream";
6
6
  import { networkInterfaces } from "os";
@@ -24,7 +24,7 @@ export const sleep = delay => new Promise(resolve => setTimeout(resolve, delay))
24
24
  *
25
25
  * @returns
26
26
  */
27
- export const nextTick = () => sleep(0);
27
+ export const yieldToEventLoop = () => new Promise(resolve => setImmediate(resolve));
28
28
 
29
29
  /**
30
30
  * Parse nats:// URL
@@ -47,12 +47,29 @@ export function parseURL(url) {
47
47
  };
48
48
  }
49
49
 
50
+ const BASE62 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
51
+ const LOG256_LOG62 = Math.log(256) / Math.log(62); // ≈ 1.3437
52
+
53
+ export function encodeBase62(buffer) {
54
+ if (buffer.length === 0) { return ""; }
55
+ const len = Math.ceil(buffer.length * LOG256_LOG62);
56
+ let n = BigInt("0x" + buffer.toString("hex"));
57
+ const chars = new Array(len);
58
+ for (let i = len - 1; i >= 0; i--) {
59
+ chars[i] = BASE62[Number(n % 62n)];
60
+ n /= 62n;
61
+ }
62
+ return chars.join("");
63
+ }
64
+
50
65
  /**
51
- * Generate small random Base64 encoded ID
52
- * @param {number} size
53
- * @returns
66
+ * Generates a cryptographically random Base62-encoded ID string.
67
+ * Exported as `randomID` in the public API.
68
+ *
69
+ * @param {number} [size=16] - Number of random bytes to encode (more bytes → longer, more unique ID).
70
+ * @returns {string} URL-safe, Base62-encoded random string.
54
71
  */
55
- export const picoid = (size = 12) => randomBytes(size).toString("base64");
72
+ export const picoid = (size = 16) => encodeBase62(randomBytes(size));
56
73
 
57
74
  /**
58
75
  * Get SHA256 hash of a string or a buffer
@@ -81,39 +98,24 @@ export const createServerAtPort = (port, pkg, handler) =>
81
98
  });
82
99
 
83
100
  /**
84
- * Create TCP or HTTP server at first free port in rage
85
- * @param {Object} pkg
86
- * @param {Function} handler
87
- * @param {number} start
88
- * @param {number} poolSize
89
- * @returns
90
- */
91
- export const createServerAtFreePort = async (pkg, handler, start = 30000, poolSize = 20000) => {
92
- for (let port = start; port < start + poolSize; port++) {
93
- try {
94
- const result = await createServerAtPort(port, pkg, handler);
95
- if (result) {
96
- return result;
97
- }
98
- } catch {
99
- // silenty ignore errors
100
- }
101
- }
102
- };
103
-
104
- /**
105
- * Create TCP server at random port
106
- * @param {Function} handler
107
- * @returns
101
+ * Create TCP or HTTP server at an OS-assigned free port
102
+ * @param {Object} pkg
103
+ * @param {Function} handler
104
+ * @returns
108
105
  */
109
- export const createTCPServer = (handler, start = 30000, poolSize = 20000) => createServerAtFreePort(net, handler, start, poolSize);
106
+ export const createServerAtFreePort = (pkg, handler) =>
107
+ new Promise((resolve, reject) => {
108
+ const server = pkg.createServer(handler);
109
+ server.on("error", reject);
110
+ server.listen(0, () => resolve({ server, port: server.address().port }));
111
+ });
110
112
 
111
113
  /**
112
- * Create HTTP server at random port
113
- * @param {Function} handler
114
- * @returns
114
+ * Create TCP server at an OS-assigned free port
115
+ * @param {Function} handler
116
+ * @returns
115
117
  */
116
- export const createHTTPServer = (handler, start = 30000, poolSize = 20000) => createServerAtFreePort(net, handler, start, poolSize);
118
+ export const createTCPServer = (handler) => createServerAtFreePort(net, handler);
117
119
 
118
120
  /**
119
121
  * Return number of seconds passed from the start of the day (0-86399)
@@ -124,39 +126,6 @@ export const getSecondsSinceMidnight = () => {
124
126
  return Math.floor((date.getTime() - date.setHours(0, 0, 0, 0)) / 1000);
125
127
  };
126
128
 
127
- /**
128
- * Parse function body and return array of param names
129
- * @param {*} fn
130
- * @returns string[]
131
- */
132
- export const getFunctionParams = (fn) => {
133
- const code = fn.toString();
134
- const endParenthesisPosition = code.indexOf(")");
135
- let params;
136
-
137
- if (endParenthesisPosition != -1) {
138
- params = code.substring(code.indexOf("(") + 1, endParenthesisPosition);
139
- } else {
140
- params = code.substring(0, code.indexOf("=>"));
141
- }
142
-
143
- params = params
144
- // cleanup spaces
145
- .replaceAll(" ", "")
146
- // split into array
147
- .split(",");
148
-
149
- // remove potential default values
150
- for (let index = 0; index < params.length; index++) {
151
- const defaultValueAssignmentPosition = params[index].indexOf("=");
152
- if (defaultValueAssignmentPosition != -1) {
153
- params[index] = params[index].substring(0, defaultValueAssignmentPosition - 1);
154
- }
155
- }
156
-
157
- return params;
158
- };
159
-
160
129
  export const proxyHttp = (target, req, res) =>
161
130
  new Promise((resolve, reject) => {
162
131
  const remoteTarget = `${target}${req.originalUrl}`;
@@ -188,37 +157,40 @@ export const proxyHttp = (target, req, res) =>
188
157
  */
189
158
  export const OverlayObject = (object, overlay) => new Proxy(object, { get: (t, p) => overlay[p] !== undefined ? overlay[p] : t[p] });
190
159
 
191
- let _geonix_version = "N/A";
192
- try {
193
- const __dirname = fileURLToPath(new URL("..", import.meta.url));
194
- const local = JSON.parse(await readFile(join(__dirname, "package.json")));
195
- _geonix_version = local.version;
196
- } catch {
197
- // ignore errors
198
- }
199
-
200
- export const GeonixVersion = _geonix_version;
201
-
202
- export const StreamChunker = (chunkSize = 65536) => {
203
- let chunker = new Transform();
160
+ /**
161
+ * The version string of the currently installed Geonix package, read from `package.json` at
162
+ * module load time. Equals `"N/A"` when the package metadata cannot be found.
163
+ *
164
+ * @type {string}
165
+ */
166
+ export const GeonixVersion = (() => {
167
+ try {
168
+ return createRequire(import.meta.url)("../package.json").version ?? "N/A";
169
+ } catch {
170
+ return "N/A";
171
+ }
172
+ })();
204
173
 
205
- chunker._transform = function (chunk, encoding, done) {
174
+ /**
175
+ * Chunk a stream into smaller chunks
176
+ *
177
+ * @param {*} chunkSize
178
+ * @returns
179
+ */
180
+ export const StreamChunker = (chunkSize = 65536) => new Transform({
181
+ transform(chunk, _encoding, done) {
206
182
  let offset = 0;
207
183
  while (offset < chunk.length) {
208
184
  const sliceSize = Math.min(chunkSize, chunk.length - offset);
209
185
  this.push(chunk.slice(offset, offset + sliceSize));
210
186
  offset += sliceSize;
211
187
  }
212
-
213
188
  done();
214
- };
215
-
216
- chunker._flush = function (done) {
189
+ },
190
+ flush(done) {
217
191
  done();
218
- };
219
-
220
- return chunker;
221
- };
192
+ }
193
+ });
222
194
 
223
195
  export async function getFirstItemFromAsyncIterable(asyncIterable) {
224
196
  const iterator = asyncIterable[Symbol.asyncIterator]();
@@ -250,15 +222,24 @@ export function isIterable(obj) {
250
222
  }
251
223
 
252
224
  /**
253
- *
254
- *
255
- * @param {Request} req
256
- * @param {parseMultipartOptions} options
257
- * @returns ParsedMultiPart[]
225
+ * Parses a `multipart/form-data` request body into an array of part objects. Each part
226
+ * exposes parsed headers (e.g. `content-disposition`), a `name`, an optional `filename`,
227
+ * and a `body` {@link import('stream').Readable}.
228
+ *
229
+ * By default parts are streamed through temporary files on disk; set `options.useMemory` to
230
+ * `true` to buffer them in memory instead.
231
+ *
232
+ * @param {import('http').IncomingMessage} req - Incoming HTTP request with a `multipart/form-data` content-type.
233
+ * @param {object} [_options] - Parsing options.
234
+ * @param {boolean} [_options.useMemory=false] - Buffer parts in memory instead of temp files.
235
+ * @param {number} [_options.maxFileSize] - Maximum allowed size in bytes for a single part.
236
+ * @param {number} [_options.maxFiles] - Maximum number of parts allowed.
237
+ * @returns {Promise<Array<{ name: string|null, filename: string|null, headers: object, body: import('stream').Readable, size: number }>>}
238
+ * @throws {Error} If the content-type is not `multipart/form-data` or a size/count limit is exceeded.
258
239
  */
259
240
  export async function parseMultipart(req, _options) {
260
241
  if (!req.headers["content-type"]?.startsWith("multipart/form-data")) {
261
- throw new Error("Invalid content type (multipart/form-data expected)");
242
+ throw Error("Invalid content type (multipart/form-data expected)");
262
243
  }
263
244
 
264
245
  const BUFFER_SIZE = 1024 * 1024;
@@ -276,15 +257,41 @@ export async function parseMultipart(req, _options) {
276
257
  options.useMemory = true;
277
258
  }
278
259
 
279
- const boundary = Buffer.from("\r\n--" + req.headers["content-type"].split("boundary=")[1]);
260
+ const boundaryValue = req.headers["content-type"].match(/boundary=([^;,\s]+)/)?.[1];
261
+ if (!boundaryValue) {
262
+ throw Error("parseMultipart: missing boundary in content-type");
263
+ }
264
+ const boundary = Buffer.from("\r\n--" + boundaryValue);
280
265
 
281
- await nextTick();
266
+ await yieldToEventLoop();
282
267
 
283
268
  let lastChunk = Buffer.from("\r\n");
284
269
  let activePart;
285
- let done = false;
270
+
271
+ const cleanup = async () => {
272
+ for (const part of parts) {
273
+ if (part.bodyFile) {
274
+ try {
275
+ part.body.destroy();
276
+ } catch {
277
+ // ignore errors
278
+ }
279
+ try {
280
+ await unlink(part.bodyFile);
281
+ } catch {
282
+ // ignore errors
283
+ }
284
+ }
285
+ }
286
+ };
286
287
 
287
288
  const write = (chunk) => {
289
+ if (options.maxFileSize !== undefined) {
290
+ activePart.size += chunk.length;
291
+ if (activePart.size > options.maxFileSize) {
292
+ throw Error(`parseMultipart: part exceeds maxFileSize of ${options.maxFileSize} bytes`);
293
+ }
294
+ }
288
295
  if (options.useMemory) {
289
296
  activePart.body.push(chunk);
290
297
  } else {
@@ -293,75 +300,95 @@ export async function parseMultipart(req, _options) {
293
300
  };
294
301
 
295
302
  const newPart = () => {
296
- // create new part
303
+ if (options.maxFiles !== undefined && parts.length >= options.maxFiles) {
304
+ throw Error(`parseMultipart: exceeded maxFiles limit of ${options.maxFiles}`);
305
+ }
297
306
  const bodyFile = tempFilename();
298
307
  activePart = {
299
308
  headers: {},
300
309
  bodyFile: options.useMemory ? undefined : bodyFile,
301
- body: options.useMemory ? [] : createWriteStream(bodyFile, { flags: "wx" })
310
+ body: options.useMemory ? [] : createWriteStream(bodyFile, { flags: "wx" }),
311
+ size: 0
302
312
  };
303
313
  parts.push(activePart);
304
314
  };
305
315
 
306
- while (stream.readable) {
307
- // next next chunk
308
- let chunk = stream.read(BUFFER_SIZE);
316
+ try {
317
+ while (stream.readable) {
318
+ // next next chunk
319
+ let chunk = stream.read(BUFFER_SIZE);
309
320
 
310
- if (!chunk) {
311
- await nextTick();
312
- continue;
313
- }
321
+ if (!chunk) {
322
+ await yieldToEventLoop();
323
+ continue;
324
+ }
314
325
 
315
- let combined = Buffer.concat([lastChunk, chunk]);
326
+ let combined = Buffer.concat([lastChunk, chunk]);
327
+ let lookbehindSet = false;
328
+
329
+ while (combined.length >= boundary.length + 2) {
330
+ const boundaryIndex = combined.indexOf(boundary);
331
+
332
+ if (boundaryIndex === -1) {
333
+ // Keep only the last boundary.length-1 bytes as lookbehind so a
334
+ // boundary that straddles a read boundary is not split across chunks.
335
+ const safeLength = combined.length - (boundary.length - 1);
336
+ if (activePart && safeLength > 0) {
337
+ write(combined.subarray(0, safeLength));
338
+ }
339
+ lastChunk = combined.subarray(safeLength);
340
+ lookbehindSet = true;
341
+ break;
342
+ }
316
343
 
317
- while (combined.length >= boundary.length + 2) {
318
- const boundaryIndex = combined.indexOf(boundary);
319
- const isLastBoundary = combined[boundaryIndex + boundary.length] === 45 && combined[boundaryIndex + boundary.length + 1] === 45;
344
+ const isLastBoundary = combined[boundaryIndex + boundary.length] === 45 && combined[boundaryIndex + boundary.length + 1] === 45;
320
345
 
321
- if (boundaryIndex === -1) {
322
- lastChunk = combined;
323
- break;
324
- }
346
+ if (boundaryIndex > 0) {
347
+ write(combined.subarray(0, boundaryIndex));
348
+ }
325
349
 
326
- if (boundaryIndex > 0) {
327
- write(combined.subarray(0, boundaryIndex));
328
- }
350
+ if (isLastBoundary) {
351
+ break;
352
+ }
329
353
 
330
- if (isLastBoundary) {
331
- combined = combined.subarray(boundaryIndex + boundary.length + 2);
332
- done = true;
333
- break;
334
- }
354
+ newPart();
335
355
 
336
- newPart();
356
+ const endOfHeaders = combined.indexOf(END_OF_HEADERS, boundaryIndex);
337
357
 
338
- const endOfHeaders = combined.indexOf(END_OF_HEADERS, boundaryIndex);
358
+ if (endOfHeaders === -1) {
359
+ throw Error("parseMultipart: malformed part — missing header terminator");
360
+ }
339
361
 
340
- activePart.headers = combined
341
- .subarray(boundaryIndex + boundary.length + 2, endOfHeaders).toString()
342
- .split("\r\n")
343
- .reduce((acc, val) => {
344
- const [header, value] = val.split(": ");
345
- acc[header.toLowerCase()] = value;
346
- return acc;
347
- }, {});
362
+ activePart.headers = combined
363
+ .subarray(boundaryIndex + boundary.length + 2, endOfHeaders).toString()
364
+ .split("\r\n")
365
+ .reduce((acc, val) => {
366
+ const [header, value] = val.split(": ");
367
+ acc[header.toLowerCase()] = value;
368
+ return acc;
369
+ }, Object.create(null));
348
370
 
349
- combined = combined.subarray(endOfHeaders + END_OF_HEADERS.length);
371
+ combined = combined.subarray(endOfHeaders + END_OF_HEADERS.length);
350
372
 
351
- lastChunk = combined;
352
- }
373
+ lastChunk = combined;
374
+ }
353
375
 
354
- // there's no boundary in the chunk, add it to active part
355
- if (activePart && lastChunk.length > 0 && !done) {
356
- write(lastChunk);
357
- lastChunk = Buffer.alloc(0);
376
+ // Carry the unprocessed remainder into the next read so it gets
377
+ // prepended to the next chunk. Skip when the lookbehind was already
378
+ // set inside the boundaryIndex === -1 branch above.
379
+ if (!lookbehindSet) {
380
+ lastChunk = combined;
381
+ }
358
382
  }
383
+ } catch (e) {
384
+ await cleanup();
385
+ throw e;
359
386
  }
360
387
 
361
388
  for (const part of parts) {
362
389
  // extract name and filename from content-disposition header
363
390
  if (part.headers["content-disposition"]) {
364
- const [, name] = part.headers["content-disposition"].match(/name="([^"]+)"/);
391
+ const [, name] = part.headers["content-disposition"].match(/name="([^"]+)"/) || [];
365
392
  const [, filename] = part.headers["content-disposition"].match(/filename="([^"]+)"/) || [];
366
393
  part.name = name ?? null;
367
394
  part.filename = filename ?? null;
@@ -387,24 +414,13 @@ export async function parseMultipart(req, _options) {
387
414
  }
388
415
 
389
416
  export function tempFilename() {
390
- return join(tmpdir(), `${randomSafeId(12)}.gxtmp`);
391
- }
392
-
393
- export function randomSafeId(size = 12) {
394
- const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
395
- let result = "";
396
-
397
- for (let i = 0; i < size; i++) {
398
- result += charset.charAt(Math.floor(Math.random() * charset.length));
399
- }
400
-
401
- return result;
417
+ return join(tmpdir(), `${picoid(12)}.gxtmp`);
402
418
  }
403
419
 
404
420
  export function deepMerge(target, ...source) {
405
421
  for (const src of source) {
406
- for (const key in src) {
407
- if (src[key] instanceof Object) {
422
+ for (const key of Object.keys(src)) {
423
+ if (src[key] instanceof Object && !Array.isArray(src[key])) {
408
424
  if (!target[key]) {
409
425
  target[key] = {};
410
426
  }
@@ -427,4 +443,22 @@ export function cleanupWebsocketUrl(url) {
427
443
  } catch {
428
444
  return url;
429
445
  }
430
- }
446
+ }
447
+
448
+ export async function fetchWithTimeout(url, options = {}, timeout = 500) {
449
+ const ac = new AbortController();
450
+ const timer = setTimeout(() => ac.abort(), timeout);
451
+ try {
452
+ return await fetch(url, { ...options, signal: ac.signal });
453
+ } finally {
454
+ clearTimeout(timer);
455
+ }
456
+ }
457
+
458
+ export function withTimeout(promise, timeout) {
459
+ let timeoutId;
460
+ const timer = new Promise((_, reject) => {
461
+ timeoutId = setTimeout(() => reject(Error("Timeout")), timeout);
462
+ });
463
+ return Promise.race([promise, timer]).finally(() => clearTimeout(timeoutId));
464
+ }
package/src/WebServer.js CHANGED
@@ -3,13 +3,21 @@ import express, { Router } from "express";
3
3
  import expressWs from "express-ws";
4
4
  import { createServerAtFreePort, createServerAtPort, sleep } from "./Util.js";
5
5
  import * as http from "http";
6
- import { Service } from "./Service.js";
7
6
  import * as path from "path";
8
7
  import { logger } from "./Logger.js";
9
8
  import { activeStreams } from "./Stream.js";
10
9
 
11
- export const HEALTH_CHECK_ENDPOINT = "/pA4vY7fT9oG5aI8cA4yV3qW5fP9qR1vI";
12
-
10
+ /**
11
+ * Creates an Express router that serves static files from `root`. Supports an optional URL
12
+ * prefix strip via `options.root` and falls back to `index.html` for unmatched paths when
13
+ * `options.indexOn404` is `true` (useful for single-page applications).
14
+ *
15
+ * @param {string} root - Filesystem path to the directory containing static assets.
16
+ * @param {object} [options] - Options forwarded to `express.static`, plus:
17
+ * @param {string} [options.root] - URL prefix to strip before serving.
18
+ * @param {boolean} [options.indexOn404] - If `true`, respond with `index.html` for all 404s.
19
+ * @returns {import('express').Router}
20
+ */
13
21
  export const ServeStatic = (root, options = {}) => {
14
22
  const router = Router();
15
23
  const absoluteRoot = path.resolve(root);
@@ -34,7 +42,6 @@ export const ServeStatic = (root, options = {}) => {
34
42
 
35
43
  if (options.indexOn404) {
36
44
  router.get("*", (req, res) => {
37
- logger.info(path.join(absoluteRoot, "index.html"));
38
45
  res.sendFile(path.join(absoluteRoot, "index.html"));
39
46
  });
40
47
  }
@@ -58,14 +65,15 @@ class WebServer {
58
65
  this.#started = true;
59
66
 
60
67
  let srv;
61
- if (process.env.LOCAL_PORT) {
62
- srv = await createServerAtPort(process.env.LOCAL_PORT, http, this.#app);
68
+ const localPort = process.env.GX_LOCAL_PORT || process.env.LOCAL_PORT;
69
+ if (localPort) {
70
+ srv = await createServerAtPort(localPort, http, this.#app);
63
71
  } else {
64
72
  srv = await createServerAtFreePort(http, this.#app);
65
73
  }
66
74
 
67
75
  if (!srv) {
68
- throw new Error("gx.webserver.start: unable to start");
76
+ throw Error("gx.webserver.start: unable to start");
69
77
  }
70
78
 
71
79
  this.#server = srv.server;
@@ -74,7 +82,7 @@ class WebServer {
74
82
  expressWs(this.#app, srv.server);
75
83
 
76
84
  // stream endpoint
77
- this.#app.get("/!!_____stream/:id", (req, res) => {
85
+ this.#app.get("/!!_gx/stream/:id", (req, res) => {
78
86
  const id = req.params.id;
79
87
 
80
88
  if (activeStreams[id]) {
@@ -86,8 +94,8 @@ class WebServer {
86
94
  }
87
95
  });
88
96
 
89
- this.#app.get(HEALTH_CHECK_ENDPOINT, (req, res) => {
90
- res.send({ status: "healthy", services: Service.serviceClasses });
97
+ this.#app.get("/!!_gx/health", (req, res) => {
98
+ res.send({ status: "healthy" });
91
99
  });
92
100
 
93
101
  // middleware to handle dynamic routers
@@ -1,10 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Skill(code-review:code-review)",
5
- "Bash(node --test --test-reporter=spec tests/integration/service.test.js tests/integration/stream.test.js)",
6
- "Bash(node --test --test-reporter=spec tests/integration/gateway.test.js)",
7
- "Bash(timeout 90 node --test --test-reporter=tap tests/integration/gateway.test.js)"
8
- ]
9
- }
10
- }