Skip to main content
A service in 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:
import { analyticsService, apiClient, socketService } 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.
import { createORPCClient } from '@orpc/client';
import { OpenAPILink } from '@orpc/openapi-client/fetch';
import type { AppClient } from 'api';
import { contract } from 'api/contract';

import config from 'config';

const link = new OpenAPILink(contract, {
  url: config.API_URL,
  fetch: (input, init) => fetch(input, { ...init, credentials: 'include' }),
});

export const apiClient = createORPCClient<AppClient>(link);
Call it directly, or through the useApiQuery / useApiMutation hooks for TanStack Query integration:
import { apiClient } from '@/services/api-client.service';

const user = await apiClient.users.getCurrent();
const page = await apiClient.users.list({ page: 1, perPage: 20 });
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:
import * as socketService from '@/services/socket.service';

socketService.connect();          // open the connection
socketService.emit('subscribe', 'user-<id>');
socketService.on('user:updated', (user) => { /* ... */ });
socketService.off('user:updated', handler);
socketService.disconnect();
Subscribe at module scope, not in a useEffect — listeners are wired once when the module loads and push into the TanStack Query cache. This is how useCurrentUser stays live:
import { apiClient } from '@/services/api-client.service';
import * as socketService from '@/services/socket.service';
import queryClient from '@/query-client';
import { queryKey } from './use-api.hook';

import type { User } from '@/types';

const currentUserKey = queryKey(apiClient.users.getCurrent);

socketService.on('user:updated', (user: User) => {
  queryClient.setQueryData<User>(currentUserKey, user);
});
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.
Connection-time subscriptions live in @/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:
import { currentUserKey } from '@/hooks';
import * as socketService from '@/services/socket.service';
import queryClient from '@/query-client';

import type { User } from '@/types';

socketService.on('connect', () => {
  const user = queryClient.getQueryData<User | null>(currentUserKey);
  if (user) socketService.emit('subscribe', `user-${user._id}`);
});

Analytics service

@/services/analytics.service.ts wraps Mixpanel, guarded by config.MIXPANEL_API_KEY (optional — analytics is a no-op when it’s unset).
import { analyticsService } from '@/services';

analyticsService.init();                 // once, on app start
analyticsService.setUser(currentUser);   // identify after sign-in
analyticsService.track('Project created', { name });
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 from config, a validated, typed object. Client env vars use the VITE_ prefix and are read via import.meta.env:
const schema = z.object({
  API_URL: z.string(),
  WS_URL: z.string(),
  WEB_URL: z.string(),
  MIXPANEL_API_KEY: z.string().optional(),
});

const processEnv = {
  API_URL: import.meta.env.VITE_API_URL,
  WS_URL: import.meta.env.VITE_WS_URL,
  WEB_URL: import.meta.env.VITE_WEB_URL,
  MIXPANEL_API_KEY: import.meta.env.VITE_MIXPANEL_API_KEY,
};
Add a new external integration by dropping another module in @/services and re-exporting it from @/services/index.ts.