diff --git a/examples/tanstack-db-web-starter/src/lib/auth.ts b/examples/tanstack-db-web-starter/src/lib/auth.ts index dcd0339d2d..4a7e5a7794 100644 --- a/examples/tanstack-db-web-starter/src/lib/auth.ts +++ b/examples/tanstack-db-web-starter/src/lib/auth.ts @@ -2,23 +2,6 @@ import { betterAuth } from "better-auth" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { db } from "@/db/connection" // your drizzle instance import * as schema from "@/db/auth-schema" -import { networkInterfaces } from "os" - -// Get network IP for trusted origins -const nets = networkInterfaces() -let networkIP = "192.168.1.1" // fallback - -for (const name of Object.keys(nets)) { - const netInterfaces = nets[name] - if (netInterfaces) { - for (const net of netInterfaces) { - if (net.family === "IPv4" && !net.internal) { - networkIP = net.address - break - } - } - } -} export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -34,8 +17,6 @@ export const auth = betterAuth({ minPasswordLength: process.env.NODE_ENV === "production" ? 8 : 1, }, trustedOrigins: [ - "https://tanstack-start-db-electric-starter.localhost", - `https://${networkIP}`, - "http://localhost:5173", // fallback for direct Vite access + "http://localhost:5173" ], }) diff --git a/examples/tanstack-db-web-starter/src/lib/collections.ts b/examples/tanstack-db-web-starter/src/lib/collections.ts index 5830c22dd6..4b0c60bbf1 100644 --- a/examples/tanstack-db-web-starter/src/lib/collections.ts +++ b/examples/tanstack-db-web-starter/src/lib/collections.ts @@ -6,22 +6,26 @@ import { selectUsersSchema, } from "@/db/schema" import { trpc } from "@/lib/trpc-client" +import { shardedFetchClient } from "@/lib/localhost-port-sharding" + +const domain = + typeof window !== `undefined` + ? window.location.origin + : `http://localhost:5173` + +const parser = { + timestamptz: (date: string) => { + return new Date(date) + } +} export const usersCollection = createCollection( electricCollectionOptions({ id: "users", shapeOptions: { - url: new URL( - `/api/users`, - typeof window !== `undefined` - ? window.location.origin - : `http://localhost:5173` - ).toString(), - parser: { - timestamptz: (date: string) => { - return new Date(date) - }, - }, + url: new URL(`/api/users`, domain).toString(), + fetchClient: shardedFetchClient(), + parser }, schema: selectUsersSchema, getKey: (item) => item.id, @@ -31,17 +35,9 @@ export const projectCollection = createCollection( electricCollectionOptions({ id: "projects", shapeOptions: { - url: new URL( - `/api/projects`, - typeof window !== `undefined` - ? window.location.origin - : `http://localhost:5173` - ).toString(), - parser: { - timestamptz: (date: string) => { - return new Date(date) - }, - }, + url: new URL(`/api/projects`, domain).toString(), + fetchClient: shardedFetchClient(), + parser }, schema: selectProjectSchema, getKey: (item) => item.id, @@ -84,18 +80,9 @@ export const todoCollection = createCollection( electricCollectionOptions({ id: "todos", shapeOptions: { - url: new URL( - `/api/todos`, - typeof window !== `undefined` - ? window.location.origin - : `http://localhost:5173` - ).toString(), - parser: { - // Parse timestamp columns into JavaScript Date objects - timestamptz: (date: string) => { - return new Date(date) - }, - }, + url: new URL(`/api/todos`, domain).toString(), + fetchClient: shardedFetchClient(), + parser }, schema: selectTodoSchema, getKey: (item) => item.id, diff --git a/examples/tanstack-db-web-starter/src/lib/localhost-port-sharding.ts b/examples/tanstack-db-web-starter/src/lib/localhost-port-sharding.ts new file mode 100644 index 0000000000..fb02fea13c --- /dev/null +++ b/examples/tanstack-db-web-starter/src/lib/localhost-port-sharding.ts @@ -0,0 +1,217 @@ +/** + * Localhost port sharding utilities. + * + * Distributes requests across multiple localhost ports in a round-robin fashion + * to work around HTTP/1's 6 concurrent connection limit per domain. + * + * Only used / useful in local development. + */ + +// Client-side utilities + +const shardPorts = typeof __ELECTRIC_SHARD_PORTS__ !== 'undefined' + ? __ELECTRIC_SHARD_PORTS__ + : [] + +let nextShardIndex = 0 + +/** + * Creates a fetch client that applies port sharding and includes credentials. + * Each call to this function assigns the next available shard port in round-robin fashion. + * All requests made by the returned fetch client will use the same shard port. + * + * @returns A fetch-compatible function pinned to a specific shard port + */ +export function shardedFetchClient() { + const shardPort = shardPorts.length > 0 + ? shardPorts[nextShardIndex++ % shardPorts.length] + : null + + return (input: RequestInfo | URL, init?: RequestInit) => { + let url = input.toString() + + if (shardPort !== null) { + const urlObj = new URL(url) + urlObj.port = String(shardPort) + url = urlObj.toString() + } + + return fetch(url, { + ...init, + credentials: 'include' + }) + } +} + +// Vite plugin utilities (server-side only) + +// @ts-ignore - http module only available server-side +import http from "http" + +if (typeof global !== 'undefined' && !global.__viteMultiPortServers) { + global.__viteMultiPortServers = new Map() +} + +/** + * Creates a Vite configuration for port sharding in development. + * + * Port sharding works around HTTP/1.1's 6 concurrent connection limit per domain + * by serving requests across multiple localhost ports. This prevents request queuing + * that would otherwise block Electric shapes during local development. + * + * @param mainPort - The main Vite dev server port (e.g., 5173) + * @param numShards - Number of additional shard ports to create + * @param mode - Vite build mode ('production', 'development', etc.) + * @returns Configuration object with mainPort, portPlugins, and definePorts + * + * @example + * const { mainPort, portPlugins, definePorts } = shardLocalPorts(5173, 25, mode) + * // Creates ports: 51730, 51731, ..., 51754 (5173 * 10 + 0..24) + */ +export default function shardLocalPorts(mainPort, numShards, mode) { + const shardPorts = mode !== 'production' + ? Array.from({ length: numShards }, (_, i) => mainPort * 10 + i) + : [] + + const allowedOrigin = `http://localhost:${mainPort}` + + const portPlugins = shardPorts.length > 0 ? [createShardPlugin(shardPorts, allowedOrigin)] : [] + + const definePorts = mode !== 'production' + ? { '__ELECTRIC_SHARD_PORTS__': JSON.stringify(shardPorts) } + : { '__ELECTRIC_SHARD_PORTS__': 'undefined' } + + return { + mainPort, + portPlugins, + definePorts, + } +} + +/** + * Creates the Vite plugin that spawns additional HTTP servers on shard ports. + */ +function createShardPlugin(shardPorts, allowedOrigin) { + return { + name: "vite-shard-local-ports", + configureServer(server) { + return () => { + cleanupExistingServers() + startShardServers(shardPorts, allowedOrigin, server) + setupCleanupOnServerClose(server) + } + }, + } +} + +/** + * Closes any servers from previous HMR reloads to free up ports. + */ +function cleanupExistingServers() { + if (typeof global === 'undefined') return + + for (const [port, srv] of global.__viteMultiPortServers.entries()) { + try { + if (srv && srv.listening) { + srv.close() + } + } catch (e) { + } + } + global.__viteMultiPortServers.clear() +} + +/** + * Starts HTTP servers on each shard port, using Vite's middleware stack. + * Small delay allows ports to be released from cleanup. + */ +function startShardServers(shardPorts, allowedOrigin, server) { + if (typeof global === 'undefined') return + + setTimeout(() => { + shardPorts.forEach((port) => { + const shardServer = createShardServer(allowedOrigin, server) + + shardServer.on("error", (err) => { + if (err.code === "EADDRINUSE") { + console.error(`❌ Port ${port} is already in use`) + process.exit(1) + } else { + throw err + } + }) + + shardServer.listen(port, () => {}) + global.__viteMultiPortServers.set(port, shardServer) + }) + }, 100) +} + +/** + * Creates an HTTP server that proxies to Vite's middleware with CORS overrides. + * + * The server intercepts CORS headers to replace Vite's default wildcard ('*') + * with a specific origin, which is required when credentials are included in requests. + */ +function createShardServer(allowedOrigin, server) { + return http.createServer((req, res) => { + if (req.method === 'OPTIONS') { + handlePreflightRequest(res, allowedOrigin) + return + } + + overrideCorsHeaders(res, allowedOrigin) + server.middlewares(req, res) + }) +} + +/** + * Handles CORS preflight (OPTIONS) requests. + */ +function handlePreflightRequest(res, allowedOrigin) { + res.setHeader('Access-Control-Allow-Origin', allowedOrigin) + res.setHeader('Access-Control-Allow-Credentials', 'true') + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS') + res.setHeader('Access-Control-Allow-Headers', 'Content-Type') + res.writeHead(204) + res.end() +} + +/** + * Overrides the response's setHeader method to intercept CORS headers. + * + * Vite's default middleware sets 'Access-Control-Allow-Origin: *', which + * conflicts with credentialed requests. This intercepts that header and + * replaces it with the specific allowed origin. + */ +function overrideCorsHeaders(res, allowedOrigin) { + const originalSetHeader = res.setHeader.bind(res) + + res.setHeader = function(name, value) { + if (name.toLowerCase() === 'access-control-allow-origin') { + return originalSetHeader('Access-Control-Allow-Origin', allowedOrigin) + } + return originalSetHeader(name, value) + } + + originalSetHeader('Access-Control-Allow-Credentials', 'true') +} + +/** + * Registers cleanup handler to close shard servers when main Vite server closes. + */ +function setupCleanupOnServerClose(server) { + if (typeof global === 'undefined') return + + server.httpServer?.once("close", () => { + global.__viteMultiPortServers.forEach((s) => { + try { + if (s.listening) { + s.close() + } + } catch (e) { + } + }) + global.__viteMultiPortServers.clear() + }) +} diff --git a/examples/tanstack-db-web-starter/src/vite-env.d.ts b/examples/tanstack-db-web-starter/src/vite-env.d.ts new file mode 100644 index 0000000000..cb038c13c5 --- /dev/null +++ b/examples/tanstack-db-web-starter/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __ELECTRIC_SHARD_PORTS__: number[] | undefined diff --git a/examples/tanstack-db-web-starter/vite.config.ts b/examples/tanstack-db-web-starter/vite.config.ts index 93f4d84236..83a6ae78f0 100644 --- a/examples/tanstack-db-web-starter/vite.config.ts +++ b/examples/tanstack-db-web-starter/vite.config.ts @@ -3,37 +3,43 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite" import viteReact from "@vitejs/plugin-react" import viteTsConfigPaths from "vite-tsconfig-paths" import tailwindcss from "@tailwindcss/vite" -import { caddyPlugin } from "./src/vite-plugin-caddy" +import shardLocalPorts from "./src/lib/localhost-port-sharding" -const config = defineConfig({ - server: { - host: true, - }, - plugins: [ - // this is the plugin that enables path aliases - viteTsConfigPaths({ - projects: [`./tsconfig.json`], - }), - // Local HTTPS with Caddy - caddyPlugin(), - tailwindcss(), - // TanStack Start must come before viteReact - tanstackStart({ - srcDirectory: 'src', - start: { entry: './start.tsx' }, - server: { entry: './server.ts' }, - router: { +const config = defineConfig(({ mode }) => { + const { mainPort, portPlugins, definePorts } = shardLocalPorts(5173, 30, mode) + + return { + define: definePorts, + server: { + port: mainPort, + strictPort: true, + host: 'localhost', + }, + plugins: [ + // this is the plugin that enables path aliases + viteTsConfigPaths({ + projects: [`./tsconfig.json`], + }), + tailwindcss(), + // TanStack Start must come before viteReact + tanstackStart({ srcDirectory: 'src', - }, - spa: { - enabled: true, - }, - }), - viteReact(), - ], - ssr: { - noExternal: ["zod"], - }, + start: { entry: './start.tsx' }, + server: { entry: './server.ts' }, + router: { + srcDirectory: 'src', + }, + spa: { + enabled: true, + }, + }), + viteReact(), + ...portPlugins + ], + ssr: { + noExternal: ["zod"], + } + } }) export default config