Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/expo/features/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions apps/expo/lib/api/rpcClient.ts
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(),
});
168 changes: 168 additions & 0 deletions apps/expo/lib/api/rpcTransport.ts
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) {
Comment on lines +56 to +74
Copy link

Copilot AI Apr 21, 2026

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 the x-packrat-rpc-retry marker 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).

Copilot uses AI. Check for mistakes.
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Release the refresh lock before retrying the original request.

Line 139 drains the queue while isRefreshing stays true until line 159. Any 401 that queues during the original retry can hang forever because no later processQueue() runs.

🐛 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
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/lib/api/rpcTransport.ts` around lines 124 - 159, The refresh lock
is held through the retry, so new 401s that enqueue while draining the queue can
deadlock; after successfully obtaining nextToken in the refresh block (after
await refreshAccessToken(...)), clear the lock (set isRefreshing = false) before
calling processQueue and before retrying the original request (the subsequent
fetchImpl/withAuthHeaders call) so queued requests can trigger a new refresh if
needed; update the refresh branch around refreshAccessToken, processQueue, and
the retry logic (symbols: isRefreshing, refreshAccessToken, processQueue,
failedQueue, fetchImpl, withAuthHeaders) to release the lock immediately after
obtaining nextToken and before processing the queue/retrying.

}
},
fetchImpl,
) satisfies FetchLike;

return rpcFetch;
};
1 change: 1 addition & 0 deletions apps/expo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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/config": "workspace:*",
"@packrat/env": "workspace:*",
"@packrat/guards": "workspace:*",
Expand Down
77 changes: 77 additions & 0 deletions apps/expo/test/rpc-client-proof.test.ts
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;
}>();
});
9 changes: 7 additions & 2 deletions apps/expo/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
{
"extends": "expo/tsconfig.base",
"extends": "../../tsconfig.json",
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does base work better ?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extending ../../tsconfig.json lets us inherit all @packrat/* workspace path aliases without duplicating them. The one thing it was missing from expo/tsconfig.base was customConditions: ["react-native"] — added that in the latest commit.


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"],
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP "from ['\"](`@packrat/`(guards|ui|analytics|web-ui)|nativewindui)" apps/expo -g '!node_modules' | head -50

Repository: PackRat-AI/PackRat

Length of output: 5993


🏁 Script executed:

cat -n /root/tsconfig.json | head -100

Repository: PackRat-AI/PackRat

Length of output: 113


🏁 Script executed:

cat -n apps/expo/tsconfig.json

Repository: 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 f

Repository: 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.json

Repository: PackRat-AI/PackRat

Length of output: 1818


🏁 Script executed:

cd apps/expo && cat -n package.json | grep -A 5 -B 5 check-types

Repository: 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-types

Repository: 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.tsx

Repository: PackRat-AI/PackRat

Length of output: 6974


Child paths completely overrides parent — critical aliases are inaccessible.

When apps/expo/tsconfig.json defines compilerOptions.paths, TypeScript uses those paths exclusively and ignores the parent's aliases. After this change, the Expo app cannot resolve @packrat/guards, @packrat/ui/*, @packrat/analytics, or nativewindui/* — all of which are actively imported throughout the codebase (50+ confirmed uses).

This breaks tsc --noEmit when run from apps/expo/ and causes IDE type resolution failures despite the imports working at runtime.

Re-declare the full alias set here (relative to apps/expo):

♻️ 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
Verify each finding against the current code and only fix it if needed.

In `@apps/expo/tsconfig.json` around lines 7 - 13, The tsconfig
"compilerOptions.paths" in apps/expo currently overrides the parent and omits
several shared aliases, causing TypeScript/IDE resolution failures; update the
"paths" object in apps/expo/tsconfig.json (the "paths" entry shown in the diff)
to include the full set of project aliases from the root (not just `@packrat/api`
and `@packrat/api-client`) — specifically add mappings for `@packrat/guards`,
`@packrat/ui/`*, `@packrat/analytics`, nativewindui/* (and any other aliases defined
in the parent) with paths relative to apps/expo so the Expo project can resolve
those imports during tsc and in editors.

},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts", "nativewind-env.d.ts"],
Expand Down
20 changes: 20 additions & 0 deletions apps/expo/vitest.types.config.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')],
},
});
19 changes: 14 additions & 5 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading