From 6d9ed7223b5ed0ab1aae75c8e969944ae9aebfa7 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 16 Apr 2026 00:49:42 -0600 Subject: [PATCH 01/11] :sparkles: add Hono RPC foundation --- apps/expo/features/auth/README.md | 13 ++ apps/expo/lib/api/rpcClient.ts | 7 + apps/expo/lib/api/rpcTransport.ts | 169 ++++++++++++++++++ apps/expo/package.json | 1 + apps/expo/test/rpc-client-proof.test.ts | 42 +++++ apps/expo/tsconfig.json | 8 +- bun.lock | 26 ++- package.json | 4 + packages/api-client/package.json | 14 ++ packages/api-client/src/client.ts | 8 + packages/api-client/src/index.ts | 3 + packages/api-client/src/responses.ts | 4 + packages/api-client/src/types.ts | 13 ++ packages/api-client/test/rpc-probe.ts | 10 ++ packages/api-client/test/rpc-types.test.ts | 59 ++++++ packages/api-client/tsconfig.json | 7 + packages/api-client/tsconfig.probe.json | 4 + packages/api/package.json | 16 +- packages/api/src/index.ts | 9 +- packages/api/src/routes/admin/index.ts | 2 +- packages/api/src/routes/catalog/index.ts | 29 ++- packages/api/src/routes/feed/index.ts | 7 +- packages/api/src/routes/guides/index.ts | 11 +- packages/api/src/routes/index.ts | 55 +++--- .../api/src/routes/packTemplates/index.ts | 9 +- packages/api/src/routes/packs/index.ts | 13 +- .../api/src/routes/trailConditions/index.ts | 4 +- packages/api/src/routes/trips/index.ts | 5 +- packages/api/src/schemas/catalog.ts | 26 ++- packages/api/src/schemas/guides.ts | 17 +- packages/api/src/services/weatherService.ts | 17 +- tsconfig.json | 3 + 32 files changed, 515 insertions(+), 100 deletions(-) create mode 100644 apps/expo/lib/api/rpcClient.ts create mode 100644 apps/expo/lib/api/rpcTransport.ts create mode 100644 apps/expo/test/rpc-client-proof.test.ts create mode 100644 packages/api-client/package.json create mode 100644 packages/api-client/src/client.ts create mode 100644 packages/api-client/src/index.ts create mode 100644 packages/api-client/src/responses.ts create mode 100644 packages/api-client/src/types.ts create mode 100644 packages/api-client/test/rpc-probe.ts create mode 100644 packages/api-client/test/rpc-types.test.ts create mode 100644 packages/api-client/tsconfig.json create mode 100644 packages/api-client/tsconfig.probe.json diff --git a/apps/expo/features/auth/README.md b/apps/expo/features/auth/README.md index 3de6a7b860..de6eaebcc1 100644 --- a/apps/expo/features/auth/README.md +++ b/apps/expo/features/auth/README.md @@ -33,6 +33,19 @@ When a user's session expires (access token and refresh token both expire), the - `atoms/authAtoms.ts`: Jotai atoms for authentication state including re-auth flags - `hooks/useAuthActions.ts`: Actions for sign-in, sign-out, and state management - `lib/api/client.ts`: Axios interceptor handling token refresh and re-auth triggering +- `lib/api/rpcTransport.ts`: Hono RPC fetch adapter mirroring the same refresh and re-auth behavior +- `lib/api/rpcClient.ts`: Shared Expo RPC client entry point backed by `@packrat/api-client` + +## RPC Migration Status + +The app is currently in a coexistence period: + +- Legacy feature modules still use `lib/api/client.ts` and `axiosInstance` +- New Hono RPC infrastructure lives in `lib/api/rpcTransport.ts` and `lib/api/rpcClient.ts` +- The RPC transport is intended to preserve the same auth contract as axios: + bearer token attachment, refresh retry queueing, and `needsReauthAtom` on hard failure + +Until feature consumers are migrated, both transport layers may exist side-by-side. Changes to auth behavior should keep them aligned. ### Example Flow diff --git a/apps/expo/lib/api/rpcClient.ts b/apps/expo/lib/api/rpcClient.ts new file mode 100644 index 0000000000..d4e76b0d6a --- /dev/null +++ b/apps/expo/lib/api/rpcClient.ts @@ -0,0 +1,7 @@ +import { createApiClient } from '@packrat/api-client'; +import { clientEnvs } from 'expo-app/env/clientEnvs'; +import { createRpcFetch } from './rpcTransport'; + +export const rpcClient = createApiClient(clientEnvs.EXPO_PUBLIC_API_URL, { + fetch: createRpcFetch(), +}); diff --git a/apps/expo/lib/api/rpcTransport.ts b/apps/expo/lib/api/rpcTransport.ts new file mode 100644 index 0000000000..4c029e4a4b --- /dev/null +++ b/apps/expo/lib/api/rpcTransport.ts @@ -0,0 +1,169 @@ +import { store } from 'expo-app/atoms/store'; +import { clientEnvs } from 'expo-app/env/clientEnvs'; +import { + needsReauthAtom, + refreshTokenAtom, + tokenAtom, +} from 'expo-app/features/auth/atoms/authAtoms'; +import Storage from 'expo-sqlite/kv-store'; + +type FetchLike = typeof fetch; + +type QueuedRequest = { + input: RequestInfo | URL; + init?: RequestInit; + resolve: (value: Response | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +const defaultBaseUrl = clientEnvs.EXPO_PUBLIC_API_URL; + +let isRefreshing = false; +let failedQueue: QueuedRequest[] = []; + +const cloneInit = (init?: RequestInit): RequestInit | undefined => { + if (!init) { + return undefined; + } + + return { + ...init, + headers: init.headers ? new Headers(init.headers) : undefined, + }; +}; + +const withAuthHeaders = async (init?: RequestInit, tokenOverride?: string | null): Promise => { + const headers = new Headers(init?.headers); + + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + + const token = tokenOverride ?? (await Storage.getItem('access_token')); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + + return { + ...init, + headers, + }; +}; + +const processQueue = async ( + error: Error | null, + token: string | null, + fetchImpl: FetchLike, +) => { + const pending = failedQueue; + failedQueue = []; + + for (const request of pending) { + if (error) { + request.reject(error); + continue; + } + + try { + const retryInit = await withAuthHeaders(request.init, token); + request.resolve(fetchImpl(request.input, retryInit)); + } catch (retryError) { + request.reject(retryError); + } + } +}; + +const refreshAccessToken = async (fetchImpl: FetchLike, baseUrl: string) => { + const refreshToken = await Storage.getItem('refresh_token'); + + const response = await fetchImpl(`${baseUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ refreshToken }), + }); + + const data = (await response.json()) as { + success?: boolean; + accessToken?: string; + refreshToken?: string; + }; + + if (!response.ok || !data.success || !data.accessToken || !data.refreshToken) { + throw new Error('Token refresh failed'); + } + + await store.set(tokenAtom, data.accessToken); + await store.set(refreshTokenAtom, data.refreshToken); + + return data.accessToken; +}; + +export const createRpcFetch = ( + options: { + baseUrl?: string; + fetchImpl?: FetchLike; + } = {}, +) => { + const baseUrl = options.baseUrl ?? defaultBaseUrl; + const fetchImpl = options.fetchImpl ?? fetch; + + const rpcFetch = Object.assign( + async (input: Parameters[0], init?: Parameters[1]) => { + const authedInit = await withAuthHeaders(init); + const response = await fetchImpl(input, authedInit); + + if (response.status !== 401) { + return response; + } + + const retried = new Headers(init?.headers).get('x-packrat-rpc-retry'); + if (retried === 'true') { + return response; + } + + if (isRefreshing) { + return await new Promise((resolve, reject) => { + failedQueue.push({ + input, + init: cloneInit(init), + resolve, + reject, + }); + }); + } + + isRefreshing = true; + + try { + const nextToken = await refreshAccessToken(fetchImpl, baseUrl); + await processQueue(null, nextToken, fetchImpl); + + const retryHeaders = new Headers(init?.headers); + retryHeaders.set('x-packrat-rpc-retry', 'true'); + + return await fetchImpl( + input, + await withAuthHeaders( + { + ...cloneInit(init), + headers: retryHeaders, + }, + nextToken, + ), + ); + } catch (error) { + await store.set(needsReauthAtom, true); + await processQueue(error as Error, null, fetchImpl); + throw error; + } finally { + isRefreshing = false; + } + }, + fetchImpl, + ) satisfies FetchLike; + + return rpcFetch; +}; diff --git a/apps/expo/package.json b/apps/expo/package.json index 2f6fd2669e..26e2591f46 100644 --- a/apps/expo/package.json +++ b/apps/expo/package.json @@ -50,6 +50,7 @@ "@gorhom/bottom-sheet": "^5.1.2", "@legendapp/state": "^3.0.0-beta.30", "@packrat-ai/nativewindui": "^2.0.2", + "@packrat/api-client": "workspace:*", "@packrat/guards": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", diff --git a/apps/expo/test/rpc-client-proof.test.ts b/apps/expo/test/rpc-client-proof.test.ts new file mode 100644 index 0000000000..e0866209c3 --- /dev/null +++ b/apps/expo/test/rpc-client-proof.test.ts @@ -0,0 +1,42 @@ +import { expectTypeOf, test } from 'vitest'; +import { createApiClient, type ApiRequestOf, type ApiResponseOf } from '@packrat/api-client'; + +const client = createApiClient('https://packrat.test'); +const guideGetById = client.api.guides[':id'].$get; +const catalogGetById = client.api.catalog[':id'].$get; +type GuideGetById = typeof guideGetById; +type CatalogGetById = typeof catalogGetById; + +test('Expo can consume typed guide and catalog RPC requests', () => { + const guideRequest: ApiRequestOf = { + param: { + id: 'ultralight-backpacking', + }, + }; + + const catalogQuery: ApiRequestOf = { + query: { + q: 'sleeping bag', + page: '1', + limit: '10', + }, + }; + + expectTypeOf(guideRequest.param.id).toEqualTypeOf(); + expectTypeOf(catalogQuery.query?.page).toEqualTypeOf(); +}); + +test('Expo can narrow typed RPC responses by route and status', () => { + type GuideResponse = ApiResponseOf; + type MissingCatalogItem = ApiResponseOf; + + expectTypeOf().toMatchTypeOf<{ + id: string; + title: string; + content: string; + }>(); + + expectTypeOf().toMatchTypeOf<{ + error: string; + }>(); +}); diff --git a/apps/expo/tsconfig.json b/apps/expo/tsconfig.json index 0028de6d44..65e47272a8 100644 --- a/apps/expo/tsconfig.json +++ b/apps/expo/tsconfig.json @@ -1,11 +1,15 @@ { - "extends": "expo/tsconfig.base", + "extends": "../../tsconfig.json", "compilerOptions": { "strict": true, "jsx": "react-jsx", "baseUrl": ".", "paths": { - "expo-app/*": ["./*"] + "expo-app/*": ["./*"], + "@packrat/api": ["../../packages/api/src/index.ts"], + "@packrat/api/*": ["../../packages/api/src/*"], + "@packrat/api-client": ["../../packages/api-client/src/index.ts"], + "@packrat/api-client/*": ["../../packages/api-client/src/*"] } }, "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"], diff --git a/bun.lock b/bun.lock index f164022eff..7dabf73fe1 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "@gorhom/bottom-sheet": "^5.1.2", "@legendapp/state": "^3.0.0-beta.30", "@packrat-ai/nativewindui": "^2.0.2", + "@packrat/api-client": "workspace:*", "@packrat/guards": "workspace:*", "@react-native-ai/apple": "~0.10.0", "@react-native-ai/llama": "~0.10.0", @@ -346,9 +347,9 @@ "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", "@cloudflare/containers": "^0.0.30", - "@hono/sentry": "^1.2.2", - "@hono/zod-openapi": "1.2.4", - "@hono/zod-validator": "^0.7.6", + "@hono/sentry": "catalog:", + "@hono/zod-openapi": "catalog:", + "@hono/zod-validator": "catalog:", "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", "@packrat/guards": "workspace:*", @@ -362,7 +363,7 @@ "drizzle-zod": "^0.8.3", "google-auth-library": "^10.1.0", "gray-matter": "^4.0.3", - "hono": "^4.7.5", + "hono": "catalog:", "hono-openapi": "^0.4.6", "linkedom": "^0.18.11", "nodemailer": "^6.10.0", @@ -389,6 +390,13 @@ "wrangler": "^4.21.2", }, }, + "packages/api-client": { + "name": "@packrat/api-client", + "version": "2.0.19", + "dependencies": { + "hono": "catalog:", + }, + }, "packages/cli": { "name": "@packrat/cli", "version": "2.0.19", @@ -480,6 +488,9 @@ "@sentry/cli", ], "catalog": { + "@hono/sentry": "^1.2.2", + "@hono/zod-openapi": "^1.3.0", + "@hono/zod-validator": "^0.7.6", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-aspect-ratio": "^1.1.7", @@ -510,6 +521,7 @@ "ai": "^5.0.136", "axios": "^1.12.0", "chalk": "^5.6.2", + "hono": "^4.10.7", "radash": "^12.1.1", "react": "19.1.0", "react-dom": "19.1.0", @@ -1028,7 +1040,7 @@ "@hono/sentry": ["@hono/sentry@1.2.2", "", { "dependencies": { "toucan-js": "^4.0.0" }, "peerDependencies": { "hono": ">=3.*" } }, "sha512-027grZBrRGDPor8mRd+QOBcSpUlF07YrTp/WFDXZhbvWZ+1LrZdERUqcdg1gBGDUTanHhd9ucblpNNN6+V1bxg=="], - "@hono/zod-openapi": ["@hono/zod-openapi@1.2.4", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.7.6", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "^4.0.0" } }, "sha512-cZu71bpODTbtIDoUsIIYPrs58wJ565Tbg6FE+JshU0irBAd6KxrP+k62Amm/mjA7tTOQ3+ingODHKGFOnv+Ibw=="], + "@hono/zod-openapi": ["@hono/zod-openapi@1.3.0", "", { "dependencies": { "@asteasolutions/zod-to-openapi": "^8.5.0", "@hono/zod-validator": "^0.7.6", "openapi3-ts": "^4.5.0" }, "peerDependencies": { "hono": ">=4.3.6", "zod": "^4.0.0" } }, "sha512-loDVevfMaaNa0slskhpMcqjSdidVXba2QJwNVmnS5Dp6L8AqSgtjJxWGJfRZtosyzYOb5gx4ZzXNCe+QhwY7xw=="], "@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="], @@ -1176,6 +1188,8 @@ "@packrat/api": ["@packrat/api@workspace:packages/api"], + "@packrat/api-client": ["@packrat/api-client@workspace:packages/api-client"], + "@packrat/cli": ["@packrat/cli@workspace:packages/cli"], "@packrat/guards": ["@packrat/guards@workspace:packages/guards"], @@ -2548,7 +2562,7 @@ "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], - "hono": ["hono@4.9.0", "", {}, "sha512-JAUc4Sqi3lhby2imRL/67LMcJFKiCu7ZKghM7iwvltVZzxEC5bVJCsAa4NTnSfmWGb+N2eOVtFE586R+K3fejA=="], + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], "hono-openapi": ["hono-openapi@0.4.8", "", { "dependencies": { "json-schema-walker": "^2.0.0" }, "peerDependencies": { "@hono/arktype-validator": "^2.0.0", "@hono/effect-validator": "^1.2.0", "@hono/typebox-validator": "^0.2.0 || ^0.3.0", "@hono/valibot-validator": "^0.5.1", "@hono/zod-validator": "^0.4.1", "@sinclair/typebox": "^0.34.9", "@valibot/to-json-schema": "^1.0.0-beta.3", "arktype": "^2.0.0", "effect": "^3.11.3", "hono": "^4.6.13", "openapi-types": "^12.1.3", "valibot": "^1.0.0-beta.9", "zod": "^3.23.8", "zod-openapi": "^4.0.0" }, "optionalPeers": ["@hono/arktype-validator", "@hono/effect-validator", "@hono/typebox-validator", "@hono/valibot-validator", "@hono/zod-validator", "@sinclair/typebox", "@valibot/to-json-schema", "arktype", "effect", "hono", "valibot", "zod", "zod-openapi"] }, "sha512-LYr5xdtD49M7hEAduV1PftOMzuT8ZNvkyWfh1DThkLsIr4RkvDb12UxgIiFbwrJB6FLtFXLoOZL9x4IeDk2+VA=="], diff --git a/package.json b/package.json index f7f61b7d1f..c03a26b57b 100644 --- a/package.json +++ b/package.json @@ -57,12 +57,16 @@ "ai": "^5.0.136", "axios": "^1.12.0", "chalk": "^5.6.2", + "hono": "^4.10.7", "radash": "^12.1.1", "react": "19.1.0", "react-dom": "19.1.0", "tailwindcss": "^3.4.17", "typescript": "~5.9.2", "zod": "^3.24.2", + "@hono/sentry": "^1.2.2", + "@hono/zod-openapi": "^1.3.0", + "@hono/zod-validator": "^0.7.6", "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-alert-dialog": "^1.1.14", "@radix-ui/react-aspect-ratio": "^1.1.7", diff --git a/packages/api-client/package.json b/packages/api-client/package.json new file mode 100644 index 0000000000..f93368220e --- /dev/null +++ b/packages/api-client/package.json @@ -0,0 +1,14 @@ +{ + "name": "@packrat/api-client", + "version": "2.0.19", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "dependencies": { + "hono": "catalog:" + } +} diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts new file mode 100644 index 0000000000..7c9fc890d5 --- /dev/null +++ b/packages/api-client/src/client.ts @@ -0,0 +1,8 @@ +import { hc, type ClientRequestOptions } from 'hono/client'; +import type { AppType } from '@packrat/api'; + +export type ApiClientOptions = ClientRequestOptions; + +export const createApiClient = (baseUrl: string, options?: ApiClientOptions) => { + return hc(baseUrl, options); +}; diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts new file mode 100644 index 0000000000..1262b266a0 --- /dev/null +++ b/packages/api-client/src/index.ts @@ -0,0 +1,3 @@ +export { createApiClient, type ApiClientOptions } from './client'; +export { type ApiErrorResponse } from './responses'; +export type { ApiClient, ApiRequestOf, ApiResponseOf, RpcAppType } from './types'; diff --git a/packages/api-client/src/responses.ts b/packages/api-client/src/responses.ts new file mode 100644 index 0000000000..02a3219ae9 --- /dev/null +++ b/packages/api-client/src/responses.ts @@ -0,0 +1,4 @@ +export type ApiErrorResponse = { + error: string; + code?: string; +}; diff --git a/packages/api-client/src/types.ts b/packages/api-client/src/types.ts new file mode 100644 index 0000000000..95ba7ebc6b --- /dev/null +++ b/packages/api-client/src/types.ts @@ -0,0 +1,13 @@ +import type { AppType } from '@packrat/api'; +import type { InferRequestType, InferResponseType } from 'hono/client'; +import type { StatusCode } from 'hono/utils/http-status'; + +export type RpcAppType = AppType; + +export type ApiClient = ReturnType; + +type ApiEndpointFn = (...args: never[]) => Promise; + +export type ApiRequestOf = InferRequestType; +export type ApiResponseOf = + InferResponseType; diff --git a/packages/api-client/test/rpc-probe.ts b/packages/api-client/test/rpc-probe.ts new file mode 100644 index 0000000000..60bd0f64a0 --- /dev/null +++ b/packages/api-client/test/rpc-probe.ts @@ -0,0 +1,10 @@ +import { createApiClient } from '../src'; + +const client = createApiClient('https://packrat.test'); + +client.api.catalog; +client.api.guides; + +// Probe the inferred path parameter key shape. +client.api.catalog[':id']; +client.api.guides[':id']; diff --git a/packages/api-client/test/rpc-types.test.ts b/packages/api-client/test/rpc-types.test.ts new file mode 100644 index 0000000000..5a5f7d6e92 --- /dev/null +++ b/packages/api-client/test/rpc-types.test.ts @@ -0,0 +1,59 @@ +import { expectTypeOf, test } from 'vitest'; +import { createApiClient, type ApiRequestOf, type ApiResponseOf } from '../src'; + +const client = createApiClient('https://packrat.test'); +const catalogGetById = client.api.catalog[':id'].$get; +type CatalogGetById = typeof catalogGetById; + +test('catalog RPC requests stay inferred', () => { + const listRequest: ApiRequestOf = { + query: { + page: '1', + limit: '20', + q: 'tent', + }, + }; + + const createRequest: ApiRequestOf = { + json: { + name: 'Test tent', + productUrl: 'https://example.com/products/tent', + sku: 'tent-123', + weight: 1000, + weightUnit: 'g', + }, + }; + + const byIdRequest: ApiRequestOf = { + param: { + id: '123', + }, + }; + + expectTypeOf(listRequest.query?.page).toEqualTypeOf(); + expectTypeOf(createRequest.json.name).toEqualTypeOf(); + expectTypeOf(byIdRequest.param.id).toEqualTypeOf(); +}); + +test('catalog RPC responses stay inferred by status code', () => { + type ListResponse = ApiResponseOf; + type ItemResponse = ApiResponseOf; + type MissingItemResponse = ApiResponseOf; + + expectTypeOf().toMatchTypeOf<{ + items: unknown[]; + totalCount: number; + page: number; + limit: number; + totalPages: number; + }>(); + + expectTypeOf().toMatchTypeOf<{ + id: number; + name: string; + }>(); + + expectTypeOf().toMatchTypeOf<{ + error: string; + }>(); +}); diff --git a/packages/api-client/tsconfig.json b/packages/api-client/tsconfig.json new file mode 100644 index 0000000000..be9c88b7a2 --- /dev/null +++ b/packages/api-client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "strict": true + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} diff --git a/packages/api-client/tsconfig.probe.json b/packages/api-client/tsconfig.probe.json new file mode 100644 index 0000000000..60e0731afa --- /dev/null +++ b/packages/api-client/tsconfig.probe.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "include": ["test/rpc-probe.ts"] +} diff --git a/packages/api/package.json b/packages/api/package.json index cd2aa544a9..873eece899 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,5 +1,13 @@ { "name": "@packrat/api", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./*": "./src/*" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", "scripts": { "check-types": "tsc --noEmit", "check-types-watch": "tsc --noEmit --watch", @@ -21,9 +29,9 @@ "@aws-sdk/client-s3": "^3.787.0", "@aws-sdk/s3-request-presigner": "^3.787.0", "@cloudflare/containers": "^0.0.30", - "@hono/sentry": "^1.2.2", - "@hono/zod-openapi": "1.2.4", - "@hono/zod-validator": "^0.7.6", + "@hono/sentry": "catalog:", + "@hono/zod-openapi": "catalog:", + "@hono/zod-validator": "catalog:", "@mozilla/readability": "^0.6.0", "@neondatabase/serverless": "^1.0.0", "@packrat/guards": "workspace:*", @@ -37,7 +45,7 @@ "drizzle-zod": "^0.8.3", "google-auth-library": "^10.1.0", "gray-matter": "^4.0.3", - "hono": "^4.7.5", + "hono": "catalog:", "hono-openapi": "^0.4.6", "linkedom": "^0.18.11", "nodemailer": "^6.10.0", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 04a1189118..5379818f91 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,6 +3,8 @@ import { sentry } from '@hono/sentry'; import { OpenAPIHono } from '@hono/zod-openapi'; import { AppContainer } from '@packrat/api/containers'; import { routes } from '@packrat/api/routes'; +import { catalogRoutes } from '@packrat/api/routes/catalog'; +import { guidesRoutes } from '@packrat/api/routes/guides'; import { processQueueBatch } from '@packrat/api/services/etl/queue'; import type { Env } from '@packrat/api/types/env'; import { getEnv } from '@packrat/api/utils/env-validation'; @@ -50,6 +52,9 @@ app.use(logger()); app.use(cors()); // Mount routes +const rpcRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>() + .route('/api/catalog', catalogRoutes) + .route('/api/guides', guidesRoutes); app.route('/api', routes); // Configure OpenAPI documentation @@ -74,8 +79,10 @@ app.get('/', (c) => { return c.text('PackRat API is running!'); }); +export type AppType = typeof rpcRoutes; + // Export the AppContainer class for Cloudflare Container binding -export { AppContainer }; +export { AppContainer, app, rpcRoutes }; export default { fetch: app.fetch, diff --git a/packages/api/src/routes/admin/index.ts b/packages/api/src/routes/admin/index.ts index 71b1b2d8d3..44b14a0cc6 100644 --- a/packages/api/src/routes/admin/index.ts +++ b/packages/api/src/routes/admin/index.ts @@ -57,7 +57,7 @@ adminRoutes.use('*', async (c, next) => { if (authHeader?.startsWith('Bearer ')) { try { const e = getEnv(c); - const payload = await verify(authHeader.slice(7), e.JWT_SECRET); + const payload = await verify(authHeader.slice(7), e.JWT_SECRET, 'HS256'); if (payload.role !== 'admin') { return c.json({ error: 'Forbidden' }, 403); } diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index 1d57514f1d..b31703df7c 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -12,20 +12,19 @@ import { queueCatalogEtlRoute } from './queueCatalogEtlRoute'; import * as updateCatalogItemRoute from './updateCatalogItemRoute'; import * as vectorSearchRoute from './vectorSearchRoute'; -const catalogRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); - -catalogRoutes.openapi(getCatalogItemsRoute.routeDefinition, getCatalogItemsRoute.handler); -catalogRoutes.openapi(vectorSearchRoute.routeDefinition, vectorSearchRoute.handler); -catalogRoutes.openapi(createCatalogItemRoute.routeDefinition, createCatalogItemRoute.handler); -catalogRoutes.route('/', getCatalogItemsCategoriesRoute); -catalogRoutes.openapi(getCatalogItemRoute.routeDefinition, getCatalogItemRoute.handler); -catalogRoutes.openapi( - getSimilarCatalogItemsRoute.routeDefinition, - getSimilarCatalogItemsRoute.handler, -); -catalogRoutes.openapi(deleteCatalogItemRoute.routeDefinition, deleteCatalogItemRoute.handler); -catalogRoutes.openapi(updateCatalogItemRoute.routeDefinition, updateCatalogItemRoute.handler); -catalogRoutes.route('/', queueCatalogEtlRoute); -catalogRoutes.route('/', backfillEmbeddingsRoute); +const catalogRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>() + .openapi(getCatalogItemsRoute.routeDefinition, getCatalogItemsRoute.handler) + .openapi(vectorSearchRoute.routeDefinition, vectorSearchRoute.handler) + .openapi(createCatalogItemRoute.routeDefinition, createCatalogItemRoute.handler) + .route('/', getCatalogItemsCategoriesRoute) + .openapi(getCatalogItemRoute.routeDefinition, getCatalogItemRoute.handler) + .openapi( + getSimilarCatalogItemsRoute.routeDefinition, + getSimilarCatalogItemsRoute.handler, + ) + .openapi(deleteCatalogItemRoute.routeDefinition, deleteCatalogItemRoute.handler) + .openapi(updateCatalogItemRoute.routeDefinition, updateCatalogItemRoute.handler) + .route('/', queueCatalogEtlRoute) + .route('/', backfillEmbeddingsRoute); export { catalogRoutes }; diff --git a/packages/api/src/routes/feed/index.ts b/packages/api/src/routes/feed/index.ts index e8d44884b1..3874d483f0 100644 --- a/packages/api/src/routes/feed/index.ts +++ b/packages/api/src/routes/feed/index.ts @@ -4,9 +4,8 @@ import type { Variables } from '@packrat/api/types/variables'; import { commentsRoutes } from './comments'; import { postsRoutes } from './posts'; -const feedRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); - -feedRoutes.route('/', postsRoutes); -feedRoutes.route('/', commentsRoutes); +const feedRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>() + .route('/', postsRoutes) + .route('/', commentsRoutes); export { feedRoutes }; diff --git a/packages/api/src/routes/guides/index.ts b/packages/api/src/routes/guides/index.ts index 5ffd3c4f57..7c9f5340ff 100644 --- a/packages/api/src/routes/guides/index.ts +++ b/packages/api/src/routes/guides/index.ts @@ -6,11 +6,10 @@ import * as getGuideRoute from './getGuideRoute'; import * as getGuidesRoute from './getGuidesRoute'; import * as searchGuidesRoute from './searchGuidesRoute'; -const guidesRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>(); - -guidesRoutes.openapi(getGuidesRoute.routeDefinition, getGuidesRoute.handler); -guidesRoutes.openapi(getCategoriesRoute.routeDefinition, getCategoriesRoute.handler); -guidesRoutes.openapi(searchGuidesRoute.routeDefinition, searchGuidesRoute.handler); -guidesRoutes.openapi(getGuideRoute.routeDefinition, getGuideRoute.handler); +const guidesRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>() + .openapi(getGuidesRoute.routeDefinition, getGuidesRoute.handler) + .openapi(getCategoriesRoute.routeDefinition, getCategoriesRoute.handler) + .openapi(searchGuidesRoute.routeDefinition, searchGuidesRoute.handler) + .openapi(getGuideRoute.routeDefinition, getGuideRoute.handler); export { guidesRoutes }; diff --git a/packages/api/src/routes/index.ts b/packages/api/src/routes/index.ts index a936431fb7..9e43385741 100644 --- a/packages/api/src/routes/index.ts +++ b/packages/api/src/routes/index.ts @@ -1,4 +1,4 @@ -import { OpenAPIHono } from '@hono/zod-openapi'; +import { $, OpenAPIHono } from '@hono/zod-openapi'; import { authMiddleware } from '@packrat/api/middleware'; import { adminRoutes } from './admin'; import { aiRoutes } from './ai'; @@ -17,36 +17,27 @@ import { userRoutes } from './user'; import { weatherRoutes } from './weather'; import { wildlifeRoutes } from './wildlife'; -const publicRoutes = new OpenAPIHono(); - -// Mount public routes -publicRoutes.route('/auth', authRoutes); -publicRoutes.route('/admin', adminRoutes); - -const protectedRoutes = new OpenAPIHono(); - -protectedRoutes.use(authMiddleware); - -// Mount protected routes -protectedRoutes.route('/catalog', catalogRoutes); -protectedRoutes.route('/guides', guidesRoutes); -protectedRoutes.route('/feed', feedRoutes); -protectedRoutes.route('/packs', packsRoutes); -protectedRoutes.route('/trips', tripsRoutes); - -protectedRoutes.route('/ai', aiRoutes); -protectedRoutes.route('/chat', chatRoutes); -protectedRoutes.route('/weather', weatherRoutes); -protectedRoutes.route('/pack-templates', packTemplatesRoutes); -protectedRoutes.route('/season-suggestions', seasonSuggestionsRoutes); -protectedRoutes.route('/user', userRoutes); -protectedRoutes.route('/upload', uploadRoutes); -protectedRoutes.route('/trail-conditions', trailConditionsRoutes); -protectedRoutes.route('/wildlife', wildlifeRoutes); - -const routes = new OpenAPIHono(); - -routes.route('/', publicRoutes); -routes.route('/', protectedRoutes); +const publicRoutes = $(new OpenAPIHono().route('/auth', authRoutes).route('/admin', adminRoutes)); + +const protectedRoutes = $( + new OpenAPIHono() + .use(authMiddleware) + .route('/catalog', catalogRoutes) + .route('/guides', guidesRoutes) + .route('/feed', feedRoutes) + .route('/packs', packsRoutes) + .route('/trips', tripsRoutes) + .route('/ai', aiRoutes) + .route('/chat', chatRoutes) + .route('/weather', weatherRoutes) + .route('/pack-templates', packTemplatesRoutes) + .route('/season-suggestions', seasonSuggestionsRoutes) + .route('/user', userRoutes) + .route('/upload', uploadRoutes) + .route('/trail-conditions', trailConditionsRoutes) + .route('/wildlife', wildlifeRoutes), +); + +const routes = $(new OpenAPIHono().route('/', publicRoutes).route('/', protectedRoutes)); export { routes }; diff --git a/packages/api/src/routes/packTemplates/index.ts b/packages/api/src/routes/packTemplates/index.ts index d4b896c977..3b38f9827e 100644 --- a/packages/api/src/routes/packTemplates/index.ts +++ b/packages/api/src/routes/packTemplates/index.ts @@ -3,10 +3,9 @@ import { generateFromOnlineContentRoutes } from './generateFromOnlineContent'; import { packTemplateItemsRoutes } from './packTemplateItems'; import { packTemplateRoutes } from './packTemplates'; -const packTemplatesRoutes = new OpenAPIHono(); - -packTemplatesRoutes.route('/', packTemplateRoutes); -packTemplatesRoutes.route('/', packTemplateItemsRoutes); -packTemplatesRoutes.route('/', generateFromOnlineContentRoutes); +const packTemplatesRoutes = new OpenAPIHono() + .route('/', packTemplateRoutes) + .route('/', packTemplateItemsRoutes) + .route('/', generateFromOnlineContentRoutes); export { packTemplatesRoutes }; diff --git a/packages/api/src/routes/packs/index.ts b/packages/api/src/routes/packs/index.ts index ca52c405f5..c0aab72864 100644 --- a/packages/api/src/routes/packs/index.ts +++ b/packages/api/src/routes/packs/index.ts @@ -5,12 +5,11 @@ import { packItemsRoutes } from './items'; import { packsListRoutes } from './list'; import { packRoutes } from './pack'; -const packsRoutes = new OpenAPIHono(); - -packsRoutes.route('/', analyzeImageRoutes); -packsRoutes.route('/', packsListRoutes); -packsRoutes.route('/', packRoutes); -packsRoutes.route('/', packItemsRoutes); -packsRoutes.route('/', generatePacksRoute); +const packsRoutes = new OpenAPIHono() + .route('/', analyzeImageRoutes) + .route('/', packsListRoutes) + .route('/', packRoutes) + .route('/', packItemsRoutes) + .route('/', generatePacksRoute); export { packsRoutes }; diff --git a/packages/api/src/routes/trailConditions/index.ts b/packages/api/src/routes/trailConditions/index.ts index 00e9d240ab..ea3f9dad2a 100644 --- a/packages/api/src/routes/trailConditions/index.ts +++ b/packages/api/src/routes/trailConditions/index.ts @@ -6,8 +6,6 @@ import { trailConditionRoutes } from './reports'; const trailConditionsRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables; -}>(); - -trailConditionsRoutes.route('/', trailConditionRoutes); +}>().route('/', trailConditionRoutes); export { trailConditionsRoutes }; diff --git a/packages/api/src/routes/trips/index.ts b/packages/api/src/routes/trips/index.ts index 61f686a6db..e8c8283792 100644 --- a/packages/api/src/routes/trips/index.ts +++ b/packages/api/src/routes/trips/index.ts @@ -2,9 +2,6 @@ import { OpenAPIHono } from '@hono/zod-openapi'; import { tripsListRoutes } from './list'; import { tripRoutes } from './trip'; -const tripsRoutes = new OpenAPIHono(); - -tripsRoutes.route('/', tripRoutes); -tripsRoutes.route('/', tripsListRoutes); +const tripsRoutes = new OpenAPIHono().route('/', tripRoutes).route('/', tripsListRoutes); export { tripsRoutes }; diff --git a/packages/api/src/schemas/catalog.ts b/packages/api/src/schemas/catalog.ts index c151a74e45..ee8e518601 100644 --- a/packages/api/src/schemas/catalog.ts +++ b/packages/api/src/schemas/catalog.ts @@ -1,5 +1,23 @@ import { z } from '@hono/zod-openapi'; +const positiveIntegerQueryParam = (defaultValue: string) => + z + .string() + .regex(/^\d+$/) + .optional() + .default(defaultValue) + .transform((value) => Number(value)) + .pipe(z.number().int().positive()); + +const boundedIntegerQueryParam = (defaultValue: string, max: number) => + z + .string() + .regex(/^\d+$/) + .optional() + .default(defaultValue) + .transform((value) => Number(value)) + .pipe(z.number().int().min(1).max(max)); + export const ErrorResponseSchema = z .object({ error: z.string().openapi({ @@ -118,12 +136,12 @@ export const CatalogItemSchema = z export const CatalogItemsQuerySchema = z .object({ - page: z.coerce.number().int().positive().optional().default(1).openapi({ - example: 1, + page: positiveIntegerQueryParam('1').openapi({ + example: '1', description: 'Page number for pagination', }), - limit: z.coerce.number().int().min(1).max(100).optional().default(20).openapi({ - example: 20, + limit: boundedIntegerQueryParam('20', 100).openapi({ + example: '20', description: 'Number of items per page', }), q: z.string().optional().openapi({ diff --git a/packages/api/src/schemas/guides.ts b/packages/api/src/schemas/guides.ts index 34160f1296..a1caf548a5 100644 --- a/packages/api/src/schemas/guides.ts +++ b/packages/api/src/schemas/guides.ts @@ -1,5 +1,14 @@ import { z } from '@hono/zod-openapi'; +const positiveIntegerQueryParam = (defaultValue: string) => + z + .string() + .regex(/^\d+$/) + .optional() + .default(defaultValue) + .transform((value) => Number(value)) + .pipe(z.number().int().positive()); + export const ErrorResponseSchema = z .object({ error: z.string().openapi({ @@ -74,12 +83,12 @@ export const GuideDetailSchema = GuideSchema.extend({ export const GuidesQuerySchema = z .object({ - page: z.coerce.number().int().positive().optional().default(1).openapi({ - example: 1, + page: positiveIntegerQueryParam('1').openapi({ + example: '1', description: 'Page number for pagination', }), - limit: z.coerce.number().int().positive().optional().default(20).openapi({ - example: 20, + limit: positiveIntegerQueryParam('20').openapi({ + example: '20', description: 'Number of guides per page', }), category: z.string().optional().openapi({ diff --git a/packages/api/src/services/weatherService.ts b/packages/api/src/services/weatherService.ts index cb9f1db7da..8ff3db836d 100644 --- a/packages/api/src/services/weatherService.ts +++ b/packages/api/src/services/weatherService.ts @@ -10,6 +10,19 @@ type WeatherData = { windSpeed: number; }; +type OpenWeatherResponse = { + main: { + temp: number; + humidity: number; + }; + weather: Array<{ + main: string; + }>; + wind: { + speed: number; + }; +}; + export class WeatherService { private env: Env; @@ -28,12 +41,12 @@ export class WeatherService { throw new Error('Weather API request failed'); } - const data = await response.json(); + const data: OpenWeatherResponse = await response.json(); return { location, temperature: Math.round(data.main.temp), - conditions: data.weather[0].main, + conditions: data.weather[0]?.main ?? 'Unknown', humidity: data.main.humidity, windSpeed: Math.round(data.wind.speed), }; diff --git a/tsconfig.json b/tsconfig.json index d6da498254..dcb5c2d2d6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,10 @@ "landing-app/*": ["./apps/landing/*"], "expo-app/*": ["./apps/expo/*"], "app/*": ["./packages/app/*"], + "@packrat/api": ["./packages/api/src/index.ts"], "@packrat/api/*": ["./packages/api/src/*"], + "@packrat/api-client": ["./packages/api-client/src/index.ts"], + "@packrat/api-client/*": ["./packages/api-client/src/*"], "@packrat/guards": ["./packages/guards/src"], "@packrat/guards/*": ["./packages/guards/src/*"], "@packrat/ui/*": ["./packages/ui/*"], From 44d4dc6baeb3aee781ffef3887b64b5b7a1fd4b7 Mon Sep 17 00:00:00 2001 From: Andrew Bierman <94939237+andrew-bierman@users.noreply.github.com> Date: Thu, 16 Apr 2026 00:51:33 -0600 Subject: [PATCH 02/11] :recycle: use openapiRoutes for RPC slices --- .../catalog/getCatalogItemsCategoriesRoute.ts | 24 +++++---- packages/api/src/routes/catalog/index.ts | 51 ++++++++++++++----- packages/api/src/routes/guides/index.ts | 29 ++++++++--- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts b/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts index 066f6de6c0..aa5aad45b6 100644 --- a/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts +++ b/packages/api/src/routes/catalog/getCatalogItemsCategoriesRoute.ts @@ -1,13 +1,15 @@ -import { createRoute, OpenAPIHono, z } from '@hono/zod-openapi'; +import { createRoute, z } from '@hono/zod-openapi'; import { CatalogCategoriesResponseSchema } from '@packrat/api/schemas/catalog'; import { CatalogService } from '@packrat/api/services'; -import type { Env } from '@packrat/api/types/env'; -import type { Variables } from '@packrat/api/types/variables'; +import type { RouteHandler } from '@packrat/api/types/routeHandler'; -export const getCatalogItemsCategoriesRoute = new OpenAPIHono<{ - Bindings: Env; - Variables: Variables; -}>(); +const categoryLimitQueryParam = z + .string() + .regex(/^\d+$/) + .optional() + .default('10') + .transform((value) => Number(value)) + .pipe(z.number().int().positive()); export const routeDefinition = createRoute({ method: 'get', @@ -18,8 +20,8 @@ export const routeDefinition = createRoute({ security: [{ bearerAuth: [] }], request: { query: z.object({ - limit: z.coerce.number().int().positive().optional().default(10).openapi({ - example: 10, + limit: categoryLimitQueryParam.openapi({ + example: '10', description: 'Maximum number of categories to return', }), }), @@ -36,9 +38,9 @@ export const routeDefinition = createRoute({ }, }); -getCatalogItemsCategoriesRoute.openapi(routeDefinition, async (c) => { +export const handler: RouteHandler = async (c) => { const { limit } = c.req.valid('query'); const categories = await new CatalogService(c).getCategories(limit); return c.json(categories, 200); -}); +}; diff --git a/packages/api/src/routes/catalog/index.ts b/packages/api/src/routes/catalog/index.ts index b31703df7c..77c7499166 100644 --- a/packages/api/src/routes/catalog/index.ts +++ b/packages/api/src/routes/catalog/index.ts @@ -1,29 +1,54 @@ -import { OpenAPIHono } from '@hono/zod-openapi'; +import { defineOpenAPIRoute, OpenAPIHono } from '@hono/zod-openapi'; import type { Env } from '@packrat/api/types/env'; import type { Variables } from '@packrat/api/types/variables'; import { backfillEmbeddingsRoute } from './backfillEmbeddingsRoute'; import * as createCatalogItemRoute from './createCatalogItemRoute'; import * as deleteCatalogItemRoute from './deleteCatalogItemRoute'; import * as getCatalogItemRoute from './getCatalogItemRoute'; -import { getCatalogItemsCategoriesRoute } from './getCatalogItemsCategoriesRoute'; +import * as getCatalogItemsCategoriesRoute from './getCatalogItemsCategoriesRoute'; import * as getCatalogItemsRoute from './getCatalogItemsRoute'; import * as getSimilarCatalogItemsRoute from './getSimilarCatalogItemsRoute'; import { queueCatalogEtlRoute } from './queueCatalogEtlRoute'; import * as updateCatalogItemRoute from './updateCatalogItemRoute'; import * as vectorSearchRoute from './vectorSearchRoute'; +const catalogOpenApiRoutes = [ + defineOpenAPIRoute({ + route: getCatalogItemsRoute.routeDefinition, + handler: getCatalogItemsRoute.handler, + }), + defineOpenAPIRoute({ + route: vectorSearchRoute.routeDefinition, + handler: vectorSearchRoute.handler, + }), + defineOpenAPIRoute({ + route: createCatalogItemRoute.routeDefinition, + handler: createCatalogItemRoute.handler, + }), + defineOpenAPIRoute({ + route: getCatalogItemsCategoriesRoute.routeDefinition, + handler: getCatalogItemsCategoriesRoute.handler, + }), + defineOpenAPIRoute({ + route: getCatalogItemRoute.routeDefinition, + handler: getCatalogItemRoute.handler, + }), + defineOpenAPIRoute({ + route: getSimilarCatalogItemsRoute.routeDefinition, + handler: getSimilarCatalogItemsRoute.handler, + }), + defineOpenAPIRoute({ + route: deleteCatalogItemRoute.routeDefinition, + handler: deleteCatalogItemRoute.handler, + }), + defineOpenAPIRoute({ + route: updateCatalogItemRoute.routeDefinition, + handler: updateCatalogItemRoute.handler, + }), +] as const; + const catalogRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>() - .openapi(getCatalogItemsRoute.routeDefinition, getCatalogItemsRoute.handler) - .openapi(vectorSearchRoute.routeDefinition, vectorSearchRoute.handler) - .openapi(createCatalogItemRoute.routeDefinition, createCatalogItemRoute.handler) - .route('/', getCatalogItemsCategoriesRoute) - .openapi(getCatalogItemRoute.routeDefinition, getCatalogItemRoute.handler) - .openapi( - getSimilarCatalogItemsRoute.routeDefinition, - getSimilarCatalogItemsRoute.handler, - ) - .openapi(deleteCatalogItemRoute.routeDefinition, deleteCatalogItemRoute.handler) - .openapi(updateCatalogItemRoute.routeDefinition, updateCatalogItemRoute.handler) + .openapiRoutes(catalogOpenApiRoutes) .route('/', queueCatalogEtlRoute) .route('/', backfillEmbeddingsRoute); diff --git a/packages/api/src/routes/guides/index.ts b/packages/api/src/routes/guides/index.ts index 7c9f5340ff..e2be713f4f 100644 --- a/packages/api/src/routes/guides/index.ts +++ b/packages/api/src/routes/guides/index.ts @@ -1,4 +1,4 @@ -import { OpenAPIHono } from '@hono/zod-openapi'; +import { defineOpenAPIRoute, OpenAPIHono } from '@hono/zod-openapi'; import type { Env } from '@packrat/api/types/env'; import type { Variables } from '@packrat/api/types/variables'; import * as getCategoriesRoute from './getCategoriesRoute'; @@ -6,10 +6,27 @@ import * as getGuideRoute from './getGuideRoute'; import * as getGuidesRoute from './getGuidesRoute'; import * as searchGuidesRoute from './searchGuidesRoute'; -const guidesRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>() - .openapi(getGuidesRoute.routeDefinition, getGuidesRoute.handler) - .openapi(getCategoriesRoute.routeDefinition, getCategoriesRoute.handler) - .openapi(searchGuidesRoute.routeDefinition, searchGuidesRoute.handler) - .openapi(getGuideRoute.routeDefinition, getGuideRoute.handler); +const guidesOpenApiRoutes = [ + defineOpenAPIRoute({ + route: getGuidesRoute.routeDefinition, + handler: getGuidesRoute.handler, + }), + defineOpenAPIRoute({ + route: getCategoriesRoute.routeDefinition, + handler: getCategoriesRoute.handler, + }), + defineOpenAPIRoute({ + route: searchGuidesRoute.routeDefinition, + handler: searchGuidesRoute.handler, + }), + defineOpenAPIRoute({ + route: getGuideRoute.routeDefinition, + handler: getGuideRoute.handler, + }), +] as const; + +const guidesRoutes = new OpenAPIHono<{ Bindings: Env; Variables: Variables }>().openapiRoutes( + guidesOpenApiRoutes, +); export { guidesRoutes }; From d7ac1ad3ba9aa2839a7d9296fb510a98083583d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Apr 2026 16:27:26 +0000 Subject: [PATCH 03/11] fix: make CI pass without hacks for RPC type safety branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/expo/lib/api/rpcTransport.ts: refactor processQueue to take options object so Biome's useMaxParams rule passes. - biome-driven import sorting across rpc foundation files (api-client src/index.ts, client.ts, types.ts, tests). - Adopt development's fixes for files that broke tsc on our older base: * packages/web-ui/src/components/calendar.tsx: react-day-picker v9 Chevron API (replaces IconLeft/IconRight). * packages/web-ui/src/components/resizable.tsx: react-resizable-panels v4 rename (PanelGroup→Group, PanelResizeHandle→Separator). * apps/guides/components/ui/chart.tsx: remove unused recharts-v3-broken shadcn chart (matches dev, which moved it to packages/web-ui). --- apps/expo/lib/api/rpcTransport.ts | 19 +- apps/expo/test/rpc-client-proof.test.ts | 2 +- apps/guides/components/ui/chart.tsx | 331 ------------------- packages/api-client/src/client.ts | 2 +- packages/api-client/src/index.ts | 4 +- packages/api-client/src/types.ts | 6 +- packages/api-client/test/rpc-types.test.ts | 2 +- packages/web-ui/src/components/calendar.tsx | 14 +- packages/web-ui/src/components/resizable.tsx | 11 +- 9 files changed, 32 insertions(+), 359 deletions(-) delete mode 100644 apps/guides/components/ui/chart.tsx diff --git a/apps/expo/lib/api/rpcTransport.ts b/apps/expo/lib/api/rpcTransport.ts index 4c029e4a4b..4e254e7138 100644 --- a/apps/expo/lib/api/rpcTransport.ts +++ b/apps/expo/lib/api/rpcTransport.ts @@ -32,7 +32,10 @@ const cloneInit = (init?: RequestInit): RequestInit | undefined => { }; }; -const withAuthHeaders = async (init?: RequestInit, tokenOverride?: string | null): Promise => { +const withAuthHeaders = async ( + init?: RequestInit, + tokenOverride?: string | null, +): Promise => { const headers = new Headers(init?.headers); if (!headers.has('Accept')) { @@ -51,8 +54,7 @@ const withAuthHeaders = async (init?: RequestInit, tokenOverride?: string | null }; const processQueue = async ( - error: Error | null, - token: string | null, + { error, token }: { error: Error | null; token: string | null }, fetchImpl: FetchLike, ) => { const pending = failedQueue; @@ -101,12 +103,7 @@ const refreshAccessToken = async (fetchImpl: FetchLike, baseUrl: string) => { return data.accessToken; }; -export const createRpcFetch = ( - options: { - baseUrl?: string; - fetchImpl?: FetchLike; - } = {}, -) => { +export const createRpcFetch = (options: { baseUrl?: string; fetchImpl?: FetchLike } = {}) => { const baseUrl = options.baseUrl ?? defaultBaseUrl; const fetchImpl = options.fetchImpl ?? fetch; @@ -139,7 +136,7 @@ export const createRpcFetch = ( try { const nextToken = await refreshAccessToken(fetchImpl, baseUrl); - await processQueue(null, nextToken, fetchImpl); + await processQueue({ error: null, token: nextToken }, fetchImpl); const retryHeaders = new Headers(init?.headers); retryHeaders.set('x-packrat-rpc-retry', 'true'); @@ -156,7 +153,7 @@ export const createRpcFetch = ( ); } catch (error) { await store.set(needsReauthAtom, true); - await processQueue(error as Error, null, fetchImpl); + await processQueue({ error: error as Error, token: null }, fetchImpl); throw error; } finally { isRefreshing = false; diff --git a/apps/expo/test/rpc-client-proof.test.ts b/apps/expo/test/rpc-client-proof.test.ts index e0866209c3..822bb433a4 100644 --- a/apps/expo/test/rpc-client-proof.test.ts +++ b/apps/expo/test/rpc-client-proof.test.ts @@ -1,5 +1,5 @@ +import { type ApiRequestOf, type ApiResponseOf, createApiClient } from '@packrat/api-client'; import { expectTypeOf, test } from 'vitest'; -import { createApiClient, type ApiRequestOf, type ApiResponseOf } from '@packrat/api-client'; const client = createApiClient('https://packrat.test'); const guideGetById = client.api.guides[':id'].$get; diff --git a/apps/guides/components/ui/chart.tsx b/apps/guides/components/ui/chart.tsx deleted file mode 100644 index 4214be0d11..0000000000 --- a/apps/guides/components/ui/chart.tsx +++ /dev/null @@ -1,331 +0,0 @@ -'use client'; - -import { cn } from '@packrat/web-ui/lib/utils'; -import { assertDefined } from 'guides-app/lib/assertDefined'; -import * as React from 'react'; -import * as RechartsPrimitive from 'recharts'; - -// Format: { THEME_NAME: CSS_SELECTOR } -const THEMES = { light: '', dark: '.dark' } as const; - -export type ChartConfig = { - [k in string]: { - label?: React.ReactNode; - icon?: React.ComponentType; - } & ( - | { color?: string; theme?: never } - | { color?: never; theme: Record } - ); -}; - -type ChartContextProps = { - config: ChartConfig; -}; - -const ChartContext = React.createContext(null); - -function useChart() { - const context = React.useContext(ChartContext); - - if (!context) { - throw new Error('useChart must be used within a '); - } - - return context; -} - -const ChartContainer = React.forwardRef< - HTMLDivElement, - React.ComponentProps<'div'> & { - config: ChartConfig; - children: React.ComponentProps['children']; - } ->(({ id, className, children, config, ...props }, ref) => { - const uniqueId = React.useId(); - const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; - - return ( - -
- - {children} -
-
- ); -}); -ChartContainer.displayName = 'Chart'; - -const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { - const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color); - - if (!colorConfig.length) { - return null; - } - - return ( -