@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.
- package/dist/createClientAddressResolver.js +83 -0
- package/dist/createSecretScrubber.js +5 -13
- package/dist/getDevInstancePath.js +22 -0
- package/dist/getProcessStartTime.js +45 -0
- package/dist/index.js +7 -1
- package/dist/isPidAlive.js +24 -0
- package/dist/parseIpRange.js +52 -0
- package/dist/readDevInstance.js +91 -0
- package/package.json +4 -3
|
@@ -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.
|
|
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
|
-
//
|
|
98
|
-
|
|
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)
|
|
100
|
+
].sort((a, b)=>b.length - a.length);
|
|
109
101
|
return function scrub(value) {
|
|
110
102
|
if (!type.isString(value)) return value;
|
|
111
|
-
return
|
|
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-
|
|
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-
|
|
38
|
-
"@lowdefy/helpers": "0.0.0-experimental-
|
|
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",
|