diff --git a/.changeset/elysia-eden-addon.md b/.changeset/elysia-eden-addon.md new file mode 100644 index 00000000..883e0396 --- /dev/null +++ b/.changeset/elysia-eden-addon.md @@ -0,0 +1,5 @@ +--- +'@tanstack/create': minor +--- + +Add a React Elysia and Eden Treaty integration with a same-origin typed API route and greeting demo. diff --git a/packages/cli/tests-e2e/elysia-smoke.spec.ts b/packages/cli/tests-e2e/elysia-smoke.spec.ts new file mode 100644 index 00000000..b3bc1133 --- /dev/null +++ b/packages/cli/tests-e2e/elysia-smoke.spec.ts @@ -0,0 +1,80 @@ +import { writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { expect, test } from '@playwright/test' +import { + attachRuntimeGuards, + createReactAppFixture, + optimizePageForFastE2E, +} from './helpers' + +for (const existing of [false, true]) { + test(`@blocking uses Elysia and Eden in a ${existing ? 'existing blank' : 'standard'} React app`, async ({ + page, + }) => { + const fixture = await createReactAppFixture({ + appName: `elysia-${existing ? 'existing' : 'standard'}`, + blank: existing, + packageManager: existing ? 'pnpm' : 'npm', + ...(existing ? { postCreateAddOns: ['elysia'] } : { addOns: ['elysia'] }), + runQualityGatesChecks: true, + afterCreate: existing + ? undefined + : async (appDir) => { + await writeFile( + join(appDir, 'src/elysia-contract.test-types.ts'), + `import { treaty } from '@elysiajs/eden' +import type { App } from './lib/elysia.server' +export function verifyTypes() { + const client = treaty('http://unused.invalid') + // @ts-expect-error The API requires a string name. + void client.api.demo.elysia.greet.post({ name: 42 }) +} +`, + ) + }, + }) + const guards = attachRuntimeGuards(page, fixture.url) + try { + if (existing) { + const response = await page.request.post( + `${fixture.url}/api/demo/elysia/greet`, + { data: { name: 'TanStack' } }, + ) + expect(response.status()).toBe(200) + expect(await response.json()).toEqual({ message: 'Hello TanStack' }) + expect( + (await page.request.get(`${fixture.url}/demo/elysia`)).status(), + ).toBe(404) + } else { + await optimizePageForFastE2E(page) + await page.emulateMedia({ colorScheme: 'dark' }) + await page.goto(`${fixture.url}/demo/elysia`) + await expect( + page.getByRole('heading', { name: 'Elysia greeting' }), + ).toBeVisible() + await page.getByLabel('Name', { exact: true }).fill('TanStack') + await page + .getByRole('button', { name: 'Greet', exact: true }) + .click({ timeout: 10_000 }) + await expect(page.getByRole('status')).toHaveText('Hello TanStack') + await test.info().attach('elysia-demo', { + body: await page.screenshot(), + contentType: 'image/png', + }) + } + const invalid = await page.request.post( + `${fixture.url}/api/demo/elysia/greet`, + { data: { name: 42 } }, + ) + expect(invalid.status()).toBe(422) + } finally { + try { + guards.assertClean() + } finally { + guards.dispose() + await fixture.stop() + await fixture.cleanup() + } + } + }) +} diff --git a/packages/create/src/frameworks/react/add-ons/elysia/README.md.ejs b/packages/create/src/frameworks/react/add-ons/elysia/README.md.ejs new file mode 100644 index 00000000..59c47545 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/README.md.ejs @@ -0,0 +1,13 @@ +## Elysia + Eden + +<% if (includeExamples && !blank) { %>Visit `/demo/elysia` after starting the app to send a typed greeting request to your Start server.<% } else { %>Send JSON such as `{"name":"TanStack"}` with a POST request to `/api/demo/elysia/greet` after starting the app. Blank projects omit the demo page.<% } %> + +- Edit `src/lib/elysia.server.ts` to add Elysia endpoints and request schemas. +- `src/routes/api.demo.elysia.$.ts` forwards requests to the Elysia app. +- Use a type-only import of `App` when building an Eden Treaty client. Browser requests should use the current origin to support development and preview ports. + +Elysia runs inside Start server routes. Start owns the server listener. Keep backend imports in server handlers and use type-only imports in client code. + +The demo validates a name of 1–80 characters. Add authentication and authorization before exposing private data or privileged operations. + +See the [Elysia Start integration guide](https://elysiajs.com/integrations/tanstack-start) for more examples. diff --git a/packages/create/src/frameworks/react/add-ons/elysia/assets/src/lib/elysia.server.ts b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/lib/elysia.server.ts new file mode 100644 index 00000000..b5662196 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/lib/elysia.server.ts @@ -0,0 +1,9 @@ +import { Elysia, t } from 'elysia' + +export const app = new Elysia({ prefix: '/api/demo/elysia' }).post( + '/greet', + ({ body }) => ({ message: `Hello ${body.name}` }), + { body: t.Object({ name: t.String({ minLength: 1, maxLength: 80 }) }) }, +) + +export type App = typeof app diff --git a/packages/create/src/frameworks/react/add-ons/elysia/assets/src/routes/api.demo.elysia.$.ts b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/routes/api.demo.elysia.$.ts new file mode 100644 index 00000000..ab05c639 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/routes/api.demo.elysia.$.ts @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' +import { app } from '#/lib/elysia.server' + +export const Route = createFileRoute('/api/demo/elysia/$')({ + server: { + handlers: { + POST: ({ request }) => app.fetch(request), + }, + }, +}) diff --git a/packages/create/src/frameworks/react/add-ons/elysia/assets/src/routes/demo/elysia.tsx b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/routes/demo/elysia.tsx new file mode 100644 index 00000000..1cca460f --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/routes/demo/elysia.tsx @@ -0,0 +1,67 @@ +import { useEffect, useId, useState } from 'react' +import type { FormEvent } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { treaty } from '@elysiajs/eden' +import type { App } from '#/lib/elysia.server' +import '#/styles/elysia.css' + +export const Route = createFileRoute('/demo/elysia')({ component: ElysiaDemo }) + +function ElysiaDemo() { + const nameId = useId() + const [ready, setReady] = useState(false) + const [message, setMessage] = useState('') + const [error, setError] = useState('') + const [pending, setPending] = useState(false) + + useEffect(() => setReady(true), []) + + async function greet(event: FormEvent) { + event.preventDefault() + if (pending) return + const name = new FormData(event.currentTarget).get('name') + if (typeof name !== 'string' || !name.trim()) { + setError('Enter a name.') + return + } + setPending(true) + setMessage('') + setError('') + try { + const client = treaty(window.location.origin) + const response = await client.api.demo.elysia.greet.post({ + name: name.trim(), + }) + if (response.error) { + setError('Could not get a greeting. Try again.') + } else { + setMessage(response.data.message) + } + } catch { + setError('Could not get a greeting. Check your connection and try again.') + } finally { + setPending(false) + } + } + + return ( +
+

Elysia greeting

+
void greet(event)}> + + + +
+ {message && {message}} + {error &&

{error}

} +
+ ) +} diff --git a/packages/create/src/frameworks/react/add-ons/elysia/assets/src/styles/elysia.css b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/styles/elysia.css new file mode 100644 index 00000000..6975c66c --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/assets/src/styles/elysia.css @@ -0,0 +1,47 @@ +.elysia-demo { + max-width: 36rem; + margin: 3rem auto; + padding: 1.5rem; + color: var(--sea-ink, #1f2937); +} +.elysia-demo h1 { + font-size: 1.75rem; + margin: 0 0 1.5rem; +} +.elysia-demo form { + display: grid; + gap: 0.75rem; +} +.elysia-demo input { + padding: 0.65rem; + border: 1px solid var(--line, #94a3b8); + border-radius: 0.4rem; + background: var(--surface, #fff); + color: inherit; + font: inherit; +} +.elysia-demo button { + justify-self: start; + padding: 0.65rem 1rem; + border: 1px solid var(--line, #94a3b8); + border-radius: 0.4rem; + background: color-mix( + in oklab, + var(--lagoon, #4fb8b2) 22%, + var(--surface-strong, #fff) + ); + color: var(--sea-ink, #173a40); + font: inherit; + cursor: pointer; +} +.elysia-demo button:disabled { + opacity: 0.6; + cursor: default; +} +.elysia-demo output { + display: block; + margin-top: 1.5rem; +} +.elysia-demo [role='alert'] { + margin-top: 1.5rem; +} diff --git a/packages/create/src/frameworks/react/add-ons/elysia/info.json b/packages/create/src/frameworks/react/add-ons/elysia/info.json new file mode 100644 index 00000000..0a8fc04d --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/info.json @@ -0,0 +1,21 @@ +{ + "name": "Elysia + Eden", + "description": "Typed same-origin API routes with Elysia and Eden Treaty.", + "phase": "add-on", + "type": "add-on", + "category": "api", + "modes": [ + "file-router" + ], + "link": "https://elysiajs.com/integrations/tanstack-start", + "color": "#A78BFA", + "routes": [ + { + "icon": "Network", + "url": "/demo/elysia", + "name": "Elysia", + "path": "src/routes/demo/elysia.tsx", + "jsName": "ElysiaDemo" + } + ] +} diff --git a/packages/create/src/frameworks/react/add-ons/elysia/package.json b/packages/create/src/frameworks/react/add-ons/elysia/package.json new file mode 100644 index 00000000..9306c730 --- /dev/null +++ b/packages/create/src/frameworks/react/add-ons/elysia/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "elysia": "^1.4.31", + "@elysiajs/eden": "^1.4.9", + "@sinclair/typebox": "^0.34.41", + "openapi-types": "^12.1.3", + "file-type": "^21.3.2" + } +}