sonamu 0.9.5 → 0.9.6
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/api/config.d.ts +13 -2
- package/dist/api/config.d.ts.map +1 -1
- package/dist/api/config.js +1 -1
- package/dist/api/context.d.ts +17 -7
- package/dist/api/context.d.ts.map +1 -1
- package/dist/api/context.js +1 -1
- package/dist/api/decorators.d.ts +18 -0
- package/dist/api/decorators.d.ts.map +1 -1
- package/dist/api/decorators.js +54 -3
- package/dist/api/index.js +8 -3
- package/dist/api/sonamu.d.ts +24 -9
- package/dist/api/sonamu.d.ts.map +1 -1
- package/dist/api/sonamu.js +365 -79
- package/dist/api/websocket-helpers.d.ts +24 -0
- package/dist/api/websocket-helpers.d.ts.map +1 -0
- package/dist/api/websocket-helpers.js +77 -0
- package/dist/bin/cli.js +12 -4
- package/dist/dict/sonamu-dictionary.js +5 -5
- package/dist/entity/entity-manager.js +1 -1
- package/dist/entity/entity.js +3 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -4
- package/dist/migration/code-generation.js +7 -7
- package/dist/stream/index.d.ts +6 -0
- package/dist/stream/index.d.ts.map +1 -1
- package/dist/stream/index.js +13 -2
- package/dist/stream/ws-audience-resolver.d.ts +15 -0
- package/dist/stream/ws-audience-resolver.d.ts.map +1 -0
- package/dist/stream/ws-audience-resolver.js +31 -0
- package/dist/stream/ws-audience.d.ts +28 -0
- package/dist/stream/ws-audience.d.ts.map +1 -0
- package/dist/stream/ws-audience.js +46 -0
- package/dist/stream/ws-cluster-bus.d.ts +23 -0
- package/dist/stream/ws-cluster-bus.d.ts.map +1 -0
- package/dist/stream/ws-cluster-bus.js +18 -0
- package/dist/stream/ws-core.d.ts +15 -0
- package/dist/stream/ws-core.d.ts.map +1 -0
- package/dist/stream/ws-core.js +1 -0
- package/dist/stream/ws-delivery.d.ts +24 -0
- package/dist/stream/ws-delivery.d.ts.map +1 -0
- package/dist/stream/ws-delivery.js +103 -0
- package/dist/stream/ws-local-connection-store.d.ts +10 -0
- package/dist/stream/ws-local-connection-store.d.ts.map +1 -0
- package/dist/stream/ws-local-connection-store.js +44 -0
- package/dist/stream/ws-presence-store.d.ts +61 -0
- package/dist/stream/ws-presence-store.d.ts.map +1 -0
- package/dist/stream/ws-presence-store.js +236 -0
- package/dist/stream/ws-registry.d.ts +42 -0
- package/dist/stream/ws-registry.d.ts.map +1 -0
- package/dist/stream/ws-registry.js +108 -0
- package/dist/stream/ws.d.ts +52 -0
- package/dist/stream/ws.d.ts.map +1 -0
- package/dist/stream/ws.js +397 -0
- package/dist/syncer/api-parser.d.ts.map +1 -1
- package/dist/syncer/api-parser.js +72 -2
- package/dist/syncer/checksum.d.ts.map +1 -1
- package/dist/syncer/checksum.js +13 -12
- package/dist/syncer/code-generator.d.ts.map +1 -1
- package/dist/syncer/code-generator.js +7 -4
- package/dist/syncer/event-batcher.d.ts +27 -0
- package/dist/syncer/event-batcher.d.ts.map +1 -0
- package/dist/syncer/event-batcher.js +69 -0
- package/dist/syncer/file-patterns.d.ts +48 -26
- package/dist/syncer/file-patterns.d.ts.map +1 -1
- package/dist/syncer/file-patterns.js +71 -23
- package/dist/syncer/file-tracking.d.ts +13 -0
- package/dist/syncer/file-tracking.d.ts.map +1 -0
- package/dist/syncer/file-tracking.js +33 -0
- package/dist/syncer/index.js +2 -2
- package/dist/syncer/module-loader.d.ts +2 -11
- package/dist/syncer/module-loader.d.ts.map +1 -1
- package/dist/syncer/module-loader.js +3 -3
- package/dist/syncer/syncer-actions.d.ts +39 -6
- package/dist/syncer/syncer-actions.d.ts.map +1 -1
- package/dist/syncer/syncer-actions.js +125 -10
- package/dist/syncer/syncer.d.ts +33 -19
- package/dist/syncer/syncer.d.ts.map +1 -1
- package/dist/syncer/syncer.js +168 -168
- package/dist/syncer/watcher.d.ts +8 -0
- package/dist/syncer/watcher.d.ts.map +1 -0
- package/dist/syncer/watcher.js +105 -0
- package/dist/tasks/workflow-manager.d.ts.map +1 -1
- package/dist/tasks/workflow-manager.js +2 -1
- package/dist/template/implementations/services.template.d.ts.map +1 -1
- package/dist/template/implementations/services.template.js +36 -1
- package/dist/testing/bootstrap.d.ts.map +1 -1
- package/dist/testing/bootstrap.js +8 -1
- package/dist/testing/fixture-manager.js +1 -1
- package/dist/types/types.d.ts +2 -1
- package/dist/types/types.d.ts.map +1 -1
- package/dist/types/types.js +2 -2
- package/dist/ui/api.js +1 -1
- package/dist/ui/cdd-service.js +1 -1
- package/dist/ui-web/assets/{index-DzZ7vBk4.js → index-BmThfg-s.js} +37 -37
- package/dist/ui-web/index.html +1 -1
- package/dist/utils/async-utils.d.ts +27 -3
- package/dist/utils/async-utils.d.ts.map +1 -1
- package/dist/utils/async-utils.js +56 -6
- package/dist/utils/formatter.d.ts +7 -1
- package/dist/utils/formatter.d.ts.map +1 -1
- package/dist/utils/formatter.js +95 -60
- package/dist/utils/fs-utils.d.ts +2 -0
- package/dist/utils/fs-utils.d.ts.map +1 -1
- package/dist/utils/fs-utils.js +10 -2
- package/dist/utils/process-utils.d.ts +6 -0
- package/dist/utils/process-utils.d.ts.map +1 -1
- package/dist/utils/process-utils.js +16 -3
- package/dist/utils/utils.d.ts +1 -0
- package/dist/utils/utils.d.ts.map +1 -1
- package/dist/utils/utils.js +2 -2
- package/package.json +4 -2
- package/src/api/__tests__/sonamu.websocket.test.ts +64 -0
- package/src/api/__tests__/websocket-context.types.test.ts +58 -0
- package/src/api/config.ts +28 -2
- package/src/api/context.ts +21 -7
- package/src/api/decorators.ts +101 -1
- package/src/api/sonamu.ts +529 -127
- package/src/api/websocket-helpers.ts +122 -0
- package/src/bin/cli.ts +10 -2
- package/src/dict/sonamu-dictionary.ts +2 -2
- package/src/entity/entity.ts +1 -1
- package/src/index.ts +6 -0
- package/src/migration/code-generation.ts +5 -5
- package/src/shared/app.shared.ts.txt +254 -1
- package/src/shared/web.shared.ts.txt +282 -1
- package/src/stream/__tests__/ws-contracts.test.ts +381 -0
- package/src/stream/__tests__/ws.test.ts +449 -0
- package/src/stream/index.ts +6 -0
- package/src/stream/ws-audience-resolver.ts +35 -0
- package/src/stream/ws-audience.ts +62 -0
- package/src/stream/ws-cluster-bus.ts +32 -0
- package/src/stream/ws-core.ts +16 -0
- package/src/stream/ws-delivery.ts +138 -0
- package/src/stream/ws-local-connection-store.ts +44 -0
- package/src/stream/ws-presence-store.ts +326 -0
- package/src/stream/ws-registry.ts +138 -0
- package/src/stream/ws.ts +591 -0
- package/src/syncer/__tests__/api-parser.websocket-type-ref.test.ts +78 -0
- package/src/syncer/api-parser.ts +112 -1
- package/src/syncer/checksum.ts +23 -29
- package/src/syncer/code-generator.ts +4 -1
- package/src/syncer/event-batcher.ts +72 -0
- package/src/syncer/file-patterns.ts +98 -30
- package/src/syncer/file-tracking.ts +27 -0
- package/src/syncer/module-loader.ts +5 -12
- package/src/syncer/syncer-actions.ts +179 -17
- package/src/syncer/syncer.ts +250 -287
- package/src/syncer/watcher.ts +128 -0
- package/src/tasks/workflow-manager.ts +1 -0
- package/src/template/__tests__/services.template.websocket.test.ts +79 -0
- package/src/template/implementations/services.template.ts +69 -0
- package/src/testing/bootstrap.ts +8 -1
- package/src/types/types.ts +20 -2
- package/src/utils/async-utils.ts +71 -4
- package/src/utils/formatter.ts +114 -75
- package/src/utils/fs-utils.ts +9 -0
- package/src/utils/process-utils.ts +17 -0
- package/src/utils/utils.ts +1 -1
package/src/utils/formatter.ts
CHANGED
|
@@ -1,108 +1,147 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
// 테스트 환경에서는 fs/promises가 mock되지만, 아래 runOxlint이 isTest 가드로 안 도니까
|
|
3
|
+
// 그냥 fs/promises 그대로 사용. (production에서만 임시파일 흐름이 돕니다.)
|
|
4
|
+
import { readFile, unlink, writeFile } from "fs/promises";
|
|
3
5
|
import { createRequire } from "module";
|
|
4
|
-
import {
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
import path, { dirname, join } from "path";
|
|
5
8
|
|
|
6
|
-
import { format } from "oxfmt";
|
|
9
|
+
import { format, type FormatConfig } from "oxfmt";
|
|
7
10
|
|
|
11
|
+
import { cached } from "./async-utils";
|
|
8
12
|
import { isTest } from "./controller";
|
|
13
|
+
import { execute } from "./process-utils";
|
|
9
14
|
|
|
10
15
|
const _require = createRequire(import.meta.url);
|
|
11
16
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
sortImports: true,
|
|
23
|
-
};
|
|
17
|
+
/**
|
|
18
|
+
* 코드를 프로젝트의 oxfmt + oxlint 설정에 맞춰 포매팅한 문자열을 반환합니다.
|
|
19
|
+
*
|
|
20
|
+
* 캐싱도 있어요 ㅎㅎ 똑같은 입력에 대해서 캐시 커버됩니다.
|
|
21
|
+
* 수명은 프로세스 죽을때까지 ㅋ
|
|
22
|
+
*/
|
|
23
|
+
export const formatCode = cached(formatCodeInternal, (code, filePath) => {
|
|
24
|
+
const ext = filePath.endsWith(".tsx") ? "tsx" : filePath.endsWith("json") ? "json" : "ts";
|
|
25
|
+
return `${ext}:${createHash("sha1").update(code).digest("hex")}`;
|
|
26
|
+
});
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* 캐시 없는 포맷함수 엔트리.
|
|
30
|
+
*/
|
|
31
|
+
async function formatCodeInternal(code: string, filePath: string): Promise<string> {
|
|
32
|
+
// json은 포맷만 하면 됩니다.
|
|
33
|
+
if (filePath.endsWith("json")) {
|
|
34
|
+
return runOxfmt(code, filePath);
|
|
30
35
|
}
|
|
36
|
+
|
|
37
|
+
// 린트 먼저 한 다음에 포맷으로 마무리해요.
|
|
38
|
+
return runOxfmt(await runOxlint(code), filePath);
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
41
|
+
/**
|
|
42
|
+
* 프로젝트 설정을 찾아서 이에 맞춰서 코드를 포맷합니다.
|
|
43
|
+
*/
|
|
44
|
+
async function runOxfmt(code: string, filePath: string): Promise<string> {
|
|
45
|
+
const result = await format(path.basename(filePath), code, await loadOxfmtConfig());
|
|
46
|
+
|
|
47
|
+
const errors = result.errors.filter((e) => e.severity === "Error");
|
|
48
|
+
if (errors.length > 0) {
|
|
49
|
+
if (!isTest()) {
|
|
50
|
+
console.error(`oxfmt errors (${filePath}):`);
|
|
51
|
+
for (const err of errors) {
|
|
52
|
+
const label = err.labels[0];
|
|
53
|
+
if (label) {
|
|
54
|
+
const before = code.slice(Math.max(0, label.start - 80), label.start);
|
|
55
|
+
const at = code.slice(label.start, label.end);
|
|
56
|
+
const after = code.slice(label.end, Math.min(code.length, label.end + 80));
|
|
57
|
+
console.error(` - ${err.message} (offset ${label.start}-${label.end})`);
|
|
58
|
+
console.error(` around: ...${before}»${at}«${after}...`);
|
|
59
|
+
} else {
|
|
60
|
+
console.error(` - ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return code;
|
|
65
|
+
}
|
|
66
|
+
return result.code;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let cachedOxfmtConfig: FormatConfig | null = null;
|
|
70
|
+
async function loadOxfmtConfig(): Promise<FormatConfig> {
|
|
71
|
+
if (cachedOxfmtConfig !== null) {
|
|
72
|
+
return cachedOxfmtConfig;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let dir = process.cwd();
|
|
76
|
+
while (true) {
|
|
77
|
+
const candidate = join(dir, ".oxfmtrc.json");
|
|
78
|
+
try {
|
|
79
|
+
cachedOxfmtConfig = JSON.parse(await readFile(candidate, "utf-8")) as FormatConfig;
|
|
80
|
+
return cachedOxfmtConfig;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
83
|
+
!isTest() && console.error(`Failed to load ${candidate}:`, e);
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
49
86
|
}
|
|
87
|
+
const parent = dirname(dir);
|
|
88
|
+
if (parent === dir) break;
|
|
89
|
+
dir = parent;
|
|
50
90
|
}
|
|
51
91
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
cachedOxfmtConfig = {};
|
|
93
|
+
return cachedOxfmtConfig;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 프로젝트 설정에 맞춰 코드를 lint합니다.
|
|
98
|
+
*
|
|
99
|
+
* 프로젝트 설정을 적용받는 oxlint cli를 찾아 띄워서,
|
|
100
|
+
* 임시 파일에 in-place로 써서 그 결과를 빼오는 방식으로 작동합니다.
|
|
101
|
+
* 왜 이렇게 하느냐? oxlint가 node api도 안 주고 cli에서 stdin 옵션도 안 주기 때문...
|
|
102
|
+
*/
|
|
103
|
+
async function runOxlint(code: string): Promise<string> {
|
|
104
|
+
if (isTest()) {
|
|
105
|
+
// 테스트 환경에서는 느려지기만 하고 검증할 가치도 없어서 안 합니다.
|
|
106
|
+
// GitHub Actions 환경에서 lint가 오래 걸려서 뻗기도 했어요. (https://github.com/cartanova-ai/sonamu/actions/runs/25267214027/job/74083630169)
|
|
107
|
+
return code;
|
|
55
108
|
}
|
|
56
109
|
|
|
57
|
-
//
|
|
58
|
-
// cwd
|
|
110
|
+
// OS tmp dir에 두는 이유: process.cwd()가 watcher 스코프(api/src 아래)일 가능성을 차단합니다.
|
|
111
|
+
// cwd가 그 위치라면 write/unlink가 짧은 순간 watcher 이벤트로 잡혀 batch에 흘러들어갈 수 있어요.
|
|
59
112
|
const tmpFile = join(
|
|
60
|
-
|
|
113
|
+
tmpdir(),
|
|
61
114
|
`.sonamu-fmt-${Date.now()}-${Math.random().toString(36).slice(2)}.ts`,
|
|
62
115
|
);
|
|
116
|
+
|
|
63
117
|
try {
|
|
64
|
-
|
|
118
|
+
await writeFile(tmpFile, code, "utf-8");
|
|
65
119
|
|
|
66
|
-
const oxlintBin = resolveOxlintBin();
|
|
67
120
|
try {
|
|
68
|
-
|
|
69
|
-
stdio: "pipe",
|
|
121
|
+
await execute(resolveOxlintBin(), ["--fix", "--fix-suggestions", "--type-aware", tmpFile], {
|
|
70
122
|
timeout: 10000,
|
|
71
123
|
});
|
|
72
|
-
} catch (
|
|
73
|
-
//
|
|
74
|
-
if (
|
|
75
|
-
|
|
76
|
-
if (typeof errObj.status === "number") {
|
|
77
|
-
void errObj.status;
|
|
78
|
-
} else {
|
|
79
|
-
throw execError;
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
throw execError;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
const lintFixed = readFileSync(tmpFile, "utf-8");
|
|
87
|
-
|
|
88
|
-
// lint fix 후 재포맷 (import 구문 변경으로 인한 정렬 등)
|
|
89
|
-
const reformatted = await format(fileName, lintFixed, OXFMT_OPTIONS);
|
|
90
|
-
if (reformatted.errors.length > 0) {
|
|
91
|
-
const errorMessages = reformatted.errors
|
|
92
|
-
.filter((e) => e.severity === "Error")
|
|
93
|
-
.map((e) => e.message);
|
|
94
|
-
if (errorMessages.length > 0) {
|
|
95
|
-
!isTest() && console.error("oxfmt reformat errors:", errorMessages);
|
|
96
|
-
throw new Error(`oxfmt reformat error: ${errorMessages.join(", ")}`);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
// lint 위반 시 exit code != 0이지만 --fix는 적용됨. exec 자체 실패만 throw.
|
|
126
|
+
if (typeof (e as Error & { code?: number }).code !== "number") {
|
|
127
|
+
throw e;
|
|
97
128
|
}
|
|
98
129
|
}
|
|
99
130
|
|
|
100
|
-
return
|
|
131
|
+
return await readFile(tmpFile, "utf-8");
|
|
101
132
|
} finally {
|
|
102
133
|
try {
|
|
103
|
-
|
|
134
|
+
await unlink(tmpFile);
|
|
104
135
|
} catch {
|
|
105
|
-
//
|
|
136
|
+
// 삭제 실패해도 어차피 ignore됨.
|
|
106
137
|
}
|
|
107
138
|
}
|
|
108
139
|
}
|
|
140
|
+
|
|
141
|
+
function resolveOxlintBin(): string {
|
|
142
|
+
try {
|
|
143
|
+
return _require.resolve("oxlint/bin/oxlint");
|
|
144
|
+
} catch {
|
|
145
|
+
return "oxlint";
|
|
146
|
+
}
|
|
147
|
+
}
|
package/src/utils/fs-utils.ts
CHANGED
|
@@ -3,6 +3,8 @@ import { type PathLike } from "fs";
|
|
|
3
3
|
import { access, readFile, stat, writeFile } from "fs/promises";
|
|
4
4
|
import path, { dirname } from "path";
|
|
5
5
|
|
|
6
|
+
import { formatCode } from "./formatter";
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* fs/promises에는 exists가 없어요. 대신 access가 있습니다.
|
|
8
10
|
* 근데 얘는 인터페이스가 쓰기 불편해요. 그래서 감싸주었습니다.
|
|
@@ -37,6 +39,8 @@ export async function fileExists(path: PathLike): Promise<boolean> {
|
|
|
37
39
|
* - services/user/user.types.ts → ../sonamu.shared
|
|
38
40
|
* - i18n/ko.ts → ../services/sonamu.shared
|
|
39
41
|
*
|
|
42
|
+
* ts/tsx 파일은 쓰기 전에 oxfmt/oxlint 한번 돌려줍니다!
|
|
43
|
+
*
|
|
40
44
|
* @param fromPath 원본 파일 경로
|
|
41
45
|
* @param toPath 대상 파일 경로
|
|
42
46
|
* @param syncHeader 동기화 시 파일 최상단에 삽입할 주석 블록. 기존 @generated 블록이 있으면 교체하고, 없으면 최상단에 추가합니다.
|
|
@@ -83,6 +87,11 @@ export async function copyFileWithReplaceCoreToShared(
|
|
|
83
87
|
}
|
|
84
88
|
}
|
|
85
89
|
|
|
90
|
+
// .ts/.tsx 산출물은 쓰기 전에 포맷도 해줘요 ㅎㅎ
|
|
91
|
+
if (toPath.endsWith(".ts") || toPath.endsWith(".tsx")) {
|
|
92
|
+
newFileContent = await formatCode(newFileContent, toPath);
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
await writeFile(toPath, newFileContent);
|
|
87
96
|
return true;
|
|
88
97
|
}
|
|
@@ -1,7 +1,24 @@
|
|
|
1
|
+
import { execFile, type ExecFileOptions } from "child_process";
|
|
1
2
|
import { setTimeout as setTimeoutPromises } from "timers/promises";
|
|
3
|
+
import { promisify } from "util";
|
|
2
4
|
|
|
3
5
|
import chalk from "chalk";
|
|
4
6
|
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* exexFileSync의 비동기 버전입니다.
|
|
11
|
+
* exit code가 non-zero이면 reject해요.
|
|
12
|
+
*/
|
|
13
|
+
export async function execute(
|
|
14
|
+
bin: string,
|
|
15
|
+
args: string[],
|
|
16
|
+
options?: ExecFileOptions,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const { stdout } = await execFileAsync(bin, args, options);
|
|
19
|
+
return typeof stdout === "string" ? stdout : stdout.toString();
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
/**
|
|
6
23
|
* 주어진 작업을 실행합니다.
|
|
7
24
|
* 주어진 프로세스 이벤트(=시그널)가 발생하였을 때에도 최대 한계(delayBeforeShutdown) 동안 작업을 기다린 후 종료합니다.
|
package/src/utils/utils.ts
CHANGED
|
@@ -118,7 +118,7 @@ export function merge<T extends Record<string, any>>(defaultObj: T, userObj: T):
|
|
|
118
118
|
|
|
119
119
|
// plain object 판별 헬퍼 함수
|
|
120
120
|
// (배열, null, Date 등을 제외한 순수 객체만 true)
|
|
121
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
121
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
122
122
|
return (
|
|
123
123
|
value !== null &&
|
|
124
124
|
typeof value === "object" &&
|