# Services Package | Section | Description | |---------|-------------| | [Overview](#overview) | Purpose and principles of Hoist's built-in singleton services | | [Architecture](#architecture) | Service hierarchy, installation, and access via XH | | [Built-in Services](#built-in-services) | Reference for all 19 built-in services by category | | [Configuration Keys Reference](#configuration-keys-reference) | Soft config keys controlling service behavior | | [User Preference Keys Reference](#user-preference-keys-reference) | Per-user preference keys used by services | | [Creating Custom Services](#creating-custom-services) | How to create and install application services | | [Common Patterns](#common-patterns) | FetchService in loads, tracking, debounced search, WebSockets | | [Common Pitfalls](#common-pitfalls) | Fetch errors, native fetch, missing loadSpec, and more | | [Related Packages](#related-packages) | Links to core, cmp, promise, and admin packages | ## Overview The `/svc/` package contains Hoist's built-in singleton services - classes that provide app-wide functionality like HTTP requests, configuration, user preferences, and activity tracking. Services are instantiated once during app initialization and accessed globally via the `XH` singleton. Services extend `HoistService` (from `/core/`) and follow these principles: - **Singleton lifecycle** - One instance per app, lives for app lifetime - **Global access** - Available as `XH.serviceName` (e.g., `XH.fetchService`) - **Async initialization** - Can perform startup work in `initAsync()` - **MobX integration** - Full support for observable state and reactions ## Architecture ``` HoistBase └── HoistService (singleton services) ├── FetchService - HTTP requests ├── ConfigService - Server configuration ├── PrefService - User preferences ├── IdentityService - Current user info ├── EnvironmentService - App environment metadata ├── TrackService - Activity tracking ├── TraceService - Distributed tracing ├── WebSocketService - Bidirectional messaging └── ... (19 built-in services total) ``` ### Service Installation Services are installed during app startup via `XH.installServicesAsync()`. Hoist's core services are installed automatically; applications add custom services in `AppModel.initAsync()`: ```typescript class AppModel extends HoistAppModel { override async initAsync(ctx: InitContext) { // Install custom services - all initialize concurrently await XH.installServicesAsync([TradeService, PortfolioService], ctx); // Chain calls for ordered initialization (when services depend on earlier ones) await XH.installServicesAsync([ReportService], ctx); // Can now use Trade/Portfolio services } } ``` ### Service Access ```typescript // Preferred: Use XH convenience methods where they exist XH.fetchJson({url: 'api/data'}); XH.getConf('featureEnabled'); XH.getPref('gridPageSize'); XH.track('User exported data'); XH.getUser().roles; XH.getEnv('appEnvironment'); // Full service access also available (for less common methods or properties) XH.configService.list; // No alias - use service directly XH.identityService.isImpersonating; // No alias - use service directly XH.prefService.pushAsync('key', v); // No alias - use service directly ``` **XH Convenience Methods:** | Method | Delegates To | |--------|--------------| | `XH.fetch()` | `fetchService.fetch()` | | `XH.fetchJson()` | `fetchService.fetchJson()` | | `XH.postJson()` | `fetchService.postJson()` | | `XH.getConf()` | `configService.get()` | | `XH.getPref()` | `prefService.get()` | | `XH.setPref()` | `prefService.set()` | | `XH.track()` | `trackService.track()` | | `XH.getEnv()` | `environmentService.get()` | | `XH.getUser()` | `identityService.user` | | `XH.getUsername()` | `identityService.username` | ## Built-in Services ### Core Data Access #### FetchService **File**: `FetchService.ts` | **Access**: `XH.fetchService` or `XH.fetch()`, `XH.fetchJson()`, etc. Managed HTTP requests with enhancements over the native Fetch API. **Key Features:** - Automatic correlation IDs for request tracking - W3C `traceparent` headers on requests to the app's own server, when tracing is enabled - Configurable timeouts (default 30 seconds) - Auto-abort of duplicate requests via `autoAbortKey` - Request/response interceptors - Rich exception handling with HTTP status, server details, and trace IDs ```typescript // Basic JSON request const users = await XH.fetchJson({url: 'api/users'}); // POST with body await XH.postJson({url: 'api/users', body: {name: 'John', role: 'admin'}}); // With timeout and abort key (cancels previous request with same key) const results = await XH.fetchJson({ url: 'api/search', params: {query: searchTerm}, timeout: 60000, autoAbortKey: 'search' }); // Pass loadSpec for consistent tracking in doLoadAsync() override async doLoadAsync(loadSpec: LoadSpec) { const data = await XH.fetchJson({url: 'api/data'}, {loadSpec}); } ``` **Configuration Options:** | Option | Type | Description | |--------|------|---------------------------------------------------------------------------------------| | `url` | string | Request URL (relative URLs appended to `XH.baseUrl`) | | `body` | any | Request body | | `params` | object | Query string parameters | | `headers` | object | Additional headers | | `timeout` | number | Timeout in ms (default 30000) | | `autoAbortKey` | string | Cancel previous requests with same key. For `fetchNdjson`, covers the whole stream | | `fetchOpts` | object | Options for the native `fetch()`. A `signal` here also aborts the request, alongside Hoist's own | | `propagateTrace` | boolean | Send a `traceparent` header when tracing is enabled. Overrides `FetchService.defaults.propagateTrace` | | `loadSpec` | LoadSpec | Metadata for tracking | | `span` | Span | Parent span for tracing. Typically supplied via a `Runner` chain (`runner.fetchJson(...)`) rather than set directly | **App-Level Defaults (`FetchService.defaults`):** FetchService exposes a `static defaults` object for correlation ID and trace header configuration. Best configured in the app's `Bootstrap` module to ensure settings are active from the very first request. | Property | Type | Default | Description | |----------|------|---------|-------------| | `autoGenCorrelationIds` | `boolean \| function` | `false` | Auto-generate correlation IDs. Set `true` or a `(opts) => boolean` function for per-request control | | `genCorrelationId` | `() => string` | 16-char random string | Custom ID generator function | | `correlationIdHeaderKey` | `string` | `'X-Correlation-ID'` | HTTP header name for correlation IDs | | `propagateTrace` | `boolean \| function` | App's own server only | Send a `traceparent` header when tracing is enabled. Set `true` for all requests, or a `(opts) => boolean` function. See [Trace headers on fetch requests](../docs/telemetry.md#trace-headers-on-fetch-requests) | ```typescript // In Bootstrap module FetchService.defaults.autoGenCorrelationIds = true; ``` #### ConfigService **File**: `ConfigService.ts` | **Access**: `XH.configService` or `XH.getConf()` Provides access to soft-configuration values from the server. Configs are defined server-side and must have `clientVisible: true` to be sent to the client. ```typescript // Get config value with optional default const timeout = XH.getConf('sessionTimeout', 30); const features = XH.getConf('enabledFeatures', []); // Check available keys (no alias - access service directly) XH.configService.list; ``` **Note:** Configs are loaded once at startup and not auto-refreshed. App refresh required for updates. #### PrefService **File**: `PrefService.ts` | **Access**: `XH.prefService` or `XH.getPref()`, `XH.setPref()` Read and write user-specific preference values persisted on the server. ```typescript // Get preference with optional default const pageSize = XH.getPref('gridPageSize', 50); // Set preference (auto-saved with debounce) XH.setPref('gridPageSize', 100); // Immediate save - no alias, access service directly await XH.prefService.pushAsync('criticalPref', value); // Distinguish an explicit user value from the server-side default if (XH.prefService.isSet('gridPageSize')) { /* user has customized this */ } // Clear the user's value, reverting to the default (real server-side unset) XH.prefService.unset('gridPageSize'); ``` Preferences are type-validated against server-defined types: `string`, `int`, `long`, `double`, `bool`, `json`. ### User & Environment #### IdentityService **File**: `IdentityService.ts` | **Access**: `XH.identityService` or `XH.getUser()` Provides authenticated user information and impersonation support. See the [Authentication concept doc](../docs/authentication.md) for full coverage of OAuth flows, identity handling, and impersonation. ```typescript // Current acting user (apparent user when impersonating) XH.getUsername(); XH.getUser().email; XH.getUser().roles; // string[] // Actual authenticated user (differs during impersonation) - no alias XH.identityService.authUsername; XH.identityService.isImpersonating; // Impersonation (requires HOIST_IMPERSONATOR role) - no alias await XH.identityService.impersonateAsync('targetUser'); await XH.identityService.endImpersonateAsync(); ``` #### EnvironmentService **File**: `EnvironmentService.ts` | **Access**: `XH.environmentService` or `XH.getEnv()` Provides app environment metadata and polls for server version changes. ```typescript // Environment info - use XH.getEnv() alias XH.getEnv('appEnvironment'); // 'Production' | 'Development' | 'Staging' | etc. // Helper methods and observable properties - access service directly XH.environmentService.isProduction(); XH.environmentService.isTest(); XH.environmentService.serverVersion; // Polled - can change without app refresh XH.environmentService.serverBuild; ``` **Version Change Handling:** When server version changes, the service can prompt users to reload or force an immediate reload based on `xhEnvPollConfig.onVersionChange` setting. ### Activity & Monitoring #### TrackService **File**: `TrackService.ts` | **Access**: `XH.trackService` or `XH.track()` Activity tracking for audit trails and analytics, viewable in the Admin Console. ```typescript // Simple tracking XH.track('User exported report'); // Detailed tracking XH.track({ message: 'Portfolio loaded', category: 'Data', data: {portfolioId: 123, recordCount: 5000}, elapsed: loadTime }); ``` **TrackOptions:** | Option | Type | Description | |--------|------|-------------| | `message` | string | What happened (required) | | `category` | string | Activity category (e.g., 'Export', 'Grid') | | `severity` | string | 'DEBUG' \| 'INFO' \| 'WARN' \| 'ERROR' | | `data` | any | Structured data about activity | | `elapsed` | number | Operation duration in ms | | `oncePerSession` | boolean | Only send first occurrence per session | ##### Server-Side Filtering: `xhActivityTrackingConfig` The client always sends all tracked entries to the server. The server then decides what to persist based on the `xhActivityTrackingConfig` soft config (editable in the Admin Console). Key properties: | Property | Default | Description | |----------|---------|-------------| | `levels` | `[{username: '*', category: '*', severity: 'INFO'}]` | Rules controlling which entries are persisted | | `maxDataLength` | `2000` | Max chars of JSON data per entry; oversized data is dropped with a warning | | `logData` | `false` | Whether to log primitive data values server-side | | `clientHealthReport` | `{intervalMins: -1}` | Config for periodic health report submissions; `-1` disables | | `maxRows` | `{default: 10000, options: [...]}` | Row limits for the Admin Console activity viewer | The `levels` array is the most important setting. Each rule specifies a `username`, `category`, and `severity` — entries matching a rule at or above its severity are persisted. The default config drops all `DEBUG` entries. To enable them: ```json { "levels": [ {"username": "*", "category": "*", "severity": "DEBUG"} ], "maxDataLength": 2000, "logData": false, "clientHealthReport": {"intervalMins": -1} } ``` Rules can also target specific users or categories: ```json { "levels": [ {"username": "jsmith", "category": "*", "severity": "DEBUG"}, {"username": "*", "category": "Data", "severity": "DEBUG"} ] } ``` Rules are evaluated in order — the first match wins. Entries that don't match any rule default to an INFO threshold, so the example above enables DEBUG+ persistence for user `jsmith` and the `Data` category while all other entries continue to require INFO or above. #### TraceService **File**: `TraceService.ts` | **Access**: `XH.traceService` > See [Telemetry & Observability](../docs/telemetry.md) for the full guide to the `Runner` chain, > tracing, metrics, and activity tracking. Client-side distributed tracing — creates spans for user actions and fetch calls, injects `traceparent` headers on requests to the app's own server, and batches completed spans for export to the Hoist server. Exceptions thrown during traced operations include a `traceId` for correlation with server-side traces. Controlled by the `xhTraceConfig` soft config. Requires hoist-core 37+. Spans are sampled at creation time using `xhTraceConfig.sampleRules` — an ordered list of tag-matching rules with glob pattern support. Child spans inherit their parent's sampling decision. The `traceparent` header propagates the sampling flag to the server. See the hoist-core tracing documentation for full sampling configuration details. Applications instrument code via the `Runner` chain on `HoistBase`: `runner(ctx?)` starts a chain, optionally seeded with a `CallContextLike` (`{span?, loadSpec?}`) — pass one through from an upstream call to continue an existing trace / load, or call with no arg to start fresh. Add a span with `.span(name)` and execute via the terminal methods — `run(fn)`, `fetch()`, `fetchJson()`, `postJson()`, etc. — which run the work inside the configured span, with optional `logInfo()` / `logDebug()` / `track()` middleware composed in. ```typescript // Start a fresh chain with a span and run an async fn. `caller` is auto-set to `this`. await this.runner().span('loadPortfolio').run(async ctx => { const positions = await this.loadPositionsAsync(ctx); this.setPositions(positions); }); // Issue a fetch under a span, no manual span management. const data = await this.runner().span('loadPortfolio').fetchJson({url: 'api/portfolio'}); // Continue an existing context (e.g. from a doLoadAsync loadSpec) and nest a child span. async doLoadAsync(loadSpec) { const data = await this.runner({loadSpec}) .span('refData') .fetchJson({url: 'api/ref'}); } // Forward a CallContext received from an upstream caller. async fetchUserAsync(ctx: CallContext) { return this.runner(ctx).span('user').fetchJson({url: 'api/user'}); } // Compose with logging - times completion via withInfo/withDebug as appropriate. await this.runner().span('loadPortfolio').logInfo('Loading portfolio').run(async ctx => { ... }); // Configure tags / kind via SpanConfig instead of a bare string. await this.runner().span({name: 'loadPortfolio', tags: {portfolioId: id}}).run(async ctx => { ... }); ``` **SpanConfig** (passed to `.span()`): | Option | Type | Description | |--------|-----------|------------------------------------------------------------------------------| | `name` | string | Span name (required) | | `kind` | SpanKind | `'internal'` \| `'client'` \| `'server'` \| `'producer'` \| `'consumer'` | | `tags` | PlainObject | Key-value attributes on the span | The framework wires `parent` and `caller` automatically from the `Runner` chain — `parent` comes from the chain's `CallContext` (with `span` derived from `loadSpec.span` when not set explicitly), `caller` defaults to the `HoistBase` that started the chain (driving the `code.namespace` tag). The `fetch*` methods on `FetchService` (and the convenience aliases `XH.fetch()`, `XH.fetchJson()`, `XH.postJson()`) also accept an optional `CallContextLike` as a second argument — useful when threading context through a fetch without an enclosing `Runner` chain: ```typescript const data = await XH.fetchJson({url: 'api/data'}, {loadSpec}); ``` To join a trace started upstream (e.g. a `traceparent` propagated via WebSocket / SSE / queue messages), pass the string to a span created directly via `XH.traceService.withSpan({name, parent: traceparent}, fn)` — the new span adopts the remote `traceId`, `parentSpanId`, and `sampled` decision. #### ClientHealthService **File**: `ClientHealthService.ts` | **Access**: `XH.clientHealthService` Gathers and reports client health metrics (memory, connection, WebSocket status). ```typescript // Get current health snapshot const report = XH.clientHealthService.getReport(); // {general: {...}, memory: {...}, connection: {...}, webSockets: {...}} // Register custom data source for health reports XH.clientHealthService.addSource('myFeature', () => ({ activeCount: myModel.activeItems.length, cacheSize: myModel.cache.size })); ``` #### InspectorService **File**: `InspectorService.ts` | **Access**: `XH.inspectorService` Developer tool for monitoring HoistModel, HoistService, and Store instances at runtime. See [`/inspector/`](../inspector/README.md) for full documentation on the Inspector UI. ```typescript // Toggle Inspector UI XH.inspectorService.toggleActive(); // View active instances XH.inspectorService.activeInstances; // InspectorInstanceData[] ``` ### Communication #### WebSocketService **File**: `WebSocketService.ts` | **Access**: `XH.webSocketService` Maintains bidirectional WebSocket connection for server-push messaging. ```typescript // Subscribe to topic const subscription = XH.webSocketService.subscribe('priceUpdates', (message) => { console.log('Price update:', message.data); }); // Clean up when done subscription.destroy(); // Or save to @managed property for auto-cleanup on model destroy // Send message to server XH.webSocketService.sendMessage({topic: 'clientEvent', data: {action: 'click'}}); // Check connection status XH.webSocketService.connected; XH.webSocketService.channelKey; // Unique channel assigned by server ``` **Built-in Topics:** - `xhHeartbeat` - Connection health monitoring - `xhRegistrationSuccess` - Server registration response - `xhForceAppSuspend` - Server-initiated app suspension - `xhRequestClientHealthReport` - Server requests health snapshot #### AlertBannerService **File**: `AlertBannerService.ts` | **Access**: `XH.alertBannerService` Displays app-wide alert banners configured via the Admin Console. Automatically polls for updates via `EnvironmentService`. ### Grid Support #### GridExportService **File**: `GridExportService.ts` | **Access**: `XH.gridExportService` Exports grid data to Excel or CSV format. ```typescript await XH.gridExportService.exportAsync(gridModel, { filename: 'users-export', type: 'excel', // 'excel' | 'excelTable' | 'csv' columns: 'VISIBLE' // 'VISIBLE' | 'ALL' | string[] }); ``` **Features:** - Server-side export via multipart streaming - Per-cell Excel formatting via column `excelFormat` property - Respects tree hierarchy and grouped rows - Custom export values via column `exportValue` property #### GridAutosizeService **File**: `GridAutosizeService.ts` | **Access**: `XH.gridAutosizeService` Calculates optimal column widths based on content (more sophisticated than native ag-Grid). ```typescript await XH.gridAutosizeService.autosizeAsync(gridModel, ['name', 'status'], { fillMode: 'right', // 'none' | 'all' | 'left' | 'right' includeCollapsedChildren: false }); ``` ### Persistence #### JsonBlobService **File**: `JsonBlobService.ts` | **Access**: `XH.jsonBlobService` Persists small unstructured JSON objects to the database. ```typescript // Create blob const blob = await XH.jsonBlobService.createAsync({ type: 'savedFilter', name: 'My Filter', value: {field: 'status', op: '=', value: 'active'} }); // Retrieve by token const blob = await XH.jsonBlobService.getAsync(token); // List by type const filters = await XH.jsonBlobService.listAsync({type: 'savedFilter'}); // Update await XH.jsonBlobService.updateAsync(token, {value: newFilterValue}); // Soft delete await XH.jsonBlobService.archiveAsync(token); ``` **Use Case:** Lightweight persistence for saved filters, UI layouts, sketches - data that doesn't warrant full domain objects. #### LocalStorageService / SessionStorageService **Files**: `storage/LocalStorageService.ts`, `storage/SessionStorageService.ts` Simple key/value access to browser storage, auto-namespaced by app and user. ```typescript // Local storage (persists across sessions) XH.localStorageService.set('lastTab', 'portfolio'); XH.localStorageService.get('lastTab', 'overview'); // Session storage (cleared when tab closes) XH.sessionStorageService.set('tempState', {step: 2}); ``` ### App Lifecycle #### IdleService **File**: `IdleService.ts` | **Access**: `XH.idleService` Suspends the application after a period of inactivity to reduce server load. **Configuration** (via `xhIdleConfig` soft config): - `timeout` - Global timeout in minutes - `appTimeouts` - Per-app timeout overrides #### AutoRefreshService **File**: `AutoRefreshService.ts` | **Access**: `XH.autoRefreshService` Triggers automatic app-wide refresh at configurable intervals. ```typescript XH.autoRefreshService.enabled; // Current state (config + user preference) XH.autoRefreshService.interval; // Interval in seconds ``` **Configuration:** - `xhAutoRefreshIntervals` soft config - Interval per app - `xhAutoRefreshEnabled` user preference - User opt-in/out #### ChangelogService **File**: `ChangelogService.ts` | **Access**: `XH.changelogService` Displays application changelog/release notes to users. ```typescript XH.changelogService.versions; // Parsed changelog entries XH.changelogService.currentVersionIsUnread; // Has user seen current version? XH.changelogService.markLatestAsRead(); ``` ## Configuration Keys Reference Services are configured via soft configs (managed in Admin Console): | Key | Service | Purpose | |-----|---------|---------| | `xhAutoRefreshIntervals` | AutoRefreshService | Refresh interval per app (seconds) | | `xhChangelogConfig` | ChangelogService | Changelog display options | | `xhIdleConfig` | IdleService | Idle timeout configuration | | `xhInspectorConfig` | InspectorService | Inspector visibility/role restrictions | | `xhExportConfig` | GridExportService | Export thresholds for UI feedback | | `xhActivityTrackingConfig` | TrackService | Severity-level filtering, data limits, health report interval | | `xhEnableImpersonation` | IdentityService | Enable/disable impersonation | | `xhEnvPollConfig` | EnvironmentService | Poll interval and version change behavior | ## User Preference Keys Reference | Key | Service | Purpose | |-----|---------|---------| | `xhAutoRefreshEnabled` | AutoRefreshService | User's auto-refresh preference | | `xhLastReadChangelog` | ChangelogService | Track latest read version | | `xhIdleDetectionDisabled` | IdleService | Disable idle suspension for user | ## Creating Custom Services See `/core/README.md` for the full guide on creating services. Quick example: ```typescript import {HoistService, XH} from '@xh/hoist/core'; import {observable} from '@xh/hoist/mobx'; export class PortfolioService extends HoistService { @observableRef accessor portfolios: Portfolio[] = []; // Called during app startup override async initAsync() { await this.loadPortfoliosAsync(); } async loadPortfoliosAsync() { const data = await XH.fetchJson({url: 'api/portfolios'}); runInAction(() => this.portfolios = data); } getPortfolio(id: string): Portfolio { return this.portfolios.find(p => p.id === id); } } // Install in AppModel.initAsync() await XH.installServicesAsync([PortfolioService], ctx); // Access anywhere XH.portfolioService.getPortfolio('abc123'); ``` ## Common Patterns ### Using FetchService in doLoadAsync Always pass `loadSpec` to fetch calls for consistent tracking: ```typescript override async doLoadAsync(loadSpec: LoadSpec) { const data = await XH.fetchJson({url: 'api/data'}, {loadSpec}); runInAction(() => this.data = data); } ``` ### Activity Tracking with Fetch Requests FetchService integrates with TrackService via the `track` option. This records the request (with timing) in the activity log, viewable in the Admin Console. ```typescript // Simple form - just a message const data = await XH.fetchJson({ url: 'api/portfolios', track: 'Loaded portfolios' }); // Full form - with category and additional data const report = await XH.fetchJson({ url: 'api/reports/generate', params: {reportId, format}, track: { message: 'Generated report', category: 'Reporting', data: {reportId, format} } }); ``` The `track` option automatically captures: - Elapsed time for the request - Correlation ID (if enabled on the request) - LoadSpec metadata (if provided) ### Activity Tracking with Promise.track() For operations that aren't fetch requests, use the `Promise.track()` extension to record activity with timing. This is implemented in `/promise/` and delegates to TrackService. ```typescript // Simple: track a single async operation via the Promise.track() extension await this.processDataAsync(records) .track('Processed records'); // Multi-step: compose tracking (with masking, logging, etc.) via the Runner chain on HoistBase await this.runner() .linkTo(this.calcTask) .track({ message: 'Ran portfolio calculation', category: 'Calculation', data: {portfolioCount: portfolios.length} }) .run(async () => { const inputs = await this.gatherInputsAsync(); return this.runCalculationAsync(inputs); }); ``` `Promise.track()` automatically: - Records the start time when the promise begins - Calculates elapsed time when the promise settles - Sets severity to 'ERROR' if the promise rejects (and re-throws the error) - Skips tracking for "routine" exceptions (e.g., user cancellations) This is useful for tracking operations like local data processing, complex calculations, or coordinated multi-step workflows where you want timing visibility without a fetch request. ### Debounced Search with Auto-Abort ```typescript @bindable accessor searchQuery = ''; constructor() { super(); this.addReaction({ track: () => this.searchQuery, run: () => this.searchAsync(), debounce: 300 }); } async searchAsync() { if (!this.searchQuery) { runInAction(() => this.results = []); return; } try { const results = await XH.fetchJson({ url: 'api/search', params: {q: this.searchQuery}, autoAbortKey: 'search' // Cancels previous search if still pending }); runInAction(() => this.results = results); } catch (e) { // Silently swallow aborted requests - a newer search has taken over if (e.isFetchAborted) return; XH.handleException(e); } } ``` ### WebSocket Subscriptions with Managed Cleanup ```typescript class PriceModel extends HoistModel { @managed priceSubscription: WebSocketSubscription; override onLinked() { this.priceSubscription = XH.webSocketService.subscribe( 'priceUpdates', (msg) => this.handlePriceUpdate(msg.data) ); } // Subscription auto-destroyed when model destroyed via @managed } ``` ### Checking Feature Configs ```typescript class MyModel extends HoistModel { get showAdvancedFeatures(): boolean { return XH.getConf('advancedFeaturesEnabled', false); } get maxExportRows(): number { return XH.getConf('exportConfig', {}).maxRows ?? 10000; } } ``` ## Common Pitfalls ### Not Handling Fetch Errors `XH.fetchJson()` throws on HTTP errors. Handle exceptions appropriately: ```typescript // ❌ Wrong: Unhandled exception crashes the app const data = await XH.fetchJson({url: 'api/data'}); // ✅ Correct: Handle in doLoadAsync with proper error handling override async doLoadAsync(loadSpec: LoadSpec) { try { const data = await XH.fetchJson({url: 'api/data'}, {loadSpec}); runInAction(() => this.data = data); } catch (e) { if (loadSpec.isStale || loadSpec.isAutoRefresh) return; runInAction(() => this.data = []); XH.handleException(e, {alertType: 'toast'}); } } ``` ### `params` Triggers a POST When Method Is Not Specified When `params` is passed without an explicit `method`, `FetchService` issues a **POST** and sends the params as `application/x-www-form-urlencoded` in the request body -- not as a URL query string on a GET. This is rarely what callers intend. **Always specify `method: 'GET'` (or use `XH.fetchService.getJson()`) when you intend a GET with query parameters.** Omitting the method changes the verb, the wire format, and the server-side route hit. ```typescript // ❌ Surprising: sends POST with form-encoded params in the body await XH.fetchJson({url: 'api/users', params: {role: 'admin'}}); // ✅ Explicit GET with query string ?role=admin await XH.fetchJson({url: 'api/users', params: {role: 'admin'}, method: 'GET'}); // ✅ Or use the dedicated getJson() helper await XH.fetchService.getJson({url: 'api/users', params: {role: 'admin'}}); ``` ### Using Native `fetch` Instead of FetchService Hoist apps should use `FetchService` (via `XH.fetch()`, `XH.fetchJson()`, `XH.postJson()`) rather than the browser's native `fetch` API. FetchService provides automatic correlation IDs, configurable timeouts, request deduplication via `autoAbortKey`, integrated activity tracking, rich exception handling with server-side details, and `loadSpec` support for stale request management. Bypassing it means losing all of these benefits. ```typescript // ❌ Wrong: Native fetch bypasses all FetchService enhancements const response = await fetch('/api/data'); const data = await response.json(); // ✅ Correct: Use FetchService via XH convenience methods const data = await XH.fetchJson({url: 'api/data'}); ``` ### Forgetting loadSpec in Fetch Calls Without `loadSpec`, requests aren't tracked and can't be cancelled when loads become stale: ```typescript // ❌ Missing loadSpec override async doLoadAsync(loadSpec: LoadSpec) { const data = await XH.fetchJson({url: 'api/data'}); // No tracking } // ✅ Include loadSpec override async doLoadAsync(loadSpec: LoadSpec) { const data = await XH.fetchJson({url: 'api/data'}, {loadSpec}); } ``` ### Using Configs for User-Specific Values Configs are app-wide; use preferences for user-specific settings: ```typescript // ❌ Wrong: Configs are shared across all users XH.getConf('userTheme'); // ✅ Correct: Preferences are per-user XH.getPref('userTheme'); ``` ### Not Cleaning Up WebSocket Subscriptions Subscriptions persist until explicitly destroyed: ```typescript // ❌ Wrong: Subscription leaks when model is destroyed const sub = XH.webSocketService.subscribe('updates', handler); // ✅ Correct: Use @managed for automatic cleanup @managed subscription = XH.webSocketService.subscribe('updates', handler); // ✅ Also correct: Manual cleanup in destroy() override destroy() { this.subscription?.destroy(); super.destroy(); } ``` ### Putting UI Code in Services Services should focus on data operations - fetching, sending, transforming, storing, and processing data. Avoid importing components or rendering UI from services. ```typescript // ❌ Wrong: Service imports and renders components import {tradeConfirmationDialog} from '../desktop/cmp/trade/TradeConfirmationDialog'; class TradeService extends HoistService { async submitTradeAsync(trade: Trade) { const confirmed = await tradeConfirmationDialog({trade}); // Tight coupling to desktop if (confirmed) await XH.postJson({...}); } } // ✅ Correct: Service handles data, caller handles UI class TradeService extends HoistService { async submitTradeAsync(trade: Trade) { return XH.postJson({url: 'api/trades', body: trade}); } } // In a Model or Component - UI concerns stay here async onSubmitClick() { const confirmed = await XH.confirm({message: 'Submit this trade?'}); if (confirmed) { await XH.tradeService.submitTradeAsync(this.trade); XH.successToast('Trade submitted'); } } ``` **Why this matters:** - Services may be shared between desktop and mobile apps - component imports create platform dependencies - Keeps services focused and testable - Makes refactoring easier - UI changes don't require service changes - Cross-platform alerts like `XH.confirm()`, `XH.toast()`, and `XH.alert()` are acceptable since they're provided by Hoist and work across platforms ## Related Packages - [`/core/`](../core/README.md) - `HoistService` base class, `XH` singleton - [`/cmp/`](../cmp/README.md) - Components that use services (Grid export button, etc.) - [`/promise/`](../promise/README.md) - Promise extensions used in service patterns (`catchDefault`, `track`, `linkTo`) - `/admin/` - Admin Console for managing configs, prefs, and viewing activity