Skip to content
Closed
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
8 changes: 8 additions & 0 deletions src/doctor/checks/framework.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,14 @@ describe('checkFramework - new frameworks', () => {
expect(result.name).toBe('SvelteKit');
});

it('reports the vite.config port for a modern TanStack Start app (detectPort fan-out)', async () => {
await writeFile(join(dir, 'package.json'), makePackageJson({ '@tanstack/react-start': '^1.0.0' }));
await writeFile(join(dir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n');
const result = await checkFramework({ installDir: dir });
expect(result.name).toBe('TanStack Start');
expect(result.detectedPort).toBe(8080);
});

it('returns null for no frameworks', async () => {
await writeFile(join(dir, 'package.json'), makePackageJson({}));
const result = await checkFramework({ installDir: dir });
Expand Down
73 changes: 73 additions & 0 deletions src/lib/port-detection.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,3 +155,76 @@ describe('port-detection — ruby/rails puma', () => {
expect(detectPort('ruby', dir)).toBe(4000);
});
});

describe('port-detection — tanstack-start config', () => {
let dir: string;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'port-tanstack-'));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it('parses port from vite.config.ts (modern @tanstack/react-start)', async () => {
await writeFile(
join(dir, 'vite.config.ts'),
"import { defineConfig } from 'vite'\nexport default defineConfig({ server: { host: '::', port: 8080 }, plugins: [] })\n",
);
expect(detectPort('tanstack-start', dir)).toBe(8080);
});

it('falls back to legacy app.config.ts (Vinxi) when no vite config', async () => {
await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n');
expect(detectPort('tanstack-start', dir)).toBe(4200);
});

it('prefers vite.config.ts over app.config.ts when both present', async () => {
await writeFile(
join(dir, 'vite.config.ts'),
"import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 8080 } })\n",
);
await writeFile(join(dir, 'app.config.ts'), 'export default { server: { port: 4200 } }\n');
expect(detectPort('tanstack-start', dir)).toBe(8080);
});

it.each(['vite.config.js', 'vite.config.mjs'])('parses port from %s', async (fileName) => {
await writeFile(join(dir, fileName), 'export default { server: { port: 5555 } }\n');
expect(detectPort('tanstack-start', dir)).toBe(5555);
});

it('falls back to default 3000 when no config file present', () => {
expect(detectPort('tanstack-start', dir)).toBe(3000);
});
});

describe('port-detection — vite frameworks', () => {
let dir: string;

beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'port-vite-'));
});

afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});

it.each(['react', 'react-router', 'vanilla-js'] as const)(
'%s parses port from vite.config.ts',
async (framework) => {
await writeFile(
join(dir, 'vite.config.ts'),
"import { defineConfig } from 'vite'\nexport default defineConfig({ server: { port: 4200 } })\n",
);
expect(detectPort(framework, dir)).toBe(4200);
},
);

it.each(['react', 'react-router', 'vanilla-js'] as const)(
'%s falls back to default 5173 when no vite config present',
(framework) => {
expect(detectPort(framework, dir)).toBe(5173);
},
);
});
31 changes: 19 additions & 12 deletions src/lib/port-detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ function parseViteConfigPort(configPath: string): number | null {
return null;
}

/**
* Try vite.config.{ts,js,mjs} in installDir for a server.port value.
* NOTE: parseViteConfigPort uses a greedy first-match /port\s*:\s*.../ regex,
* so an unrelated `port:` (e.g. hmr.port) appearing before server.port wins.
* Pre-existing behavior for all Vite frameworks — not a regression here.
*/
function parseViteConfigPortFromDir(installDir: string): number | null {
for (const p of ['vite.config.ts', 'vite.config.js', 'vite.config.mjs']) {
const port = parseViteConfigPort(join(installDir, p));
if (port) return port;
}
return null;
}

/**
* Parse port from Next.js package.json scripts.
* Next.js uses: "dev": "next dev -p 4000" or --port 4000
Expand Down Expand Up @@ -206,24 +220,17 @@ export function detectPort(integration: Integration, installDir: string): number
break;

case 'tanstack-start':
detectedPort = parseTanStackPort(installDir);
// Modern @tanstack/react-start is Vite-based (port in vite.config.ts);
// legacy Vinxi projects use app.config.ts. Try Vite first.
detectedPort = parseViteConfigPortFromDir(installDir) ?? parseTanStackPort(installDir);
break;

case 'react':
case 'react-router':
case 'vanilla-js': {
case 'vanilla-js':
// Vite-based frameworks
const viteConfigs = [
join(installDir, 'vite.config.ts'),
join(installDir, 'vite.config.js'),
join(installDir, 'vite.config.mjs'),
];
for (const configPath of viteConfigs) {
detectedPort = parseViteConfigPort(configPath);
if (detectedPort) break;
}
detectedPort = parseViteConfigPortFromDir(installDir);
break;
}

case 'dotnet':
detectedPort = parseDotnetPort(installDir);
Expand Down
174 changes: 172 additions & 2 deletions src/lib/validation/validator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ describe('validateInstallation', () => {
// Redirect URI says /auth/callback but route is at /callback
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
// Route exists at DIFFERENT path (dot notation)
mkdirSync(join(testDir, 'app', 'routes'), { recursive: true });
Expand All @@ -390,7 +390,7 @@ describe('validateInstallation', () => {
writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } }));
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
'WORKOS_API_KEY=sk_test\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:5173/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
// Route at correct path using dot notation: auth.callback.tsx
mkdirSync(join(testDir, 'app', 'routes'), { recursive: true });
Expand Down Expand Up @@ -819,4 +819,174 @@ describe('validateInstallation', () => {
expect(envIssue?.hint).toContain('VITE_WORKOS_CLIENT_ID');
});
});

describe('redirect URI port validation', () => {
const portError = (result: { issues: { severity: string; message: string }[] }) =>
result.issues.find((i) => i.severity === 'error' && /port/i.test(i.message));

it('flags TanStack redirect URI port that mismatches the vite.config port (friction scenario)', async () => {
writeFileSync(
join(testDir, 'package.json'),
JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }),
);
// dev server runs on 8080 (Vite), but the redirect URI points at :3000
writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n');
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true });
writeFileSync(
join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'),
'export default function Callback() {}',
);

const result = await validateInstallation('tanstack-start', testDir, { runBuild: false });

expect(result.passed).toBe(false);
expect(portError(result)).toBeDefined();
});

it('does not flag a port issue when TanStack redirect URI port matches the vite.config port', async () => {
writeFileSync(
join(testDir, 'package.json'),
JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }),
);
writeFileSync(join(testDir, 'vite.config.ts'), 'export default { server: { host: "::", port: 8080 } }\n');
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true });
writeFileSync(
join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'),
'export default function Callback() {}',
);

const result = await validateInstallation('tanstack-start', testDir, { runBuild: false });

expect(portError(result)).toBeUndefined();
const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file'));
expect(mismatchIssue).toBeUndefined();
});

it('flags React Router redirect URI port against the default 5173 (helper in isolation)', async () => {
writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } }));
// No vite.config → detected port is the RR default 5173; env points at :3000
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'routes'), { recursive: true });
writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}');

const result = await validateInstallation('react-router', testDir, { runBuild: false });

expect(result.passed).toBe(false);
expect(portError(result)).toBeDefined();
});

it('flags Next.js redirect URI port against a -p dev-script port', async () => {
writeFileSync(
join(testDir, 'package.json'),
JSON.stringify({
dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' },
scripts: { dev: 'next dev -p 4000' },
}),
);
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:3000/api/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true });
writeFileSync(
join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'),
"import { handleAuth } from '@workos-inc/authkit-nextjs';",
);
writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};');
writeFileSync(join(testDir, 'app', 'layout.tsx'), '<AuthKitProvider>');

const result = await validateInstallation('nextjs', testDir, { runBuild: false });

expect(portError(result)).toBeDefined();
});

it('skips the port check for non-local (production) hosts', async () => {
writeFileSync(
join(testDir, 'package.json'),
JSON.stringify({ dependencies: { '@tanstack/react-start': '^1.0.0' } }),
);
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=https://app.example.com/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'routes', 'auth', 'callback'), { recursive: true });
writeFileSync(
join(testDir, 'app', 'routes', 'auth', 'callback', 'index.tsx'),
'export default function Callback() {}',
);

const result = await validateInstallation('tanstack-start', testDir, { runBuild: false });

expect(portError(result)).toBeUndefined();
});

it('normalizes a missing port to the protocol default (80) before comparing', async () => {
writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } }));
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://localhost/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'routes'), { recursive: true });
writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}');

const result = await validateInstallation('react-router', testDir, { runBuild: false });

expect(portError(result)).toBeDefined();
});

it('recognizes the IPv6 localhost form [::1]', async () => {
writeFileSync(join(testDir, 'package.json'), JSON.stringify({ dependencies: { 'react-router': '^7.0.0' } }));
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nWORKOS_REDIRECT_URI=http://[::1]:3000/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
mkdirSync(join(testDir, 'app', 'routes'), { recursive: true });
writeFileSync(join(testDir, 'app', 'routes', 'callback.tsx'), 'export default function Callback() {}');

const result = await validateInstallation('react-router', testDir, { runBuild: false });

expect(portError(result)).toBeDefined();
});

it('route-mismatch hint uses the redirect URI real origin, not a hardcoded localhost:3000', async () => {
writeFileSync(
join(testDir, 'package.json'),
JSON.stringify({
dependencies: { '@workos-inc/authkit-nextjs': '^1.0.0' },
scripts: { dev: 'next dev -p 8080' },
}),
);
// detected port (8080) matches the env port so no port-mismatch noise
writeFileSync(
join(testDir, '.env.local'),
'WORKOS_API_KEY=sk_test_1234567890123456789012345\nWORKOS_CLIENT_ID=client_test_id\nNEXT_PUBLIC_WORKOS_REDIRECT_URI=http://localhost:8080/auth/callback\nWORKOS_COOKIE_PASSWORD=supersecretpasswordthatis32chars!\n',
);
// route lives at a DIFFERENT path → triggers the mismatch hint
mkdirSync(join(testDir, 'app', 'api', 'auth', 'callback'), { recursive: true });
writeFileSync(
join(testDir, 'app', 'api', 'auth', 'callback', 'route.ts'),
"import { handleAuth } from '@workos-inc/authkit-nextjs';",
);
writeFileSync(join(testDir, 'middleware.ts'), 'export const authkitMiddleware = () => {};');
writeFileSync(join(testDir, 'app', 'layout.tsx'), '<AuthKitProvider>');

const result = await validateInstallation('nextjs', testDir, { runBuild: false });

const mismatchIssue = result.issues.find((i) => i.message.includes('no matching route file'));
expect(mismatchIssue).toBeDefined();
expect(mismatchIssue?.hint).toContain('http://localhost:8080');
expect(mismatchIssue?.hint).not.toContain('localhost:3000');
});
});
});
Loading
Loading