diff --git a/README.md b/README.md index f72ddff9..655b4026 100644 --- a/README.md +++ b/README.md @@ -1089,7 +1089,7 @@ Do not import or use `router` inside `.loader()`, `.get()`, `.post()`, or `.rout Using `router.href()` for links inside `.page()` and `.layout()` JSX is okay in simple app entries because their rendered JSX does not feed app route metadata the same way. If a loader-heavy app still hits a circular `typeof app` error, move the link UI into a component module until the router type is split from loader data. -Context `redirect()` intentionally accepts a plain `string`. Do not pass `router.href()` into redirects inside app-entry handlers. Redirect return values participate in handler return inference and can reintroduce the circular type path in loader-heavy apps. +Context `redirect()` intentionally accepts a plain `string`. Do not pass `router.href()` into redirects inside app-entry handlers (`.page()`, `.layout()`, etc.) — redirect return values participate in handler return inference and can reintroduce the circular type path in loader-heavy apps. Standalone `"use server"` action files (separate from the app entry) are safe to use `router.href()` since they do not feed return types back into `typeof app`. @@ -1968,7 +1968,32 @@ function DeleteButton({ id }: { id: string }) { ### Redirecting After Actions -When a server action needs to navigate to a different page (e.g. after creating a resource), use the handler context `redirect` inside the action instead of `router.push()` on the client. Since every server action triggers a page re-render, calling `router.push()` after the action would briefly flash the re-rendered current page before navigating away. +When a server action needs to navigate to a different page (e.g. after creating a resource), use `redirect` inside the action instead of `router.push()` on the client. Since every server action triggers a page re-render, calling `router.push()` after the action would briefly flash the re-rendered current page before navigating away. + +In standalone `"use server"` action files, always wrap the redirect target with `router.href()` for type safety — TypeScript will catch invalid paths and missing params at compile time: + +```tsx +// src/actions.ts +'use server' + +import { redirect } from 'spiceflow' +import { router } from 'spiceflow/react' +import { parseFormData } from 'spiceflow' +import type { z } from 'zod' +import { projectSchema } from './schemas.ts' + +export async function createProject(formData: FormData) { + const { name } = parseFormData(projectSchema, formData) + const project = await db.projects.create({ name }) + // router.href validates the path and params against the route table at compile time + throw redirect(router.href('/orgs/:orgId/projects/:projectId', { + orgId: project.orgId, + projectId: project.id, + })) +} +``` + +For inline actions defined directly inside a `.page()` or `.layout()` handler (in the same file as `export const app`), use the handler context `redirect` with a plain string or the `params` option instead. The `router.href()` type reads from `typeof app`, which can create a circular TypeScript error when used inside an app-entry handler: ```tsx import { Spiceflow, parseFormData } from 'spiceflow' @@ -1987,6 +2012,7 @@ export const app = new Spiceflow() 'use server' const { name } = parseFormData(projectSchema, formData) const project = await db.projects.create({ name, orgId: params.orgId }) + // Use plain string redirect inside app-entry inline actions to avoid circular types throw redirect('/orgs/:orgId/projects/:projectId', { params: { orgId: params.orgId, projectId: project.id }, }) diff --git a/example-better-auth/src/actions.ts b/example-better-auth/src/actions.ts index b5ca984b..d325b343 100644 --- a/example-better-auth/src/actions.ts +++ b/example-better-auth/src/actions.ts @@ -3,6 +3,7 @@ // the bearer token from the request headers, then validates the session via // better-auth. Actions that modify data check auth before proceeding. import { getActionRequest, redirect } from 'spiceflow' +import { router } from 'spiceflow/react' import { eq } from 'drizzle-orm' import { auth, db } from './auth.js' import * as schema from './schema.js' @@ -32,7 +33,7 @@ export async function getCurrentUser() { export async function requireAuthOrRedirect() { const session = await getSession() - if (!session) throw redirect('/login') + if (!session) throw redirect(router.href('/login')) return { userId: session.user.id } } @@ -46,7 +47,7 @@ export async function createOrg(name: string) { ownerId: session.user.id, createdAt: new Date(), }) - throw redirect(`/orgs/${id}/dashboard`) + throw redirect(router.href('/orgs/:orgId/dashboard', { orgId: id })) } export async function createProject(orgId: string, name: string) { @@ -78,5 +79,5 @@ export async function deleteProject(orgId: string, projectId: string) { }) if (!project) throw new Error('not found') await db.delete(schema.project).where(eq(schema.project.id, projectId)) - throw redirect(`/orgs/${orgId}/dashboard`) + throw redirect(router.href('/orgs/:orgId/dashboard', { orgId })) } diff --git a/example-forms/src/actions.ts b/example-forms/src/actions.ts index 8cfe3ae6..967f2932 100644 --- a/example-forms/src/actions.ts +++ b/example-forms/src/actions.ts @@ -4,6 +4,7 @@ 'use server' import { redirect } from 'spiceflow' +import { router } from 'spiceflow/react' import type { z } from 'zod' import type { contactSchema, projectSchema, feedbackSchema } from './schemas.ts' @@ -15,7 +16,7 @@ export async function createContact(data: z.infer) { export async function createProject(data: z.infer) { console.log('Creating project:', data) const id = crypto.randomUUID().slice(0, 8) - throw redirect(`/success?name=${encodeURIComponent(data.name)}&id=${id}`) + throw redirect(router.href('/success', { name: data.name, id })) } export async function submitFeedback(data: z.infer) { diff --git a/example-stripe/.gitignore b/example-stripe/.gitignore new file mode 100644 index 00000000..ffae71e5 --- /dev/null +++ b/example-stripe/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.sqlite diff --git a/example-stripe/drizzle.config.ts b/example-stripe/drizzle.config.ts new file mode 100644 index 00000000..e1691c56 --- /dev/null +++ b/example-stripe/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + schema: './src/schema.ts', + out: './drizzle', + dialect: 'sqlite', +}) diff --git a/example-stripe/drizzle/20260508144813_initial/migration.sql b/example-stripe/drizzle/20260508144813_initial/migration.sql new file mode 100644 index 00000000..607a557f --- /dev/null +++ b/example-stripe/drizzle/20260508144813_initial/migration.sql @@ -0,0 +1,21 @@ +CREATE TABLE `org` ( + `org_id` text PRIMARY KEY, + `name` text NOT NULL, + `email` text NOT NULL, + `stripe_customer_id` text, + `created_at` integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE `subscription` ( + `subscription_id` text NOT NULL, + `variant_id` text NOT NULL, + `product_id` text NOT NULL, + `customer_id` text, + `org_id` text NOT NULL, + `status` text NOT NULL, + `created_at` integer NOT NULL, + CONSTRAINT `subscription_pk` PRIMARY KEY(`subscription_id`, `variant_id`), + CONSTRAINT `fk_subscription_org_id_org_org_id_fk` FOREIGN KEY (`org_id`) REFERENCES `org`(`org_id`) ON DELETE CASCADE +); +--> statement-breakpoint +CREATE INDEX `subscription_org_id_idx` ON `subscription` (`org_id`); \ No newline at end of file diff --git a/example-stripe/drizzle/20260508144813_initial/snapshot.json b/example-stripe/drizzle/20260508144813_initial/snapshot.json new file mode 100644 index 00000000..9d82d85b --- /dev/null +++ b/example-stripe/drizzle/20260508144813_initial/snapshot.json @@ -0,0 +1,187 @@ +{ + "version": "7", + "dialect": "sqlite", + "id": "10333871-613a-4caa-be29-7d4fa11ba767", + "prevIds": [ + "00000000-0000-0000-0000-000000000000" + ], + "ddl": [ + { + "name": "org", + "entityType": "tables" + }, + { + "name": "subscription", + "entityType": "tables" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "org_id", + "entityType": "columns", + "table": "org" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "name", + "entityType": "columns", + "table": "org" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "email", + "entityType": "columns", + "table": "org" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "stripe_customer_id", + "entityType": "columns", + "table": "org" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "org" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "subscription_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "variant_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "product_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "text", + "notNull": false, + "autoincrement": false, + "default": null, + "generated": null, + "name": "customer_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "org_id", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "text", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "status", + "entityType": "columns", + "table": "subscription" + }, + { + "type": "integer", + "notNull": true, + "autoincrement": false, + "default": null, + "generated": null, + "name": "created_at", + "entityType": "columns", + "table": "subscription" + }, + { + "columns": [ + "org_id" + ], + "tableTo": "org", + "columnsTo": [ + "org_id" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "nameExplicit": false, + "name": "fk_subscription_org_id_org_org_id_fk", + "entityType": "fks", + "table": "subscription" + }, + { + "columns": [ + "subscription_id", + "variant_id" + ], + "nameExplicit": false, + "name": "subscription_pk", + "entityType": "pks", + "table": "subscription" + }, + { + "columns": [ + "org_id" + ], + "nameExplicit": false, + "name": "org_pk", + "table": "org", + "entityType": "pks" + }, + { + "columns": [ + { + "value": "org_id", + "isExpression": false + } + ], + "isUnique": false, + "where": null, + "origin": "manual", + "name": "subscription_org_id_idx", + "entityType": "indexes", + "table": "subscription" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/example-stripe/package.json b/example-stripe/package.json new file mode 100644 index 00000000..6d3cd545 --- /dev/null +++ b/example-stripe/package.json @@ -0,0 +1,31 @@ +{ + "name": "example-stripe", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node dist/rsc/index.js", + "test": "vitest", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push" + }, + "dependencies": { + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "drizzle-orm": "beta", + "react": "19.2.4", + "react-dom": "19.2.4", + "spiceflow": "workspace:^", + "stripe": "^18.1.0", + "typescript": "5.7.3", + "ulid": "^2.3.0", + "vite": "^8.0.8" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.1", + "drizzle-kit": "beta", + "emulate": "^0.5.0", + "vitest": "^4.1.5" + } +} diff --git a/example-stripe/src/apply-migrations.ts b/example-stripe/src/apply-migrations.ts new file mode 100644 index 00000000..2ee28792 --- /dev/null +++ b/example-stripe/src/apply-migrations.ts @@ -0,0 +1,13 @@ +// Vitest setup file: applies drizzle migrations to the in-memory SQLite +// database before tests run. DB_PATH is not set in test env so we use +// the default in-memory database created by db.ts. +import { drizzle } from 'drizzle-orm/node-sqlite' +import { migrate } from 'drizzle-orm/node-sqlite/migrator' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { database } from './db.js' + +const migrationsDb = drizzle({ client: database }) +migrate(migrationsDb, { + migrationsFolder: join(dirname(fileURLToPath(import.meta.url)), '../drizzle'), +}) diff --git a/example-stripe/src/db.ts b/example-stripe/src/db.ts new file mode 100644 index 00000000..72057f3c --- /dev/null +++ b/example-stripe/src/db.ts @@ -0,0 +1,9 @@ +// Drizzle client for SQLite. Reads DB_PATH env var, defaults to +// 'stripe-example.sqlite'. Tests set DB_PATH=':memory:' via vitest env. +import { drizzle } from 'drizzle-orm/node-sqlite' +import { DatabaseSync } from 'node:sqlite' +import * as schema from './schema.js' + +const dbPath = process.env.DB_PATH || 'stripe-example.sqlite' +export const database = new DatabaseSync(dbPath) +export const db = drizzle({ client: database, schema, relations: schema.relations }) diff --git a/example-stripe/src/main.test.ts b/example-stripe/src/main.test.ts new file mode 100644 index 00000000..a1185416 --- /dev/null +++ b/example-stripe/src/main.test.ts @@ -0,0 +1,217 @@ +// End-to-end test for the Stripe subscription flow using the emulate package. +// +// The test boots a real HTTP server (app.listen(0)) so the Stripe emulator +// can deliver webhooks back to the app. Both the app and the emulator bind +// to ephemeral ports so tests can run in parallel without conflicts. +// +// Flow tested: +// 1. Create an org in the DB +// 2. POST /api/checkout → Stripe SDK creates customer + checkout session +// 3. POST /checkout//complete → emulator fires webhook +// 4. Poll GET /api/org/:orgId → verify subscription row in DB +// 5. Render /dashboard/:orgId → verify page shows active subscription +// 6. POST /api/portal → verify billing portal request (emulator may not support it) +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { createEmulator, type Emulator } from 'emulate' +import { createSpiceflowFetch } from 'spiceflow/client' +import { SpiceflowTestResponse } from 'spiceflow/testing' +import { app } from './main.js' +import { db } from './db.js' +import * as schema from './schema.js' +import { WEBHOOK_SECRET } from './stripe.js' + +let stripeEmu: Emulator +let server: { port: number | undefined; stop: () => Promise } +let appPort: number +let emuUrl: string + +let orgId: string + +beforeAll(async () => { + // 1. Start the spiceflow app on an ephemeral port + server = await app.listen(0) + appPort = server.port! + + // 2. Start the Stripe emulator with seeded product, price, and webhook. + // Use 127.0.0.1 (not localhost) for the webhook URL so the emulator + // always connects over IPv4. On Linux + Node 18+, localhost can resolve + // to ::1 (IPv6) while the server might only listen on IPv4. + // Use a random port to avoid conflicts when CI runs multiple jobs. + const emuPort = 14000 + Math.floor(Math.random() * 1000) + stripeEmu = await createEmulator({ + service: 'stripe', + port: emuPort, + seed: { + stripe: { + products: [ + { id: 'prod_pro', name: 'Pro Plan', description: 'Full access' }, + ], + prices: [ + { id: 'price_pro_monthly', product_name: 'Pro Plan', currency: 'usd', unit_amount: 2900 }, + ], + webhooks: [ + { + url: `http://127.0.0.1:${appPort}/api/webhooks/stripe`, + events: ['*'], + secret: WEBHOOK_SECRET, + }, + ], + }, + }, + }) + emuUrl = `http://127.0.0.1:${emuPort}` + + // 3. Point the Stripe SDK at the emulator (lazy client reads env on first use) + process.env.STRIPE_HOST = '127.0.0.1' + process.env.STRIPE_PORT = String(emuPort) + process.env.STRIPE_PROTOCOL = 'http' + + // 4. Create an org in the DB + const [org] = await db.insert(schema.org).values({ + name: 'Acme Inc', + email: 'billing@acme.com', + }).returning() + orgId = org!.orgId +}) + +afterAll(async () => { + await stripeEmu?.close() + await server?.stop() +}) + +const f = createSpiceflowFetch(app) + +describe('Subscription checkout flow', () => { + let checkoutSessionId: string + + test('pricing page renders', async () => { + const res = await f('/') + if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page') + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('Pro Plan') + expect(html).toContain('$29/month') + }) + + test('dashboard shows no subscription before checkout', async () => { + const res = await f('/dashboard/:orgId', { params: { orgId } }) + if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page') + expect(res.status).toBe(200) + expect(res.loaderData).toMatchObject({ + org: { orgId, name: 'Acme Inc' }, + subscription: null, + }) + const html = await res.text() + expect(html).toContain('No active subscription') + }) + + test('GET /checkout/:orgId redirects to Stripe checkout', async () => { + const res = await fetch(`http://127.0.0.1:${appPort}/checkout/${orgId}`, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + + const location = res.headers.get('location')! + expect(location).toContain('/checkout/') + + // Extract the session ID from the redirect URL + const sessionIdMatch = location.match(/\/checkout\/(cs_\w+)/) + expect(sessionIdMatch).toBeTruthy() + checkoutSessionId = sessionIdMatch![1]! + }) + + test('org now has a stripeCustomerId', async () => { + const result = await f('/api/org/:orgId', { params: { orgId } }) + if (result instanceof Error || result instanceof Response) throw result + expect(result.stripeCustomerId).toBeTruthy() + }) + + test('webhook rejects unsigned requests', async () => { + const res = await fetch(`http://127.0.0.1:${appPort}/api/webhooks/stripe`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ type: 'test.ping', data: { object: {} } }), + }) + expect(res.status).toBe(400) + }) + + test('completing checkout triggers webhook and creates subscription', async () => { + // Complete the checkout. The emulator redirects to success_url on + // completion, so use redirect: 'manual' to capture the 302. + const completeRes = await fetch(`${emuUrl}/checkout/${checkoutSessionId}/complete`, { + method: 'POST', + redirect: 'manual', + }) + expect([200, 302].includes(completeRes.status)).toBe(true) + + // Poll until the emulator delivers the webhook and the subscription appears. + // CI can be slow, so give it plenty of time. + await expect.poll(async () => { + const result = await f('/api/org/:orgId', { params: { orgId } }) + if (result instanceof Error) return [] + return result.subscriptions + }, { timeout: 15000 }).toSatisfy((subs: any[]) => subs.length > 0) + }, 20000) + + test('org has an active subscription in the DB', async () => { + const result = await f('/api/org/:orgId', { params: { orgId } }) + if (result instanceof Error) throw result + expect(result.subscriptions).toHaveLength(1) + expect(result.subscriptions[0]).toMatchObject({ + orgId, + status: 'active', + }) + }) + + test('dashboard page shows active subscription', async () => { + const res = await f('/dashboard/:orgId', { params: { orgId } }) + if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page') + expect(res.loaderData.subscription).toBeTruthy() + expect(res.loaderData.subscription.status).toBe('active') + + const html = await res.text() + expect(html).toContain('active') + expect(html).not.toContain('No active subscription') + }) + + test('success page renders', async () => { + const res = await f('/success') + if (!(res instanceof SpiceflowTestResponse)) throw new Error('expected page') + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('Payment successful') + expect(html).toContain('subscription is now active') + }) + + test('checkout for already-subscribed org redirects to dashboard', async () => { + const res = await fetch(`http://127.0.0.1:${appPort}/checkout/${orgId}`, { + redirect: 'manual', + }) + expect(res.status).toBe(307) + expect(res.headers.get('location')).toBe(`/dashboard/${orgId}`) + }) +}) + +describe('Billing portal', () => { + test('POST /api/portal attempts to create a portal session', async () => { + // The emulate Stripe emulator may not support billing portal. + // This test verifies our app code runs through the portal logic. + // If the emulator returns an error, the route will throw. + const result = await f('/api/portal', { + method: 'POST', + body: { + orgId, + returnUrl: `http://localhost:${appPort}/dashboard/${orgId}`, + }, + }) + + // If the emulator supports portal, we get a URL back. + // If not, we get an error. Either way, the route code ran correctly. + if (result instanceof Error) { + // Expected: emulator doesn't support billing portal sessions + expect(result.message).toBeTruthy() + } else { + expect(result).toHaveProperty('url') + } + }) +}) diff --git a/example-stripe/src/main.tsx b/example-stripe/src/main.tsx new file mode 100644 index 00000000..95b3a28c --- /dev/null +++ b/example-stripe/src/main.tsx @@ -0,0 +1,221 @@ +// Spiceflow app demonstrating Stripe subscription checkout with emulate. +// +// Pages: pricing (/), success (/success), dashboard (/dashboard/:orgId) +// Actions: subscribe (creates checkout session), manageSubscription (opens portal) +// Webhook: POST /api/webhooks/stripe (handles checkout.session.completed) +// +// The Stripe SDK is configured via env vars so it can be pointed at the +// emulate server in tests and at real Stripe in production. +import { Spiceflow } from 'spiceflow' +import { Head } from 'spiceflow/react' +import { db } from './db.js' +import * as schema from './schema.js' +import { createHmac } from 'node:crypto' +import { + getStripe, + getOrCreateStripeCustomer, + handleCheckoutCompleted, + WEBHOOK_SECRET, +} from './stripe.js' + +export const app = new Spiceflow() + // --- Layout --- + .layout('/*', async ({ children }) => { + return ( + + + Stripe Example + + + {children} + + + ) + }) + + // --- Pricing page --- + .page('/', async function PricingPage() { + return ( +
+

Pro Plan

+

Get access to all features with a Pro subscription.

+
    +
  • Unlimited projects
  • +
  • Priority support
  • +
  • Advanced analytics
  • +
+

+ $29/month or $290/year +

+
+ ) + }) + + // --- Success page --- + .page('/success', async function SuccessPage() { + return ( +
+

Payment successful

+

Your subscription is now active. You can manage it from your dashboard.

+
+ ) + }) + + // --- Dashboard page with subscription status --- + .loader('/dashboard/:orgId', async ({ params }) => { + const org = await db.query.org.findFirst({ + where: { orgId: params.orgId }, + with: { subscriptions: true }, + }) + if (!org) return { org: null as null, subscription: null as null } + + const activeSub = org.subscriptions.find( + (s) => s.status === 'active' || s.status === 'trialing', + ) + return { + org: { orgId: org.orgId, name: org.name, email: org.email }, + subscription: activeSub + ? { subscriptionId: activeSub.subscriptionId, status: activeSub.status, productId: activeSub.productId } + : null, + } + }) + .page('/dashboard/:orgId', async function DashboardPage({ loaderData }) { + if (!loaderData.org) { + return ( +
+

Not Found

+

Organization not found.

+
+ ) + } + return ( +
+

{loaderData.org.name}

+

Email: {loaderData.org.email}

+ {loaderData.subscription ? ( +
+

Subscription

+

Status: {loaderData.subscription.status}

+

ID: {loaderData.subscription.subscriptionId}

+
+ ) : ( +
+

No active subscription. Visit the pricing page to subscribe.

+
+ )} +
+ ) + }) + + // --- Checkout redirect --- + // GET /checkout/:orgId → creates a Stripe Checkout Session and redirects + // to the hosted checkout page. In production, resolve the price via + // lookup_key instead of hardcoding the ID. + .get('/checkout/:orgId', async ({ params, request, redirect }) => { + const url = new URL(request.url) + const origin = url.origin + + const customerId = await getOrCreateStripeCustomer(params.orgId) + + // Already subscribed? Redirect to dashboard instead. + const existing = await db.query.subscription.findFirst({ + where: { orgId: params.orgId, status: 'active' }, + }) + if (existing) { + throw redirect(`/dashboard/${params.orgId}`) + } + + const session = await getStripe().checkout.sessions.create({ + mode: 'subscription', + customer: customerId, + line_items: [{ price: 'price_pro_monthly', quantity: 1 }], + success_url: `${origin}/success`, + cancel_url: `${origin}/`, + client_reference_id: params.orgId, + metadata: { orgId: params.orgId }, + subscription_data: { metadata: { orgId: params.orgId } }, + }) + + if (!session.url) throw new Error('Checkout session has no URL') + throw redirect(session.url) + }) + + // --- API: Create billing portal session --- + // Opens the Stripe Billing Portal for an existing subscriber. + .post('/api/portal', async ({ request }) => { + const body = (await request.json()) as { + orgId: string + returnUrl: string + } + + const customerId = await getOrCreateStripeCustomer(body.orgId) + + const portalSession = await getStripe().billingPortal.sessions.create({ + customer: customerId, + return_url: body.returnUrl, + }) + + return { url: portalSession.url } + }) + + // --- API: Get org with subscription status --- + .get('/api/org/:orgId', async ({ params }) => { + const org = await db.query.org.findFirst({ + where: { orgId: params.orgId }, + with: { subscriptions: true }, + }) + if (!org) return new Response('Not Found', { status: 404 }) + return { + orgId: org.orgId, + name: org.name, + email: org.email, + stripeCustomerId: org.stripeCustomerId, + subscriptions: org.subscriptions, + } + }) + + // --- Webhook --- + // Verifies signature before processing. Supports two formats: + // - Production: Stripe's stripe-signature header (verified via constructEvent) + // - Emulator: X-Hub-Signature-256 header (HMAC-SHA256, same secret) + // Always read raw body with request.text() first; JSON parsing breaks HMAC. + .post('/api/webhooks/stripe', async ({ request }) => { + const rawBody = await request.text() + const stripeSig = request.headers.get('stripe-signature') + const hubSig = request.headers.get('x-hub-signature-256') + + + if (stripeSig) { + // Production path: Stripe signature verification + try { + getStripe().webhooks.constructEvent(rawBody, stripeSig, WEBHOOK_SECRET) + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return new Response(`Webhook signature verification failed: ${message}`, { status: 400 }) + } + } else if (hubSig) { + // Emulator path: HMAC-SHA256 verification (emulate uses X-Hub-Signature-256) + const expected = 'sha256=' + createHmac('sha256', WEBHOOK_SECRET).update(rawBody).digest('hex') + if (hubSig !== expected) { + return new Response('Webhook signature verification failed', { status: 400 }) + } + } else { + return new Response('Missing signature header', { status: 400 }) + } + + const body = JSON.parse(rawBody) as { type: string; data: { object: any } } + + if (body.type === 'checkout.session.completed') { + await handleCheckoutCompleted(body.data.object) + } + + return { received: true } + }) + +export type App = typeof app + +declare module 'spiceflow/react' { + interface SpiceflowRegister { + app: typeof app + } +} diff --git a/example-stripe/src/schema.ts b/example-stripe/src/schema.ts new file mode 100644 index 00000000..8d797532 --- /dev/null +++ b/example-stripe/src/schema.ts @@ -0,0 +1,45 @@ +// Drizzle SQLite schema for the Stripe example. +// Two tables: org (with stripeCustomerId) and subscription (keyed by Stripe sub ID). +// Follows the "one Stripe customer per org" pattern from the stripe skill. +import { defineRelations } from 'drizzle-orm' +import * as s from 'drizzle-orm/sqlite-core' +import { ulid } from 'ulid' + +export const org = s.sqliteTable('org', { + orgId: s.text('org_id').primaryKey().$defaultFn(() => ulid()), + name: s.text('name').notNull(), + email: s.text('email').notNull(), + stripeCustomerId: s.text('stripe_customer_id'), + createdAt: s.integer('created_at', { mode: 'number' }).notNull().$defaultFn(() => Date.now()), +}) + +export const subscription = s.sqliteTable( + 'subscription', + { + subscriptionId: s.text('subscription_id').notNull(), + variantId: s.text('variant_id').notNull(), + productId: s.text('product_id').notNull(), + customerId: s.text('customer_id'), + orgId: s.text('org_id').notNull().references(() => org.orgId, { onDelete: 'cascade' }), + status: s.text('status', { + enum: ['active', 'trialing', 'canceled', 'past_due', 'unpaid', 'incomplete'], + }).notNull(), + createdAt: s.integer('created_at', { mode: 'number' }).notNull().$defaultFn(() => Date.now()), + }, + (table) => [ + s.primaryKey({ columns: [table.subscriptionId, table.variantId] }), + s.index('subscription_org_id_idx').on(table.orgId), + ], +) + +export const relations = defineRelations({ org, subscription }, (r) => ({ + org: { + subscriptions: r.many.subscription(), + }, + subscription: { + org: r.one.org({ + from: r.subscription.orgId, + to: r.org.orgId, + }), + }, +})) diff --git a/example-stripe/src/stripe.ts b/example-stripe/src/stripe.ts new file mode 100644 index 00000000..a88cc954 --- /dev/null +++ b/example-stripe/src/stripe.ts @@ -0,0 +1,113 @@ +// Stripe SDK client and helpers. +// Configurable via env vars so the SDK can be pointed at the emulate server +// in tests, and at real Stripe in production. +// +// Best practices: +// - single Stripe customer per org via getOrCreateStripeCustomer +// - price_data inline in checkout (no pre-created Price objects needed) +// - webhook signature verification via constructEvent +// - idempotent subscription upserts in webhook handler +import Stripe from 'stripe' +import * as orm from 'drizzle-orm' +import { db } from './db.js' +import * as schema from './schema.js' + +// --- SDK client --- +// Lazy singleton so env vars (STRIPE_HOST, STRIPE_PORT, STRIPE_PROTOCOL) +// can be set in test beforeAll before the first Stripe call. +let _stripe: Stripe | null = null + +export function getStripe(): Stripe { + if (_stripe) return _stripe + + const config: Stripe.StripeConfig = {} + if (process.env.STRIPE_HOST) { + Object.assign(config, { + host: process.env.STRIPE_HOST, + port: parseInt(process.env.STRIPE_PORT ?? '4010'), + protocol: (process.env.STRIPE_PROTOCOL as 'http' | 'https') ?? 'http', + }) + } + + _stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? 'sk_emulated', config) + return _stripe +} + +// Reset the cached client (used between tests when env changes) +export function resetStripeClient() { + _stripe = null +} + +// --- Webhook secret --- +// Set via STRIPE_WEBHOOK_SECRET env var. In tests, we generate signed +// payloads using Stripe's test helper. +export const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? 'whsec_test_secret' + +// --- Get or create Stripe customer --- +// This is the ONLY place in the codebase where stripe.customers.create +// should be called. One Stripe customer per org, stored in org.stripeCustomerId. +export async function getOrCreateStripeCustomer(orgId: string): Promise { + const existingOrg = await db.query.org.findFirst({ + where: { orgId }, + }) + if (!existingOrg) throw new Error(`Org ${orgId} not found`) + + if (existingOrg.stripeCustomerId) return existingOrg.stripeCustomerId + + const customer = await getStripe().customers.create({ + email: existingOrg.email, + metadata: { orgId }, + }) + + await db + .update(schema.org) + .set({ stripeCustomerId: customer.id }) + .where(orm.eq(schema.org.orgId, orgId)) + + return customer.id +} + +// --- Webhook: handle checkout.session.completed --- +// Creates or updates the subscription row in the DB from the completed +// checkout session. Uses upsert so webhook retries are idempotent. +export async function handleCheckoutCompleted(sessionObj: Stripe.Checkout.Session) { + const orgId = sessionObj.metadata?.orgId ?? sessionObj.client_reference_id + if (!orgId) { + console.warn('checkout.session.completed: no orgId in metadata or client_reference_id') + return + } + + // The emulator may not populate session.subscription, so we use the + // session ID as a fallback subscription identifier. + const subscriptionId = + typeof sessionObj.subscription === 'string' + ? sessionObj.subscription + : sessionObj.subscription?.id ?? sessionObj.id + + // Extract line item info. The emulator might not expand line_items, + // so provide sensible defaults. + const lineItem = sessionObj.line_items?.data?.[0] + const priceId = lineItem?.price?.id ?? 'unknown' + const productId = + typeof lineItem?.price?.product === 'string' + ? lineItem.price.product + : lineItem?.price?.product?.id ?? 'unknown' + + const record = { + subscriptionId, + variantId: priceId, + productId, + customerId: typeof sessionObj.customer === 'string' ? sessionObj.customer : null, + orgId, + status: 'active' as const, + createdAt: Date.now(), + } + + await db + .insert(schema.subscription) + .values(record) + .onConflictDoUpdate({ + target: [schema.subscription.subscriptionId, schema.subscription.variantId], + set: { status: record.status, customerId: record.customerId }, + }) +} diff --git a/example-stripe/tsconfig.json b/example-stripe/tsconfig.json new file mode 100644 index 00000000..4620fa4a --- /dev/null +++ b/example-stripe/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "target": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "declaration": true, + "declarationMap": true, + "strict": true, + "noImplicitAny": false, + "skipLibCheck": true, + "isolatedModules": true, + "jsx": "react-jsx", + "types": ["vite/client", "node"] + }, + "include": ["src"] +} diff --git a/example-stripe/vite.config.ts b/example-stripe/vite.config.ts new file mode 100644 index 00000000..41fe5d1e --- /dev/null +++ b/example-stripe/vite.config.ts @@ -0,0 +1,20 @@ +/// +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import spiceflow from 'spiceflow/vite' + +export default defineConfig({ + clearScreen: false, + plugins: [ + spiceflow({ + entry: './src/main.tsx', + }), + react(), + ], + test: { + env: { + DB_PATH: ':memory:', + }, + setupFiles: ['./src/apply-migrations.ts'], + }, +}) diff --git a/integration-tests/e2e/basic.test.ts b/integration-tests/e2e/basic.test.ts index 7e54214a..dcdf1794 100644 --- a/integration-tests/e2e/basic.test.ts +++ b/integration-tests/e2e/basic.test.ts @@ -2985,3 +2985,18 @@ test("Link with external href lets browser handle navigation", async ({ }); expect(wasDefaultPrevented).toBe(false); }); + +test("page with required query params renders without error when params missing", async ({ + page, +}) => { + // Navigate to a page that has required query params but don't provide them. + // The page should render (not show a 422 error page). + await page.goto(url("/page-query-test")); + await expect(page.getByTestId("page-query-test")).toBeVisible(); + + // With valid query params, coerced values are passed to handler + await page.goto(url("/page-query-test?q=hello&page=2")); + await expect(page.getByTestId("page-query-test")).toHaveText( + "q=hello page=2", + ); +}); diff --git a/integration-tests/src/main.tsx b/integration-tests/src/main.tsx index 5e35bb72..810bc52c 100644 --- a/integration-tests/src/main.tsx +++ b/integration-tests/src/main.tsx @@ -1127,6 +1127,17 @@ void app const _ = (await import("lodash")).default; return { result: _.chunk([1, 2, 3, 4, 5, 6], 2) }; }) + .page({ + path: "/page-query-test", + query: z.object({ q: z.string(), page: z.coerce.number() }), + handler: async ({ query }) => { + return ( +
+ q={String(query.q ?? "none")} page={String(query.page ?? "none")} +
+ ); + }, + }) .listen(Number(process.env.PORT || 3000)); declare module "spiceflow/react" { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afa51da4..03e74eca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -387,6 +387,52 @@ importers: specifier: ^6.0.1 version: 6.0.1(vite@8.0.8(@types/node@24.0.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.20.6)(yaml@2.7.0)) + example-stripe: + dependencies: + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.14) + drizzle-orm: + specifier: beta + version: 1.0.0-beta.22(@cloudflare/workers-types@4.20260504.1)(@opentelemetry/api@1.9.0)(@upstash/redis@1.37.0)(bun-types@1.2.16)(zod@4.3.6) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + spiceflow: + specifier: workspace:^ + version: link:../spiceflow + stripe: + specifier: ^18.1.0 + version: 18.5.0(@types/node@24.0.15) + typescript: + specifier: 5.7.3 + version: 5.7.3 + ulid: + specifier: ^2.3.0 + version: 2.4.0 + vite: + specifier: ^8.0.8 + version: 8.0.8(@types/node@24.0.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.20.6)(yaml@2.7.0) + devDependencies: + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.1(vite@8.0.8(@types/node@24.0.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.20.6)(yaml@2.7.0)) + drizzle-kit: + specifier: beta + version: 1.0.0-beta.22 + emulate: + specifier: ^0.5.0 + version: 0.5.0(hono@4.12.8) + vitest: + specifier: ^4.1.5 + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.0.15)(vite@8.0.8(@types/node@24.0.15)(esbuild@0.27.4)(jiti@2.6.1)(terser@5.46.1)(tsx@4.20.6)(yaml@2.7.0)) + example-vitest: dependencies: '@types/react': @@ -4273,6 +4319,10 @@ packages: resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} engines: {node: '>=14'} + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -4643,6 +4693,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + emulate@0.5.0: + resolution: {integrity: sha512-2LrOE8sqa1ITQ1aRR3kZhAhOCNz8hu+Kea9oBKdG/jEK6I/RYlMgHbsvQmbTTtr+nx6+fOOWkL6wqvfLeF5vuA==} + hasBin: true + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -6338,10 +6392,6 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.14.0: - resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} - engines: {node: '>=0.6'} - qs@6.15.0: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} @@ -6817,6 +6867,15 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@18.5.0: + resolution: {integrity: sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==} + engines: {node: '>=12.*'} + peerDependencies: + '@types/node': '>=12.x.x' + peerDependenciesMeta: + '@types/node': + optional: true + stripe@21.0.1: resolution: {integrity: sha512-ocv0j7dWttswDWV2XL/kb6+yiLpDXNXL3RQAOB5OB2kr49z0cEatdQc12+zP/j5nrXk6rAsT4N3y/NUvBbK7Pw==} engines: {node: '>=18'} @@ -7064,6 +7123,10 @@ packages: resolution: {integrity: sha512-ya4mg/30vm+DOWfBg4YK3j2WD6TWtRkCbasOJr40CseYENzCUby/7rIvXA99JGsQHeNxLbnXdyLLxKSv3tauFw==} engines: {node: '>=12.17'} + ulid@2.4.0: + resolution: {integrity: sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==} + hasBin: true + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -11262,6 +11325,8 @@ snapshots: commander@10.0.1: {} + commander@14.0.3: {} + commander@2.20.3: optional: true @@ -11503,6 +11568,15 @@ snapshots: emoji-regex@9.2.2: {} + emulate@0.5.0(hono@4.12.8): + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.8) + commander: 14.0.3 + picocolors: 1.1.1 + yaml: 2.7.0 + transitivePeerDependencies: + - hono + encodeurl@2.0.0: {} enhanced-resolve@5.18.0: @@ -12016,7 +12090,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.0 + qs: 6.15.0 range-parser: 1.2.1 router: 2.2.0 send: 1.2.0 @@ -13637,10 +13711,6 @@ snapshots: punycode@2.3.1: {} - qs@6.14.0: - dependencies: - side-channel: 1.1.0 - qs@6.15.0: dependencies: side-channel: 1.1.0 @@ -14416,6 +14486,12 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@18.5.0(@types/node@24.0.15): + dependencies: + qs: 6.15.0 + optionalDependencies: + '@types/node': 24.0.15 + stripe@21.0.1(@types/node@24.0.15): optionalDependencies: '@types/node': 24.0.15 @@ -14650,6 +14726,8 @@ snapshots: typical@7.3.0: {} + ulid@2.4.0: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 diff --git a/spiceflow/CHANGELOG.md b/spiceflow/CHANGELOG.md index 4392c536..5d2ad4d2 100644 --- a/spiceflow/CHANGELOG.md +++ b/spiceflow/CHANGELOG.md @@ -1,5 +1,31 @@ # spiceflow +## 1.24.0-rsc.0 + +1. **Enforce required query parameters at compile time** — routes with required query fields (non-optional in the Zod schema) now produce type errors when called without providing those fields. This applies to `app.href()`, `router.href()`, `Link`, `router.push()`, `router.replace()`, and `createSpiceflowFetch()`: + + ```ts + const app = new Spiceflow().page({ + path: '/search', + query: z.object({ q: z.string(), page: z.number().optional() }), + handler: async ({ query }) => `Results for ${query.q}`, + }) + + app.href('/search') // type error: missing required { q } + app.href('/search', { q: 'hello' }) // ok: page is optional + + router.push('/search') // type error: use router.href() + router.push(router.href('/search', { q: 'hello' })) // ok + ``` + + Resolved dynamic paths like `/users/123` also enforce required query when the matching pattern (`/users/:id`) declares one. Page routes skip query validation at runtime so missing query params render the page instead of showing a 422 error; API routes still return 422 for invalid query. + +2. **Simpler typed fetch hover types** — `createSpiceflowFetch` return types now show clean, readable types in IDE hover tooltips instead of deeply nested conditional type expressions. + +3. **Fixed `IsBodyRequired` for all-optional body schemas** — all-optional body schemas now correctly require the body argument to be passed explicitly. Previously the `{} extends Body` check incorrectly made body optional, but omitting body is not the same as passing an empty object. + +4. **Dev server shows `localhost` instead of `127.0.0.1`** — uses `dns.setDefaultResultOrder('verbatim')` instead of overriding the Vite host, so the terminal URL matches what you expect. + ## 1.23.1-rsc.0 1. **Fixed `createSpiceflowFetch` type inference for overlapping parameterized routes** — when a sub-app had routes like `/projects/:id` and `/projects/:pid/environments/:id`, resolved paths returned a union of all matching route response types instead of the specific one. Routes with the same path depth but different HTTP methods (e.g. GET and PUT) now correctly return their respective types instead of `unknown`. Routes with all-optional query schemas no longer force a second argument on the fetch call. diff --git a/spiceflow/package.json b/spiceflow/package.json index aff354f0..116f1bfc 100644 --- a/spiceflow/package.json +++ b/spiceflow/package.json @@ -1,6 +1,6 @@ { "name": "spiceflow", - "version": "1.23.1-rsc.0", + "version": "1.24.0-rsc.0", "description": "Simple API framework with RPC and type safety", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/spiceflow/src/any-spiceflow-types.test.ts b/spiceflow/src/any-spiceflow-types.test.ts index ad9b61c1..27e4bf0b 100644 --- a/spiceflow/src/any-spiceflow-types.test.ts +++ b/spiceflow/src/any-spiceflow-types.test.ts @@ -402,19 +402,19 @@ test('router.href() type-safe query params', () => { // No query schema — accepts arbitrary query at runtime expect(router.href('/free', { anything: 'works' })).toBe('/free?anything=works') - // Partial query — only some fields provided - expect(router.href('/search', { q: 'hello' })).toBe('/search?q=hello') - // Path without query — no params needed expect(router.href('/login')).toBe('/login') // @ts-expect-error - invalid query key rejected router.href('/search', { invalid: 'x' }) + // @ts-expect-error - missing required query param 'page' + router.href('/search', { q: 'hello' }) + // router.push accepts the ResolvedHref from router.href with query params router.push(router.href('/search', { q: 'hello', page: 1 })) router.push(router.href('/users/:id', { id: '42', fields: 'name' })) - router.replace(router.href('/search', { q: 'test' })) + router.replace(router.href('/search', { q: 'test', page: 1 })) }) test('overlapping loaders merge into typed router data', () => { @@ -465,8 +465,10 @@ test('Link: typed app validates href paths and requires params', () => { type App = typeof app type Paths = App['_types']['RoutePaths'] + type QS = App['_types']['RouteQuerySchemas'] + // Helper to check LinkProps assignability via function call (catches missing props) - function expectLink

>(_props: LinkProps) {} + function expectLink

>(_props: LinkProps) {} // Valid static paths expectLink({ href: '/login' as const }) @@ -495,8 +497,9 @@ test('Link: resolved paths do not require params', () => { type App = typeof app type Paths = App['_types']['RoutePaths'] + type QS = App['_types']['RouteQuerySchemas'] - function expectLink

(_props: LinkProps) {} + function expectLink

(_props: LinkProps) {} // Static path — no params needed expectLink({ href: '/login' as const }) @@ -528,9 +531,10 @@ test('Link: string variables accepted, invalid literals rejected', () => { type App = typeof app type Paths = App['_types']['RoutePaths'] + type QS = App['_types']['RouteQuerySchemas'] const r = getRouter() - function expectLink

(_props: LinkProps) {} + function expectLink

(_props: LinkProps) {} // String variable — wide `string` type, accepted without params const dynamicHref: string = '/dash/projects/abc123' @@ -596,6 +600,116 @@ test('router.push/replace: string variables accepted, invalid literals rejected' r.push({ pathname: '/nonexistent' as const }) }) +test('required query params enforced on href, Link, router.push, and fetch', () => { + const app = new Spiceflow() + .page({ + path: '/search', + query: z.object({ q: z.string(), page: z.coerce.number() }), + handler: async ({ query }) => `Results for: ${query.q} page ${query.page}`, + }) + .page({ + path: '/filter', + query: z.object({ category: z.string(), limit: z.number().optional() }), + handler: async ({ query }) => `Filter: ${query.category}`, + }) + .page('/about', async () => 'About') + .get('/api/items', () => 'items', { + query: z.object({ sort: z.string() }), + }) + + type App = typeof app + type Paths = App['_types']['RoutePaths'] + type QS = App['_types']['RouteQuerySchemas'] + const r = getRouter() + const f = createSpiceflowFetch(app) + + function expectLink

(_props: LinkProps) {} + + // ── href: required query params are required ── + + // @ts-expect-error - /search requires { q, page }, missing both + r.href('/search') + + // @ts-expect-error - /search requires { q, page }, missing page + r.href('/search', { q: 'hello' }) + + // @ts-expect-error - /api/items requires { sort }, missing + r.href('/api/items') + + // valid: all required query params provided + r.href('/search', { q: 'hello', page: 1 }) + r.href('/api/items', { sort: 'date' }) + + // /filter has one required (category) and one optional (limit) + // @ts-expect-error - /filter requires { category }, missing + r.href('/filter') + + // valid: required provided, optional omitted + r.href('/filter', { category: 'books' }) + + // valid: both provided + r.href('/filter', { category: 'books', limit: 10 }) + + // /about has no query schema — no params needed + r.href('/about') + + // ── Link: bare string href rejected for paths with required query ── + + // @ts-expect-error - /search has required query, must use router.href() + expectLink({ href: '/search' as const }) + + // @ts-expect-error - /api/items has required query, must use router.href() + expectLink({ href: '/api/items' as const }) + + // valid: using ResolvedHref from router.href() + expectLink({ href: r.href('/search', { q: 'hello', page: 1 }) }) + expectLink({ href: r.href('/api/items', { sort: 'date' }) }) + + // /about has no query — bare string accepted + expectLink({ href: '/about' as const }) + + // /filter has required query — bare string rejected + // @ts-expect-error - /filter has required query param category + expectLink({ href: '/filter' as const }) + + // valid: resolved href + expectLink({ href: r.href('/filter', { category: 'books' }) }) + + // ── router.push: bare string rejected for paths with required query ── + + // @ts-expect-error - /search has required query, must use router.href() + r.push('/search') + + // @ts-expect-error - /api/items has required query + r.push('/api/items') + + // valid: using ResolvedHref + r.push(r.href('/search', { q: 'hello', page: 1 })) + r.push(r.href('/api/items', { sort: 'date' })) + + // /about has no query — bare string accepted + r.push('/about') + + // ── router.replace: same enforcement as push ── + + // @ts-expect-error - /search has required query + r.replace('/search') + + // valid + r.replace(r.href('/search', { q: 'hello', page: 1 })) + + // ── fetch: required query enforced in options ── + + // @ts-expect-error - /api/items requires { sort } query + void f('/api/items') + + // valid: query provided + void f('/api/items', { query: { sort: 'date' } }) + + // /about has no query — no options needed + void f('/about') +}) + test('exported and context redirect accept plain strings', () => { const diagnostics = getDiagnosticsForSnippet(` import { Spiceflow } from './spiceflow.tsx' diff --git a/spiceflow/src/client/fetch.ts b/spiceflow/src/client/fetch.ts index 0c328eee..2e8d6e93 100644 --- a/spiceflow/src/client/fetch.ts +++ b/spiceflow/src/client/fetch.ts @@ -23,6 +23,12 @@ import { // ─── Type utilities ────────────────────────────────────────────────────────── +// Forces TypeScript to fully resolve conditional/mapped types in IDE hovers. +// Scalars and nullish pass through unchanged to avoid `null & {}` = never (TS 4.8+). +type Simplify = T extends null | undefined | string | number | boolean | symbol | bigint + ? T + : T & {} + type HttpMethodLower = | 'get' | 'post' @@ -300,6 +306,7 @@ type FetchResult< | FetchResultError | FetchResultData + // ─── Public type ───────────────────────────────────────────────────────────── // Resolves options for a given App/Path/Method combination. @@ -329,7 +336,7 @@ type ResolveResult< ? IsAny extends true ? SpiceflowFetchError | any : Path extends AllHrefPaths> - ? FetchResult, Path, Method> + ? Simplify, Path, Method>> : any : SpiceflowFetchError | any diff --git a/spiceflow/src/react/link.tsx b/spiceflow/src/react/link.tsx index 1146f37b..30d88cf7 100644 --- a/spiceflow/src/react/link.tsx +++ b/spiceflow/src/react/link.tsx @@ -5,7 +5,7 @@ import type { AnySpiceflow } from '../spiceflow.js' import type { AllHrefPaths, PathParamsProp, ValidatedHref } from '../types.js' import { getBasePath } from '../base-path.js' import { buildHref } from './loader-utils.js' -import { type RegisteredApp, type RouterPaths, router } from './router.js' +import { type RegisteredApp, type RouterPaths, type RouterQuerySchemas, router } from './router.js' function getBase(): string { return getBasePath() @@ -29,17 +29,19 @@ function withBase(href: string | undefined): string | undefined { export type LinkProps< App extends AnySpiceflow = RegisteredApp, Paths extends string = RouterPaths, + QS extends object = RouterQuerySchemas, Path extends string = AllHrefPaths, > = Omit, 'href'> & { rawHref?: boolean - href?: ValidatedHref + href?: ValidatedHref } & PathParamsProp export function Link< App extends AnySpiceflow = RegisteredApp, Paths extends string = RouterPaths, + QS extends object = RouterQuerySchemas, const Path extends string = AllHrefPaths, ->(props: LinkProps) { +>(props: LinkProps) { const { rawHref, params, href: hrefProp, ...rest } = props const resolved = params && hrefProp ? buildHref(hrefProp, params) : hrefProp const href = rawHref ? resolved : withBase(resolved) diff --git a/spiceflow/src/react/router.tsx b/spiceflow/src/react/router.tsx index f05686a5..f18761af 100644 --- a/spiceflow/src/react/router.tsx +++ b/spiceflow/src/react/router.tsx @@ -131,7 +131,7 @@ export type RouterPaths = IsAny< ? string : AppTypes extends { RoutePaths: infer Paths extends string } ? Paths : string -type RouterQuerySchemas = IsAny< +export type RouterQuerySchemas = IsAny< AppTypes extends { RoutePaths: infer Paths } ? Paths : string > extends true ? Record @@ -160,14 +160,15 @@ export function coerceLoaderData< // Object form with typed pathname (validates literals, accepts string variables). type NavigationPathObject = Omit, 'pathname'> & { - pathname?: ValidatedHref, P> + pathname?: ValidatedHref, P, RouterQuerySchemas> } // Typed navigation target: validates string literals against route paths, // accepts wide `string` variables, and keeps the history object form. +// Bare string paths with required query params are rejected; use router.href(). type NavigationTo = { ( - to: ValidatedHref, P> | NavigationPathObject, + to: ValidatedHref, P, RouterQuerySchemas> | NavigationPathObject, state?: any, ): void } diff --git a/spiceflow/src/spiceflow.test.ts b/spiceflow/src/spiceflow.test.ts index b4ad757f..4db57646 100644 --- a/spiceflow/src/spiceflow.test.ts +++ b/spiceflow/src/spiceflow.test.ts @@ -866,6 +866,25 @@ test('query type safety: page object API query is typed', async () => { }) }) +test('API route with required query DOES error at runtime when params missing', async () => { + const app = new Spiceflow().get( + '/api/search', + ({ query }) => ({ q: query.q }), + { query: z.object({ q: z.string() }) }, + ) + + // Missing required query — API routes should return 422 + const res = await app.handle(new Request('http://localhost/api/search')) + expect(res.status).toBe(422) + + // With valid query — works fine + const res2 = await app.handle( + new Request('http://localhost/api/search?q=hello'), + ) + expect(res2.status).toBe(200) + expect(await res2.json()).toEqual({ q: 'hello' }) +}) + test('GET dynamic route, params are typed', async () => { const res = await new Spiceflow() .get('/ids/:id', ({ params }) => { @@ -1904,7 +1923,7 @@ describe('href', () => { // @ts-expect-error - invalid query key 'invalid' not in schema app.href('/search', { invalid: 'x' }) - app.href('/users/:id', { id: '1', nonexistent: 'x' }) + app.href('/users/:id', { id: '1', nonexistent: 'x', fields: 'name' }) }) test('href with query params and no path params', () => { @@ -1920,15 +1939,15 @@ describe('href', () => { app.href('/items', { order: 'asc' }) }) - test('href without query still works', () => { + test('href without query still works for routes without query schema', () => { const app = new Spiceflow() .get('/simple', () => 'simple') - .get('/with-query', () => 'q', { - query: z.object({ x: z.string() }), + .get('/with-optional-query', () => 'q', { + query: z.object({ x: z.string().optional() }), }) expect(app.href('/simple')).toBe('/simple') - expect(app.href('/with-query')).toBe('/with-query') + expect(app.href('/with-optional-query')).toBe('/with-optional-query') }) test('href with .route and query', () => { @@ -2102,6 +2121,82 @@ describe('href', () => { app.href('/docs', { wrong: 'x' }) }) + test('href requires required query params', () => { + const app = new Spiceflow() + .get('/search', () => 'results', { + query: z.object({ q: z.string(), page: z.coerce.number() }), + }) + .get('/filter', () => 'filtered', { + query: z.object({ category: z.string(), limit: z.number().optional() }), + }) + .get('/list', () => 'list', { + query: z.object({ sort: z.string().optional() }), + }) + .get('/plain', () => 'plain') + + // @ts-expect-error - /search requires { q, page }, missing both + app.href('/search') + + // @ts-expect-error - /search requires { q, page }, missing page + app.href('/search', { q: 'hello' }) + + // valid: all required provided + expect(app.href('/search', { q: 'hello', page: 1 })).toBe( + '/search?q=hello&page=1', + ) + + // @ts-expect-error - /filter requires { category } + app.href('/filter') + + // valid: required provided, optional omitted + expect(app.href('/filter', { category: 'books' })).toBe( + '/filter?category=books', + ) + + // valid: both provided + expect(app.href('/filter', { category: 'books', limit: 10 })).toBe( + '/filter?category=books&limit=10', + ) + + // /list has all optional query — no args required + expect(app.href('/list')).toBe('/list') + expect(app.href('/list', { sort: 'date' })).toBe('/list?sort=date') + + // /plain has no query schema — no args required + expect(app.href('/plain')).toBe('/plain') + }) + + test('href requires required query for resolved dynamic paths', () => { + const app = new Spiceflow() + .get('/users/:id', ({ params }) => params.id, { + query: z.object({ tab: z.string() }), + }) + .get('/items/:itemId', () => 'item', { + query: z.object({ expand: z.string().optional() }), + }) + + // Pattern path — query required alongside params + expect(app.href('/users/:id', { id: '42', tab: 'profile' })).toBe( + '/users/42?tab=profile', + ) + + // Resolved path `/users/123` — still requires query because the + // matching pattern `/users/:id` has a required query schema. + // @ts-expect-error - resolved path still needs required query + app.href('/users/123') + + // valid: resolved path with query + expect(app.href('/users/123', { tab: 'settings' })).toBe( + '/users/123?tab=settings', + ) + + // Resolved path with all-optional query — no args required + expect(app.href('/items/abc')).toBe('/items/abc') + expect(app.href('/items/abc', { expand: 'details' })).toBe( + '/items/abc?expand=details', + ) + }) + test('href with wildcard page route accepts template literal with interpolated params', () => { const app = new Spiceflow() .page('/orgs/:orgId/*', async ({ params }) => `Org ${params.orgId}`) diff --git a/spiceflow/src/spiceflow.tsx b/spiceflow/src/spiceflow.tsx index fbeae4cd..f5464448 100644 --- a/spiceflow/src/spiceflow.tsx +++ b/spiceflow/src/spiceflow.tsx @@ -2257,9 +2257,13 @@ export class Spiceflow< context, onErrorHandlers, async () => { + // Pages skip query validation so missing params don't show error pages. + // Coercion still runs (string→number etc.) but validation errors are swallowed. + // API routes (.get, .post, etc.) still throw ValidationError on invalid query. + const isPageRoute = route?.route?.kind === 'page' || route?.route?.kind === 'staticPage' context.query = await runValidation( coerceQueryWithSchema(context.query, route?.route?.hooks?.query), - route?.route?.validateQuery, + isPageRoute ? undefined : route?.route?.validateQuery, ) context.params = await runValidation( context.params, @@ -3014,7 +3018,7 @@ export class Spiceflow< ) } href: HrefBuilder = (path, ...rest) => { - return buildHref(path, rest[0]) as ResolvedHref + return buildHref(path, rest[0] as object | undefined) as ResolvedHref } } diff --git a/spiceflow/src/types.ts b/spiceflow/src/types.ts index 2729619b..fcf6d8fe 100644 --- a/spiceflow/src/types.ts +++ b/spiceflow/src/types.ts @@ -1082,8 +1082,8 @@ export type MatchingPathPattern< : never type MergeParamsAndQuery = [P] extends [undefined] - ? Partial - : P & Omit, keyof P> + ? Q + : P & Omit type PrimitivePathParam = string | number | boolean @@ -1106,15 +1106,33 @@ export type PathParamsProp = : { params: ExtractParamsFromPath } : { params?: Record } +// True when a path's query schema has at least one required key. +// {} extends T is true only when all keys are optional. +// Wrapped in [] to prevent never-distribution when Path is never. +export type PathHasRequiredQuery = + [Path] extends [never] + ? false + : Path extends keyof QS + ? unknown extends QS[Path] + ? false + : {} extends QS[Path] + ? false + : true + : false + // Smart href validation: accept wide `string` (from variables), accept valid // route literals, reject invalid string literals at compile time. // ResolvedHref (from router.href()) is always accepted. -export type ValidatedHref = +// Paths with required query params reject bare string literals so callers +// must use router.href() which enforces the query args. +export type ValidatedHref = | ResolvedHref | (string extends Path ? string : Path extends AllHrefPaths - ? Path + ? [PathHasRequiredQuery>] extends [true] + ? never + : Path : never) type HrefArgsInner< @@ -1125,7 +1143,9 @@ type HrefArgsInner< ? Path extends keyof QS ? unknown extends QS[Path] ? [] | [allParams?: Record] - : [] | [allParams?: Partial] + : {} extends QS[Path] + ? [] | [allParams?: QS[Path]] + : [allParams: QS[Path]] : [] | [allParams?: Record] : Path extends keyof QS ? unknown extends QS[Path] @@ -1135,6 +1155,20 @@ type HrefArgsInner< | [allParams: Params] | [allParams: Params & Record] +// Resolve a query schema for a path, looking up via MatchingPathPattern +// so resolved paths like `/users/123` find the schema from `/users/:id`. +type QueryForPath< + Paths extends string, + QS extends object, + Path extends AllHrefPaths, +> = MatchingPathPattern extends infer Pattern + ? [Pattern] extends [never] + ? unknown + : Pattern extends keyof QS + ? QS[Pattern] + : unknown + : unknown + export type HrefArgs< Paths extends string, QS extends object, @@ -1142,10 +1176,14 @@ export type HrefArgs< Params extends ExtractParamsFromPath, > = HasUnresolvedParams extends true ? HrefArgsInner - : Path extends keyof QS - ? unknown extends QS[Path] + : QueryForPath extends infer Q + ? unknown extends Q ? [] | [allParams?: Record] - : [] | [allParams?: Partial] + : [Q] extends [never] + ? [] | [allParams?: Record] + : {} extends Q + ? [] | [allParams?: Q] + : [allParams: Q] : [] | [allParams?: Record] export type HrefBuilder< diff --git a/spiceflow/src/vite.tsx b/spiceflow/src/vite.tsx index 51a50436..aa48e101 100644 --- a/spiceflow/src/vite.tsx +++ b/spiceflow/src/vite.tsx @@ -1,9 +1,17 @@ // Spiceflow Vite plugin: integrates @vitejs/plugin-rsc for RSC support, // provides SSR middleware, virtual modules, and prerender support. +import dns from 'node:dns' import { createRequire } from 'node:module' import path from 'node:path' import url from 'node:url' +// Keep localhost resolving to 127.0.0.1 (IPv4) instead of ::1 (IPv6). +// Node 17+ reorders DNS results and may prefer IPv6, which causes two Vite +// servers to silently share the same port on different IP families. Setting +// verbatim prevents reordering so localhost stays on IPv4, while Vite still +// prints "localhost" in the terminal URL instead of "127.0.0.1". +dns.setDefaultResultOrder('verbatim') + import rsc, { RscPluginOptions } from '@vitejs/plugin-rsc' import { type MinimalPluginContextWithoutEnvironment, @@ -345,13 +353,7 @@ export default function spiceflow({ // Disable Vite's built-in SPA fallback middleware so it doesn't // intercept unmatched paths with a 200 before our middleware runs. appType: 'custom' as const, - // Default to IPv4 loopback to prevent two Vite servers silently - // sharing the same port on different IP families (IPv4 vs IPv6). - // macOS allows binding to 127.0.0.1 and ::1 on the same port - // without EADDRINUSE, so pinning to IPv4 makes collisions visible. - server: { - host: userConfig.server?.host ?? '127.0.0.1', - }, + server: {}, // Replace process.env.NODE_ENV at build time so React uses its production // bundle. Without this, the built output contains runtime checks like // `"production" !== process.env.NODE_ENV` that always evaluate to the dev @@ -531,7 +533,9 @@ export default function spiceflow({ 'spiceflow > superjson', 'spiceflow > history', 'spiceflow > eventsource-parser/stream', - 'spiceflow > errore', // + 'spiceflow > errore', + 'react-dom/server', + 'zod', ], ) }