ArchitectureHono RPC Client — Typed API calls between Angular and Worker

Hono RPC Client — Typed API calls between Angular and Worker

The Bloqr Compiler uses hono/client to provide end-to-end type-safe HTTP calls from the Angular frontend to the Cloudflare Worker.

Hono RPC Client — Typed API calls between Angular and Worker

Overview

The Bloqr Compiler uses hono/client to provide end-to-end type-safe HTTP calls from the Angular frontend to the Cloudflare Worker.

Two services cover the full API surface:

ServiceFileScope
ApiClientServicefrontend/src/app/services/api-client.tsPublic (unauthenticated) endpoints
AuthedApiClientServicefrontend/src/app/services/authed-api-client.service.tsAuthenticated endpoints (Bearer + Trace-ID)

Architecture

LayerFileRole
Workerworker/hono-app.tsExports AppType = typeof app
Shared TypesAppType in api-client.tsRequest/response types for all routes
Public clientfrontend/src/app/services/api-client.tsApiClientService — unauthenticated endpoints
Authed clientfrontend/src/app/services/authed-api-client.service.tsAuthedApiClientService — authenticated endpoints
ComponentsAny component that needs typed API callsInjects one or both services

Worker: Exporting AppType

The worker exports the app's type so the frontend can mirror it:

// worker/hono-app.ts
export const app = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>();

// ... route definitions ...

export type AppType = typeof app;

Angular: Public RPC Client (ApiClientService)

The ApiClientService in frontend/src/app/services/api-client.ts wraps the hc<AppType>() call with Angular's dependency injection:

import { ApiClientService } from './services/api-client';

@Component({ standalone: true, ... })
export class HealthComponent {
  private readonly apiClient = inject(ApiClientService);

  async checkHealth(): Promise<void> {
    // Fully typed request and response — no manual interface needed
    const res = await this.apiClient.client.api.health.$get();
    if (res.ok) {
      const data = await res.json();
      console.log(data.status);    // 'healthy' | 'degraded' | 'down'
      console.log(data.version);   // string
      console.log(data.timestamp); // string
    }
  }
}

OpenAPI spec endpoint

// Fetch the live OpenAPI spec document (public, no auth required)
const res = await this.apiClient.client.api['openapi.json'].$get();
const spec = await res.json();

Angular: Authenticated RPC Client (AuthedApiClientService)

The AuthedApiClientService uses the same hc<AppType>() pattern but injects a Bearer token and trace ID header before every call. Tokens are resolved via AuthFacadeService.getToken() (reads the Better Auth session cookie) — they are never stored in component state or localStorage.

Usage

import { AuthedApiClientService } from './services/authed-api-client.service';

@Component({ standalone: true, ... })
export class CompileComponent {
  private readonly rpc = inject(AuthedApiClientService);

  async runCompile(): Promise<void> {
    // Bearer token + X-Trace-ID are injected automatically
    const result = await this.rpc.compile({
      configuration: {
        name: 'My List',
        sources: [{ source: 'https://easylist.to/easylist/easylist.txt' }],
        transformations: ['RemoveComments', 'Deduplicate'],
      },
    });
    console.log(result.ruleCount); // number
  }
}

Available methods

MethodHTTPPathTier required
compile(request)POST/api/compileFree
validateRules(request)POST/api/validateFree
validateRule(request)POST/api/validate-ruleFree
listRules()GET/api/rulesFree
createRuleSet(request)POST/api/rulesFree
compileAsync(request)POST/api/compile/asyncPro

getHeaders() internals

private async getHeaders(): Promise<Record<string, string>> {
    const headers: Record<string, string> = {
        'X-Trace-ID': this.log.sessionId,
    };
    if (!this.auth.isSignedIn()) return headers;

    const token = await this.auth.getToken();
    if (!token) throw new Error('Session token unavailable — please sign in again');

    headers['Authorization'] = `Bearer ${token}`;
    return headers;
}

ZTA compliance

  • Token resolved per-call (not cached in the service).
  • Throws with a descriptive message on auth failure.
  • X-Trace-ID always set for Worker-side log correlation.
  • Never touches localStorage or component state.

When to use AuthedApiClientService vs HttpClient services

ScenarioRecommended service
Components in the browser + Angular HTTP lifecycleEither — AuthedApiClientService is simpler
SSR / service workers / outside Angular DIAuthedApiClientService (no HttpClient dep)
Streaming / WebSocket upgradesHttpClient with reportProgress: true
Batch observables / reactive pipelinesHttpClient + RxJS (CompilerService)

AppType

AppType in api-client.ts covers all routes — both public and authenticated. This single type is shared by both ApiClientService and AuthedApiClientService.

export type AppType = {
    api: {
        // Public routes
        health: { $get: () => TypedResponse<HealthResponse> };
        version: { $get: () => TypedResponse<VersionResponse> };
        'openapi.json': { $get: () => TypedResponse<Record<string, unknown>> };
        // Authenticated routes
        compile: { $post: (opts: { json: CompileRequest }) => TypedResponse<CompileResponse> };
        validate: { $post: (opts: { json: ValidateRequest }) => TypedResponse<ValidateResponse> };
        'validate-rule': { $post: (opts: { json: ValidateRuleRequest }) => TypedResponse<ValidateRuleResponse> };
        rules: {
            $get: () => TypedResponse<RulesListData>;
            $post: (opts: { json: RuleSetCreate }) => TypedResponse<{ success: boolean; ruleSet: RuleSetData }>;
        };
    };
};

AppType evolution

To replace the inline AppType with the real worker type:

// Option A: Direct cross-workspace path import (works during local dev)
import type { AppType } from '../../../../worker/hono-app';

// Option B: Published types package (recommended for production)
import type { AppType } from '@bloqr-backend/worker-types';

To share types across the monorepo without a published package, add a paths mapping to the Angular tsconfig.json:

{
  "compilerOptions": {
    "paths": {
      "@bloqr-backend/worker/*": ["../worker/*"]
    }
  }
}

Server-Timing headers

The Hono worker adds Server-Timing headers to every response (via hono/timing).

For HttpClient-based services:

const res = await this.http.post('/api/compile', body, { observe: 'response' }).toPromise();
const timing = res?.headers.get('Server-Timing');
// "auth;dur=12.3, handler;dur=245.7"

For AuthedApiClientService, use the raw ClientResponse:

const rawRes = await this.rpcClient.api.compile.$post({ json: body }, { headers });
const timing = rawRes.headers.get('Server-Timing');

Route Coverage

Public routes (ApiClientService)

MethodPathClient method
GET/api/healthclient.api.health.$get()
GET/api/versionclient.api.version.$get()
GET/api/openapi.jsonclient.api['openapi.json'].$get()

Authenticated routes (AuthedApiClientService)

MethodPathService methodTier
POST/api/compilerpc.compile(request)Free
POST/api/validaterpc.validateRules(request)Free
POST/api/validate-rulerpc.validateRule(request)Free
GET/api/rulesrpc.listRules()Free
POST/api/rulesrpc.createRuleSet(request)Free
POST/api/compile/asyncrpc.compileAsync(request)Pro

References