Skip to main content

Architecture Overview

System Architecture

Inkweld is a monorepo application with distinct frontend and backend services:

Frontend

Technology Stack

  • Framework: Angular with standalone components
  • State Management: Service-based with RxJS
  • Offline Storage: IndexedDB via y-indexeddb
  • Real-Time: Yjs + y-websocket provider
  • Editor: ProseMirror with y-prosemirror binding
  • Build: Angular CLI + Vite
  • Testing: Vitest (unit) + Playwright (e2e)

Modern Angular Patterns

// Dependency injection with inject()
export class MyComponent {
private projectService = inject(ProjectService);
private router = inject(Router);
}

// Modern control flow
@if (project) {
<app-editor [project]="project" />
} @else {
<app-loading />
}

@for (item of items; track item.id) {
<app-item [data]="item" />
}

Key Services

  • ProjectStateService - Central project state management
  • UnifiedProjectService - Hybrid online/offline operations
  • DocumentService - Yjs document lifecycle
  • WorldbuildingService - Template/schema system
  • AuthService - Authentication and session management

Editor Schema

The ProseMirror schema is assembled in frontend/src/app/components/element-ref/extended-schema.ts. It composes three sources:

  1. ngx-editor's base nodes and marks (@bobbyquantum/ngx-editor/schema)
  2. prosemirror-tables' tableNodes() output
  3. Inkweld's own extensions from @inkweld/prosemirror/schema — the elementRef node plus the comment, autoReview, and secure link marks

new Schema(...) is constructed in the frontend, never inside the shared package. The shared package returns specs only; building the Schema there would pull a second copy of prosemirror-model into the bundle and break the class-identity checks in y-prosemirror and EditorView — typing would silently stop working. prosemirror-tables is pinned in resolutions alongside the other ProseMirror packages for the same reason.

Tables

Table support is layered on prosemirror-tables rather than on ngx-editor's menu, because Inkweld replaced that menu with its own Material toolbar (editor-toolbar.component.ts). Only the schema and the editing plugins come from the library:

  • SchematableNodes() with cellContent: 'paragraph+'. Cells are restricted to paragraphs deliberately: allowing arbitrary blocks would permit nested tables and headings that no export format renders sensibly. An extra align cell attribute carries GFM column alignment.
  • PluginscolumnResizing, tableEditing, and a Tab / Shift-Tab keymap, appended to the editor's plugin list in document.service.ts. columnResizing must be registered before tableEditing.
  • Wire format — table node names are block-level in packages/inkweld-prosemirror/src/xml/tags.ts, so an empty cell serializes as <table_cell></table_cell> rather than collapsing to a self-closing tag and desynchronising the row.
  • MarkdownmarkdownToXml parses GFM tables and xmlToMarkdown emits them, so tables round-trip through the MCP tools and markdown export.
  • Publish — the HTML, EPUB, and Typst/PDF generators each render tables. Tables are not yet part of the user-configurable publish-styles system and currently get fixed built-in styling.

Backend (Bun + Hono)

Technology Stack

  • Runtime: Bun (JavaScript runtime built for speed)
  • Framework: Hono (lightweight web framework)
  • Database: SQLite or D1 via Drizzle ORM
  • Document Storage: LevelDB (per-project instances)
  • Real-Time: Native WebSocket support
  • Testing: Bun's built-in test runner

API Architecture

// Hono route example
app.get('/api/projects', authMiddleware, async (c) => {
const user = c.get('user');
const projects = await projectService.findByUser(user.id);
return c.json(projects);
});

// WebSocket upgrade
app.get('/ws/:projectId', async (c) => {
const projectId = c.req.param('projectId');
return wsHandler.upgrade(c.req.raw, projectId);
});

Key Modules

  • auth/ - Session-based authentication
  • routes/ - HTTP endpoints
  • services/ - Business logic layer
  • db/ - Drizzle ORM schema and database setup
  • durable-objects/ - (Cloudflare Workers deployment only)

Real-Time Collaboration (Yjs)

CRDT Technology

Yjs uses Conflict-free Replicated Data Types (CRDTs) to enable:

  • Concurrent editing without conflicts
  • Eventual consistency across all clients
  • Offline support with automatic merging
  • Fine-grained updates (character-level)

Data Flow

Per-Project Storage

Each project gets its own LevelDB instance:

Benefits:

  • Isolation - Projects don't interfere
  • Scalability - Independent read/write operations
  • Cleanup - Easy to delete project data
  • Connection pooling - Automatic idle connection management

Database Schema

Drizzle ORM Schema

Document Storage Architecture

Authentication & Security

Session-Based Auth

  • httpOnly cookies for CSRF protection
  • Session store backed by Drizzle
  • No JWT tokens (intentional design choice)
  • Optional GitHub OAuth (configurable)

Security Measures

  • CORS configuration via ALLOWED_ORIGINS
  • CSRF protection on state-changing requests
  • Content Security Policy headers
  • Rate limiting (configurable)
  • User approval workflow (optional)

Deployment Targets

Bun (Primary)

bun run dev # Development
bun run build # Production build
bun run start # Production server

Node.js (Compatible)

bun run build:node # Transpile for Node
node dist/node-runner.js

Cloudflare Workers (Experimental)

bun run build:worker
npx wrangler deploy

Requires:

  • Durable Objects for WebSocket persistence
  • D1 database binding
  • R2 for file storage (optional)
docker build -t inkweld .
docker run -p 8333:8333 -v inkweld_data:/data inkweld

Benefits:

  • Single self-contained image with frontend embedded (~340MB)
  • Automatic migrations on startup
  • Volume mounting for persistence
  • Health check endpoint

Build Pipeline

Development

npm run dev # Runs both frontend and backend

Powered by:

  • Concurrently to run multiple processes
  • Angular CLI dev server (port 4200)
  • Bun runtime (port 8333)

Production

# Frontend
cd frontend
bun run build
bun run compress # Optional

# Backend
cd backend
bun run build

Output:

  • frontend/dist/ - Angular production bundle
  • backend/dist/ - Bun-optimized backend code

Docker Build

Multi-stage Dockerfile:

  1. Frontend build stage (Node.js)
  2. Backend build stage (Bun)
  3. Runtime stage (minimal Bun image)
    • Copies frontend dist to static assets
    • Includes migrations for auto-run
    • Non-root user for security

Testing Strategy

Frontend Tests

npm test # Vitest unit tests
npm run e2e # Playwright e2e tests
  • Unit tests with @ngneat/spectator
  • E2E tests with fixtures (authenticatedPage, etc.)
  • Mock API handlers in e2e/mock-api/
  • Screenshot tests for visual regression

Backend Tests

bun test
  • Unit tests for services and utilities
  • Integration tests for API endpoints
  • Supertest for HTTP assertions
  • In-memory SQLite for test isolation

API Documentation

OpenAPI Specification

Generated from code annotations:

cd backend
bun run generate:openapi

Output: backend/openapi.json

Client Generation

Auto-generate TypeScript client for frontend:

cd backend
bun run generate:angular-client

Output: frontend/src/api-client/

Never edit generated files manually.

Code Quality

Linting

  • ESLint with TypeScript support
  • Prettier for formatting
  • Shared config across frontend/backend

Pre-commit Hooks

Consider adding:

  • Lint-staged for fast checks
  • Husky for Git hooks
  • Prettier format check

CI/CD

GitHub Actions workflow:

  1. Lint all code
  2. Test frontend and backend
  3. Build Docker image
  4. Publish to GHCR (on main branch)

Performance Considerations

Frontend

  • Lazy loading for routes
  • OnPush change detection where appropriate
  • Virtual scrolling for long lists (consider)
  • Service Worker for offline support

Backend

  • LevelDB connection pooling with automatic cleanup
  • Database indexing on foreign keys
  • Pagination for large result sets
  • WebSocket connection limits (configurable)

Real-Time

  • Debounced updates for UI refresh
  • Incremental sync (only changed content)
  • Efficient CRDT merging via Yjs
  • Binary encoding over WebSocket

Development Workflow

Workspace Structure

NPM Scripts (Root)

bun install # Install all dependencies
npm run dev # Start dev servers
npm test # Run all tests
npm run lint # Lint all code

Git Workflow

  1. Create feature branch
  2. Make changes + add tests
  3. Run npm test and npm run lint
  4. Open PR
  5. CI validates
  6. Merge to main

Extensibility

Custom Worldbuilding Templates

Worldbuilding element schemas (templates) are edited in-app through the unified interactive schema editor: the WorldbuildingEditorComponent renders a live preview of the schema (previewSchema + editMode inputs) and the owning TemplateEditorPageComponent applies the resulting SchemaEditEvents to its own schema state, autosaving via the schemaChange output.

The editor has two responsive layouts — a sidenav with sections on wide screens and a stacked accordion on narrow ones. In edit mode Schema Details is the top tab/section (name, icon, description), followed by the schema's tabs and the fixed Identity, Relationships, Media and Styling sections. Within the preview you can:

  • add/remove/reorder tabs and fields,
  • edit each field's config inline (label, type, placeholder, options, …),
  • rename a tab and pick its icon from a curated Material-icon picker that covers every built-in element-type icon (so existing schemas' icons are always available),
  • edit the schema name/icon/description under Schema Details,
  • set the default appearance/image that new elements of this type get under Styling.

Changes save automatically; schema-design snapshots can be created and restored from the snapshot button.

interface ElementTypeSchema {
id: string; // nanoid, used for all lookups
name: string;
icon: string; // Material icon name
description: string;
version: number; // bumped on each save, used for migrations
tabs: TabSchema[];
defaultValues?: Record<string, unknown>;
defaultAppearance?: ElementAppearance; // menu/content backgrounds
defaultImage?: string; // media:// reference or URL
}

interface TabSchema {
key: string;
label: string;
icon?: string;
order?: number;
fields: FieldSchema[];
}

interface FieldSchema {
key: string;
label: string;
type:
| 'text'
| 'textarea'
| 'number'
| 'date'
| 'select'
| 'multiselect'
| 'array'
| 'checkbox';
placeholder?: string;
description?: string;
defaultValue?: unknown;
options?: string[] | { value: string; label: string }[];
validation?: FieldValidation;
layout?: { span?: number; order?: number };
}

Plugin System (Future)

Consider:

  • Custom document types
  • Export format plugins
  • Theme customization
  • Integration hooks (Discord, Slack)

Next Steps