Skip to main content

Walkthrough: create a small deployable weather client

This example builds a small React + TypeScript web app that:

  • creates a Service Framework profile
  • registers a WeatherService
  • asks the browser for the current location
  • fetches weather data for that location
  • displays the result on the home screen

The example uses the free Open-Meteo API so you do not need an API key while learning.

A runnable version of this walkthrough lives at runtime-examples/weather-client-example. If you want to run the finished app immediately, start there. If you want to understand how to build it step by step, read on.

Before you start​

You need:

  • Node.js and npm installed
  • a terminal such as PowerShell
  • the published Service Framework packages from npm, or local file: references if you are working from this repository

If you are working from this repository directly, the existing weather-client-example is the ready-to-run version of everything described below.

Step 1: create the app shell​

Create a new React + TypeScript application with Vite:

npm create vite@latest service-framework-weather -- --template react-ts
cd service-framework-weather
npm install

What this does:

  • creates a new folder named service-framework-weather
  • sets up React and TypeScript for you
  • installs the starter dependencies

Step 2: add the framework packages​

Install the published packages from npm:

npm install @realitycollective/service-framework @realitycollective/service-framework-react

If you are working locally against this repository instead, add file: dependencies pointing to the packages in packages/:

npm install ../../packages/service-framework ../../packages/service-framework-react

Step 3: create the example files​

Add these files to src/:

src/
App.tsx
main.tsx
profile.ts
services/
tokens.ts
weather-service.ts

Step 4: define the token​

Create src/services/tokens.ts:

import { createServiceToken } from "@realitycollective/service-framework";
import type { WeatherService } from "./weather-service";

export const WEATHER_SERVICE_TOKEN = createServiceToken<WeatherService>("WeatherService");

Step 5: create the weather service​

Create src/services/weather-service.ts:

import { BaseService } from "@realitycollective/service-framework";

export interface WeatherServiceConfig {
readonly temperatureUnit: "celsius" | "fahrenheit";
}

export interface WeatherResult {
readonly latitude: number;
readonly longitude: number;
readonly temperature: number;
readonly weatherCode: number;
readonly description: string;
}

export class WeatherService extends BaseService<WeatherServiceConfig> {
public async getCurrentWeather(): Promise<WeatherResult> {
const position = await this.getCurrentPosition();
const latitude = position.coords.latitude;
const longitude = position.coords.longitude;

const url = new URL("https://api.open-meteo.com/v1/forecast");
url.searchParams.set("latitude", String(latitude));
url.searchParams.set("longitude", String(longitude));
url.searchParams.set("current", "temperature_2m,weather_code");
url.searchParams.set("temperature_unit", this.serviceConfig.temperatureUnit);

const response = await fetch(url, { signal: this.abortSignal });

if (!response.ok) {
throw new Error(`Weather request failed with status ${response.status}.`);
}

const payload = await response.json() as {
readonly current?: {
readonly temperature_2m?: number;
readonly weather_code?: number;
};
};

if (payload.current?.temperature_2m === undefined || payload.current.weather_code === undefined) {
throw new Error("Weather response did not include current conditions.");
}

return {
latitude,
longitude,
temperature: payload.current.temperature_2m,
weatherCode: payload.current.weather_code,
description: describeWeatherCode(payload.current.weather_code)
};
}

private async getCurrentPosition(): Promise<GeolocationPosition> {
if (!("geolocation" in navigator)) {
throw new Error("This browser does not support geolocation.");
}

return await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 15000
});
});
}
}

function describeWeatherCode(code: number): string {
switch (code) {
case 0:
return "Clear sky";
case 1:
case 2:
case 3:
return "Partly cloudy";
case 45:
case 48:
return "Fog";
case 51:
case 53:
case 55:
return "Drizzle";
case 61:
case 63:
case 65:
return "Rain";
case 71:
case 73:
case 75:
return "Snow";
case 95:
return "Thunderstorm";
default:
return `Weather code ${code}`;
}
}

What this service does:

  • reads the configuration from serviceConfig
  • uses the browser geolocation API to get the current location
  • calls a public weather API
  • returns a typed result object
  • uses abortSignal so the request can be cancelled if the service is disposed

Step 6: register the service in a profile​

Create src/profile.ts:

import { createServiceProfile } from "@realitycollective/service-framework";
import { WEATHER_SERVICE_TOKEN } from "./services/tokens";
import { WeatherService } from "./services/weather-service";

export const profile = createServiceProfile("weather-client", [
{
token: WEATHER_SERVICE_TOKEN,
config: {
temperatureUnit: "celsius"
},
useClass: WeatherService
}
]);

Step 7: provide the framework to React​

Create src/main.tsx:

import React from "react";
import ReactDOM from "react-dom/client";
import { ServiceFrameworkProvider } from "@realitycollective/service-framework-react";
import { App } from "./App";
import { profile } from "./profile";

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ServiceFrameworkProvider profile={profile}>
<App />
</ServiceFrameworkProvider>
</React.StrictMode>
);

This is the point where React and the Service Framework meet. The provider initializes the profile and makes the service manager available to all child components.

Step 8: consume the service on the home screen​

Create src/App.tsx:

import React, { useState } from "react";
import { useService } from "@realitycollective/service-framework-react";
import { WEATHER_SERVICE_TOKEN } from "./services/tokens";
import type { WeatherResult } from "./services/weather-service";

export function App(): React.JSX.Element {
const weatherService = useService(WEATHER_SERVICE_TOKEN);
const [weather, setWeather] = useState<WeatherResult | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

async function loadWeather(): Promise<void> {
setLoading(true);
setError(null);

try {
const result = await weatherService.getCurrentWeather();
setWeather(result);
} catch (serviceError) {
setError(serviceError instanceof Error ? serviceError.message : String(serviceError));
setWeather(null);
} finally {
setLoading(false);
}
}

return (
<main style={{ fontFamily: "sans-serif", maxWidth: 720, margin: "0 auto", padding: 24 }}>
<p>Reality Collective Service Framework</p>
<h1>Weather at my current location</h1>
<p>
This screen resolves a service from the framework and asks that service to fetch the weather.
</p>

<button type="button" onClick={() => void loadWeather()} disabled={loading}>
{loading ? "Loading weather..." : "Get weather for my location"}
</button>

{error ? <p style={{ color: "crimson" }}>{error}</p> : null}

{weather ? (
<section>
<h2>Current conditions</h2>
<p>Temperature: {weather.temperature} degrees</p>
<p>Conditions: {weather.description}</p>
<p>
Coordinates: {weather.latitude.toFixed(3)}, {weather.longitude.toFixed(3)}
</p>
</section>
) : null}
</main>
);
}

This component is doing three simple things:

  1. resolving the weather service with useService()
  2. calling the service when the user clicks a button
  3. storing the result in React state so the UI updates

Step 9: run and build the app​

During development:

npm run dev

To create a production build:

npm run build

The built files are written to dist/. You can deploy that folder to a static web host.

Useful options include:

  • Azure Static Web Apps
  • Netlify
  • Vercel
  • GitHub Pages, if your routing and asset paths are configured for it

Important note: browser geolocation usually requires https:// in production. It also works on http://localhost during local development.


Appendix: abstracting the weather provider as a service module​

The WeatherService you built in the walkthrough is self-contained: it knows how to get the user's location and also knows the specific Open-Meteo API. That is fine for a small example, but as an app grows you often want to separate what the service does from which provider it delegates to. You might want to swap Open-Meteo for a paid provider with more data, point a test suite at a stub that returns fixed values, or support multiple providers at once.

Service modules are the framework's pattern for exactly this. A module is a child service that:

  • is initialized and started alongside its parent
  • has access to the parent via parentService
  • follows the same lifecycle (initialize, start, destroy, etc.)
  • is registered under the parent's entry in the profile, not as a top-level service

The result is that swapping providers becomes a profile change. The WeatherService itself does not change at all.

What changes and what stays the same​

FileChange
services/weather-provider-module.tsnew - abstract base module that any provider must extend
services/open-meteo-provider-module.tsnew - the concrete Open-Meteo implementation, extracted from WeatherService
services/weather-service.tsupdated - delegates the fetch call to whichever provider module is attached
profile.tsupdated - registers the provider module under the WeatherService entry
App.tsx, main.tsx, tokens.tsunchanged

Step A1: define the abstract provider module​

Create src/services/weather-provider-module.ts:

import { BaseServiceModule } from "@realitycollective/service-framework";
import type { WeatherService, WeatherResult } from "./weather-service";

export abstract class WeatherProviderModule extends BaseServiceModule<WeatherService> {
public abstract fetchWeather(
latitude: number,
longitude: number,
temperatureUnit: "celsius" | "fahrenheit"
): Promise<WeatherResult>;
}

This is the contract every provider must fulfil. It says nothing about which API to call, what credentials to use, or how data is mapped - those are the provider's private concerns. WeatherService only needs to know this interface exists.

Step A2: extract the Open-Meteo implementation​

Create src/services/open-meteo-provider-module.ts:

import { WeatherProviderModule } from "./weather-provider-module";
import type { WeatherResult } from "./weather-service";

export class OpenMeteoProviderModule extends WeatherProviderModule {
public override async fetchWeather(
latitude: number,
longitude: number,
temperatureUnit: "celsius" | "fahrenheit"
): Promise<WeatherResult> {
const url = new URL("https://api.open-meteo.com/v1/forecast");
url.searchParams.set("latitude", String(latitude));
url.searchParams.set("longitude", String(longitude));
url.searchParams.set("current", "temperature_2m,weather_code");
url.searchParams.set("temperature_unit", temperatureUnit);

const response = await fetch(url, { signal: this.abortSignal });

if (!response.ok) {
throw new Error(`Weather request failed with status ${response.status}.`);
}

const payload = await response.json() as {
readonly current?: {
readonly temperature_2m?: number;
readonly weather_code?: number;
};
};

if (
payload.current?.temperature_2m === undefined ||
payload.current.weather_code === undefined
) {
throw new Error("Weather response did not include current conditions.");
}

return {
latitude,
longitude,
temperature: payload.current.temperature_2m,
weatherCode: payload.current.weather_code,
description: describeWeatherCode(payload.current.weather_code)
};
}
}

function describeWeatherCode(code: number): string {
switch (code) {
case 0: return "Clear sky";
case 1: case 2: case 3: return "Partly cloudy";
case 45: case 48: return "Fog";
case 51: case 53: case 55: return "Drizzle";
case 61: case 63: case 65: return "Rain";
case 71: case 73: case 75: return "Snow";
case 95: return "Thunderstorm";
default: return `Weather code ${code}`;
}
}

Everything that was inside WeatherService and touched the Open-Meteo API now lives here. The abortSignal is available because BaseServiceModule extends BaseService, so the same this.abortSignal property is present.

Step A3: update WeatherService to delegate to the provider​

Replace the body of src/services/weather-service.ts with this:

import { BaseService } from "@realitycollective/service-framework";
import { WeatherProviderModule } from "./weather-provider-module";

export interface WeatherServiceConfig {
readonly temperatureUnit: "celsius" | "fahrenheit";
}

export interface WeatherResult {
readonly latitude: number;
readonly longitude: number;
readonly temperature: number;
readonly weatherCode: number;
readonly description: string;
}

export class WeatherService extends BaseService<WeatherServiceConfig> {
public async getCurrentWeather(): Promise<WeatherResult> {
const provider = this.serviceModules.find(
(m): m is WeatherProviderModule => m instanceof WeatherProviderModule
);

if (provider === undefined) {
throw new Error("WeatherService has no provider module registered.");
}

const position = await this.getCurrentPosition();

return provider.fetchWeather(
position.coords.latitude,
position.coords.longitude,
this.serviceConfig.temperatureUnit
);
}

private async getCurrentPosition(): Promise<GeolocationPosition> {
if (!("geolocation" in navigator)) {
throw new Error("This browser does not support geolocation.");
}

return await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
maximumAge: 30000,
timeout: 15000
});
});
}
}

this.serviceModules is a readonly array of every module attached to this service. The find call with a type predicate (m is WeatherProviderModule) narrows the result so TypeScript knows the found value has a fetchWeather method. WeatherService now has no import of and no knowledge of OpenMeteoProviderModule.

Step A4: attach the provider module in the profile​

Update src/profile.ts:

import { createServiceProfile } from "@realitycollective/service-framework";
import { WEATHER_SERVICE_TOKEN } from "./services/tokens";
import { WeatherService } from "./services/weather-service";
import { OpenMeteoProviderModule } from "./services/open-meteo-provider-module";

export const profile = createServiceProfile("weather-client", [
{
token: WEATHER_SERVICE_TOKEN,
config: {
temperatureUnit: "celsius"
},
useClass: WeatherService,
modules: [
{
name: "open-meteo-provider",
useClass: OpenMeteoProviderModule
}
]
}
]);

The only change from the original profile is the modules array on the WeatherService registration. The module is initialized and started with its parent service automatically.

Swapping to a different provider​

To use a different provider - for example a paid service with hourly forecasts - create another module that extends WeatherProviderModule and replace the entry in modules:

import { TomorrowIoProviderModule } from "./services/tomorrow-io-provider-module";

modules: [
{
name: "tomorrow-io-provider",
config: { apiKey: import.meta.env.VITE_TOMORROW_IO_KEY },
useClass: TomorrowIoProviderModule
}
]

WeatherService, App.tsx, and every other file are untouched. The only change is the profile.

Using a stub provider in tests​

The same pattern makes unit testing straightforward. A stub module returns a fixed result instead of hitting the network:

import { WeatherProviderModule } from "./weather-provider-module";
import type { WeatherResult } from "./weather-service";

export class StubWeatherProviderModule extends WeatherProviderModule {
public override async fetchWeather(): Promise<WeatherResult> {
return {
latitude: 51.5,
longitude: -0.1,
temperature: 14,
weatherCode: 1,
description: "Partly cloudy"
};
}
}

Register StubWeatherProviderModule in your test profile the same way you register the real one. The WeatherService code path is exercised without any network calls.

Summary of the pattern​

The core idea is that the service owns the workflow and the module owns the integration:

  • WeatherService - knows how to ask for location, knows what WeatherResult looks like, knows which config options are meaningful
  • WeatherProviderModule - defines the contract between service and provider
  • OpenMeteoProviderModule - knows the Open-Meteo API, endpoint, and response shape; nothing else does
  • Profile - the only place that decides which provider is in use

This separation pays off whenever requirements change at the integration boundary: a new provider, a backend proxy, a different unit system, or a test stub are all profile-level decisions.