Canvas Export - Server-Side Rendering via TypeScript Plugin

Documentation Review

ADRs Read:

Guides Read:

Context

QNTX needs to render canvas snapshots server-side for:

  1. Automated Bluesky posts with IPFS links
  2. Static canvas publishing
  3. Sharing canvas snapshots without client interaction

Current State:

Goal: Run the same browser TypeScript code server-side using Bun subprocess as a gRPC plugin.

Critical Learning from Attempt 3

What went wrong:

ADR-002 Requirements (MUST FOLLOW):

  1. Plugins must be in configured search paths: ~/.qntx/plugins or ./plugins
  2. Binary names must follow conventions: qntx-{name}-plugin, qntx-{name}, or {name}
  3. Binaries must be executable or .ts files
  4. Enabled in am.toml: [plugin].enabled = ["canvas-renderer"]
  5. Discovery happens via LoadPluginsFromConfig()discoverPlugin() → binary search

Three-Phase Plan


Phase 1: TypeScript Plugin Runtime with "Hello World" (~1 day)

Goal: Verify end-to-end that a TypeScript plugin can be discovered, launched via Bun, and respond to gRPC requests.

Why This Phase Matters

This establishes TypeScript as a supported plugin language. Without this working, nothing else matters.

1.1 Create TypeScript Runtime Infrastructure

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:

1.2 Create Hello World Test Plugin

Location: ./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!' });
        });
    }
}

1.3 Update Go Discovery to Support TypeScript

Files to modify:

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.

1.4 Configuration

Project am.toml:

[plugin]
enabled = ["hello-world"]
paths = ["~/.qntx/plugins", "./plugins"]  # Default, but explicit

Plugin placement options:

  1. ./plugins/hello-world/plugin.ts (preferred - project-level)
  2. ./plugins/hello-world (if package.json has "qntx-plugin": true)
  3. ~/.qntx/plugins/qntx-hello-world (user-level, requires symlink)

1.5 Verification Steps (CRITICAL - DO NOT SKIP)

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!"}

Phase 1 Success Criteria

DO NOT PROCEED TO PHASE 2 UNTIL ALL CRITERIA ARE MET


Phase 2: Canvas Renderer Plugin (~2 days)

Goal: First production TypeScript plugin that renders canvas HTML using shared browser code.

Prerequisites

2.1 Choose DOM Library

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.

2.2 Create Shared Canvas Builder

Problem: Current canvas-building code assumes browser environment.

Solution: Make it environment-agnostic by:

  1. Accepting document as parameter (no window.document access)
  2. Avoiding browser-only APIs (no addEventListener in builder)
  3. Exporting pure rendering functions

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');
    // ...
}

2.3 Handle Browser API Dependencies

Challenge: Code may use:

Approach:

  1. Identify browser API usage in canvas-building code
  2. Either:

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: () => ''
            })
        };
    }
}

2.4 Build Canvas Renderer Plugin

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 });
        });
    }
}

2.5 CSS Bundling

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');
}

2.6 Configuration

Project am.toml:

[plugin]
enabled = ["canvas-renderer"]  # Remove hello-world if no longer needed

2.7 Verification Steps

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

Phase 2 Success Criteria

DO NOT PROCEED TO PHASE 3 UNTIL ALL CRITERIA ARE MET


Phase 3: Production Integration (~1 day)

Goal: Wire canvas-renderer into QNTX export/publish flows.

Prerequisites

3.1 Add Go Export Handler

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))

3.2 Add Frontend API

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}`);
}

3.3 Update Export Button

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)');
    }
});

3.4 Verification Steps

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

Phase 3 Success Criteria


Testing Strategy

Unit Tests

TypeScript (Bun):

Go:

Integration Tests

Phase 1:

Phase 2:

Phase 3:

Manual Testing Checklist

Phase 1:

Phase 2:

Phase 3:


Dependencies

Required:

Verification:

bun --version  # Should be 1.0+
which protoc   # Should exist

Rollback Plan

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


Success Metrics

Phase 1:

Phase 2:

Phase 3:


Documentation to Create

Won't do:


Critical Reminders

  1. Read ADR-002 requirements FIRST
  2. Verify each phase before proceeding
  3. Use ./qntx-plugins/ directory for QNTX-maintained plugins
  4. Follow naming conventions: qntx-{name}-plugin, qntx-{name}, or {name}
  5. Test discovery independently before integration
  6. We're using Bun, not Node.js
  7. Manual verification at each step
  8. Don't skip success criteria

Phase 3 Status (2026-02-26)

Completed:

Known Limitations (documented in canvas.go):

Won't Do:

Out of Scope (future work):

Status: Phase 3 functional, ready for review as POC