@ibgib/space-gib 0.0.1
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/CHANGELOG.md +31 -0
- package/Dockerfile +14 -0
- package/IMPLEMENTATION.md +484 -0
- package/README.md +46 -0
- package/dist/client/bootstrap.mjs +58 -0
- package/dist/client/bootstrap.mjs.map +7 -0
- package/dist/client/chunk-CT47Z5WU.mjs +21 -0
- package/dist/client/chunk-CT47Z5WU.mjs.map +7 -0
- package/dist/client/chunk-RHEDTRKF.mjs +235 -0
- package/dist/client/chunk-RHEDTRKF.mjs.map +7 -0
- package/dist/client/index.html +147 -0
- package/dist/client/index.mjs +2 -0
- package/dist/client/index.mjs.map +7 -0
- package/dist/client/script.mjs +2 -0
- package/dist/client/script.mjs.map +7 -0
- package/dist/client/style.css +605 -0
- package/dist/respec-gib.node.mjs +5 -0
- package/dist/server/server.mjs +20157 -0
- package/dist/server/server.mjs.map +7 -0
- package/generate-version-file.js +35 -0
- package/package.json +27 -0
- package/src/client/AUTO-GENERATED-version.mts +11 -0
- package/src/client/README.md +19 -0
- package/src/client/api/function-infos.web.mts +38 -0
- package/src/client/api/space-gib-api-bridge.mts +85 -0
- package/src/client/bootstrap.mts +49 -0
- package/src/client/components/keystone-creator/keystone-creator.css +139 -0
- package/src/client/components/keystone-creator/keystone-creator.html +26 -0
- package/src/client/components/keystone-creator/keystone-creator.mts +229 -0
- package/src/client/constants.mts +76 -0
- package/src/client/custom.d.ts +11 -0
- package/src/client/dev-tools.mts +540 -0
- package/src/client/helpers.web.mts +178 -0
- package/src/client/index.html +147 -0
- package/src/client/index.mts +59 -0
- package/src/client/script.mts +13 -0
- package/src/client/style.css +605 -0
- package/src/client/types.mts +85 -0
- package/src/client/ui/shell/space-gib-shell-constants.mts +24 -0
- package/src/client/ui/shell/space-gib-shell-service.mts +233 -0
- package/src/client/ui/shell/space-gib-shell-types.mts +5 -0
- package/src/client/witness/app/space-gib/space-gib-app-v1.mts +160 -0
- package/src/client/witness/app/space-gib/space-gib-constants.mts +38 -0
- package/src/client/witness/app/space-gib/space-gib-helper.mts +72 -0
- package/src/client/witness/app/space-gib/space-gib-types.mts +47 -0
- package/src/common/keystone-policies.mts +159 -0
- package/src/respec-gib.node.mts +6 -0
- package/src/server/README.md +18 -0
- package/src/server/bootstrap-helper.mts +141 -0
- package/src/server/bootstrap-helper.respec.mts +100 -0
- package/src/server/metaspace-nodeindexedspace/metaspace-nodeindexedspace.mts +85 -0
- package/src/server/path-constants.mts +89 -0
- package/src/server/path-helper.mts +101 -0
- package/src/server/path-helper.respec.mts +94 -0
- package/src/server/serve-gib/CHANGELOG.md +29 -0
- package/src/server/serve-gib/README.md +34 -0
- package/src/server/serve-gib/constants.mts +1 -0
- package/src/server/serve-gib/handlers/api/debug/ws-echo.handler.mts +104 -0
- package/src/server/serve-gib/handlers/api/health.handler.mts +23 -0
- package/src/server/serve-gib/handlers/api/health.respec.mts +51 -0
- package/src/server/serve-gib/handlers/api/ibgib/ibgib-handler-types.mts +49 -0
- package/src/server/serve-gib/handlers/api/ibgib/ibgib.handler.mts +176 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-evolve.handler.mts +261 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-genesis.handler.mts +146 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-get.handler.mts +198 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-get.respec.mts +107 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-handler-types.mts +29 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-post.handler.mts +70 -0
- package/src/server/serve-gib/handlers/api/keystone/keystone-post.respec.mts +130 -0
- package/src/server/serve-gib/handlers/error-handler.mts +36 -0
- package/src/server/serve-gib/handlers/handler-base.mts +383 -0
- package/src/server/serve-gib/handlers/static-handler.mts +82 -0
- package/src/server/serve-gib/handlers/ws/sync-upgrade.handler.mts +498 -0
- package/src/server/serve-gib/handlers/ws/ws-helper.mts +111 -0
- package/src/server/serve-gib/handlers/ws/ws-types.mts +53 -0
- package/src/server/serve-gib/serve-gib-helpers.mts +32 -0
- package/src/server/serve-gib/serve-gib-v1.mts +172 -0
- package/src/server/serve-gib/serve-gib.respec.mts +90 -0
- package/src/server/serve-gib/types.mts +102 -0
- package/src/server/server-constants.mts +2 -0
- package/src/server/server.mts +96 -0
- package/tsconfig.json +29 -0
- package/tsconfig.server.json +29 -0
- package/tsconfig.test.json +27 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { extractErrorMsg } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs';
|
|
2
|
+
import { validateIbGibAddr } from '@ibgib/ts-gib/dist/V1/validate-helper.mjs';
|
|
3
|
+
|
|
4
|
+
import { GLOBAL_LOG_A_LOT } from './server-constants.mjs';
|
|
5
|
+
import { FORBIDDEN_PATTERNS, MAX_PATH_LENGTH, VALID_STATIC_PATH_REGEX } from './path-constants.mjs';
|
|
6
|
+
|
|
7
|
+
const logalot = GLOBAL_LOG_A_LOT;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* little helper function just to DRY out code.
|
|
11
|
+
*/
|
|
12
|
+
export function runtimeErrorValidationStrings(lc: string, error: any): string[] {
|
|
13
|
+
const emsg = `${lc} ${extractErrorMsg(error)}`;
|
|
14
|
+
console.error(emsg);
|
|
15
|
+
return [`runtime error: ${emsg}`];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates that a path is safe from common attacks and under max length.
|
|
20
|
+
*/
|
|
21
|
+
export function validateRawPath(rawPath: string): string[] {
|
|
22
|
+
const lc = `[${validateRawPath.name}]`;
|
|
23
|
+
try {
|
|
24
|
+
if (logalot) { console.log(`${lc} starting... (I: 80bd6f63e6c83b8e943b90383e044826)`); }
|
|
25
|
+
|
|
26
|
+
const errors: string[] = [];
|
|
27
|
+
|
|
28
|
+
if (!rawPath || rawPath.length > MAX_PATH_LENGTH) {
|
|
29
|
+
errors.push(`path too long. MAX_PATH_LENGTH: ${MAX_PATH_LENGTH}, incoming path length: ${rawPath.length}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
33
|
+
if (pattern.test(rawPath)) {
|
|
34
|
+
errors.push(`invalid path. contains forbidden pattern.source: ${pattern.source} (E: 56a228fb9473a086b83d0088b5af1826)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return errors;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return runtimeErrorValidationStrings(lc, error);
|
|
41
|
+
} finally {
|
|
42
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* simple boolean wrapper for {@link validateRawPath}
|
|
48
|
+
*/
|
|
49
|
+
export function isSafePath(path: string): boolean {
|
|
50
|
+
const errors = validateRawPath(path);
|
|
51
|
+
return errors.length === 0;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function validateStaticPath(path: string): string[] {
|
|
55
|
+
const lc = `[${validateStaticPath.name}]`;
|
|
56
|
+
try {
|
|
57
|
+
if (logalot) { console.log(`${lc} starting... (I: 4f9cf8ffd528c7dd88324529528eeb26)`); }
|
|
58
|
+
|
|
59
|
+
const errors: string[] = [];
|
|
60
|
+
|
|
61
|
+
if (!VALID_STATIC_PATH_REGEX.test(path)) {
|
|
62
|
+
errors.push(`static path fails to match VALID_STATIC_PATH_REGEX of ${VALID_STATIC_PATH_REGEX} (E: dfdf38a0439d4d09186e6fa81ab66826) `);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return errors;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
return runtimeErrorValidationStrings(lc, error);
|
|
68
|
+
} finally {
|
|
69
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Validates a static file path.
|
|
75
|
+
*/
|
|
76
|
+
export function isValidStaticPath(path: string): boolean {
|
|
77
|
+
if (!isSafePath(path)) { return false; }
|
|
78
|
+
const errors = validateStaticPath(path);
|
|
79
|
+
return errors.length === 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Decodes and validates a single ibGib address segment from a URL path.
|
|
84
|
+
* Uses the library's internal validateIbGibAddr for deep checking.
|
|
85
|
+
*/
|
|
86
|
+
export function validateAddressSegment(encodedAddr: string): string[] {
|
|
87
|
+
const lc = `[${validateAddressSegment.name}]`;
|
|
88
|
+
try {
|
|
89
|
+
if (logalot) { console.log(`${lc} starting... (I: 1bb9ff7e1aefb77b670b2519658cc126)`); }
|
|
90
|
+
if (!encodedAddr) {
|
|
91
|
+
throw new Error(`(UNEXPECTED) falsy encodedAddr? We're expecting to have already guaranteed there are no double-slashes (i.e. no empty segments) (E: 7be7668e7b8c9fbbdf56f0fe19f92e26)`);
|
|
92
|
+
}
|
|
93
|
+
const addr = decodeURIComponent(encodedAddr);
|
|
94
|
+
const errors = validateIbGibAddr({ addr });
|
|
95
|
+
return errors ?? [];
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return runtimeErrorValidationStrings(lc, error);
|
|
98
|
+
} finally {
|
|
99
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { respecfully, iReckon, ifWe } from '@ibgib/helper-gib/dist/respec-gib/respec-gib.mjs';
|
|
2
|
+
import { isSafePath, isValidStaticPath, API_PATHS, getPathParams, MAX_PATH_LENGTH, validateAddressSegment } from './path-helper.mjs';
|
|
3
|
+
|
|
4
|
+
const sir = `[${import.meta.url}]`;
|
|
5
|
+
|
|
6
|
+
await respecfully(sir, 'Path Helper Validation', async () => {
|
|
7
|
+
|
|
8
|
+
await respecfully(sir, 'isSafePath', async () => {
|
|
9
|
+
await ifWe(sir, 'valid simple paths', async () => {
|
|
10
|
+
iReckon(sir, isSafePath('/api/health')).isGonnaBeTrue();
|
|
11
|
+
iReckon(sir, isSafePath('/index.html')).isGonnaBeTrue();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
await ifWe(sir, 'too long paths', async () => {
|
|
15
|
+
const longPath = '/'.repeat(MAX_PATH_LENGTH + 1);
|
|
16
|
+
iReckon(sir, isSafePath(longPath)).isGonnaBeFalse();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
await ifWe(sir, 'directory traversal attempts', async () => {
|
|
20
|
+
iReckon(sir, isSafePath('/../etc/passwd')).isGonnaBeFalse();
|
|
21
|
+
iReckon(sir, isSafePath('/api/../../etc/passwd')).isGonnaBeFalse();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
await ifWe(sir, 'null bytes', async () => {
|
|
25
|
+
iReckon(sir, isSafePath('/api/health\0')).isGonnaBeFalse();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
await ifWe(sir, 'redundant slashes', async () => {
|
|
29
|
+
iReckon(sir, isSafePath('/api//health')).isGonnaBeFalse();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await ifWe(sir, 'dot-file access', async () => {
|
|
33
|
+
iReckon(sir, isSafePath('/.env')).isGonnaBeFalse();
|
|
34
|
+
iReckon(sir, isSafePath('/scripts/.hidden')).isGonnaBeFalse();
|
|
35
|
+
iReckon(sir, isSafePath('/.git/config')).isGonnaBeFalse();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
await respecfully(sir, 'validateAddressSegment', async () => {
|
|
40
|
+
await ifWe(sir, 'valid encoded addresses', async () => {
|
|
41
|
+
const validGib = 'a'.repeat(64);
|
|
42
|
+
iReckon(sir, validateAddressSegment(`someib%5E${validGib}`)).isGonnaBeTrue();
|
|
43
|
+
// gib with dots (common in sha256.sha256)
|
|
44
|
+
const dotGib = `${validGib}.${validGib}`;
|
|
45
|
+
iReckon(sir, validateAddressSegment(`someib%5E${dotGib}`)).isGonnaBeTrue();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
await ifWe(sir, 'invalid encoded addresses', async () => {
|
|
49
|
+
iReckon(sir, validateAddressSegment('no_delim')).isGonnaBeFalse();
|
|
50
|
+
iReckon(sir, validateAddressSegment('%5Estarts_with_delim')).isGonnaBeFalse();
|
|
51
|
+
iReckon(sir, validateAddressSegment('too%5Emany%5Edelims')).isGonnaBeFalse();
|
|
52
|
+
iReckon(sir, validateAddressSegment('too%5Emany..dots')).isGonnaBeFalse();
|
|
53
|
+
iReckon(sir, validateAddressSegment('invalid_chars_!%5Eabc')).isGonnaBeFalse();
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
await respecfully(sir, 'API_PATHS Regexes', async () => {
|
|
58
|
+
await ifWe(sir, 'HEALTH regex', async () => {
|
|
59
|
+
iReckon(sir, API_PATHS.HEALTH.test('/api/health')).isGonnaBeTrue();
|
|
60
|
+
iReckon(sir, API_PATHS.HEALTH.test('/api/health/')).isGonnaBeTrue();
|
|
61
|
+
iReckon(sir, API_PATHS.HEALTH.test('/api/health/more')).isGonnaBeFalse();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await ifWe(sir, 'IBGIB_ADDR regex', async () => {
|
|
65
|
+
// valid encoded addr: someib%5Esomegib
|
|
66
|
+
const addr = '/api/ibgib/someib%5Esomegib';
|
|
67
|
+
iReckon(sir, API_PATHS.IBGIB_ADDR.test(addr)).isGonnaBeTrue();
|
|
68
|
+
|
|
69
|
+
const params = getPathParams(addr, API_PATHS.IBGIB_ADDR);
|
|
70
|
+
iReckon(sir, params?.[0]).isGonnaBe('someib%5Esomegib');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
await ifWe(sir, 'IBGIB_GRAPH regex', async () => {
|
|
74
|
+
const path = '/api/ibgib/graph/someib%5Esomegib';
|
|
75
|
+
iReckon(sir, API_PATHS.IBGIB_GRAPH.test(path)).isGonnaBeTrue();
|
|
76
|
+
|
|
77
|
+
const params = getPathParams(path, API_PATHS.IBGIB_GRAPH);
|
|
78
|
+
iReckon(sir, params?.[0]).isGonnaBe('someib%5Esomegib');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await ifWe(sir, 'KEYSTONE regex', async () => {
|
|
82
|
+
iReckon(sir, API_PATHS.KEYSTONE.test('/api/keystone')).isGonnaBeTrue();
|
|
83
|
+
iReckon(sir, API_PATHS.KEYSTONE.test('/api/keystone/')).isGonnaBeTrue();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await ifWe(sir, 'KEYSTONE_EVOLVE regex', async () => {
|
|
87
|
+
const path = '/api/keystone/evolve/someib%5Esomegib';
|
|
88
|
+
iReckon(sir, API_PATHS.KEYSTONE_EVOLVE.test(path)).isGonnaBeTrue();
|
|
89
|
+
|
|
90
|
+
const params = getPathParams(path, API_PATHS.KEYSTONE_EVOLVE);
|
|
91
|
+
iReckon(sir, params?.[0]).isGonnaBe('someib%5Esomegib');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# serve-gib changelog
|
|
2
|
+
|
|
3
|
+
_note: as you implement features/fixes/etc., please document them here under the "Working Version" section._
|
|
4
|
+
|
|
5
|
+
## Working Version
|
|
6
|
+
|
|
7
|
+
* impl: initial core infrastructure
|
|
8
|
+
* defined `RouteInfo`, `ResponseResult`, and `ServeGibHandler` foundational types.
|
|
9
|
+
* implemented `ServeGib_V1` with pipeline-based request processing.
|
|
10
|
+
* added robust `try..catch..finally` pattern with built-in access logging and timing.
|
|
11
|
+
* impl: handler "pit of success" plumbing
|
|
12
|
+
* implemented `ServeGibHandlerBase` abstract class to reduce boilerplate.
|
|
13
|
+
* automated route matching via `canHandleRoute` and `regex` properties.
|
|
14
|
+
* implemented `handleRouteImpl` pattern to separate plumbing from business logic.
|
|
15
|
+
* added generic `TQueryParams` support with automated parsing via `parseQueryParams`.
|
|
16
|
+
* feat: hierarchical handler organization
|
|
17
|
+
* established folder-based grouping mirroring URL paths (e.g., `handlers/api/ibgib/`).
|
|
18
|
+
* enforced "one handler per hard route" and "one handler per file" architectural guidelines.
|
|
19
|
+
* feat: concrete core handlers
|
|
20
|
+
* `HealthHandler`: Simple liveness probe.
|
|
21
|
+
* `IbGibHandler`: Single ibGib address resolution.
|
|
22
|
+
* `IbGibGraphHandler`: Complex graph/BFS retrieval.
|
|
23
|
+
* `KeystoneHandler`: Genesis and evolution turn persistence.
|
|
24
|
+
* `StaticFileHandler`: High-performance asset serving with SPA history fallback.
|
|
25
|
+
* `ErrorHandler`: Standardized 404 and fatal error reporting.
|
|
26
|
+
* docs: architectural guidance
|
|
27
|
+
* created `README.md` with detailed guidance on handler philosophy and plumbing usage.
|
|
28
|
+
* test: validation suite
|
|
29
|
+
* implemented `serve-gib.respec.mts` verifying orchestrator pipeline and `RouteInfo` construction.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# serve-gib Microframework
|
|
2
|
+
|
|
3
|
+
A minimalist, high-observability HTTP microframework for ibgib servers.
|
|
4
|
+
|
|
5
|
+
## Architecture & Guidance
|
|
6
|
+
|
|
7
|
+
### 1. Handler Philosophy
|
|
8
|
+
* **One handler per "hard" route**: A handler should correspond to a unique URL path pattern, disregarding query parameters.
|
|
9
|
+
* **One regex per handler**: Concrete handlers should ideally define a single `regex` property for routing.
|
|
10
|
+
* **One handler per file**: Each route handler should live in its own file within the `handlers/` directory (e.g., `handlers/api/health.handler.mts`).
|
|
11
|
+
* The subfolders should match the hard route of the handler, e.g., `api/ibgib` route should be in the `handlers/api/ibgib` subfolder.
|
|
12
|
+
|
|
13
|
+
### 2. The "Pit of Success" Plumbing
|
|
14
|
+
The `ServeGibHandlerBase` class provides built-in plumbing to reduce boilerplate:
|
|
15
|
+
* **`handleRoute` (Plumbing)**: Automatically checks `canHandleRoute`, parses query parameters, and provides a top-level `try..catch` block for logging and error reporting.
|
|
16
|
+
* **`handleRouteImpl` (Implementation)**: This is where the core logic of the handler resides.
|
|
17
|
+
* **`canHandleRoute`**: Defaults to a regex match on `info.pathname`. Override this for more complex routing logic.
|
|
18
|
+
|
|
19
|
+
### 3. Query Parameters
|
|
20
|
+
Query parameters are handled via the `parseQueryParams` method in the base class.
|
|
21
|
+
* By default, it parses URL search params into a key-value object.
|
|
22
|
+
* Handlers can override this method to perform custom parsing or validation (e.g., parsing JSON strings from query params).
|
|
23
|
+
* The base class is generic: `ServeGibHandlerBase<TQueryParams = any>`.
|
|
24
|
+
|
|
25
|
+
### 4. RouteInfo
|
|
26
|
+
The `RouteInfo` object provides an enriched view of the incoming request:
|
|
27
|
+
* `decodedPathSegments`: Pre-split and decoded path parts.
|
|
28
|
+
* `protocol`, `method`, `body`: Basic HTTP info.
|
|
29
|
+
* `queryParams`: The result of the handler's `parseQueryParams` call.
|
|
30
|
+
|
|
31
|
+
## Pipeline Order
|
|
32
|
+
The `ServeGib_V1` executes handlers in the order they are provided in the `handlers` array. The first handler to return a non-undefined `ResponseResult` wins.
|
|
33
|
+
* **Tip**: Order specific routes (like `/api/ibgib/graph`) before more general ones (like `/api/ibgib`).
|
|
34
|
+
* **Fallback**: The `ErrorHandler` is used as a final catch-all for 404s and fatal errors.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const GLOBAL_LOG_A_LOT = false;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module handlers/api/debug/ws-echo.handler
|
|
3
|
+
*
|
|
4
|
+
* Minimal WebSocket echo handler for validating end-to-end WebSocket
|
|
5
|
+
* plumbing: Traefik passthrough → Node.js upgrade event → browser client.
|
|
6
|
+
*
|
|
7
|
+
* ## intent
|
|
8
|
+
*
|
|
9
|
+
* This is a diagnostic-only endpoint. The goal is to confirm that:
|
|
10
|
+
* 1. Traefik correctly forwards WS upgrade requests to the container
|
|
11
|
+
* 2. The Node.js `upgrade` event fires and we can accept the handshake
|
|
12
|
+
* 3. The browser client can open a wss:// connection and exchange frames
|
|
13
|
+
*
|
|
14
|
+
* Once WS sync is confirmed end-to-end, this handler can be removed or
|
|
15
|
+
* gated behind an env flag.
|
|
16
|
+
*
|
|
17
|
+
* ## endpoint
|
|
18
|
+
*
|
|
19
|
+
* `GET /api/debug/ws-echo` (with Upgrade: websocket header)
|
|
20
|
+
*
|
|
21
|
+
* On connect: server sends `{"status":"connected","msg":"ws-echo ready"}`
|
|
22
|
+
* On message: server echoes `{"echo":<original message>}`
|
|
23
|
+
* On close: server logs and cleans up
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { IncomingMessage } from 'node:http';
|
|
27
|
+
import { Socket } from 'node:net';
|
|
28
|
+
|
|
29
|
+
import { extractErrorMsg } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs';
|
|
30
|
+
|
|
31
|
+
import { GLOBAL_LOG_A_LOT } from '../../../constants.mjs';
|
|
32
|
+
import {
|
|
33
|
+
encodeTextFrame, decodeTextFrame, encodeCloseFrame, performHandshake
|
|
34
|
+
} from '../../ws/ws-helper.mjs';
|
|
35
|
+
import { API_PATH_REGEXES } from '../../../../path-constants.mjs';
|
|
36
|
+
|
|
37
|
+
const logalot = GLOBAL_LOG_A_LOT;
|
|
38
|
+
|
|
39
|
+
const lc = `[WsEchoHandler]`;
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Public handler
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Returns true if this request is a WebSocket upgrade to the debug echo path.
|
|
48
|
+
* Call this from the server's `upgrade` event listener.
|
|
49
|
+
*/
|
|
50
|
+
export function canHandleWsUpgrade(req: IncomingMessage): boolean {
|
|
51
|
+
const pathname = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
52
|
+
return API_PATH_REGEXES.DEBUG_WS_ECHO.test(pathname);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Accepts the WebSocket upgrade and sets up the echo loop.
|
|
57
|
+
*
|
|
58
|
+
* Client will receive a JSON `{status:"connected"}` on open, and
|
|
59
|
+
* `{echo:<msg>}` for every message it sends.
|
|
60
|
+
*/
|
|
61
|
+
export function handleWsEchoUpgrade(req: IncomingMessage, socket: Socket, head: Buffer): void {
|
|
62
|
+
const lc_fn = `${lc}[handleWsEchoUpgrade]`;
|
|
63
|
+
try {
|
|
64
|
+
if (logalot) { console.log(`${lc_fn} starting...`); }
|
|
65
|
+
|
|
66
|
+
const ok = performHandshake(req, socket);
|
|
67
|
+
if (!ok) { return; }
|
|
68
|
+
|
|
69
|
+
// Send the "connected" greeting
|
|
70
|
+
socket.write(encodeTextFrame(JSON.stringify({ status: 'connected', msg: 'ws-echo ready' })));
|
|
71
|
+
|
|
72
|
+
// Echo loop
|
|
73
|
+
socket.on('data', (data: Buffer) => {
|
|
74
|
+
try {
|
|
75
|
+
const text = decodeTextFrame(data);
|
|
76
|
+
if (text === null) {
|
|
77
|
+
// Close frame or unsupported opcode — send close and end
|
|
78
|
+
socket.write(encodeCloseFrame(1000));
|
|
79
|
+
socket.end();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (logalot) { console.log(`${lc_fn} echo: ${text}`); }
|
|
83
|
+
socket.write(encodeTextFrame(JSON.stringify({ echo: text })));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(`${lc_fn} error in data handler: ${extractErrorMsg(error)}`);
|
|
86
|
+
socket.destroy();
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
socket.on('error', (error) => {
|
|
91
|
+
console.error(`${lc_fn} socket error: ${extractErrorMsg(error)}`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
socket.on('close', () => {
|
|
95
|
+
if (logalot) { console.log(`${lc_fn} socket closed.`); }
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(`${lc_fn} ${extractErrorMsg(error)}`);
|
|
100
|
+
socket.destroy();
|
|
101
|
+
} finally {
|
|
102
|
+
if (logalot) { console.log(`${lc_fn} complete.`); }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module serve-gib/handlers/api/health
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { ServeGibHandlerBase } from '../handler-base.mjs';
|
|
6
|
+
import { RequestContext, ResponseResult, ServeGibHttpMethod } from '../../types.mjs';
|
|
7
|
+
import { API_PATH_REGEXES } from '../../../path-constants.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* GET /api/health
|
|
11
|
+
*/
|
|
12
|
+
export class HealthHandler extends ServeGibHandlerBase {
|
|
13
|
+
protected override method: ServeGibHttpMethod = 'ALL';
|
|
14
|
+
protected override regex = API_PATH_REGEXES.HEALTH;
|
|
15
|
+
|
|
16
|
+
protected async handleRouteImpl(reqCtx: RequestContext): Promise<ResponseResult | undefined> {
|
|
17
|
+
if (reqCtx.method === 'GET') {
|
|
18
|
+
return this.ok({ status: 'ok', timestamp: new Date().toISOString() });
|
|
19
|
+
} else {
|
|
20
|
+
throw new Error(`info.method !== 'GET' in ${HealthHandler.name} (E: bfff3c547b5827ca7845277804057f26)`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { respecfully, iReckon, ifWe, ifWeMight } from '@ibgib/helper-gib/dist/respec-gib/respec-gib.mjs';
|
|
2
|
+
import { ServeGib_V1 } from '../../serve-gib-v1.mjs';
|
|
3
|
+
import { HealthHandler } from './health.handler.mjs';
|
|
4
|
+
import { ErrorHandler } from '../error-handler.mjs';
|
|
5
|
+
|
|
6
|
+
const sir = `[${import.meta.url}]`;
|
|
7
|
+
|
|
8
|
+
await respecfully(sir, 'Health Handler Integration', async () => {
|
|
9
|
+
const testDataDir = './test-data-health';
|
|
10
|
+
const port = 3002;
|
|
11
|
+
|
|
12
|
+
await ifWe(sir, 'get a 200 OK from /api/health', async () => {
|
|
13
|
+
// 1. Setup ServeGib_V1
|
|
14
|
+
const serveGib = new ServeGib_V1({
|
|
15
|
+
port,
|
|
16
|
+
dataDir: testDataDir,
|
|
17
|
+
handlers: [new HealthHandler()],
|
|
18
|
+
errorHandler: new ErrorHandler()
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// 2. Mock GET Request
|
|
22
|
+
const req: any = {
|
|
23
|
+
url: '/api/health',
|
|
24
|
+
method: 'GET',
|
|
25
|
+
headers: {},
|
|
26
|
+
on: (event: string, cb: any) => {
|
|
27
|
+
if (event === 'end') { cb(); }
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let capturedStatus: number = 0;
|
|
32
|
+
let capturedBody: any = null;
|
|
33
|
+
const res: any = {
|
|
34
|
+
writeHead: (status: number) => {
|
|
35
|
+
capturedStatus = status;
|
|
36
|
+
},
|
|
37
|
+
end: (body: string) => {
|
|
38
|
+
capturedBody = JSON.parse(body);
|
|
39
|
+
},
|
|
40
|
+
statusCode: 0
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// 3. Handle
|
|
44
|
+
await serveGib.handleRequest(req, res);
|
|
45
|
+
|
|
46
|
+
// 4. Verify
|
|
47
|
+
iReckon(sir, capturedStatus).asTo('captured status code').isGonnaBe(200);
|
|
48
|
+
iReckon(sir, capturedBody.status).asTo('captured body status').isGonnaBe('ok');
|
|
49
|
+
iReckon(sir, capturedBody.timestamp).asTo('captured body timestamp').isGonnaBe(capturedBody.timestamp);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { IbGibAddr } from "@ibgib/ts-gib/dist/types.mjs";
|
|
2
|
+
|
|
3
|
+
import { DomainInfo } from "../../../types.mjs";
|
|
4
|
+
|
|
5
|
+
export interface IbGibParams {
|
|
6
|
+
domainInfo: DomainInfo;
|
|
7
|
+
ibGibIb: string;
|
|
8
|
+
ibGibGib: string;
|
|
9
|
+
ibGibAddr: IbGibAddr;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// #region IbGibGetQueryParams
|
|
13
|
+
export const DEFAULT_QUERY_PARAMS_GET_IBGIB = {
|
|
14
|
+
/**
|
|
15
|
+
* If true, will get the latest ibgib.
|
|
16
|
+
*/
|
|
17
|
+
getLatest: false, // Default to false for regular ibgibs
|
|
18
|
+
/**
|
|
19
|
+
* If true, will get the ibgib's entire dependency graph
|
|
20
|
+
*/
|
|
21
|
+
getGraph: false, // Default to false for regular ibgibs
|
|
22
|
+
/**
|
|
23
|
+
* If true, returns only the resolved address (addr) rather than the full
|
|
24
|
+
* ibgib or graph body. Useful for a lightweight "tip check" before initiating
|
|
25
|
+
* a full sync: client compares `latestAddr` with its locally known addr.
|
|
26
|
+
*
|
|
27
|
+
* ## notes
|
|
28
|
+
*
|
|
29
|
+
* Typically combined with `getLatest: true` to ask "what is the server's
|
|
30
|
+
* current tip for this timeline?" without paying the cost of fetching the
|
|
31
|
+
* full ibgib body or graph.
|
|
32
|
+
*
|
|
33
|
+
* When `getLatest: false` and `addrOnly: true`, returns the addr as-is
|
|
34
|
+
* (i.e., confirms the given addr exists on the server).
|
|
35
|
+
*/
|
|
36
|
+
addrOnly: false,
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* @see {@link DEFAULT_QUERY_PARAMS_GET_IBGIB} key/values for jsdocs
|
|
40
|
+
*/
|
|
41
|
+
export type IbGibGetQueryParams = Partial<typeof DEFAULT_QUERY_PARAMS_GET_IBGIB>;
|
|
42
|
+
export type KNOWN_QUERY_PARAMS_IBGIB_GET = keyof IbGibGetQueryParams;
|
|
43
|
+
export const KNOWN_QUERY_PARAMS_IBGIB_GET = Object.keys(DEFAULT_QUERY_PARAMS_GET_IBGIB) as KNOWN_QUERY_PARAMS_IBGIB_GET[];
|
|
44
|
+
export function isKnownQueryParamsKey_IbGibGet(key: any): key is KNOWN_QUERY_PARAMS_IBGIB_GET {
|
|
45
|
+
return !!key && typeof key === 'string' ?
|
|
46
|
+
KNOWN_QUERY_PARAMS_IBGIB_GET.includes(key as any) :
|
|
47
|
+
false;
|
|
48
|
+
}
|
|
49
|
+
// #endregion IbGibGetQueryParams
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module serve-gib/handlers/api/ibgib
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { extractErrorMsg } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs';
|
|
6
|
+
import { getIbGibAddr } from '@ibgib/ts-gib/dist/helper.mjs';
|
|
7
|
+
import { IbGib_V1 } from '@ibgib/ts-gib/dist/V1/types.mjs';
|
|
8
|
+
|
|
9
|
+
import { GLOBAL_LOG_A_LOT } from '../../../constants.mjs';
|
|
10
|
+
import { ServeGibHandlerWithMetaspaceBase } from '../../handler-base.mjs';
|
|
11
|
+
import { RequestContext, ResponseResult, ServeGibHttpMethod } from '../../../types.mjs';
|
|
12
|
+
import { validateAddressSegment } from '../../../../path-helper.mjs';
|
|
13
|
+
import { API_PATH_REGEXES } from '../../../../path-constants.mjs';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_QUERY_PARAMS_GET_IBGIB, IbGibGetQueryParams, IbGibParams,
|
|
16
|
+
isKnownQueryParamsKey_IbGibGet, KNOWN_QUERY_PARAMS_IBGIB_GET
|
|
17
|
+
} from './ibgib-handler-types.mjs';
|
|
18
|
+
import { validateAndCoerceBooleanQueryParam } from '../../../serve-gib-helpers.mjs';
|
|
19
|
+
|
|
20
|
+
export interface IbGibGraphResponseBody {
|
|
21
|
+
addr: string;
|
|
22
|
+
count: number;
|
|
23
|
+
graph: Record<string, IbGib_V1>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const logalot = GLOBAL_LOG_A_LOT;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* GET /api/ibgib/:domainAddr/:ibGibAddr
|
|
30
|
+
*/
|
|
31
|
+
export class IbGibHandler extends ServeGibHandlerWithMetaspaceBase<IbGibParams, IbGibGetQueryParams> {
|
|
32
|
+
protected override lc: string = `[${IbGibHandler.name}]`;
|
|
33
|
+
protected override method: ServeGibHttpMethod = 'GET';
|
|
34
|
+
protected override regex = API_PATH_REGEXES.IBGIB_ADDR;
|
|
35
|
+
|
|
36
|
+
protected override async parseParamsImpl(reqCtx: RequestContext<IbGibParams, IbGibGetQueryParams>): Promise<IbGibParams | undefined> {
|
|
37
|
+
const lc = `${this.lc}[${this.parseParamsImpl.name}]`;
|
|
38
|
+
try {
|
|
39
|
+
if (logalot) { console.log(`${lc} starting...`); }
|
|
40
|
+
const match = reqCtx.pathname.match(this.regex);
|
|
41
|
+
if (!match) { throw new Error(`(UNEXPECTED) match falsy for regex? at this point, we have passed canHandleRoute, so this should parse correctly. (E: 8ce0810a3a88f420639bf8286df1d826)`); }
|
|
42
|
+
const domainIb = decodeURIComponent(match[1]);
|
|
43
|
+
const domainGib = decodeURIComponent(match[2]);
|
|
44
|
+
const domainAddr = getIbGibAddr({ ib: domainIb, gib: domainGib });
|
|
45
|
+
const ibGibIb = decodeURIComponent(match[3]);
|
|
46
|
+
const ibGibGib = decodeURIComponent(match[4]);
|
|
47
|
+
const ibGibAddr = getIbGibAddr({ ib: ibGibIb, gib: ibGibGib });
|
|
48
|
+
return {
|
|
49
|
+
ibGibIb, ibGibGib, ibGibAddr,
|
|
50
|
+
domainInfo: this.getDomainInfo({ domainAddr }),
|
|
51
|
+
};
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
54
|
+
throw error;
|
|
55
|
+
} finally {
|
|
56
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
protected override async validateQueryParams({ queryParams }: { queryParams: any; }): Promise<string[]> {
|
|
61
|
+
const lc = `${this.lc}[${this.validateQueryParams.name}]`;
|
|
62
|
+
try {
|
|
63
|
+
if (logalot) { console.log(`${lc} starting...`); }
|
|
64
|
+
|
|
65
|
+
const errors: string[] = [];
|
|
66
|
+
|
|
67
|
+
// strict checking
|
|
68
|
+
const queryParamsKeys = Object.keys(queryParams);
|
|
69
|
+
const invalidKeys: string[] = queryParamsKeys.filter(x => !isKnownQueryParamsKey_IbGibGet(x));
|
|
70
|
+
if (invalidKeys.length > 0) {
|
|
71
|
+
throw new Error(`invalid query params keys: ${invalidKeys.join(', ')}. valid query params keys: ${KNOWN_QUERY_PARAMS_IBGIB_GET.join(', ')} (E: b7e8a9f2c1d34e56b89a01d4f2e7c326)`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Enforce that EVERY key must have a validation function mapping
|
|
75
|
+
type ValidationFn = () => Promise<void>;
|
|
76
|
+
const ValidationFns: Record<KNOWN_QUERY_PARAMS_IBGIB_GET, ValidationFn> = {
|
|
77
|
+
getLatest: async () => validateAndCoerceBooleanQueryParam(queryParams, 'getLatest', errors),
|
|
78
|
+
getGraph: async () => validateAndCoerceBooleanQueryParam(queryParams, 'getGraph', errors),
|
|
79
|
+
addrOnly: async () => validateAndCoerceBooleanQueryParam(queryParams, 'addrOnly', errors),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// execute all validation functions
|
|
83
|
+
for (const fn of Object.values(ValidationFns)) {
|
|
84
|
+
await fn();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return errors;
|
|
88
|
+
|
|
89
|
+
} catch (error) {
|
|
90
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
91
|
+
throw error;
|
|
92
|
+
} finally {
|
|
93
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
protected async handleRouteImpl(reqCtx: RequestContext<IbGibParams, IbGibGetQueryParams>): Promise<ResponseResult | undefined> {
|
|
98
|
+
const lc = `${this.lc}[${this.handleRouteImpl.name}]`;
|
|
99
|
+
try {
|
|
100
|
+
if (logalot) { console.log(`${lc} starting... (I: 03d6a889b1f8af879c0f7158a3b3de26)`); }
|
|
101
|
+
|
|
102
|
+
if (logalot) { console.log(`${lc} starting... (I: 03d6a889b1f8af879c0f7158a3b3de26)`); }
|
|
103
|
+
if (!reqCtx.metaspace) { throw new Error(`(UNEXPECTED) metaspace falsy? we should have initialized it by now since this is a domain-dependent handler (E: 864661c668d2aa1918b7ee677a7d9c26)`); }
|
|
104
|
+
|
|
105
|
+
const { ibGibIb, ibGibGib } = reqCtx.params as IbGibParams;
|
|
106
|
+
if (!ibGibIb || !ibGibGib) { return undefined; }
|
|
107
|
+
|
|
108
|
+
// Reconstruct addr from params
|
|
109
|
+
const rawAddr = `${ibGibIb}^${ibGibGib}`;
|
|
110
|
+
if (!validateAddressSegment(rawAddr)) return this.error(400, 'Invalid address format');
|
|
111
|
+
const addr = rawAddr;
|
|
112
|
+
|
|
113
|
+
const queryParams = {
|
|
114
|
+
...DEFAULT_QUERY_PARAMS_GET_IBGIB,
|
|
115
|
+
...(reqCtx.queryParams ?? {}),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const { getLatest, getGraph, addrOnly } = queryParams;
|
|
119
|
+
|
|
120
|
+
const space = await reqCtx.metaspace.getLocalUserSpace({ lock: false });
|
|
121
|
+
if (!space) { throw new Error(`(UNEXPECTED) couldn't get default local user space? (E: ec6161ea3a08892ad8bfd1b7b0d79826)`); }
|
|
122
|
+
|
|
123
|
+
let addr_latest: string = addr;
|
|
124
|
+
if (getLatest) {
|
|
125
|
+
const latestAddr = await reqCtx.metaspace.getLatestAddr({ addr, space });
|
|
126
|
+
if (latestAddr) { addr_latest = latestAddr; }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Lightweight tip-check: return only the resolved address.
|
|
130
|
+
// Client uses this to decide whether a full sync is necessary
|
|
131
|
+
// without incurring the cost of fetching the full ibgib or graph.
|
|
132
|
+
if (addrOnly && !getGraph) {
|
|
133
|
+
return this.ok({ addr: addr_latest, clientAddr: addr });
|
|
134
|
+
} /* <<<<< returns early */
|
|
135
|
+
|
|
136
|
+
if (getGraph) {
|
|
137
|
+
const resGraph = await reqCtx.metaspace.getDependencyGraph({
|
|
138
|
+
ibGibAddr: addr_latest,
|
|
139
|
+
space,
|
|
140
|
+
live: getLatest,
|
|
141
|
+
});
|
|
142
|
+
const addrs = Object.keys(resGraph);
|
|
143
|
+
if (addrs.length > 0) {
|
|
144
|
+
if (addrOnly) {
|
|
145
|
+
// Return only the address list for delta negotiation (smart diff):
|
|
146
|
+
// client compares these against what it already has to determine
|
|
147
|
+
// the minimal set it needs to fetch.
|
|
148
|
+
return this.ok({ addr: addr_latest, clientAddr: addr, addrs });
|
|
149
|
+
} else {
|
|
150
|
+
const responseBody: IbGibGraphResponseBody = {
|
|
151
|
+
addr: addr_latest,
|
|
152
|
+
count: addrs.length,
|
|
153
|
+
graph: resGraph
|
|
154
|
+
};
|
|
155
|
+
return this.ok(responseBody);
|
|
156
|
+
}
|
|
157
|
+
} else {
|
|
158
|
+
return this.notFound(`Graph not found for ${addr_latest} (E: 89b259f13ae5e4f0d8287f6c0475a826)`);
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
const resGet = await reqCtx.metaspace.get({ space, addr: addr_latest });
|
|
162
|
+
if (resGet.success && resGet.ibGibs?.length === 1) {
|
|
163
|
+
const ibGib = resGet.ibGibs[0];
|
|
164
|
+
return this.ok(ibGib);
|
|
165
|
+
} else {
|
|
166
|
+
return this.notFound(`IbGib not found for ${addr_latest} (E: 017621e0a8086832ff06cfc884b8c426)`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (logalot) { console.error(extractErrorMsg(error)) }
|
|
171
|
+
return this.error(500, extractErrorMsg(error));
|
|
172
|
+
} finally {
|
|
173
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|