Skip to content

feat: show a clear error when trying to use CAAPI untunnelled in localhost#3504

Open
fredericoo wants to merge 7 commits intomainfrom
fb-better-feedback-for-cap
Open

feat: show a clear error when trying to use CAAPI untunnelled in localhost#3504
fredericoo wants to merge 7 commits intomainfrom
fb-better-feedback-for-cap

Conversation

@fredericoo
Copy link
Contributor

@fredericoo fredericoo commented Feb 25, 2026

WHY are these changes introduced?

Local development has a bad failure mode for Customer Account OAuth:

When a developer opens /account on localhost without tunneling, the app redirects into the OAuth flow and eventually fails with a redirect URI error from Shopify. The error is late and unclear, so it does not tell developers what they need to do next.

WHAT is this pull request doing?

  • throws an error and offers a way out for users: image
  • zero impact in bundle size: because NODE_ENV is static at build time, we can get rid of the code path entirely when bundling with vite

HOW to test your changes?

  1. Install deps and build Hydrogen package:
    • pnpm --dir packages/hydrogen build
  2. Run unit tests:
    • pnpm --dir packages/hydrogen test -- src/customer/customer.test.ts
  3. Start a dev app without customer-account tunneling and visit:
    • http://localhost:3000/account
  4. Verify result:
    • You should not be redirected to Shopify OAuth.
    • You should see a 400 error page with guidance to use --customer-account-push and a *.tryhydrogen.dev URL.
  5. Start dev with tunneling (--customer-account-push) and open the provided *.tryhydrogen.dev URL.
  6. Verify /account continues into normal Customer Account login flow.

Post-merge steps

None.

Checklist

  • I've read the Contributing Guidelines
  • I've considered possible cross-platform impacts (Mac, Linux, Windows)
  • I've added a changeset if this PR contains user-facing or noteworthy changes
  • I've added tests to cover my changes
  • I've added or updated the documentation

@shopify
Copy link
Contributor

shopify bot commented Feb 25, 2026

Oxygen deployed a preview of your fb-better-feedback-for-cap branch. Details:

Storefront Status Preview link Deployment details Last update (UTC)
Skeleton (skeleton.hydrogen.shop) ✅ Successful (Logs) Preview deployment Inspect deployment February 26, 2026 6:51 PM

Learn more about Hydrogen's GitHub integration.

@fredericoo fredericoo marked this pull request as ready for review February 25, 2026 16:00
@fredericoo fredericoo requested a review from a team as a code owner February 25, 2026 16:00
Copy link
Contributor

@kdaviduik kdaviduik left a comment

Choose a reason for hiding this comment

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

massive DX improvement ❤️

Comment on lines 129 to 148
it('Throws guidance error in development when request origin is not a tunnel', async () => {
process.env.NODE_ENV = 'development';

const customer = createCustomerAccountClient({
session,
customerAccountId: 'customerAccountId',
shopId: '1',
request: new Request('https://localhost'),
waitUntil: vi.fn(),
});

try {
await customer.login();
} catch (error) {
const response = error as Response & {message?: string};

expect(response.status).toBe(400);
expect(response.message).toContain('--customer-account-push');
expect(response.message).toContain('.tryhydrogen.dev');
}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should add expect.assertions(3) at the top of this test as a defensive measure. If the code under test were refactored to no longer throw, the catch block would never execute and the test would silently pass with zero assertions.

Suggested change
it('Throws guidance error in development when request origin is not a tunnel', async () => {
process.env.NODE_ENV = 'development';
const customer = createCustomerAccountClient({
session,
customerAccountId: 'customerAccountId',
shopId: '1',
request: new Request('https://localhost'),
waitUntil: vi.fn(),
});
try {
await customer.login();
} catch (error) {
const response = error as Response & {message?: string};
expect(response.status).toBe(400);
expect(response.message).toContain('--customer-account-push');
expect(response.message).toContain('.tryhydrogen.dev');
}
it('Throws guidance error in development when request origin is not a tunnel', async () => {
expect.assertions(3);
process.env.NODE_ENV = 'development';
const customer = createCustomerAccountClient({
session,
customerAccountId: 'customerAccountId',
shopId: '1',
request: new Request('https://localhost'),
waitUntil: vi.fn(),
});
try {
await customer.login();
} catch (error) {
const response = error as Response & {message?: string};
expect(response.status).toBe(400);
expect(response.message).toContain('--customer-account-push');
expect(response.message).toContain('.tryhydrogen.dev');
}
});

Same applies to the handleAuthStatus() test at line 1304. This is a pre-existing pattern throughout the file (~6 other tests use the same try/catch without expect.assertions()), so not unique to this PR - I'm fine with this being a follow-up as long as it gets done :)

Comment on lines 363 to 379
if (process.env.NODE_ENV === 'development') {
if (!requestUrl.hostname.endsWith('.tryhydrogen.dev')) {
throw new Response(
[
'Customer Account API OAuth requires a Hydrogen tunnel in local development.',
'Run `shopify hydrogen dev --customer-account-push`.',
'Then open the tunnel URL shown in your terminal (`https://*.tryhydrogen.dev`) instead of localhost.',
].join('\n\n'),
{
status: 400,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
},
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

(threading) Re: the customAuthStatusHandler asymmetry (lines 137-139) - this check in login() always fires regardless of whether a customAuthStatusHandler is provided, but the check in defaultAuthStatusHandler is bypassed when a custom handler is set.

non-blocking: This asymmetry is actually correct - login() initiates an OAuth redirect that fundamentally requires a publicly reachable URL, so the tunnel check is always relevant. A customAuthStatusHandler might intentionally handle the non-tunnel case differently (e.g., a mock or test double). I think a brief code comment explaining this rationale would help future readers.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

i’ve added this check whenever ifInvalidCredentialThrowError() is placed. this seems to be the most reliable way: nowhere that needs a valid credential will work without it being tunnelled

do you think this is a good assumption?

Copy link
Contributor

Choose a reason for hiding this comment

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

yeah that sounds reasonable to me!

throw new Response(
[
'Customer Account API OAuth requires a Hydrogen tunnel in local development.',
'Run `shopify hydrogen dev --customer-account-push`.',
Copy link
Contributor

Choose a reason for hiding this comment

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

note: these commands can also be aliased as just h2 dev rather than shopify hydrogen dev. i don't think the message needs to be updated but if you can think of a good way to communicate that either one works, go for it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

thats very fair

i made it more generic just mentioning to use the flag. if they got to this screen it means they know how to start the dev server, and however they did, they can just add the flag to the end

Copy link
Contributor

Choose a reason for hiding this comment

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

yep exactly, i think that's super reasonable!

Copy link
Contributor

@kdaviduik kdaviduik left a comment

Choose a reason for hiding this comment

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

i trust you, though i'm not able to 🎩 this - i'm seeing this error. both with mock shop and a shop linked. i will investigate more tomorrow - this is also happening to be on main

Image

@fredericoo
Copy link
Contributor Author

i trust you, though i'm not able to 🎩 this - i'm seeing this error. both with mock shop and a shop linked. i will investigate more tomorrow - this is also happening to be on main

Image

yeah this is a real issue: if you are logged in or have a token you get a 500 instead, working on it!

@fredericoo fredericoo changed the title feat: error when using CAAPI untunelled feat: error when using CAAPI untunnelled Feb 26, 2026
@binks-code-reviewer
Copy link

binks-code-reviewer bot commented Feb 26, 2026

🤖 Code Review · #projects-dev-ai for questions
React with 👍/👎 or reply — all feedback helps improve the agent.

Complete - No issues

📋 History

✅ No issues → ✅ No issues → ✅ No issues

Comment on lines +55 to +56
function throwIfNotTunnelled(hostname: string) {
if (process.env.NODE_ENV === 'development') {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

i’ve opted to place all the logic in this function instead of lots of ifs

when vite bundles this it’ll become a noop function so it is fine, although it adds a few bytes to the bundle we will survive

process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: 'You do not have the valid credential to use Customer Account API (/account).';
: 'You do not have a valid credential to use Customer Account API (/account).';
Copy link
Contributor Author

Choose a reason for hiding this comment

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

this read a little broken english, right?

Copy link
Contributor

Choose a reason for hiding this comment

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

agreed. "...have valid credentials..." reads even better to me. the singular "credential" feels a bit off to me

@fredericoo fredericoo force-pushed the fb-better-feedback-for-cap branch from a681ba2 to dceae08 Compare February 26, 2026 18:49
isLoggedIn: () => Promise<boolean>;
/** Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option. */
handleAuthStatus: () => void | DataFunctionValue;
handleAuthStatus: () => Promise<void>;
Copy link
Contributor Author

@fredericoo fredericoo Feb 26, 2026

Choose a reason for hiding this comment

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

conversation starter:

this type was wrong. this function is async, and it does not return DataFunctionValue (which includes Response). those things are thrown today

I think i should leave this alone in this PR (revert this change) and we fix it later, with a changelog.

this also is impacting whenever we call handleAuthStatus() which is in many loaders within skeletons. they have a race condition: if they get processed in due time, the response (redirect) will be thrown, otherwise not

Copy link
Contributor

Choose a reason for hiding this comment

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

I think i should leave this alone in this PR (revert this change) and we fix it later, with a changelog.

nice catch and that sounds great! i'd definitely vote to fix it. i'm fine either way - whether you want to fix it in this PR or as a follow up. but if this gets fixed in this PR the changeset should definitely be updated to mention it

@fredericoo fredericoo requested a review from kdaviduik February 26, 2026 19:03
await customer.login();
} catch (error) {
const response = error as Response & {message?: string};

Copy link
Contributor

Choose a reason for hiding this comment

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

non-blocking: these tests work correctly because the test file globally stubs Response with a mock class at lines 26-38 that stores the constructor's body argument as .message. This is a pre-existing test pattern, not something this PR introduced.

The tests are correct and pass as intended. However, .message is a non-standard property that only exists due to this mock - the standard Response Web API doesn't have it. If the test environment or mock strategy ever changes, these assertions would silently break. Using await response.text() would be the spec-compliant approach, but since this follows the existing pattern throughout the file, I'm fine with this shipping as-is.


function throwIfNotTunnelled(hostname: string) {
if (process.env.NODE_ENV === 'development') {
if (!hostname.endsWith('.tryhydrogen.dev')) {
Copy link
Contributor

Choose a reason for hiding this comment

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

non-blocking: I think a brief comment here would help future maintainers understand the coupling between this check and the tunnel infrastructure:

Suggested change
if (!hostname.endsWith('.tryhydrogen.dev')) {
// This domain must match the tunnel domain provisioned by --customer-account-push.
// If the tunnel infrastructure changes domains, update this check.
if (!hostname.endsWith('.tryhydrogen.dev')) {

Extracting to a named constant (e.g., TUNNEL_DOMAIN_SUFFIX) would also reduce the magic string, but a comment is sufficient for a single-use constant imo

if (!hostname.endsWith('.tryhydrogen.dev')) {
throw new Response(
[
'Customer Account API OAuth requires a Hydrogen tunnel in local development.',
Copy link
Contributor

Choose a reason for hiding this comment

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

non-blocking, very minor polish: a developer seeing this error has NOT yet started with the --customer-account-push flag. The sequencing might read slightly more naturally as "Restart your development server with the --customer-account-push flag..." since the implicit step is "stop your current server, restart with the flag."

throw new Response(
[
'Customer Account API OAuth requires a Hydrogen tunnel in local development.',
'Run `shopify hydrogen dev --customer-account-push`.',
Copy link
Contributor

Choose a reason for hiding this comment

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

yep exactly, i think that's super reasonable!

Comment on lines 363 to 379
if (process.env.NODE_ENV === 'development') {
if (!requestUrl.hostname.endsWith('.tryhydrogen.dev')) {
throw new Response(
[
'Customer Account API OAuth requires a Hydrogen tunnel in local development.',
'Run `shopify hydrogen dev --customer-account-push`.',
'Then open the tunnel URL shown in your terminal (`https://*.tryhydrogen.dev`) instead of localhost.',
].join('\n\n'),
{
status: 400,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
},
);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

yeah that sounds reasonable to me!

isLoggedIn: () => Promise<boolean>;
/** Check for a not logged in customer and redirect customer to login page. The redirect can be overwritten with `customAuthStatusHandler` option. */
handleAuthStatus: () => void | DataFunctionValue;
handleAuthStatus: () => Promise<void>;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think i should leave this alone in this PR (revert this change) and we fix it later, with a changelog.

nice catch and that sounds great! i'd definitely vote to fix it. i'm fine either way - whether you want to fix it in this PR or as a follow up. but if this gets fixed in this PR the changeset should definitely be updated to mention it

@fredericoo fredericoo changed the title feat: error when using CAAPI untunnelled feat: show a clear error when trying to use CAAPI untunnelled in localhost Feb 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants