toiljs 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.
Files changed (86) hide show
  1. package/.babelrc +13 -0
  2. package/.gitattributes +2 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
  4. package/.github/ISSUE_TEMPLATE/bug_report.yml +90 -0
  5. package/.github/ISSUE_TEMPLATE/config.yml +8 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +43 -0
  8. package/.github/changelog-config.json +45 -0
  9. package/.github/dependabot.yml +27 -0
  10. package/.github/workflows/ci.yml +191 -0
  11. package/.idea/codeStyles/Project.xml +54 -0
  12. package/.idea/codeStyles/codeStyleConfig.xml +5 -0
  13. package/.idea/inspectionProfiles/Project_Default.xml +6 -0
  14. package/.idea/modules.xml +8 -0
  15. package/.idea/prettier.xml +6 -0
  16. package/.idea/toiljs.iml +8 -0
  17. package/.idea/vcs.xml +6 -0
  18. package/.prettierrc.json +12 -0
  19. package/.vscode/settings.json +10 -0
  20. package/CHANGELOG.md +5 -0
  21. package/LICENSE +188 -0
  22. package/README.md +1 -0
  23. package/as-pect.asconfig.json +34 -0
  24. package/as-pect.config.js +65 -0
  25. package/eslint.config.js +48 -0
  26. package/examples/basic/.prettierrc +1 -0
  27. package/examples/basic/client/404.tsx +14 -0
  28. package/examples/basic/client/layout.tsx +14 -0
  29. package/examples/basic/client/routes/about.tsx +13 -0
  30. package/examples/basic/client/routes/blog/[id].tsx +14 -0
  31. package/examples/basic/client/routes/docs/[...slug].tsx +15 -0
  32. package/examples/basic/client/routes/index.tsx +13 -0
  33. package/examples/basic/client/routes/io.tsx +28 -0
  34. package/examples/basic/eslint.config.js +3 -0
  35. package/examples/basic/package.json +24 -0
  36. package/examples/basic/toil.config.ts +7 -0
  37. package/examples/basic/tsconfig.json +4 -0
  38. package/package.json +141 -0
  39. package/presets/eslint.js +77 -0
  40. package/presets/no-uint8array-tostring.js +201 -0
  41. package/presets/prettier.json +11 -0
  42. package/presets/tsconfig.json +37 -0
  43. package/src/backend/index.ts +167 -0
  44. package/src/cli/create.ts +272 -0
  45. package/src/cli/index.ts +161 -0
  46. package/src/cli/ui.ts +79 -0
  47. package/src/client/channel.ts +146 -0
  48. package/src/client/index.ts +12 -0
  49. package/src/client/match.ts +39 -0
  50. package/src/client/runtime.tsx +190 -0
  51. package/src/compiler/config.ts +115 -0
  52. package/src/compiler/generate.ts +91 -0
  53. package/src/compiler/index.ts +49 -0
  54. package/src/compiler/plugin.ts +26 -0
  55. package/src/compiler/routes.ts +70 -0
  56. package/src/compiler/vite.ts +90 -0
  57. package/src/io/BinaryReader.ts +344 -0
  58. package/src/io/BinaryWriter.ts +385 -0
  59. package/src/io/FastMap.ts +127 -0
  60. package/src/io/FastSet.ts +96 -0
  61. package/src/io/index.ts +11 -0
  62. package/src/io/lengths.ts +14 -0
  63. package/src/io/types.ts +18 -0
  64. package/src/logger/index.ts +22 -0
  65. package/src/server/index.ts +11 -0
  66. package/src/server/main.ts +13 -0
  67. package/src/shared/index.ts +10 -0
  68. package/std/client/index.d.ts +15 -0
  69. package/std/client/package.json +3 -0
  70. package/test/channel.test.ts +21 -0
  71. package/test/io.test.ts +85 -0
  72. package/test/placeholder.test.ts +9 -0
  73. package/test/routes.test.ts +42 -0
  74. package/tests/server/example.spec.ts +7 -0
  75. package/toilconfig.json +30 -0
  76. package/tsconfig.backend.json +13 -0
  77. package/tsconfig.base.json +35 -0
  78. package/tsconfig.cli.json +13 -0
  79. package/tsconfig.client.json +14 -0
  80. package/tsconfig.compiler.json +13 -0
  81. package/tsconfig.io.json +12 -0
  82. package/tsconfig.json +22 -0
  83. package/tsconfig.logger.json +12 -0
  84. package/tsconfig.server.json +10 -0
  85. package/tsconfig.shared.json +12 -0
  86. package/vitest.config.ts +22 -0
@@ -0,0 +1,77 @@
1
+ // toiljs shared ESLint flat config — opinionated, strict, React-aware.
2
+ // Use it from your project's eslint.config.js:
3
+ // import toiljs from 'toiljs/eslint';
4
+ // export default toiljs;
5
+ import eslintReact from '@eslint-react/eslint-plugin';
6
+ import eslint from '@eslint/js';
7
+ import reactHooks from 'eslint-plugin-react-hooks';
8
+ import reactRefresh from 'eslint-plugin-react-refresh';
9
+ import tseslint from 'typescript-eslint';
10
+
11
+ import noUint8ArrayToString from './no-uint8array-tostring.js';
12
+
13
+ export default tseslint.config(
14
+ // Build output + the toil-generated working dir are not hand-written source.
15
+ { ignores: ['dist', 'build', '.toil', 'node_modules'] },
16
+ {
17
+ extends: [
18
+ eslint.configs.recommended,
19
+ ...tseslint.configs.recommended,
20
+ ...tseslint.configs.strictTypeChecked,
21
+ ],
22
+ files: ['**/*.{ts,tsx}'],
23
+ languageOptions: {
24
+ ecmaVersion: 2023,
25
+ // No tsconfigRootDir: projectService discovers the consumer's tsconfig from cwd.
26
+ parserOptions: {
27
+ projectService: true,
28
+ },
29
+ },
30
+ plugins: {
31
+ 'react-hooks': reactHooks,
32
+ 'react-refresh': reactRefresh,
33
+ '@eslint-react': eslintReact,
34
+ custom: noUint8ArrayToString,
35
+ },
36
+ rules: {
37
+ ...reactHooks.configs.recommended.rules,
38
+ 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
39
+ 'no-undef': 'off',
40
+ '@typescript-eslint/no-unused-vars': 'off',
41
+ 'no-empty': 'off',
42
+ '@typescript-eslint/restrict-template-expressions': 'off',
43
+ '@typescript-eslint/only-throw-error': 'off',
44
+ '@typescript-eslint/no-unnecessary-condition': 'off',
45
+ '@typescript-eslint/unbound-method': 'warn',
46
+ '@typescript-eslint/no-confusing-void-expression': 'off',
47
+ '@typescript-eslint/no-extraneous-class': 'off',
48
+ 'no-async-promise-executor': 'off',
49
+ '@typescript-eslint/no-misused-promises': 'off',
50
+ '@typescript-eslint/no-unnecessary-type-parameters': 'off',
51
+ '@typescript-eslint/no-duplicate-enum-values': 'off',
52
+ 'prefer-spread': 'off',
53
+ '@typescript-eslint/no-empty-object-type': 'off',
54
+ '@typescript-eslint/no-floating-promises': 'off',
55
+ '@typescript-eslint/ban-ts-comment': 'off',
56
+ 'no-constant-binary-expression': 'off',
57
+ 'no-useless-assignment': 'off',
58
+ '@typescript-eslint/no-unsafe-assignment': 'off',
59
+ '@typescript-eslint/no-unsafe-call': 'off',
60
+ '@typescript-eslint/no-unsafe-member-access': 'off',
61
+ '@typescript-eslint/no-unsafe-argument': 'off',
62
+ '@typescript-eslint/no-unnecessary-type-conversion': 'warn',
63
+ 'react-hooks/set-state-in-effect': 'warn',
64
+ 'custom/no-uint8array-tostring': 'error',
65
+ 'padding-line-between-statements': [
66
+ 'error',
67
+ { blankLine: 'always', prev: 'block-like', next: '*' },
68
+ ],
69
+ '@typescript-eslint/no-deprecated': 'off',
70
+ '@typescript-eslint/no-unnecessary-type-arguments': 'off',
71
+ },
72
+ },
73
+ {
74
+ files: ['**/*.js'],
75
+ ...tseslint.configs.disableTypeChecked,
76
+ },
77
+ );
@@ -0,0 +1,201 @@
1
+ // ESLint rule: disallow `.toString()` on Uint8Array (and branded byte types), which returns
2
+ // comma-separated decimals instead of hex. Ported to plain JS from the typed source so the
3
+ // shareable preset can load it at runtime without a TypeScript loader.
4
+ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils';
5
+ import { SyntaxKind } from 'typescript';
6
+
7
+ function isUint8ArrayType(type, checker) {
8
+ const symbol = type.getSymbol();
9
+ if (symbol?.getName() === 'Uint8Array') {
10
+ return true;
11
+ }
12
+
13
+ const baseTypes = type.getBaseTypes?.();
14
+ if (baseTypes) {
15
+ for (const baseType of baseTypes) {
16
+ if (isUint8ArrayType(baseType, checker)) {
17
+ return true;
18
+ }
19
+ }
20
+ }
21
+
22
+ if (type.isIntersection()) {
23
+ for (const subType of type.types) {
24
+ if (isUint8ArrayType(subType, checker)) {
25
+ return true;
26
+ }
27
+ }
28
+ }
29
+
30
+ if (type.isUnion()) {
31
+ return (
32
+ type.types.length > 0 &&
33
+ type.types.every((subType) => isUint8ArrayType(subType, checker))
34
+ );
35
+ }
36
+
37
+ const constraint = type.getConstraint?.();
38
+ if (constraint && isUint8ArrayType(constraint, checker)) {
39
+ return true;
40
+ }
41
+
42
+ return false;
43
+ }
44
+
45
+ /**
46
+ * Types whose toString() is the dangerous default behavior we want to catch.
47
+ * If toString is declared on any type NOT in this set, it has been
48
+ * intentionally overridden and we should leave it alone.
49
+ */
50
+ const DEFAULT_TOSTRING_OWNERS = new Set([
51
+ 'Object',
52
+ 'Uint8Array',
53
+ 'Int8Array',
54
+ 'Uint8ClampedArray',
55
+ 'Int16Array',
56
+ 'Uint16Array',
57
+ 'Int32Array',
58
+ 'Uint32Array',
59
+ 'Float32Array',
60
+ 'Float64Array',
61
+ 'BigInt64Array',
62
+ 'BigUint64Array',
63
+ ]);
64
+
65
+ /**
66
+ * Given a declaration node, walk up the AST parents to find the enclosing
67
+ * class or interface name. More reliable than checker.getTypeAtLocation(decl.parent),
68
+ * which can return odd results for .d.ts files.
69
+ */
70
+ function getEnclosingClassName(decl) {
71
+ let current = decl.parent;
72
+ while (current) {
73
+ if (
74
+ current.kind === SyntaxKind.ClassDeclaration ||
75
+ current.kind === SyntaxKind.ClassExpression ||
76
+ current.kind === SyntaxKind.InterfaceDeclaration
77
+ ) {
78
+ if (current.name) {
79
+ return current.name.text;
80
+ }
81
+ }
82
+
83
+ current = current.parent;
84
+ }
85
+
86
+ return undefined;
87
+ }
88
+
89
+ /**
90
+ * Checks whether the resolved toString() on this type is a custom override
91
+ * rather than the default Uint8Array/Object prototype version.
92
+ */
93
+ function hasCustomToString(type, checker) {
94
+ const toStringSymbol = type.getProperty('toString');
95
+ if (!toStringSymbol) {
96
+ return false;
97
+ }
98
+
99
+ const declarations = toStringSymbol.getDeclarations();
100
+ if (!declarations || declarations.length === 0) {
101
+ return false;
102
+ }
103
+
104
+ for (const decl of declarations) {
105
+ const ownerName = getEnclosingClassName(decl);
106
+ if (ownerName && !DEFAULT_TOSTRING_OWNERS.has(ownerName)) {
107
+ return true;
108
+ }
109
+ }
110
+
111
+ // Fallback: also check the apparent type, which can differ for branded
112
+ // types or type aliases that wrap a class.
113
+ const apparentType = checker.getApparentType(type);
114
+ if (apparentType !== type) {
115
+ const apparentToString = apparentType.getProperty('toString');
116
+ if (apparentToString && apparentToString !== toStringSymbol) {
117
+ const apparentDecls = apparentToString.getDeclarations();
118
+ if (apparentDecls) {
119
+ for (const decl of apparentDecls) {
120
+ const ownerName = getEnclosingClassName(decl);
121
+ if (ownerName && !DEFAULT_TOSTRING_OWNERS.has(ownerName)) {
122
+ return true;
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ return false;
130
+ }
131
+
132
+ const createRule = ESLintUtils.RuleCreator(
133
+ (name) => `https://github.com/dacely-cloud/toiljs/tree/main/presets#${name}`,
134
+ );
135
+
136
+ const rule = createRule({
137
+ name: 'no-uint8array-tostring',
138
+ meta: {
139
+ type: 'problem',
140
+ docs: {
141
+ description:
142
+ 'Disallow .toString() on Uint8Array and branded types (Script, Bytes32, etc.) which produces comma-separated decimals instead of hex',
143
+ },
144
+ messages: {
145
+ noUint8ArrayToString:
146
+ '{{typeName}}.toString() returns comma-separated decimals (e.g. "0,32,70,107"), not a hex string. ' +
147
+ 'Use Buffer.from(arr).toString("hex") or toHex() instead.',
148
+ },
149
+ schema: [],
150
+ },
151
+ defaultOptions: [],
152
+ create(context) {
153
+ const services = ESLintUtils.getParserServices(context);
154
+ const checker = services.program.getTypeChecker();
155
+
156
+ return {
157
+ CallExpression(node) {
158
+ if (
159
+ node.callee.type !== AST_NODE_TYPES.MemberExpression ||
160
+ node.callee.property.type !== AST_NODE_TYPES.Identifier ||
161
+ node.callee.property.name !== 'toString' ||
162
+ node.arguments.length > 0
163
+ ) {
164
+ return;
165
+ }
166
+
167
+ const objectNode = node.callee.object;
168
+ const tsNode = services.esTreeNodeToTSNodeMap.get(objectNode);
169
+ const type = checker.getTypeAtLocation(tsNode);
170
+
171
+ if (!isUint8ArrayType(type, checker)) {
172
+ return;
173
+ }
174
+
175
+ if (hasCustomToString(type, checker)) {
176
+ return;
177
+ }
178
+
179
+ const typeName = checker.typeToString(type);
180
+ context.report({
181
+ node,
182
+ messageId: 'noUint8ArrayToString',
183
+ data: { typeName },
184
+ });
185
+ },
186
+ };
187
+ },
188
+ });
189
+
190
+ const plugin = {
191
+ meta: {
192
+ name: 'eslint-plugin-no-uint8array-tostring',
193
+ version: '1.0.0',
194
+ },
195
+ rules: {
196
+ 'no-uint8array-tostring': rule,
197
+ },
198
+ };
199
+
200
+ export default plugin;
201
+ export { rule };
@@ -0,0 +1,11 @@
1
+ {
2
+ "printWidth": 120,
3
+ "tabWidth": 4,
4
+ "useTabs": false,
5
+ "semi": true,
6
+ "singleQuote": true,
7
+ "trailingComma": "none",
8
+ "bracketSpacing": true,
9
+ "bracketSameLine": true,
10
+ "arrowParens": "always"
11
+ }
@@ -0,0 +1,37 @@
1
+ // toiljs shared client tsconfig — opinionated, strict.
2
+ // Extend it from your project: { "extends": "toiljs/tsconfig", "include": ["client", ".toil"] }
3
+ {
4
+ "compilerOptions": {
5
+ "strict": true,
6
+ "noImplicitAny": true,
7
+ "strictNullChecks": true,
8
+ "strictFunctionTypes": true,
9
+ "strictBindCallApply": true,
10
+ "strictPropertyInitialization": true,
11
+ "noImplicitThis": true,
12
+ "useUnknownInCatchVariables": true,
13
+ "alwaysStrict": true,
14
+ "noUnusedLocals": true,
15
+ "noUnusedParameters": true,
16
+ "exactOptionalPropertyTypes": false,
17
+ "noImplicitReturns": false,
18
+ "noFallthroughCasesInSwitch": true,
19
+ "noUncheckedIndexedAccess": false,
20
+ "noImplicitOverride": true,
21
+ "noPropertyAccessFromIndexSignature": false,
22
+ "module": "ES2020",
23
+ "target": "ES2020",
24
+ "moduleResolution": "Bundler",
25
+ "jsx": "react-jsx",
26
+ "lib": ["ESNext", "DOM", "DOM.Iterable", "DOM.AsyncIterable"],
27
+ "isolatedModules": true,
28
+ "verbatimModuleSyntax": false,
29
+ "allowImportingTsExtensions": true,
30
+ "esModuleInterop": true,
31
+ "resolveJsonModule": true,
32
+ "forceConsistentCasingInFileNames": true,
33
+ "skipLibCheck": true,
34
+ "moduleDetection": "force",
35
+ "noEmit": true
36
+ }
37
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * toiljs backend — the self-host / dev server, built on @btc-vision/hyper-express (uWebSockets.js)
3
+ * for very high throughput. It serves the built client (static assets + SPA fallback) and exposes
4
+ * a WebSocket channel for realtime / live updates.
5
+ *
6
+ * This is the Node "server" that hosts the app on a local machine; it is distinct from the
7
+ * AssemblyScript WASM target in `src/server`.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ import {
13
+ Server,
14
+ type MiddlewareNext,
15
+ type Request,
16
+ type Response,
17
+ type Websocket,
18
+ } from '@btc-vision/hyper-express';
19
+
20
+ const DEFAULT_MAX_BODY_LENGTH = 1024 * 1024 * 8; // 8 MB
21
+ const MAX_BODY_BUFFER = 1024 * 32; // 32 KB
22
+ const HTTP_IDLE_TIMEOUT = 60; // seconds
23
+ const HTTP_RESPONSE_TIMEOUT = 120; // seconds
24
+
25
+ const WS_MAX_PAYLOAD_LENGTH = 1024 * 1024; // 1 MB
26
+ const WS_IDLE_TIMEOUT = 120; // seconds
27
+ const WS_MAX_BACKPRESSURE = 1024 * 1024 * 2; // 2 MB
28
+
29
+ const CORS_METHODS = 'GET, POST, OPTIONS, PUT, PATCH, DELETE';
30
+ const CORS_HEADERS = 'X-Requested-With, content-type';
31
+
32
+ /** Options for {@link startBackend}. */
33
+ export interface BackendOptions {
34
+ /** Directory to serve (the built client `outDir`, e.g. `dist`). */
35
+ readonly root: string;
36
+ /** Listening port. Default `3000`. */
37
+ readonly port?: number;
38
+ /** Bind host. Default `0.0.0.0`. */
39
+ readonly host?: string;
40
+ /** WebSocket channel path. Default `/_toil`. */
41
+ readonly wsPath?: string;
42
+ /** Send permissive CORS headers + handle preflight. Default `true`. */
43
+ readonly cors?: boolean;
44
+ /** Max request body length in bytes. Default 8 MB. */
45
+ readonly maxBodyLength?: number;
46
+ }
47
+
48
+ /** A running backend instance. */
49
+ export interface RunningBackend {
50
+ readonly port: number;
51
+ readonly host: string;
52
+ readonly wsPath: string;
53
+ /** Sends a message to every connected WebSocket client. */
54
+ broadcast(message: string): void;
55
+ /** Number of currently-connected WebSocket clients. */
56
+ clientCount(): number;
57
+ /** Gracefully shuts the server down. */
58
+ close(): Promise<void>;
59
+ }
60
+
61
+ /** Resolves a request path to a file inside `root`, guarding against path traversal. */
62
+ function resolveStaticFile(root: string, requestPath: string): string | null {
63
+ const decoded = decodeURIComponent(requestPath);
64
+ const resolved = path.join(root, decoded);
65
+ if (resolved !== root && !resolved.startsWith(root + path.sep)) return null; // traversal
66
+ if (decoded === '/' || decoded === '') return null; // defer to SPA fallback
67
+ if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved;
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * Starts the hyper-express server serving `root` with an SPA fallback to `index.html`,
73
+ * plus a WebSocket channel at `wsPath`. Resolves once the server is listening.
74
+ */
75
+ export async function startBackend(options: BackendOptions): Promise<RunningBackend> {
76
+ const port = options.port ?? 3000;
77
+ const host = options.host ?? '0.0.0.0';
78
+ const wsPath = options.wsPath ?? '/_toil';
79
+ const cors = options.cors ?? true;
80
+ const root = path.resolve(options.root);
81
+ const indexHtml = path.join(root, 'index.html');
82
+
83
+ const app = new Server({
84
+ max_body_length: options.maxBodyLength ?? DEFAULT_MAX_BODY_LENGTH,
85
+ max_body_buffer: MAX_BODY_BUFFER,
86
+ fast_abort: true,
87
+ idle_timeout: HTTP_IDLE_TIMEOUT,
88
+ response_timeout: HTTP_RESPONSE_TIMEOUT,
89
+ });
90
+
91
+ const clients = new Set<Websocket>();
92
+
93
+ // Never let an unhandled error leak a stack trace; write a safe response if the socket is live.
94
+ app.set_error_handler((_request: Request, response: Response, _error: Error) => {
95
+ if (response.completed) return;
96
+ response.atomic(() => {
97
+ response.status(500).json({ error: 'Internal server error.' });
98
+ });
99
+ });
100
+
101
+ // CORS + hide the underlying server header.
102
+ if (cors) {
103
+ app.use((request: Request, response: Response, next: MiddlewareNext) => {
104
+ if (request.method !== 'OPTIONS') {
105
+ response.setHeader('Access-Control-Allow-Origin', '*');
106
+ response.setHeader('Access-Control-Allow-Methods', CORS_METHODS);
107
+ response.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
108
+ }
109
+ response.removeHeader('uWebSockets');
110
+ next();
111
+ });
112
+ app.options('/*', (_request: Request, response: Response) => {
113
+ response.setHeader('Access-Control-Allow-Origin', '*');
114
+ response.setHeader('Access-Control-Allow-Methods', CORS_METHODS);
115
+ response.setHeader('Access-Control-Allow-Headers', CORS_HEADERS);
116
+ response.setHeader('Access-Control-Max-Age', '86400');
117
+ response.status(204).send();
118
+ });
119
+ }
120
+
121
+ // Realtime WebSocket channel: each client joins, messages are broadcast to all peers.
122
+ app.ws(
123
+ wsPath,
124
+ {
125
+ message_type: 'String',
126
+ max_payload_length: WS_MAX_PAYLOAD_LENGTH,
127
+ idle_timeout: WS_IDLE_TIMEOUT,
128
+ max_backpressure: WS_MAX_BACKPRESSURE,
129
+ },
130
+ (ws) => {
131
+ clients.add(ws);
132
+ ws.send(JSON.stringify({ type: 'connected', clients: clients.size }));
133
+ ws.on('message', (message: string) => {
134
+ for (const client of clients) client.send(message);
135
+ });
136
+ // Backpressure on this socket has drained — broadcast channel has nothing to flush.
137
+ ws.on('drain', () => {
138
+ /* no-op */
139
+ });
140
+ ws.on('close', () => {
141
+ clients.delete(ws);
142
+ });
143
+ },
144
+ );
145
+
146
+ // Static client with SPA fallback — anything that isn't a real file serves index.html.
147
+ app.get('/*', (request: Request, response: Response) => {
148
+ if (response.completed) return;
149
+ const file = resolveStaticFile(root, request.path);
150
+ response.sendFile(file ?? indexHtml);
151
+ });
152
+
153
+ await app.listen(port, host);
154
+
155
+ return {
156
+ port,
157
+ host,
158
+ wsPath,
159
+ broadcast: (message: string): void => {
160
+ for (const client of clients) client.send(message);
161
+ },
162
+ clientCount: (): number => clients.size,
163
+ close: async (): Promise<void> => {
164
+ await app.shutdown();
165
+ },
166
+ };
167
+ }