@lowdefy/node-utils 0.0.0-experimental-20260928105334 → 0.0.0-experimental-20260928120643

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.
@@ -0,0 +1,83 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import net from 'node:net';
16
+ import { type } from '@lowdefy/helpers';
17
+ import parseIpRange from './parseIpRange.js';
18
+ // A dual-stack socket reports an IPv4 peer as "::ffff:203.0.113.7", and a
19
+ // proxy on one may forward it that way. Both come out in the IPv4 form, so
20
+ // logs and the session record carry one spelling of an address.
21
+ function unmapIpv4(address) {
22
+ const mapped = /^::ffff:([\d.]+)$/i.exec(address);
23
+ if (mapped !== null && net.isIP(mapped[1]) === 4) {
24
+ return mapped[1];
25
+ }
26
+ return address;
27
+ }
28
+ // Some proxies write a hop with its port: "203.0.113.7:51234", "[2001:db8::1]:443".
29
+ function parseForwardedHop(hop) {
30
+ const bracketed = /^\[([^\]]+)\](?::\d+)?$/.exec(hop);
31
+ if (bracketed !== null) {
32
+ return net.isIP(bracketed[1]) === 6 ? unmapIpv4(bracketed[1]) : null;
33
+ }
34
+ if (net.isIP(hop) !== 0) {
35
+ return unmapIpv4(hop);
36
+ }
37
+ const withPort = /^([\d.]+):\d+$/.exec(hop);
38
+ if (withPort !== null && net.isIP(withPort[1]) === 4) {
39
+ return withPort[1];
40
+ }
41
+ return null;
42
+ }
43
+ // The client address is the socket's peer unless that peer is a trusted proxy.
44
+ // A trusted proxy appends the address it received from to X-Forwarded-For, so
45
+ // the chain is read from the right: every trusted hop is skipped and the first
46
+ // untrusted one is the client. Entries to its left were written by whoever sent
47
+ // the request and are never believed. With no trusted proxies the header is not
48
+ // read at all - a client cannot choose its own address.
49
+ //
50
+ // trustedProxies holds IP addresses and CIDR ranges the build has validated.
51
+ function createClientAddressResolver({ trustedProxies }) {
52
+ const trusted = new net.BlockList();
53
+ (trustedProxies ?? []).forEach((entry)=>{
54
+ const { address, prefix, family } = parseIpRange(entry);
55
+ trusted.addSubnet(address, prefix, family);
56
+ });
57
+ function isTrusted(address) {
58
+ return trusted.check(address, net.isIP(address) === 6 ? 'ipv6' : 'ipv4');
59
+ }
60
+ return function resolveClientAddress({ peerAddress, forwardedFor }) {
61
+ if (!type.isString(peerAddress) || net.isIP(peerAddress) === 0) {
62
+ return null;
63
+ }
64
+ let address = unmapIpv4(peerAddress);
65
+ if (!isTrusted(address) || !type.isString(forwardedFor)) {
66
+ return address;
67
+ }
68
+ const hops = forwardedFor.split(',').map((hop)=>hop.trim()).filter((hop)=>hop !== '');
69
+ for(let index = hops.length - 1; index >= 0; index -= 1){
70
+ const hop = parseForwardedHop(hops[index]);
71
+ // A malformed hop ends the walk at the last address a trusted proxy vouched for.
72
+ if (hop === null) {
73
+ return address;
74
+ }
75
+ address = hop;
76
+ if (!isTrusted(address)) {
77
+ return address;
78
+ }
79
+ }
80
+ return address;
81
+ };
82
+ }
83
+ export default createClientAddressResolver;
@@ -82,7 +82,7 @@ function secretForms(value) {
82
82
  function createSecretScrubber({ secrets, env = process.env }) {
83
83
  const values = collectStringLeaves(secrets, []);
84
84
  if (!type.isNone(env.CRON_SECRET)) values.push(env.CRON_SECRET);
85
- if (!type.isNone(env.AUTH_SECRET)) values.push(env.AUTH_SECRET);
85
+ if (!type.isNone(env.BETTER_AUTH_SECRET)) values.push(env.BETTER_AUTH_SECRET);
86
86
  const expanded = values.flatMap((value)=>[
87
87
  value,
88
88
  ...parseJsonLeaves(value)
@@ -94,21 +94,13 @@ function createSecretScrubber({ secrets, env = process.env }) {
94
94
  if (form.length > 0) forms.add(form);
95
95
  });
96
96
  });
97
- // No secrets means no pattern: an empty alternation would match at every position.
98
- if (forms.size === 0) {
99
- return function scrub(value) {
100
- return value;
101
- };
102
- }
103
- // One alternation, so every log line and Sentry string takes a single pass however many
104
- // secrets there are. Longest first, so a form is replaced whole before a shorter form it
105
- // contains can match at the same position.
106
- const pattern = new RegExp([
97
+ // Longest first, so a form is replaced whole before a shorter form it contains can break it up.
98
+ const sortedForms = [
107
99
  ...forms
108
- ].sort((a, b)=>b.length - a.length).map((form)=>form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
100
+ ].sort((a, b)=>b.length - a.length);
109
101
  return function scrub(value) {
110
102
  if (!type.isString(value)) return value;
111
- return value.replace(pattern, REDACTED);
103
+ return sortedForms.reduce((text, form)=>text.split(form).join(REDACTED), value);
112
104
  };
113
105
  }
114
106
  export default createSecretScrubber;
@@ -0,0 +1,22 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import path from 'path';
16
+ // Keyed on the config directory, not the dev server directory, so one app has
17
+ // one record however it was launched (CLI, --dev-directory, the monorepo dev
18
+ // script) and every reader - CLI, MCP shim, hub - finds it the same way.
19
+ function getDevInstancePath({ configDirectory }) {
20
+ return path.join(configDirectory, '.lowdefy', 'instance.json');
21
+ }
22
+ export default getDevInstancePath;
@@ -0,0 +1,45 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import { spawnSync } from 'child_process';
16
+ // A pid alone does not identify a process: after a reboot, or enough churn,
17
+ // the pid in the hub's registry can belong to something else entirely. The pid
18
+ // plus its start time does. Windows has no ps; there the hub relies on the pid.
19
+ //
20
+ // ps prints lstart in the caller's time zone and locale. Hubs, shims and CLIs
21
+ // run with the environment of whichever session started them, so without
22
+ // pinning both, a reader in another session would read another string for
23
+ // the same process - and a hub would drop, orphaning, every server it should
24
+ // adopt.
25
+ function getProcessStartTime({ pid }) {
26
+ if (process.platform === 'win32') {
27
+ return null;
28
+ }
29
+ const result = spawnSync('ps', [
30
+ '-o',
31
+ 'lstart=',
32
+ '-p',
33
+ String(pid)
34
+ ], {
35
+ encoding: 'utf8',
36
+ env: {
37
+ ...process.env,
38
+ LC_ALL: 'C',
39
+ TZ: 'UTC'
40
+ }
41
+ });
42
+ const startTime = (result.stdout ?? '').trim();
43
+ return startTime === '' ? null : startTime;
44
+ }
45
+ export default getProcessStartTime;
package/dist/index.js CHANGED
@@ -16,14 +16,20 @@
16
16
  import cleanDirectory from './cleanDirectory.js';
17
17
  import collectEnvironmentGuards from './collectEnvironmentGuards.js';
18
18
  import copyFileOrDirectory from './copyFileOrDirectory.js';
19
+ import createClientAddressResolver from './createClientAddressResolver.js';
19
20
  import createSecretScrubber from './createSecretScrubber.js';
20
21
  import findAvailablePort from './findAvailablePort.js';
22
+ import getDevInstancePath from './getDevInstancePath.js';
21
23
  import getFileExtension, { getFileSubExtension } from './getFileExtension.js';
24
+ import getProcessStartTime from './getProcessStartTime.js';
22
25
  import getSecretsFromEnv from './getSecretsFromEnv.js';
23
26
  import installIfPackageJsonChanged from './installIfPackageJsonChanged.js';
27
+ import isPidAlive from './isPidAlive.js';
24
28
  import isPortAvailable from './isPortAvailable.js';
29
+ import parseIpRange from './parseIpRange.js';
30
+ import readDevInstance from './readDevInstance.js';
25
31
  import spawnProcess from './spawnProcess.js';
26
32
  import readFile from './readFile.js';
27
33
  import writeFile from './writeFile.js';
28
34
  import writeFileIfChanged from './writeFileIfChanged.js';
29
- export { checkEnvironmentGuards, cleanDirectory, collectEnvironmentGuards, copyFileOrDirectory, createSecretScrubber, findAvailablePort, getFileExtension, getFileSubExtension, getSecretsFromEnv, installIfPackageJsonChanged, isPortAvailable, spawnProcess, readFile, writeFile, writeFileIfChanged };
35
+ export { checkEnvironmentGuards, cleanDirectory, collectEnvironmentGuards, copyFileOrDirectory, createClientAddressResolver, createSecretScrubber, findAvailablePort, getDevInstancePath, getFileExtension, getFileSubExtension, getProcessStartTime, getSecretsFromEnv, installIfPackageJsonChanged, isPidAlive, isPortAvailable, parseIpRange, readDevInstance, spawnProcess, readFile, writeFile, writeFileIfChanged };
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ function isPidAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch (error) {
20
+ // EPERM means the process exists but belongs to another user.
21
+ return error.code === 'EPERM';
22
+ }
23
+ }
24
+ export default isPidAlive;
@@ -0,0 +1,52 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import net from 'node:net';
16
+ const PREFIX_PATTERN = /^\d{1,3}$/;
17
+ // Parses an IP address ("10.0.0.7", "2001:db8::1") or a CIDR range
18
+ // ("10.0.0.0/8", "2001:db8::/32") into the arguments net.BlockList.addSubnet
19
+ // takes. A bare address is a range of one. Returns null for anything else, so
20
+ // a malformed entry is refused rather than silently matching nothing.
21
+ function parseIpRange(value) {
22
+ if (typeof value !== 'string') {
23
+ return null;
24
+ }
25
+ const [address, prefixPart, ...rest] = value.trim().split('/');
26
+ const version = net.isIP(address);
27
+ if (version === 0 || rest.length > 0) {
28
+ return null;
29
+ }
30
+ const family = version === 6 ? 'ipv6' : 'ipv4';
31
+ const maxPrefix = version === 6 ? 128 : 32;
32
+ if (prefixPart === undefined) {
33
+ return {
34
+ address,
35
+ prefix: maxPrefix,
36
+ family
37
+ };
38
+ }
39
+ if (!PREFIX_PATTERN.test(prefixPart)) {
40
+ return null;
41
+ }
42
+ const prefix = Number(prefixPart);
43
+ if (prefix > maxPrefix) {
44
+ return null;
45
+ }
46
+ return {
47
+ address,
48
+ prefix,
49
+ family
50
+ };
51
+ }
52
+ export default parseIpRange;
@@ -0,0 +1,91 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import fs from 'fs';
16
+ import { type } from '@lowdefy/helpers';
17
+ import getDevInstancePath from './getDevInstancePath.js';
18
+ import getProcessStartTime from './getProcessStartTime.js';
19
+ import isPidAlive from './isPidAlive.js';
20
+ function readRecord(instancePath) {
21
+ try {
22
+ return JSON.parse(fs.readFileSync(instancePath, 'utf8'));
23
+ } catch {
24
+ // No record, or one mid-write - either way no live instance to report.
25
+ return null;
26
+ }
27
+ }
28
+ // Readers poll the record (every 100 ms while waiting on a build), and a
29
+ // start time costs a ps process, so it is read once a few seconds per pid.
30
+ const START_TIME_TTL_MS = 5000;
31
+ const startTimes = new Map();
32
+ function readStartTime(pid) {
33
+ const cached = startTimes.get(pid);
34
+ if (!type.isUndefined(cached) && Date.now() - cached.readAt < START_TIME_TTL_MS) {
35
+ return cached.startTime;
36
+ }
37
+ const now = Date.now();
38
+ startTimes.forEach((entry, key)=>{
39
+ if (now - entry.readAt >= START_TIME_TTL_MS) {
40
+ startTimes.delete(key);
41
+ }
42
+ });
43
+ const startTime = getProcessStartTime({
44
+ pid
45
+ });
46
+ startTimes.set(pid, {
47
+ startTime,
48
+ readAt: now
49
+ });
50
+ return startTime;
51
+ }
52
+ // A pid alone does not name a process: after a crash (kill -9 skips the
53
+ // manager's cleanup) or a reboot, the record's pid can belong to something
54
+ // else. The manager records its start time too. A record from a manager that
55
+ // predates it, or a start time ps could not read, leaves the pid to decide -
56
+ // never "not running", which would let a second dev server start beside it.
57
+ function isRecordProcess(record) {
58
+ if (!isPidAlive(record.pid)) {
59
+ return false;
60
+ }
61
+ if (type.isNone(record.processStartTime)) {
62
+ return true;
63
+ }
64
+ const startTime = readStartTime(record.pid);
65
+ return type.isNone(startTime) || startTime === record.processStartTime;
66
+ }
67
+ // A record is live only when it was written for this exact directory and its
68
+ // process still runs. The directory check matters because .lowdefy/ gets
69
+ // copied between git worktrees - a copied record names the source checkout
70
+ // and must not make this checkout look occupied.
71
+ function readDevInstance({ configDirectory }) {
72
+ const record = readRecord(getDevInstancePath({
73
+ configDirectory
74
+ }));
75
+ if (record === null) {
76
+ return null;
77
+ }
78
+ let realConfigDirectory;
79
+ try {
80
+ // The path as stored on disk, as the manager records it, so a case
81
+ // variant of the directory (macOS) still finds its record.
82
+ realConfigDirectory = fs.realpathSync.native(configDirectory);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (record.configDirectory !== realConfigDirectory || !isRecordProcess(record)) {
87
+ return null;
88
+ }
89
+ return record;
90
+ }
91
+ export default readDevInstance;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/node-utils",
3
- "version": "0.0.0-experimental-20260928105334",
3
+ "version": "0.0.0-experimental-20260928120643",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "homepage": "https://lowdefy.com",
@@ -34,11 +34,12 @@
34
34
  "dist/*"
35
35
  ],
36
36
  "dependencies": {
37
- "@lowdefy/errors": "0.0.0-experimental-20260928105334",
38
- "@lowdefy/helpers": "0.0.0-experimental-20260928105334",
37
+ "@lowdefy/errors": "0.0.0-experimental-20260928120643",
38
+ "@lowdefy/helpers": "0.0.0-experimental-20260928120643",
39
39
  "fs-extra": "11.3.4"
40
40
  },
41
41
  "devDependencies": {
42
+ "@jest/globals": "28.1.3",
42
43
  "@swc/cli": "0.8.1",
43
44
  "@swc/core": "1.15.32",
44
45
  "@swc/jest": "0.2.39",