ADRs Read:
Guides Read:
QNTX needs to render canvas snapshots server-side for:
Current State:
/api/canvas/export-dom)web/ts/components/glyph/canvas/Goal: Run the same browser TypeScript code server-side using Bun subprocess as a gRPC plugin.
What went wrong:
plugin/typescript/examples/canvas-renderer/)ADR-002 Requirements (MUST FOLLOW):
~/.qntx/plugins or ./pluginsqntx-{name}-plugin, qntx-{name}, or {name}.ts filesam.toml: [plugin].enabled = ["canvas-renderer"]LoadPluginsFromConfig() → discoverPlugin() → binary searchGoal: Verify end-to-end that a TypeScript plugin can be discovered, launched via Bun, and respond to gRPC requests.
This establishes TypeScript as a supported plugin language. Without this working, nothing else matters.
Location: plugin/typescript/runtime/
Files to create:
plugin/typescript/runtime/
├── package.json # Bun dependencies
├── tsconfig.json # TypeScript config
├── main.ts # Entry point - starts gRPC server
└── plugin-service.ts # Implements DomainPluginService
Dependencies (package.json):
{
"name": "@qntx/typescript-runtime",
"type": "module",
"dependencies": {
"@grpc/grpc-js": "^1.10.0",
"@grpc/proto-loader": "^0.7.10"
}
}
Key functionality:
--plugin-path CLI arg)--grpc-port or auto-allocate)GRPC_ADDRESS=host:port to stdout for Go discoveryLocation: ./qntx-plugins/hello-world/plugin.ts
Why ./qntx-plugins/ and not plugin/typescript/examples/? Because ADR-002 specifies ./qntx-plugins as a search path for QNTX-maintained plugins.
File structure:
./plugins/hello-world/
├── plugin.ts # Main plugin file
└── package.json # Mark as QNTX plugin: {"qntx-plugin": true}
plugin.ts minimal implementation:
export default {
name: 'hello-world',
version: '1.0.0',
async init(config: any) {
console.log('[HelloWorld] Initialized');
return { success: true };
},
registerHTTP(mux: any) {
mux.handle('GET', '/hello', (req: any, res: any) => {
res.json({ message: 'Hello from TypeScript!' });
});
}
}
Files to modify:
plugin/grpc/discovery.go - Already has TypeScript detection (line 336-338)plugin/grpc/loader.go - Already has launchPlugin that wraps Bun (line 314-436)Verify existing code:
// discovery.go line 336-338
isTypeScriptPlugin := strings.HasSuffix(binary, ".ts") || isPackageJSONPlugin(binary)
This should already work! But we need to verify the search finds it.
Project am.toml:
[plugin]
enabled = ["hello-world"]
paths = ["~/.qntx/plugins", "./plugins"] # Default, but explicit
Plugin placement options:
./plugins/hello-world/plugin.ts (preferred - project-level)./plugins/hello-world (if package.json has "qntx-plugin": true)~/.qntx/plugins/qntx-hello-world (user-level, requires symlink)Step 1: Manual runtime test
cd plugin/typescript/runtime
bun install
bun run main.ts --plugin-path ../../plugins/hello-world/plugin.ts --grpc-port 50051
Expected output:
GRPC_ADDRESS=127.0.0.1:50051
[HelloWorld] Runtime started
Step 2: gRPC connectivity test
# In another terminal
grpcurl -plaintext localhost:50051 qntx.plugin.DomainPluginService/Metadata
Expected: Returns plugin metadata JSON
Step 3: Discovery test
# Add to am.toml: enabled = ["hello-world"]
# Start server
make dev
Expected in logs:
plugin-loader Searching for 'hello-world' plugin binary in 2 paths
plugin-loader Will load 'hello-world' plugin from binary: ./plugins/hello-world/plugin.ts
plugin-loader Launching TypeScript plugin via Bun runtime
hello-world GRPC_ADDRESS=127.0.0.1:38700
plugin-loader Plugin 'hello-world' v1.0.0 loaded and ready
Step 4: HTTP handler test
curl http://localhost:8772/api/hello-world/hello
Expected: {"message": "Hello from TypeScript!"}
./plugins/hello-world/plugin.ts/api/hello-world/hello returns JSONmake test passes (if we add tests)DO NOT PROCEED TO PHASE 2 UNTIL ALL CRITERIA ARE MET
Goal: First production TypeScript plugin that renders canvas HTML using shared browser code.
Options:
Decision: Start with happy-dom, fallback to jsdom if issues.
Rationale: happy-dom is lighter and Bun already uses it internally. jsdom is more mature but heavier.
Problem: Current canvas-building code assumes browser environment.
Solution: Make it environment-agnostic by:
document as parameter (no window.document access)addEventListener in builder)Files to create/modify:
web/ts/components/glyph/canvas/
├── canvas-workspace-builder.ts # Modify to accept document param
└── canvas-builder-shared.ts # New: environment-agnostic wrapper
Key change:
// Before (browser-only)
export function buildCanvasWorkspace(canvasId: string, glyphs: Glyph[]): HTMLElement {
const workspace = document.createElement('div'); // ❌ Assumes global
// ...
}
// After (environment-agnostic)
export function buildCanvasWorkspace(
canvasId: string,
glyphs: Glyph[],
document: Document // ✅ Injected
): HTMLElement {
const workspace = document.createElement('div');
// ...
}
Challenge: Code may use:
window.getComputedStyle()element.getBoundingClientRect()Approach:
getBoundingClientRect returns fixed dimensions)if (typeof window !== 'undefined')Create: plugin/typescript/runtime/browser-stubs.ts
// Minimal stubs for server-side rendering
export function setupBrowserStubs(globalThis: any) {
if (typeof globalThis.window === 'undefined') {
// Stub window.getComputedStyle
globalThis.window = {
getComputedStyle: () => ({
getPropertyValue: () => ''
})
};
}
}
Location: ./plugins/canvas-renderer/
Structure:
./plugins/canvas-renderer/
├── plugin.ts # Main plugin
├── dom-env.ts # happy-dom setup
├── css-loader.ts # Load CSS files
├── package.json # {"qntx-plugin": true}
└── README.md # Plugin documentation
plugin.ts core logic:
import { Window } from 'happy-dom';
import { buildCanvasWorkspace } from '../../../web/ts/components/glyph/canvas/canvas-workspace-builder';
export default {
name: 'canvas-renderer',
version: '1.0.0',
async init(config: any) {
console.log('[CanvasRenderer] Initialized');
return { success: true };
},
registerHTTP(mux: any) {
mux.handle('POST', '/render', async (req: any, res: any) => {
const { canvas_id, glyphs } = await req.json();
// Create server-side DOM
const window = new Window();
const document = window.document;
// Build canvas using shared code
const workspace = buildCanvasWorkspace(canvas_id, glyphs, document);
// Load CSS
const css = loadCanvasCSS();
// Build HTML
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>${css}</style>
</head>
<body>${workspace.outerHTML}</body>
</html>`;
res.json({ html });
});
}
}
Challenge: Canvas needs CSS from multiple files.
Approach:
// css-loader.ts
import { readFileSync } from 'fs';
import { join } from 'path';
const CSS_FILES = [
'web/css/core.css',
'web/css/canvas.css',
'web/css/glyph.css',
];
export function loadCanvasCSS(): string {
const root = process.cwd(); // QNTX repo root
return CSS_FILES.map(file => {
const path = join(root, file);
return readFileSync(path, 'utf-8');
}).join('\n');
}
Project am.toml:
[plugin]
enabled = ["canvas-renderer"] # Remove hello-world if no longer needed
Step 1: Plugin loads
make dev
# Check logs for "canvas-renderer v1.0.0 loaded"
Step 2: Render endpoint works
curl -X POST http://localhost:8772/api/canvas-renderer/render \
-H 'Content-Type: application/json' \
-d '{
"canvas_id": "test",
"glyphs": [{
"id": "note-1",
"symbol": "▣",
"x": 100,
"y": 100,
"width": 200,
"height": 150,
"content": "Test note"
}]
}'
Expected: Returns {"html": "<!DOCTYPE html>..."}
Step 3: HTML structure validation
# Save output to file
curl ... > test-output.html
# Verify structure
grep 'canvas-workspace' test-output.html
grep 'data-canvas-id="test"' test-output.html
grep 'Test note' test-output.html
Step 4: Visual comparison
test-output.html in browser/api/canvas-renderer/render returns HTMLmake test passesDO NOT PROCEED TO PHASE 3 UNTIL ALL CRITERIA ARE MET
Goal: Wire canvas-renderer into QNTX export/publish flows.
File: glyph/handlers/canvas_export.go
Add new method:
func (h *CanvasHandler) HandleExport(w http.ResponseWriter, r *http.Request) {
canvasID := r.URL.Query().Get("canvas_id")
if canvasID == "" {
http.Error(w, "canvas_id required", http.StatusBadRequest)
return
}
// Get glyphs from storage
glyphs, err := h.store.ListGlyphsByCanvas(r.Context(), canvasID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Get canvas-renderer plugin
manager := grpcplugin.GetDefaultPluginManager()
plugin, ok := manager.GetPlugin("canvas-renderer")
if !ok {
http.Error(w, "canvas-renderer plugin not loaded", http.StatusServiceUnavailable)
return
}
// Call plugin via gRPC
// TODO: Implement plugin.CallHTTP or similar
// Write HTML to docs/demo/index.html
// TODO: Write response
}
Route: server/routing.go
http.HandleFunc("/api/canvas/export", wrap(s.canvasHandler.HandleExport))
File: web/ts/api/canvas.ts
export async function exportCanvas(canvasId: string): Promise<void> {
const response = await apiFetch('/api/canvas/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ canvas_id: canvasId }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Export failed');
}
const result = await response.json();
log.info(SEG.GLYPH, `Canvas exported to ${result.path}`);
}
File: web/ts/components/glyph/manifestations/canvas-expanded.ts
Find the export button (currently calls exportCanvasDOM):
// Replace exportCanvasDOM with exportCanvas
import { exportCanvas } from '../../../api/canvas';
const exportBtn = new Button({
label: 'Export',
icon: '↓',
onClick: async () => {
await exportCanvas(glyph.id); // Pass canvas ID, not workspace element
log.info(SEG.GLYPH, '[Canvas] Export complete (server-side)');
}
});
Step 1: End-to-end export flow
make demo
# 1. Create a subcanvas
# 2. Double-click to expand fullscreen
# 3. Add some glyphs (note, code, etc.)
# 4. Click Export button
# 5. Check docs/demo/index.html exists
Step 2: HTML output validation
# Open docs/demo/index.html in browser
# Verify:
# - Canvas renders correctly
# - All glyphs visible
# - CSS applied
# - No console errors
Step 3: Compare client vs server rendering
make test passesmake demo works end-to-endTypeScript (Bun):
plugin/typescript/runtime/*.test.ts - Runtime gRPC service./plugins/canvas-renderer/*.test.ts - Canvas rendering logicGo:
plugin/grpc/loader_test.go - Add TypeScript plugin discovery testsglyph/handlers/canvas_export_test.go - Export handler testsPhase 1:
Phase 2:
Phase 3:
Phase 1:
Phase 2:
Phase 3:
Required:
curl -fsSL https://bun.sh/install | bashbun add happy-dom (in plugin directory)Verification:
bun --version # Should be 1.0+
which protoc # Should exist
If any phase fails and cannot be fixed quickly:
Phase 1 failure: TypeScript plugin infrastructure doesn't work
Phase 2 failure: Canvas rendering doesn't work
Phase 3 failure: Integration breaks
Phase 1:
Phase 2:
Phase 3:
plugin/typescript/README.md - TypeScript runtime overviewdocs/development/ts-plugin.md - Add actual implementation notesWon't do:
./qntx-plugins/canvas-renderer/README.md - Not needed for POCCANVAS-EXPORT-COMPLETE.md - Phase 3 status section in this file is sufficient./qntx-plugins/ directory for QNTX-maintained plugins ✅qntx-{name}-plugin, qntx-{name}, or {name} ✅Completed:
HandleExportStatic in canvas.go)exportCanvasStatic in canvas.ts)make demo)Known Limitations (documented in canvas.go):
Won't Do:
Out of Scope (future work):
Status: Phase 3 functional, ready for review as POC