From 148f97933a0b269f10493d92bfb18fe11e4ac3a3 Mon Sep 17 00:00:00 2001 From: natew Date: Wed, 27 Aug 2025 11:26:27 -1000 Subject: [PATCH] feat: middlewares can return new Response, and web.rewrites option for rewriting paths --- .claude/settings.local.json | 3 +- apps/onestack.dev/REWRITE_TESTING.md | 149 +++++++ apps/onestack.dev/app/_middleware.tsx | 51 +++ .../onestack.dev/app/middleware-health+api.ts | 7 + .../app/subdomain/[name]/about.tsx | 26 ++ .../app/subdomain/[name]/index.tsx | 48 +++ .../app/subdomain/myapp/about+spa.tsx | 8 + .../app/subdomain/myapp/index+spa.tsx | 9 + .../app/subdomain/testapp/about+spa.tsx | 8 + .../app/subdomain/testapp/index+spa.tsx | 9 + .../app/test-middleware-response+api.ts | 7 + apps/onestack.dev/app/test-rewrites+spa.tsx | 242 ++++++++++++ apps/onestack.dev/data/docs/middleware.mdx | 214 ++++++++++ apps/onestack.dev/data/docs/url-rewriting.mdx | 366 ++++++++++++++++++ apps/onestack.dev/routes.d.ts | 6 +- .../tests/rewrite-integration.test.tsx | 365 +++++++++++++++++ apps/onestack.dev/vite.config.ts | 9 + packages/one/src/createHandleRequest.ts | 55 +-- packages/one/src/createMiddleware.ts | 2 +- packages/one/src/link/href.ts | 29 +- packages/one/src/link/useLinkTo.tsx | 6 +- packages/one/src/router/getLinkingConfig.ts | 36 +- .../one/src/utils/getPathnameFromFilePath.ts | 2 +- packages/one/src/utils/rewrite.ts | 158 ++++++++ .../build/buildVercelOutputDirectory.ts | 36 +- .../one/src/vercel/build/getPathFromRoute.ts | 7 +- packages/one/src/vite/one.ts | 13 + packages/one/src/vite/types.ts | 13 + packages/one/types/createHandleRequest.d.ts | 2 +- packages/one/types/createMiddleware.d.ts | 2 +- packages/one/types/utils/rewrite.d.ts | 32 ++ packages/one/types/vite/types.d.ts | 12 + test-env-debug.mjs | 58 +++ test-env-simple.mjs | 69 ++++ test-final-check.mjs | 71 ++++ test-link-navigation.mjs | 90 +++++ test-rewrite-debug.mjs | 112 ++++++ tests/test/app/_middleware-rewrite.tsx | 61 +++ tests/test/app/_middleware.tsx | 45 +++ tests/test/app/actual-path.tsx | 8 + tests/test/app/api/v2/users+api.ts | 18 + tests/test/app/page.tsx | 8 + tests/test/app/rewritten.tsx | 8 + .../test/app/server/[subdomain]/index+spa.tsx | 8 + tests/test/routes.d.ts | 6 +- tests/test/tests/middleware-rewrite.test.ts | 163 ++++++++ tests/test/tests/rewrite.test.ts | 304 +++++++++++++++ 47 files changed, 2907 insertions(+), 54 deletions(-) create mode 100644 apps/onestack.dev/REWRITE_TESTING.md create mode 100644 apps/onestack.dev/app/_middleware.tsx create mode 100644 apps/onestack.dev/app/middleware-health+api.ts create mode 100644 apps/onestack.dev/app/subdomain/[name]/about.tsx create mode 100644 apps/onestack.dev/app/subdomain/[name]/index.tsx create mode 100644 apps/onestack.dev/app/subdomain/myapp/about+spa.tsx create mode 100644 apps/onestack.dev/app/subdomain/myapp/index+spa.tsx create mode 100644 apps/onestack.dev/app/subdomain/testapp/about+spa.tsx create mode 100644 apps/onestack.dev/app/subdomain/testapp/index+spa.tsx create mode 100644 apps/onestack.dev/app/test-middleware-response+api.ts create mode 100644 apps/onestack.dev/app/test-rewrites+spa.tsx create mode 100644 apps/onestack.dev/data/docs/middleware.mdx create mode 100644 apps/onestack.dev/data/docs/url-rewriting.mdx create mode 100644 apps/onestack.dev/tests/rewrite-integration.test.tsx create mode 100644 packages/one/src/utils/rewrite.ts create mode 100644 packages/one/types/utils/rewrite.d.ts create mode 100644 test-env-debug.mjs create mode 100644 test-env-simple.mjs create mode 100644 test-final-check.mjs create mode 100644 test-link-navigation.mjs create mode 100644 test-rewrite-debug.mjs create mode 100644 tests/test/app/_middleware-rewrite.tsx create mode 100644 tests/test/app/actual-path.tsx create mode 100644 tests/test/app/api/v2/users+api.ts create mode 100644 tests/test/app/page.tsx create mode 100644 tests/test/app/rewritten.tsx create mode 100644 tests/test/app/server/[subdomain]/index+spa.tsx create mode 100644 tests/test/tests/middleware-rewrite.test.ts create mode 100644 tests/test/tests/rewrite.test.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index bbb73cf81..a4523c5b8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -16,7 +16,8 @@ "Bash(mkdir:*)", "Bash(TEST_ONLY=dev yarn vitest --config ./vitest.config.rolldown.ts --run --reporter=dot --color=false api-rolldown.test.ts)", "Bash(bun llink:*)", - "Bash(bun:*)" + "Bash(bun:*)", + "WebSearch" ], "deny": [] } diff --git a/apps/onestack.dev/REWRITE_TESTING.md b/apps/onestack.dev/REWRITE_TESTING.md new file mode 100644 index 000000000..c21bd1a97 --- /dev/null +++ b/apps/onestack.dev/REWRITE_TESTING.md @@ -0,0 +1,149 @@ +# Testing URL Rewriting Features + +This document describes how to test the URL rewriting and enhanced middleware features in One. + +## Quick Start + +1. **Start the dev server:** + ```bash + cd apps/onestack.dev + yarn dev + ``` + +2. **Visit the test page:** + Open http://localhost:6173/test-rewrites + +3. **Test subdomain routing:** + Try these URLs in your browser (`.localhost` domains work on modern systems): + - http://app1.localhost:6173 + - http://app2.localhost:6173 + - http://docs.localhost:6173 + +## Features to Test + +### 1. URL Rewriting + +The following rewrite rules are configured in `vite.config.ts`: + +```typescript +{ + web: { + rewrites: { + '*.localhost': '/subdomain/*', + 'docs.localhost': '/docs', + '/old-docs/*': '/docs/*' + } + } +} +``` + +### 2. Middleware Capabilities + +The middleware in `app/_middleware.tsx` demonstrates: + +- **Request rewriting**: Modifying the URL before it reaches route handlers +- **Response interception**: Returning responses directly from middleware +- **Subdomain handling**: Detecting and routing based on subdomains + +### 3. Link Component Integration + +Links with internal paths like `/subdomain/app1` should automatically render with external URLs like `http://app1.localhost` when rewrites are configured. + +## Running Integration Tests + +```bash +# Run all tests +yarn test + +# Run rewrite tests specifically +yarn vitest run rewrite-integration.test.tsx + +# Run with debugging (opens browser) +DEBUG=1 yarn vitest run rewrite-integration.test.tsx +``` + +## Test Files + +- **Test page**: `/app/test-rewrites.tsx` - Interactive test page +- **Middleware**: `/app/_middleware.tsx` - Request/response handling +- **Subdomain pages**: `/app/subdomain/[name]/index.tsx` - Dynamic subdomain routing +- **Integration tests**: `/tests/rewrite-integration.test.tsx` - Automated tests + +## Manual Testing Checklist + +### Basic Functionality + +- [ ] Visit http://localhost:6173/test-rewrites +- [ ] Check that current URL is displayed correctly +- [ ] Verify links show their rendered hrefs + +### Subdomain Routing + +- [ ] Visit http://app1.localhost:6173 +- [ ] Verify it shows "Subdomain: app1" +- [ ] Navigate to http://app1.localhost:6173/about +- [ ] Verify navigation between subdomain pages works + +### Link Transformation + +- [ ] Hover over subdomain links on test page +- [ ] Verify browser shows subdomain URLs in status bar +- [ ] Click subdomain links +- [ ] Verify navigation works correctly + +### Middleware Response + +- [ ] Click "Test Middleware Response" button +- [ ] Verify JSON response appears +- [ ] Check response contains expected fields + +### Path Rewrites + +- [ ] Visit http://localhost:6173/old-docs/intro +- [ ] Verify it redirects or rewrites to /docs/intro + +## Troubleshooting + +### Subdomain Not Resolving + +If `*.localhost` doesn't work on your system: + +1. **Check your OS**: Modern macOS and Linux support `.localhost` by default +2. **Try 127.0.0.1**: Use `http://127.0.0.1:6173` instead +3. **Add to hosts file** (if needed): + ```bash + sudo echo "127.0.0.1 app1.localhost" >> /etc/hosts + sudo echo "127.0.0.1 app2.localhost" >> /etc/hosts + ``` + +### Links Not Transforming + +1. Check that rewrites are configured in `vite.config.ts` +2. Verify environment variable is set: `process.env.ONE_URL_REWRITES` +3. Check browser console for errors +4. Ensure you've rebuilt after config changes + +### Middleware Not Running + +1. Verify `_middleware.tsx` is in the correct location +2. Check that it exports a default function +3. Look for errors in server console +4. Ensure middleware is created with `createMiddleware()` + +## Implementation Details + +### How It Works + +1. **Configuration**: Rewrite rules are defined in `vite.config.ts` +2. **Environment**: Rules are passed to client via `process.env.ONE_URL_REWRITES` +3. **Middleware**: Requests are modified before routing +4. **Links**: The Link component applies reverse rewrites for display +5. **Navigation**: React Navigation handles routing with rewritten paths + +### Key Files + +- `/packages/one/src/utils/rewrite.ts` - Rewrite utilities +- `/packages/one/src/createMiddleware.ts` - Middleware types +- `/packages/one/src/createHandleRequest.ts` - Request handling +- `/packages/one/src/link/href.ts` - Link URL resolution +- `/packages/one/src/router/getLinkingConfig.ts` - React Navigation integration \ No newline at end of file diff --git a/apps/onestack.dev/app/_middleware.tsx b/apps/onestack.dev/app/_middleware.tsx new file mode 100644 index 000000000..655d24876 --- /dev/null +++ b/apps/onestack.dev/app/_middleware.tsx @@ -0,0 +1,51 @@ +import { createMiddleware } from 'one' + +/** + * Middleware for handling URL rewrites in onestack.dev + * This handles subdomain routing for testing purposes + */ +export default createMiddleware(async ({ request, next }) => { + const url = new URL(request.url) + const host = request.headers.get('host') || '' + + // Handle subdomain rewrites for .localhost domains + if (host.includes('.localhost')) { + const parts = host.split('.') + const subdomain = parts[0] + + // Special case for docs.localhost + if (subdomain === 'docs') { + const newUrl = new URL(request.url) + newUrl.pathname = `/docs${url.pathname === '/' ? '' : url.pathname}` + return next(new Request(newUrl, request)) + } + + // General subdomain handling + if (subdomain && subdomain !== 'www') { + const newUrl = new URL(request.url) + newUrl.pathname = `/subdomain/${subdomain}${url.pathname}` + return next(new Request(newUrl, request)) + } + } + + // Handle path rewrites + if (url.pathname.startsWith('/old-docs/')) { + const newUrl = new URL(request.url) + newUrl.pathname = url.pathname.replace('/old-docs/', '/docs/') + return next(new Request(newUrl, request)) + } + + // Test response interception + if (url.pathname === '/test-middleware-response') { + return new Response(JSON.stringify({ + message: 'Direct response from middleware', + timestamp: Date.now(), + host, + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + } + + return next() +}) \ No newline at end of file diff --git a/apps/onestack.dev/app/middleware-health+api.ts b/apps/onestack.dev/app/middleware-health+api.ts new file mode 100644 index 000000000..ea5193810 --- /dev/null +++ b/apps/onestack.dev/app/middleware-health+api.ts @@ -0,0 +1,7 @@ +export function GET() { + return Response.json({ + status: 'healthy', + timestamp: Date.now(), + middleware: 'rewrite' + }) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/subdomain/[name]/about.tsx b/apps/onestack.dev/app/subdomain/[name]/about.tsx new file mode 100644 index 000000000..39526deb9 --- /dev/null +++ b/apps/onestack.dev/app/subdomain/[name]/about.tsx @@ -0,0 +1,26 @@ +import { Link, useParams } from 'one' + +export default function SubdomainAboutPage() { + const params = useParams<{ name: string }>() + const name = params?.name || 'unknown' + + return ( +
+

About - {name}

+

This is the about page for subdomain: {name}

+ +
+ + ← Back to {name} home + +
+ +
+

Navigation Test

+

The link above should render with the subdomain URL.

+

Current path: /subdomain/{name}/about

+

Should display as: {name}.localhost/about

+
+
+ ) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/subdomain/[name]/index.tsx b/apps/onestack.dev/app/subdomain/[name]/index.tsx new file mode 100644 index 000000000..ac3c96dc0 --- /dev/null +++ b/apps/onestack.dev/app/subdomain/[name]/index.tsx @@ -0,0 +1,48 @@ +import { Link, useParams, useLoader } from 'one' + +export default function SubdomainPage() { + const params = useParams<{ name: string }>() + const data = useLoader(loader) + const name = params?.name || 'unknown' + + return ( +
+

Subdomain: {name}

+

This page is served from the rewritten path /subdomain/{name}

+ +
+

Test Links (should show external URLs):

+
    +
  • + + About Page (should render as {name}.localhost/about) + +
  • +
  • + + Other Subdomain (should render as other.localhost/page) + +
  • +
  • + + Docs (regular link, no rewrite) + +
  • +
+
+ +
+

Debug Info:

+
{JSON.stringify({ params, loaderData: data }, null, 2)}
+
+
+ ) +} + +export async function loader({ params }: { params: { name: string } }) { + return { + subdomain: params.name, + timestamp: Date.now(), + message: `Loaded subdomain: ${params.name}` + } +} \ No newline at end of file diff --git a/apps/onestack.dev/app/subdomain/myapp/about+spa.tsx b/apps/onestack.dev/app/subdomain/myapp/about+spa.tsx new file mode 100644 index 000000000..cfbedcd13 --- /dev/null +++ b/apps/onestack.dev/app/subdomain/myapp/about+spa.tsx @@ -0,0 +1,8 @@ +export default function MyAppAboutPage() { + return ( +
+

About myapp

+

This is the about page for myapp subdomain

+
+ ) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/subdomain/myapp/index+spa.tsx b/apps/onestack.dev/app/subdomain/myapp/index+spa.tsx new file mode 100644 index 000000000..67b184002 --- /dev/null +++ b/apps/onestack.dev/app/subdomain/myapp/index+spa.tsx @@ -0,0 +1,9 @@ +export default function MyAppSubdomainPage() { + return ( +
+

myapp Subdomain Home

+

This is the home page for myapp subdomain

+ About +
+ ) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/subdomain/testapp/about+spa.tsx b/apps/onestack.dev/app/subdomain/testapp/about+spa.tsx new file mode 100644 index 000000000..26191c162 --- /dev/null +++ b/apps/onestack.dev/app/subdomain/testapp/about+spa.tsx @@ -0,0 +1,8 @@ +export default function TestAppAboutPage() { + return ( +
+

About testapp

+

This is the about page for testapp subdomain

+
+ ) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/subdomain/testapp/index+spa.tsx b/apps/onestack.dev/app/subdomain/testapp/index+spa.tsx new file mode 100644 index 000000000..058cf319b --- /dev/null +++ b/apps/onestack.dev/app/subdomain/testapp/index+spa.tsx @@ -0,0 +1,9 @@ +export default function TestAppSubdomainPage() { + return ( +
+

testapp Subdomain Home

+

This is the home page for testapp subdomain

+ About +
+ ) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/test-middleware-response+api.ts b/apps/onestack.dev/app/test-middleware-response+api.ts new file mode 100644 index 000000000..0c2237345 --- /dev/null +++ b/apps/onestack.dev/app/test-middleware-response+api.ts @@ -0,0 +1,7 @@ +export function GET() { + return Response.json({ + message: 'Direct response from middleware', + timestamp: Date.now(), + source: 'test-middleware-response' + }) +} \ No newline at end of file diff --git a/apps/onestack.dev/app/test-rewrites+spa.tsx b/apps/onestack.dev/app/test-rewrites+spa.tsx new file mode 100644 index 000000000..aa813a3fa --- /dev/null +++ b/apps/onestack.dev/app/test-rewrites+spa.tsx @@ -0,0 +1,242 @@ +import { Link } from 'one' +import { useState, useEffect } from 'react' + +export default function TestRewritesPage() { + const [middlewareResponse, setMiddlewareResponse] = useState(null) + const [currentUrl, setCurrentUrl] = useState('') + const [linkHrefs, setLinkHrefs] = useState>({}) + + useEffect(() => { + // Set current URL on client + if (typeof window !== 'undefined') { + setCurrentUrl(window.location.href) + + // Check what hrefs are actually rendered + setTimeout(() => { + const links = document.querySelectorAll('a[data-test-link]') + const hrefs: Record = {} + links.forEach((link) => { + const testId = link.getAttribute('data-test-link') + if (testId) { + hrefs[testId] = link.getAttribute('href') || '' + } + }) + setLinkHrefs(hrefs) + }, 100) + } + }, []) + + const testMiddlewareResponse = async () => { + try { + const res = await fetch('/test-middleware-response') + const data = await res.json() + setMiddlewareResponse(data) + } catch (err: any) { + setMiddlewareResponse({ error: err.message }) + } + } + + return ( +
+

URL Rewriting Test Page

+ +
+

Current URL

+ + {currentUrl || 'Loading...'} + +
+ +
+

Test Subdomain Links

+

These links should render with subdomain URLs when rewrites are configured:

+
    +
  • + + App1 Subdomain → /subdomain/app1 + + {linkHrefs.app1 && ( + + (renders as: {linkHrefs.app1}) + + )} +
  • +
  • + + App2 Dashboard → /subdomain/app2/dashboard + + {linkHrefs['app2-dashboard'] && ( + + (renders as: {linkHrefs['app2-dashboard']}) + + )} +
  • +
  • + + Test About → /subdomain/test/about + + {linkHrefs['test-about'] && ( + + (renders as: {linkHrefs['test-about']}) + + )} +
  • +
+
+ +
+

Test Path Rewrites

+

These demonstrate path-based rewrites:

+
    +
  • + + Regular docs link → /docs/intro (no rewrite) + +
  • +
  • + + Old docs link → /old-docs/intro (should rewrite to /docs/intro) + +
  • +
+
+ +
+

Test Middleware Response

+

Test that middleware can return responses directly:

+ + + {middlewareResponse && ( +
+            {JSON.stringify(middlewareResponse, null, 2)}
+          
+ )} +
+ +
+

Testing Instructions

+
    +
  1. + Test subdomain routing locally: +
      +
    • Visit http://app1.localhost:6173
    • +
    • Should show the subdomain page with "app1" as the subdomain
    • +
    +
  2. +
  3. + Test Link rendering: +
      +
    • Check the "renders as:" text next to each link above
    • +
    • Subdomain links should show as *.localhost URLs if rewrites are working
    • +
    +
  4. +
  5. + Test navigation: +
      +
    • Click on subdomain links
    • +
    • Should navigate correctly with proper URL in address bar
    • +
    +
  6. +
  7. + Test middleware response: +
      +
    • Click the button above
    • +
    • Should receive JSON response directly from middleware
    • +
    +
  8. +
+
+ +
+

Direct Subdomain Test URLs

+

The .localhost domain automatically resolves to 127.0.0.1 on modern systems.

+

Try these URLs directly in your browser:

+ +
+ +
+

Configuration Used

+
+{`// vite.config.ts
+{
+  web: {
+    rewrites: {
+      '*.localhost': '/subdomain/*',
+      'docs.localhost': '/docs',
+      '/old-docs/*': '/docs/*',
+    }
+  }
+}`}
+        
+
+
+ ) +} \ No newline at end of file diff --git a/apps/onestack.dev/data/docs/middleware.mdx b/apps/onestack.dev/data/docs/middleware.mdx new file mode 100644 index 000000000..6370db9e7 --- /dev/null +++ b/apps/onestack.dev/data/docs/middleware.mdx @@ -0,0 +1,214 @@ +--- +title: Middleware +description: Learn how to use middleware to intercept and modify requests and responses +--- + +# Middleware + +Middleware allows you to run code before a request is completed. You can use middleware to: + +- Modify incoming requests before they reach your route handlers +- Return responses directly without reaching route handlers +- Add headers or modify responses +- Implement authentication and authorization +- Log requests and responses +- Implement URL rewriting and redirects + +## Creating Middleware + +Create a `_middleware.ts` file in any route directory. The middleware will apply to all routes in that directory and its subdirectories. + +```tsx +// app/_middleware.tsx +import { createMiddleware } from 'one' + +export default createMiddleware(async ({ request, next, context }) => { + // Code here runs before the request is handled + console.log('Incoming request:', request.url) + + // Continue to the next middleware or route handler + const response = await next() + + // Code here runs after the response is generated + console.log('Outgoing response:', response.status) + + return response +}) +``` + +## Request Modification + +Middleware can modify the incoming request before passing it to the next handler. This is useful for URL rewriting, adding headers, or transforming the request. + +```tsx +export default createMiddleware(async ({ request, next }) => { + // Create a new request with modified URL + const url = new URL(request.url) + url.searchParams.set('modified', 'true') + + const modifiedRequest = new Request(url.toString(), request) + + // Pass the modified request to the next handler + return next(modifiedRequest) +}) +``` + +### URL Rewriting Example + +A common use case is rewriting URLs based on subdomains: + +```tsx +export default createMiddleware(async ({ request, next }) => { + const host = request.headers.get('host') || '' + + // Extract subdomain + if (host.includes('.')) { + const subdomain = host.split('.')[0] + + // Rewrite subdomain.example.com/path to /tenants/subdomain/path + if (subdomain && subdomain !== 'www') { + const url = new URL(request.url) + url.pathname = `/tenants/${subdomain}${url.pathname}` + + return next(new Request(url, request)) + } + } + + return next() +}) +``` + +## Response Handling + +Middleware can return a response directly, bypassing route handlers entirely: + +```tsx +export default createMiddleware(async ({ request, next }) => { + // Check authentication + const token = request.headers.get('authorization') + + if (!token) { + // Return early with an error response + return new Response('Unauthorized', { status: 401 }) + } + + // Continue if authenticated + return next() +}) +``` + +## Middleware Context + +The context object allows sharing data between middlewares in the chain: + +```tsx +// app/_middleware.tsx - Root middleware +export default createMiddleware(async ({ request, next, context }) => { + // Add user info to context + context.user = await getUserFromToken(request.headers.get('authorization')) + + return next() +}) + +// app/admin/_middleware.tsx - Nested middleware +export default createMiddleware(async ({ request, next, context }) => { + // Access context from parent middleware + if (!context.user?.isAdmin) { + return new Response('Forbidden', { status: 403 }) + } + + return next() +}) +``` + +## Middleware Execution Order + +Middlewares execute in order from parent to child directories: + +1. `app/_middleware.tsx` runs first +2. `app/blog/_middleware.tsx` runs second (for `/blog` routes) +3. `app/blog/posts/_middleware.tsx` runs third (for `/blog/posts` routes) + +Each middleware can: +- Modify the request before calling `next()` +- Return early without calling `next()` +- Modify the response after `next()` returns + +## Common Patterns + +### Authentication + +```tsx +export default createMiddleware(async ({ request, next }) => { + const session = await getSession(request) + + if (!session) { + // Redirect to login + return new Response(null, { + status: 302, + headers: { Location: '/login' } + }) + } + + return next() +}) +``` + +### Request Logging + +```tsx +export default createMiddleware(async ({ request, next }) => { + const start = Date.now() + + const response = await next() + + const duration = Date.now() - start + console.log(`${request.method} ${request.url} - ${response.status} - ${duration}ms`) + + return response +}) +``` + +### CORS Headers + +```tsx +export default createMiddleware(async ({ request, next }) => { + const response = await next() + + // Add CORS headers to the response + response.headers.set('Access-Control-Allow-Origin', '*') + response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS') + + return response +}) +``` + +### Rate Limiting + +```tsx +const requestCounts = new Map() + +export default createMiddleware(async ({ request, next }) => { + const ip = request.headers.get('x-forwarded-for') || 'unknown' + const count = requestCounts.get(ip) || 0 + + if (count > 100) { + return new Response('Rate limit exceeded', { status: 429 }) + } + + requestCounts.set(ip, count + 1) + + // Reset counts periodically + setTimeout(() => requestCounts.delete(ip), 60000) + + return next() +}) +``` + +## Best Practices + +1. **Keep middleware focused**: Each middleware should have a single responsibility +2. **Call next() when appropriate**: Always call `next()` unless you're intentionally stopping the request +3. **Handle errors gracefully**: Wrap async operations in try-catch blocks +4. **Minimize performance impact**: Avoid expensive operations in middleware that runs on every request +5. **Use context sparingly**: Only add necessary data to context to avoid memory overhead \ No newline at end of file diff --git a/apps/onestack.dev/data/docs/url-rewriting.mdx b/apps/onestack.dev/data/docs/url-rewriting.mdx new file mode 100644 index 000000000..b8bb01116 --- /dev/null +++ b/apps/onestack.dev/data/docs/url-rewriting.mdx @@ -0,0 +1,366 @@ +--- +title: URL Rewriting +description: Configure URL rewriting for subdomain routing and path transformations +--- + +# URL Rewriting + +One supports URL rewriting to enable subdomain-based routing, path transformations, and multi-tenant applications. This feature works seamlessly with the Link component and React Navigation. + +## Configuration + +Configure URL rewrites in your `vite.config.ts` file: + +```ts +// vite.config.ts +import { one } from 'one/vite' + +export default { + plugins: [ + one({ + web: { + rewrites: { + // Subdomain wildcards + '*.app.com': '/tenants/*', + + // Exact subdomain match + 'admin.app.com': '/admin', + + // Path rewrites + '/api/v1/*': '/api/v2/*' + } + } + }) + ] +} +``` + +## Rewrite Patterns + +### Wildcard Subdomains + +Use `*` to match any subdomain segment: + +```ts +{ + '*.app.com': '/tenants/*' +} +``` + +This rewrites: +- `acme.app.com/dashboard` → `/tenants/acme/dashboard` +- `beta.app.com/settings` → `/tenants/beta/settings` + +### Multiple Wildcards + +You can use multiple wildcards for complex patterns: + +```ts +{ + '*.*.cdn.com': '/cdn/*/*' +} +``` + +This rewrites: +- `images.user.cdn.com/avatar.jpg` → `/cdn/images/user/avatar.jpg` + +### Exact Matches + +For specific subdomains without wildcards: + +```ts +{ + 'admin.app.com': '/admin', + 'api.app.com': '/api' +} +``` + +### Path Rewrites + +Rewrite URL paths without subdomain changes: + +```ts +{ + '/old/*': '/new/*', + '/api/v1/*': '/api/v2/*' +} +``` + +## Middleware Implementation + +To handle subdomain rewrites, create a middleware: + +```tsx +// app/_middleware.tsx +import { createMiddleware } from 'one' + +export default createMiddleware(async ({ request, next }) => { + const url = new URL(request.url) + const host = request.headers.get('host') || '' + + // Handle subdomain.app.com + const match = host.match(/^([^.]+)\.app\.com/) + if (match) { + const subdomain = match[1] + + // Rewrite to /tenants/subdomain/... + url.pathname = `/tenants/${subdomain}${url.pathname}` + + // Pass modified request to route handler + return next(new Request(url, request)) + } + + return next() +}) +``` + +## Link Component Integration + +The Link component automatically handles reverse rewrites. Internal paths are converted to external URLs: + +```tsx +// Your code +Dashboard + +// Renders as +Dashboard +``` + +This works automatically when you have configured: + +```ts +{ + '*.app.com': '/tenants/*' +} +``` + +## File Structure + +With subdomain routing, organize your files to match the rewritten paths: + +``` +app/ + tenants/ + [tenant]/ + index.tsx // Homepage for each tenant + dashboard.tsx // Tenant dashboard + settings.tsx // Tenant settings +``` + +Access the tenant parameter in your routes: + +```tsx +// app/tenants/[tenant]/dashboard.tsx +export default function TenantDashboard({ + params +}: { + params: { tenant: string } +}) { + return

Dashboard for {params.tenant}

+} + +export async function loader({ params }) { + const tenantData = await fetchTenantData(params.tenant) + return { tenantData } +} +``` + +## Testing Locally + +For local development, use `.localhost` subdomains which automatically resolve to `127.0.0.1`: + +```ts +// Development configuration +{ + '*.localhost': '/tenants/*' +} +``` + +This allows you to test with: +- `http://acme.localhost:3000` +- `http://beta.localhost:3000` + +No hosts file modification needed on modern macOS and Linux systems. + +## Multi-Tenant Example + +Here's a complete example for a multi-tenant SaaS application: + +### 1. Configure Rewrites + +```ts +// vite.config.ts +export default { + plugins: [ + one({ + web: { + rewrites: { + '*.myapp.com': '/app/*', + 'admin.myapp.com': '/admin', + 'api.myapp.com': '/api' + } + } + }) + ] +} +``` + +### 2. Create Middleware + +```tsx +// app/_middleware.tsx +export default createMiddleware(async ({ request, next }) => { + const host = request.headers.get('host') || '' + + // Extract tenant from subdomain + const match = host.match(/^([^.]+)\.myapp\.com/) + if (match && match[1] !== 'www') { + const tenant = match[1] + + // Special handling for reserved subdomains + if (tenant === 'admin') { + const url = new URL(request.url) + url.pathname = `/admin${url.pathname}` + return next(new Request(url, request)) + } + + if (tenant === 'api') { + const url = new URL(request.url) + url.pathname = `/api${url.pathname}` + return next(new Request(url, request)) + } + + // Regular tenant + const url = new URL(request.url) + url.pathname = `/app/${tenant}${url.pathname}` + return next(new Request(url, request)) + } + + return next() +}) +``` + +### 3. Create Routes + +```tsx +// app/app/[tenant]/index.tsx +export default function TenantHome({ params, data }) { + return ( +
+

{data.tenant.name}

+ + Settings + +
+ ) +} + +export async function loader({ params }) { + const tenant = await db.tenant.findUnique({ + where: { slug: params.tenant } + }) + + if (!tenant) { + throw new Response('Tenant not found', { status: 404 }) + } + + return { tenant } +} +``` + +### 4. Use Links + +```tsx +// Links automatically use the correct subdomain + + Acme Dashboard + +// Renders as: https://acme.myapp.com/dashboard + + + Admin Panel + +// Renders as: https://admin.myapp.com/users +``` + +## Production Deployment + +For production, configure your DNS and hosting provider: + +### DNS Configuration + +Add wildcard DNS records: +``` +*.myapp.com CNAME myapp.com +admin.myapp.com CNAME myapp.com +api.myapp.com CNAME myapp.com +``` + +### Vercel + +Vercel automatically handles wildcard domains. Add them in your project settings: +- `*.myapp.com` +- `myapp.com` + +### Other Providers + +Most modern hosting providers support wildcard domains. Check their documentation for specific configuration. + +## Advanced Patterns + +### Conditional Rewrites + +```tsx +export default createMiddleware(async ({ request, next }) => { + const host = request.headers.get('host') || '' + const userAgent = request.headers.get('user-agent') || '' + + // Different rewrites for mobile + if (userAgent.includes('Mobile')) { + // Mobile-specific rewrite logic + } + + return next() +}) +``` + +### Geographic Rewrites + +```tsx +export default createMiddleware(async ({ request, next }) => { + const country = request.headers.get('cf-ipcountry') || 'US' + + // Rewrite based on country + const url = new URL(request.url) + url.pathname = `/${country.toLowerCase()}${url.pathname}` + + return next(new Request(url, request)) +}) +``` + +## Troubleshooting + +### Links Not Rewriting + +Ensure your rewrite configuration is properly set in `vite.config.ts` and that the patterns match your use case. + +### Middleware Not Running + +Check that your `_middleware.tsx` file is in the correct location and exports a default function created with `createMiddleware`. + +### Local Testing Issues + +Use `.localhost` domains for local testing, or configure your hosts file: + +```bash +# /etc/hosts +127.0.0.1 acme.local +127.0.0.1 beta.local +``` + +## Performance Considerations + +- URL rewriting happens on every request, so keep the logic simple +- Cache tenant data when possible +- Consider using CDN rules for static assets +- Use the `context` object to share data between middlewares \ No newline at end of file diff --git a/apps/onestack.dev/routes.d.ts b/apps/onestack.dev/routes.d.ts index f40a020d8..d0c53a1ef 100644 --- a/apps/onestack.dev/routes.d.ts +++ b/apps/onestack.dev/routes.d.ts @@ -6,9 +6,9 @@ import type { OneRouter } from 'one' declare module 'one' { export namespace OneRouter { export interface __routes extends Record { - StaticRoutes: `/` | `/_sitemap` | `/docs` | `/test` - DynamicRoutes: `/docs/${OneRouter.SingleRoutePart}` - DynamicRouteTemplate: `/docs/[slug]` + StaticRoutes: `/` | `/_sitemap` | `/docs` | `/subdomain/myapp` | `/subdomain/myapp/about` | `/subdomain/testapp` | `/subdomain/testapp/about` | `/test` | `/test-rewrites` + DynamicRoutes: `/docs/${OneRouter.SingleRoutePart}` | `/subdomain/${OneRouter.SingleRoutePart}` | `/subdomain/${OneRouter.SingleRoutePart}/about` + DynamicRouteTemplate: `/docs/[slug]` | `/subdomain/[name]` | `/subdomain/[name]/about` IsTyped: true } } diff --git a/apps/onestack.dev/tests/rewrite-integration.test.tsx b/apps/onestack.dev/tests/rewrite-integration.test.tsx new file mode 100644 index 000000000..5a4412f3e --- /dev/null +++ b/apps/onestack.dev/tests/rewrite-integration.test.tsx @@ -0,0 +1,365 @@ +import { type Browser, type BrowserContext, chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, test } from 'vitest' + +const serverUrl = process.env.ONE_SERVER_URL || 'http://localhost:6173' +const isDebug = !!process.env.DEBUG + +console.info(`Testing rewrites at: ${serverUrl} with debug mode: ${isDebug}`) + +let browser: Browser +let context: BrowserContext + +beforeAll(async () => { + browser = await chromium.launch({ headless: !isDebug }) + context = await browser.newContext() +}) + +afterAll(async () => { + await browser.close() +}) + +describe('URL Rewriting', () => { + test('test page loads without errors', async () => { + const page = await context.newPage() + + const consoleErrors: string[] = [] + page.on('console', (msg) => { + if (msg.type() === 'error') { + consoleErrors.push(msg.text()) + } + }) + + await page.goto(`${serverUrl}/test-rewrites`) + + // Check page loaded + const title = await page.textContent('h1') + expect(title).toContain('URL Rewriting Test Page') + + // No console errors + expect(consoleErrors).toHaveLength(0) + + await page.close() + }) + + test('subdomain links are transformed to external URLs', async () => { + const page = await context.newPage() + await page.goto(`${serverUrl}/test-rewrites`) + + // Wait for client-side hydration + await page.waitForTimeout(1000) + + // Check all links - they might be transformed to full URLs + const allLinks = await page.locator('a').all() + const subdomainLinks = [] + + for (const link of allLinks) { + const href = await link.getAttribute('href') + const text = await link.textContent() + if (href && (href.includes('.localhost') || href.includes('/subdomain/'))) { + subdomainLinks.push({ href, text }) + console.info(`Found link: "${text?.trim()}" -> ${href}`) + } + } + + expect(subdomainLinks.length).toBeGreaterThan(0) + + // Check if at least one link was transformed to subdomain URL + const hasSubdomainUrl = subdomainLinks.some(l => l.href.includes('.localhost')) + console.info('Has subdomain URLs:', hasSubdomainUrl) + + // The transformed links should use subdomain URLs + if (hasSubdomainUrl) { + expect(subdomainLinks[0].href).toContain('.localhost') + } else { + // If not transformed, they should at least have the subdomain path + expect(subdomainLinks[0].href).toContain('/subdomain/') + } + + await page.close() + }) + + test('middleware can return response directly', async () => { + const page = await context.newPage() + await page.goto(`${serverUrl}/test-rewrites`) + + // Click the test middleware button + await page.click('button:has-text("Test Middleware Response")') + + // Wait for response to appear - look for the JSON response specifically + await page.waitForTimeout(1000) // Give time for the response + + // Find the pre element that contains the JSON response (not the config) + const responseElements = await page.locator('pre').all() + let middlewareResponse: string | null = null + + for (const element of responseElements) { + const text = await element.textContent() + if (text && text.includes('"message"') && text.includes('"timestamp"')) { + middlewareResponse = text + break + } + } + + expect(middlewareResponse).toBeTruthy() + expect(middlewareResponse).toContain('Direct response from middleware') + expect(middlewareResponse).toContain('timestamp') + + await page.close() + }) + + test('subdomain routing with .localhost works', async () => { + const page = await context.newPage() + + // Visit subdomain URL + // Note: This will only work if the server is configured to accept any host + await page.goto(`${serverUrl.replace('localhost', 'app1.localhost')}/`) + + // Check if we get the subdomain page or a valid response + const response = page.url() + console.info('Subdomain URL response:', response) + + // The page should either show the subdomain content or redirect appropriately + // We can't guarantee subdomain resolution in all test environments + const content = await page.textContent('body') + expect(content).toBeDefined() + + await page.close() + }) + + test('navigation between pages maintains proper URLs', async () => { + const page = await context.newPage() + await page.goto(`${serverUrl}/test-rewrites`) + + // Get initial URL + const initialUrl = page.url() + console.info('Initial URL:', initialUrl) + + // Click on a subdomain link + const hasLink = await page.locator('[data-test-link="app1"]').count() + if (hasLink > 0) { + await page.click('[data-test-link="app1"]') + + // Wait for navigation + await page.waitForTimeout(1000) + + // Check new URL + const newUrl = page.url() + console.info('After navigation URL:', newUrl) + + // Should have navigated to a different page + expect(newUrl).not.toBe(initialUrl) + + // Check page content + const h1 = await page.textContent('h1') + expect(h1).toBeDefined() + } + + await page.close() + }) + + test('path rewrites work correctly', async () => { + const page = await context.newPage() + + // Try to access /old-docs/intro which should rewrite to /docs/intro + const response = await page.goto(`${serverUrl}/old-docs/intro`) + + // Should not get a 404 + const status = response?.status() || 0 + console.info('Old docs rewrite status:', status) + + // Either it successfully rewrites or returns a valid response + expect(status).toBeLessThan(500) + + await page.close() + }) + + test('middleware request modification preserves query parameters', async () => { + const page = await context.newPage() + + // Navigate with query parameters + await page.goto(`${serverUrl}/test-rewrites?test=123&foo=bar`) + + // Check that query params are preserved in the URL + const url = new URL(page.url()) + expect(url.searchParams.get('test')).toBe('123') + expect(url.searchParams.get('foo')).toBe('bar') + + await page.close() + }) + + test('multiple middleware features work together', async () => { + const page = await context.newPage() + await page.goto(`${serverUrl}/test-rewrites`) + + // Test 1: Check page loads + const title = await page.textContent('h1') + expect(title).toBeTruthy() + + // Test 2: Middleware response works + await page.click('button:has-text("Test Middleware Response")') + await page.waitForTimeout(1000) + + // Find the JSON response + const responseElements = await page.locator('pre').all() + let hasJsonResponse = false + for (const element of responseElements) { + const text = await element.textContent() + if (text && text.includes('"message"')) { + hasJsonResponse = true + break + } + } + expect(hasJsonResponse).toBe(true) + + // Test 3: Links are present - check for any links with subdomain + const allLinks = await page.locator('a').all() + let hasSubdomainLink = false + for (const link of allLinks) { + const href = await link.getAttribute('href') + if (href && (href.includes('.localhost') || href.includes('/subdomain/'))) { + hasSubdomainLink = true + break + } + } + expect(hasSubdomainLink).toBe(true) + + await page.close() + }) +}) + +describe('Link Navigation', () => { + test('clicking subdomain links navigates correctly with port preserved', { timeout: 15000 }, async () => { + const page = await context.newPage() + await page.goto(`${serverUrl}/test-rewrites`) + + // Wait for hydration + await page.waitForTimeout(2000) + + // Find links by text content since data attributes aren't passed through + const link = await page.locator('a:has-text("App1 Subdomain")') + const linkExists = await link.count() > 0 + expect(linkExists).toBe(true) + + // Get the actual href + const actualHref = await link.evaluate(el => (el as HTMLAnchorElement).href) + console.log('Link actual href (browser):', actualHref) + + // Check that port is preserved + const port = serverUrl.split(':')[2]?.split('/')[0] + expect(actualHref).toContain(`:${port}`) + expect(actualHref).toContain('app1.localhost') + + // Click the link and verify navigation + await link.click() + await page.waitForTimeout(2000) + + const currentUrl = page.url() + console.log('After click, current URL:', currentUrl) + expect(currentUrl).toContain('app1.localhost') + expect(currentUrl).toContain(`:${port}`) + + // Verify content loaded + const h1 = await page.textContent('h1') + expect(h1).toBeTruthy() + + await page.close() + }) + + test('subdomain links work in both directions', { timeout: 15000 }, async () => { + const page = await context.newPage() + + // Start from subdomain page + const port = serverUrl.split(':')[2]?.split('/')[0] + await page.goto(`http://testapp.localhost:${port}/`) + await page.waitForTimeout(2000) + + // Check we're on the subdomain page + const h1 = await page.textContent('h1') + console.log('Subdomain page H1:', h1) + expect(h1).toContain('testapp') + + // Find any link back (might be "Back to main site" or similar) + const links = await page.locator('a').all() + let foundMainLink = false + + for (const link of links) { + const text = await link.textContent() + if (text && text.toLowerCase().includes('back')) { + foundMainLink = true + await link.click() + break + } + } + + if (!foundMainLink) { + // If no "back" link, just verify we can navigate to root + await page.goto(`http://localhost:${port}/`) + } + + await page.waitForTimeout(1000) + + // Should be on main site + const currentUrl = page.url() + expect(currentUrl).toContain(`localhost:${port}`) + expect(currentUrl).not.toContain('testapp') + + await page.close() + }) +}) + +describe('Subdomain Pages', () => { + test('subdomain page renders correctly', async () => { + const page = await context.newPage() + + // Navigate to the subdomain route directly + await page.goto(`${serverUrl}/subdomain/testapp`) + + // Check page content + const h1 = await page.textContent('h1') + expect(h1).toContain('testapp') + + // Check that the debug info shows correct params + const content = await page.textContent('body') + expect(content).toContain('testapp') + + await page.close() + }) + + test('subdomain about page works', async () => { + const page = await context.newPage() + + // Navigate to subdomain about page + await page.goto(`${serverUrl}/subdomain/testapp/about`) + + // Check page content + const h1 = await page.textContent('h1') + expect(h1).toContain('About') + expect(h1).toContain('testapp') + + await page.close() + }) + + test('navigation within subdomain pages works', async () => { + const page = await context.newPage() + + // Start at subdomain home + await page.goto(`${serverUrl}/subdomain/myapp`) + + // Check we're on the right page + let h1 = await page.textContent('h1') + expect(h1).toContain('myapp') + + // Look for about link and click it if present + const hasAboutLink = await page.locator('a:has-text("About")').count() + if (hasAboutLink > 0) { + await page.click('a:has-text("About")') + await page.waitForTimeout(500) + + // Check we navigated to about page + h1 = await page.textContent('h1') + expect(h1).toContain('About') + } + + await page.close() + }) +}) diff --git a/apps/onestack.dev/vite.config.ts b/apps/onestack.dev/vite.config.ts index 656a1b8b5..7f79a2535 100644 --- a/apps/onestack.dev/vite.config.ts +++ b/apps/onestack.dev/vite.config.ts @@ -30,6 +30,15 @@ export default { react: { compiler: process.env.NODE_ENV === 'production', }, + web: { + rewrites: { + // Test subdomain rewrites with .localhost + '*.localhost': '/subdomain/*', + 'docs.localhost': '/docs', + // Test path rewrites + '/old-docs/*': '/docs/*', + }, + }, }), tamaguiPlugin({ diff --git a/packages/one/src/createHandleRequest.ts b/packages/one/src/createHandleRequest.ts index 1dc34512e..cde0acf86 100644 --- a/packages/one/src/createHandleRequest.ts +++ b/packages/one/src/createHandleRequest.ts @@ -29,12 +29,12 @@ export async function runMiddlewares( handlers: RequestHandlers, request: Request, route: RouteInfo, - getResponse: () => Promise + getResponse: (finalRequest: Request) => Promise ): Promise { const middlewares = route.middlewares if (!middlewares?.length) { - return await getResponse() + return await getResponse(request) } if (!handlers.loadMiddleware) { throw new Error(`No middleware handler configured`) @@ -42,12 +42,12 @@ export async function runMiddlewares( const context: MiddlewareContext = {} - async function dispatch(index: number): Promise { + async function dispatch(index: number, currentRequest = request): Promise { const middlewareModule = middlewares![index] - // no more middlewares, finish + // no more middlewares, finish with potentially modified request if (!middlewareModule) { - return await getResponse() + return await getResponse(currentRequest) } const exported = (await handlers.loadMiddleware!(middlewareModule))?.default as @@ -58,20 +58,20 @@ export async function runMiddlewares( throw new Error(`No valid export found in middleware: ${middlewareModule.contextKey}`) } - // go to next middleware - const next = async () => { - return dispatch(index + 1) + // go to next middleware, optionally with a modified request + const next = async (modifiedRequest?: Request) => { + return dispatch(index + 1, modifiedRequest || currentRequest) } // run middlewares, if response returned, exit early - const response = await exported({ request, next, context }) + const response = await exported({ request: currentRequest, next, context }) if (response) { return response } - // If the middleware returns null/void, keep going - return dispatch(index + 1) + // If the middleware returns null/void, keep going with current request + return dispatch(index + 1, currentRequest) } // Start with the first middleware (index 0). @@ -127,20 +127,23 @@ export async function resolveLoaderRoute( url: URL, route: RouteInfoCompiled ) { - return await runMiddlewares(handlers, request, route, async () => { + return await runMiddlewares(handlers, request, route, async (finalRequest) => { + // Extract URL from potentially modified request + const finalUrl = new URL(finalRequest.url) + return await resolveResponse(async () => { const headers = new Headers() headers.set('Content-Type', 'text/javascript') try { const loaderResponse = await handlers.handleLoader!({ - request, + request: finalRequest, route, - url, + url: finalUrl, loaderProps: { - path: url.pathname, - request: route.type === 'ssr' ? request : undefined, - params: getLoaderParams(url, route), + path: finalUrl.pathname, + request: route.type === 'ssr' ? finalRequest : undefined, + params: getLoaderParams(finalUrl, route), }, }) @@ -167,19 +170,21 @@ export async function resolvePageRoute( url: URL, route: RouteInfoCompiled ) { - const { pathname, search } = url - return resolveResponse(async () => { - const resolved = await runMiddlewares(handlers, request, route, async () => { + const resolved = await runMiddlewares(handlers, request, route, async (finalRequest) => { + // Extract URL from potentially modified request + const finalUrl = new URL(finalRequest.url) + const { pathname, search } = finalUrl + return await handlers.handlePage!({ - request, + request: finalRequest, route, - url, + url: finalUrl, loaderProps: { path: pathname + search, - // Ensure SSR loaders receive the original request - request: route.type === 'ssr' ? request : undefined, - params: getLoaderParams(url, route), + // Ensure SSR loaders receive the potentially modified request + request: route.type === 'ssr' ? finalRequest : undefined, + params: getLoaderParams(finalUrl, route), }, }) }) diff --git a/packages/one/src/createMiddleware.ts b/packages/one/src/createMiddleware.ts index 156b8cec4..cba263041 100644 --- a/packages/one/src/createMiddleware.ts +++ b/packages/one/src/createMiddleware.ts @@ -5,7 +5,7 @@ export interface MiddlewareContext {} export type Middleware = (props: { request: Request - next: () => Promise + next: (request?: Request) => Promise context: MiddlewareContext }) => RequestResponse diff --git a/packages/one/src/link/href.ts b/packages/one/src/link/href.ts index 12bb4eaac..e1fbc6e7a 100644 --- a/packages/one/src/link/href.ts +++ b/packages/one/src/link/href.ts @@ -1,4 +1,6 @@ import type { OneRouter } from '../interfaces/router' +import { Platform } from 'react-native' +import { getRewriteConfig, reverseRewrite } from '../utils/rewrite' /** Resolve an href object into a fully qualified, relative href. */ export const resolveHref = (href: OneRouter.Href): string => { @@ -7,13 +9,38 @@ export const resolveHref = (href: OneRouter.Href): string => { } const path = href.pathname ?? '' if (!href?.params) { + // Apply reverse rewrites if on web + if (Platform.OS === 'web') { + const rewrites = getRewriteConfig() + if (Object.keys(rewrites).length > 0) { + const externalUrl = reverseRewrite(path, rewrites) + // If it's a full URL (subdomain rewrite), return it as-is + if (externalUrl.startsWith('http://') || externalUrl.startsWith('https://')) { + return externalUrl + } + } + } return path } const { pathname, params } = createQualifiedPathname(path, { ...href.params, }) const paramsString = createQueryParams(params) - return pathname + (paramsString ? `?${paramsString}` : '') + const fullPath = pathname + (paramsString ? `?${paramsString}` : '') + + // Apply reverse rewrites if on web + if (Platform.OS === 'web') { + const rewrites = getRewriteConfig() + if (Object.keys(rewrites).length > 0) { + const externalUrl = reverseRewrite(fullPath, rewrites) + // If it's a full URL (subdomain rewrite), return it as-is + if (externalUrl.startsWith('http://') || externalUrl.startsWith('https://')) { + return externalUrl + } + } + } + + return fullPath } function createQualifiedPathname( diff --git a/packages/one/src/link/useLinkTo.tsx b/packages/one/src/link/useLinkTo.tsx index fc7b0e0f5..1e11b08d7 100644 --- a/packages/one/src/link/useLinkTo.tsx +++ b/packages/one/src/link/useLinkTo.tsx @@ -48,9 +48,13 @@ export function useLinkTo(props: { href: string; replace?: boolean }) { } } + // Don't strip group segments from full URLs (subdomain rewrites) + const isFullUrl = props.href.startsWith('http://') || props.href.startsWith('https://') + const processedHref = isFullUrl ? props.href : stripGroupSegmentsFromPath(props.href) || '/' + return { // Ensure there's always a value for href. Manually append the baseUrl to the href prop that shows in the static HTML. - href: appendBaseUrl(stripGroupSegmentsFromPath(props.href) || '/'), + href: isFullUrl ? processedHref : appendBaseUrl(processedHref), role: 'link' as const, onPress, } diff --git a/packages/one/src/router/getLinkingConfig.ts b/packages/one/src/router/getLinkingConfig.ts index 6c4897081..739ed2ace 100644 --- a/packages/one/src/router/getLinkingConfig.ts +++ b/packages/one/src/router/getLinkingConfig.ts @@ -8,6 +8,7 @@ import { getPathFromState, getStateFromPath, } from '../link/linking' +import { applyRewrites, getRewriteConfig, reverseRewrite } from '../utils/rewrite' export function getNavigationConfig( routes: RouteNode, @@ -38,12 +39,19 @@ export function getLinkingConfig(routes: RouteNode, metaOnly = true): OneLinking subscribe: addEventListener, getStateFromPath: getStateFromPathMemoized, getPathFromState(state: State, options: Parameters[1]) { - return ( + const path = getPathFromState(state, { ...config, ...options, }) ?? '/' - ) + + // Apply reverse rewrites for external URLs + const rewrites = getRewriteConfig() + if (Object.keys(rewrites).length > 0) { + return reverseRewrite(path, rewrites) + } + + return path }, // Add all functions to ensure the types never need to fallback. // This is a convenience for usage in the package. @@ -55,11 +63,33 @@ export const stateCache = new Map() /** We can reduce work by memoizing the state by the pathname. This only works because the options (linking config) theoretically never change. */ function getStateFromPathMemoized(path: string, options: Parameters[1]) { + // Apply rewrites to incoming path + const rewrites = getRewriteConfig() + let finalPath = path + + if (Object.keys(rewrites).length > 0) { + try { + // Parse the path as a URL to apply rewrites + // We need to handle both full URLs and paths + const isFullUrl = path.startsWith('http://') || path.startsWith('https://') + const url = isFullUrl ? new URL(path) : new URL(path, 'http://temp') + + const rewrittenUrl = applyRewrites(url, rewrites) + if (rewrittenUrl) { + finalPath = rewrittenUrl.pathname + rewrittenUrl.search + } + } catch (err) { + // If URL parsing fails, use original path + console.warn('Failed to apply rewrites to path:', err) + } + } + + // Cache with original path as key const cached = stateCache.get(path) if (cached) { return cached } - const result = getStateFromPath(path, options) + const result = getStateFromPath(finalPath, options) stateCache.set(path, result) return result } diff --git a/packages/one/src/utils/getPathnameFromFilePath.ts b/packages/one/src/utils/getPathnameFromFilePath.ts index c2a9349e4..b16ee68c5 100644 --- a/packages/one/src/utils/getPathnameFromFilePath.ts +++ b/packages/one/src/utils/getPathnameFromFilePath.ts @@ -4,7 +4,7 @@ export function getPathnameFromFilePath( inputPath: string, params = {}, strict = false, - options: { preserveExtensions?: boolean, includeIndex?: boolean } = {} + options: { preserveExtensions?: boolean; includeIndex?: boolean } = {} ) { const path = inputPath.replace(/\+(spa|ssg|ssr|api)\.tsx?$/, '') const dirname = Path.dirname(path).replace(/\([^\/]+\)/gi, '') diff --git a/packages/one/src/utils/rewrite.ts b/packages/one/src/utils/rewrite.ts new file mode 100644 index 000000000..b9460893e --- /dev/null +++ b/packages/one/src/utils/rewrite.ts @@ -0,0 +1,158 @@ +/** + * URL rewriting utilities for handling subdomain and path rewrites + */ + +export interface RewriteRule { + pattern: RegExp + target: (match: RegExpMatchArray, host?: string) => string + isSubdomain: boolean +} + +/** + * Parse a rewrite rule string into a pattern and target function + * Examples: + * - '*.start.chat': '/server/*' (subdomain wildcard) + * - 'admin.app.com': '/admin' (exact subdomain) + * - '/old/*': '/new/*' (path rewrite) + */ +export function parseRewriteRule(ruleKey: string, ruleValue: string): RewriteRule { + const isSubdomain = !ruleKey.startsWith('/') + + // Escape special regex characters except for * + const escapedPattern = ruleKey.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '([^./]+)') + + const pattern = isSubdomain ? new RegExp(`^${escapedPattern}$`) : new RegExp(`^${escapedPattern}`) + + const target = (match: RegExpMatchArray, host?: string) => { + let result = ruleValue + + // Replace wildcards in target with captured groups + match.slice(1).forEach((group, index) => { + result = result.replace('*', group) + }) + + return result + } + + return { pattern, target, isSubdomain } +} + +/** + * Apply rewrite rules to a URL + * Returns a new URL if a rule matches, null otherwise + */ +export function applyRewrites(url: URL, rewrites: Record): URL | null { + const host = url.hostname + const pathname = url.pathname + + for (const [ruleKey, ruleValue] of Object.entries(rewrites)) { + const rule = parseRewriteRule(ruleKey, ruleValue) + + if (rule.isSubdomain) { + // Check if host matches the pattern + const match = rule.pattern.exec(host) + if (match) { + const newUrl = new URL(url.toString()) + newUrl.pathname = rule.target(match, host) + pathname + return newUrl + } + } else { + // Check if pathname matches the pattern + const match = rule.pattern.exec(pathname) + if (match) { + const newUrl = new URL(url.toString()) + newUrl.pathname = rule.target(match) + return newUrl + } + } + } + + return null +} + +/** + * Reverse a rewrite for Link components + * Converts internal paths back to external URLs + * Example: '/server/tamagui/docs' → 'https://tamagui.start.chat/docs' + */ +export function reverseRewrite( + path: string, + rewrites: Record, + currentHost?: string +): string { + // Try to find a matching rewrite rule that would produce this path + for (const [ruleKey, ruleValue] of Object.entries(rewrites)) { + const rule = parseRewriteRule(ruleKey, ruleValue) + + if (rule.isSubdomain) { + // Check if this path could be the result of this rewrite + // For '*.start.chat': '/server/*', check if path starts with '/server/' + const valuePattern = ruleValue.replace(/\*/g, '([^/]+)') + const valueRegex = new RegExp(`^${valuePattern}(.*)$`) + const match = valueRegex.exec(path) + + if (match) { + // Reconstruct the original subdomain URL + const subdomain = match[1] + const remainingPath = match[2] || '' + + // Replace * in the rule key with the extracted subdomain + const originalHost = ruleKey.replace('*', subdomain) + + // In browser environment, use the current protocol and port + if (typeof window !== 'undefined') { + const protocol = window.location.protocol + const port = window.location.port ? `:${window.location.port}` : '' + return `${protocol}//${originalHost}${port}${remainingPath}` + } else { + // Server-side default + return `https://${originalHost}${remainingPath}` + } + } + } else { + // For path rewrites like '/old/*': '/new/*' + const valuePattern = ruleValue.replace(/\*/g, '(.+)') + const valueRegex = new RegExp(`^${valuePattern}$`) + const match = valueRegex.exec(path) + + if (match) { + // Replace wildcards in the original rule with matched groups + let originalPath = ruleKey + match.slice(1).forEach((group) => { + originalPath = originalPath.replace('*', group) + }) + return originalPath + } + } + } + + // No matching rewrite found, return original path + return path +} + +/** + * Get rewrite configuration from environment + */ +export function getRewriteConfig(): Record { + if (typeof process !== 'undefined' && process.env.ONE_URL_REWRITES) { + try { + return JSON.parse(process.env.ONE_URL_REWRITES) + } catch { + console.warn('Failed to parse ONE_URL_REWRITES') + return {} + } + } + + // Check for import.meta.env (Vite) + try { + // @ts-ignore - import.meta might not be available + if (typeof import.meta !== 'undefined' && import.meta.env?.ONE_URL_REWRITES) { + // @ts-ignore + return JSON.parse(import.meta.env.ONE_URL_REWRITES) + } + } catch { + // import.meta not available + } + + return {} +} diff --git a/packages/one/src/vercel/build/buildVercelOutputDirectory.ts b/packages/one/src/vercel/build/buildVercelOutputDirectory.ts index ea91e5464..382a4d5c9 100644 --- a/packages/one/src/vercel/build/buildVercelOutputDirectory.ts +++ b/packages/one/src/vercel/build/buildVercelOutputDirectory.ts @@ -26,8 +26,15 @@ async function moveAllFiles(src: string, dest: string) { function getMiddlewaresByNamedRegex(buildInfoForWriting: One.BuildInfo) { return buildInfoForWriting.manifest.allRoutes .filter((r) => r.middlewares && r.middlewares.length > 0) - .map((r) => [r.namedRegex, r.middlewares!.map((m) => m.contextKey.startsWith('dist/middlewares/') ? m.contextKey.substring('dist/middlewares/'.length) : m.contextKey)]) - .sort((a, b) => b[0].length - a[0].length); + .map((r) => [ + r.namedRegex, + r.middlewares!.map((m) => + m.contextKey.startsWith('dist/middlewares/') + ? m.contextKey.substring('dist/middlewares/'.length) + : m.contextKey + ), + ]) + .sort((a, b) => b[0].length - a[0].length) } export const buildVercelOutputDirectory = async ({ @@ -113,22 +120,29 @@ export const buildVercelOutputDirectory = async ({ join(vercelMiddlewareDir, wrappedMiddlewareEntryPointFilename) ) const middlewaresByNamedRegex = getMiddlewaresByNamedRegex(buildInfoForWriting) - const middlewaresToVariableNameMap = middlewaresByNamedRegex.reduce((acc, [namedRegex, middlewares]) => { - (Array.isArray(middlewares) ? middlewares : [middlewares]).forEach(middleware => { - const middlewareVariableName = middleware.replace(/\.[a-z]+$/, '').replaceAll('/', '_'); - acc[middleware] = middlewareVariableName - }) - return acc - }, {}) + const middlewaresToVariableNameMap = middlewaresByNamedRegex.reduce( + (acc, [namedRegex, middlewares]) => { + ;(Array.isArray(middlewares) ? middlewares : [middlewares]).forEach((middleware) => { + const middlewareVariableName = middleware.replace(/\.[a-z]+$/, '').replaceAll('/', '_') + acc[middleware] = middlewareVariableName + }) + return acc + }, + {} + ) await FSExtra.writeFile( wrappedMiddlewareEntryPointPath, ` const middlewaresByNamedRegex = ${JSON.stringify(middlewaresByNamedRegex)} -${Object.entries(middlewaresToVariableNameMap).map(([path, variableName]) => `import ${variableName} from './${path}'`).join('\n')} +${Object.entries(middlewaresToVariableNameMap) + .map(([path, variableName]) => `import ${variableName} from './${path}'`) + .join('\n')} function getMiddleware(path) { switch (path){ - ${Object.entries(middlewaresToVariableNameMap).map(([path, variableName]) => `case '${path}': return ${variableName}`).join('\n')} + ${Object.entries(middlewaresToVariableNameMap) + .map(([path, variableName]) => `case '${path}': return ${variableName}`) + .join('\n')} default: return null } } diff --git a/packages/one/src/vercel/build/getPathFromRoute.ts b/packages/one/src/vercel/build/getPathFromRoute.ts index d7dc53c0d..fcbdd81f9 100644 --- a/packages/one/src/vercel/build/getPathFromRoute.ts +++ b/packages/one/src/vercel/build/getPathFromRoute.ts @@ -1,8 +1,11 @@ import { getPathnameFromFilePath } from '../../utils/getPathnameFromFilePath' import type { RouteInfo } from '../../vite/types' -export function getPathFromRoute(route: RouteInfo, options: { includeIndex?: boolean } = {}) { - return getPathnameFromFilePath(route.file, {}, false, {...options, preserveExtensions: true}) +export function getPathFromRoute( + route: RouteInfo, + options: { includeIndex?: boolean } = {} +) { + return getPathnameFromFilePath(route.file, {}, false, { ...options, preserveExtensions: true }) .replace(/^\.\//, '/') .replace(/\/+$/, '') } diff --git a/packages/one/src/vite/one.ts b/packages/one/src/vite/one.ts index 4d7a9700d..d40115cd5 100644 --- a/packages/one/src/vite/one.ts +++ b/packages/one/src/vite/one.ts @@ -287,6 +287,11 @@ export function one(options: One.PluginOptions = {}): PluginOption { ), }), + ...(options.web?.rewrites && { + 'process.env.ONE_URL_REWRITES': JSON.stringify(JSON.stringify(options.web.rewrites)), + 'import.meta.env.ONE_URL_REWRITES': JSON.stringify(JSON.stringify(options.web.rewrites)), + }), + ...(options.setupFile && { 'process.env.ONE_SETUP_FILE': JSON.stringify(options.setupFile), }), @@ -305,6 +310,10 @@ export function one(options: One.PluginOptions = {}): PluginOption { 'process.env.TAMAGUI_ENVIRONMENT': '"client"', 'import.meta.env.VITE_ENVIRONMENT': '"client"', 'process.env.EXPO_OS': '"web"', + ...(options.web?.rewrites && { + 'process.env.ONE_URL_REWRITES': JSON.stringify(JSON.stringify(options.web.rewrites)), + 'import.meta.env.ONE_URL_REWRITES': JSON.stringify(JSON.stringify(options.web.rewrites)), + }), }, }, @@ -314,6 +323,10 @@ export function one(options: One.PluginOptions = {}): PluginOption { 'process.env.TAMAGUI_ENVIRONMENT': '"ssr"', 'import.meta.env.VITE_ENVIRONMENT': '"ssr"', 'process.env.EXPO_OS': '"web"', + ...(options.web?.rewrites && { + 'process.env.ONE_URL_REWRITES': JSON.stringify(JSON.stringify(options.web.rewrites)), + 'import.meta.env.ONE_URL_REWRITES': JSON.stringify(JSON.stringify(options.web.rewrites)), + }), }, }, diff --git a/packages/one/src/vite/types.ts b/packages/one/src/vite/types.ts index 41c56eb0c..89683a9dd 100644 --- a/packages/one/src/vite/types.ts +++ b/packages/one/src/vite/types.ts @@ -242,6 +242,19 @@ export namespace One { */ defaultRenderMode?: RouteRenderMode + /** + * URL rewrite rules for subdomain-based routing and path rewrites. + * Supports wildcard patterns with * for matching. + * + * @example + * { + * '*.start.chat': '/server/*', + * 'admin.app.com': '/admin', + * '/old/*': '/new/*' + * } + */ + rewrites?: Record + /** * An array of redirect objects, works in development and production: * diff --git a/packages/one/types/createHandleRequest.d.ts b/packages/one/types/createHandleRequest.d.ts index 93c3fac2a..6c459c855 100644 --- a/packages/one/types/createHandleRequest.d.ts +++ b/packages/one/types/createHandleRequest.d.ts @@ -15,7 +15,7 @@ type RequestHandlerProps = { loaderProps?: LoaderProps; }; type RequestHandlerResponse = null | string | Response; -export declare function runMiddlewares(handlers: RequestHandlers, request: Request, route: RouteInfo, getResponse: () => Promise): Promise; +export declare function runMiddlewares(handlers: RequestHandlers, request: Request, route: RouteInfo, getResponse: (finalRequest: Request) => Promise): Promise; export declare function resolveAPIRoute(handlers: RequestHandlers, request: Request, url: URL, route: RouteInfoCompiled): Promise; export declare function resolveLoaderRoute(handlers: RequestHandlers, request: Request, url: URL, route: RouteInfoCompiled): Promise; export declare function resolvePageRoute(handlers: RequestHandlers, request: Request, url: URL, route: RouteInfoCompiled): Promise; diff --git a/packages/one/types/createMiddleware.d.ts b/packages/one/types/createMiddleware.d.ts index 95e6e2a9c..83b536faa 100644 --- a/packages/one/types/createMiddleware.d.ts +++ b/packages/one/types/createMiddleware.d.ts @@ -4,7 +4,7 @@ export interface MiddlewareContext { } export type Middleware = (props: { request: Request; - next: () => Promise; + next: (request?: Request) => Promise; context: MiddlewareContext; }) => RequestResponse; export declare function createMiddleware(middleware: Middleware): Middleware; diff --git a/packages/one/types/utils/rewrite.d.ts b/packages/one/types/utils/rewrite.d.ts new file mode 100644 index 000000000..15c6c6580 --- /dev/null +++ b/packages/one/types/utils/rewrite.d.ts @@ -0,0 +1,32 @@ +/** + * URL rewriting utilities for handling subdomain and path rewrites + */ +export interface RewriteRule { + pattern: RegExp; + target: (match: RegExpMatchArray, host?: string) => string; + isSubdomain: boolean; +} +/** + * Parse a rewrite rule string into a pattern and target function + * Examples: + * - '*.start.chat': '/server/*' (subdomain wildcard) + * - 'admin.app.com': '/admin' (exact subdomain) + * - '/old/*': '/new/*' (path rewrite) + */ +export declare function parseRewriteRule(ruleKey: string, ruleValue: string): RewriteRule; +/** + * Apply rewrite rules to a URL + * Returns a new URL if a rule matches, null otherwise + */ +export declare function applyRewrites(url: URL, rewrites: Record): URL | null; +/** + * Reverse a rewrite for Link components + * Converts internal paths back to external URLs + * Example: '/server/tamagui/docs' → 'https://tamagui.start.chat/docs' + */ +export declare function reverseRewrite(path: string, rewrites: Record, currentHost?: string): string; +/** + * Get rewrite configuration from environment + */ +export declare function getRewriteConfig(): Record; +//# sourceMappingURL=rewrite.d.ts.map \ No newline at end of file diff --git a/packages/one/types/vite/types.d.ts b/packages/one/types/vite/types.d.ts index 65c0706ad..0d155b24e 100644 --- a/packages/one/types/vite/types.d.ts +++ b/packages/one/types/vite/types.d.ts @@ -209,6 +209,18 @@ export declare namespace One { * @default 'ssg' */ defaultRenderMode?: RouteRenderMode; + /** + * URL rewrite rules for subdomain-based routing and path rewrites. + * Supports wildcard patterns with * for matching. + * + * @example + * { + * '*.start.chat': '/server/*', + * 'admin.app.com': '/admin', + * '/old/*': '/new/*' + * } + */ + rewrites?: Record; /** * An array of redirect objects, works in development and production: * diff --git a/test-env-debug.mjs b/test-env-debug.mjs new file mode 100644 index 000000000..b8f1bb811 --- /dev/null +++ b/test-env-debug.mjs @@ -0,0 +1,58 @@ +import { chromium } from 'playwright' + +async function testEnvVariable() { + console.log('Testing ONE_URL_REWRITES environment variable...') + + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage() + + try { + await page.goto('http://localhost:8083/test-rewrites') + + // Check what's actually in the built code + const scriptContent = await page.evaluate(() => { + // Check all script tags for ONE_URL_REWRITES + const scripts = Array.from(document.querySelectorAll('script')) + const results = [] + + for (const script of scripts) { + const src = script.src || 'inline' + const text = script.textContent || '' + if (text.includes('ONE_URL_REWRITES')) { + results.push({ + src, + found: text.substring(text.indexOf('ONE_URL_REWRITES') - 50, text.indexOf('ONE_URL_REWRITES') + 100) + }) + } + } + + return { + scriptMatches: results, + processEnv: typeof process !== 'undefined' ? process.env.ONE_URL_REWRITES : 'no process', + importMetaEnv: typeof import.meta !== 'undefined' ? import.meta.env?.ONE_URL_REWRITES : 'no import.meta' + } + }) + + console.log('Script analysis:', JSON.stringify(scriptContent, null, 2)) + + // Check network requests to see what's being sent + const responses = [] + page.on('response', response => { + const url = response.url() + if (url.includes('.js') || url.includes('.mjs')) { + responses.push(url) + } + }) + + await page.reload() + await page.waitForTimeout(1000) + + console.log('\nJS files loaded:') + responses.slice(0, 5).forEach(r => console.log(' -', r)) + + } finally { + await browser.close() + } +} + +testEnvVariable().catch(console.error) \ No newline at end of file diff --git a/test-env-simple.mjs b/test-env-simple.mjs new file mode 100644 index 000000000..4ffcd1641 --- /dev/null +++ b/test-env-simple.mjs @@ -0,0 +1,69 @@ +import { chromium } from 'playwright' + +async function testEnvVariable() { + console.log('Testing ONE_URL_REWRITES environment variable...') + + const browser = await chromium.launch({ headless: true }) + const page = await browser.newPage() + + // Capture console errors + page.on('console', msg => { + if (msg.type() === 'error' || msg.text().includes('Failed')) { + console.log('Console:', msg.text()) + } + }) + + try { + await page.goto('http://localhost:8084/test-rewrites') + await page.waitForTimeout(1000) + + // Check if env vars are defined + const envCheck = await page.evaluate(() => { + const result = { + hasProcess: typeof process !== 'undefined', + hasImportMeta: false, + processValue: 'not found', + importMetaValue: 'not found' + } + + if (typeof process !== 'undefined' && process.env && process.env.ONE_URL_REWRITES) { + result.processValue = process.env.ONE_URL_REWRITES + } + + try { + if (import.meta && import.meta.env && import.meta.env.ONE_URL_REWRITES) { + result.hasImportMeta = true + result.importMetaValue = import.meta.env.ONE_URL_REWRITES + } + } catch (e) { + // ignore + } + + return result + }) + + console.log('\nEnvironment check:', envCheck) + + // Now check Link hrefs + const links = await page.$$eval('a[href*="/subdomain/"]', elements => + elements.map(el => ({ + text: el.textContent, + href: el.getAttribute('href') + })) + ) + + console.log('\nLinks found:') + links.forEach(link => { + console.log(` "${link.text.trim()}" -> ${link.href}`) + }) + + // Check if links have been transformed + const hasSubdomainUrl = links.some(l => l.href && l.href.includes('.localhost')) + console.log('\nLinks transformed to subdomain URLs:', hasSubdomainUrl) + + } finally { + await browser.close() + } +} + +testEnvVariable().catch(console.error) \ No newline at end of file diff --git a/test-final-check.mjs b/test-final-check.mjs new file mode 100644 index 000000000..b91bac9a5 --- /dev/null +++ b/test-final-check.mjs @@ -0,0 +1,71 @@ +import { chromium } from 'playwright' + +async function testFinalCheck() { + console.log('Final check of URL rewriting feature...\n') + + const browser = await chromium.launch({ headless: false }) + const page = await browser.newPage() + + // Capture console messages + let errorCount = 0 + page.on('console', msg => { + if (msg.type() === 'error' && msg.text().includes('Failed to parse')) { + errorCount++ + } + }) + + try { + // Test 1: Load test page + console.log('1. Loading test-rewrites page...') + await page.goto('http://localhost:8085/test-rewrites') + await page.waitForTimeout(2000) // Give time for hydration + + console.log(` ✓ Page loaded (${errorCount} parse errors)`) + + // Test 2: Check Link hrefs + console.log('\n2. Checking Link hrefs after hydration...') + const links = await page.$$eval('a', elements => + elements.map(el => ({ + text: el.textContent?.trim(), + href: el.getAttribute('href'), + actualHref: el.href // This is what the browser sees + })) + ) + + const subdomainLinks = links.filter(l => + l.text && l.text.includes('Subdomain') + ) + + console.log(` Found ${subdomainLinks.length} subdomain links:`) + subdomainLinks.forEach(link => { + console.log(` "${link.text}"`) + console.log(` href attribute: ${link.href}`) + console.log(` actual href: ${link.actualHref}`) + const isTransformed = link.actualHref?.includes('.localhost') + console.log(` ✓ Transformed to subdomain: ${isTransformed}`) + }) + + // Test 3: Direct subdomain access + console.log('\n3. Testing direct subdomain access...') + await page.goto('http://app1.localhost:8085/') + await page.waitForTimeout(1000) + + const currentUrl = page.url() + const pageTitle = await page.textContent('h1') + console.log(` Current URL: ${currentUrl}`) + console.log(` Page title: ${pageTitle}`) + console.log(` ✓ Subdomain routing: ${currentUrl.includes('app1.localhost')}`) + + console.log('\n✅ All checks complete!') + console.log('\nYou can now hover over the links to see the subdomain URLs!') + console.log('Press Ctrl+C to exit...') + + // Keep browser open for manual inspection + await new Promise(resolve => setTimeout(resolve, 30000)) + + } finally { + await browser.close() + } +} + +testFinalCheck().catch(console.error) \ No newline at end of file diff --git a/test-link-navigation.mjs b/test-link-navigation.mjs new file mode 100644 index 000000000..fc8cf2588 --- /dev/null +++ b/test-link-navigation.mjs @@ -0,0 +1,90 @@ +import { chromium } from 'playwright' + +async function testLinkNavigation() { + console.log('Testing subdomain link navigation with port preservation...\n') + + const browser = await chromium.launch({ headless: false }) + const page = await browser.newPage() + + try { + // Test 1: Load test page + console.log('1. Loading test-rewrites page...') + await page.goto('http://localhost:8081/test-rewrites') + await page.waitForTimeout(2000) // Wait for hydration + + // Test 2: Check link href includes port + console.log('\n2. Checking subdomain links have correct port...') + const linkInfo = await page.evaluate(() => { + const link = document.querySelector('a[href*="subdomain"]') + if (!link) return null + return { + text: link.textContent, + href: link.getAttribute('href'), + actualHref: link.href, + includesPort: link.href.includes(':8081') + } + }) + + console.log(` Link text: "${linkInfo?.text?.trim()}"`) + console.log(` href attribute: ${linkInfo?.href}`) + console.log(` actual href (with port): ${linkInfo?.actualHref}`) + console.log(` ✓ Includes port :8081: ${linkInfo?.includesPort}`) + + if (!linkInfo?.includesPort) { + console.error(' ❌ ERROR: Port not preserved in subdomain URL!') + } + + // Test 3: Click the link and verify navigation + console.log('\n3. Clicking subdomain link...') + const beforeUrl = page.url() + console.log(` Before click: ${beforeUrl}`) + + // Click the first subdomain link + await page.click('a[href*="subdomain"]:first-of-type') + await page.waitForTimeout(2000) // Wait for navigation + + const afterUrl = page.url() + const pageContent = await page.textContent('h1') + console.log(` After click: ${afterUrl}`) + console.log(` Page H1: ${pageContent}`) + + // Verify we're on the subdomain URL with port + const isSubdomainUrl = afterUrl.includes('.localhost:8081') + console.log(` ✓ Navigated to subdomain URL: ${isSubdomainUrl}`) + + if (!isSubdomainUrl) { + console.error(' ❌ ERROR: Navigation failed - not on subdomain URL!') + } + + // Test 4: Navigate back and try another link + console.log('\n4. Testing navigation back...') + await page.goBack() + await page.waitForTimeout(1000) + + const backUrl = page.url() + console.log(` Back to: ${backUrl}`) + console.log(` ✓ Back navigation works: ${backUrl.includes('/test-rewrites')}`) + + // Test 5: Direct subdomain access with port + console.log('\n5. Testing direct subdomain access with port...') + await page.goto('http://app1.localhost:8081/') + await page.waitForTimeout(1000) + + const directUrl = page.url() + const directContent = await page.textContent('h1') + console.log(` Direct access URL: ${directUrl}`) + console.log(` Page content: ${directContent}`) + console.log(` ✓ Direct access works: ${directUrl.includes('app1.localhost:8081')}`) + + console.log('\n✅ All navigation tests complete!') + console.log('\nKeeping browser open for 10 seconds for manual inspection...') + await page.waitForTimeout(10000) + + } catch (error) { + console.error('Test failed:', error) + } finally { + await browser.close() + } +} + +testLinkNavigation().catch(console.error) \ No newline at end of file diff --git a/test-rewrite-debug.mjs b/test-rewrite-debug.mjs new file mode 100644 index 000000000..42453b385 --- /dev/null +++ b/test-rewrite-debug.mjs @@ -0,0 +1,112 @@ +import { chromium } from 'playwright' + +async function testRewriteFeature() { + console.log('Starting rewrite feature test...') + + const browser = await chromium.launch({ headless: false }) + const context = await browser.newContext() + const page = await context.newPage() + + // Enable console logging + page.on('console', msg => { + if (msg.type() === 'error' || msg.text().includes('Failed')) { + console.log('Console error:', msg.text()) + } + }) + + try { + // Test 1: Load test-rewrites page + console.log('\n1. Loading test-rewrites page...') + await page.goto('http://localhost:8083/test-rewrites') + await page.waitForTimeout(1000) + + // Check for errors in console + const pageContent = await page.content() + console.log(' Page loaded:', pageContent.includes('URL Rewriting Test Page')) + + // Test 2: Check Link hrefs + console.log('\n2. Checking Link hrefs...') + const links = await page.locator('a[href*="/subdomain/"]').all() + console.log(` Found ${links.length} subdomain links`) + + for (const link of links) { + const href = await link.getAttribute('href') + const text = await link.textContent() + console.log(` Link: "${text?.trim()}" -> href="${href}"`) + } + + // Test 3: Test subdomain access directly + console.log('\n3. Testing subdomain access...') + await page.goto('http://app1.localhost:8083/') + await page.waitForTimeout(1000) + + const currentUrl = page.url() + console.log(` After navigation, URL is: ${currentUrl}`) + + const h1Text = await page.textContent('h1') + console.log(` Page H1: ${h1Text}`) + + // Check if we got redirected incorrectly + if (!currentUrl.includes('app1.localhost')) { + console.log(' ERROR: Got redirected away from subdomain!') + } + + // Test 4: Check environment variable + console.log('\n4. Checking ONE_URL_REWRITES...') + const envCheck = await page.evaluate(() => { + const results = {} + try { + if (typeof process !== 'undefined' && process.env.ONE_URL_REWRITES) { + results.processEnv = process.env.ONE_URL_REWRITES + } + } catch (e) { + results.processError = e.message + } + + try { + // @ts-ignore + if (typeof import.meta !== 'undefined' && import.meta.env?.ONE_URL_REWRITES) { + // @ts-ignore + results.importMeta = import.meta.env.ONE_URL_REWRITES + } + } catch (e) { + results.importMetaError = e.message + } + + return results + }) + console.log(' Environment check:', JSON.stringify(envCheck, null, 2)) + + // Test 5: Check if rewrites are being applied + console.log('\n5. Testing rewrite function...') + const rewriteTest = await page.evaluate(() => { + // Import the rewrite functions + try { + // Try to access the rewrite config + const getConfig = () => { + if (typeof process !== 'undefined' && process.env.ONE_URL_REWRITES) { + try { + return JSON.parse(process.env.ONE_URL_REWRITES) + } catch { + return { error: 'Failed to parse process.env' } + } + } + return { error: 'No env var found' } + } + + return { + config: getConfig(), + hasReverseRewrite: typeof window.reverseRewrite === 'function', + } + } catch (e) { + return { error: e.message } + } + }) + console.log(' Rewrite test:', JSON.stringify(rewriteTest, null, 2)) + + } finally { + await browser.close() + } +} + +testRewriteFeature().catch(console.error) \ No newline at end of file diff --git a/tests/test/app/_middleware-rewrite.tsx b/tests/test/app/_middleware-rewrite.tsx new file mode 100644 index 000000000..da4cd1870 --- /dev/null +++ b/tests/test/app/_middleware-rewrite.tsx @@ -0,0 +1,61 @@ +import { createMiddleware } from 'one' + +/** + * Example middleware demonstrating URL rewriting capabilities for tests + * + * This middleware shows two main features: + * 1. Request rewriting - modifying the URL before it reaches the route handler + * 2. Response interception - returning a response directly from middleware + */ +export default createMiddleware(async ({ request, next }) => { + const url = new URL(request.url) + const host = request.headers.get('host') || '' + + // Example 1: Subdomain-based rewriting + // Handle *.localhost subdomains for local testing + if (host.includes('.localhost')) { + const subdomain = host.split('.')[0] + + // Rewrite subdomain.localhost/path to /server/subdomain/path + if (subdomain && subdomain !== 'www') { + const newUrl = new URL(request.url) + newUrl.pathname = `/server/${subdomain}${newUrl.pathname}` + + // Pass the modified request to the next middleware/handler + return next(new Request(newUrl, request)) + } + } + + // Example 2: Path-based rewriting + // Rewrite /api/v1/* to /api/v2/* + if (url.pathname.startsWith('/api/v1/')) { + const newUrl = new URL(request.url) + newUrl.pathname = url.pathname.replace('/api/v1/', '/api/v2/') + return next(new Request(newUrl, request)) + } + + // Example 3: Response interception + // Middleware can return a response directly + if (url.pathname === '/middleware-health') { + return new Response(JSON.stringify({ + status: 'healthy', + timestamp: Date.now(), + middleware: 'rewrite' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + } + + // Example 4: Testing rewrite with header + // For testing purposes, allow header-based rewrites + const rewriteTo = request.headers.get('x-rewrite-to') + if (rewriteTo) { + const newUrl = new URL(request.url) + newUrl.pathname = rewriteTo + return next(new Request(newUrl, request)) + } + + // Continue to next middleware/handler without modification + return next() +}) \ No newline at end of file diff --git a/tests/test/app/_middleware.tsx b/tests/test/app/_middleware.tsx index 3d98008be..b73a55228 100644 --- a/tests/test/app/_middleware.tsx +++ b/tests/test/app/_middleware.tsx @@ -5,9 +5,54 @@ import { readFile } from 'node:fs' console.info(readFile) export default createMiddleware(async ({ request, next }) => { + const url = new URL(request.url) + const host = request.headers.get('host') || '' + + // Existing test middleware functionality if (request.url.includes(`test-middleware`)) { return Response.json({ middleware: 'works' }) } + + // Subdomain-based rewriting for *.localhost + if (host.includes('.localhost')) { + const subdomain = host.split('.')[0] + + // Rewrite subdomain.localhost/path to /server/subdomain/path + if (subdomain && subdomain !== 'www') { + const newUrl = new URL(request.url) + newUrl.pathname = `/server/${subdomain}${newUrl.pathname}` + + // Pass the modified request to the next middleware/handler + return next(new Request(newUrl, request)) + } + } + + // Path-based rewriting: /api/v1/* to /api/v2/* + if (url.pathname.startsWith('/api/v1/')) { + const newUrl = new URL(request.url) + newUrl.pathname = url.pathname.replace('/api/v1/', '/api/v2/') + return next(new Request(newUrl, request)) + } + + // Response interception for middleware-health endpoint + if (url.pathname === '/middleware-health') { + return new Response(JSON.stringify({ + status: 'healthy', + timestamp: Date.now(), + middleware: 'rewrite' + }), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) + } + + // Testing rewrite with header + const rewriteTo = request.headers.get('x-rewrite-to') + if (rewriteTo) { + const newUrl = new URL(request.url) + newUrl.pathname = rewriteTo + return next(new Request(newUrl, request)) + } const response = await next() diff --git a/tests/test/app/actual-path.tsx b/tests/test/app/actual-path.tsx new file mode 100644 index 000000000..ce31e2f63 --- /dev/null +++ b/tests/test/app/actual-path.tsx @@ -0,0 +1,8 @@ +export default function ActualPath() { + return ( +
+

Actual Path Page

+

This page is at /actual-path and is used for testing request rewrites.

+
+ ) +} \ No newline at end of file diff --git a/tests/test/app/api/v2/users+api.ts b/tests/test/app/api/v2/users+api.ts new file mode 100644 index 000000000..c543d63c6 --- /dev/null +++ b/tests/test/app/api/v2/users+api.ts @@ -0,0 +1,18 @@ +export async function GET() { + return Response.json({ + version: 'v2', + users: [ + { id: 1, name: 'User 1' }, + { id: 2, name: 'User 2' } + ] + }) +} + +export async function POST(request: Request) { + const body = await request.json() + return Response.json({ + version: 'v2', + created: true, + data: body + }) +} \ No newline at end of file diff --git a/tests/test/app/page.tsx b/tests/test/app/page.tsx new file mode 100644 index 000000000..500306de1 --- /dev/null +++ b/tests/test/app/page.tsx @@ -0,0 +1,8 @@ +export default function Page() { + return ( +
+

Test Page

+

This is a generic test page.

+
+ ) +} \ No newline at end of file diff --git a/tests/test/app/rewritten.tsx b/tests/test/app/rewritten.tsx new file mode 100644 index 000000000..c0d153e2c --- /dev/null +++ b/tests/test/app/rewritten.tsx @@ -0,0 +1,8 @@ +export default function Rewritten() { + return ( +
+

Rewritten Page

+

This page is the result of a rewrite.

+
+ ) +} \ No newline at end of file diff --git a/tests/test/app/server/[subdomain]/index+spa.tsx b/tests/test/app/server/[subdomain]/index+spa.tsx new file mode 100644 index 000000000..7a9e6f350 --- /dev/null +++ b/tests/test/app/server/[subdomain]/index+spa.tsx @@ -0,0 +1,8 @@ +export default function SubdomainServerPage({ params }: { params: { subdomain: string } }) { + return ( +
+

Server: {params.subdomain}

+

This page is served from the rewritten path /server/{params.subdomain}

+
+ ) +} \ No newline at end of file diff --git a/tests/test/routes.d.ts b/tests/test/routes.d.ts index 96ed20f0a..e52782329 100644 --- a/tests/test/routes.d.ts +++ b/tests/test/routes.d.ts @@ -6,9 +6,9 @@ import type { OneRouter } from 'one' declare module 'one' { export namespace OneRouter { export interface __routes extends Record { - StaticRoutes: `/` | `/(auth-guard)` | `/(auth-guard)/auth-guard` | `/(blog)` | `/(blog)/blog/my-first-post` | `/(marketing)/about` | `/(sub-page-group)` | `/(sub-page-group)/sub-page` | `/(sub-page-group)/sub-page/sub` | `/(sub-page-group)/sub-page/sub2` | `/_sitemap` | `/about` | `/auth-guard` | `/blog/my-first-post` | `/expo-video` | `/hooks` | `/hooks/cases/navigating-into-nested-navigator` | `/hooks/cases/navigating-into-nested-navigator/nested-1` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2/page` | `/hooks/contents` | `/hooks/contents/page-1` | `/hooks/contents/page-2` | `/layouts` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]/` | `/loader` | `/loader/other` | `/middleware` | `/not-found/deep/test` | `/not-found/fallback/test` | `/not-found/test` | `/rn-features/platform-specific-extensions/test` | `/rn-features/platform-specific-extensions/test-route-1` | `/rn-features/platform-specific-extensions/test-route-2` | `/router/ignoredRouteFiles/route.normal` | `/server-data` | `/sheet` | `/spa/spapage` | `/ssr` | `/ssr/` | `/ssr/basic` | `/ssr/request-test` | `/sub-page` | `/sub-page/sub` | `/sub-page/sub2` | `/vite-features/import-meta-env` | `/web-extensions` - DynamicRoutes: `/dynamic-folder-routes/${OneRouter.SingleRoutePart}/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-nested-slug/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-nested-slug/${OneRouter.SingleRoutePart}/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-slug/${OneRouter.SingleRoutePart}` | `/layouts/nested-layout/with-slug-layout-folder/${OneRouter.SingleRoutePart}` | `/not-found/+not-found` | `/not-found/deep/+not-found` | `/router/ignoredRouteFiles/+not-found` | `/routes/subpath/${string}` | `/segments-stable-ids/${string}` | `/spa/${OneRouter.SingleRoutePart}` | `/ssr/${OneRouter.SingleRoutePart}` | `/ssr/${OneRouter.SingleRoutePart}/request-test` | `/ssr/${string}` - DynamicRouteTemplate: `/dynamic-folder-routes/[serverId]/[channelId]` | `/hooks/contents/with-nested-slug/[folderSlug]` | `/hooks/contents/with-nested-slug/[folderSlug]/[fileSlug]` | `/hooks/contents/with-slug/[slug]` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]` | `/not-found/+not-found` | `/not-found/deep/+not-found` | `/router/ignoredRouteFiles/+not-found` | `/routes/subpath/[...subpath]` | `/segments-stable-ids/[...segments]` | `/spa/[spaparams]` | `/ssr/[...rest]` | `/ssr/[id]/request-test` | `/ssr/[param]` + StaticRoutes: `/` | `/(auth-guard)` | `/(auth-guard)/auth-guard` | `/(blog)` | `/(blog)/blog/my-first-post` | `/(marketing)/about` | `/(sub-page-group)` | `/(sub-page-group)/sub-page` | `/(sub-page-group)/sub-page/sub` | `/(sub-page-group)/sub-page/sub2` | `/_sitemap` | `/about` | `/actual-path` | `/auth-guard` | `/blog/my-first-post` | `/expo-video` | `/hooks` | `/hooks/cases/navigating-into-nested-navigator` | `/hooks/cases/navigating-into-nested-navigator/nested-1` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2` | `/hooks/cases/navigating-into-nested-navigator/nested-1/nested-2/page` | `/hooks/contents` | `/hooks/contents/page-1` | `/hooks/contents/page-2` | `/layouts` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]/` | `/loader` | `/loader/other` | `/middleware` | `/not-found/deep/test` | `/not-found/fallback/test` | `/not-found/test` | `/rn-features/platform-specific-extensions/test` | `/rn-features/platform-specific-extensions/test-route-1` | `/rn-features/platform-specific-extensions/test-route-2` | `/router/ignoredRouteFiles/route.normal` | `/server-data` | `/sheet` | `/spa/spapage` | `/ssr` | `/ssr/` | `/ssr/basic` | `/ssr/request-test` | `/sub-page` | `/sub-page/sub` | `/sub-page/sub2` | `/vite-features/import-meta-env` | `/web-extensions` + DynamicRoutes: `/dynamic-folder-routes/${OneRouter.SingleRoutePart}/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-nested-slug/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-nested-slug/${OneRouter.SingleRoutePart}/${OneRouter.SingleRoutePart}` | `/hooks/contents/with-slug/${OneRouter.SingleRoutePart}` | `/layouts/nested-layout/with-slug-layout-folder/${OneRouter.SingleRoutePart}` | `/not-found/+not-found` | `/not-found/deep/+not-found` | `/router/ignoredRouteFiles/+not-found` | `/routes/subpath/${string}` | `/segments-stable-ids/${string}` | `/server/${OneRouter.SingleRoutePart}` | `/spa/${OneRouter.SingleRoutePart}` | `/ssr/${OneRouter.SingleRoutePart}` | `/ssr/${OneRouter.SingleRoutePart}/request-test` | `/ssr/${string}` + DynamicRouteTemplate: `/dynamic-folder-routes/[serverId]/[channelId]` | `/hooks/contents/with-nested-slug/[folderSlug]` | `/hooks/contents/with-nested-slug/[folderSlug]/[fileSlug]` | `/hooks/contents/with-slug/[slug]` | `/layouts/nested-layout/with-slug-layout-folder/[layoutSlug]` | `/not-found/+not-found` | `/not-found/deep/+not-found` | `/router/ignoredRouteFiles/+not-found` | `/routes/subpath/[...subpath]` | `/segments-stable-ids/[...segments]` | `/server/[subdomain]` | `/spa/[spaparams]` | `/ssr/[...rest]` | `/ssr/[id]/request-test` | `/ssr/[param]` IsTyped: true } } diff --git a/tests/test/tests/middleware-rewrite.test.ts b/tests/test/tests/middleware-rewrite.test.ts new file mode 100644 index 000000000..fe807de32 --- /dev/null +++ b/tests/test/tests/middleware-rewrite.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test } from 'vitest' + +// Helper function for testing +async function fetchWithHost(path: string, host: string) { + return await fetch(`${process.env.ONE_SERVER_URL}${path}`, { + headers: { host } + }) +} + +describe('Middleware Request Rewriting', () => { + test('middleware can modify request URL', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/test-path`, { + headers: { 'x-rewrite-to': '/actual-path' } + }) + + // The middleware should rewrite /test-path to /actual-path + // This assumes you have a route at /actual-path that returns specific content + const text = await res.text() + expect(res.ok).toBe(true) + }) + + test('middleware can pass modified request to next', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/original`, { + headers: { 'x-transform': 'true' } + }) + + // Test that the request was transformed + const data = await res.json() + expect(data).toBeDefined() + }) + + test('subdomain rewriting with .localhost', async () => { + // Test subdomain.localhost rewriting + const res = await fetchWithHost('/', 'tamagui.localhost') + + // Should be rewritten to /server/tamagui + const text = await res.text() + expect(res.ok).toBe(true) + }) + + test('multiple subdomains work independently', async () => { + const res1 = await fetchWithHost('/api', 'app1.localhost') + const res2 = await fetchWithHost('/api', 'app2.localhost') + + // Both should work but be routed to different paths + expect(res1.ok).toBe(true) + expect(res2.ok).toBe(true) + }) + + test('middleware can return Response directly', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/middleware-health`) + const data = await res.json() + + expect(res.ok).toBe(true) + expect(data).toEqual({ + status: 'healthy', + timestamp: expect.any(Number), + middleware: 'rewrite' + }) + }) + + test('middleware chain with multiple rewrites', async () => { + // If you have multiple middleware files, test that they chain correctly + const res = await fetch(`${process.env.ONE_SERVER_URL}/chain-test`, { + headers: { + 'x-chain': '1', + 'x-rewrite-to': '/final-destination' + } + }) + + expect(res.ok).toBe(true) + }) + + test('API path rewriting v1 to v2', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/api/v1/users`) + + // Should be rewritten to /api/v2/users + // Assuming you have a route at /api/v2/users + expect(res.ok).toBe(true) + }) + + test('preserves query parameters during rewrite', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/test?param1=value1¶m2=value2`, { + headers: { 'x-rewrite-to': '/rewritten' } + }) + + // The query parameters should be preserved + expect(res.ok).toBe(true) + }) + + test('preserves request method during rewrite', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/test`, { + method: 'POST', + headers: { + 'x-rewrite-to': '/api/endpoint', + 'content-type': 'application/json' + }, + body: JSON.stringify({ test: 'data' }) + }) + + // The POST method and body should be preserved + expect(res.ok).toBe(true) + }) + + test('www subdomain is not rewritten', async () => { + const res = await fetchWithHost('/page', 'www.localhost') + + // www should not trigger subdomain rewriting + // Should go to normal /page route + expect(res.ok).toBe(true) + }) +}) + +describe('Middleware Response Handling', () => { + test('middleware can intercept and return early', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/middleware-health`) + const data = await res.json() + + expect(data.middleware).toBe('rewrite') + expect(data.status).toBe('healthy') + }) + + test('middleware can set custom headers', async () => { + const res = await fetch(`${process.env.ONE_SERVER_URL}/any-path`) + + // Check if middleware added any custom headers + // This depends on your middleware implementation + const headers = res.headers + expect(headers).toBeDefined() + }) + + test('middleware respects response status codes', async () => { + // If middleware returns a 404 or other status + const res = await fetch(`${process.env.ONE_SERVER_URL}/non-existent-after-rewrite`, { + headers: { 'x-rewrite-to': '/definitely-not-a-route' } + }) + + expect(res.status).toBe(404) + }) +}) + +describe('Integration with existing middleware', () => { + test('rewrite middleware works with existing test middleware', async () => { + // Test that the new rewrite middleware doesn't break existing functionality + const res = await fetch(`${process.env.ONE_SERVER_URL}/middleware?test-middleware`) + const data = await res.json() + + expect(data).toMatchInlineSnapshot(` + { + "middleware": "works", + } + `) + }) + + test('rewrite happens before route matching', async () => { + // Test that rewrites happen early enough in the pipeline + const res = await fetchWithHost('/docs', 'api.localhost') + + // Should be rewritten to /server/api/docs + // and then matched against routes + expect(res.ok).toBe(true) + }) +}) \ No newline at end of file diff --git a/tests/test/tests/rewrite.test.ts b/tests/test/tests/rewrite.test.ts new file mode 100644 index 000000000..c854eb723 --- /dev/null +++ b/tests/test/tests/rewrite.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, test } from 'vitest' +import { + parseRewriteRule, + applyRewrites, + reverseRewrite, + getRewriteConfig +} from '../../../packages/one/src/utils/rewrite' + +describe('URL Rewrite Utilities', () => { + describe('parseRewriteRule', () => { + test('parses wildcard subdomain rule', () => { + const rule = parseRewriteRule('*.start.chat', '/server/*') + + expect(rule.isSubdomain).toBe(true) + expect(rule.pattern.test('tamagui.start.chat')).toBe(true) + expect(rule.pattern.test('vite.start.chat')).toBe(true) + expect(rule.pattern.test('start.chat')).toBe(false) + expect(rule.pattern.test('sub.domain.start.chat')).toBe(false) // Only single wildcard + + const match = rule.pattern.exec('tamagui.start.chat')! + expect(rule.target(match)).toBe('/server/tamagui') + }) + + test('parses exact subdomain rule', () => { + const rule = parseRewriteRule('admin.app.com', '/admin') + + expect(rule.isSubdomain).toBe(true) + expect(rule.pattern.test('admin.app.com')).toBe(true) + expect(rule.pattern.test('user.app.com')).toBe(false) + expect(rule.pattern.test('app.com')).toBe(false) + }) + + test('parses path wildcard rule', () => { + const rule = parseRewriteRule('/api/*', '/v2/api/*') + + expect(rule.isSubdomain).toBe(false) + expect(rule.pattern.test('/api/users')).toBe(true) + expect(rule.pattern.test('/api/posts')).toBe(true) + expect(rule.pattern.test('/other')).toBe(false) + + const match = rule.pattern.exec('/api/users')! + expect(rule.target(match)).toBe('/v2/api/users') + }) + + test('handles multiple wildcards', () => { + const rule = parseRewriteRule('*.*.example.com', '/multi/*/*') + + expect(rule.pattern.test('sub.domain.example.com')).toBe(true) + expect(rule.pattern.test('a.b.example.com')).toBe(true) + expect(rule.pattern.test('domain.example.com')).toBe(false) + + const match = rule.pattern.exec('api.v2.example.com')! + expect(rule.target(match)).toBe('/multi/api/v2') + }) + + test('escapes special regex characters', () => { + const rule = parseRewriteRule('api.app.com', '/api') + + // The dot should be escaped, not a wildcard + expect(rule.pattern.test('api.app.com')).toBe(true) + expect(rule.pattern.test('apiXapp.com')).toBe(false) // X should not match escaped dot + }) + }) + + describe('applyRewrites', () => { + const rewrites = { + '*.start.chat': '/server/*', + 'admin.app.com': '/admin', + '*.api.app.com': '/api/v2/*', + '/old/*': '/new/*' + } + + test('rewrites subdomain to path', () => { + const url = new URL('https://tamagui.start.chat/docs') + const result = applyRewrites(url, rewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/server/tamagui/docs') + expect(result?.hostname).toBe('tamagui.start.chat') + }) + + test('handles exact subdomain match', () => { + const url = new URL('https://admin.app.com/users') + const result = applyRewrites(url, rewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/admin/users') + }) + + test('handles nested subdomain wildcards', () => { + const url = new URL('https://v3.api.app.com/users') + const result = applyRewrites(url, rewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/api/v2/v3/users') + }) + + test('preserves query parameters', () => { + const url = new URL('https://tamagui.start.chat/search?q=test&page=2') + const result = applyRewrites(url, rewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/server/tamagui/search') + expect(result?.search).toBe('?q=test&page=2') + }) + + test('preserves hash fragments', () => { + const url = new URL('https://tamagui.start.chat/docs#section') + const result = applyRewrites(url, rewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/server/tamagui/docs') + expect(result?.hash).toBe('#section') + }) + + test('handles path rewrites', () => { + const url = new URL('https://example.com/old/path/to/resource') + const result = applyRewrites(url, rewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/new/path/to/resource') + }) + + test('returns null for non-matching URLs', () => { + const url = new URL('https://other.com/path') + const result = applyRewrites(url, rewrites) + + expect(result).toBeNull() + }) + + test('handles localhost subdomains', () => { + const localhostRewrites = { + '*.localhost': '/server/*' + } + + const url = new URL('http://tamagui.localhost:3000/docs') + const result = applyRewrites(url, localhostRewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/server/tamagui/docs') + expect(result?.port).toBe('3000') + }) + + test('applies first matching rule only', () => { + const priorityRewrites = { + '*.start.chat': '/priority/*', + 'tamagui.start.chat': '/specific' + } + + const url = new URL('https://tamagui.start.chat/docs') + const result = applyRewrites(url, priorityRewrites) + + expect(result).not.toBeNull() + expect(result?.pathname).toBe('/priority/tamagui/docs') + }) + }) + + describe('reverseRewrite', () => { + const rewrites = { + '*.start.chat': '/server/*', + 'admin.app.com': '/admin', + '/old/*': '/new/*' + } + + test('converts internal path to subdomain URL', () => { + const result = reverseRewrite('/server/tamagui/docs', rewrites) + expect(result).toBe('https://tamagui.start.chat/docs') + }) + + test('handles exact path matches', () => { + const result = reverseRewrite('/admin/users', rewrites) + expect(result).toBe('https://admin.app.com/users') + }) + + test('handles path rewrites', () => { + const result = reverseRewrite('/new/resource', rewrites) + expect(result).toBe('/old/resource') + }) + + test('returns original path if no match', () => { + const result = reverseRewrite('/other/path', rewrites) + expect(result).toBe('/other/path') + }) + + test('preserves trailing slashes', () => { + const result = reverseRewrite('/server/tamagui/', rewrites) + expect(result).toBe('https://tamagui.start.chat/') + }) + + test('handles root paths', () => { + const result = reverseRewrite('/server/tamagui', rewrites) + expect(result).toBe('https://tamagui.start.chat') + }) + + test('handles complex nested paths', () => { + const result = reverseRewrite('/server/vite/api/v2/users/123', rewrites) + expect(result).toBe('https://vite.start.chat/api/v2/users/123') + }) + + test('uses http protocol in non-browser environment', () => { + // In test environment, window is undefined + const result = reverseRewrite('/server/test', rewrites) + expect(result).toBe('https://test.start.chat') + }) + + test('handles localhost rewrites', () => { + const localhostRewrites = { + '*.localhost': '/server/*' + } + + const result = reverseRewrite('/server/app/page', localhostRewrites) + expect(result).toBe('https://app.localhost/page') + }) + }) + + describe('getRewriteConfig', () => { + test('returns empty object when no config is set', () => { + const originalEnv = process.env.ONE_URL_REWRITES + delete process.env.ONE_URL_REWRITES + + const config = getRewriteConfig() + expect(config).toEqual({}) + + // Restore + if (originalEnv) process.env.ONE_URL_REWRITES = originalEnv + }) + + test('parses config from process.env', () => { + const originalEnv = process.env.ONE_URL_REWRITES + process.env.ONE_URL_REWRITES = JSON.stringify({ + '*.test.com': '/test/*' + }) + + const config = getRewriteConfig() + expect(config).toEqual({ + '*.test.com': '/test/*' + }) + + // Restore + if (originalEnv) { + process.env.ONE_URL_REWRITES = originalEnv + } else { + delete process.env.ONE_URL_REWRITES + } + }) + + test('handles invalid JSON gracefully', () => { + const originalEnv = process.env.ONE_URL_REWRITES + process.env.ONE_URL_REWRITES = 'invalid json' + + const config = getRewriteConfig() + expect(config).toEqual({}) + + // Restore + if (originalEnv) { + process.env.ONE_URL_REWRITES = originalEnv + } else { + delete process.env.ONE_URL_REWRITES + } + }) + }) + + describe('Complex rewrite scenarios', () => { + test('handles multiple wildcard segments correctly', () => { + const rewrites = { + '*.*.cdn.com': '/cdn/*/*' + } + + const url = new URL('https://images.user.cdn.com/avatar.jpg') + const result = applyRewrites(url, rewrites) + + expect(result?.pathname).toBe('/cdn/images/user/avatar.jpg') + + const reversed = reverseRewrite('/cdn/images/user/avatar.jpg', rewrites) + expect(reversed).toBe('https://images.user.cdn.com/avatar.jpg') + }) + + test('handles port numbers correctly', () => { + const rewrites = { + '*.localhost': '/dev/*' + } + + const url = new URL('http://app.localhost:3000/page') + const result = applyRewrites(url, rewrites) + + expect(result?.pathname).toBe('/dev/app/page') + expect(result?.port).toBe('3000') + }) + + test('handles empty paths correctly', () => { + const rewrites = { + '*.app.com': '/apps/*' + } + + const url = new URL('https://dashboard.app.com') + const result = applyRewrites(url, rewrites) + + expect(result?.pathname).toBe('/apps/dashboard') + + const reversed = reverseRewrite('/apps/dashboard', rewrites) + expect(reversed).toBe('https://dashboard.app.com') + }) + }) +}) \ No newline at end of file