apps/web is a small module under @/services that wraps an external system behind a typed interface. The base web app ships three, all re-exported from @/services:
The oRPC API client is delivered by the Auth plugin (
auth-starter) alongside the hooks and the authenticated shell. The socket and analytics services live in the base web app.API client
The API client is the oRPC client, in@/services/api-client.service.ts. It’s typed by the API’s exported AppClient, so calls are fully checked end to end. There is no axios wrapper and no separate client class to construct.
useApiQuery / useApiMutation hooks for TanStack Query integration:
credentials: 'include' ships the better-auth session cookie with every request, and the client exposes an ORPC_PATH symbol so query keys derive straight from the procedure — covered in Calling the API.
Socket service
@/services/socket.service.ts wraps a Socket.IO client pointed at config.WS_URL. It connects lazily (autoConnect: false) over the WebSocket transport and exposes a small surface:
useEffect — listeners are wired once when the module loads and push into the TanStack Query cache. This is how useCurrentUser stays live:
Ship enforces a no-
useEffect rule (there’s a skill for it). Socket listeners that feed the cache live at module scope, like the snippet above — not inside component effects.@/services/socket-handlers.ts. On connect, it reads the cached user and joins that user’s room so server-side mutation handlers can target them:
Analytics service
@/services/analytics.service.ts wraps Mixpanel, guarded by config.MIXPANEL_API_KEY (optional — analytics is a no-op when it’s unset).
track swallows its own errors, so a failed analytics call never breaks a user flow. Swap Mixpanel for another provider by editing this one file — like every Ship service, it’s yours to change.
Config and env
Services read configuration fromconfig, a validated, typed object. Client env vars use the VITE_ prefix and are read via import.meta.env:
@/services and re-exporting it from @/services/index.ts.