Files
spa/.planning/phase-1-foundation/03-vite-dev-proxy.md
T
julian 26e059fc20 feat: planning structure + task 1.2 stack rounding-out
Add .planning/ scaffolding:
- ROADMAP.md (4 phases, 8 non-negotiable design rules)
- phase-1-foundation/ README + 9 task files (1.2-1.10)
- phase-2-live-map / phase-3-dogfood-readiness / phase-4-future README placeholders

Task 1.2 — stack rounding-out:
- Tailwind 4 via @tailwindcss/vite + src/styles/globals.css
- shadcn/ui (slate, new-york) primitives in src/ui/primitives/:
  button, input, label, form, card, alert
- TanStack Router 1.169 + Query 5.100 (devtools + plugin in devDeps)
- Zustand 5, @directus/sdk 21, zod 4, react-hook-form 7 + resolvers
- Prettier 3 + eslint-config-prettier + eslint-plugin-prettier
- ESLint override disabling react-refresh/only-export-components for
  src/ui/primitives/** (intentional dual-exports in shadcn primitives)
- Path alias @/* -> ./src/* in tsconfig.json + tsconfig.app.json
  (TS 6 deprecates baseUrl; paths now resolve relative to config file).
  Pulled forward from 1.3 because shadcn add CLI needs it resolvable.
- Scripts: dev, build, preview, lint, typecheck, format, format:check,
  test (placeholder)
- App.tsx Tailwind smoke test (centred card + shadcn Button)
- README.md rewritten with stack/scripts/shadcn-add docs

All four gates green: typecheck, lint, format:check, build (222KB / 70KB gz).
2026-05-02 18:41:54 +02:00

7.0 KiB

Task 1.3 — Vite dev proxy + path aliases + tsconfig hardening

Phase: 1 — Foundation Status: Not started Depends on: 1.2 Wiki refs: docs/wiki/entities/react-spa.md §Auth pattern

Goal

Configure Vite's dev server to proxy /api, /ws-business, /ws-live, and /config.json to the appropriate local services so the SPA dev experience matches the stage/prod reverse-proxy topology. Same-origin in dev means cookies flow correctly, identical to stage/prod where Traefik does the routing. Also: add path aliases (@/) so imports stay short, and tighten tsconfig.json for stricter type safety.

After this task, pnpm dev against a locally-running Directus + (eventually) Processor lets the SPA exercise the real auth flow without CORS workarounds.

Deliverables

  • vite.config.ts updated:
    • server.proxy configured for:
      • /apihttp://localhost:8055 (Directus REST/GraphQL).
      • /ws-businessws://localhost:8055/websocket (Directus business-plane WS), with ws: true and rewrite to strip /ws-business.
      • /ws-livews://localhost:8081 (Processor live WS, once Phase 1.5 lands), with ws: true and rewrite to strip /ws-live.
      • Don't proxy /config.json — it's served from public/ by Vite directly in dev.
    • resolve.alias for @/src/.
    • server.port pinned to 5173 (Vite default; mention in README so anyone running other Vite apps avoids the conflict).
  • tsconfig.app.json updated:
    • compilerOptions.baseUrl to ., paths to { "@/*": ["src/*"] }.
    • Add noUncheckedIndexedAccess: true (matches tcp-ingestion / processor strictness).
    • Add noImplicitOverride: true.
    • verbatimModuleSyntax: true is fine if already on; otherwise leave for now.
  • README.md updated: a "local dev" section listing the assumed local services + how to override the proxy targets via env vars (next bullet).
  • Optional: env-var override for proxy targets:
    • VITE_DEV_DIRECTUS_URL (default http://localhost:8055).
    • VITE_DEV_PROCESSOR_WS_URL (default ws://localhost:8081).
    • Read in vite.config.ts via loadEnv.
    • Document in .env.dev.example (committed) and .env.dev.local (gitignored).

Specification

Why proxy in dev instead of CORS

CORS-with-credentials requires:

  1. The browser sending withCredentials: true on every request.
  2. The server responding with Access-Control-Allow-Origin: <exact-origin> (not *) and Access-Control-Allow-Credentials: true.
  3. Cookies being scoped correctly (SameSite=None; Secure for cross-site — which means HTTPS even in dev).

This works but it's three places that must agree, and SameSite=None; Secure requires localhost-with-TLS in dev (extra setup). The dev proxy collapses all of it into "everything is one origin," matching stage/prod's reverse-proxy topology. Pick the dev proxy.

Proxy config shape

// vite.config.ts
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';

export default defineConfig(({ mode }) => {
  const env = loadEnv(mode, process.cwd(), 'VITE_');
  const directusUrl = env.VITE_DEV_DIRECTUS_URL ?? 'http://localhost:8055';
  const processorWsUrl = env.VITE_DEV_PROCESSOR_WS_URL ?? 'ws://localhost:8081';

  return {
    plugins: [react(), tailwindcss()],
    resolve: {
      alias: { '@': path.resolve(__dirname, './src') },
    },
    server: {
      port: 5173,
      proxy: {
        '/api': {
          target: directusUrl,
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/api/, ''),
        },
        '/ws-business': {
          target: directusUrl,
          ws: true,
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/ws-business/, '/websocket'),
        },
        '/ws-live': {
          target: processorWsUrl,
          ws: true,
          changeOrigin: true,
          rewrite: (p) => p.replace(/^\/ws-live/, ''),
        },
      },
    },
  };
});

The path namespacing (/api, /ws-business, /ws-live) is what the SPA code calls. The proxy strips the namespace and forwards. Stage/prod's Traefik does the same routing under the public domain — same paths, same code, no env branching in the SPA.

Path alias

@/ is the convention shadcn/ui assumes (its components.json config wires @/components/..., @/lib/utils). Aligning Vite + tsconfig with it means imports look like:

import { Button } from '@/ui/primitives/button';
import { useAuthStore } from '@/auth/store';

instead of ../../../ui/primitives/button. Don't add other top-level aliases — @/ for src/ is enough.

Why noUncheckedIndexedAccess

Without it, arr[0] is typed as T even when the array might be empty. With it, arr[0] is typed T | undefined, forcing a check. Catches a real class of bugs in map-position code (where "first device" assumptions are easy to make and easy to get wrong when the array is empty during reconnect).

The processor and tcp-ingestion both have this on. Match.

Vite proxy and WebSocket behaviour

The ws: true flag on the proxy entry tells Vite (which uses http-proxy-middleware) to forward the upgrade request. Cookies attached to the upgrade carry through transparently — that's the whole point of running same-origin in dev.

One gotcha: the proxy's target for a WS route must use a ws:// or wss:// scheme, not http://. If you accidentally use http://, the upgrade fails silently and the SPA gets a connection error. Document in the README.

Acceptance criteria

  • pnpm dev starts at http://localhost:5173; the SPA loads.
  • With Directus running locally on :8055, fetch('/api/server/info') from the browser console returns Directus's server info — proxy is forwarding.
  • WebSocket smoke test: new WebSocket('ws://localhost:5173/ws-business') connects (returns code 1006 if Directus's WS endpoint is locked down without auth, but the upgrade itself succeeds — that's the test).
  • import { foo } from '@/auth/foo' resolves both at type-check time and at runtime.
  • pnpm typecheck is stricter — adding arr[0].whatever without a check should now fail.
  • No CORS errors in the browser console when calling /api/... from http://localhost:5173.

Risks / open questions

  • Directus's WS path is /websocket, not /ws. Confirmed by reading Directus 11.x source. The proxy rewrite rule reflects this.
  • Processor WS path is unsettled. processor-ws-contract uses /processor/ws as illustrative; the actual path will be set by the proxy config in stage/prod (trm/deploy). For dev we assume the Processor binds to :8081 with no path prefix, and the proxy adds the /ws-live prefix on the SPA side. Reconcile if Phase 1.5 lands with a different path convention.
  • Multiple Vite apps on one machine. Pinning to :5173 collides if another Vite app is running. Document the override (VITE_PORT=5174 pnpm dev works as a one-off; permanent change needs the config edit).

Done

(Filled in when the task lands.)