-
Notifications
You must be signed in to change notification settings - Fork 38
✨ add Hono RPC foundation #2268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6d9ed72
44d4dc6
d7ac1ad
6dec866
6b3c46c
e64480f
1c6a86b
708cde7
7256829
647623f
557369c
b3cf057
fae9b16
3665ea7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| import { createApiClient } from '@packrat/api-client'; | ||
| import { clientEnvs } from '@packrat/env/expo-client'; | ||
| import { createRpcFetch } from './rpcTransport'; | ||
|
|
||
| export const rpcClient = createApiClient(clientEnvs.EXPO_PUBLIC_API_URL, { | ||
| fetch: createRpcFetch(), | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { clientEnvs } from '@packrat/env/expo-client'; | ||
| import { store } from 'expo-app/atoms/store'; | ||
| 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<Response>) => 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<RequestInit> => { | ||
| 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, token }: { 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 retryHeaders = new Headers(request.init?.headers); | ||
| retryHeaders.set('x-packrat-rpc-retry', 'true'); | ||
| const retryInit = await withAuthHeaders({ ...request.init, headers: retryHeaders }, 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<FetchLike>[0], init?: Parameters<FetchLike>[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<Response>((resolve, reject) => { | ||
| failedQueue.push({ | ||
| input, | ||
| init: cloneInit(init), | ||
| resolve, | ||
| reject, | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| isRefreshing = true; | ||
|
|
||
| try { | ||
| const nextToken = await refreshAccessToken(fetchImpl, baseUrl); | ||
| await processQueue({ error: null, token: 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: error as Error, token: null }, fetchImpl); | ||
| throw error; | ||
| } finally { | ||
| isRefreshing = false; | ||
|
Comment on lines
+126
to
+161
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Release the refresh lock before retrying the original request. Line 139 drains the queue while 🐛 Proposed fix- try {
- const nextToken = await refreshAccessToken(fetchImpl, baseUrl);
- await processQueue({ error: null, token: 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) {
+ let nextToken: string;
+ try {
+ nextToken = await refreshAccessToken(fetchImpl, baseUrl);
+ } catch (error) {
+ isRefreshing = false;
await store.set(needsReauthAtom, true);
await processQueue({ error: error as Error, token: null }, fetchImpl);
throw error;
- } finally {
- isRefreshing = false;
}
+
+ isRefreshing = false;
+ await processQueue({ error: null, token: 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,
+ ),
+ );🤖 Prompt for AI Agents |
||
| } | ||
| }, | ||
| fetchImpl, | ||
| ) satisfies FetchLike; | ||
|
|
||
| return rpcFetch; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import { type ApiRequestOf, type ApiResponseOf, createApiClient } from '@packrat/api-client'; | ||
| import { expectTypeOf, test } from 'vitest'; | ||
|
|
||
| const client = createApiClient('https://packrat.test'); | ||
| const guideGetById = client.api.guides[':id'].$get; | ||
| const catalogGetById = client.api.catalog[':id'].$get; | ||
| const tripGetById = client.api.trips[':tripId'].$get; | ||
| type GuideGetById = typeof guideGetById; | ||
| type CatalogGetById = typeof catalogGetById; | ||
| type TripGetById = typeof tripGetById; | ||
|
|
||
| test('Expo can consume typed guide and catalog RPC requests', () => { | ||
| const guideRequest: ApiRequestOf<GuideGetById> = { | ||
| param: { | ||
| id: 'ultralight-backpacking', | ||
| }, | ||
| }; | ||
|
|
||
| const catalogQuery: ApiRequestOf<typeof client.api.catalog.$get> = { | ||
| query: { | ||
| q: 'sleeping bag', | ||
| page: '1', | ||
| limit: '10', | ||
| }, | ||
| }; | ||
|
|
||
| expectTypeOf(guideRequest.param.id).toEqualTypeOf<string>(); | ||
| expectTypeOf(catalogQuery.query?.page).toEqualTypeOf<string | undefined>(); | ||
| }); | ||
|
|
||
| test('Expo can narrow typed RPC responses by route and status', () => { | ||
| type GuideResponse = ApiResponseOf<GuideGetById>; | ||
| type MissingCatalogItem = ApiResponseOf<CatalogGetById, 404>; | ||
|
|
||
| expectTypeOf<GuideResponse>().toMatchTypeOf<{ | ||
| id: string; | ||
| title: string; | ||
| content: string; | ||
| }>(); | ||
|
|
||
| expectTypeOf<MissingCatalogItem>().toMatchTypeOf<{ | ||
| error: string; | ||
| }>(); | ||
| }); | ||
|
|
||
| test('Expo can consume typed trips RPC requests', () => { | ||
| const createRequest: ApiRequestOf<typeof client.api.trips.$post> = { | ||
| json: { | ||
| id: 't_123', | ||
| name: 'Pacific Crest Trail', | ||
| localCreatedAt: new Date().toISOString(), | ||
| localUpdatedAt: new Date().toISOString(), | ||
| }, | ||
| }; | ||
|
|
||
| const byIdRequest: ApiRequestOf<TripGetById> = { | ||
| param: { tripId: 't_123' }, | ||
| }; | ||
|
|
||
| expectTypeOf(createRequest.json.name).toEqualTypeOf<string>(); | ||
| expectTypeOf(byIdRequest.param.tripId).toEqualTypeOf<string>(); | ||
| }); | ||
|
|
||
| test('Expo can narrow typed trips RPC responses by status', () => { | ||
| type TripResponse = ApiResponseOf<TripGetById>; | ||
| type MissingTrip = ApiResponseOf<TripGetById, 404>; | ||
|
|
||
| expectTypeOf<TripResponse>().toMatchTypeOf<{ | ||
| id: string; | ||
| name: string; | ||
| userId: number; | ||
| }>(); | ||
|
|
||
| expectTypeOf<MissingTrip>().toMatchTypeOf<{ | ||
| error: string; | ||
| }>(); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,16 @@ | ||
| { | ||
| "extends": "expo/tsconfig.base", | ||
| "extends": "../../tsconfig.json", | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does base work better ?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Extending Generated by Claude Code |
||
| "compilerOptions": { | ||
| "strict": true, | ||
| "jsx": "react-jsx", | ||
| "customConditions": ["react-native"], | ||
| "baseUrl": ".", | ||
| "paths": { | ||
| "expo-app/*": ["./*"] | ||
| "expo-app/*": ["./*"], | ||
| "@packrat/api": ["../../packages/api/src/index.ts"], | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be in base |
||
| "@packrat/api/*": ["../../packages/api/src/*"], | ||
| "@packrat/api-client": ["../../packages/api-client/src/index.ts"], | ||
| "@packrat/api-client/*": ["../../packages/api-client/src/*"] | ||
| } | ||
|
Comment on lines
8
to
14
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
rg -nP "from ['\"](`@packrat/`(guards|ui|analytics|web-ui)|nativewindui)" apps/expo -g '!node_modules' | head -50Repository: PackRat-AI/PackRat Length of output: 5993 🏁 Script executed: cat -n /root/tsconfig.json | head -100Repository: PackRat-AI/PackRat Length of output: 113 🏁 Script executed: cat -n apps/expo/tsconfig.jsonRepository: PackRat-AI/PackRat Length of output: 746 🏁 Script executed: # Check if there are any other tsconfig files in apps that might be relevant
fd tsconfig.json apps/ --type fRepository: PackRat-AI/PackRat Length of output: 163 🏁 Script executed: git ls-files | grep "^tsconfig.json$"Repository: PackRat-AI/PackRat Length of output: 75 🏁 Script executed: cat -n tsconfig.jsonRepository: PackRat-AI/PackRat Length of output: 1818 🏁 Script executed: cd apps/expo && cat -n package.json | grep -A 5 -B 5 check-typesRepository: PackRat-AI/PackRat Length of output: 873 🏁 Script executed: # Also check the root package.json check-types script
cat -n package.json | grep -A 5 -B 5 check-typesRepository: PackRat-AI/PackRat Length of output: 755 🏁 Script executed: # Verify the learning context - let's see if these imports should use the path aliases per the learnings
# Check one file to see what's currently imported
cat apps/expo/features/wildlife/screens/SpeciesDetailScreen.tsxRepository: PackRat-AI/PackRat Length of output: 6974 Child When This breaks Re-declare the full alias set here (relative to ♻️ Required additions "paths": {
"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/*"]
+ "@packrat/api-client/*": ["../../packages/api-client/src/*"],
+ "@packrat/guards": ["../../packages/guards/src"],
+ "@packrat/guards/*": ["../../packages/guards/src/*"],
+ "@packrat/ui/*": ["../../packages/ui/*"],
+ "@packrat/analytics": ["../../packages/analytics/src"],
+ "@packrat/analytics/*": ["../../packages/analytics/src/*"],
+ "nativewindui/*": ["./components/ui/*"]
}🤖 Prompt for AI Agents |
||
| }, | ||
| "include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"], | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { resolve } from 'node:path'; | ||
| import { defineConfig } from 'vitest/config'; | ||
|
|
||
| export default defineConfig({ | ||
| resolve: { | ||
| alias: { | ||
| 'expo-app': resolve(__dirname, '.'), | ||
| '@packrat/api': resolve(__dirname, '../../packages/api/src/index.ts'), | ||
| '@packrat/api-client': resolve(__dirname, '../../packages/api-client/src/index.ts'), | ||
| }, | ||
| }, | ||
| test: { | ||
| name: 'expo-rpc-types', | ||
| environment: 'node', | ||
| typecheck: { | ||
| enabled: true, | ||
| }, | ||
| include: [resolve(__dirname, 'test/**/*.test.ts')], | ||
| }, | ||
| }); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Queued 401 retries executed via
processQueue()don't set thex-packrat-rpc-retrymarker header. If a queued request is retried after a refresh and still gets a 401 (e.g., refresh succeeded but the endpoint still rejects), it can trigger another refresh cycle and potentially loop. Consider setting the retry marker on queued retries as well (and/or limiting refresh attempts per request).