Compare commits

..

72 Commits

Author SHA1 Message Date
3ebb47c5ab feat: add Expo React Native client scaffold
- Expo SDK 54 / React Native 0.81 with expo-router
- Tailscale devcontainer feature for direct device connectivity
- Dev proxy for React Native DevTools access over Tailscale
- EAS build configuration for development/preview/production
- Ona automation for Expo dev server

Co-authored-by: Ona <no-reply@ona.com>
2026-02-21 14:22:29 +00:00
f987253e53 Merge pull request #27 from kennethnym/feat/feed-source-actions
feat: add actions to FeedSource interface
2026-02-15 12:54:47 +00:00
699155e0d8 feat: add actions to FeedSource interface
Add listActions() and executeAction() to FeedSource for write
operations back to external services. Actions use arktype schemas
for input validation via StandardSchemaV1.

- ActionDefinition type with optional input schema
- FeedEngine routes actions with existence and ID validation
- Source IDs use reverse-domain format (aris.location, aris.tfl)
- LocationSource: update-location action with schema validation
- TflSource: set-lines-of-interest action with lineId validation
- No-op implementations for sources without actions

Co-authored-by: Ona <no-reply@ona.com>
2026-02-15 12:53:10 +00:00
4d6cac7ec8 Merge pull request #26 from kennethnym/refactor/required-fetch-context
refactor: make fetchContext required on FeedSource
2026-02-14 16:41:34 +00:00
1f2920a7ad refactor: make fetchContext required on FeedSource
Sources that cannot provide context now return null
instead of omitting the method. The engine checks the
return value rather than method existence.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 16:20:24 +00:00
476c6f06d9 Merge pull request #25 from kennethnym/feat/google-calendar-source
feat: add Google Calendar data source
2026-02-14 15:44:03 +00:00
bfdc5e67b7 chore: restore default comment on lookaheadHours
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:43:00 +00:00
2cf6536e48 refactor: move source options into source file
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
c7a1048320 chore: remove obvious comments from types
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
512faf191e feat: add Google Calendar data source
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:42:59 +00:00
e5d65816dc Merge pull request #24 from kennethnym/feat/apple-calendar-source
feat: add Apple Calendar source package
2026-02-14 15:42:04 +00:00
13c411c842 perf: cache fetched events within a refresh cycle
FeedEngine calls fetchContext then fetchItems with the same
context. Cache events by context.time reference to avoid
duplicate CalDAV round-trips.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:36:36 +00:00
e8ba49d7bb refactor: use switch/case in parser, move options
- Replace if/else chains with switch/case in ical-parser
- Move CalendarSourceOptions to calendar-source.ts

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:29:49 +00:00
3010eb8990 refactor: replace Map with Record in tests
Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 15:20:23 +00:00
6c4982ae85 fix: use Promise.allSettled for calendar fetching
A transient error on one calendar (e.g. shared calendar
with permission issues) no longer discards results from
all other calendars.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-14 00:44:47 +00:00
f557a0f967 feat: add Apple Calendar source package
Add @aris/source-apple-calendar for fetching iCloud
calendar events via CalDAV using tsdav and ical.js.

- CalendarSource implements FeedSource with fetchItems
  and fetchContext for downstream context
- CalendarCredentialProvider interface for token injection
- CalendarDAVClient interface for testability
- iCal parser extracts full event data including
  attendees, alarms, organizer, and recurrence
- Priority based on event proximity to current time

Co-authored-by: Ona <no-reply@ona.com>
2026-02-13 22:08:58 +00:00
1b2929c2b6 Merge pull request #22 from kennethnym/feat/weather-service
feat(backend): add WeatherService
2026-02-13 20:05:06 +00:00
e5f1273baf feat(backend): add WeatherService
Manage per-user WeatherSource instances via FeedSourceProvider,
following the same pattern as LocationService. Wire into
FeedEngineService so weather data is included in the feed.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-13 20:02:58 +00:00
c3429e1a77 Merge pull request #23 from kennethnym/feat/tfl-service
feat(backend): add TflService
2026-02-13 20:01:03 +00:00
54e4b0dcf7 feat(backend): add TflService
Manages per-user TflSource instances with individual line
configuration. Implements FeedSourceProvider so it can be
wired into FeedEngineService.

Adds TflSource.setLines() so line config can be mutated
in place, keeping engine references valid.

Also exports ITflApi from @aris/source-tfl for testability.

Co-authored-by: Ona <no-reply@ona.com>
2026-02-13 19:38:21 +00:00
31a82c1d9f ci: run bun install in postCreateCommand
Co-authored-by: Ona <no-reply@ona.com>
2026-02-08 11:55:00 +00:00
d1102fe1ac Merge pull request #21 from kennethnym/feat/feed-engine-service
feat(backend): add FeedEngineService
2026-01-25 23:16:08 +00:00
db0c57f04b feat(backend): add FeedEngineService
Manages FeedEngine instances per user with auto-registration of
sources from FeedSourceProvider implementations.

- Add FeedSourceProvider interface
- Add FeedEngineService with providers array injection
- Update LocationService to implement FeedSourceProvider

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 23:12:48 +00:00
9e3fe2ea16 Merge pull request #20 from kennethnym/feat/location-router
feat(backend): add location router with tRPC factory pattern
2026-01-25 23:00:03 +00:00
949b7c8571 feat(backend): add location router with tRPC factory pattern
- Add createLocationRouter with location.update mutation
- Refactor tRPC to factory pattern (createTRPC, createTRPCRouter)
- Protected procedure by default (all routes require auth)
- Replace zod with arktype for input validation
- Wire location router in main() with dependency injection

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 22:58:32 +00:00
bd6cc3c963 Merge pull request #19 from kennethnym/feat/trpc
feat(backend): add tRPC with Hono adapter
2026-01-25 22:26:41 +00:00
aff9464245 feat(backend): add tRPC with Hono adapter
- Add @trpc/server, @hono/trpc-server, zod dependencies
- Create tRPC context with BetterAuth session
- Create router with publicProcedure and protectedProcedure
- Mount tRPC at /trpc/* via Hono adapter

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 22:19:05 +00:00
0db6cae82b Merge pull request #18 from kennethnym/feat/location-service
feat(backend): add LocationService
2026-01-25 19:41:26 +00:00
faad9e9736 feat(backend): add LocationService
- Add LocationService to manage LocationSource per user
- Add UserNotFoundError for generic user-related errors
- updateUserLocation throws if source not initialized

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 19:39:55 +00:00
da2c1b9ee7 Merge pull request #17 from kennethnym/feat/auth
feat(backend): add BetterAuth email/password authentication
2026-01-25 16:29:41 +00:00
c10c8a553a feat(backend): add BetterAuth email/password authentication
- Add PostgreSQL connection (src/db.ts)
- Configure BetterAuth with email/password (src/auth/index.ts)
- Add session middleware for route protection (src/auth/session-middleware.ts)
- Add registerAuthHandlers for mounting auth routes (src/auth/http.ts)
- Rename index.ts to server.ts
- Add .env.example with required environment variables

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 16:21:00 +00:00
fffcccc227 Merge pull request #16 from kennethnym/feat/aris-backend
feat(backend): init aris-backend with Hono
2026-01-25 14:55:17 +00:00
b744af9c51 feat(backend): init aris-backend with Hono
- Add apps/aris-backend package with Hono server
- Add /health endpoint
- Add backend-spec.md with design decisions

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 14:54:08 +00:00
13300fb6a6 Merge pull request #15 from kennethnym/feat/source-tfl
refactor: migrate aris-data-source-tfl to aris-source-tfl
2026-01-25 14:23:26 +00:00
66ee44b470 refactor: migrate aris-data-source-tfl to aris-source-tfl
Migrates TFL package from old DataSource interface to new FeedSource
interface for use with FeedEngine.

Changes:
- Rename package from @aris/data-source-tfl to @aris/source-tfl
- Replace TflDataSource class with TflSource implementing FeedSource
- Add dependency on @aris/source-location for LocationKey
- Use normalized priority values (0-1) instead of arbitrary numbers
- Update tests for FeedSource interface
- Update README.md with new package name

Co-authored-by: Ona <no-reply@ona.com>
2026-01-25 14:20:13 +00:00
1893c516f3 Merge pull request #14 from kennethnym/feat/feed-engine
feat(core): add FeedEngine for FeedSource orchestration
2026-01-24 23:07:41 +00:00
181160b018 feat(core): add FeedEngine for FeedSource orchestration
Introduces FeedEngine that consumes FeedSource instances and manages
the dependency graph for context flow and item collection.

- Validates dependency graph (missing deps, circular references)
- Topologically sorts sources for execution order
- Runs fetchContext() in dependency order, accumulating context
- Runs fetchItems() on all sources with final context
- Supports reactive updates via onContextUpdate/onItemsUpdate
- Graceful error handling (continues after source failures)

Marks DataSource, ContextProvider, ContextBridge, Reconciler, and
FeedController as deprecated in favor of FeedSource + FeedEngine.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-24 22:42:00 +00:00
559f82ce96 Merge pull request #13 from kennethnym/feat/source-weatherkit
feat: add @aris/source-weatherkit package
2026-01-19 00:56:32 +00:00
5e040470c7 feat: add @aris/source-weatherkit package
Implements FeedSource for WeatherKit API. Depends on location source,
provides weather context for downstream sources, and produces weather
feed items.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:50:13 +00:00
c2f2aeec1d Merge pull request #12 from kennethnym/feat/source-location
feat(source-location): add LocationSource for push-based location context
2026-01-19 00:38:28 +00:00
75ce06d39b feat(source-location): add LocationSource for push-based location context
Implements FeedSource interface. Accepts external location pushes,
provides context to downstream sources, does not produce feed items.

Supports configurable history size.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:37:35 +00:00
a7b6232058 Merge pull request #11 from kennethnym/fix/core-package-json-paths
fix(core): correct main and types paths in package.json
2026-01-19 00:29:50 +00:00
5df3dbd1b5 fix(core): correct main and types paths in package.json
Paths pointed to index.ts but actual file is at src/index.ts.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-19 00:08:04 +00:00
b7c7bcfc7c Merge pull request #9 from kennethnym/feat/feed-source-interface
feat(core): add FeedSource interface
2026-01-18 23:49:31 +00:00
9a47dda767 test(core): remove legacy integration tests
Tests were for DataSource/ContextProvider/ContextBridge which are now
deprecated in favor of FeedSource.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:46:38 +00:00
286a933d1e test(core): add FeedSource integration tests
Tests graph validation (dependency existence, cycle detection, topological
sort) and refresh behavior (context accumulation, item collection).

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:45:05 +00:00
1d9de2851a feat(core): add FeedSource interface
Unifies DataSource and ContextProvider into a single interface that
forms a dependency graph. Sources declare dependencies on other sources
and can provide context, feed items, or both.

Deprecates DataSource, ContextProvider, and ContextBridge.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 23:32:47 +00:00
80192c6dc1 Merge pull request #8 from kennethnym/docs/gpg-signing-instruction
docs: add GPG signing instruction to AGENTS.md
2026-01-18 20:41:06 +00:00
0eb77b73c6 docs: add GPG signing instruction to AGENTS.md
Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:39:26 +00:00
dfce846c9a Merge pull request #7 from kennethnym/feat/feed-controller-orchestration
feat(core): add FeedController orchestration layer
2026-01-18 20:30:27 +00:00
b73e603c90 feat(core): return RefreshResult from ContextBridge.refresh()
Surfaces provider errors through RefreshResult.errors instead of
silently ignoring them.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:28:54 +00:00
037589cf4f refactor(core): rename getCurrentValue to fetchCurrentValue
Also use Promise.allSettled in ContextBridge.refresh() to handle
provider errors gracefully.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 20:23:54 +00:00
3c16dd4275 feat(core): add FeedController orchestration layer
Adds orchestration for feed reconciliation with context-driven updates:

- FeedController: holds context, debounces updates, reconciles sources
- ContextBridge: bridges context providers to controller
- ContextProvider: reactive + on-demand context value interface
- Branded ContextKey<T> for type-safe context keys

Moves source files to src/ directory and consolidates tests into
integration test.

Co-authored-by: Ona <no-reply@ona.com>
2026-01-18 00:58:29 +00:00
2eff7b49dc Merge pull request #6 from kennethnym/fix/oxfmt-ignore-patterns
fix: ignore .claude and fixtures in oxfmt
2026-01-17 16:11:31 +00:00
d9405a239a fix: ignore .claude and fixtures in oxfmt
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 16:03:12 +00:00
1ed335f783 Merge pull request #2 from kennethnym/feat/data-source-tfl
Add @aris/data-source-tfl package
2026-01-17 11:58:44 +00:00
6ecf080177 Merge pull request #5 from kennethnym/chore/add-claude-skills
Add Claude skills for React best practices and web design
2026-01-17 11:58:33 +00:00
482c1c8b0f Resolve bun.lock conflict
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 11:50:41 +00:00
c90bef0330 Merge pull request #4 from kennethnym/dev/agents-md
docs: add AGENTS.md
2026-01-17 11:49:25 +00:00
de813d5b4a docs: add AGENTS.md
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 11:48:20 +00:00
552629bcdb Merge pull request #3 from kennethnym/feat/data-source-weatherkit
Add WeatherKit data source package
2026-01-17 01:18:27 +00:00
51749ad811 Include response body in API error messages
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 01:18:06 +00:00
6cf147989f Refactor WeatherKit client to injectable interface
- Add WeatherKitClient interface and DefaultWeatherKitClient class
- WeatherKitDataSource accepts either client or credentials
- Simplify tests by injecting mock client directly
- Update fixture generation script to use new client class

Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 01:14:18 +00:00
850d1925b6 Add query() tests with mocked API response
Tests transformation logic including:
- Feed item type assignment
- Hourly/daily limits
- Timestamp from context
- Unit conversion (metric/imperial)
- Priority assignment
- Unique ID generation

Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 01:09:39 +00:00
ceb9dbd576 Fix flaky test assertion on condition code
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:40:28 +00:00
7e0f30351f Merge origin/master into feat/data-source-weatherkit
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:39:07 +00:00
494e211844 Add fixture generation instructions to README
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:38:15 +00:00
06c568ad69 Minify fixture and fix lockfile
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:37:22 +00:00
785cbefce4 Add WeatherKit data source package
Implements @aris/data-source-weatherkit for fetching weather data from
Apple WeatherKit REST API.

- WeatherKitDataSource class implementing DataSource interface
- Feed items: current, hourly, daily, and alerts
- Priority adjustment based on weather conditions and alert severity
- Unit conversion (metric/imperial)
- Response validation with arktype
- Test fixtures from real API responses

Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:34:46 +00:00
20559b92ad Resolve bun.lock conflict
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:08:29 +00:00
c2f0b03924 Minify fixture JSON
Co-authored-by: Ona <no-reply@ona.com>
2026-01-17 00:07:22 +00:00
8ec8b9a13e Add @aris/data-source-tfl package
TfL data source for tube, overground, and Elizabeth line alerts.

- Fetches line statuses and filters to minor/major delays and closures
- Sorts alerts by severity, then by proximity to user location
- Caches station data after first fetch
- Uses arktype for API response validation
- Supports API injection for testing

Co-authored-by: Ona <no-reply@ona.com>
2026-01-16 23:57:29 +00:00
146 changed files with 20726 additions and 296 deletions

View File

@@ -10,12 +10,13 @@
"context": ".", "context": ".",
"dockerfile": "Dockerfile" "dockerfile": "Dockerfile"
}, },
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh" "postCreateCommand": "bun install",
"postStartCommand": "./scripts/setup-git.sh && ./scripts/setup-nvim.sh",
// Features add additional features to your environment. See https://containers.dev/features // Features add additional features to your environment. See https://containers.dev/features
// Beware: features are not supported on all platforms and may have unintended side-effects. // Beware: features are not supported on all platforms and may have unintended side-effects.
// "features": { "features": {
// "ghcr.io/devcontainers/features/docker-in-docker": { "ghcr.io/tailscale/codespace/tailscale": {
// "moby": false "version": "latest"
// } }
// } }
} }

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
core

8
.ona/automations.yaml Normal file
View File

@@ -0,0 +1,8 @@
services:
expo:
name: Expo Dev Server
description: Expo development server for aris-client
triggeredBy:
- postDevcontainerStart
commands:
start: cd apps/aris-client && ./scripts/run-dev-server.sh

View File

@@ -8,5 +8,5 @@
"ignoreCase": true, "ignoreCase": true,
"newlinesBetween": true "newlinesBetween": true
}, },
"ignorePatterns": [] "ignorePatterns": [".claude", "fixtures"]
} }

42
AGENTS.md Normal file
View File

@@ -0,0 +1,42 @@
# AGENTS.md
## Project
ARIS is an AI-powered personal assistant that aggregates data from various sources into a contextual feed. Monorepo with `packages/` (shared libraries) and `apps/` (applications).
## Commands
- Install: `bun install`
- Test: `bun test` (run in the specific package directory)
- Lint: `bun run lint`
- Format: `bun run format`
- Type check: `bun tsc --noEmit`
Use Bun exclusively. Do not use npm or yarn.
## Code Style
- File names: kebab-case (`data-source.ts`)
- Prefer function declarations over arrow functions
- Never use `any` - use `unknown` and narrow types
- Enums: use const objects with corresponding types:
```typescript
const Priority = {
Low: "Low",
High: "High",
} as const
type Priority = (typeof Priority)[keyof typeof Priority]
```
- File organization: types first, then primary functions, then helpers
## Before Committing
1. Format: `bun run format`
2. Test the modified package: `cd packages/<package> && bun test`
3. Fix all type errors related to your changes
## Git
- Branch: `feat/<task>`, `fix/<task>`, `ci/<task>`, etc.
- Commits: conventional commit format, title <= 50 chars
- Signing: If `GPG_PRIVATE_KEY_PASSPHRASE` env var is available, use it to sign commits with `git commit -S`

View File

@@ -6,10 +6,25 @@ To install dependencies:
bun install bun install
``` ```
To run: ## Packages
### @aris/source-tfl
TfL (Transport for London) feed source for tube, overground, and Elizabeth line alerts.
#### Testing
```bash ```bash
bun run index.ts cd packages/aris-source-tfl
bun run test
``` ```
This project was created using `bun init` in bun v1.3.6. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime. #### Fixtures
Tests use fixture data from real TfL API responses stored in `fixtures/tfl-responses.json`.
To refresh fixtures:
```bash
bun run fetch-fixtures
```

View File

@@ -0,0 +1,14 @@
# PostgreSQL connection string
DATABASE_URL=postgresql://user:password@localhost:5432/aris
# BetterAuth secret (min 32 chars, generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET=
# Base URL of the backend
BETTER_AUTH_URL=http://localhost:3000
# Apple WeatherKit credentials
WEATHERKIT_PRIVATE_KEY=
WEATHERKIT_KEY_ID=
WEATHERKIT_TEAM_ID=
WEATHERKIT_SERVICE_ID=

View File

@@ -0,0 +1,26 @@
{
"name": "@aris/backend",
"version": "0.0.0",
"type": "module",
"main": "src/server.ts",
"scripts": {
"dev": "bun run --watch src/server.ts",
"start": "bun run src/server.ts",
"test": "bun test src/"
},
"dependencies": {
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-tfl": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@hono/trpc-server": "^0.3",
"@trpc/server": "^11",
"arktype": "^2.1.29",
"better-auth": "^1",
"hono": "^4",
"pg": "^8"
},
"devDependencies": {
"@types/pg": "^8"
}
}

View File

@@ -0,0 +1,7 @@
import type { Hono } from "hono"
import { auth } from "./index.ts"
export function registerAuthHandlers(app: Hono): void {
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw))
}

View File

@@ -0,0 +1,10 @@
import { betterAuth } from "better-auth"
import { pool } from "../db.ts"
export const auth = betterAuth({
database: pool,
emailAndPassword: {
enabled: true,
},
})

View File

@@ -0,0 +1,54 @@
import type { Context, Next } from "hono"
import { auth } from "./index.ts"
type SessionUser = typeof auth.$Infer.Session.user
type Session = typeof auth.$Infer.Session.session
export interface SessionVariables {
user: SessionUser | null
session: Session | null
}
/**
* Middleware that attaches session and user to the context.
* Does not reject unauthenticated requests - use requireSession for that.
*/
export async function sessionMiddleware(c: Context, next: Next): Promise<void> {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (session) {
c.set("user", session.user)
c.set("session", session.session)
} else {
c.set("user", null)
c.set("session", null)
}
await next()
}
/**
* Middleware that requires a valid session. Returns 401 if not authenticated.
*/
export async function requireSession(c: Context, next: Next): Promise<Response | void> {
const session = await auth.api.getSession({ headers: c.req.raw.headers })
if (!session) {
return c.json({ error: "Unauthorized" }, 401)
}
c.set("user", session.user)
c.set("session", session.session)
await next()
}
/**
* Get session from headers. Useful for WebSocket upgrade validation.
*/
export async function getSessionFromHeaders(
headers: Headers,
): Promise<{ user: SessionUser; session: Session } | null> {
const session = await auth.api.getSession({ headers })
return session
}

View File

@@ -0,0 +1,5 @@
import { Pool } from "pg"
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})

View File

@@ -0,0 +1,162 @@
import { describe, expect, mock, test } from "bun:test"
import { LocationService } from "../location/service.ts"
import { FeedEngineService } from "./service.ts"
describe("FeedEngineService", () => {
test("engineForUser creates engine on first call", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
expect(engine).toBeDefined()
})
test("engineForUser returns same engine for same user", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).toBe(engine2)
})
test("engineForUser returns different engines for different users", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
const engine2 = service.engineForUser("user-2")
expect(engine1).not.toBe(engine2)
})
test("engineForUser registers sources from all providers", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
})
test("engineForUser works with empty providers array", async () => {
const service = new FeedEngineService([])
const engine = service.engineForUser("user-1")
const result = await engine.refresh()
expect(result.errors).toHaveLength(0)
expect(result.items).toHaveLength(0)
})
test("refresh returns feed result", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const result = await service.refresh("user-1")
expect(result).toHaveProperty("context")
expect(result).toHaveProperty("items")
expect(result).toHaveProperty("errors")
expect(result.context.time).toBeInstanceOf(Date)
})
test("refresh uses location from LocationService", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
// Create engine first, then update location
service.engineForUser("user-1")
locationService.updateUserLocation("user-1", location)
const result = await service.refresh("user-1")
expect(result.context.location).toEqual(location)
})
test("subscribe receives updates", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
// Push location to trigger update
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
// Wait for async update propagation
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).toHaveBeenCalled()
})
test("subscribe returns unsubscribe function", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
const unsubscribe = service.subscribe("user-1", callback)
unsubscribe()
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser stops engine and removes it", async () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const callback = mock()
service.subscribe("user-1", callback)
service.removeUser("user-1")
// Push location - should not trigger update since engine is stopped
locationService.feedSourceForUser("user-1")
locationService.updateUserLocation("user-1", {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
})
await new Promise((resolve) => setTimeout(resolve, 10))
expect(callback).not.toHaveBeenCalled()
})
test("removeUser allows new engine to be created", () => {
const locationService = new LocationService()
const service = new FeedEngineService([locationService])
const engine1 = service.engineForUser("user-1")
service.removeUser("user-1")
const engine2 = service.engineForUser("user-1")
expect(engine1).not.toBe(engine2)
})
})

View File

@@ -0,0 +1,75 @@
import { FeedEngine, type FeedResult, type FeedSource, type FeedSubscriber } from "@aris/core"
/**
* Provides a FeedSource instance for a user.
*/
export interface FeedSourceProvider {
feedSourceForUser(userId: string): FeedSource
}
/**
* Manages FeedEngine instances per user.
*
* Receives FeedSource instances from injected providers and wires them
* into per-user engines. Engines are auto-started on creation.
*/
export class FeedEngineService {
private engines = new Map<string, FeedEngine>()
constructor(private readonly providers: FeedSourceProvider[]) {}
/**
* Get or create a FeedEngine for a user.
* Automatically registers sources and starts the engine.
*/
engineForUser(userId: string): FeedEngine {
let engine = this.engines.get(userId)
if (!engine) {
engine = this.createEngine(userId)
this.engines.set(userId, engine)
}
return engine
}
/**
* Refresh a user's feed.
*/
async refresh(userId: string): Promise<FeedResult> {
const engine = this.engineForUser(userId)
return engine.refresh()
}
/**
* Subscribe to feed updates for a user.
* Returns unsubscribe function.
*/
subscribe(userId: string, callback: FeedSubscriber): () => void {
const engine = this.engineForUser(userId)
return engine.subscribe(callback)
}
/**
* Remove a user's FeedEngine.
* Stops the engine and cleans up resources.
*/
removeUser(userId: string): void {
const engine = this.engines.get(userId)
if (engine) {
engine.stop()
this.engines.delete(userId)
}
}
private createEngine(userId: string): FeedEngine {
const engine = new FeedEngine()
for (const provider of this.providers) {
const source = provider.feedSourceForUser(userId)
engine.register(source)
}
engine.start()
return engine
}
}

View File

@@ -0,0 +1,8 @@
export class UserNotFoundError extends Error {
constructor(
public readonly userId: string,
message?: string,
) {
super(message ? `${message}: user not found: ${userId}` : `User not found: ${userId}`)
}
}

View File

@@ -0,0 +1,37 @@
import { TRPCError } from "@trpc/server"
import { type } from "arktype"
import type { TRPC } from "../trpc/router.ts"
import type { LocationService } from "./service.ts"
import { UserNotFoundError } from "../lib/error.ts"
const locationInput = type({
lat: "number",
lng: "number",
accuracy: "number",
timestamp: "Date",
})
export function createLocationRouter(
t: TRPC,
{ locationService }: { locationService: LocationService },
) {
return t.router({
update: t.procedure.input(locationInput).mutation(({ input, ctx }) => {
try {
locationService.updateUserLocation(ctx.user.id, {
lat: input.lat,
lng: input.lng,
accuracy: input.accuracy,
timestamp: input.timestamp,
})
} catch (error) {
if (error instanceof UserNotFoundError) {
throw new TRPCError({ code: "NOT_FOUND", message: error.message })
}
throw error
}
}),
})
}

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test"
import { UserNotFoundError } from "../lib/error.ts"
import { LocationService } from "./service.ts"
describe("LocationService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new LocationService()
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.location")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("updateUserLocation updates the source", () => {
const service = new LocationService()
const source = service.feedSourceForUser("user-1")
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location)
expect(source.lastLocation).toEqual(location)
})
test("updateUserLocation throws if source does not exist", () => {
const service = new LocationService()
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
expect(() => service.updateUserLocation("user-1", location)).toThrow(UserNotFoundError)
})
test("lastUserLocation returns null for unknown user", () => {
const service = new LocationService()
expect(service.lastUserLocation("unknown")).toBeNull()
})
test("lastUserLocation returns last location", () => {
const service = new LocationService()
service.feedSourceForUser("user-1")
const location1 = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
const location2 = {
lat: 52.0,
lng: -0.2,
accuracy: 5,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location1)
service.updateUserLocation("user-1", location2)
expect(service.lastUserLocation("user-1")).toEqual(location2)
})
test("removeUser removes the source", () => {
const service = new LocationService()
service.feedSourceForUser("user-1")
const location = {
lat: 51.5074,
lng: -0.1278,
accuracy: 10,
timestamp: new Date(),
}
service.updateUserLocation("user-1", location)
service.removeUser("user-1")
expect(service.lastUserLocation("user-1")).toBeNull()
})
test("removeUser allows new source to be created", () => {
const service = new LocationService()
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
})

View File

@@ -0,0 +1,57 @@
import { LocationSource, type Location } from "@aris/source-location"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts"
/**
* Manages LocationSource instances per user.
*/
export class LocationService implements FeedSourceProvider {
private sources = new Map<string, LocationSource>()
/**
* Get or create a LocationSource for a user.
* @param userId - The user's unique identifier
* @returns The user's LocationSource instance
*/
feedSourceForUser(userId: string): LocationSource {
let source = this.sources.get(userId)
if (!source) {
source = new LocationSource()
this.sources.set(userId, source)
}
return source
}
/**
* Update location for a user.
* @param userId - The user's unique identifier
* @param location - The new location data
* @throws {UserNotFoundError} If no source exists for the user
*/
updateUserLocation(userId: string, location: Location): void {
const source = this.sources.get(userId)
if (!source) {
throw new UserNotFoundError(userId)
}
source.pushLocation(location)
}
/**
* Get last known location for a user.
* @param userId - The user's unique identifier
* @returns The last location, or null if none exists
*/
lastUserLocation(userId: string): Location | null {
return this.sources.get(userId)?.lastLocation ?? null
}
/**
* Remove a user's LocationSource.
* @param userId - The user's unique identifier
*/
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -0,0 +1,46 @@
import { trpcServer } from "@hono/trpc-server"
import { Hono } from "hono"
import { registerAuthHandlers } from "./auth/http.ts"
import { LocationService } from "./location/service.ts"
import { createContext } from "./trpc/context.ts"
import { createTRPCRouter } from "./trpc/router.ts"
import { WeatherService } from "./weather/service.ts"
function main() {
const locationService = new LocationService()
const weatherService = new WeatherService({
credentials: {
privateKey: process.env.WEATHERKIT_PRIVATE_KEY!,
keyId: process.env.WEATHERKIT_KEY_ID!,
teamId: process.env.WEATHERKIT_TEAM_ID!,
serviceId: process.env.WEATHERKIT_SERVICE_ID!,
},
})
const trpcRouter = createTRPCRouter({ locationService, weatherService })
const app = new Hono()
app.get("/health", (c) => c.json({ status: "ok" }))
registerAuthHandlers(app)
app.use(
"/trpc/*",
trpcServer({
router: trpcRouter,
createContext,
}),
)
return app
}
const app = main()
export default {
port: 3000,
fetch: app.fetch,
}

View File

@@ -0,0 +1,206 @@
import type { Context } from "@aris/core"
import type { ITflApi, StationLocation, TflLineId, TflLineStatus } from "@aris/source-tfl"
import { describe, expect, test } from "bun:test"
import { UserNotFoundError } from "../lib/error.ts"
import { TflService } from "./service.ts"
class StubTflApi implements ITflApi {
private statuses: TflLineStatus[]
constructor(statuses: TflLineStatus[] = []) {
this.statuses = statuses
}
async fetchLineStatuses(lines?: TflLineId[]): Promise<TflLineStatus[]> {
if (lines) {
return this.statuses.filter((s) => lines.includes(s.lineId))
}
return this.statuses
}
async fetchStations(): Promise<StationLocation[]> {
return [
{
id: "940GZZLUKSX",
name: "King's Cross",
lat: 51.5308,
lng: -0.1238,
lines: ["northern", "victoria"],
},
]
}
}
function createContext(): Context {
return { time: new Date("2026-01-15T12:00:00Z") }
}
const sampleStatuses: TflLineStatus[] = [
{
lineId: "northern",
lineName: "Northern",
severity: "minor-delays",
description: "Minor delays on the Northern line",
},
{
lineId: "victoria",
lineName: "Victoria",
severity: "major-delays",
description: "Severe delays on the Victoria line",
},
{
lineId: "central",
lineName: "Central",
severity: "closure",
description: "Central line suspended",
},
]
describe("TflService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new TflService(new StubTflApi())
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.tfl")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("updateLinesOfInterest mutates the existing source in place", () => {
const service = new TflService(new StubTflApi())
const original = service.feedSourceForUser("user-1")
service.updateLinesOfInterest("user-1", ["northern", "victoria"])
const after = service.feedSourceForUser("user-1")
expect(after).toBe(original)
})
test("updateLinesOfInterest throws if source does not exist", () => {
const service = new TflService(new StubTflApi())
expect(() => service.updateLinesOfInterest("user-1", ["northern"])).toThrow(UserNotFoundError)
})
test("removeUser removes the source", () => {
const service = new TflService(new StubTflApi())
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
test("removeUser clears line configuration", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.updateLinesOfInterest("user-1", ["northern"])
service.removeUser("user-1")
const items = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(items.length).toBe(3)
})
test("shares single api instance across users", () => {
const api = new StubTflApi()
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.feedSourceForUser("user-2")
expect(service.feedSourceForUser("user-1").id).toBe("aris.tfl")
expect(service.feedSourceForUser("user-2").id).toBe("aris.tfl")
})
describe("returned source fetches items", () => {
test("source returns feed items from api", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
expect(items.length).toBe(3)
for (const item of items) {
expect(item.type).toBe("tfl-alert")
expect(item.id).toMatch(/^tfl-alert-/)
expect(typeof item.priority).toBe("number")
expect(item.timestamp).toBeInstanceOf(Date)
}
})
test("source returns items sorted by priority descending", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
for (let i = 1; i < items.length; i++) {
expect(items[i - 1]!.priority).toBeGreaterThanOrEqual(items[i]!.priority)
}
})
test("source returns empty array when no disruptions", async () => {
const api = new StubTflApi([])
const service = new TflService(api)
const source = service.feedSourceForUser("user-1")
const items = await source.fetchItems(createContext())
expect(items).toEqual([])
})
test("updateLinesOfInterest filters items to configured lines", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
const before = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(before.length).toBe(3)
service.updateLinesOfInterest("user-1", ["northern"])
const after = await service.feedSourceForUser("user-1").fetchItems(createContext())
expect(after.length).toBe(1)
expect(after[0]!.data.line).toBe("northern")
})
test("different users get independent line configs", async () => {
const api = new StubTflApi(sampleStatuses)
const service = new TflService(api)
service.feedSourceForUser("user-1")
service.feedSourceForUser("user-2")
service.updateLinesOfInterest("user-1", ["northern"])
service.updateLinesOfInterest("user-2", ["central"])
const items1 = await service.feedSourceForUser("user-1").fetchItems(createContext())
const items2 = await service.feedSourceForUser("user-2").fetchItems(createContext())
expect(items1.length).toBe(1)
expect(items1[0]!.data.line).toBe("northern")
expect(items2.length).toBe(1)
expect(items2[0]!.data.line).toBe("central")
})
})
})

View File

@@ -0,0 +1,40 @@
import { TflSource, type ITflApi, type TflLineId } from "@aris/source-tfl"
import type { FeedSourceProvider } from "../feed/service.ts"
import { UserNotFoundError } from "../lib/error.ts"
/**
* Manages per-user TflSource instances with individual line configuration.
*/
export class TflService implements FeedSourceProvider {
private sources = new Map<string, TflSource>()
constructor(private readonly api: ITflApi) {}
feedSourceForUser(userId: string): TflSource {
let source = this.sources.get(userId)
if (!source) {
source = new TflSource({ client: this.api })
this.sources.set(userId, source)
}
return source
}
/**
* Update monitored lines for a user. Mutates the existing TflSource
* so that references held by FeedEngine remain valid.
* @throws {UserNotFoundError} If no source exists for the user
*/
updateLinesOfInterest(userId: string, lines: TflLineId[]): void {
const source = this.sources.get(userId)
if (!source) {
throw new UserNotFoundError(userId)
}
source.setLinesOfInterest(lines)
}
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -0,0 +1,14 @@
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch"
import { auth } from "../auth/index.ts"
export async function createContext(opts: FetchCreateContextFnOptions) {
const session = await auth.api.getSession({ headers: opts.req.headers })
return {
user: session?.user ?? null,
session: session?.session ?? null,
}
}
export type Context = Awaited<ReturnType<typeof createContext>>

View File

@@ -0,0 +1,50 @@
import { initTRPC, TRPCError } from "@trpc/server"
import type { LocationService } from "../location/service.ts"
import type { WeatherService } from "../weather/service.ts"
import type { Context } from "./context.ts"
import { createLocationRouter } from "../location/router.ts"
interface AuthedContext {
user: NonNullable<Context["user"]>
session: NonNullable<Context["session"]>
}
function createTRPC() {
const t = initTRPC.context<Context>().create()
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user || !ctx.session) {
throw new TRPCError({ code: "UNAUTHORIZED" })
}
return next({
ctx: {
user: ctx.user,
session: ctx.session,
},
})
})
return {
router: t.router,
procedure: t.procedure.use(isAuthed),
}
}
export type TRPC = ReturnType<typeof createTRPC>
export interface TRPCRouterDeps {
locationService: LocationService
weatherService: WeatherService
}
export function createTRPCRouter({ locationService }: TRPCRouterDeps) {
const t = createTRPC()
return t.router({
location: createLocationRouter(t, { locationService }),
})
}
export type TRPCRouter = ReturnType<typeof createTRPCRouter>

View File

@@ -0,0 +1,116 @@
import type { Context } from "@aris/core"
import { LocationKey } from "@aris/source-location"
import {
Units,
WeatherFeedItemType,
type WeatherKitClient,
type WeatherKitResponse,
} from "@aris/source-weatherkit"
import { describe, expect, test } from "bun:test"
import fixture from "../../../../packages/aris-source-weatherkit/fixtures/san-francisco.json"
import { WeatherService } from "./service.ts"
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
function createMockClient(response: WeatherKitResponse): WeatherKitClient {
return {
fetch: async () => response,
}
}
function createMockContext(location?: { lat: number; lng: number }): Context {
const ctx: Context = { time: new Date("2026-01-17T00:00:00Z") }
if (location) {
ctx[LocationKey] = { ...location, accuracy: 10, timestamp: new Date() }
}
return ctx
}
describe("WeatherService", () => {
test("feedSourceForUser creates source on first call", () => {
const service = new WeatherService({ client: mockClient })
const source = service.feedSourceForUser("user-1")
expect(source).toBeDefined()
expect(source.id).toBe("aris.weather")
})
test("feedSourceForUser returns same source for same user", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).toBe(source2)
})
test("feedSourceForUser returns different sources for different users", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
const source2 = service.feedSourceForUser("user-2")
expect(source1).not.toBe(source2)
})
test("feedSourceForUser applies hourly and daily limits", async () => {
const service = new WeatherService({
client: mockClient,
hourlyLimit: 3,
dailyLimit: 2,
})
const source = service.feedSourceForUser("user-1")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const hourly = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const daily = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourly).toHaveLength(3)
expect(daily).toHaveLength(2)
})
test("feedSourceForUser applies units", async () => {
const service = new WeatherService({
client: mockClient,
units: Units.imperial,
})
const source = service.feedSourceForUser("user-1")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await source.fetchItems(context)
const current = items.find((i) => i.type === WeatherFeedItemType.current)
expect(current).toBeDefined()
// Fixture has ~15.87°C, imperial should be ~60.6°F
expect(current!.data.temperature).toBeGreaterThan(50)
})
test("removeUser removes the source", () => {
const service = new WeatherService({ client: mockClient })
service.feedSourceForUser("user-1")
service.removeUser("user-1")
// After removal, feedSourceForUser should create a new instance
const source2 = service.feedSourceForUser("user-1")
expect(source2).toBeDefined()
})
test("removeUser allows new source to be created", () => {
const service = new WeatherService({ client: mockClient })
const source1 = service.feedSourceForUser("user-1")
service.removeUser("user-1")
const source2 = service.feedSourceForUser("user-1")
expect(source1).not.toBe(source2)
})
test("removeUser is no-op for unknown user", () => {
const service = new WeatherService({ client: mockClient })
expect(() => service.removeUser("unknown")).not.toThrow()
})
})

View File

@@ -0,0 +1,40 @@
import { WeatherSource, type WeatherSourceOptions } from "@aris/source-weatherkit"
import type { FeedSourceProvider } from "../feed/service.ts"
/**
* Options forwarded to every per-user WeatherSource.
* Must include either `credentials` or `client` (same requirement as WeatherSourceOptions).
*/
export type WeatherServiceOptions = WeatherSourceOptions
/**
* Manages WeatherSource instances per user.
*/
export class WeatherService implements FeedSourceProvider {
private sources = new Map<string, WeatherSource>()
private readonly options: WeatherServiceOptions
constructor(options: WeatherServiceOptions) {
this.options = options
}
/**
* Get or create a WeatherSource for a user.
*/
feedSourceForUser(userId: string): WeatherSource {
let source = this.sources.get(userId)
if (!source) {
source = new WeatherSource(this.options)
this.sources.set(userId, source)
}
return source
}
/**
* Remove a user's WeatherSource.
*/
removeUser(userId: string): void {
this.sources.delete(userId)
}
}

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.json",
"include": ["src"]
}

43
apps/aris-client/.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android

View File

@@ -0,0 +1 @@
{ "recommendations": ["expo.vscode-expo-tools"] }

View File

@@ -0,0 +1,7 @@
{
"editor.codeActionsOnSave": {
"source.fixAll": "explicit",
"source.organizeImports": "explicit",
"source.sortMembers": "explicit"
}
}

View File

@@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.

62
apps/aris-client/app.json Normal file
View File

@@ -0,0 +1,62 @@
{
"expo": {
"name": "Aris",
"slug": "aris-client",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "aris",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"infoPlist": {
"NSAppTransportSecurity": {
"NSAllowsArbitraryLoads": true
},
"ITSAppUsesNonExemptEncryption": false
},
"bundleIdentifier": "sh.nym.aris"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"package": "sh.nym.aris"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-font"
],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
},
"extra": {
"router": {},
"eas": {
"projectId": "61092d23-36aa-418e-929d-ea40dc912e8f"
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

21
apps/aris-client/eas.json Normal file
View File

@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 18.0.1",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}

View File

@@ -0,0 +1,10 @@
// https://docs.expo.dev/guides/using-eslint/
const { defineConfig } = require("eslint/config")
const expoConfig = require("eslint-config-expo/flat")
module.exports = defineConfig([
expoConfig,
{
ignores: ["dist/*"],
},
])

View File

@@ -0,0 +1,53 @@
{
"name": "aris-client",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "./scripts/run-dev-server.sh",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "expo lint",
"build:ios": "eas build --profile development --platform ios --non-interactive",
"debugger": "bun run scripts/open-debugger.ts"
},
"dependencies": {
"@expo-google-fonts/inter": "^0.4.2",
"@expo/vector-icons": "^15.0.3",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"expo": "~54.0.33",
"expo-constants": "~18.0.13",
"expo-dev-client": "~6.0.20",
"expo-font": "~14.0.11",
"expo-haptics": "~15.0.8",
"expo-image": "~3.0.11",
"expo-linking": "~8.0.11",
"expo-location": "~19.0.8",
"expo-router": "~6.0.23",
"expo-splash-screen": "~31.0.13",
"expo-status-bar": "~3.0.9",
"expo-symbols": "~1.0.8",
"expo-system-ui": "~6.0.9",
"expo-web-browser": "~15.0.10",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@types/react": "~19.1.0",
"eas-cli": "^18.0.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"typescript": "~5.9.2"
}
}

View File

@@ -0,0 +1,127 @@
// Reverse proxy that sits in front of Metro so that all requests
// (including those arriving via Tailscale or Ona port-forwarding) reach
// Metro as loopback connections. This satisfies the isLocalSocket check
// in Expo's debug middleware, making /debugger-frontend, /json, and
// /open-debugger accessible from a remote browser.
import type { ServerWebSocket } from "bun"
const PROXY_PORT = parseInt(process.env.PROXY_PORT || "8080", 10)
const METRO_PORT = parseInt(process.env.METRO_PORT || "8081", 10)
const METRO_BASE = `http://127.0.0.1:${METRO_PORT}`
function forwardHeaders(headers: Headers): Headers {
const result = new Headers(headers)
result.delete("origin")
result.delete("referer")
result.set("host", `127.0.0.1:${METRO_PORT}`)
return result
}
interface WsData {
upstream: WebSocket
isDevice: boolean
}
Bun.serve<WsData>({
port: PROXY_PORT,
async fetch(req, server) {
const url = new URL(req.url)
// WebSocket upgrade — bridge to Metro's ws endpoint
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
const wsUrl = `ws://127.0.0.1:${METRO_PORT}${url.pathname}${url.search}`
const upstream = new WebSocket(wsUrl)
// Wait for upstream to connect before upgrading the client
try {
await new Promise<void>((resolve, reject) => {
upstream.addEventListener("open", () => resolve())
upstream.addEventListener("error", () => reject(new Error("upstream ws failed")))
})
} catch {
return new Response("Upstream WebSocket unavailable", { status: 502 })
}
const isDevice = url.pathname.startsWith("/inspector/device")
const ok = server.upgrade(req, { data: { upstream, isDevice } })
if (!ok) {
upstream.close()
return new Response("WebSocket upgrade failed", { status: 500 })
}
return undefined
}
// HTTP proxy
const upstream = `${METRO_BASE}${url.pathname}${url.search}`
const res = await fetch(upstream, {
method: req.method,
headers: forwardHeaders(req.headers),
body: req.body,
redirect: "manual",
})
return new Response(res.body, {
status: res.status,
statusText: res.statusText,
headers: res.headers,
})
},
websocket: {
message(ws: ServerWebSocket<WsData>, msg) {
ws.data.upstream.send(msg)
},
open(ws: ServerWebSocket<WsData>) {
const { upstream } = ws.data
upstream.addEventListener("message", (ev) => {
if (typeof ev.data === "string") {
ws.send(ev.data)
} else if (ev.data instanceof ArrayBuffer) {
ws.sendBinary(new Uint8Array(ev.data))
}
})
upstream.addEventListener("close", () => ws.close())
upstream.addEventListener("error", () => ws.close())
// Print debugger URL shortly after a device connects,
// giving Metro time to register the target.
if (ws.data.isDevice) {
setTimeout(() => printDebuggerUrl(), 1000)
}
},
close(ws: ServerWebSocket<WsData>) {
ws.data.upstream.close()
},
},
})
const tsIp = await Bun.$`tailscale ip -4`.text().then((s) => s.trim())
async function printDebuggerUrl() {
const base = `http://${tsIp}:${PROXY_PORT}`
const res = await fetch(`${METRO_BASE}/json`)
if (!res.ok) return
interface DebugTarget {
webSocketDebuggerUrl: string
reactNative?: {
capabilities?: { prefersFuseboxFrontend?: boolean }
}
}
const targets: DebugTarget[] = await res.json()
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) return
const wsPath = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
console.log(
`\n React Native DevTools:\n ${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsPath)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true\n`,
)
}
console.log(`[proxy] listening on :${PROXY_PORT}, forwarding to 127.0.0.1:${METRO_PORT}`)

View File

@@ -0,0 +1,52 @@
// Opens React Native DevTools in Chrome, connected to the first
// available Hermes debug target. Requires Metro + proxy to be running.
import { $ } from "bun"
const PROXY_PORT = process.env.PROXY_PORT || "8080"
const METRO_PORT = process.env.METRO_PORT || "8081"
const tsIp = (await $`tailscale ip -4`.text()).trim()
const base = `http://${tsIp}:${PROXY_PORT}`
interface DebugTarget {
devtoolsFrontendUrl: string
webSocketDebuggerUrl: string
reactNative?: {
capabilities?: {
prefersFuseboxFrontend?: boolean
}
}
}
const res = await fetch(`${base}/json`)
if (!res.ok) {
console.error("Failed to fetch /json — is Metro running?")
process.exit(1)
}
const targets: DebugTarget[] = await res.json()
const target = targets.find((t) => t.reactNative?.capabilities?.prefersFuseboxFrontend)
if (!target) {
console.error("No debug target found. Is the app connected?")
process.exit(1)
}
const wsUrl = target.webSocketDebuggerUrl
.replace(/^ws:\/\//, "")
.replace(`127.0.0.1:${METRO_PORT}`, `${tsIp}:${PROXY_PORT}`)
const url = `${base}/debugger-frontend/rn_fusebox.html?ws=${encodeURIComponent(wsUrl)}&sources.hide_add_folder=true&unstable_enableNetworkPanel=true`
console.log(url)
// Open in Chrome app mode if on macOS
try {
await $`open -a "Google Chrome" --args --app=${url}`.quiet()
} catch {
try {
await $`xdg-open ${url}`.quiet()
} catch {
console.log("Open the URL above in Chrome.")
}
}

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs")
const path = require("path")
const readline = require("readline")
const root = process.cwd()
const oldDirs = ["app", "components", "hooks", "constants", "scripts"]
const exampleDir = "app-example"
const newAppDir = "app"
const exampleDirPath = path.join(root, exampleDir)
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true })
console.log(`📁 /${exampleDir} directory created.`)
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir)
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir)
await fs.promises.rename(oldDirPath, newDirPath)
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`)
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true })
console.log(`❌ /${dir} deleted.`)
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`)
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir)
await fs.promises.mkdir(newAppDirPath, { recursive: true })
console.log("\n📁 New /app directory created.")
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx")
await fs.promises.writeFile(indexPath, indexContent)
console.log("📄 app/index.tsx created.")
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx")
await fs.promises.writeFile(layoutPath, layoutContent)
console.log("📄 app/_layout.tsx created.")
console.log("\n✅ Project reset complete. Next steps:")
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`,
)
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`)
}
}
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y"
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close())
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.")
rl.close()
}
},
)

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
PROXY_PORT=8080
METRO_PORT=8081
# Start a reverse proxy so Metro sees all requests as loopback.
# This makes debugger endpoints (/debugger-frontend, /json, /open-debugger)
# accessible through the Tailscale IP.
PROXY_PORT=$PROXY_PORT METRO_PORT=$METRO_PORT bun run scripts/dev-proxy.ts &
PROXY_PID=$!
trap "kill $PROXY_PID 2>/dev/null" EXIT
EXPO_PACKAGER_PROXY_URL=http://$(tailscale ip -4):$PROXY_PORT bunx expo start --localhost -p $METRO_PORT

View File

@@ -0,0 +1,36 @@
import { Tabs } from "expo-router"
import React from "react"
import { HapticTab } from "@/components/haptic-tab"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export default function TabLayout() {
const colorScheme = useColorScheme()
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? "light"].tint,
headerShown: false,
tabBarButton: HapticTab,
}}
>
<Tabs.Screen
name="index"
options={{
title: "Home",
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: "Explore",
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
)
}

View File

@@ -0,0 +1,114 @@
import { Image } from "expo-image"
import { Platform, StyleSheet } from "react-native"
import { ExternalLink } from "@/components/external-link"
import ParallaxScrollView from "@/components/parallax-scroll-view"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
import { Collapsible } from "@/components/ui/collapsible"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Fonts } from "@/constants/theme"
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: "#D0D0D0", dark: "#353636" }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}
>
<ThemedView style={styles.titleContainer}>
<ThemedText
type="title"
style={{
fontFamily: Fonts.rounded,
}}
>
Explore
</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{" "}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{" "}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{" "}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{" "}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image
source={require("@assets/images/react-logo.png")}
style={{ width: 100, height: 100, alignSelf: "center" }}
/>
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{" "}
<ThemedText type="defaultSemiBold">useColorScheme()</ThemedText> hook lets you inspect
what the user&apos;s current color scheme is, and so you can adjust UI colors accordingly.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{" "}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful{" "}
<ThemedText type="defaultSemiBold" style={{ fontFamily: Fonts.mono }}>
react-native-reanimated
</ThemedText>{" "}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{" "}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
)
}
const styles = StyleSheet.create({
headerImage: {
color: "#808080",
bottom: -90,
left: -35,
position: "absolute",
},
titleContainer: {
flexDirection: "row",
gap: 8,
},
})

View File

@@ -0,0 +1,96 @@
import { Image } from "expo-image"
import { Link } from "expo-router"
import { Platform, StyleSheet } from "react-native"
import { HelloWave } from "@/components/hello-wave"
import ParallaxScrollView from "@/components/parallax-scroll-view"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
export default function HomeScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: "#A1CEDC", dark: "#1D3D47" }}
headerImage={
<Image source={require("@assets/images/partial-react-logo.png")} style={styles.reactLogo} />
}
>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{" "}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: "cmd + d",
android: "cmd + m",
web: "F12",
})}
</ThemedText>{" "}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<Link href="/modal">
<Link.Trigger>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
</Link.Trigger>
<Link.Preview />
<Link.Menu>
<Link.MenuAction title="Action" icon="cube" onPress={() => alert("Action pressed")} />
<Link.MenuAction
title="Share"
icon="square.and.arrow.up"
onPress={() => alert("Share pressed")}
/>
<Link.Menu title="More" icon="ellipsis">
<Link.MenuAction
title="Delete"
icon="trash"
destructive
onPress={() => alert("Delete pressed")}
/>
</Link.Menu>
</Link.Menu>
</Link>
<ThemedText>
{`Tap the Explore tab to learn more about what's included in this starter app.`}
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
{`When you're ready, run `}
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{" "}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{" "}
<ThemedText type="defaultSemiBold">app</ThemedText> to{" "}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>
)
}
const styles = StyleSheet.create({
titleContainer: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: "absolute",
},
})

View File

@@ -0,0 +1,23 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native"
import { Stack } from "expo-router"
import { StatusBar } from "expo-status-bar"
import "react-native-reanimated"
import { useColorScheme } from "@/hooks/use-color-scheme"
export const unstable_settings = {
anchor: "(tabs)",
}
export default function RootLayout() {
const colorScheme = useColorScheme()
return (
<ThemeProvider value={colorScheme === "dark" ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="modal" options={{ presentation: "modal", title: "Modal" }} />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
)
}

View File

@@ -0,0 +1,29 @@
import { Link } from "expo-router"
import { StyleSheet } from "react-native"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
export default function ModalScreen() {
return (
<ThemedView style={styles.container}>
<ThemedText type="title">This is a modal</ThemedText>
<Link href="/" dismissTo style={styles.link}>
<ThemedText type="link">Go to home screen</ThemedText>
</Link>
</ThemedView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: "center",
justifyContent: "center",
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
})

View File

@@ -0,0 +1,25 @@
import { Href, Link } from "expo-router"
import { openBrowserAsync, WebBrowserPresentationStyle } from "expo-web-browser"
import { type ComponentProps } from "react"
type Props = Omit<ComponentProps<typeof Link>, "href"> & { href: Href & string }
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (process.env.EXPO_OS !== "web") {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault()
// Open the link in an in-app browser.
await openBrowserAsync(href, {
presentationStyle: WebBrowserPresentationStyle.AUTOMATIC,
})
}
}}
/>
)
}

View File

@@ -0,0 +1,18 @@
import { BottomTabBarButtonProps } from "@react-navigation/bottom-tabs"
import { PlatformPressable } from "@react-navigation/elements"
import * as Haptics from "expo-haptics"
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === "ios") {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}
props.onPressIn?.(ev)
}}
/>
)
}

View File

@@ -0,0 +1,20 @@
import Animated from "react-native-reanimated"
export function HelloWave() {
return (
<Animated.Text
style={{
fontSize: 28,
lineHeight: 32,
marginTop: -6,
animationName: {
"50%": { transform: [{ rotate: "25deg" }] },
},
animationIterationCount: 4,
animationDuration: "300ms",
}}
>
👋
</Animated.Text>
)
}

View File

@@ -0,0 +1,82 @@
import type { PropsWithChildren, ReactElement } from "react"
import { StyleSheet } from "react-native"
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollOffset,
} from "react-native-reanimated"
import { ThemedView } from "@/components/themed-view"
import { useColorScheme } from "@/hooks/use-color-scheme"
import { useThemeColor } from "@/hooks/use-theme-color"
const HEADER_HEIGHT = 250
type Props = PropsWithChildren<{
headerImage: ReactElement
headerBackgroundColor: { dark: string; light: string }
}>
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const backgroundColor = useThemeColor({}, "background")
const colorScheme = useColorScheme() ?? "light"
const scrollRef = useAnimatedRef<Animated.ScrollView>()
const scrollOffset = useScrollOffset(scrollRef)
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75],
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
}
})
return (
<Animated.ScrollView
ref={scrollRef}
style={{ backgroundColor, flex: 1 }}
scrollEventThrottle={16}
>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}
>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
)
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: "hidden",
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: "hidden",
},
})

View File

@@ -0,0 +1,60 @@
import { StyleSheet, Text, type TextProps } from "react-native"
import { useThemeColor } from "@/hooks/use-theme-color"
export type ThemedTextProps = TextProps & {
lightColor?: string
darkColor?: string
type?: "default" | "title" | "defaultSemiBold" | "subtitle" | "link"
}
export function ThemedText({
style,
lightColor,
darkColor,
type = "default",
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, "text")
return (
<Text
style={[
{ color },
type === "default" ? styles.default : undefined,
type === "title" ? styles.title : undefined,
type === "defaultSemiBold" ? styles.defaultSemiBold : undefined,
type === "subtitle" ? styles.subtitle : undefined,
type === "link" ? styles.link : undefined,
style,
]}
{...rest}
/>
)
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: "600",
},
title: {
fontSize: 32,
fontWeight: "bold",
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: "bold",
},
link: {
lineHeight: 30,
fontSize: 16,
color: "#0a7ea4",
},
})

View File

@@ -0,0 +1,14 @@
import { View, type ViewProps } from "react-native"
import { useThemeColor } from "@/hooks/use-theme-color"
export type ThemedViewProps = ViewProps & {
lightColor?: string
darkColor?: string
}
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, "background")
return <View style={[{ backgroundColor }, style]} {...otherProps} />
}

View File

@@ -0,0 +1,46 @@
import { PropsWithChildren, useState } from "react"
import { StyleSheet, TouchableOpacity } from "react-native"
import { ThemedText } from "@/components/themed-text"
import { ThemedView } from "@/components/themed-view"
import { IconSymbol } from "@/components/ui/icon-symbol"
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false)
const theme = useColorScheme() ?? "light"
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}
>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === "light" ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? "90deg" : "0deg" }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
)
}
const styles = StyleSheet.create({
heading: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
})

View File

@@ -0,0 +1,32 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from "expo-symbols"
import { StyleProp, ViewStyle } from "react-native"
export function IconSymbol({
name,
size = 24,
color,
style,
weight = "regular",
}: {
name: SymbolViewProps["name"]
size?: number
color: string
style?: StyleProp<ViewStyle>
weight?: SymbolWeight
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
)
}

View File

@@ -0,0 +1,41 @@
// Fallback for using MaterialIcons on Android and web.
import MaterialIcons from "@expo/vector-icons/MaterialIcons"
import { SymbolWeight, SymbolViewProps } from "expo-symbols"
import { ComponentProps } from "react"
import { OpaqueColorValue, type StyleProp, type TextStyle } from "react-native"
type IconMapping = Record<SymbolViewProps["name"], ComponentProps<typeof MaterialIcons>["name"]>
type IconSymbolName = keyof typeof MAPPING
/**
* Add your SF Symbols to Material Icons mappings here.
* - see Material Icons in the [Icons Directory](https://icons.expo.fyi).
* - see SF Symbols in the [SF Symbols](https://developer.apple.com/sf-symbols/) app.
*/
const MAPPING = {
"house.fill": "home",
"paperplane.fill": "send",
"chevron.left.forwardslash.chevron.right": "code",
"chevron.right": "chevron-right",
} as IconMapping
/**
* An icon component that uses native SF Symbols on iOS, and Material Icons on Android and web.
* This ensures a consistent look across platforms, and optimal resource usage.
* Icon `name`s are based on SF Symbols and require manual mapping to Material Icons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName
size?: number
color: string | OpaqueColorValue
style?: StyleProp<TextStyle>
weight?: SymbolWeight
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />
}

View File

@@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from "react-native"
const tintColorLight = "#0a7ea4"
const tintColorDark = "#fff"
export const Colors = {
light: {
text: "#11181C",
background: "#fff",
tint: tintColorLight,
icon: "#687076",
tabIconDefault: "#687076",
tabIconSelected: tintColorLight,
},
dark: {
text: "#ECEDEE",
background: "#151718",
tint: tintColorDark,
icon: "#9BA1A6",
tabIconDefault: "#9BA1A6",
tabIconSelected: tintColorDark,
},
}
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: "system-ui",
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: "ui-serif",
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: "ui-rounded",
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: "ui-monospace",
},
default: {
sans: "normal",
serif: "serif",
rounded: "normal",
mono: "monospace",
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
})

View File

@@ -0,0 +1 @@
export { useColorScheme } from "react-native"

View File

@@ -0,0 +1,21 @@
import { useEffect, useState } from "react"
import { useColorScheme as useRNColorScheme } from "react-native"
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false)
useEffect(() => {
setHasHydrated(true)
}, [])
const colorScheme = useRNColorScheme()
if (hasHydrated) {
return colorScheme
}
return "light"
}

View File

@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from "@/constants/theme"
import { useColorScheme } from "@/hooks/use-color-scheme"
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) {
const theme = useColorScheme() ?? "light"
const colorFromProps = props[theme]
if (colorFromProps) {
return colorFromProps
} else {
return Colors[theme][colorName]
}
}

View File

@@ -0,0 +1,11 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"],
"@assets/*": ["./assets/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

3098
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,306 @@
# Backend Service Architecture: Per-User Refactor
## Problem Statement
The current backend uses a **per-source service** pattern: each source type (Location, Weather, TFL) has its own `XxxService` class that manages a `Map<userId, SourceInstance>`. Adding a new source requires:
1. A new `XxxService` class with identical boilerplate (~30-40 lines: Map, get-or-create, removeUser)
2. Wiring it into `server.ts` constructor
3. Passing it to `FeedEngineService`
4. Optionally adding source-specific tRPC routes
With 3 sources this is manageable. With 10+ (calendar, music, transit, news, etc.) it becomes:
- **Repetitive**: Every service class repeats the same Map + get-or-create + removeUser pattern
- **Fragmented lifecycle**: User cleanup requires calling `removeUser` on every service independently
- **No user-level config**: No unified place to store which sources a user has enabled or their per-source settings
- **Hard to reason about**: User state is scattered across N independent Maps
### Current Flow
```
server.ts
├── new LocationService() ← owns Map<userId, LocationSource>
├── new WeatherService(creds) ← owns Map<userId, WeatherSource>
├── new TflService(api) ← owns Map<userId, TflSource>
└── FeedEngineService([loc, weather, tfl])
└── owns Map<userId, FeedEngine>
└── on create: asks each service for feedSourceForUser(userId)
```
4 independent Maps for 3 sources. Each user's state lives in 4 different places.
## Scope
**Backend only** (`apps/aris-backend`). No changes to `aris-core` or source packages (`packages/aris-source-*`). The `FeedSource` interface and source implementations remain unchanged.
## Architectural Options
### Option A: UserSession Object
A single `UserSession` class owns everything for one user. A `UserSessionManager` is the only top-level Map.
```typescript
class UserSession {
readonly userId: string
readonly engine: FeedEngine
private sources: Map<string, FeedSource>
constructor(userId: string, sourceFactories: SourceFactory[]) {
this.engine = new FeedEngine()
this.sources = new Map()
for (const factory of sourceFactories) {
const source = factory.create()
this.sources.set(source.id, source)
this.engine.register(source)
}
this.engine.start()
}
getSource<T extends FeedSource>(id: string): T | undefined {
return this.sources.get(id) as T | undefined
}
destroy(): void {
this.engine.stop()
this.sources.clear()
}
}
class UserSessionManager {
private sessions = new Map<string, UserSession>()
getOrCreate(userId: string): UserSession { ... }
remove(userId: string): void { ... } // single cleanup point
}
```
**Source-specific operations** use typed accessors:
```typescript
const session = manager.getOrCreate(userId)
const location = session.getSource<LocationSource>("location")
location?.pushLocation({ lat: 51.5, lng: -0.1, ... })
```
**Pros:**
- Single Map, single cleanup point
- All user state co-located
- Easy to add TTL/eviction at one level
- Source factories are simple functions, no service classes needed
**Cons:**
- `getSource<T>("id")` requires callers to know the source ID string and cast type
- Shared resources (e.g., TFL API client) need to be passed through factories
### Option B: Source Registry with Factories
Keep `FeedEngineService` but replace per-source service classes with a registry of factory functions. No `XxxService` classes at all.
```typescript
interface SourceFactory {
readonly sourceId: string
create(userId: string): FeedSource
}
// Weather factory — closure over shared credentials
function weatherSourceFactory(creds: WeatherKitCredentials): SourceFactory {
return {
sourceId: "weather",
create: () => new WeatherSource({ credentials: creds }),
}
}
// TFL factory — closure over shared API client
function tflSourceFactory(api: ITflApi): SourceFactory {
return {
sourceId: "tfl",
create: () => new TflSource({ client: api }),
}
}
class FeedEngineService {
private engines = new Map<string, FeedEngine>()
private userSources = new Map<string, Map<string, FeedSource>>()
constructor(private readonly factories: SourceFactory[]) {}
engineForUser(userId: string): FeedEngine { ... }
getSourceForUser<T extends FeedSource>(userId: string, sourceId: string): T | undefined { ... }
removeUser(userId: string): void { ... } // cleans up engine + all sources
}
```
**Pros:**
- Minimal change from current structure — `FeedEngineService` evolves, services disappear
- Factory functions are 5-10 lines each, no classes
- Shared resources handled naturally via closures
**Cons:**
- `FeedEngineService` grows in responsibility (engine + source tracking + source access)
- Still two Maps (engines + userSources), though co-located
### Option C: UserSession + Typed Source Handles (Recommended)
Combines Option A's co-location with type-safe source access. `UserSession` owns everything. Source-specific operations go through **source handles** — thin typed wrappers registered at setup time.
```typescript
// Source handle: typed wrapper for source-specific operations
interface SourceHandle<T extends FeedSource = FeedSource> {
readonly source: T
}
class UserSession {
readonly engine: FeedEngine
private handles = new Map<string, SourceHandle>()
register<T extends FeedSource>(source: T): SourceHandle<T> {
this.engine.register(source)
const handle: SourceHandle<T> = { source }
this.handles.set(source.id, handle)
return handle
}
destroy(): void {
this.engine.stop()
this.handles.clear()
}
}
// In setup code — handles are typed at creation time
function createSession(userId: string, deps: SessionDeps): UserSession {
const session = new UserSession(userId)
const locationHandle = session.register(new LocationSource())
const weatherHandle = session.register(new WeatherSource(deps.weatherCreds))
const tflHandle = session.register(new TflSource({ client: deps.tflApi }))
return session
}
```
**Source-specific operations** use the typed handles returned at registration:
```typescript
// In the tRPC router or wherever source-specific ops happen:
// The handle is obtained during session setup and stored where needed
locationHandle.source.pushLocation({ ... })
tflHandle.source.setLinesOfInterest(["northern"])
```
**Pros:**
- Single Map, single cleanup
- Type-safe source access without string-based lookups or casts
- No boilerplate service classes
- Handles can be extended later (e.g., add per-source config, metrics)
- Shared resources passed directly to constructors
**Cons:**
- Handles need to be threaded to where they're used (tRPC routers, etc.)
- Slightly more setup code in the factory function
## Source-Specific Operations: Approaches
Orthogonal to the session model, there are three ways to handle operations like `pushLocation` or `setLinesOfInterest`:
### Approach 1: Direct Source Access (Recommended)
Callers get a typed reference to the source and call methods directly. This is what all three options above use in different ways.
```typescript
locationSource.pushLocation(location)
tflSource.setLinesOfInterest(lines)
```
**Why this works:** Source packages already define these methods. The backend just needs to expose the source instance to the right caller. No new abstraction needed.
### Approach 2: Command Dispatch
A generic `dispatch(command)` method on the session routes typed commands to sources.
```typescript
session.dispatch({ type: "location.update", payload: { lat: 51.5, ... } })
```
**Tradeoff:** Adds indirection and a command type registry. Useful if sources are dynamically loaded plugins, but over-engineered for the current case where sources are known at compile time.
### Approach 3: Context-Only
All input goes through `FeedEngine` context updates. Sources react to context changes.
```typescript
engine.pushContext({ [LocationKey]: location })
// LocationSource picks this up via onContextUpdate
```
**Tradeoff:** Location already works this way (it's a context provider). But not all operations map to context — `setLinesOfInterest` is configuration, not context. Would require stretching the context concept.
## User Source Configuration (DB-Persisted)
Regardless of which option is chosen, user source config needs a storage model:
```sql
CREATE TABLE user_source_config (
user_id TEXT NOT NULL REFERENCES users(id),
source_id TEXT NOT NULL, -- e.g., "weather", "tfl", "location"
enabled BOOLEAN NOT NULL DEFAULT true,
config JSONB NOT NULL DEFAULT '{}', -- source-specific settings
PRIMARY KEY (user_id, source_id)
);
```
On session creation:
1. Load `user_source_config` rows for the user
2. Only create sources where `enabled = true`
3. Pass `config` JSON to the source factory/constructor
New users get default config rows inserted on first login.
## Recommendation
**Option C (UserSession + Typed Source Handles)** with **Approach 1 (Direct Source Access)**.
Rationale:
- Eliminates all per-source service boilerplate
- Single user lifecycle management point
- Type-safe without string-based lookups in hot paths
- Minimal new abstraction — `UserSession` is a thin container, not a framework
- Handles are just typed references, not a new pattern to learn
- Natural extension point for per-user config loading from DB
## Acceptance Criteria
1. **No per-source service classes**: `LocationService`, `WeatherService`, `TflService` are removed
2. **Single user state container**: All per-user state (engine, sources) lives in one object
3. **Single cleanup**: Removing a user requires one call, not N
4. **Type-safe source access**: Source-specific operations don't require string-based lookups or unsafe casts at call sites
5. **Existing tests pass**: `FeedEngineService` tests are migrated to the new structure
6. **tRPC routes work**: Location update route works through the new architecture
7. **DB config table**: `user_source_config` table exists; session creation reads from it
8. **Default config**: New users get default source config on first session
## Implementation Steps
1. Create `user_source_config` DB table and migration
2. Create `UserSession` class with `register()`, `destroy()`, typed handle return
3. Create `UserSessionManager` with `getOrCreate()`, `remove()`, config loading
4. Create `createSession()` factory that reads DB config and registers enabled sources
5. Refactor `server.ts` to use `UserSessionManager` instead of individual services
6. Refactor tRPC router to receive session/handles instead of individual services
7. Delete `LocationService`, `WeatherService`, `TflService` classes
8. Migrate existing tests to new structure
9. Add tests for session lifecycle (create, destroy, config loading)
## Open Questions
- **TTL/eviction**: Should `UserSessionManager` handle idle session cleanup? (Currently deferred in backend-spec.md)
- **Hot reload config**: If a user changes their source config, should the session be recreated or patched in-place?
- **Shared source instances**: Some sources (e.g., TFL) share an API client. Should the factory receive shared deps, or should there be a DI container?

193
docs/backend-spec.md Normal file
View File

@@ -0,0 +1,193 @@
# ARIS Backend Specification
## Problem Statement
ARIS needs a backend service that manages per-user FeedEngine instances and delivers real-time feed updates to clients. The backend must handle authentication, maintain WebSocket connections for live updates, and accept context updates (like location) that trigger feed recalculations.
## Requirements
### Authentication
- Email/password authentication using BetterAuth
- PostgreSQL for session and user storage
- Session tokens validated via `Authorization: Bearer <token>` header
- Auth endpoints exposed via BetterAuth's built-in routes
### FeedEngine Management
- Each authenticated user gets their own FeedEngine instance
- Instances are cached in memory with a 30-minute TTL
- TTL resets on any activity (WebSocket message, location update)
- Default sources registered for each user: `LocationSource`, `WeatherSource`, `TflSource`
- Source configuration is hardcoded initially (customization deferred)
### WebSocket Connection
- Single endpoint: `GET /ws` (upgrades to WebSocket)
- Authentication via `Authorization: Bearer <token>` header on upgrade request
- Rejected before upgrade if token is invalid
- Multiple connections per user allowed (e.g., multiple devices)
- All connections for a user receive the same feed updates
- On connect: immediately send current feed state
### JSON-RPC Protocol
All WebSocket communication uses JSON-RPC 2.0.
**Client → Server (Requests):**
```json
{ "jsonrpc": "2.0", "method": "location.update", "params": { "lat": 51.5, "lng": -0.1, "accuracy": 10, "timestamp": "2025-01-01T12:00:00Z" }, "id": 1 }
{ "jsonrpc": "2.0", "method": "feed.refresh", "params": {}, "id": 2 }
```
**Server → Client (Responses):**
```json
{ "jsonrpc": "2.0", "result": { "ok": true }, "id": 1 }
```
**Server → Client (Notifications - no id):**
```json
{ "jsonrpc": "2.0", "method": "feed.update", "params": { "items": [...], "errors": [...] } }
```
### JSON-RPC Methods
| Method | Params | Description |
| ----------------- | ----------------------------------- | ------------------------------------------- |
| `location.update` | `{ lat, lng, accuracy, timestamp }` | Push location update, triggers feed refresh |
| `feed.refresh` | `{}` | Force manual feed refresh |
### Server Notifications
| Method | Params | Description |
| ------------- | ---------------------------- | ---------------------- |
| `feed.update` | `{ context, items, errors }` | Feed state changed |
| `error` | `{ code, message, data? }` | Source or system error |
### Error Handling
- Source failures during refresh are reported via `error` notification
- Format: `{ "jsonrpc": "2.0", "method": "error", "params": { "code": -32000, "message": "...", "data": { "sourceId": "weather" } } }`
## Acceptance Criteria
1. **Auth Flow**
- [ ] User can sign up with email/password via `POST /api/auth/sign-up`
- [ ] User can sign in via `POST /api/auth/sign-in` and receive session token
- [ ] Invalid credentials return 401
2. **WebSocket Connection**
- [ ] `GET /ws` with valid `Authorization` header upgrades to WebSocket
- [ ] `GET /ws` without valid token returns 401 (no upgrade)
- [ ] On successful connect, client receives `feed.update` notification with current state
- [ ] Multiple connections from same user all receive updates
3. **FeedEngine Lifecycle**
- [ ] First connection for a user creates FeedEngine with default sources
- [ ] Subsequent connections reuse the same FeedEngine
- [ ] FeedEngine is destroyed after 30 minutes of inactivity
- [ ] Activity (any WebSocket message) resets the TTL
4. **JSON-RPC Methods**
- [ ] `location.update` updates LocationSource and triggers feed refresh
- [ ] `feed.refresh` triggers manual refresh
- [ ] Both return `{ "ok": true }` on success
- [ ] Invalid method returns JSON-RPC error
5. **Feed Updates**
- [ ] FeedEngine subscription pushes updates to all user's WebSocket connections
- [ ] Updates include `context`, `items`, and `errors`
## Implementation Approach
### Phase 1: Project Setup
1. Create `apps/aris-backend` with Hono
2. Configure TypeScript, add dependencies (hono, better-auth, postgres driver)
3. Set up database connection and BetterAuth
### Phase 2: Authentication
4. Configure BetterAuth with email/password provider
5. Mount BetterAuth routes at `/api/auth/*`
6. Create session validation helper for extracting user from token
### Phase 3: FeedEngine Manager
7. Create `FeedEngineManager` class:
- `getOrCreate(userId): FeedEngine` - returns cached or creates new
- `touch(userId)` - resets TTL
- `destroy(userId)` - manual cleanup
- Internal TTL cleanup loop
8. Factory function to create FeedEngine with default sources
### Phase 4: WebSocket Handler
9. Create WebSocket upgrade endpoint at `/ws`
10. Validate `Authorization` header before upgrade
11. On connect: register connection, send initial feed state
12. On disconnect: unregister connection
### Phase 5: JSON-RPC Handler
13. Create JSON-RPC message parser and dispatcher
14. Implement `location.update` method
15. Implement `feed.refresh` method
16. Wire FeedEngine subscription to broadcast `feed.update` to all user connections
### Phase 6: Connection Manager
17. Create `ConnectionManager` to track WebSocket connections per user
18. Broadcast helper to send to all connections for a user
### Phase 7: Integration & Testing
19. Integration test: auth → connect → location update → receive feed
20. Test multiple connections receive same updates
21. Test TTL cleanup
## Package Structure
```
apps/aris-backend/
├── package.json
├── src/
│ ├── index.ts # Entry point, Hono app
│ ├── auth.ts # BetterAuth configuration
│ ├── db.ts # Database connection
│ ├── ws/
│ │ ├── handler.ts # WebSocket upgrade & message handling
│ │ ├── jsonrpc.ts # JSON-RPC parser & types
│ │ └── methods.ts # Method implementations
│ ├── feed/
│ │ ├── manager.ts # FeedEngineManager (TTL cache)
│ │ ├── factory.ts # Creates FeedEngine with default sources
│ │ └── connections.ts # ConnectionManager (user → WebSocket[])
│ └── types.ts # Shared types
```
## Dependencies
```json
{
"dependencies": {
"hono": "^4",
"better-auth": "^1",
"postgres": "^3",
"@aris/core": "workspace:*",
"@aris/source-location": "workspace:*",
"@aris/source-weatherkit": "workspace:*",
"@aris/data-source-tfl": "workspace:*"
}
}
```
## Open Questions (Deferred)
- User source configuration storage (database schema)
- Rate limiting on WebSocket methods
- Reconnection handling (client-side concern)
- Horizontal scaling (would need Redis for shared state)

View File

@@ -0,0 +1,269 @@
# FeedSource Actions
## Problem Statement
`FeedSource` is read-only. Sources can provide context and feed items but can't expose write operations (play, RSVP, dismiss). This blocks interactive sources like Spotify, calendar, and tasks.
## Scope
**`aris-core` only.** Add action support to `FeedSource` and `FeedItem`. No changes to existing fields or methods — purely additive.
## Design
### Why Not MCP
MCP was considered. It doesn't fit because:
- MCP resources don't accept input context (FeedSource needs accumulated context as input)
- MCP has no structured feed items (priority, timestamp, type)
- MCP's isolation model conflicts with ARIS's dependency graph
- Adding these as MCP extensions would mean the extensions are the entire protocol
The interface is designed to be **protocol-compatible** — a future `RemoteFeedSource` adapter can map each field/method to a JSON-RPC operation without changing the interface:
| FeedSource field/method | Future protocol operation |
| ----------------------- | ------------------------- |
| `id`, `dependencies` | `source/describe` |
| `listActions()` | `source/listActions` |
| `fetchContext()` | `source/fetchContext` |
| `fetchItems()` | `source/fetchItems` |
| `executeAction()` | `source/executeAction` |
| `onContextUpdate()` | `source/contextUpdated` |
| `onItemsUpdate()` | `source/itemsUpdated` |
No interface changes needed when the transport layer is built.
### Source ID & Action ID Convention
Source IDs use reverse domain notation. Built-in sources use `aris.<name>`. Third parties use their own domain.
Action IDs are descriptive verb-noun pairs in kebab-case, scoped to their source. The globally unique form is `<sourceId>/<actionId>`.
| Source ID | Action IDs |
| --------------- | -------------------------------------------------------------- |
| `aris.location` | `update-location` (migrated from `pushLocation()`) |
| `aris.tfl` | `set-lines-of-interest` (migrated from `setLinesOfInterest()`) |
| `aris.weather` | _(none)_ |
| `com.spotify` | `play-track`, `pause-playback`, `skip-track`, `like-track` |
| `aris.calendar` | `rsvp`, `create-event` |
| `com.todoist` | `complete-task`, `snooze-task` |
This means existing source packages need their `id` updated (e.g., `"location"``"aris.location"`).
### New Types
```typescript
/** Describes an action a source can perform. */
interface ActionDefinition<TInput = unknown> {
/** Descriptive action name in kebab-case (e.g., "update-location", "play-track") */
readonly id: string
/** Human-readable label for UI (e.g., "Play", "RSVP Yes") */
readonly label: string
/** Optional longer description */
readonly description?: string
/** Schema for input validation. Accepts any Standard Schema compatible validator (arktype, zod, valibot, etc.). Omit if no params. */
readonly input?: StandardSchemaV1<TInput>
}
```
`StandardSchemaV1` is the [Standard Schema](https://github.com/standard-schema/standard-schema) interface implemented by arktype, zod, and valibot. This means sources can use any validator:
```typescript
import { type } from "arktype"
import { z } from "zod"
// With arktype
{ id: "play-track", label: "Play", input: type({ trackId: "string" }) }
// With zod
{ id: "play-track", label: "Play", input: z.object({ trackId: z.string() }) }
// Without validation (e.g., remote sources using raw JSON Schema)
{ id: "play-track", label: "Play" }
/** Result of executing an action. */
interface ActionResult {
ok: boolean
data?: Record<string, unknown>
error?: string
}
/** Reference to an action on a specific feed item. */
interface ItemAction {
/** Action ID (matches ActionDefinition.id on the source) */
actionId: string
/** Per-item label override (e.g., "RSVP to standup") */
label?: string
/** Pre-filled params for this item (e.g., { eventId: "abc" }) */
params?: Record<string, unknown>
}
```
### Changes to FeedSource
Two optional fields added. Nothing else changes.
```typescript
interface FeedSource<TItem extends FeedItem = FeedItem> {
readonly id: string // unchanged
readonly dependencies?: readonly string[] // unchanged
fetchContext(...): ... // unchanged
onContextUpdate?(...): ... // unchanged
fetchItems?(...): ... // unchanged
onItemsUpdate?(...): ... // unchanged
/** List actions this source supports. Empty record if none. Maps to: source/listActions */
listActions(): Promise<Record<string, ActionDefinition>>
/** Execute an action by ID. No-op returning { ok: false } if source has no actions. */
executeAction(
actionId: string,
params: Record<string, unknown>,
): Promise<ActionResult>
}
```
### Changes to FeedItem
One optional field added.
```typescript
interface FeedItem<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> {
id: string // unchanged
type: TType // unchanged
priority: number // unchanged
timestamp: Date // unchanged
data: TData // unchanged
/** Actions the user can take on this item. */
actions?: readonly ItemAction[]
}
```
### Changes to FeedEngine
Two new methods. Existing methods unchanged.
```typescript
class FeedEngine {
// All existing methods unchanged...
/** Route an action call to the correct source. */
async executeAction(
sourceId: string,
actionId: string,
params: Record<string, unknown>,
): Promise<ActionResult>
/** List all actions across all registered sources. */
listActions(): { sourceId: string; actions: readonly ActionDefinition[] }[]
}
```
### Example: Spotify Source
```typescript
class SpotifySource implements FeedSource<SpotifyFeedItem> {
readonly id = "com.spotify"
async listActions() {
return {
"play-track": { id: "play-track", label: "Play", input: type({ trackId: "string" }) },
"pause-playback": { id: "pause-playback", label: "Pause" },
"skip-track": { id: "skip-track", label: "Skip" },
"like-track": { id: "like-track", label: "Like", input: type({ trackId: "string" }) },
}
}
async executeAction(actionId: string, params: Record<string, unknown>): Promise<ActionResult> {
switch (actionId) {
case "play-track":
await this.client.play(params.trackId as string)
return { ok: true }
case "pause-playback":
await this.client.pause()
return { ok: true }
case "skip-track":
await this.client.skip()
return { ok: true }
case "like-track":
await this.client.like(params.trackId as string)
return { ok: true }
default:
return { ok: false, error: `Unknown action: ${actionId}` }
}
}
async fetchContext(): Promise<null> {
return null
}
// Note: for a source with no actions, it would be:
// async listActions() { return {} }
// async executeAction(): Promise<ActionResult> {
// return { ok: false, error: "No actions supported" }
// }
async fetchItems(context: Context): Promise<SpotifyFeedItem[]> {
const track = await this.client.getCurrentTrack()
if (!track) return []
return [
{
id: `spotify-${track.id}`,
type: "spotify-now-playing",
priority: 0.4,
timestamp: context.time,
data: { trackName: track.name, artist: track.artist },
actions: [
{ actionId: "pause-playback" },
{ actionId: "skip-track" },
{ actionId: "like-track", params: { trackId: track.id } },
],
},
]
}
}
```
## Acceptance Criteria
1. `ActionDefinition` type exists with `id`, `label`, `description?`, `inputSchema?`
2. `ActionResult` type exists with `ok`, `data?`, `error?`
3. `ItemAction` type exists with `actionId`, `label?`, `params?`
4. `FeedSource.listActions()` is a required method returning `Record<string, ActionDefinition>` (empty record if no actions)
5. `FeedSource.executeAction()` is a required method (no-op for sources without actions)
6. `FeedItem.actions` is an optional readonly array of `ItemAction`
7. `FeedEngine.executeAction()` routes to correct source, returns `ActionResult`
8. `FeedEngine.listActions()` aggregates actions from all sources
9. Existing tests pass unchanged (all changes are additive)
10. New tests: action execution, unknown action ID, unknown source ID, source without actions, `listActions()` aggregation
## Implementation Steps
1. Create `action.ts` in `aris-core/src` with `ActionDefinition`, `ActionResult`, `ItemAction`
2. Add optional `actions` and `executeAction` to `FeedSource` interface in `feed-source.ts`
3. Add optional `actions` field to `FeedItem` interface in `feed.ts`
4. Add `executeAction()` and `listActions()` to `FeedEngine` in `feed-engine.ts`
5. Export new types from `aris-core/index.ts`
6. Add tests for `FeedEngine.executeAction()` routing
7. Add tests for `FeedEngine.listActions()` aggregation
8. Add tests for error cases (unknown action, unknown source, source without actions)
9. Update source IDs to reverse-domain format (`"location"``"aris.location"`, etc.) across all source packages
10. Migrate `LocationSource.pushLocation()` → action `update-location` on `aris.location`
11. Migrate `TflSource.setLinesOfInterest()` → action `set-lines-of-interest` on `aris.tfl`
12. Add `async listActions() { return {} }` and no-op `executeAction()` to sources without actions (WeatherSource, GoogleCalendarSource, AppleCalendarSource)
13. Update any tests or code referencing old source IDs
14. Run all tests to confirm nothing breaks
## What This Defers
- Transport layer (JSON-RPC over HTTP/WebSocket) — built when remote sources are needed
- `RemoteFeedSource` adapter — mechanical once transport exists
- MCP adapter — wraps MCP servers as FeedSource
- Runtime schema validation of action params
- Action permissions / confirmation UI
- Source discovery / registry API
- Backend service consolidation (separate spec, depends on this one)

View File

@@ -0,0 +1,186 @@
# @aris/core
Core orchestration layer for ARIS feed reconciliation.
## Overview
```mermaid
flowchart TB
subgraph Sources["Feed Sources (Graph)"]
LS[Location Source]
WS[Weather Source]
TS[TFL Source]
CS[Calendar Source]
end
LS --> WS
LS --> TS
subgraph Controller["FeedController"]
direction TB
C1[Holds context]
C2[Manages source graph]
C3[Reconciles on update]
C4[Notifies subscribers]
end
Sources --> Controller
Controller --> Sub[Subscribers]
```
## Concepts
### FeedSource
A unified interface for sources that provide context and/or feed items. Sources form a dependency graph.
```ts
interface FeedSource<TItem extends FeedItem = FeedItem> {
readonly id: string
readonly dependencies?: readonly string[]
// Context production (optional)
onContextUpdate?(
callback: (update: Partial<Context>) => void,
getContext: () => Context,
): () => void
fetchContext?(context: Context): Promise<Partial<Context>>
// Feed item production (optional)
onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void
fetchItems?(context: Context): Promise<TItem[]>
}
```
A source may:
- Provide context for other sources (implement `fetchContext`/`onContextUpdate`)
- Produce feed items (implement `fetchItems`/`onItemsUpdate`)
- Both
### Context Keys
Each package exports typed context keys for type-safe access:
```ts
import { contextKey, type ContextKey } from "@aris/core"
interface Location {
lat: number
lng: number
}
export const LocationKey: ContextKey<Location> = contextKey("location")
```
## Usage
### Define a Context-Only Source
```ts
import type { FeedSource } from "@aris/core"
const locationSource: FeedSource = {
id: "location",
onContextUpdate(callback, _getContext) {
const watchId = navigator.geolocation.watchPosition((pos) => {
callback({
[LocationKey]: { lat: pos.coords.latitude, lng: pos.coords.longitude },
})
})
return () => navigator.geolocation.clearWatch(watchId)
},
async fetchContext() {
const pos = await getCurrentPosition()
return {
[LocationKey]: { lat: pos.coords.latitude, lng: pos.coords.longitude },
}
},
}
```
### Define a Source with Dependencies
```ts
import type { FeedSource, FeedItem } from "@aris/core"
import { contextValue } from "@aris/core"
type WeatherItem = FeedItem<"weather", { temp: number; condition: string }>
const weatherSource: FeedSource<WeatherItem> = {
id: "weather",
dependencies: ["location"],
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return {}
const weather = await fetchWeatherApi(location)
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: weather.temp, condition: weather.condition },
},
]
},
}
```
### Graph Behavior
The source graph:
1. Validates all dependencies exist
2. Detects circular dependencies
3. Topologically sorts sources
On refresh:
1. `fetchContext` runs in dependency order
2. `fetchItems` runs on all sources
3. Combined items returned to subscribers
On reactive update:
1. Source pushes context update via `onContextUpdate` callback
2. Dependent sources re-run `fetchContext`
3. Affected sources re-run `fetchItems`
4. Subscribers notified
## API
### Context
| Export | Description |
| ---------------------------- | --------------------------------------- |
| `ContextKey<T>` | Branded type for type-safe context keys |
| `contextKey<T>(key)` | Creates a typed context key |
| `contextValue(context, key)` | Type-safe context value accessor |
| `Context` | Time + arbitrary key-value bag |
### Feed
| Export | Description |
| ------------------------ | ------------------------ |
| `FeedSource<TItem>` | Unified source interface |
| `FeedItem<TType, TData>` | Single item in the feed |
### Legacy (deprecated)
| Export | Description |
| ---------------------------- | ------------------------ |
| `DataSource<TItem, TConfig>` | Use `FeedSource` instead |
| `ContextProvider<T>` | Use `FeedSource` instead |
| `ContextBridge` | Use source graph instead |

View File

@@ -1,10 +0,0 @@
export interface Location {
lat: number
lng: number
accuracy: number
}
export interface Context {
time: Date
location?: Location
}

View File

@@ -1,7 +0,0 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
export interface DataSource<TItem extends FeedItem = FeedItem, TConfig = unknown> {
readonly type: TItem["type"]
query(context: Context, config: TConfig): Promise<TItem[]>
}

View File

@@ -1,10 +0,0 @@
export interface FeedItem<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> {
id: string
type: TType
priority: number
timestamp: Date
data: TData
}

View File

@@ -1,5 +0,0 @@
export type { Context, Location } from "./context"
export type { FeedItem } from "./feed"
export type { DataSource } from "./data-source"
export type { ReconcilerConfig, ReconcileResult, SourceError } from "./reconciler"
export { Reconciler } from "./reconciler"

View File

@@ -2,9 +2,12 @@
"name": "@aris/core", "name": "@aris/core",
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"main": "index.ts", "main": "src/index.ts",
"types": "index.ts", "types": "src/index.ts",
"scripts": { "scripts": {
"test": "bun test ." "test": "bun test ."
},
"dependencies": {
"@standard-schema/spec": "^1.1.0"
} }
} }

View File

@@ -1,240 +0,0 @@
import { describe, expect, test } from "bun:test"
import type { Context } from "./context"
import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed"
import { Reconciler } from "./reconciler"
type WeatherData = { temp: number }
type WeatherItem = FeedItem<"weather", WeatherData>
type CalendarData = { title: string }
type CalendarItem = FeedItem<"calendar", CalendarData>
const createMockContext = (): Context => ({
time: new Date("2026-01-15T12:00:00Z"),
})
const createWeatherSource = (items: WeatherItem[], delay = 0): DataSource<WeatherItem> => ({
type: "weather",
async query() {
if (delay > 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
}
return items
},
})
const createCalendarSource = (items: CalendarItem[]): DataSource<CalendarItem> => ({
type: "calendar",
async query() {
return items
},
})
const createFailingSource = (type: string, error: Error): DataSource<FeedItem> => ({
type,
async query() {
throw error
},
})
describe("Reconciler", () => {
test("returns empty result when no sources registered", async () => {
const reconciler = new Reconciler()
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
expect(result.errors).toEqual([])
})
test("collects items from single source", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler().register(createWeatherSource(items))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual(items)
expect(result.errors).toEqual([])
})
test("collects items from multiple sources", async () => {
const weatherItems: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const calendarItems: CalendarItem[] = [
{
id: "calendar-1",
type: "calendar",
priority: 0.8,
timestamp: new Date(),
data: { title: "Meeting" },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(weatherItems))
.register(createCalendarSource(calendarItems))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toHaveLength(2)
expect(result.errors).toEqual([])
})
test("sorts items by priority descending", async () => {
const weatherItems: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.2,
timestamp: new Date(),
data: { temp: 20 },
},
]
const calendarItems: CalendarItem[] = [
{
id: "calendar-1",
type: "calendar",
priority: 0.9,
timestamp: new Date(),
data: { title: "Meeting" },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(weatherItems))
.register(createCalendarSource(calendarItems))
const result = await reconciler.reconcile(createMockContext())
expect(result.items[0]?.id).toBe("calendar-1")
expect(result.items[1]?.id).toBe("weather-1")
})
test("captures errors from failing sources", async () => {
const error = new Error("Source failed")
const reconciler = new Reconciler().register(createFailingSource("failing", error))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
expect(result.errors).toHaveLength(1)
expect(result.errors[0]?.sourceType).toBe("failing")
expect(result.errors[0]?.error.message).toBe("Source failed")
})
test("returns partial results when some sources fail", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(items))
.register(createFailingSource("failing", new Error("Failed")))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toHaveLength(1)
expect(result.errors).toHaveLength(1)
})
test("times out slow sources", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler({ timeout: 50 }).register(createWeatherSource(items, 100))
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
expect(result.errors).toHaveLength(1)
expect(result.errors[0]?.sourceType).toBe("weather")
expect(result.errors[0]?.error.message).toContain("timed out")
})
test("unregister removes source", async () => {
const items: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const reconciler = new Reconciler().register(createWeatherSource(items)).unregister("weather")
const result = await reconciler.reconcile(createMockContext())
expect(result.items).toEqual([])
})
test("infers discriminated union type from chained registers", async () => {
const weatherItems: WeatherItem[] = [
{
id: "weather-1",
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: { temp: 20 },
},
]
const calendarItems: CalendarItem[] = [
{
id: "calendar-1",
type: "calendar",
priority: 0.8,
timestamp: new Date(),
data: { title: "Meeting" },
},
]
const reconciler = new Reconciler()
.register(createWeatherSource(weatherItems))
.register(createCalendarSource(calendarItems))
const { items } = await reconciler.reconcile(createMockContext())
// Type narrowing should work
for (const item of items) {
if (item.type === "weather") {
expect(typeof item.data.temp).toBe("number")
} else if (item.type === "calendar") {
expect(typeof item.data.title).toBe("string")
}
}
})
})

View File

@@ -0,0 +1,27 @@
import type { StandardSchemaV1 } from "@standard-schema/spec"
/**
* Describes an action a source can perform.
*
* Action IDs use descriptive verb-noun kebab-case (e.g., "update-location", "play-track").
* Combined with the source's reverse-domain ID, they form a globally unique identifier:
* `<sourceId>/<actionId>` (e.g., "aris.location/update-location").
*/
export class UnknownActionError extends Error {
readonly actionId: string
constructor(actionId: string) {
super(`Unknown action: ${actionId}`)
this.name = "UnknownActionError"
this.actionId = actionId
}
}
export interface ActionDefinition<TInput = unknown> {
/** Descriptive action name in kebab-case (e.g., "update-location", "play-track") */
readonly id: string
/** Optional longer description */
readonly description?: string
/** Schema for input validation. Accepts any Standard Schema compatible validator (arktype, zod, valibot, etc.). */
readonly input?: StandardSchemaV1<TInput>
}

View File

@@ -0,0 +1,102 @@
import type { Context } from "./context"
import type { ContextProvider } from "./context-provider"
interface ContextUpdatable {
pushContextUpdate(update: Partial<Context>): void
}
export interface ProviderError {
key: string
error: Error
}
export interface RefreshResult {
errors: ProviderError[]
}
/**
* Bridges context providers to a feed controller.
*
* Subscribes to provider updates and forwards them to the controller.
* Supports manual refresh to gather current values from all providers.
*
* @example
* ```ts
* const controller = new FeedController()
* .addDataSource(new WeatherDataSource())
* .addDataSource(new TflDataSource())
*
* const bridge = new ContextBridge(controller)
* .addProvider(new LocationProvider())
* .addProvider(new MusicProvider())
*
* // Manual refresh gathers from all providers
* await bridge.refresh()
*
* // Cleanup
* bridge.stop()
* controller.stop()
* ```
*/
export class ContextBridge {
private controller: ContextUpdatable
private providers = new Map<string, ContextProvider>()
private cleanups: Array<() => void> = []
constructor(controller: ContextUpdatable) {
this.controller = controller
}
/**
* Registers a context provider. Immediately subscribes to updates.
*/
addProvider<T>(provider: ContextProvider<T>): this {
this.providers.set(provider.key, provider as ContextProvider)
const cleanup = provider.onUpdate((value) => {
this.controller.pushContextUpdate({ [provider.key]: value })
})
this.cleanups.push(cleanup)
return this
}
/**
* Gathers current values from all providers and pushes to controller.
* Use for manual refresh when user pulls to refresh.
* Returns errors from providers that failed to fetch.
*/
async refresh(): Promise<RefreshResult> {
const updates: Partial<Context> = {}
const errors: ProviderError[] = []
const entries = Array.from(this.providers.entries())
const results = await Promise.allSettled(
entries.map(([_, provider]) => provider.fetchCurrentValue()),
)
entries.forEach(([key], i) => {
const result = results[i]
if (result?.status === "fulfilled") {
updates[key] = result.value
} else if (result?.status === "rejected") {
errors.push({
key,
error: result.reason instanceof Error ? result.reason : new Error(String(result.reason)),
})
}
})
this.controller.pushContextUpdate(updates)
return { errors }
}
/**
* Unsubscribes from all providers.
*/
stop(): void {
this.cleanups.forEach((cleanup) => cleanup())
this.cleanups = []
}
}

View File

@@ -0,0 +1,35 @@
/**
* Provides context values reactively and on-demand.
*
* Implementations push updates when values change (reactive) and
* return current values when requested (for manual refresh).
*
* @example
* ```ts
* class LocationProvider implements ContextProvider<Location> {
* readonly key = LocationKey
*
* onUpdate(callback: (value: Location) => void): () => void {
* const watchId = navigator.geolocation.watchPosition(pos => {
* callback({ lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy })
* })
* return () => navigator.geolocation.clearWatch(watchId)
* }
*
* async fetchCurrentValue(): Promise<Location> {
* const pos = await getCurrentPosition()
* return { lat: pos.coords.latitude, lng: pos.coords.longitude, accuracy: pos.coords.accuracy }
* }
* }
* ```
*/
export interface ContextProvider<T = unknown> {
/** The context key this provider populates */
readonly key: string
/** Subscribe to value changes. Returns cleanup function. */
onUpdate(callback: (value: T) => void): () => void
/** Fetch current value on-demand (used for manual refresh). */
fetchCurrentValue(): Promise<T>
}

View File

@@ -0,0 +1,46 @@
/**
* Branded type for type-safe context keys.
*
* Each package defines its own keys with associated value types:
* ```ts
* const LocationKey: ContextKey<Location> = contextKey("location")
* ```
*/
export type ContextKey<T> = string & { __contextValue?: T }
/**
* Creates a typed context key.
*
* @example
* ```ts
* interface Location { lat: number; lng: number; accuracy: number }
* const LocationKey: ContextKey<Location> = contextKey("location")
* ```
*/
export function contextKey<T>(key: string): ContextKey<T> {
return key as ContextKey<T>
}
/**
* Type-safe accessor for context values.
*
* @example
* ```ts
* const location = contextValue(context, LocationKey)
* if (location) {
* console.log(location.lat, location.lng)
* }
* ```
*/
export function contextValue<T>(context: Context, key: ContextKey<T>): T | undefined {
return context[key] as T | undefined
}
/**
* Arbitrary key-value bag representing the current state.
* Always includes `time`. Other keys are added by context providers.
*/
export interface Context {
time: Date
[key: string]: unknown
}

View File

@@ -0,0 +1,35 @@
import type { Context } from "./context"
import type { FeedItem } from "./feed"
/**
* Produces feed items from an external source.
*
* @example
* ```ts
* type WeatherItem = FeedItem<"weather", { temp: number }>
*
* class WeatherDataSource implements DataSource<WeatherItem> {
* readonly type = "weather"
*
* async query(context: Context): Promise<WeatherItem[]> {
* const location = contextValue(context, LocationKey)
* if (!location) return []
* const data = await fetchWeather(location)
* return [{
* id: `weather-${Date.now()}`,
* type: this.type,
* priority: 0.5,
* timestamp: context.time,
* data: { temp: data.temperature },
* }]
* }
* }
* ```
*/
export interface DataSource<TItem extends FeedItem = FeedItem, TConfig = unknown> {
/** Unique identifier for this source type */
readonly type: TItem["type"]
/** Queries the source and returns feed items */
query(context: Context, config: TConfig): Promise<TItem[]>
}

View File

@@ -0,0 +1,161 @@
import type { Context } from "./context"
import type { DataSource } from "./data-source"
import type { FeedItem } from "./feed"
import type { ReconcileResult } from "./reconciler"
import { Reconciler } from "./reconciler"
export interface FeedControllerConfig {
/** Timeout for each data source query in milliseconds */
timeout?: number
/** Debounce window for batching context updates (default: 100ms) */
debounceMs?: number
/** Initial context state */
initialContext?: Context
}
export type FeedSubscriber<TItems extends FeedItem> = (result: ReconcileResult<TItems>) => void
interface RegisteredSource {
source: DataSource<FeedItem, unknown>
config: unknown
}
const DEFAULT_DEBOUNCE_MS = 100
/**
* Orchestrates feed reconciliation in response to context updates.
*
* Holds context state, debounces updates, queries data sources, and
* notifies subscribers. Each user should have their own instance.
*
* @example
* ```ts
* const controller = new FeedController({ debounceMs: 100 })
* .addDataSource(new WeatherDataSource())
* .addDataSource(new TflDataSource())
*
* controller.subscribe((result) => {
* console.log(result.items)
* })
*
* // Context update triggers debounced reconcile
* controller.pushContextUpdate({ [LocationKey]: location })
*
* // Direct reconcile (no debounce)
* const result = await controller.reconcile()
*
* // Cleanup
* controller.stop()
* ```
*/
export class FeedController<TItems extends FeedItem = never> {
private sources = new Map<string, RegisteredSource>()
private subscribers = new Set<FeedSubscriber<TItems>>()
private context: Context
private debounceMs: number
private timeout: number | undefined
private pendingTimeout: ReturnType<typeof setTimeout> | null = null
private stopped = false
constructor(config?: FeedControllerConfig) {
this.context = config?.initialContext ?? { time: new Date() }
this.debounceMs = config?.debounceMs ?? DEFAULT_DEBOUNCE_MS
this.timeout = config?.timeout
}
/** Registers a data source. */
addDataSource<TItem extends FeedItem, TConfig>(
source: DataSource<TItem, TConfig>,
config?: TConfig,
): FeedController<TItems | TItem> {
this.sources.set(source.type, {
source: source as DataSource<FeedItem, unknown>,
config,
})
return this as FeedController<TItems | TItem>
}
/** Removes a data source by type. */
removeDataSource<T extends TItems["type"]>(
sourceType: T,
): FeedController<Exclude<TItems, { type: T }>> {
this.sources.delete(sourceType)
return this as unknown as FeedController<Exclude<TItems, { type: T }>>
}
/** Stops the controller and cancels pending reconciles. */
stop(): void {
this.stopped = true
if (this.pendingTimeout) {
clearTimeout(this.pendingTimeout)
this.pendingTimeout = null
}
}
/** Merges update into context and schedules a debounced reconcile. */
pushContextUpdate(update: Partial<Context>): void {
this.context = { ...this.context, ...update, time: new Date() }
this.scheduleReconcile()
}
/** Subscribes to feed updates. Returns unsubscribe function. */
subscribe(callback: FeedSubscriber<TItems>): () => void {
this.subscribers.add(callback)
return () => {
this.subscribers.delete(callback)
}
}
/** Immediately reconciles with current or provided context. */
async reconcile(context?: Context): Promise<ReconcileResult<TItems>> {
const ctx = context ?? this.context
const reconciler = this.createReconciler()
return reconciler.reconcile(ctx)
}
/** Returns current context. */
getContext(): Context {
return this.context
}
private scheduleReconcile(): void {
if (this.pendingTimeout) return
this.pendingTimeout = setTimeout(() => {
this.flushPending()
}, this.debounceMs)
}
private async flushPending(): Promise<void> {
this.pendingTimeout = null
if (this.stopped) return
if (this.sources.size === 0) return
const reconciler = this.createReconciler()
const result = await reconciler.reconcile(this.context)
this.notifySubscribers(result)
}
private createReconciler(): Reconciler<TItems> {
const reconciler = new Reconciler<TItems>({ timeout: this.timeout })
Array.from(this.sources.values()).forEach(({ source, config }) => {
reconciler.register(source, config)
})
return reconciler as Reconciler<TItems>
}
private notifySubscribers(result: ReconcileResult<TItems>): void {
this.subscribers.forEach((callback) => {
try {
callback(result)
} catch {
// Subscriber errors shouldn't break other subscribers
}
})
}
}

View File

@@ -0,0 +1,641 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { FeedEngine } from "./feed-engine"
import { UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// CONTEXT KEYS
// =============================================================================
interface Location {
lat: number
lng: number
}
interface Weather {
temperature: number
condition: string
}
const LocationKey: ContextKey<Location> = contextKey("location")
const WeatherKey: ContextKey<Weather> = contextKey("weather")
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherFeedItem = FeedItem<"weather", { temperature: number; condition: string }>
type AlertFeedItem = FeedItem<"alert", { message: string }>
// =============================================================================
// TEST HELPERS
// =============================================================================
interface SimulatedLocationSource extends FeedSource {
simulateUpdate(location: Location): void
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
id: "location",
...noActions,
onContextUpdate(cb) {
callback = cb
return () => {
callback = null
}
},
async fetchContext() {
return { [LocationKey]: currentLocation }
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.({ [LocationKey]: location })
},
}
}
function createWeatherSource(
fetchWeather: (location: Location) => Promise<Weather> = async () => ({
temperature: 20,
condition: "sunny",
}),
): FeedSource<WeatherFeedItem> {
return {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
},
]
},
}
}
function createAlertSource(): FeedSource<AlertFeedItem> {
return {
id: "alert",
dependencies: ["weather"],
...noActions,
async fetchContext() {
return null
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
return [
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
},
]
}
return []
},
}
}
// =============================================================================
// TESTS
// =============================================================================
describe("FeedEngine", () => {
describe("registration", () => {
test("registers sources", () => {
const engine = new FeedEngine()
const location = createLocationSource()
engine.register(location)
// Can refresh without error
expect(engine.refresh()).resolves.toBeDefined()
})
test("unregisters sources", async () => {
const engine = new FeedEngine()
const location = createLocationSource()
engine.register(location)
engine.unregister("location")
const result = await engine.refresh()
expect(result.items).toHaveLength(0)
})
test("allows chained registration", () => {
const engine = new FeedEngine()
.register(createLocationSource())
.register(createWeatherSource())
.register(createAlertSource())
expect(engine.refresh()).resolves.toBeDefined()
})
})
describe("graph validation", () => {
test("throws on missing dependency", async () => {
const engine = new FeedEngine()
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
...noActions,
async fetchContext() {
return null
},
}
engine.register(orphan)
await expect(engine.refresh()).rejects.toThrow(
'Source "orphan" depends on "nonexistent" which is not registered',
)
})
test("throws on circular dependency", async () => {
const engine = new FeedEngine()
const a: FeedSource = {
id: "a",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
engine.register(a).register(b)
await expect(engine.refresh()).rejects.toThrow("Circular dependency detected: a → b → a")
})
test("throws on longer cycles", async () => {
const engine = new FeedEngine()
const a: FeedSource = {
id: "a",
dependencies: ["c"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
const c: FeedSource = {
id: "c",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
engine.register(a).register(b).register(c)
await expect(engine.refresh()).rejects.toThrow("Circular dependency detected")
})
})
describe("refresh", () => {
test("runs fetchContext in dependency order", async () => {
const order: string[] = []
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
const weather: FeedSource = {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined()
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
}
const engine = new FeedEngine().register(weather).register(location)
await engine.refresh()
expect(order).toEqual(["location", "weather"])
})
test("accumulates context across sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const { context } = await engine.refresh()
expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
expect(contextValue(context, WeatherKey)).toEqual({
temperature: 20,
condition: "sunny",
})
})
test("collects items from all sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const { items } = await engine.refresh()
expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("weather")
})
test("sorts items by priority descending", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource(async () => ({
temperature: 15,
condition: "storm",
}))
const alert = createAlertSource()
const engine = new FeedEngine().register(location).register(weather).register(alert)
const { items } = await engine.refresh()
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
})
test("handles missing upstream context gracefully", async () => {
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
return null // No location available
},
}
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const { context, items } = await engine.refresh()
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
test("captures errors from fetchContext", async () => {
const failing: FeedSource = {
id: "failing",
...noActions,
async fetchContext() {
throw new Error("Context fetch failed")
},
}
const engine = new FeedEngine().register(failing)
const { errors } = await engine.refresh()
expect(errors).toHaveLength(1)
expect(errors[0]!.sourceId).toBe("failing")
expect(errors[0]!.error.message).toBe("Context fetch failed")
})
test("captures errors from fetchItems", async () => {
const failing: FeedSource = {
id: "failing",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
throw new Error("Items fetch failed")
},
}
const engine = new FeedEngine().register(failing)
const { errors } = await engine.refresh()
expect(errors).toHaveLength(1)
expect(errors[0]!.sourceId).toBe("failing")
expect(errors[0]!.error.message).toBe("Items fetch failed")
})
test("continues after source error", async () => {
const failing: FeedSource = {
id: "failing",
...noActions,
async fetchContext() {
throw new Error("Failed")
},
}
const working: FeedSource = {
id: "working",
...noActions,
async fetchContext() {
return null
},
async fetchItems() {
return [
{
id: "item-1",
type: "test",
priority: 0.5,
timestamp: new Date(),
data: {},
},
]
},
}
const engine = new FeedEngine().register(failing).register(working)
const { items, errors } = await engine.refresh()
expect(errors).toHaveLength(1)
expect(items).toHaveLength(1)
})
})
describe("currentContext", () => {
test("returns initial context before refresh", () => {
const engine = new FeedEngine()
const context = engine.currentContext()
expect(context.time).toBeInstanceOf(Date)
})
test("returns accumulated context after refresh", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const engine = new FeedEngine().register(location)
await engine.refresh()
const context = engine.currentContext()
expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
})
})
describe("subscribe", () => {
test("returns unsubscribe function", () => {
const engine = new FeedEngine()
let callCount = 0
const unsubscribe = engine.subscribe(() => {
callCount++
})
unsubscribe()
// Subscriber should not be called after unsubscribe
expect(callCount).toBe(0)
})
})
describe("reactive updates", () => {
test("start subscribes to onContextUpdate", async () => {
const location = createLocationSource()
const weather = createWeatherSource()
const engine = new FeedEngine().register(location).register(weather)
const results: Array<{ items: FeedItem[] }> = []
engine.subscribe((result) => {
results.push({ items: result.items })
})
engine.start()
// Simulate location update
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
// Wait for async refresh
await new Promise((resolve) => setTimeout(resolve, 50))
expect(results.length).toBeGreaterThan(0)
expect(results[0]!.items[0]!.type).toBe("weather")
})
test("stop unsubscribes from all sources", async () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
let callCount = 0
engine.subscribe(() => {
callCount++
})
engine.start()
engine.stop()
// Simulate update after stop
location.simulateUpdate({ lat: 1, lng: 1 })
await new Promise((resolve) => setTimeout(resolve, 50))
expect(callCount).toBe(0)
})
test("start is idempotent", () => {
const location = createLocationSource()
const engine = new FeedEngine().register(location)
// Should not throw or double-subscribe
engine.start()
engine.start()
engine.stop()
})
})
describe("executeAction", () => {
test("routes action to correct source", async () => {
let receivedAction = ""
let receivedParams: unknown = {}
const source: FeedSource = {
id: "test-source",
async listActions() {
return {
"do-thing": { id: "do-thing" },
}
},
async executeAction(actionId, params) {
receivedAction = actionId
receivedParams = params
},
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
await engine.executeAction("test-source", "do-thing", { key: "value" })
expect(receivedAction).toBe("do-thing")
expect(receivedParams).toEqual({ key: "value" })
})
test("throws for unknown source", async () => {
const engine = new FeedEngine()
await expect(engine.executeAction("nonexistent", "action", {})).rejects.toThrow(
"Source not found: nonexistent",
)
})
test("throws for unknown action on source", async () => {
const source: FeedSource = {
id: "test-source",
...noActions,
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
await expect(engine.executeAction("test-source", "nonexistent", {})).rejects.toThrow(
'Action "nonexistent" not found on source "test-source"',
)
})
})
describe("listActions", () => {
test("returns actions for a specific source", async () => {
const source: FeedSource = {
id: "test-source",
async listActions() {
return {
"action-1": { id: "action-1" },
"action-2": { id: "action-2" },
}
},
async executeAction() {},
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
const actions = await engine.listActions("test-source")
expect(Object.keys(actions)).toEqual(["action-1", "action-2"])
})
test("throws for unknown source", async () => {
const engine = new FeedEngine()
await expect(engine.listActions("nonexistent")).rejects.toThrow(
"Source not found: nonexistent",
)
})
test("throws on mismatched action ID", async () => {
const source: FeedSource = {
id: "bad-source",
async listActions() {
return {
"correct-key": { id: "wrong-id" },
}
},
async executeAction() {},
async fetchContext() {
return null
},
}
const engine = new FeedEngine().register(source)
await expect(engine.listActions("bad-source")).rejects.toThrow(
'Action ID mismatch on source "bad-source"',
)
})
})
})

View File

@@ -0,0 +1,380 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
import type { FeedSource } from "./feed-source"
export interface SourceError {
sourceId: string
error: Error
}
export interface FeedResult<TItem extends FeedItem = FeedItem> {
context: Context
items: TItem[]
errors: SourceError[]
}
export type FeedSubscriber<TItem extends FeedItem = FeedItem> = (result: FeedResult<TItem>) => void
interface SourceGraph {
sources: Map<string, FeedSource>
sorted: FeedSource[]
dependents: Map<string, string[]>
}
/**
* Orchestrates FeedSources, managing the dependency graph and context flow.
*
* Sources declare dependencies on other sources. The engine:
* - Validates the dependency graph (no missing deps, no cycles)
* - Runs fetchContext() in topological order during refresh
* - Runs fetchItems() on all sources with accumulated context
* - Subscribes to reactive updates via onContextUpdate/onItemsUpdate
*
* @example
* ```ts
* const engine = new FeedEngine()
* .register(locationSource)
* .register(weatherSource)
* .register(alertSource)
*
* // Pull-based refresh
* const { context, items, errors } = await engine.refresh()
*
* // Reactive updates
* engine.subscribe((result) => {
* console.log(result.items)
* })
* engine.start()
*
* // Cleanup
* engine.stop()
* ```
*/
export class FeedEngine<TItems extends FeedItem = FeedItem> {
private sources = new Map<string, FeedSource>()
private graph: SourceGraph | null = null
private context: Context = { time: new Date() }
private subscribers = new Set<FeedSubscriber<TItems>>()
private cleanups: Array<() => void> = []
private started = false
/**
* Registers a FeedSource. Invalidates the cached graph.
*/
register<TItem extends FeedItem>(source: FeedSource<TItem>): FeedEngine<TItems | TItem> {
this.sources.set(source.id, source)
this.graph = null
return this as FeedEngine<TItems | TItem>
}
/**
* Unregisters a FeedSource by ID. Invalidates the cached graph.
*/
unregister(sourceId: string): this {
this.sources.delete(sourceId)
this.graph = null
return this
}
/**
* Refreshes the feed by running all sources in dependency order.
* Calls fetchContext() then fetchItems() on each source.
*/
async refresh(): Promise<FeedResult<TItems>> {
const graph = this.ensureGraph()
const errors: SourceError[] = []
// Reset context with fresh time
let context: Context = { time: new Date() }
// Run fetchContext in topological order
for (const source of graph.sorted) {
try {
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
}
} catch (err) {
errors.push({
sourceId: source.id,
error: err instanceof Error ? err : new Error(String(err)),
})
}
}
// Run fetchItems on all sources
const items: FeedItem[] = []
for (const source of graph.sorted) {
if (source.fetchItems) {
try {
const sourceItems = await source.fetchItems(context)
items.push(...sourceItems)
} catch (err) {
errors.push({
sourceId: source.id,
error: err instanceof Error ? err : new Error(String(err)),
})
}
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
this.context = context
return { context, items: items as TItems[], errors }
}
/**
* Subscribes to feed updates. Returns unsubscribe function.
*/
subscribe(callback: FeedSubscriber<TItems>): () => void {
this.subscribers.add(callback)
return () => {
this.subscribers.delete(callback)
}
}
/**
* Starts reactive subscriptions on all sources.
* Sources with onContextUpdate will trigger re-computation of dependents.
*/
start(): void {
if (this.started) return
this.started = true
const graph = this.ensureGraph()
for (const source of graph.sorted) {
if (source.onContextUpdate) {
const cleanup = source.onContextUpdate(
(update) => {
this.handleContextUpdate(source.id, update)
},
() => this.context,
)
this.cleanups.push(cleanup)
}
if (source.onItemsUpdate) {
const cleanup = source.onItemsUpdate(
() => {
this.scheduleRefresh()
},
() => this.context,
)
this.cleanups.push(cleanup)
}
}
}
/**
* Stops all reactive subscriptions.
*/
stop(): void {
this.started = false
for (const cleanup of this.cleanups) {
cleanup()
}
this.cleanups = []
}
/**
* Returns the current accumulated context.
*/
currentContext(): Context {
return this.context
}
/**
* Execute an action on a registered source.
* Validates the action exists before dispatching.
*
* In pull-only mode (before `start()` is called), the action mutates source
* state but does not automatically refresh dependents. Call `refresh()`
* after to propagate changes. In reactive mode (`start()` called), sources
* that push context updates (e.g., LocationSource) will trigger dependent
* refresh automatically.
*/
async executeAction(sourceId: string, actionId: string, params: unknown): Promise<unknown> {
const actions = await this.listActions(sourceId)
if (!(actionId in actions)) {
throw new Error(`Action "${actionId}" not found on source "${sourceId}"`)
}
return this.sources.get(sourceId)!.executeAction(actionId, params)
}
/**
* List actions available on a specific source.
* Validates that action definition IDs match their record keys.
*/
async listActions(sourceId: string): Promise<Record<string, ActionDefinition>> {
const source = this.sources.get(sourceId)
if (!source) {
throw new Error(`Source not found: ${sourceId}`)
}
const actions = await source.listActions()
for (const [key, definition] of Object.entries(actions)) {
if (key !== definition.id) {
throw new Error(
`Action ID mismatch on source "${sourceId}": key "${key}" !== definition.id "${definition.id}"`,
)
}
}
return actions
}
private ensureGraph(): SourceGraph {
if (!this.graph) {
this.graph = buildGraph(Array.from(this.sources.values()))
}
return this.graph
}
private handleContextUpdate(sourceId: string, update: Partial<Context>): void {
this.context = { ...this.context, ...update, time: new Date() }
// Re-run dependents and notify
this.refreshDependents(sourceId)
}
private async refreshDependents(sourceId: string): Promise<void> {
const graph = this.ensureGraph()
const toRefresh = this.collectDependents(sourceId, graph)
// Re-run fetchContext for dependents in order
for (const id of toRefresh) {
const source = graph.sources.get(id)
if (source) {
try {
const update = await source.fetchContext(this.context)
if (update) {
this.context = { ...this.context, ...update }
}
} catch {
// Errors during reactive updates are logged but don't stop propagation
}
}
}
// Collect items from all sources
const items: FeedItem[] = []
const errors: SourceError[] = []
for (const source of graph.sorted) {
if (source.fetchItems) {
try {
const sourceItems = await source.fetchItems(this.context)
items.push(...sourceItems)
} catch (err) {
errors.push({
sourceId: source.id,
error: err instanceof Error ? err : new Error(String(err)),
})
}
}
}
items.sort((a, b) => b.priority - a.priority)
this.notifySubscribers({
context: this.context,
items: items as TItems[],
errors,
})
}
private collectDependents(sourceId: string, graph: SourceGraph): string[] {
const result: string[] = []
const visited = new Set<string>()
const collect = (id: string): void => {
const deps = graph.dependents.get(id) ?? []
for (const dep of deps) {
if (!visited.has(dep)) {
visited.add(dep)
result.push(dep)
collect(dep)
}
}
}
collect(sourceId)
// Return in topological order
return graph.sorted.filter((s) => result.includes(s.id)).map((s) => s.id)
}
private scheduleRefresh(): void {
// Simple immediate refresh for now - could add debouncing later
this.refresh().then((result) => {
this.notifySubscribers(result)
})
}
private notifySubscribers(result: FeedResult<TItems>): void {
this.subscribers.forEach((callback) => {
try {
callback(result)
} catch {
// Subscriber errors shouldn't break other subscribers
}
})
}
}
function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>()
for (const source of sources) {
byId.set(source.id, source)
}
// Validate dependencies exist
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
if (!byId.has(dep)) {
throw new Error(`Source "${source.id}" depends on "${dep}" which is not registered`)
}
}
}
// Check for cycles and topologically sort
const visited = new Set<string>()
const visiting = new Set<string>()
const sorted: FeedSource[] = []
function visit(id: string, path: string[]): void {
if (visiting.has(id)) {
const cycle = [...path.slice(path.indexOf(id)), id].join(" → ")
throw new Error(`Circular dependency detected: ${cycle}`)
}
if (visited.has(id)) return
visiting.add(id)
const source = byId.get(id)!
for (const dep of source.dependencies ?? []) {
visit(dep, [...path, id])
}
visiting.delete(id)
visited.add(id)
sorted.push(source)
}
for (const source of sources) {
visit(source.id, [])
}
// Build reverse dependency map
const dependents = new Map<string, string[]>()
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
const list = dependents.get(dep) ?? []
list.push(source.id)
dependents.set(dep, list)
}
}
return { sources: byId, sorted, dependents }
}

View File

@@ -0,0 +1,493 @@
import { describe, expect, test } from "bun:test"
import type { ActionDefinition, Context, ContextKey, FeedItem, FeedSource } from "./index"
import { UnknownActionError, contextKey, contextValue } from "./index"
// No-op action methods for test sources
const noActions = {
async listActions(): Promise<Record<string, ActionDefinition>> {
return {}
},
async executeAction(actionId: string): Promise<void> {
throw new UnknownActionError(actionId)
},
}
// =============================================================================
// CONTEXT KEYS
// =============================================================================
interface Location {
lat: number
lng: number
}
interface Weather {
temperature: number
condition: string
}
const LocationKey: ContextKey<Location> = contextKey("location")
const WeatherKey: ContextKey<Weather> = contextKey("weather")
// =============================================================================
// FEED ITEMS
// =============================================================================
type WeatherFeedItem = FeedItem<"weather", { temperature: number; condition: string }>
type AlertFeedItem = FeedItem<"alert", { message: string }>
// =============================================================================
// TEST HELPERS
// =============================================================================
interface SimulatedLocationSource extends FeedSource {
simulateUpdate(location: Location): void
}
function createLocationSource(): SimulatedLocationSource {
let callback: ((update: Partial<Context>) => void) | null = null
let currentLocation: Location = { lat: 0, lng: 0 }
return {
id: "location",
...noActions,
onContextUpdate(cb) {
callback = cb
return () => {
callback = null
}
},
async fetchContext() {
return { [LocationKey]: currentLocation }
},
simulateUpdate(location: Location) {
currentLocation = location
callback?.({ [LocationKey]: location })
},
}
}
function createWeatherSource(
fetchWeather: (location: Location) => Promise<Weather> = async () => ({
temperature: 20,
condition: "sunny",
}),
): FeedSource<WeatherFeedItem> {
return {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(context) {
const location = contextValue(context, LocationKey)
if (!location) return null
const weather = await fetchWeather(location)
return { [WeatherKey]: weather }
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
return [
{
id: `weather-${Date.now()}`,
type: "weather",
priority: 0.5,
timestamp: new Date(),
data: {
temperature: weather.temperature,
condition: weather.condition,
},
},
]
},
}
}
function createAlertSource(): FeedSource<AlertFeedItem> {
return {
id: "alert",
dependencies: ["weather"],
...noActions,
async fetchContext() {
return null
},
async fetchItems(context) {
const weather = contextValue(context, WeatherKey)
if (!weather) return []
if (weather.condition === "storm") {
return [
{
id: "alert-storm",
type: "alert",
priority: 1.0,
timestamp: new Date(),
data: { message: "Storm warning!" },
},
]
}
return []
},
}
}
// =============================================================================
// GRAPH SIMULATION (until FeedController is updated)
// =============================================================================
interface SourceGraph {
sources: Map<string, FeedSource>
sorted: FeedSource[]
dependents: Map<string, string[]>
}
function buildGraph(sources: FeedSource[]): SourceGraph {
const byId = new Map<string, FeedSource>()
for (const source of sources) {
byId.set(source.id, source)
}
// Validate dependencies exist
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
if (!byId.has(dep)) {
throw new Error(`Source "${source.id}" depends on "${dep}" which is not registered`)
}
}
}
// Check for cycles and topologically sort
const visited = new Set<string>()
const visiting = new Set<string>()
const sorted: FeedSource[] = []
function visit(id: string, path: string[]): void {
if (visiting.has(id)) {
const cycle = [...path.slice(path.indexOf(id)), id].join(" → ")
throw new Error(`Circular dependency detected: ${cycle}`)
}
if (visited.has(id)) return
visiting.add(id)
const source = byId.get(id)!
for (const dep of source.dependencies ?? []) {
visit(dep, [...path, id])
}
visiting.delete(id)
visited.add(id)
sorted.push(source)
}
for (const source of sources) {
visit(source.id, [])
}
// Build reverse dependency map
const dependents = new Map<string, string[]>()
for (const source of sources) {
for (const dep of source.dependencies ?? []) {
const list = dependents.get(dep) ?? []
list.push(source.id)
dependents.set(dep, list)
}
}
return { sources: byId, sorted, dependents }
}
async function refreshGraph(graph: SourceGraph): Promise<{ context: Context; items: FeedItem[] }> {
let context: Context = { time: new Date() }
// Run fetchContext in topological order
for (const source of graph.sorted) {
const update = await source.fetchContext(context)
if (update) {
context = { ...context, ...update }
}
}
// Run fetchItems on all sources
const items: FeedItem[] = []
for (const source of graph.sorted) {
if (source.fetchItems) {
const sourceItems = await source.fetchItems(context)
items.push(...sourceItems)
}
}
// Sort by priority descending
items.sort((a, b) => b.priority - a.priority)
return { context, items }
}
// =============================================================================
// TESTS
// =============================================================================
describe("FeedSource", () => {
describe("interface", () => {
test("source with only context production", () => {
const source = createLocationSource()
expect(source.id).toBe("location")
expect(source.dependencies).toBeUndefined()
expect(source.fetchContext).toBeDefined()
expect(source.onContextUpdate).toBeDefined()
expect(source.fetchItems).toBeUndefined()
})
test("source with dependencies and both context and items", () => {
const source = createWeatherSource()
expect(source.id).toBe("weather")
expect(source.dependencies).toEqual(["location"])
expect(source.fetchContext).toBeDefined()
expect(source.fetchItems).toBeDefined()
})
test("source with only item production", () => {
const source = createAlertSource()
expect(source.id).toBe("alert")
expect(source.dependencies).toEqual(["weather"])
expect(source.fetchContext).toBeDefined()
expect(source.fetchItems).toBeDefined()
})
test("source without context returns null from fetchContext", async () => {
const source = createAlertSource()
const result = await source.fetchContext({ time: new Date() })
expect(result).toBeNull()
})
})
describe("graph validation", () => {
test("validates all dependencies exist", () => {
const orphan: FeedSource = {
id: "orphan",
dependencies: ["nonexistent"],
...noActions,
async fetchContext() {
return null
},
}
expect(() => buildGraph([orphan])).toThrow(
'Source "orphan" depends on "nonexistent" which is not registered',
)
})
test("detects circular dependencies", () => {
const a: FeedSource = {
id: "a",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
expect(() => buildGraph([a, b])).toThrow("Circular dependency detected: a → b → a")
})
test("detects longer cycles", () => {
const a: FeedSource = {
id: "a",
dependencies: ["c"],
...noActions,
async fetchContext() {
return null
},
}
const b: FeedSource = {
id: "b",
dependencies: ["a"],
...noActions,
async fetchContext() {
return null
},
}
const c: FeedSource = {
id: "c",
dependencies: ["b"],
...noActions,
async fetchContext() {
return null
},
}
expect(() => buildGraph([a, b, c])).toThrow("Circular dependency detected")
})
test("topologically sorts sources", () => {
const location = createLocationSource()
const weather = createWeatherSource()
const alert = createAlertSource()
// Register in wrong order
const graph = buildGraph([alert, weather, location])
expect(graph.sorted.map((s) => s.id)).toEqual(["location", "weather", "alert"])
})
test("builds reverse dependency map", () => {
const location = createLocationSource()
const weather = createWeatherSource()
const alert = createAlertSource()
const graph = buildGraph([location, weather, alert])
expect(graph.dependents.get("location")).toEqual(["weather"])
expect(graph.dependents.get("weather")).toEqual(["alert"])
expect(graph.dependents.get("alert")).toBeUndefined()
})
})
describe("graph refresh", () => {
test("runs fetchContext in dependency order", async () => {
const order: string[] = []
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
order.push("location")
return { [LocationKey]: { lat: 51.5, lng: -0.1 } }
},
}
const weather: FeedSource = {
id: "weather",
dependencies: ["location"],
...noActions,
async fetchContext(ctx) {
order.push("weather")
const loc = contextValue(ctx, LocationKey)
expect(loc).toBeDefined()
return { [WeatherKey]: { temperature: 20, condition: "sunny" } }
},
}
const graph = buildGraph([weather, location])
await refreshGraph(graph)
expect(order).toEqual(["location", "weather"])
})
test("accumulates context across sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { context } = await refreshGraph(graph)
expect(contextValue(context, LocationKey)).toEqual({
lat: 51.5,
lng: -0.1,
})
expect(contextValue(context, WeatherKey)).toEqual({
temperature: 20,
condition: "sunny",
})
})
test("collects items from all sources", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(1)
expect(items[0]!.type).toBe("weather")
})
test("downstream source receives upstream context", async () => {
const location = createLocationSource()
location.simulateUpdate({ lat: 51.5, lng: -0.1 })
const weather = createWeatherSource(async () => ({
temperature: 15,
condition: "storm",
}))
const alert = createAlertSource()
const graph = buildGraph([location, weather, alert])
const { items } = await refreshGraph(graph)
expect(items).toHaveLength(2)
expect(items[0]!.type).toBe("alert") // priority 1.0
expect(items[1]!.type).toBe("weather") // priority 0.5
})
test("source without location context returns empty items", async () => {
// Location source exists but hasn't been updated
const location: FeedSource = {
id: "location",
...noActions,
async fetchContext() {
// Simulate no location available
return null
},
}
const weather = createWeatherSource()
const graph = buildGraph([location, weather])
const { context, items } = await refreshGraph(graph)
expect(contextValue(context, WeatherKey)).toBeUndefined()
expect(items).toHaveLength(0)
})
})
describe("reactive updates", () => {
test("onContextUpdate receives callback and returns cleanup", () => {
const location = createLocationSource()
let updateCount = 0
const cleanup = location.onContextUpdate!(
() => {
updateCount++
},
() => ({ time: new Date() }),
)
location.simulateUpdate({ lat: 1, lng: 1 })
expect(updateCount).toBe(1)
location.simulateUpdate({ lat: 2, lng: 2 })
expect(updateCount).toBe(2)
cleanup()
location.simulateUpdate({ lat: 3, lng: 3 })
expect(updateCount).toBe(2) // no more updates after cleanup
})
})
})

View File

@@ -0,0 +1,86 @@
import type { ActionDefinition } from "./action"
import type { Context } from "./context"
import type { FeedItem } from "./feed"
/**
* Unified interface for sources that provide context, feed items, and actions.
*
* Sources form a dependency graph — a source declares which other sources
* it depends on, and the graph ensures dependencies are resolved before
* dependents run.
*
* Source IDs use reverse domain notation. Built-in sources use `aris.<name>`,
* third parties use their own domain (e.g., `com.spotify`).
*
* Every method maps to a protocol operation for remote source support:
* - `id`, `dependencies` → source/describe
* - `listActions()` → source/listActions
* - `executeAction()` → source/executeAction
* - `fetchContext()` → source/fetchContext
* - `fetchItems()` → source/fetchItems
* - `onContextUpdate()` → source/contextUpdated (notification)
* - `onItemsUpdate()` → source/itemsUpdated (notification)
*
* @example
* ```ts
* const locationSource: FeedSource = {
* id: "aris.location",
* async listActions() { return { "update-location": { id: "update-location" } } },
* async executeAction(actionId) { throw new UnknownActionError(actionId) },
* async fetchContext() { ... },
* }
* ```
*/
export interface FeedSource<TItem extends FeedItem = FeedItem> {
/** Unique identifier for this source in reverse-domain format */
readonly id: string
/** IDs of sources this source depends on */
readonly dependencies?: readonly string[]
/**
* List actions this source supports. Empty record if none.
* Maps to: source/listActions
*/
listActions(): Promise<Record<string, ActionDefinition>>
/**
* Execute an action by ID. Throws on unknown action or invalid input.
* Maps to: source/executeAction
*/
executeAction(actionId: string, params: unknown): Promise<unknown>
/**
* Subscribe to reactive context updates.
* Called when the source can push context changes proactively.
* Returns cleanup function.
* Maps to: source/contextUpdated (notification, source → host)
*/
onContextUpdate?(
callback: (update: Partial<Context>) => void,
getContext: () => Context,
): () => void
/**
* Fetch context on-demand.
* Called during manual refresh or initial load.
* Return null if this source cannot provide context.
* Maps to: source/fetchContext
*/
fetchContext(context: Context): Promise<Partial<Context> | null>
/**
* Subscribe to reactive feed item updates.
* Called when the source can push item changes proactively.
* Returns cleanup function.
* Maps to: source/itemsUpdated (notification, source → host)
*/
onItemsUpdate?(callback: (items: TItem[]) => void, getContext: () => Context): () => void
/**
* Fetch feed items on-demand.
* Called during manual refresh or when dependencies update.
* Maps to: source/fetchItems
*/
fetchItems?(context: Context): Promise<TItem[]>
}

View File

@@ -0,0 +1,31 @@
/**
* A single item in the feed.
*
* @example
* ```ts
* type WeatherItem = FeedItem<"weather", { temp: number; condition: string }>
*
* const item: WeatherItem = {
* id: "weather-123",
* type: "weather",
* priority: 0.5,
* timestamp: new Date(),
* data: { temp: 18, condition: "cloudy" },
* }
* ```
*/
export interface FeedItem<
TType extends string = string,
TData extends Record<string, unknown> = Record<string, unknown>,
> {
/** Unique identifier */
id: string
/** Item type, matches the data source type */
type: TType
/** Sort priority (higher = more important, shown first) */
priority: number
/** When this item was generated */
timestamp: Date
/** Type-specific payload */
data: TData
}

View File

@@ -0,0 +1,46 @@
// Context
export type { Context, ContextKey } from "./context"
export { contextKey, contextValue } from "./context"
// Actions
export type { ActionDefinition } from "./action"
export { UnknownActionError } from "./action"
// Feed
export type { FeedItem } from "./feed"
// Feed Source
export type { FeedSource } from "./feed-source"
// Feed Engine
export type { FeedResult, FeedSubscriber, SourceError } from "./feed-engine"
export { FeedEngine } from "./feed-engine"
// =============================================================================
// DEPRECATED - Use FeedSource + FeedEngine instead
// =============================================================================
// Data Source (deprecated - use FeedSource)
export type { DataSource } from "./data-source"
// Context Provider (deprecated - use FeedSource)
export type { ContextProvider } from "./context-provider"
// Context Bridge (deprecated - use FeedEngine)
export type { ProviderError, RefreshResult } from "./context-bridge"
export { ContextBridge } from "./context-bridge"
// Reconciler (deprecated - use FeedEngine)
export type {
ReconcileResult,
ReconcilerConfig,
SourceError as ReconcilerSourceError,
} from "./reconciler"
export { Reconciler } from "./reconciler"
// Feed Controller (deprecated - use FeedEngine)
export type {
FeedControllerConfig,
FeedSubscriber as FeedControllerSubscriber,
} from "./feed-controller"
export { FeedController } from "./feed-controller"

View File

@@ -0,0 +1,4 @@
WEATHERKIT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
WEATHERKIT_KEY_ID=ABC123DEFG
WEATHERKIT_TEAM_ID=TEAM123456
WEATHERKIT_SERVICE_ID=com.example.weatherkit.test

View File

@@ -0,0 +1,58 @@
# @aris/data-source-weatherkit
Weather data source using Apple WeatherKit REST API.
## Usage
```typescript
import { WeatherKitDataSource, Units } from "@aris/data-source-weatherkit"
const dataSource = new WeatherKitDataSource({
credentials: {
privateKey: "-----BEGIN PRIVATE KEY-----\n...",
keyId: "ABC123",
teamId: "DEF456",
serviceId: "com.example.weatherkit",
},
hourlyLimit: 12, // optional, default: 12
dailyLimit: 7, // optional, default: 7
})
const items = await dataSource.query(context, {
units: Units.metric, // or Units.imperial
})
```
## Feed Items
The data source returns four types of feed items:
| Type | Description |
| ----------------- | -------------------------- |
| `weather-current` | Current weather conditions |
| `weather-hourly` | Hourly forecast |
| `weather-daily` | Daily forecast |
| `weather-alert` | Weather alerts |
## Priority
Base priorities are adjusted based on weather conditions:
- Severe conditions (tornado, hurricane, blizzard, etc.): +0.3
- Moderate conditions (thunderstorm, heavy rain, etc.): +0.15
- Alert severity: extreme=1.0, severe=0.9, moderate=0.75, minor=0.7
## Authentication
WeatherKit requires Apple Developer credentials. Generate a private key in the Apple Developer portal under Certificates, Identifiers & Profiles > Keys.
## Validation
API responses are validated using [arktype](https://arktype.io) schemas.
## Generating Test Fixtures
To regenerate fixture data from the real API:
1. Create a `.env` file with your credentials (see `.env.example`)
2. Run `bun run scripts/generate-fixtures.ts`

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"name": "@aris/data-source-weatherkit",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"test": "bun test ."
},
"dependencies": {
"@aris/core": "workspace:*",
"arktype": "^2.1.0"
}
}

View File

@@ -0,0 +1,60 @@
import { DefaultWeatherKitClient } from "../src/weatherkit"
function loadEnv(): Record<string, string> {
const content = require("fs").readFileSync(".env", "utf-8")
const env: Record<string, string> = {}
for (const line of content.split("\n")) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith("#")) continue
const eqIndex = trimmed.indexOf("=")
if (eqIndex === -1) continue
const key = trimmed.slice(0, eqIndex)
let value = trimmed.slice(eqIndex + 1)
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1)
}
env[key] = value.replace(/\\n/g, "\n")
}
return env
}
const env = loadEnv()
const client = new DefaultWeatherKitClient({
privateKey: env.WEATHERKIT_PRIVATE_KEY!,
keyId: env.WEATHERKIT_KEY_ID!,
teamId: env.WEATHERKIT_TEAM_ID!,
serviceId: env.WEATHERKIT_SERVICE_ID!,
})
const locations = {
sanFrancisco: { lat: 37.7749, lng: -122.4194 },
}
async function main() {
console.log("Fetching weather data for San Francisco...")
const response = await client.fetch({
lat: locations.sanFrancisco.lat,
lng: locations.sanFrancisco.lng,
})
const fixture = {
generatedAt: new Date().toISOString(),
location: locations.sanFrancisco,
response,
}
const output = JSON.stringify(fixture)
await Bun.write("fixtures/san-francisco.json", output)
console.log("Fixture written to fixtures/san-francisco.json")
}
main().catch(console.error)

View File

@@ -0,0 +1,220 @@
import type { Context } from "@aris/core"
import { describe, expect, test } from "bun:test"
import type { WeatherKitClient, WeatherKitResponse } from "./weatherkit"
import fixture from "../fixtures/san-francisco.json"
import { WeatherKitDataSource, Units } from "./data-source"
import { WeatherFeedItemType } from "./feed-items"
const mockCredentials = {
privateKey: "mock",
keyId: "mock",
teamId: "mock",
serviceId: "mock",
}
const createMockClient = (response: WeatherKitResponse): WeatherKitClient => ({
fetch: async () => response,
})
const createMockContext = (location?: { lat: number; lng: number }): Context => ({
time: new Date("2026-01-17T00:00:00Z"),
location: location ? { ...location, accuracy: 10 } : undefined,
})
describe("WeatherKitDataSource", () => {
test("returns empty array when location is missing", async () => {
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
})
const items = await dataSource.query(createMockContext())
expect(items).toEqual([])
})
test("type is weather-current", () => {
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
})
expect(dataSource.type).toBe(WeatherFeedItemType.current)
})
test("throws error if neither client nor credentials provided", () => {
expect(() => new WeatherKitDataSource({})).toThrow(
"Either client or credentials must be provided",
)
})
})
describe("WeatherKitDataSource with fixture", () => {
const response = fixture.response
test("parses current weather from fixture", () => {
const current = response.currentWeather
expect(typeof current.conditionCode).toBe("string")
expect(typeof current.temperature).toBe("number")
expect(typeof current.humidity).toBe("number")
expect(current.pressureTrend).toMatch(/^(rising|falling|steady)$/)
})
test("parses hourly forecast from fixture", () => {
const hours = response.forecastHourly.hours
expect(hours.length).toBeGreaterThan(0)
const firstHour = hours[0]!
expect(firstHour.forecastStart).toBeDefined()
expect(typeof firstHour.temperature).toBe("number")
expect(typeof firstHour.precipitationChance).toBe("number")
})
test("parses daily forecast from fixture", () => {
const days = response.forecastDaily.days
expect(days.length).toBeGreaterThan(0)
const firstDay = days[0]!
expect(firstDay.forecastStart).toBeDefined()
expect(typeof firstDay.temperatureMax).toBe("number")
expect(typeof firstDay.temperatureMin).toBe("number")
expect(firstDay.sunrise).toBeDefined()
expect(firstDay.sunset).toBeDefined()
})
test("hourly limit is respected", () => {
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
hourlyLimit: 6,
})
expect(dataSource["hourlyLimit"]).toBe(6)
})
test("daily limit is respected", () => {
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
dailyLimit: 3,
})
expect(dataSource["dailyLimit"]).toBe(3)
})
test("default limits are applied", () => {
const dataSource = new WeatherKitDataSource({
credentials: mockCredentials,
})
expect(dataSource["hourlyLimit"]).toBe(12)
expect(dataSource["dailyLimit"]).toBe(7)
})
})
describe("unit conversion", () => {
test("Units enum has metric and imperial", () => {
expect(Units.metric).toBe("metric")
expect(Units.imperial).toBe("imperial")
})
})
describe("query() with mocked client", () => {
const mockClient = createMockClient(fixture.response as WeatherKitResponse)
test("transforms API response into feed items", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context)
expect(items.length).toBeGreaterThan(0)
expect(items.some((i) => i.type === WeatherFeedItemType.current)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.hourly)).toBe(true)
expect(items.some((i) => i.type === WeatherFeedItemType.daily)).toBe(true)
})
test("applies hourly and daily limits", async () => {
const dataSource = new WeatherKitDataSource({
client: mockClient,
hourlyLimit: 3,
dailyLimit: 2,
})
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context)
const hourlyItems = items.filter((i) => i.type === WeatherFeedItemType.hourly)
const dailyItems = items.filter((i) => i.type === WeatherFeedItemType.daily)
expect(hourlyItems.length).toBe(3)
expect(dailyItems.length).toBe(2)
})
test("sets timestamp from context.time", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const queryTime = new Date("2026-01-17T12:00:00Z")
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
context.time = queryTime
const items = await dataSource.query(context)
for (const item of items) {
expect(item.timestamp).toEqual(queryTime)
}
})
test("converts temperatures to imperial", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const metricItems = await dataSource.query(context, {
units: Units.metric,
})
const imperialItems = await dataSource.query(context, {
units: Units.imperial,
})
const metricCurrent = metricItems.find((i) => i.type === WeatherFeedItemType.current)
const imperialCurrent = imperialItems.find((i) => i.type === WeatherFeedItemType.current)
expect(metricCurrent).toBeDefined()
expect(imperialCurrent).toBeDefined()
const metricTemp = (metricCurrent!.data as { temperature: number }).temperature
const imperialTemp = (imperialCurrent!.data as { temperature: number }).temperature
// Verify conversion: F = C * 9/5 + 32
const expectedImperial = (metricTemp * 9) / 5 + 32
expect(imperialTemp).toBeCloseTo(expectedImperial, 2)
})
test("assigns priority based on weather conditions", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context)
for (const item of items) {
expect(item.priority).toBeGreaterThanOrEqual(0)
expect(item.priority).toBeLessThanOrEqual(1)
}
const currentItem = items.find((i) => i.type === WeatherFeedItemType.current)
expect(currentItem).toBeDefined()
// Base priority for current is 0.5, may be adjusted for conditions
expect(currentItem!.priority).toBeGreaterThanOrEqual(0.5)
})
test("generates unique IDs for each item", async () => {
const dataSource = new WeatherKitDataSource({ client: mockClient })
const context = createMockContext({ lat: 37.7749, lng: -122.4194 })
const items = await dataSource.query(context)
const ids = items.map((i) => i.id)
const uniqueIds = new Set(ids)
expect(uniqueIds.size).toBe(ids.length)
})
})

View File

@@ -0,0 +1,306 @@
import type { Context, DataSource } from "@aris/core"
import {
WeatherFeedItemType,
type CurrentWeatherFeedItem,
type DailyWeatherFeedItem,
type HourlyWeatherFeedItem,
type WeatherAlertFeedItem,
type WeatherFeedItem,
} from "./feed-items"
import {
ConditionCode,
DefaultWeatherKitClient,
Severity,
type CurrentWeather,
type DailyForecast,
type HourlyForecast,
type WeatherAlert,
type WeatherKitClient,
type WeatherKitCredentials,
} from "./weatherkit"
export const Units = {
metric: "metric",
imperial: "imperial",
} as const
export type Units = (typeof Units)[keyof typeof Units]
export interface WeatherKitDataSourceOptions {
credentials?: WeatherKitCredentials
client?: WeatherKitClient
hourlyLimit?: number
dailyLimit?: number
}
export interface WeatherKitQueryConfig {
units?: Units
}
export class WeatherKitDataSource implements DataSource<WeatherFeedItem, WeatherKitQueryConfig> {
private readonly DEFAULT_HOURLY_LIMIT = 12
private readonly DEFAULT_DAILY_LIMIT = 7
readonly type = WeatherFeedItemType.current
private readonly client: WeatherKitClient
private readonly hourlyLimit: number
private readonly dailyLimit: number
constructor(options: WeatherKitDataSourceOptions) {
if (!options.client && !options.credentials) {
throw new Error("Either client or credentials must be provided")
}
this.client = options.client ?? new DefaultWeatherKitClient(options.credentials!)
this.hourlyLimit = options.hourlyLimit ?? this.DEFAULT_HOURLY_LIMIT
this.dailyLimit = options.dailyLimit ?? this.DEFAULT_DAILY_LIMIT
}
async query(context: Context, config: WeatherKitQueryConfig = {}): Promise<WeatherFeedItem[]> {
if (!context.location) {
return []
}
const units = config.units ?? Units.metric
const timestamp = context.time
const response = await this.client.fetch({
lat: context.location.lat,
lng: context.location.lng,
})
const items: WeatherFeedItem[] = []
if (response.currentWeather) {
items.push(createCurrentWeatherFeedItem(response.currentWeather, timestamp, units))
}
if (response.forecastHourly?.hours) {
const hours = response.forecastHourly.hours.slice(0, this.hourlyLimit)
for (let i = 0; i < hours.length; i++) {
const hour = hours[i]
if (hour) {
items.push(createHourlyWeatherFeedItem(hour, i, timestamp, units))
}
}
}
if (response.forecastDaily?.days) {
const days = response.forecastDaily.days.slice(0, this.dailyLimit)
for (let i = 0; i < days.length; i++) {
const day = days[i]
if (day) {
items.push(createDailyWeatherFeedItem(day, i, timestamp, units))
}
}
}
if (response.weatherAlerts?.alerts) {
for (const alert of response.weatherAlerts.alerts) {
items.push(createWeatherAlertFeedItem(alert, timestamp))
}
}
return items
}
}
const BASE_PRIORITY = {
current: 0.5,
hourly: 0.3,
daily: 0.2,
alert: 0.7,
} as const
const SEVERE_CONDITIONS = new Set<ConditionCode>([
ConditionCode.SevereThunderstorm,
ConditionCode.Hurricane,
ConditionCode.Tornado,
ConditionCode.TropicalStorm,
ConditionCode.Blizzard,
ConditionCode.FreezingRain,
ConditionCode.Hail,
ConditionCode.Frigid,
ConditionCode.Hot,
])
const MODERATE_CONDITIONS = new Set<ConditionCode>([
ConditionCode.Thunderstorm,
ConditionCode.IsolatedThunderstorms,
ConditionCode.ScatteredThunderstorms,
ConditionCode.HeavyRain,
ConditionCode.HeavySnow,
ConditionCode.FreezingDrizzle,
ConditionCode.BlowingSnow,
])
function adjustPriorityForCondition(basePriority: number, conditionCode: ConditionCode): number {
if (SEVERE_CONDITIONS.has(conditionCode)) {
return Math.min(1, basePriority + 0.3)
}
if (MODERATE_CONDITIONS.has(conditionCode)) {
return Math.min(1, basePriority + 0.15)
}
return basePriority
}
function adjustPriorityForAlertSeverity(severity: Severity): number {
switch (severity) {
case Severity.Extreme:
return 1
case Severity.Severe:
return 0.9
case Severity.Moderate:
return 0.75
case Severity.Minor:
return BASE_PRIORITY.alert
}
}
function convertTemperature(celsius: number, units: Units): number {
if (units === Units.imperial) {
return (celsius * 9) / 5 + 32
}
return celsius
}
function convertSpeed(kmh: number, units: Units): number {
if (units === Units.imperial) {
return kmh * 0.621371
}
return kmh
}
function convertDistance(km: number, units: Units): number {
if (units === Units.imperial) {
return km * 0.621371
}
return km
}
function convertPrecipitation(mm: number, units: Units): number {
if (units === Units.imperial) {
return mm * 0.0393701
}
return mm
}
function convertPressure(mb: number, units: Units): number {
if (units === Units.imperial) {
return mb * 0.02953
}
return mb
}
function createCurrentWeatherFeedItem(
current: CurrentWeather,
timestamp: Date,
units: Units,
): CurrentWeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.current, current.conditionCode)
return {
id: `weather-current-${timestamp.getTime()}`,
type: WeatherFeedItemType.current,
priority,
timestamp,
data: {
conditionCode: current.conditionCode,
daylight: current.daylight,
humidity: current.humidity,
precipitationIntensity: convertPrecipitation(current.precipitationIntensity, units),
pressure: convertPressure(current.pressure, units),
pressureTrend: current.pressureTrend,
temperature: convertTemperature(current.temperature, units),
temperatureApparent: convertTemperature(current.temperatureApparent, units),
uvIndex: current.uvIndex,
visibility: convertDistance(current.visibility, units),
windDirection: current.windDirection,
windGust: convertSpeed(current.windGust, units),
windSpeed: convertSpeed(current.windSpeed, units),
},
}
}
function createHourlyWeatherFeedItem(
hourly: HourlyForecast,
index: number,
timestamp: Date,
units: Units,
): HourlyWeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.hourly, hourly.conditionCode)
return {
id: `weather-hourly-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.hourly,
priority,
timestamp,
data: {
forecastTime: new Date(hourly.forecastStart),
conditionCode: hourly.conditionCode,
daylight: hourly.daylight,
humidity: hourly.humidity,
precipitationAmount: convertPrecipitation(hourly.precipitationAmount, units),
precipitationChance: hourly.precipitationChance,
precipitationType: hourly.precipitationType,
temperature: convertTemperature(hourly.temperature, units),
temperatureApparent: convertTemperature(hourly.temperatureApparent, units),
uvIndex: hourly.uvIndex,
windDirection: hourly.windDirection,
windGust: convertSpeed(hourly.windGust, units),
windSpeed: convertSpeed(hourly.windSpeed, units),
},
}
}
function createDailyWeatherFeedItem(
daily: DailyForecast,
index: number,
timestamp: Date,
units: Units,
): DailyWeatherFeedItem {
const priority = adjustPriorityForCondition(BASE_PRIORITY.daily, daily.conditionCode)
return {
id: `weather-daily-${timestamp.getTime()}-${index}`,
type: WeatherFeedItemType.daily,
priority,
timestamp,
data: {
forecastDate: new Date(daily.forecastStart),
conditionCode: daily.conditionCode,
maxUvIndex: daily.maxUvIndex,
precipitationAmount: convertPrecipitation(daily.precipitationAmount, units),
precipitationChance: daily.precipitationChance,
precipitationType: daily.precipitationType,
snowfallAmount: convertPrecipitation(daily.snowfallAmount, units),
sunrise: new Date(daily.sunrise),
sunset: new Date(daily.sunset),
temperatureMax: convertTemperature(daily.temperatureMax, units),
temperatureMin: convertTemperature(daily.temperatureMin, units),
},
}
}
function createWeatherAlertFeedItem(alert: WeatherAlert, timestamp: Date): WeatherAlertFeedItem {
const priority = adjustPriorityForAlertSeverity(alert.severity)
return {
id: `weather-alert-${alert.id}`,
type: WeatherFeedItemType.alert,
priority,
timestamp,
data: {
alertId: alert.id,
areaName: alert.areaName,
certainty: alert.certainty,
description: alert.description,
detailsUrl: alert.detailsUrl,
effectiveTime: new Date(alert.effectiveTime),
expireTime: new Date(alert.expireTime),
severity: alert.severity,
source: alert.source,
urgency: alert.urgency,
},
}
}

View File

@@ -0,0 +1,97 @@
import type { FeedItem } from "@aris/core"
import type { Certainty, ConditionCode, PrecipitationType, Severity, Urgency } from "./weatherkit"
export const WeatherFeedItemType = {
current: "weather-current",
hourly: "weather-hourly",
daily: "weather-daily",
alert: "weather-alert",
} as const
export type WeatherFeedItemType = (typeof WeatherFeedItemType)[keyof typeof WeatherFeedItemType]
export type CurrentWeatherData = {
conditionCode: ConditionCode
daylight: boolean
humidity: number
precipitationIntensity: number
pressure: number
pressureTrend: "rising" | "falling" | "steady"
temperature: number
temperatureApparent: number
uvIndex: number
visibility: number
windDirection: number
windGust: number
windSpeed: number
}
export interface CurrentWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.current,
CurrentWeatherData
> {}
export type HourlyWeatherData = {
forecastTime: Date
conditionCode: ConditionCode
daylight: boolean
humidity: number
precipitationAmount: number
precipitationChance: number
precipitationType: PrecipitationType
temperature: number
temperatureApparent: number
uvIndex: number
windDirection: number
windGust: number
windSpeed: number
}
export interface HourlyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.hourly,
HourlyWeatherData
> {}
export type DailyWeatherData = {
forecastDate: Date
conditionCode: ConditionCode
maxUvIndex: number
precipitationAmount: number
precipitationChance: number
precipitationType: PrecipitationType
snowfallAmount: number
sunrise: Date
sunset: Date
temperatureMax: number
temperatureMin: number
}
export interface DailyWeatherFeedItem extends FeedItem<
typeof WeatherFeedItemType.daily,
DailyWeatherData
> {}
export type WeatherAlertData = {
alertId: string
areaName: string
certainty: Certainty
description: string
detailsUrl: string
effectiveTime: Date
expireTime: Date
severity: Severity
source: string
urgency: Urgency
}
export interface WeatherAlertFeedItem extends FeedItem<
typeof WeatherFeedItemType.alert,
WeatherAlertData
> {}
export type WeatherFeedItem =
| CurrentWeatherFeedItem
| HourlyWeatherFeedItem
| DailyWeatherFeedItem
| WeatherAlertFeedItem

View File

@@ -0,0 +1,38 @@
export {
WeatherKitDataSource,
Units,
type Units as UnitsType,
type WeatherKitDataSourceOptions,
type WeatherKitQueryConfig,
} from "./data-source"
export {
WeatherFeedItemType,
type WeatherFeedItemType as WeatherFeedItemTypeType,
type CurrentWeatherData,
type CurrentWeatherFeedItem,
type DailyWeatherData,
type DailyWeatherFeedItem,
type HourlyWeatherData,
type HourlyWeatherFeedItem,
type WeatherAlertData,
type WeatherAlertFeedItem,
type WeatherFeedItem,
} from "./feed-items"
export {
Severity,
Urgency,
Certainty,
PrecipitationType,
ConditionCode,
DefaultWeatherKitClient,
type Severity as SeverityType,
type Urgency as UrgencyType,
type Certainty as CertaintyType,
type PrecipitationType as PrecipitationTypeType,
type ConditionCode as ConditionCodeType,
type WeatherKitCredentials,
type WeatherKitClient,
type WeatherKitQueryOptions,
} from "./weatherkit"

Some files were not shown because too many files have changed in this diff Show More