whygraph 0.1.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 (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +205 -0
  3. package/dist/cli/commands/config.d.ts +14 -0
  4. package/dist/cli/commands/config.js +123 -0
  5. package/dist/cli/commands/down.d.ts +9 -0
  6. package/dist/cli/commands/down.js +46 -0
  7. package/dist/cli/commands/init.d.ts +17 -0
  8. package/dist/cli/commands/init.js +144 -0
  9. package/dist/cli/commands/issues.d.ts +10 -0
  10. package/dist/cli/commands/issues.js +376 -0
  11. package/dist/cli/commands/mcp.d.ts +2 -0
  12. package/dist/cli/commands/mcp.js +9 -0
  13. package/dist/cli/commands/restart.d.ts +11 -0
  14. package/dist/cli/commands/restart.js +43 -0
  15. package/dist/cli/commands/serve.d.ts +14 -0
  16. package/dist/cli/commands/serve.js +132 -0
  17. package/dist/cli/commands/server-utils.d.ts +6 -0
  18. package/dist/cli/commands/server-utils.js +94 -0
  19. package/dist/cli/commands/status.d.ts +11 -0
  20. package/dist/cli/commands/status.js +97 -0
  21. package/dist/cli/commands/up.d.ts +13 -0
  22. package/dist/cli/commands/up.js +62 -0
  23. package/dist/cli/commands/validate.d.ts +14 -0
  24. package/dist/cli/commands/validate.js +88 -0
  25. package/dist/cli/commands/viz.d.ts +7 -0
  26. package/dist/cli/commands/viz.js +97 -0
  27. package/dist/cli/index.d.ts +2 -0
  28. package/dist/cli/index.js +33 -0
  29. package/dist/entity/id.d.ts +8 -0
  30. package/dist/entity/id.js +48 -0
  31. package/dist/entity/issues.d.ts +12 -0
  32. package/dist/entity/issues.js +68 -0
  33. package/dist/entity/parser.d.ts +6 -0
  34. package/dist/entity/parser.js +166 -0
  35. package/dist/entity/types.d.ts +54 -0
  36. package/dist/entity/types.js +21 -0
  37. package/dist/entity/validate.d.ts +12 -0
  38. package/dist/entity/validate.js +136 -0
  39. package/dist/entity/writer.d.ts +16 -0
  40. package/dist/entity/writer.js +142 -0
  41. package/dist/frontend/assets/index-ByZzPwVe.css +1 -0
  42. package/dist/frontend/assets/index-F9dxfzD_.js +170 -0
  43. package/dist/frontend/index.html +14 -0
  44. package/dist/graph/cascade.d.ts +10 -0
  45. package/dist/graph/cascade.js +49 -0
  46. package/dist/graph/decisions.d.ts +11 -0
  47. package/dist/graph/decisions.js +27 -0
  48. package/dist/graph/gaps.d.ts +10 -0
  49. package/dist/graph/gaps.js +58 -0
  50. package/dist/graph/nodes.d.ts +20 -0
  51. package/dist/graph/nodes.js +33 -0
  52. package/dist/graph/projection.d.ts +6 -0
  53. package/dist/graph/projection.js +44 -0
  54. package/dist/graph/query.d.ts +15 -0
  55. package/dist/graph/query.js +82 -0
  56. package/dist/graph/search.d.ts +2 -0
  57. package/dist/graph/search.js +23 -0
  58. package/dist/graph/supersede.d.ts +7 -0
  59. package/dist/graph/supersede.js +48 -0
  60. package/dist/graph/temporal.d.ts +13 -0
  61. package/dist/graph/temporal.js +28 -0
  62. package/dist/mcp/index.d.ts +2 -0
  63. package/dist/mcp/index.js +10 -0
  64. package/dist/mcp/server.d.ts +3 -0
  65. package/dist/mcp/server.js +340 -0
  66. package/dist/onboarding/interview.d.ts +22 -0
  67. package/dist/onboarding/interview.js +92 -0
  68. package/dist/onboarding/scan.d.ts +17 -0
  69. package/dist/onboarding/scan.js +106 -0
  70. package/dist/platform/rules.d.ts +8 -0
  71. package/dist/platform/rules.js +229 -0
  72. package/dist/server/core.d.ts +26 -0
  73. package/dist/server/core.js +111 -0
  74. package/dist/server/derived.d.ts +8 -0
  75. package/dist/server/derived.js +13 -0
  76. package/dist/server/etag.d.ts +9 -0
  77. package/dist/server/etag.js +25 -0
  78. package/dist/server/http.d.ts +13 -0
  79. package/dist/server/http.js +131 -0
  80. package/dist/server/pubsub.d.ts +12 -0
  81. package/dist/server/pubsub.js +19 -0
  82. package/dist/server/schema.d.ts +2 -0
  83. package/dist/server/schema.js +362 -0
  84. package/dist/server/stale-refs.d.ts +7 -0
  85. package/dist/server/stale-refs.js +23 -0
  86. package/dist/server/watcher.d.ts +21 -0
  87. package/dist/server/watcher.js +98 -0
  88. package/dist/server/worktree-watcher.d.ts +20 -0
  89. package/dist/server/worktree-watcher.js +79 -0
  90. package/dist/server/worktree.d.ts +22 -0
  91. package/dist/server/worktree.js +84 -0
  92. package/package.json +73 -0
@@ -0,0 +1,229 @@
1
+ import { execSync } from "node:child_process";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ const START_MARKER = "<!-- whygraph:start -->";
5
+ const END_MARKER = "<!-- whygraph:end -->";
6
+ function generateInstructions(config) {
7
+ const tagList = config.tags.join(", ");
8
+ return [
9
+ "## Whygraph — Decision Capture",
10
+ "",
11
+ "Before writing, modifying, or deleting production code, capture any non-trivial",
12
+ "decision as a Decision node. A decision is non-trivial if a junior engineer would",
13
+ "benefit from knowing why the choice was made.",
14
+ "",
15
+ "### Recognition Heuristic",
16
+ "",
17
+ "Scan your output for these signals:",
18
+ "- Choosing between alternatives",
19
+ "- Rejecting an approach",
20
+ "- Setting a convention or pattern",
21
+ "- Adding, removing, or changing a dependency",
22
+ "- Configuring infrastructure or deployment",
23
+ "- Making a security or performance tradeoff",
24
+ "- Altering user-facing behavior",
25
+ "- Scoping to production code the agent authored",
26
+ "- Establishing an invariant or constraint",
27
+ "",
28
+ "### Decision File Format",
29
+ "",
30
+ "Write decision files to `.whygraph/graph/` with YAML frontmatter:",
31
+ "",
32
+ "```yaml",
33
+ "---",
34
+ `id: ${config.prefix}<4-char-nanoid>`,
35
+ "label: Decision",
36
+ "title: <short title>",
37
+ "status: active",
38
+ "date: <YYYY-MM-DD>",
39
+ "affects:",
40
+ " - <entity-id>",
41
+ "tags:",
42
+ ` - <one of: ${tagList}>`,
43
+ `created_at: <ISO 8601>`,
44
+ `updated_at: <ISO 8601>`,
45
+ "---",
46
+ "",
47
+ "## Context",
48
+ "<why this decision was needed>",
49
+ "",
50
+ "## Decision",
51
+ "<what was decided>",
52
+ "",
53
+ "## Tradeoffs",
54
+ "<what was gained and lost>",
55
+ "",
56
+ "## Alternatives",
57
+ "<what was considered and rejected>",
58
+ "```",
59
+ "",
60
+ `Allowed tags: ${tagList}`,
61
+ "",
62
+ "### MCP Server",
63
+ "",
64
+ `Use the \`whygraph\` MCP server for decision capture tools.`,
65
+ "If the server is unreachable, write decision files directly to `.whygraph/graph/`.",
66
+ "",
67
+ "### Server Status",
68
+ "",
69
+ "Run `whygraph status` to check if the server is running.",
70
+ "Run `whygraph up` to start the server.",
71
+ "",
72
+ ].join("\n");
73
+ }
74
+ function upsertMarkedSection(existing, content) {
75
+ const wrapped = `${START_MARKER}\n${content}${END_MARKER}\n`;
76
+ if (existing.includes(START_MARKER)) {
77
+ const startIdx = existing.indexOf(START_MARKER);
78
+ const endIdx = existing.indexOf(END_MARKER);
79
+ /* v8 ignore next 1 */
80
+ const afterEnd = endIdx >= 0 ? endIdx + END_MARKER.length + 1 : existing.length;
81
+ return existing.slice(0, startIdx) + wrapped + existing.slice(afterEnd);
82
+ }
83
+ /* v8 ignore next 1 */
84
+ const sep = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "";
85
+ return existing + sep + wrapped;
86
+ }
87
+ // ============================================================
88
+ // MCP Registration
89
+ // ============================================================
90
+ function registerMcpWithClaude(projectDir) {
91
+ try {
92
+ execSync("claude mcp add --scope project whygraph -- whygraph mcp", {
93
+ cwd: projectDir,
94
+ stdio: "ignore",
95
+ });
96
+ return true;
97
+ /* v8 ignore start */
98
+ }
99
+ catch {
100
+ // claude CLI not available — write .mcp.json directly as fallback
101
+ try {
102
+ const mcpJsonPath = join(projectDir, ".mcp.json");
103
+ let mcpJson = {};
104
+ if (existsSync(mcpJsonPath)) {
105
+ mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf-8"));
106
+ }
107
+ const servers = (mcpJson.mcpServers ?? {});
108
+ if (!servers.whygraph) {
109
+ servers.whygraph = { type: "stdio", command: "whygraph", args: ["mcp"] };
110
+ mcpJson.mcpServers = servers;
111
+ writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n", "utf-8");
112
+ }
113
+ return true;
114
+ }
115
+ catch {
116
+ return false;
117
+ }
118
+ }
119
+ /* v8 ignore stop */
120
+ }
121
+ function registerMcpWithCursor(projectDir) {
122
+ try {
123
+ const cursorDir = join(projectDir, ".cursor");
124
+ mkdirSync(cursorDir, { recursive: true });
125
+ const mcpPath = join(cursorDir, "mcp.json");
126
+ let mcp = {};
127
+ if (existsSync(mcpPath)) {
128
+ mcp = JSON.parse(readFileSync(mcpPath, "utf-8"));
129
+ }
130
+ const servers = (mcp.mcpServers ?? {});
131
+ if (!servers.whygraph) {
132
+ servers.whygraph = { command: "whygraph", args: ["mcp"] };
133
+ mcp.mcpServers = servers;
134
+ writeFileSync(mcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
135
+ }
136
+ return true;
137
+ }
138
+ catch {
139
+ return false;
140
+ }
141
+ }
142
+ function registerMcpWithCopilot(projectDir) {
143
+ try {
144
+ const vscodeDir = join(projectDir, ".vscode");
145
+ mkdirSync(vscodeDir, { recursive: true });
146
+ const mcpPath = join(vscodeDir, "mcp.json");
147
+ let mcp = {};
148
+ if (existsSync(mcpPath)) {
149
+ mcp = JSON.parse(readFileSync(mcpPath, "utf-8"));
150
+ }
151
+ const servers = (mcp.servers ?? {});
152
+ if (!servers.whygraph) {
153
+ servers.whygraph = { type: "stdio", command: "whygraph", args: ["mcp"] };
154
+ mcp.servers = servers;
155
+ writeFileSync(mcpPath, JSON.stringify(mcp, null, 2) + "\n", "utf-8");
156
+ }
157
+ return true;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ function writeMcpSetupMd(projectDir, environment) {
164
+ const whygraphDir = join(projectDir, ".whygraph");
165
+ mkdirSync(whygraphDir, { recursive: true });
166
+ const filePath = join(whygraphDir, "MCP_SETUP.md");
167
+ const lines = ["# Whygraph MCP Setup", ""];
168
+ switch (environment) {
169
+ case "claude-code":
170
+ lines.push("Run the following command in your project root:", "", "```bash", "claude mcp add --scope project whygraph -- whygraph mcp", "```");
171
+ break;
172
+ case "cursor":
173
+ lines.push("Add the following to `.cursor/mcp.json` in your project root:", "", "```json", JSON.stringify({ mcpServers: { whygraph: { command: "whygraph", args: ["mcp"] } } }, null, 2), "```");
174
+ break;
175
+ case "copilot":
176
+ lines.push("Add the following to `.vscode/mcp.json` in your project root:", "", "```json", JSON.stringify({ servers: { whygraph: { type: "stdio", command: "whygraph", args: ["mcp"] } } }, null, 2), "```");
177
+ break;
178
+ default:
179
+ lines.push("Add the whygraph MCP server to your AI assistant's MCP configuration.", "", "**Command:** `whygraph mcp`", "**Transport:** stdio", "", "Refer to your AI assistant's documentation for how to register MCP servers.");
180
+ }
181
+ writeFileSync(filePath, lines.join("\n") + "\n", "utf-8");
182
+ return filePath;
183
+ }
184
+ // ============================================================
185
+ // Platform Writers
186
+ // ============================================================
187
+ export function writePlatformRules(projectDir, environment, _primeOutput, config) {
188
+ const instructions = config ? generateInstructions(config) : _primeOutput;
189
+ switch (environment) {
190
+ case "claude-code":
191
+ return writeClaudeCodeRules(projectDir, instructions);
192
+ case "cursor": {
193
+ const mcpRegistered = registerMcpWithCursor(projectDir);
194
+ const mcpSetupPath = mcpRegistered ? undefined : writeMcpSetupMd(projectDir, environment);
195
+ return { ...writeAgentsMd(projectDir, instructions, environment), mcpRegistered, mcpSetupPath };
196
+ }
197
+ case "copilot": {
198
+ const mcpRegistered = registerMcpWithCopilot(projectDir);
199
+ const mcpSetupPath = mcpRegistered ? undefined : writeMcpSetupMd(projectDir, environment);
200
+ return { ...writeAgentsMd(projectDir, instructions, environment), mcpRegistered, mcpSetupPath };
201
+ }
202
+ case "other": {
203
+ const mcpSetupPath = writeMcpSetupMd(projectDir, environment);
204
+ return { ...writeAgentsMd(projectDir, instructions, environment), mcpRegistered: false, mcpSetupPath };
205
+ }
206
+ }
207
+ }
208
+ function writeClaudeCodeRules(projectDir, instructions) {
209
+ const mcpRegistered = registerMcpWithClaude(projectDir);
210
+ const mcpSetupPath = mcpRegistered ? undefined : writeMcpSetupMd(projectDir, "claude-code");
211
+ const claudeMdPath = join(projectDir, "CLAUDE.md");
212
+ let existing = "";
213
+ if (existsSync(claudeMdPath)) {
214
+ existing = readFileSync(claudeMdPath, "utf-8");
215
+ }
216
+ const newContent = upsertMarkedSection(existing, instructions);
217
+ writeFileSync(claudeMdPath, newContent, "utf-8");
218
+ return { environment: "claude-code", filePath: claudeMdPath, mcpRegistered, mcpSetupPath };
219
+ }
220
+ function writeAgentsMd(projectDir, instructions, environment) {
221
+ const filePath = join(projectDir, "AGENTS.md");
222
+ let existing = "";
223
+ if (existsSync(filePath)) {
224
+ existing = readFileSync(filePath, "utf-8");
225
+ }
226
+ const newContent = upsertMarkedSection(existing, instructions);
227
+ writeFileSync(filePath, newContent, "utf-8");
228
+ return { environment, filePath };
229
+ }
@@ -0,0 +1,26 @@
1
+ import graphology from "graphology";
2
+ import type { EntityIssue } from "../entity/issues.js";
3
+ import { PubSub } from "./pubsub.js";
4
+ import type { Entity } from "../entity/types.js";
5
+ type MultiDirectedGraph = graphology.MultiDirectedGraph;
6
+ declare const MultiDirectedGraph: typeof graphology.MultiDirectedGraph;
7
+ export declare class ServerCore {
8
+ private entityMap;
9
+ private graph;
10
+ private readonly whygraphDir;
11
+ readonly pubsub: PubSub;
12
+ constructor(whygraphDir: string);
13
+ getWhygraphDir(): string;
14
+ load(): Promise<void>;
15
+ getIssues(): EntityIssue[];
16
+ getEntity(id: string): Entity | undefined;
17
+ getAllEntities(): Entity[];
18
+ getGraph(): MultiDirectedGraph;
19
+ getEntityMap(): Map<string, Entity>;
20
+ addOrUpdateEntity(id: string, entity: Entity): void;
21
+ removeEntity(id: string): void;
22
+ private rebuildGraph;
23
+ private reconcileEntityIssue;
24
+ private reconcileAllIssues;
25
+ }
26
+ export {};
@@ -0,0 +1,111 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import graphology from "graphology";
4
+ import { parseEntity } from "../entity/parser.js";
5
+ import { validateEntity, validateEntityRefs } from "../entity/validate.js";
6
+ import { reconcileIssue, listIssues, deleteIssue } from "../entity/issues.js";
7
+ import { buildGraph } from "../graph/projection.js";
8
+ import { PubSub } from "./pubsub.js";
9
+ const MultiDirectedGraph = graphology.MultiDirectedGraph;
10
+ export class ServerCore {
11
+ entityMap;
12
+ graph;
13
+ whygraphDir;
14
+ pubsub;
15
+ constructor(whygraphDir) {
16
+ this.whygraphDir = whygraphDir;
17
+ this.entityMap = new Map();
18
+ this.graph = new MultiDirectedGraph();
19
+ this.pubsub = new PubSub();
20
+ }
21
+ getWhygraphDir() {
22
+ return this.whygraphDir;
23
+ }
24
+ async load() {
25
+ const graphDir = path.join(this.whygraphDir, "graph");
26
+ let entries;
27
+ try {
28
+ entries = await fs.readdir(graphDir);
29
+ }
30
+ catch {
31
+ // Directory doesn't exist — start with empty state
32
+ return;
33
+ }
34
+ const mdFiles = entries.filter((f) => f.endsWith(".md"));
35
+ for (const file of mdFiles) {
36
+ const filePath = path.join(graphDir, file);
37
+ try {
38
+ const content = await fs.readFile(filePath, "utf-8");
39
+ const entity = parseEntity(content);
40
+ if (entity) {
41
+ this.entityMap.set(entity.id, entity);
42
+ }
43
+ else {
44
+ console.warn(`Skipping unparseable file: ${file}`);
45
+ }
46
+ }
47
+ catch (err) {
48
+ console.warn(`Error reading file ${file}:`, err);
49
+ }
50
+ }
51
+ this.rebuildGraph();
52
+ this.reconcileAllIssues();
53
+ }
54
+ getIssues() {
55
+ return listIssues(this.whygraphDir);
56
+ }
57
+ getEntity(id) {
58
+ return this.entityMap.get(id);
59
+ }
60
+ getAllEntities() {
61
+ return Array.from(this.entityMap.values());
62
+ }
63
+ getGraph() {
64
+ return this.graph;
65
+ }
66
+ getEntityMap() {
67
+ return this.entityMap;
68
+ }
69
+ addOrUpdateEntity(id, entity) {
70
+ const isUpdate = this.entityMap.has(id);
71
+ this.entityMap.set(id, entity);
72
+ this.rebuildGraph();
73
+ this.reconcileEntityIssue(id, entity);
74
+ this.pubsub.publish({
75
+ type: isUpdate ? "entity_updated" : "entity_created",
76
+ entityId: id,
77
+ entity,
78
+ });
79
+ }
80
+ removeEntity(id) {
81
+ const existed = this.entityMap.has(id);
82
+ this.entityMap.delete(id);
83
+ this.rebuildGraph();
84
+ deleteIssue(this.whygraphDir, id);
85
+ if (existed) {
86
+ this.pubsub.publish({
87
+ type: "entity_deleted",
88
+ entityId: id,
89
+ });
90
+ }
91
+ }
92
+ rebuildGraph() {
93
+ this.graph = buildGraph(this.getAllEntities());
94
+ }
95
+ reconcileEntityIssue(id, entity) {
96
+ const { errors } = validateEntity(entity);
97
+ const refErrors = validateEntityRefs(entity, this.entityMap);
98
+ reconcileIssue(this.whygraphDir, id, [...errors, ...refErrors]);
99
+ }
100
+ reconcileAllIssues() {
101
+ for (const [id, entity] of this.entityMap) {
102
+ this.reconcileEntityIssue(id, entity);
103
+ }
104
+ const existingIssues = listIssues(this.whygraphDir);
105
+ for (const issue of existingIssues) {
106
+ if (!this.entityMap.has(issue.entityId)) {
107
+ deleteIssue(this.whygraphDir, issue.entityId);
108
+ }
109
+ }
110
+ }
111
+ }
@@ -0,0 +1,8 @@
1
+ import type { Entity } from "../entity/types.js";
2
+ import type { ValidationError } from "../entity/validate.js";
3
+ import type { SupersedeCandidate } from "../graph/supersede.js";
4
+ export interface DerivedState {
5
+ validationErrors: Map<string, ValidationError[]>;
6
+ supersedeCandidates: SupersedeCandidate[];
7
+ }
8
+ export declare function computeDerivedState(entities: Map<string, Entity>): DerivedState;
@@ -0,0 +1,13 @@
1
+ import { validateEntity } from "../entity/validate.js";
2
+ import { detectSupersedeCandidates } from "../graph/supersede.js";
3
+ export function computeDerivedState(entities) {
4
+ const validationErrors = new Map();
5
+ for (const [id, entity] of entities) {
6
+ const result = validateEntity(entity);
7
+ if (result.errors.length > 0) {
8
+ validationErrors.set(id, result.errors);
9
+ }
10
+ }
11
+ const supersedeCandidates = detectSupersedeCandidates(entities);
12
+ return { validationErrors, supersedeCandidates };
13
+ }
@@ -0,0 +1,9 @@
1
+ import type { Entity } from "../entity/types.js";
2
+ /**
3
+ * Compute an ETag for an entity by hashing its rendered markdown output.
4
+ */
5
+ export declare function computeETag(entity: Entity): string;
6
+ /**
7
+ * Compare two entities by ETag to determine if the worktree copy has diverged.
8
+ */
9
+ export declare function isDirty(worktreeEntity: Entity, mainEntity: Entity): boolean;
@@ -0,0 +1,25 @@
1
+ import { renderEntity } from "../entity/writer.js";
2
+ /**
3
+ * FNV-1a hash of a string, returned as hex.
4
+ */
5
+ function fnv1a(input) {
6
+ let hash = 0x811c9dc5; // FNV offset basis (32-bit)
7
+ for (let i = 0; i < input.length; i++) {
8
+ hash ^= input.charCodeAt(i);
9
+ hash = (hash * 0x01000193) >>> 0; // FNV prime, keep as uint32
10
+ }
11
+ return hash.toString(16);
12
+ }
13
+ /**
14
+ * Compute an ETag for an entity by hashing its rendered markdown output.
15
+ */
16
+ export function computeETag(entity) {
17
+ const rendered = renderEntity(entity);
18
+ return fnv1a(rendered);
19
+ }
20
+ /**
21
+ * Compare two entities by ETag to determine if the worktree copy has diverged.
22
+ */
23
+ export function isDirty(worktreeEntity, mainEntity) {
24
+ return computeETag(worktreeEntity) !== computeETag(mainEntity);
25
+ }
@@ -0,0 +1,13 @@
1
+ import { type Server } from "node:http";
2
+ import type { ServerCore } from "./core.js";
3
+ export interface HttpServerOptions {
4
+ port?: number;
5
+ frontendDir?: string;
6
+ }
7
+ export interface HttpServer {
8
+ start(): Promise<void>;
9
+ stop(): Promise<void>;
10
+ readonly server: Server;
11
+ }
12
+ export declare function createHttpServer(core: ServerCore, portOrOptions?: number | HttpServerOptions): HttpServer;
13
+ export declare function startServer(core: ServerCore, port?: number): Promise<HttpServer>;
@@ -0,0 +1,131 @@
1
+ import { createServer } from "node:http";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { WebSocketServer } from "ws";
5
+ import { useServer } from "graphql-ws/use/ws";
6
+ import { createYoga } from "graphql-yoga";
7
+ import { execute, subscribe } from "graphql";
8
+ import { buildSchema } from "./schema.js";
9
+ const DEFAULT_PORT = 4777;
10
+ const MIME_TYPES = {
11
+ ".html": "text/html",
12
+ ".js": "application/javascript",
13
+ ".css": "text/css",
14
+ ".json": "application/json",
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".svg": "image/svg+xml",
18
+ ".ico": "image/x-icon",
19
+ ".woff": "font/woff",
20
+ ".woff2": "font/woff2",
21
+ };
22
+ function defaultFrontendDir() {
23
+ // In production, frontend is at dist/frontend relative to the package root.
24
+ // import.meta.url points to dist/server/http.js, so go up one level to dist/,
25
+ // then into frontend/.
26
+ const thisFile = new URL(import.meta.url).pathname;
27
+ return path.resolve(path.dirname(thisFile), "..", "frontend");
28
+ }
29
+ function tryServeStatic(frontendDir, reqUrl, res) {
30
+ const urlPath = reqUrl.split("?")[0];
31
+ const filePath = path.join(frontendDir, urlPath);
32
+ // Prevent directory traversal
33
+ /* v8 ignore next 3 */
34
+ if (!filePath.startsWith(frontendDir)) {
35
+ return false;
36
+ }
37
+ try {
38
+ const stat = fs.statSync(filePath);
39
+ if (stat.isFile()) {
40
+ const ext = path.extname(filePath);
41
+ const mime = MIME_TYPES[ext] ?? "application/octet-stream";
42
+ const content = fs.readFileSync(filePath);
43
+ res.writeHead(200, { "Content-Type": mime });
44
+ res.end(content);
45
+ return true;
46
+ }
47
+ }
48
+ catch {
49
+ // File not found, fall through
50
+ }
51
+ return false;
52
+ }
53
+ function serveIndexFallback(frontendDir, res) {
54
+ const indexPath = path.join(frontendDir, "index.html");
55
+ try {
56
+ const content = fs.readFileSync(indexPath, "utf-8");
57
+ res.writeHead(200, { "Content-Type": "text/html" });
58
+ res.end(content);
59
+ return true;
60
+ }
61
+ catch {
62
+ return false;
63
+ }
64
+ }
65
+ export function createHttpServer(core, portOrOptions) {
66
+ const options = typeof portOrOptions === "number"
67
+ ? { port: portOrOptions }
68
+ /* v8 ignore next 1 */
69
+ : portOrOptions ?? {};
70
+ /* v8 ignore next 1 */
71
+ const port = options.port ?? DEFAULT_PORT;
72
+ const frontendDir = options.frontendDir ?? defaultFrontendDir();
73
+ const schema = buildSchema(core);
74
+ const yoga = createYoga({
75
+ schema,
76
+ graphqlEndpoint: "/api/graphql",
77
+ landingPage: false,
78
+ });
79
+ const server = createServer((req, res) => {
80
+ /* v8 ignore next 1 */
81
+ const url = req.url ?? "/";
82
+ // Health endpoint
83
+ if (url === "/api/health" && req.method === "GET") {
84
+ res.writeHead(200, { "Content-Type": "application/json" });
85
+ res.end(JSON.stringify({ status: "ok" }));
86
+ return;
87
+ }
88
+ // API routes go to yoga
89
+ if (url.startsWith("/api/")) {
90
+ yoga(req, res);
91
+ return;
92
+ }
93
+ // Try to serve a static file
94
+ if (tryServeStatic(frontendDir, url, res)) {
95
+ return;
96
+ }
97
+ // SPA fallback: serve index.html for all non-API routes
98
+ if (serveIndexFallback(frontendDir, res)) {
99
+ return;
100
+ }
101
+ // No frontend built — fall back to yoga for backwards compat
102
+ yoga(req, res);
103
+ });
104
+ // WebSocket server for graphql-ws subscriptions
105
+ const wss = new WebSocketServer({ server, path: "/api/graphql" });
106
+ const wsCleanup = useServer({
107
+ schema,
108
+ execute: execute,
109
+ subscribe: subscribe,
110
+ }, wss);
111
+ return {
112
+ server,
113
+ start() {
114
+ return new Promise((resolve, reject) => {
115
+ server.on("error", reject);
116
+ server.listen(port, () => resolve());
117
+ });
118
+ },
119
+ stop() {
120
+ return new Promise((resolve) => {
121
+ wsCleanup.dispose();
122
+ wss.close();
123
+ server.close(() => resolve());
124
+ });
125
+ },
126
+ };
127
+ }
128
+ export function startServer(core, port = DEFAULT_PORT) {
129
+ const httpServer = createHttpServer(core, { port });
130
+ return httpServer.start().then(() => httpServer);
131
+ }
@@ -0,0 +1,12 @@
1
+ import type { Entity } from "../entity/types.js";
2
+ export interface GraphEvent {
3
+ type: "entity_created" | "entity_updated" | "entity_deleted" | "graph_changed";
4
+ entityId?: string;
5
+ entity?: Entity;
6
+ }
7
+ export type Subscriber = (event: GraphEvent) => void;
8
+ export declare class PubSub {
9
+ private subscribers;
10
+ subscribe(callback: Subscriber): () => void;
11
+ publish(event: GraphEvent): void;
12
+ }
@@ -0,0 +1,19 @@
1
+ export class PubSub {
2
+ subscribers = new Set();
3
+ subscribe(callback) {
4
+ this.subscribers.add(callback);
5
+ return () => {
6
+ this.subscribers.delete(callback);
7
+ };
8
+ }
9
+ publish(event) {
10
+ for (const subscriber of this.subscribers) {
11
+ try {
12
+ subscriber(event);
13
+ }
14
+ catch {
15
+ // Non-blocking: errors in one subscriber must not affect others
16
+ }
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,2 @@
1
+ import type { ServerCore } from "./core.js";
2
+ export declare function buildSchema(core: ServerCore): import("graphql-yoga").GraphQLSchemaWithContext<import("graphql-yoga").YogaInitialContext>;