Skip to content

Frontend Error Architecture

Frontend Error Architecture

Back to README | Error Reporting

PR #1748 introduced a layered, secure error-passing architecture that covers two distinct scenarios: server-initiated errors surfaced during redirects, and Angular SPA-internal navigation errors. Errors that are explicitly reported (via LogService.reportError() or the frontend /api/log/frontend-error endpoint) are persisted to a shared D1 audit table; the flash-token and router-state flows themselves do not automatically write to D1.


Table of Contents

  1. Architecture Overview
  2. Flow 1: KV Flash Store (server → client)
  3. Flow 2: Angular Router State (SPA-internal)
  4. Flash API Endpoint
  5. Frontend Error Log Endpoint
  6. D1 Audit Log — error_events Table
  7. Angular Services
  8. Security Properties
  9. Operational Guide

Architecture Overview

graph TB
    subgraph Worker["Cloudflare Worker (Hono)"]
        SetFlash["setFlash(kv, msg, type)"]
        GetFlashRoute["GET /api/flash/:token"]
        LogRoute["POST /api/log/frontend-error"]
        LogErrorToD1["logErrorToD1(db, event)"]
        ErrorEvents[("D1 error_events")]
    end

    subgraph Angular["Angular SPA"]
        FlashSvc["FlashService"]
        GlobalEH["GlobalErrorHandler"]
        NavErrSvc["NavigationErrorService"]
        Banner["UrlErrorBannerComponent"]
    end

    Worker -->|"redirect to /sign-in?flash=<token>"| Browser
    Browser -->|"GET /api/flash/:token"| GetFlashRoute
    GetFlashRoute --> FlashSvc
    FlashSvc --> Banner

    GlobalEH -->|"POST /api/log/frontend-error"| LogRoute
    LogRoute --> LogErrorToD1
    LogErrorToD1 --> ErrorEvents

    NavErrSvc -->|"router.getCurrentNavigation()"| Banner
    LogRoute -->|"source = 'frontend' (hard-coded)"| ErrorEvents

The architecture has two independent runtime flows and one shared write path:

FlowTriggerTransportConsumer
KV FlashServer redirects with ?flash=<token>GET /api/flash/:tokenFlashService.consume()
Router StateAngular router navigation errorsIn-process signalNavigationErrorService
D1 AuditUnhandled frontend/worker errorsPOST /api/log/frontend-errorOffline analysis

Flow 1: KV Flash Store (server → client)

Sequence

sequenceDiagram
    participant W as Worker
    participant KV as FLASH_STORE KV
    participant B as Browser
    participant A as Angular FlashService

    W->>KV: kv.put("flash:<uuid>", payload, { expirationTtl: 60 })
    W->>B: HTTP 302 → /sign-in?flash=<uuid>
    B->>A: app initializer runs readFromUrl()
    A->>W: GET /api/flash/<uuid>
    W->>KV: kv.get("flash:<uuid>")
    KV-->>W: { message, type, createdAt }
    W->>KV: kv.delete("flash:<uuid>") via waitUntil()
    W-->>A: 200 { message, type, createdAt }
    A->>B: currentFlash.set(payload)
    B->>B: UrlErrorBannerComponent renders banner
    B->>B: history.replaceState() removes ?flash= from URL

KV Key Format

flash:<crypto.randomUUID()>

Example stored value (JSON-encoded):

{
"message": "You must be signed in to access this page",
"type": "warn",
"createdAt": "2026-05-20T14:32:01.123Z"
}

setFlashworker/lib/flash.ts

export async function setFlash(
kv: KVNamespace,
message: string,
type: FlashType = 'info',
ttlSeconds = 60,
): Promise<string> {
const token = crypto.randomUUID();
const payload: FlashMessage = {
message,
type,
createdAt: new Date().toISOString(),
};
await kv.put(`flash:${token}`, JSON.stringify(payload), { expirationTtl: ttlSeconds });
return token;
}

getFlashworker/lib/flash.ts

export async function getFlash(
kv: KVNamespace,
token: string,
executionCtx?: ExecutionContext,
): Promise<FlashMessage | null> {
const key = `flash:${token}`;
const raw = await kv.get(key, 'text');
if (!raw) return null;
const deletePromise = kv.delete(key).catch((err) => {
console.warn('[flash] Failed to delete consumed flash key:', err instanceof Error ? err.message : String(err));
});
if (executionCtx) {
executionCtx.waitUntil(deletePromise);
}
try {
return JSON.parse(raw) as FlashMessage;
} catch {
return null;
}
}

waitUntil pattern: The delete is registered with the Worker runtime via executionCtx.waitUntil(). This means the KV delete completes even after the HTTP response has been flushed to the client, minimising the race window where a second concurrent request with the same token could read the message twice. The 60-second server-side TTL is the final safety net.


Flow 2: Angular Router State (SPA-internal)

NavigationErrorService provides a navigateWithError(commands, code, extras) helper that attaches a structured AppError to the Angular Router navigation’s extras.state. Unlike Flow 1, no network request is involved — the error travels entirely inside the Router state and is read by UrlErrorBannerComponent via the currentError() signal.

This flow handles cases such as:

  • An auth guard redirecting to /sign-in (calls NavigationErrorService.navigateWithError(['/sign-in'], 'TOKEN_EXPIRED', ...))
  • Route resolver failures
  • Lazy-loaded chunk load errors

Flash API Endpoint

GET /api/flash/{token}worker/routes/flash.routes.ts

PropertyValue
MethodGET
Path/api/flash/{token}
Auth requiredNo — publicly accessible pre-auth
Rate limitingGlobal pre-auth tier (do not add route-level; would double-count)
TagMeta

Request

The token path parameter is Zod-validated as a UUID:

request: {
params: z.object({
token: z.string().uuid(),
}),
},

Responses

StatusConditionBody
200Token found and not expired{ message, type, createdAt }
404Unknown, consumed, or expired token{ success: false, error: string }
503FLASH_STORE KV binding absent{ success: false, error: string }

Response Schema

const FlashMessageSchema = z.object({
message: z.string(),
type: z.enum(['info', 'warn', 'error', 'success']),
createdAt: z.string(),
});

Handler

flashRoutes.openapi(getFlashRoute, async (c) => {
if (!c.env.FLASH_STORE) {
return c.json({ success: false, error: 'Flash store unavailable' }, 503);
}
const { token } = c.req.valid('param');
const flash = await getFlash(c.env.FLASH_STORE, token, c.executionCtx);
if (!flash) {
return c.json({ success: false, error: 'Flash message not found or expired' }, 404);
}
return c.json(flash, 200);
});

Frontend Error Log Endpoint

POST /api/log/frontend-errorworker/routes/log.routes.ts

PropertyValue
MethodPOST
Path/api/log/frontend-error
Auth requiredNo — errors logged regardless of auth state
Rate limitingGlobal pre-auth tier + bodySizeMiddleware()
Response204 No Content

Request Body Schema

const FrontendErrorBodySchema = z.object({
/** Human-readable error message (required). */
message: z.string().min(1).max(2048),
/** Stack trace from Error.stack (optional). */
stack: z.string().max(16384).optional(),
/** Free-form JSON context (route, component name, etc.). */
context: z.string().max(4096).optional(),
/** document.location.href at the time of the error. */
url: z.string().max(2048).optional(),
/** navigator.userAgent */
userAgent: z.string().max(512).optional(),
/** Frontend session ID (from auth state). */
sessionId: z.string().max(256).optional(),
});

Handler (key properties)

logRoutes.openapi(logFrontendErrorRoute, async (c) => {
if (!c.env.DB) {
return c.json({ success: false, error: 'Database unavailable' }, 503);
}
const body = c.req.valid('json');
// Source hard-coded server-side — clients cannot spoof it.
c.executionCtx.waitUntil(
logErrorToD1(c.env.DB, {
source: 'frontend',
message: body.message,
stack: body.stack,
context: parsedContext,
url: body.url,
userAgent: body.userAgent,
sessionId: body.sessionId,
}),
);
return c.body(null, 204);
});

Non-blocking design: The D1 insert is fired via executionCtx.waitUntil() so the 204 response is returned to Angular immediately. logErrorToD1 never throws — it catches all errors internally and logs a console warning, ensuring the error reporter never disrupts the page.

Source spoofing prevention: The source field is hard-coded to 'frontend' server-side and is not part of FrontendErrorBodySchema. Clients cannot inject a different source value.


D1 Audit Log — error_events Table

Migration: migrations/0012_error_events.sql

Schema

CREATE TABLE IF NOT EXISTS "error_events" (
"id" TEXT NOT NULL,
"source" TEXT NOT NULL DEFAULT 'worker',
"severity" TEXT,
"message" TEXT NOT NULL,
"stack" TEXT,
"context" TEXT,
"url" TEXT,
"user_agent" TEXT,
"session_id" TEXT,
"created_at" TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
PRIMARY KEY ("id"),
CHECK ("source" IN ('worker', 'frontend')),
CHECK ("severity" IN ('info', 'warning', 'error', 'fatal') OR "severity" IS NULL)
);

Indexes

CREATE INDEX IF NOT EXISTS "idx_error_events_created_at" ON "error_events"("created_at");
CREATE INDEX IF NOT EXISTS "idx_error_events_source" ON "error_events"("source");
CREATE INDEX IF NOT EXISTS "idx_error_events_severity" ON "error_events"("severity");

Column Reference

ColumnTypeNullableDescription
idTEXTNoUUID v4, generated by Worker via crypto.randomUUID()
sourceTEXTNo'worker' or 'frontend' — hard-coded by server
severityTEXTYes'info' | 'warning' | 'error' | 'fatal'
messageTEXTNoHuman-readable error message
stackTEXTYesStack trace from Error.stack
contextTEXTYesJSON-encoded context object or raw string
urlTEXTYesRequest URL (worker) or document.location.href (frontend)
user_agentTEXTYesUser-Agent header or navigator.userAgent
session_idTEXTYesAuth session ID if available
created_atTEXTNoISO-8601 timestamp, defaulted by D1

logErrorToD1worker/utils/error-logger.ts

export async function logErrorToD1(db: D1Database, event: ErrorEvent): Promise<void> {
try {
const contextStr = event.context != null
? (typeof event.context === 'string' ? event.context : JSON.stringify(event.context))
: null;
await db.prepare(
`INSERT INTO error_events
(id, source, message, stack, context, url, user_agent, session_id, severity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
crypto.randomUUID(),
event.source ?? 'worker',
event.message,
event.stack ?? null,
contextStr,
event.url ?? null,
event.userAgent ?? null,
event.sessionId ?? null,
event.severity ?? null,
)
.run();
} catch (err) {
console.warn(
'[error-logger] Failed to persist error event to D1:',
err instanceof Error ? err.message : String(err),
);
}
}

Sample Queries

Query recent frontend errors:

SELECT id, message, url, session_id, created_at
FROM error_events
WHERE source = 'frontend'
ORDER BY created_at DESC
LIMIT 50;

Query fatal worker errors in the last hour:

SELECT id, message, stack, url, created_at
FROM error_events
WHERE source = 'worker'
AND severity = 'fatal'
AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')
ORDER BY created_at DESC;

Run via Wrangler:

Terminal window
deno task wrangler d1 execute bloqr-backend-app-db \
--command "SELECT source, severity, COUNT(*) as cnt FROM error_events GROUP BY source, severity"

Angular Services

FlashServicefrontend/src/app/services/flash.service.ts

@Injectable({ providedIn: 'root' })
export class FlashService {
readonly currentFlash = signal<FlashMessage | null>(null);
/** Direct signal write — no network call. */
set(message: string, type: FlashType): void { ... }
/** Clears the current flash. */
clear(): void { ... }
/** Exchanges a KV token for the message via GET /api/flash/:token. */
consume(token: string): void { ... }
/** Reads ?flash=<token> from URL, calls consume(), then strips param. */
readFromUrl(search?: string): void { ... }
}

readFromUrl() is called during provideAppInitializer before first render. After consuming the token it calls history.replaceState() to remove the ?flash= parameter from the address bar — preventing it from leaking via the Referer header on subsequent navigations or appearing in browser history.

Component: UrlErrorBannerComponent

Reads FlashService.currentFlash() as a signal and renders the banner when the value is non-null. Calls FlashService.clear() on dismiss.


Security Properties

PropertyImplementation
Token entropycrypto.randomUUID() — 122 bits of entropy
Server-side TTL60 seconds via expirationTtl — expired tokens return 404 regardless of delete race
Consume-once semanticskv.delete() via waitUntil() after first read
No auth requiredFlash and log endpoints are publicly accessible (pre-auth scenarios)
Rate limitingGlobal pre-auth tier in hono-app.ts; no additional route-level middleware to avoid double-counting
Source spoofing preventionsource field hard-coded to 'frontend' server-side; absent from FrontendErrorBodySchema
Body size capbodySizeMiddleware() on POST /api/log/frontend-error prevents large stack trace flooding
No PII in flash messagesOnly UI status strings; no user data in KV
Parameterized queriesD1 inserts use .prepare().bind() — no SQL injection surface
URL cleanuphistory.replaceState() removes ?flash= to prevent Referer leakage

Operational Guide

Applying the Migration

Terminal window
# Local (Miniflare / wrangler dev)
deno task wrangler d1 migrations apply bloqr-backend-app-db --local
# Production
deno task wrangler d1 migrations apply bloqr-backend-app-db

Configuring FLASH_STORE

The KV binding must be declared in wrangler.toml:

[[kv_namespaces]]
binding = "FLASH_STORE"
id = "<your-kv-namespace-id>"

The handler returns 503 if FLASH_STORE is unbound — no runtime panic.

Verifying the Flash Flow

Terminal window
# 1. Set a flash message (replace with a real Worker trigger, or call setFlash directly in tests)
# 2. Fetch the token:
curl https://api.bloqr.dev/api/flash/<uuid>
# → { "message": "...", "type": "warn", "createdAt": "..." }
# 3. Fetch the same token again:
curl https://api.bloqr.dev/api/flash/<uuid>
# → 404 { "success": false, "error": "Flash message not found or expired" }

Monitoring error_events

Use the Wrangler D1 console or the admin endpoint POST /api/admin/storage/query (admin key required) to run read-only SQL against the live D1 database.

Note: In production, /api/admin/storage/query is additionally gated by Cloudflare Access. A request supplying only X-Admin-Key will be rejected unless it also presents a valid CF Access service-token (CF-Access-Client-Id / CF-Access-Client-Secret headers) or a valid CF Access JWT cookie.

Terminal window
curl -X POST https://api.bloqr.dev/api/admin/storage/query \
-H "X-Admin-Key: $ADMIN_KEY" \
-H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" \
-H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" \
-H "Content-Type: application/json" \
-d '{"query": "SELECT source, COUNT(*) as cnt FROM error_events GROUP BY source"}'