@lowdefy/node-utils 5.6.0 → 6.1.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.
@@ -0,0 +1,34 @@
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 { type } from '@lowdefy/helpers';
16
+ import { ConfigError } from '@lowdefy/errors';
17
+ import collectEnvironmentGuards from './collectEnvironmentGuards.js';
18
+ // Checks the current environment's guards against this process's environment variables. The build
19
+ // runs it before anything deploys, and the production server again at startup: an image built once
20
+ // can be started with different variables. Values are never echoed: a failure names the variable,
21
+ // not what it holds.
22
+ function checkEnvironmentGuards({ name, guards, configKey }) {
23
+ const failures = collectEnvironmentGuards({
24
+ guards
25
+ }).filter(({ variable, pattern })=>{
26
+ const value = process.env[variable];
27
+ return !type.isString(value) || !new RegExp(pattern).test(value);
28
+ });
29
+ if (failures.length === 0) return;
30
+ throw new ConfigError(`Environment "${name}" guards failed: ${failures.map(({ label, variable })=>type.isString(process.env[variable]) ? `${label} does not match its guard` : `${label} is not set (${variable})`).join('; ')}. Update the guard in config.environments.${name}.guards together with the value, or fix the value.`, {
31
+ configKey
32
+ });
33
+ }
34
+ export default checkEnvironmentGuards;
@@ -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
+ */ // Guards pin an environment's secrets and environment variables to a pattern kept in code: the
16
+ // value must match the regular expression, so changing a guarded value (the prod database URI,
17
+ // say) takes a change to the deployment's variables AND a reviewed change to the app config — a
18
+ // second factor for deploy-time configuration. Checked at build, before anything deploys.
19
+ //
20
+ // guards:
21
+ // secrets: { MONGODB_URI: 'acme-prod\.a1b2c\.mongodb\.net' } # LOWDEFY_SECRET_<NAME>
22
+ // env: { BETTER_AUTH_URL: '^https://app\.acme\.com$' }
23
+ //
24
+ // Values are never echoed: a failure names the variable, not what it holds.
25
+ function collectEnvironmentGuards({ guards }) {
26
+ const checks = [];
27
+ Object.entries(guards?.secrets ?? {}).forEach(([name, pattern])=>{
28
+ if (name.startsWith('~')) return;
29
+ checks.push({
30
+ label: `secret "${name}"`,
31
+ variable: `LOWDEFY_SECRET_${name}`,
32
+ pattern
33
+ });
34
+ });
35
+ Object.entries(guards?.env ?? {}).forEach(([name, pattern])=>{
36
+ if (name.startsWith('~')) return;
37
+ checks.push({
38
+ label: `environment variable "${name}"`,
39
+ variable: name,
40
+ pattern
41
+ });
42
+ });
43
+ return checks;
44
+ }
45
+ export default collectEnvironmentGuards;
@@ -0,0 +1,114 @@
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 { type } from '@lowdefy/helpers';
16
+ const REDACTED = '[REDACTED]';
17
+ // Shorter values, like `true` or a port number, would shred unrelated text.
18
+ const MIN_SECRET_LENGTH = 8;
19
+ function collectStringLeaves(value, leaves) {
20
+ if (type.isString(value)) {
21
+ leaves.push(value);
22
+ return leaves;
23
+ }
24
+ if (type.isArray(value)) {
25
+ value.forEach((item)=>collectStringLeaves(item, leaves));
26
+ return leaves;
27
+ }
28
+ if (type.isObject(value)) {
29
+ Object.values(value).forEach((item)=>collectStringLeaves(item, leaves));
30
+ }
31
+ return leaves;
32
+ }
33
+ // Authors JSON-encode structured secrets and pick a leaf with `_json.parse`, so the leaf, not
34
+ // the whole encoded string, is what ends up in an error message.
35
+ function parseJsonLeaves(value) {
36
+ let parsed;
37
+ try {
38
+ parsed = JSON.parse(value);
39
+ } catch {
40
+ return [];
41
+ }
42
+ if (!type.isObject(parsed) && !type.isArray(parsed)) {
43
+ return [];
44
+ }
45
+ return collectStringLeaves(parsed, []);
46
+ }
47
+ // A secret embedded in a larger base64 payload (a Basic auth header built from
48
+ // `user:password`) only matches the secret's own base64 when both start on the same 3-byte
49
+ // boundary. Encoding at each offset and keeping only the characters whose 6 bits fall wholly
50
+ // inside the secret's bytes gives a fragment that matches whatever surrounds it.
51
+ function base64Fragments(value) {
52
+ const bytes = Buffer.from(value, 'utf8');
53
+ const fragments = [];
54
+ [
55
+ 0,
56
+ 1,
57
+ 2
58
+ ].forEach((offset)=>{
59
+ const padded = Buffer.concat([
60
+ Buffer.alloc(offset),
61
+ bytes
62
+ ]);
63
+ const start = Math.ceil(8 * offset / 6);
64
+ const end = Math.floor(8 * (offset + bytes.length) / 6);
65
+ [
66
+ 'base64',
67
+ 'base64url'
68
+ ].forEach((encoding)=>{
69
+ fragments.push(padded.toString(encoding).replace(/=+$/, '').slice(start, end));
70
+ });
71
+ });
72
+ return fragments;
73
+ }
74
+ function secretForms(value) {
75
+ return [
76
+ value,
77
+ encodeURIComponent(value),
78
+ JSON.stringify(value).slice(1, -1),
79
+ ...base64Fragments(value)
80
+ ];
81
+ }
82
+ function createSecretScrubber({ secrets, env = process.env }) {
83
+ const values = collectStringLeaves(secrets, []);
84
+ if (!type.isNone(env.CRON_SECRET)) values.push(env.CRON_SECRET);
85
+ if (!type.isNone(env.AUTH_SECRET)) values.push(env.AUTH_SECRET);
86
+ const expanded = values.flatMap((value)=>[
87
+ value,
88
+ ...parseJsonLeaves(value)
89
+ ]);
90
+ const secretValues = new Set(expanded.filter((value)=>value.length >= MIN_SECRET_LENGTH));
91
+ const forms = new Set();
92
+ secretValues.forEach((value)=>{
93
+ secretForms(value).forEach((form)=>{
94
+ if (form.length > 0) forms.add(form);
95
+ });
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([
107
+ ...forms
108
+ ].sort((a, b)=>b.length - a.length).map((form)=>form.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|'), 'g');
109
+ return function scrub(value) {
110
+ if (!type.isString(value)) return value;
111
+ return value.replace(pattern, REDACTED);
112
+ };
113
+ }
114
+ export default createSecretScrubber;
@@ -0,0 +1,27 @@
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 isPortAvailable from './isPortAvailable.js';
16
+ async function findAvailablePort({ port, maxAttempts = 100 }) {
17
+ const startPort = Number(port);
18
+ for(let candidate = startPort; candidate < startPort + maxAttempts; candidate += 1){
19
+ if (await isPortAvailable({
20
+ port: candidate
21
+ })) {
22
+ return candidate;
23
+ }
24
+ }
25
+ throw new Error(`[Server Error] No available port found in range ${startPort}-${startPort + maxAttempts - 1}.`);
26
+ }
27
+ export default findAvailablePort;
package/dist/index.js CHANGED
@@ -12,11 +12,18 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ import cleanDirectory from './cleanDirectory.js';
15
+ */ import checkEnvironmentGuards from './checkEnvironmentGuards.js';
16
+ import cleanDirectory from './cleanDirectory.js';
17
+ import collectEnvironmentGuards from './collectEnvironmentGuards.js';
16
18
  import copyFileOrDirectory from './copyFileOrDirectory.js';
19
+ import createSecretScrubber from './createSecretScrubber.js';
20
+ import findAvailablePort from './findAvailablePort.js';
17
21
  import getFileExtension, { getFileSubExtension } from './getFileExtension.js';
18
22
  import getSecretsFromEnv from './getSecretsFromEnv.js';
23
+ import installIfPackageJsonChanged from './installIfPackageJsonChanged.js';
24
+ import isPortAvailable from './isPortAvailable.js';
19
25
  import spawnProcess from './spawnProcess.js';
20
26
  import readFile from './readFile.js';
21
27
  import writeFile from './writeFile.js';
22
- export { cleanDirectory, copyFileOrDirectory, getFileExtension, getFileSubExtension, getSecretsFromEnv, spawnProcess, readFile, writeFile };
28
+ import writeFileIfChanged from './writeFileIfChanged.js';
29
+ export { checkEnvironmentGuards, cleanDirectory, collectEnvironmentGuards, copyFileOrDirectory, createSecretScrubber, findAvailablePort, getFileExtension, getFileSubExtension, getSecretsFromEnv, installIfPackageJsonChanged, isPortAvailable, spawnProcess, readFile, writeFile, writeFileIfChanged };
@@ -0,0 +1,43 @@
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 crypto from 'crypto';
16
+ import path from 'path';
17
+ import { type } from '@lowdefy/helpers';
18
+ import readFile from './readFile.js';
19
+ import writeFile from './writeFile.js';
20
+ async function installIfPackageJsonChanged({ directory, install }) {
21
+ if (!type.isString(directory)) {
22
+ throw new Error(`installIfPackageJsonChanged requires a directory string. Received ${JSON.stringify(directory)}.`);
23
+ }
24
+ if (!type.isFunction(install)) {
25
+ throw new Error('installIfPackageJsonChanged requires an install function.');
26
+ }
27
+ const packageJsonContent = await readFile(path.join(directory, 'package.json'));
28
+ if (packageJsonContent === null) {
29
+ throw new Error(`Could not read package.json in ${directory}.`);
30
+ }
31
+ const hash = crypto.createHash('sha1').update(packageJsonContent).digest('base64');
32
+ // Stored inside node_modules so deleting node_modules also clears the hash,
33
+ // forcing the next run to install.
34
+ const hashPath = path.join(directory, 'node_modules', '.lowdefy-install-hash');
35
+ const previousHash = await readFile(hashPath);
36
+ if (previousHash === hash) {
37
+ return false;
38
+ }
39
+ await install();
40
+ await writeFile(hashPath, hash);
41
+ return true;
42
+ }
43
+ export default installIfPackageJsonChanged;
@@ -0,0 +1,62 @@
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 'net';
16
+ // libuv sets SO_REUSEADDR on listening sockets, so on macOS a wildcard bind
17
+ // succeeds while another process holds a specific loopback address (and vice
18
+ // versa) — only an exact address match fails with EADDRINUSE. A single
19
+ // anonymous bind therefore reports ports busy on 127.0.0.1 (how Vite binds
20
+ // `localhost`) as available. Probe the wildcard and both loopback addresses
21
+ // so each common binding pattern is detected by its exact match.
22
+ const HOSTS = [
23
+ undefined,
24
+ '127.0.0.1',
25
+ '::1'
26
+ ];
27
+ function checkHost({ port, host }) {
28
+ return new Promise((resolve, reject)=>{
29
+ const server = net.createServer();
30
+ server.on('error', (error)=>{
31
+ if (error.code === 'EADDRINUSE') {
32
+ resolve(false);
33
+ return;
34
+ }
35
+ // Host has no IPv6 loopback — nothing can occupy that address.
36
+ if ([
37
+ 'EADDRNOTAVAIL',
38
+ 'EAFNOSUPPORT',
39
+ 'EINVAL'
40
+ ].includes(error.code)) {
41
+ resolve(true);
42
+ return;
43
+ }
44
+ reject(error);
45
+ });
46
+ server.listen(port, host, ()=>{
47
+ server.close(()=>resolve(true));
48
+ });
49
+ });
50
+ }
51
+ async function isPortAvailable({ port }) {
52
+ for (const host of HOSTS){
53
+ if (!await checkHost({
54
+ port,
55
+ host
56
+ })) {
57
+ return false;
58
+ }
59
+ }
60
+ return true;
61
+ }
62
+ export default isPortAvailable;
@@ -0,0 +1,27 @@
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 readFile from './readFile.js';
16
+ import writeFile from './writeFile.js';
17
+ // Skipping byte-identical writes keeps file mtimes stable so watchers (Vite
18
+ // module graph, chokidar) do not react to rebuilds that changed nothing.
19
+ async function writeFileIfChanged(filePath, content) {
20
+ const existing = await readFile(filePath);
21
+ if (existing === content) {
22
+ return false;
23
+ }
24
+ await writeFile(filePath, content);
25
+ return true;
26
+ }
27
+ export default writeFileIfChanged;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/node-utils",
3
- "version": "5.6.0",
3
+ "version": "6.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "",
6
6
  "homepage": "https://lowdefy.com",
@@ -34,13 +34,13 @@
34
34
  "dist/*"
35
35
  ],
36
36
  "dependencies": {
37
- "@lowdefy/errors": "5.6.0",
38
- "@lowdefy/helpers": "5.6.0",
39
- "fs-extra": "11.1.1"
37
+ "@lowdefy/errors": "6.1.0",
38
+ "@lowdefy/helpers": "6.1.0",
39
+ "fs-extra": "11.3.4"
40
40
  },
41
41
  "devDependencies": {
42
- "@swc/cli": "0.8.0",
43
- "@swc/core": "1.15.18",
42
+ "@swc/cli": "0.8.1",
43
+ "@swc/core": "1.15.32",
44
44
  "@swc/jest": "0.2.39",
45
45
  "copyfiles": "2.4.1",
46
46
  "jest": "28.1.3"