bonescript-compiler 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/dist/algorithm_catalog.d.ts +32 -0
  3. package/dist/algorithm_catalog.js +323 -0
  4. package/dist/algorithm_catalog.js.map +1 -0
  5. package/dist/ast.d.ts +244 -0
  6. package/dist/ast.js +8 -0
  7. package/dist/ast.js.map +1 -0
  8. package/dist/cli.d.ts +4 -0
  9. package/dist/cli.js +605 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/emit_batch.d.ts +7 -0
  12. package/dist/emit_batch.js +133 -0
  13. package/dist/emit_batch.js.map +1 -0
  14. package/dist/emit_capability.d.ts +7 -0
  15. package/dist/emit_capability.js +376 -0
  16. package/dist/emit_capability.js.map +1 -0
  17. package/dist/emit_composition.d.ts +22 -0
  18. package/dist/emit_composition.js +184 -0
  19. package/dist/emit_composition.js.map +1 -0
  20. package/dist/emit_deploy.d.ts +9 -0
  21. package/dist/emit_deploy.js +191 -0
  22. package/dist/emit_deploy.js.map +1 -0
  23. package/dist/emit_events.d.ts +14 -0
  24. package/dist/emit_events.js +305 -0
  25. package/dist/emit_events.js.map +1 -0
  26. package/dist/emit_extras.d.ts +12 -0
  27. package/dist/emit_extras.js +234 -0
  28. package/dist/emit_extras.js.map +1 -0
  29. package/dist/emit_full.d.ts +13 -0
  30. package/dist/emit_full.js +273 -0
  31. package/dist/emit_full.js.map +1 -0
  32. package/dist/emit_maintenance.d.ts +16 -0
  33. package/dist/emit_maintenance.js +442 -0
  34. package/dist/emit_maintenance.js.map +1 -0
  35. package/dist/emit_runtime.d.ts +13 -0
  36. package/dist/emit_runtime.js +691 -0
  37. package/dist/emit_runtime.js.map +1 -0
  38. package/dist/emit_sourcemap.d.ts +29 -0
  39. package/dist/emit_sourcemap.js +123 -0
  40. package/dist/emit_sourcemap.js.map +1 -0
  41. package/dist/emit_tests.d.ts +15 -0
  42. package/dist/emit_tests.js +185 -0
  43. package/dist/emit_tests.js.map +1 -0
  44. package/dist/emit_websocket.d.ts +6 -0
  45. package/dist/emit_websocket.js +223 -0
  46. package/dist/emit_websocket.js.map +1 -0
  47. package/dist/emitter.d.ts +25 -0
  48. package/dist/emitter.js +511 -0
  49. package/dist/emitter.js.map +1 -0
  50. package/dist/extension_manager.d.ts +38 -0
  51. package/dist/extension_manager.js +170 -0
  52. package/dist/extension_manager.js.map +1 -0
  53. package/dist/formatter.d.ts +34 -0
  54. package/dist/formatter.js +317 -0
  55. package/dist/formatter.js.map +1 -0
  56. package/dist/index.d.ts +42 -0
  57. package/dist/index.js +113 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/ir.d.ts +168 -0
  60. package/dist/ir.js +10 -0
  61. package/dist/ir.js.map +1 -0
  62. package/dist/lexer.d.ts +195 -0
  63. package/dist/lexer.js +619 -0
  64. package/dist/lexer.js.map +1 -0
  65. package/dist/lowering.d.ts +25 -0
  66. package/dist/lowering.js +500 -0
  67. package/dist/lowering.js.map +1 -0
  68. package/dist/module_loader.d.ts +25 -0
  69. package/dist/module_loader.js +126 -0
  70. package/dist/module_loader.js.map +1 -0
  71. package/dist/optimizer.d.ts +26 -0
  72. package/dist/optimizer.js +158 -0
  73. package/dist/optimizer.js.map +1 -0
  74. package/dist/parse_decls.d.ts +13 -0
  75. package/dist/parse_decls.js +442 -0
  76. package/dist/parse_decls.js.map +1 -0
  77. package/dist/parse_decls2.d.ts +13 -0
  78. package/dist/parse_decls2.js +295 -0
  79. package/dist/parse_decls2.js.map +1 -0
  80. package/dist/parse_expr.d.ts +7 -0
  81. package/dist/parse_expr.js +197 -0
  82. package/dist/parse_expr.js.map +1 -0
  83. package/dist/parse_types.d.ts +6 -0
  84. package/dist/parse_types.js +51 -0
  85. package/dist/parse_types.js.map +1 -0
  86. package/dist/parser.d.ts +10 -0
  87. package/dist/parser.js +62 -0
  88. package/dist/parser.js.map +1 -0
  89. package/dist/parser_base.d.ts +19 -0
  90. package/dist/parser_base.js +50 -0
  91. package/dist/parser_base.js.map +1 -0
  92. package/dist/parser_recovery.d.ts +26 -0
  93. package/dist/parser_recovery.js +140 -0
  94. package/dist/parser_recovery.js.map +1 -0
  95. package/dist/scaffold.d.ts +13 -0
  96. package/dist/scaffold.js +376 -0
  97. package/dist/scaffold.js.map +1 -0
  98. package/dist/solver.d.ts +26 -0
  99. package/dist/solver.js +281 -0
  100. package/dist/solver.js.map +1 -0
  101. package/dist/typechecker.d.ts +52 -0
  102. package/dist/typechecker.js +534 -0
  103. package/dist/typechecker.js.map +1 -0
  104. package/dist/types.d.ts +38 -0
  105. package/dist/types.js +85 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/verifier.d.ts +46 -0
  108. package/dist/verifier.js +307 -0
  109. package/dist/verifier.js.map +1 -0
  110. package/package.json +52 -0
  111. package/src/algorithm_catalog.ts +345 -0
  112. package/src/ast.ts +334 -0
  113. package/src/cli.ts +624 -0
  114. package/src/emit_batch.ts +140 -0
  115. package/src/emit_capability.ts +436 -0
  116. package/src/emit_composition.ts +196 -0
  117. package/src/emit_deploy.ts +190 -0
  118. package/src/emit_events.ts +307 -0
  119. package/src/emit_extras.ts +240 -0
  120. package/src/emit_full.ts +309 -0
  121. package/src/emit_maintenance.ts +459 -0
  122. package/src/emit_runtime.ts +731 -0
  123. package/src/emit_sourcemap.ts +140 -0
  124. package/src/emit_tests.ts +205 -0
  125. package/src/emit_websocket.ts +229 -0
  126. package/src/emitter.ts +566 -0
  127. package/src/extension_manager.ts +187 -0
  128. package/src/formatter.ts +297 -0
  129. package/src/index.ts +88 -0
  130. package/src/ir.ts +215 -0
  131. package/src/lexer.ts +630 -0
  132. package/src/lowering.ts +556 -0
  133. package/src/module_loader.ts +114 -0
  134. package/src/optimizer.ts +196 -0
  135. package/src/parse_decls.ts +409 -0
  136. package/src/parse_decls2.ts +244 -0
  137. package/src/parse_expr.ts +197 -0
  138. package/src/parse_types.ts +54 -0
  139. package/src/parser.ts +64 -0
  140. package/src/parser_base.ts +57 -0
  141. package/src/parser_recovery.ts +153 -0
  142. package/src/scaffold.ts +375 -0
  143. package/src/solver.ts +330 -0
  144. package/src/typechecker.ts +591 -0
  145. package/src/types.ts +122 -0
  146. package/src/verifier.ts +348 -0
@@ -0,0 +1,140 @@
1
+ /**
2
+ * BoneScript Source Map Emitter
3
+ * Generates a .bone.map file that maps generated TS line numbers back to .bone source.
4
+ * Also generates a debug wrapper that intercepts runtime errors and annotates them.
5
+ */
6
+
7
+ import * as IR from "./ir";
8
+
9
+ export interface BoneSourceMap {
10
+ version: 1;
11
+ source_hash: string;
12
+ source_file: string;
13
+ mappings: BoneMapping[];
14
+ }
15
+
16
+ export interface BoneMapping {
17
+ generated_file: string;
18
+ generated_line: number;
19
+ bone_line: number;
20
+ bone_column: number;
21
+ description: string;
22
+ }
23
+
24
+ /**
25
+ * Emit a source map JSON file for the compiled system.
26
+ * Maps IR nodes to their approximate .bone source locations.
27
+ */
28
+ export function emitSourceMapFile(system: IR.IRSystem, sourceFile: string): string {
29
+ const mappings: BoneMapping[] = [];
30
+
31
+ // Map each module to a rough line estimate
32
+ // (Real line numbers would require storing loc in IR — this is a best-effort approximation)
33
+ let estimatedLine = 1;
34
+ for (const mod of system.modules) {
35
+ mappings.push({
36
+ generated_file: `src/routes/${mod.name.replace(/Service$/, "").toLowerCase()}.ts`,
37
+ generated_line: 1,
38
+ bone_line: estimatedLine,
39
+ bone_column: 1,
40
+ description: `Module: ${mod.name}`,
41
+ });
42
+ estimatedLine += 10;
43
+
44
+ for (const iface of mod.interfaces) {
45
+ for (const method of iface.methods) {
46
+ mappings.push({
47
+ generated_file: `src/routes/${mod.name.replace(/Service$/, "").toLowerCase()}.ts`,
48
+ generated_line: estimatedLine,
49
+ bone_line: estimatedLine,
50
+ bone_column: 3,
51
+ description: `Capability: ${method.name}`,
52
+ });
53
+ estimatedLine += 5;
54
+ }
55
+ }
56
+ }
57
+
58
+ const map: BoneSourceMap = {
59
+ version: 1,
60
+ source_hash: system.source_hash,
61
+ source_file: sourceFile,
62
+ mappings,
63
+ };
64
+
65
+ return JSON.stringify(map, null, 2);
66
+ }
67
+
68
+ /**
69
+ * Emit a debug error handler that annotates runtime errors with .bone context.
70
+ * Reads the source map at runtime to provide dual-stack traces.
71
+ */
72
+ export function emitDebugHandler(system: IR.IRSystem): string {
73
+ return `// Generated by BoneScript compiler. DO NOT EDIT.
74
+ // Runtime error handler with .bone source annotation.
75
+
76
+ import * as fs from "fs";
77
+ import * as path from "path";
78
+
79
+ interface BoneMapping {
80
+ generated_file: string;
81
+ generated_line: number;
82
+ bone_line: number;
83
+ bone_column: number;
84
+ description: string;
85
+ }
86
+
87
+ interface BoneSourceMap {
88
+ version: number;
89
+ source_hash: string;
90
+ source_file: string;
91
+ mappings: BoneMapping[];
92
+ }
93
+
94
+ let sourceMap: BoneSourceMap | null = null;
95
+
96
+ function loadSourceMap(): BoneSourceMap | null {
97
+ if (sourceMap) return sourceMap;
98
+ const mapPath = path.join(__dirname, "..", "${system.name}.bone.map");
99
+ if (!fs.existsSync(mapPath)) return null;
100
+ try {
101
+ sourceMap = JSON.parse(fs.readFileSync(mapPath, "utf-8"));
102
+ return sourceMap;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }
107
+
108
+ export function annotateBoneError(err: Error, generatedFile: string): string {
109
+ const map = loadSourceMap();
110
+ if (!map) return err.stack || err.message;
111
+
112
+ // Extract line number from stack trace
113
+ const lineMatch = err.stack?.match(new RegExp(generatedFile.replace(/[/\\\\]/g, "[/\\\\\\\\]") + ":(\\\\d+)"));
114
+ if (!lineMatch) return err.stack || err.message;
115
+
116
+ const generatedLine = parseInt(lineMatch[1], 10);
117
+ const mapping = map.mappings
118
+ .filter(m => m.generated_file.includes(generatedFile) && m.generated_line <= generatedLine)
119
+ .sort((a, b) => b.generated_line - a.generated_line)[0];
120
+
121
+ if (!mapping) return err.stack || err.message;
122
+
123
+ return [
124
+ \`BoneScript source: \${map.source_file}:\${mapping.bone_line}:\${mapping.bone_column}\`,
125
+ \` Context: \${mapping.description}\`,
126
+ \`Generated TS: \${generatedFile}:\${generatedLine}\`,
127
+ \`\`,
128
+ err.stack || err.message,
129
+ ].join("\\n");
130
+ }
131
+
132
+ // Global error handler — annotates unhandled errors with .bone context
133
+ process.on("uncaughtException", (err: Error) => {
134
+ const annotated = annotateBoneError(err, err.stack?.split("\\n")[1]?.match(/\\((.+):\\d+:\\d+\\)/)?.[1] || "");
135
+ console.error("[BoneScript] Unhandled error:");
136
+ console.error(annotated);
137
+ // process.exit(1); // disabled - errors should not crash the server
138
+ });
139
+ `;
140
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * BoneScript Test Emitter
3
+ * Generates regression tests derived from capability declarations.
4
+ * Implements spec/10 §7 (Regression Tests).
5
+ *
6
+ * For each capability, generates:
7
+ * - Happy path test (valid preconditions → effects applied)
8
+ * - Precondition failure test (invalid state → 422)
9
+ * - Idempotency test (if idempotent: true)
10
+ * For each state machine:
11
+ * - Valid transition tests
12
+ * - Invalid transition rejection tests
13
+ */
14
+
15
+ import * as IR from "./ir";
16
+
17
+ function toSnakeCase(s: string): string {
18
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
19
+ }
20
+
21
+ export function emitTestSuite(system: IR.IRSystem): string {
22
+ const lines: string[] = [];
23
+
24
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
25
+ lines.push(`// Regression tests derived from capability declarations.`);
26
+ lines.push(`// Run: npx ts-node src/tests.ts`);
27
+ lines.push(``);
28
+ lines.push(`import * as http from "http";`);
29
+ lines.push(``);
30
+ lines.push(`const BASE_URL = process.env.TEST_BASE_URL || "http://localhost:3000";`);
31
+ lines.push(`const AUTH_TOKEN = process.env.TEST_AUTH_TOKEN || "";`);
32
+ lines.push(``);
33
+ lines.push(`let passed = 0;`);
34
+ lines.push(`let failed = 0;`);
35
+ lines.push(``);
36
+
37
+ // Test helper
38
+ lines.push(`async function request(method: string, path: string, body?: any): Promise<{ status: number; data: any }> {`);
39
+ lines.push(` const url = new URL(path, BASE_URL);`);
40
+ lines.push(` const res = await fetch(url.toString(), {`);
41
+ lines.push(` method,`);
42
+ lines.push(` headers: {`);
43
+ lines.push(` "Content-Type": "application/json",`);
44
+ lines.push(` ...(AUTH_TOKEN ? { "Authorization": \`Bearer \${AUTH_TOKEN}\` } : {}),`);
45
+ lines.push(` },`);
46
+ lines.push(` body: body ? JSON.stringify(body) : undefined,`);
47
+ lines.push(` });`);
48
+ lines.push(` const data = await res.json().catch(() => ({}));`);
49
+ lines.push(` return { status: res.status, data };`);
50
+ lines.push(`}`);
51
+ lines.push(``);
52
+
53
+ lines.push(`async function test(name: string, fn: () => Promise<void>): Promise<void> {`);
54
+ lines.push(` try {`);
55
+ lines.push(` await fn();`);
56
+ lines.push(` console.log(\` ✓ \${name}\`);`);
57
+ lines.push(` passed++;`);
58
+ lines.push(` } catch (e: any) {`);
59
+ lines.push(` console.log(\` ✗ \${name}: \${e.message}\`);`);
60
+ lines.push(` failed++;`);
61
+ lines.push(` }`);
62
+ lines.push(`}`);
63
+ lines.push(``);
64
+
65
+ lines.push(`function assert(condition: boolean, message: string): void {`);
66
+ lines.push(` if (!condition) throw new Error(message);`);
67
+ lines.push(`}`);
68
+ lines.push(``);
69
+ lines.push(`(async () => {`);
70
+
71
+ // Generate tests for each module
72
+ for (const mod of system.modules) {
73
+ if (mod.kind !== "api_service" || mod.models.length === 0) continue;
74
+
75
+ const model = mod.models[0];
76
+ const tablePath = `/${toSnakeCase(model.name)}s`;
77
+
78
+ lines.push(`// ─── ${mod.name} Tests ─────────────────────────────────────────────────────`);
79
+ lines.push(``);
80
+
81
+ // CRUD tests
82
+ lines.push(`console.log("\\n${mod.name} — CRUD");`);
83
+ lines.push(``);
84
+
85
+ lines.push(`let __${toSnakeCase(model.name)}_id: string;`);
86
+ lines.push(``);
87
+
88
+ // Create test
89
+ const createFields = model.fields.filter(f =>
90
+ !["id", "created_at", "updated_at"].includes(f.name)
91
+ );
92
+ const samplePayload = createFields.reduce((acc, f) => {
93
+ acc[f.name] = sampleValue(f.type);
94
+ return acc;
95
+ }, {} as Record<string, any>);
96
+
97
+ lines.push(`await test("POST ${tablePath} — creates entity", async () => {`);
98
+ lines.push(` const { status, data } = await request("POST", "${tablePath}", ${JSON.stringify(samplePayload)});`);
99
+ lines.push(` assert(status === 201, \`Expected 201, got \${status}: \${JSON.stringify(data)}\`);`);
100
+ lines.push(` assert(data.id, "Response must have id");`);
101
+ lines.push(` __${toSnakeCase(model.name)}_id = data.id;`);
102
+ lines.push(`});`);
103
+ lines.push(``);
104
+
105
+ // Read test
106
+ lines.push(`await test("GET ${tablePath}/:id — reads entity", async () => {`);
107
+ lines.push(` const { status, data } = await request("GET", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`);`);
108
+ lines.push(` assert(status === 200, \`Expected 200, got \${status}\`);`);
109
+ lines.push(` assert(data.id === __${toSnakeCase(model.name)}_id, "ID must match");`);
110
+ lines.push(`});`);
111
+ lines.push(``);
112
+
113
+ // List test
114
+ lines.push(`await test("GET ${tablePath} — lists entities", async () => {`);
115
+ lines.push(` const { status, data } = await request("GET", "${tablePath}");`);
116
+ lines.push(` assert(status === 200, \`Expected 200, got \${status}\`);`);
117
+ lines.push(` assert(Array.isArray(data.items), "Response must have items array");`);
118
+ lines.push(` assert(typeof data.total === "number", "Response must have total");`);
119
+ lines.push(`});`);
120
+ lines.push(``);
121
+
122
+ // Auth test
123
+ lines.push(`await test("GET ${tablePath} — rejects unauthenticated", async () => {`);
124
+ lines.push(` const res = await fetch(\`\${BASE_URL}${tablePath}\`);`);
125
+ lines.push(` assert(res.status === 401, \`Expected 401, got \${res.status}\`);`);
126
+ lines.push(`});`);
127
+ lines.push(``);
128
+
129
+ // Capability tests
130
+ for (const iface of mod.interfaces) {
131
+ for (const method of iface.methods) {
132
+ if (["create", "read", "update", "delete", "list"].includes(method.name)) continue;
133
+
134
+ const endpoint = `${tablePath}/${method.name.replace(/_/g, "-")}`;
135
+ lines.push(`await test("POST ${endpoint} — capability executes", async () => {`);
136
+ lines.push(` const { status, data } = await request("POST", "${endpoint}", {`);
137
+ lines.push(` ${toSnakeCase(model.name)}_id: __${toSnakeCase(model.name)}_id,`);
138
+ lines.push(` });`);
139
+ lines.push(` // Capability may return 200 (success) or 422 (precondition failed) — both are valid`);
140
+ lines.push(` assert([200, 422].includes(status), \`Expected 200 or 422, got \${status}: \${JSON.stringify(data)}\`);`);
141
+ lines.push(`});`);
142
+ lines.push(``);
143
+
144
+ if (method.preconditions.length > 0) {
145
+ lines.push(`await test("POST ${endpoint} — returns 401 without auth", async () => {`);
146
+ lines.push(` const res = await fetch(\`\${BASE_URL}${endpoint}\`, { method: "POST" });`);
147
+ lines.push(` assert(res.status === 401, \`Expected 401, got \${res.status}\`);`);
148
+ lines.push(`});`);
149
+ lines.push(``);
150
+ }
151
+ }
152
+ }
153
+
154
+ // State machine tests
155
+ for (const sm of mod.state_machines) {
156
+ lines.push(`// State machine: ${sm.entity}`);
157
+ lines.push(`await test("PUT ${tablePath}/:id — rejects invalid state transition", async () => {`);
158
+ lines.push(` const { status, data } = await request("PUT", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`, {`);
159
+ lines.push(` state: "__invalid_state__",`);
160
+ lines.push(` });`);
161
+ lines.push(` assert(status === 422, \`Expected 422 for invalid transition, got \${status}\`);`);
162
+ lines.push(` assert(data.error?.code === "INVALID_TRANSITION", "Error code must be INVALID_TRANSITION");`);
163
+ lines.push(`});`);
164
+ lines.push(``);
165
+ }
166
+
167
+ // Delete test (last, so ID is still valid)
168
+ lines.push(`await test("DELETE ${tablePath}/:id — deletes entity", async () => {`);
169
+ lines.push(` const { status } = await request("DELETE", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`);`);
170
+ lines.push(` assert(status === 204, \`Expected 204, got \${status}\`);`);
171
+ lines.push(`});`);
172
+ lines.push(``);
173
+
174
+ // 404 after delete
175
+ lines.push(`await test("GET ${tablePath}/:id — returns 404 after delete", async () => {`);
176
+ lines.push(` const { status } = await request("GET", \`${tablePath}/\${__${toSnakeCase(model.name)}_id}\`);`);
177
+ lines.push(` assert(status === 404, \`Expected 404, got \${status}\`);`);
178
+ lines.push(`});`);
179
+ lines.push(``);
180
+ }
181
+
182
+ // Summary
183
+ lines.push(`console.log(\`\\n${"═".repeat(40)}\`);`);
184
+ lines.push(`console.log(\`Results: \${passed} passed, \${failed} failed\`);`);
185
+ lines.push(`console.log("═".repeat(40));`);
186
+ lines.push(`if (failed > 0) process.exit(1);`);
187
+ lines.push(`})().catch(e => { console.error(e); process.exit(1); });`);
188
+
189
+ return lines.join("\n");
190
+ }
191
+
192
+ function sampleValue(type: string): any {
193
+ switch (type) {
194
+ case "string": return "test_value";
195
+ case "uint": case "int": return 1;
196
+ case "float": return 1.0;
197
+ case "bool": return true;
198
+ case "uuid": return "00000000-0000-0000-0000-000000000001";
199
+ case "timestamp": return "2024-01-01T00:00:00.000Z";
200
+ case "json": return {};
201
+ default:
202
+ if (type.startsWith("list<") || type.startsWith("set<")) return [];
203
+ return null;
204
+ }
205
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * BoneScript WebSocket Runtime Emitter
3
+ * Generates runnable WebSocket servers for `channel` declarations.
4
+ */
5
+
6
+ import * as IR from "./ir";
7
+
8
+ function toSnakeCase(s: string): string {
9
+ return s.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase();
10
+ }
11
+
12
+ function toCamelCase(s: string): string {
13
+ return s.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
14
+ }
15
+
16
+ // ─── WebSocket Server ────────────────────────────────────────────────────────
17
+
18
+ export function emitWebSocketServer(system: IR.IRSystem): string {
19
+ const channels = system.modules.filter(m => m.kind === "realtime_service");
20
+ if (channels.length === 0) return "";
21
+
22
+ const lines: string[] = [];
23
+ lines.push(`// Generated by BoneScript compiler. DO NOT EDIT.`);
24
+ lines.push(`// WebSocket server for realtime channels`);
25
+ lines.push(``);
26
+ lines.push(`import { WebSocketServer, WebSocket } from "ws";`);
27
+ lines.push(`import { IncomingMessage, Server } from "http";`);
28
+ lines.push(`import jwt from "jsonwebtoken";`);
29
+ lines.push(`import { eventBus } from "./events";`);
30
+ lines.push(`import { logger } from "./logger";`);
31
+ lines.push(``);
32
+ lines.push(`const JWT_SECRET = process.env.JWT_SECRET || "bonescript-dev-secret-change-in-production";`);
33
+ lines.push(``);
34
+ // Redis pub/sub for multi-instance support
35
+ lines.push(`// Redis pub/sub for multi-instance WebSocket broadcasting`);
36
+ lines.push(`let redisSub: any = null;`);
37
+ lines.push(`let redisPub: any = null;`);
38
+ lines.push(`if (process.env.REDIS_URL) {`);
39
+ lines.push(` try {`);
40
+ lines.push(` const { createClient } = require("redis");`);
41
+ lines.push(` redisSub = createClient({ url: process.env.REDIS_URL });`);
42
+ lines.push(` redisPub = createClient({ url: process.env.REDIS_URL });`);
43
+ lines.push(` Promise.all([redisSub.connect(), redisPub.connect()]).then(() => {`);
44
+ lines.push(` logger.info("redis_connected", { event: "startup" });`);
45
+ lines.push(` }).catch((e: any) => {`);
46
+ lines.push(` logger.error("redis_connect_failed", { event: "startup", metadata: { error: e.message } });`);
47
+ lines.push(` redisSub = null; redisPub = null;`);
48
+ lines.push(` });`);
49
+ lines.push(` } catch { redisSub = null; redisPub = null; }`);
50
+ lines.push(`}`);
51
+
52
+ // Per-channel client registry
53
+ lines.push(`interface Client {`);
54
+ lines.push(` socket: WebSocket;`);
55
+ lines.push(` user_id: string;`);
56
+ lines.push(` channel: string;`);
57
+ lines.push(` topics: Set<string>;`);
58
+ lines.push(`}`);
59
+ lines.push(``);
60
+ lines.push(`const clients: Map<string, Set<Client>> = new Map();`);
61
+ lines.push(``);
62
+
63
+ // Channel configs
64
+ lines.push(`const CHANNELS: Record<string, { ordering: string; persistence: string; max_size: number }> = {`);
65
+ for (const ch of channels) {
66
+ lines.push(` "${ch.name}": {`);
67
+ lines.push(` ordering: "${ch.config["ordering"] || "fifo"}",`);
68
+ lines.push(` persistence: "${ch.config["persistence"] || "none"}",`);
69
+ lines.push(` max_size: ${ch.config["max_size"] || 10000},`);
70
+ lines.push(` },`);
71
+ }
72
+ lines.push(`};`);
73
+ lines.push(``);
74
+
75
+ // Message buffer for persistence
76
+ lines.push(`const messageBuffers: Map<string, any[]> = new Map();`);
77
+ lines.push(``);
78
+ lines.push(`function getBuffer(channel: string): any[] {`);
79
+ lines.push(` const buf = messageBuffers.get(channel);`);
80
+ lines.push(` if (buf) return buf;`);
81
+ lines.push(` const fresh: any[] = [];`);
82
+ lines.push(` messageBuffers.set(channel, fresh);`);
83
+ lines.push(` return fresh;`);
84
+ lines.push(`}`);
85
+ lines.push(``);
86
+ lines.push(`function persistMessage(channel: string, msg: any) {`);
87
+ lines.push(` const cfg = CHANNELS[channel];`);
88
+ lines.push(` if (!cfg || cfg.persistence === "none") return;`);
89
+ lines.push(` const buf = getBuffer(channel);`);
90
+ lines.push(` buf.push(msg);`);
91
+ lines.push(``);
92
+ lines.push(` // Honor persistence config (last_N, full)`);
93
+ lines.push(` const match = cfg.persistence.match(/^last_(\\d+)$/);`);
94
+ lines.push(` if (match) {`);
95
+ lines.push(` const limit = parseInt(match[1], 10);`);
96
+ lines.push(` while (buf.length > limit) buf.shift();`);
97
+ lines.push(` } else if (buf.length > cfg.max_size) {`);
98
+ lines.push(` buf.shift();`);
99
+ lines.push(` }`);
100
+ lines.push(`}`);
101
+ lines.push(``);
102
+
103
+ // Setup function
104
+ lines.push(`export function setupWebSocketServer(httpServer: Server): WebSocketServer {`);
105
+ lines.push(` const wss = new WebSocketServer({ server: httpServer, path: "/ws" });`);
106
+ lines.push(``);
107
+ lines.push(` wss.on("connection", (socket: WebSocket, req: IncomingMessage) => {`);
108
+ lines.push(` const url = new URL(req.url || "/", \`http://\${req.headers.host}\`);`);
109
+ lines.push(` const channel = url.searchParams.get("channel") || "";`);
110
+ lines.push(` const token = url.searchParams.get("token") || "";`);
111
+ lines.push(``);
112
+ lines.push(` if (!CHANNELS[channel]) {`);
113
+ lines.push(` socket.send(JSON.stringify({ type: "error", message: "Unknown channel: " + channel }));`);
114
+ lines.push(` socket.close();`);
115
+ lines.push(` return;`);
116
+ lines.push(` }`);
117
+ lines.push(``);
118
+ lines.push(` let userId: string;`);
119
+ lines.push(` try {`);
120
+ lines.push(` const decoded = jwt.verify(token, JWT_SECRET) as { sub: string };`);
121
+ lines.push(` userId = decoded.sub;`);
122
+ lines.push(` } catch {`);
123
+ lines.push(` socket.send(JSON.stringify({ type: "error", message: "Authentication failed" }));`);
124
+ lines.push(` socket.close();`);
125
+ lines.push(` return;`);
126
+ lines.push(` }`);
127
+ lines.push(``);
128
+ lines.push(` const client: Client = { socket, user_id: userId, channel, topics: new Set() };`);
129
+ lines.push(` const set = clients.get(channel) || new Set();`);
130
+ lines.push(` set.add(client);`);
131
+ lines.push(` clients.set(channel, set);`);
132
+ lines.push(``);
133
+ lines.push(` console.log(\`[ws] User \${userId} connected to \${channel} (\${set.size} active)\`);`);
134
+ lines.push(``);
135
+ lines.push(` // Send buffered history`);
136
+ lines.push(` const history = messageBuffers.get(channel) || [];`);
137
+ lines.push(` for (const msg of history) {`);
138
+ lines.push(` socket.send(JSON.stringify(msg));`);
139
+ lines.push(` }`);
140
+ lines.push(``);
141
+ lines.push(` socket.send(JSON.stringify({ type: "connected", channel, history_size: history.length }));`);
142
+ lines.push(``);
143
+ lines.push(` socket.on("message", (data) => {`);
144
+ lines.push(` try {`);
145
+ lines.push(` const msg = JSON.parse(data.toString());`);
146
+ lines.push(``);
147
+ lines.push(` if (msg.type === "subscribe" && msg.topic) {`);
148
+ lines.push(` client.topics.add(msg.topic);`);
149
+ lines.push(` return;`);
150
+ lines.push(` }`);
151
+ lines.push(` if (msg.type === "unsubscribe" && msg.topic) {`);
152
+ lines.push(` client.topics.delete(msg.topic);`);
153
+ lines.push(` return;`);
154
+ lines.push(` }`);
155
+ lines.push(``);
156
+ lines.push(` // Broadcast`);
157
+ lines.push(` const broadcast = {`);
158
+ lines.push(` type: msg.type || "message",`);
159
+ lines.push(` payload: msg.payload || msg,`);
160
+ lines.push(` from: userId,`);
161
+ lines.push(` timestamp: new Date().toISOString(),`);
162
+ lines.push(` };`);
163
+ lines.push(``);
164
+ lines.push(` persistMessage(channel, broadcast);`);
165
+ lines.push(` broadcastToChannel(channel, broadcast, client);`);
166
+ lines.push(` } catch (e: any) {`);
167
+ lines.push(` socket.send(JSON.stringify({ type: "error", message: e.message }));`);
168
+ lines.push(` }`);
169
+ lines.push(` });`);
170
+ lines.push(``);
171
+ lines.push(` socket.on("close", () => {`);
172
+ lines.push(` const set = clients.get(channel);`);
173
+ lines.push(` if (set) set.delete(client);`);
174
+ lines.push(` console.log(\`[ws] User \${userId} disconnected from \${channel}\`);`);
175
+ lines.push(` });`);
176
+ lines.push(``);
177
+ lines.push(` // Heartbeat`);
178
+ lines.push(` const heartbeat = setInterval(() => {`);
179
+ lines.push(` if (socket.readyState === WebSocket.OPEN) {`);
180
+ lines.push(` socket.ping();`);
181
+ lines.push(` } else {`);
182
+ lines.push(` clearInterval(heartbeat);`);
183
+ lines.push(` }`);
184
+ lines.push(` }, 30000);`);
185
+ lines.push(` });`);
186
+ lines.push(``);
187
+ lines.push(` // Bridge: forward eventBus events to WebSocket clients`);
188
+ for (const ch of channels) {
189
+ lines.push(` // Channel '${ch.name}' bridge`);
190
+ }
191
+ lines.push(``);
192
+ // Redis subscription for cross-instance delivery
193
+ lines.push(` // Subscribe to Redis channels for cross-instance delivery`);
194
+ lines.push(` if (redisSub) {`);
195
+ for (const ch of channels) {
196
+ lines.push(` redisSub.subscribe("ws:${ch.name}", (message: string) => {`);
197
+ lines.push(` const set = clients.get("${ch.name}");`);
198
+ lines.push(` if (!set) return;`);
199
+ lines.push(` for (const client of set) {`);
200
+ lines.push(` if (client.socket.readyState === WebSocket.OPEN) client.socket.send(message);`);
201
+ lines.push(` }`);
202
+ lines.push(` }).catch(() => {});`);
203
+ }
204
+ lines.push(` }`);
205
+ lines.push(` return wss;`);
206
+ lines.push(`}`);
207
+ lines.push(``);
208
+ lines.push(`function broadcastToChannel(channel: string, message: any, exclude?: Client) {`);
209
+ lines.push(` const set = clients.get(channel);`);
210
+ lines.push(` const data = JSON.stringify(message);`);
211
+ lines.push(` // Broadcast to local clients`);
212
+ lines.push(` if (set) {`);
213
+ lines.push(` for (const client of set) {`);
214
+ lines.push(` if (client === exclude) continue;`);
215
+ lines.push(` if (client.socket.readyState === WebSocket.OPEN) {`);
216
+ lines.push(` client.socket.send(data);`);
217
+ lines.push(` }`);
218
+ lines.push(` }`);
219
+ lines.push(` }`);
220
+ lines.push(` // Publish to Redis for cross-instance delivery`);
221
+ lines.push(` if (redisPub) {`);
222
+ lines.push(` redisPub.publish(\`ws:\${channel}\`, data).catch(() => {});`);
223
+ lines.push(` }`);
224
+ lines.push(`}`);
225
+ lines.push(``);
226
+ lines.push(`export { broadcastToChannel };`);
227
+
228
+ return lines.join("\n");
229
+ }