Skip to content

Commit f80fc07

Browse files
committed
examples: add an new example for typed readable streams in TanStack Start
1 parent 0d3bba8 commit f80fc07

File tree

10 files changed

+359
-0
lines changed

10 files changed

+359
-0
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "tanstack-start-typed-readable-stream",
3+
"private": true,
4+
"sideEffects": false,
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite dev",
8+
"build": "vite build && tsc --noEmit",
9+
"start": "vite start"
10+
},
11+
"dependencies": {
12+
"@tanstack/react-router": "^1.132.33",
13+
"@tanstack/react-router-devtools": "^1.132.33",
14+
"@tanstack/react-start": "^1.132.36",
15+
"react": "^19.0.0",
16+
"react-dom": "^19.0.0",
17+
"zod": "^3.24.2"
18+
},
19+
"devDependencies": {
20+
"@types/node": "^22.5.4",
21+
"@types/react": "^19.0.8",
22+
"@types/react-dom": "^19.0.3",
23+
"@vitejs/plugin-react": "^4.3.4",
24+
"typescript": "^5.7.2",
25+
"vite": "^7.1.7",
26+
"vite-tsconfig-paths": "^5.1.4"
27+
}
28+
}
15 KB
Binary file not shown.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/* eslint-disable */
2+
3+
// @ts-nocheck
4+
5+
// noinspection JSUnusedGlobalSymbols
6+
7+
// This file was automatically generated by TanStack Router.
8+
// You should NOT make any changes in this file as it will be overwritten.
9+
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10+
11+
import { Route as rootRouteImport } from './routes/__root'
12+
import { Route as IndexRouteImport } from './routes/index'
13+
14+
const IndexRoute = IndexRouteImport.update({
15+
id: '/',
16+
path: '/',
17+
getParentRoute: () => rootRouteImport,
18+
} as any)
19+
20+
export interface FileRoutesByFullPath {
21+
'/': typeof IndexRoute
22+
}
23+
export interface FileRoutesByTo {
24+
'/': typeof IndexRoute
25+
}
26+
export interface FileRoutesById {
27+
__root__: typeof rootRouteImport
28+
'/': typeof IndexRoute
29+
}
30+
export interface FileRouteTypes {
31+
fileRoutesByFullPath: FileRoutesByFullPath
32+
fullPaths: '/'
33+
fileRoutesByTo: FileRoutesByTo
34+
to: '/'
35+
id: '__root__' | '/'
36+
fileRoutesById: FileRoutesById
37+
}
38+
export interface RootRouteChildren {
39+
IndexRoute: typeof IndexRoute
40+
}
41+
42+
declare module '@tanstack/react-router' {
43+
interface FileRoutesByPath {
44+
'/': {
45+
id: '/'
46+
path: '/'
47+
fullPath: '/'
48+
preLoaderRoute: typeof IndexRouteImport
49+
parentRoute: typeof rootRouteImport
50+
}
51+
}
52+
}
53+
54+
const rootRouteChildren: RootRouteChildren = {
55+
IndexRoute: IndexRoute,
56+
}
57+
export const routeTree = rootRouteImport
58+
._addFileChildren(rootRouteChildren)
59+
._addFileTypes<FileRouteTypes>()
60+
61+
import type { getRouter } from './router.tsx'
62+
import type { createStart } from '@tanstack/react-start'
63+
declare module '@tanstack/react-start' {
64+
interface Register {
65+
ssr: true
66+
router: Awaited<ReturnType<typeof getRouter>>
67+
}
68+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { createRouter } from '@tanstack/react-router'
2+
import { routeTree } from './routeTree.gen'
3+
4+
export function getRouter() {
5+
const router = createRouter({
6+
routeTree,
7+
defaultPreload: 'intent',
8+
defaultErrorComponent: (err) => <p>{err.error.stack}</p>,
9+
defaultNotFoundComponent: () => <p>not found</p>,
10+
scrollRestoration: true,
11+
})
12+
13+
return router
14+
}
15+
16+
declare module '@tanstack/react-router' {
17+
interface Register {
18+
router: ReturnType<typeof getRouter>
19+
}
20+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/// <reference types="vite/client" />
2+
import * as React from 'react'
3+
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'
4+
import {
5+
HeadContent,
6+
Link,
7+
Outlet,
8+
Scripts,
9+
createRootRoute,
10+
} from '@tanstack/react-router'
11+
import appCss from '~/styles/app.css?url'
12+
13+
export const Route = createRootRoute({
14+
head: () => ({
15+
links: [{ rel: 'stylesheet', href: appCss }],
16+
}),
17+
component: RootComponent,
18+
})
19+
20+
function RootComponent() {
21+
return (
22+
<RootDocument>
23+
<Outlet />
24+
</RootDocument>
25+
)
26+
}
27+
28+
function RootDocument({ children }: { children: React.ReactNode }) {
29+
return (
30+
<html>
31+
<head>
32+
<HeadContent />
33+
</head>
34+
<body>
35+
<div>
36+
<Link to="/">Index</Link>
37+
<Link to="/about">About</Link>
38+
</div>
39+
40+
{children}
41+
<TanStackRouterDevtools position="bottom-right" />
42+
<Scripts />
43+
</body>
44+
</html>
45+
)
46+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { createFileRoute } from '@tanstack/react-router'
2+
import { createServerFn } from '@tanstack/react-start'
3+
import { useCallback, useState } from 'react'
4+
import { z } from 'zod'
5+
6+
// This schema will be used to define the type
7+
// of each chunk in the `ReadableStream`.
8+
// (It mimics OpenAi's streaming response format.)
9+
const textPartSchema = z.object({
10+
choices: z.array(
11+
z.object({
12+
delta: z.object({
13+
content: z.string().optional(),
14+
}),
15+
index: z.number(),
16+
finish_reason: z.string().nullable(),
17+
}),
18+
),
19+
})
20+
21+
export type TextPart = z.infer<typeof textPartSchema>
22+
23+
function sleep(ms: number) {
24+
return new Promise((resolve) => setTimeout(resolve, ms))
25+
}
26+
27+
const streamingResponseFn = createServerFn({
28+
method: 'GET',
29+
}).handler(async () => {
30+
const messages = Array.from({ length: 10 }, () =>
31+
Math.floor(Math.random() * 100),
32+
).map((n, i) =>
33+
textPartSchema.parse({
34+
choices: [
35+
{
36+
delta: { content: `Number #${i + 1}: ${n}\n` },
37+
index: i,
38+
finish_reason: null,
39+
},
40+
],
41+
}),
42+
)
43+
44+
// This `ReadableStream` is typed, so each chunk
45+
// will be of type `TextPart`.
46+
const stream = new ReadableStream<TextPart>({
47+
async start(controller) {
48+
for (const message of messages) {
49+
await sleep(500)
50+
controller.enqueue(message)
51+
}
52+
sleep(500)
53+
controller.close()
54+
},
55+
})
56+
57+
return stream
58+
})
59+
60+
export const Route = createFileRoute('/')({
61+
component: RouteComponent,
62+
})
63+
64+
function RouteComponent() {
65+
const [message, setMessage] = useState('')
66+
67+
const getStreamingResponse = useCallback(async () => {
68+
const response = await streamingResponseFn()
69+
70+
if (!response) {
71+
return
72+
}
73+
74+
const reader = response.getReader()
75+
let done = false
76+
setMessage('')
77+
while (!done) {
78+
const { value, done: doneReading } = await reader.read()
79+
done = doneReading
80+
if (value) {
81+
// Notice how we know the value of `chunk` (`TextPart | undefined`)
82+
// here, because it's coming from the typed `ReadableStream`
83+
const chunk = value?.choices[0].delta.content
84+
if (chunk) {
85+
setMessage((prev) => prev + chunk)
86+
}
87+
}
88+
}
89+
}, [])
90+
91+
return (
92+
<main>
93+
<h1>Typed Readable Stream</h1>
94+
<button onClick={() => getStreamingResponse()}>
95+
Get 10 random numbers
96+
</button>
97+
<pre>{message}</pre>
98+
</main>
99+
)
100+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
body {
2+
font-family:
3+
Gordita, Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue',
4+
sans-serif;
5+
}
6+
7+
a {
8+
margin-right: 1rem;
9+
}
10+
11+
main {
12+
text-align: center;
13+
padding: 1em;
14+
margin: 0 auto;
15+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"include": ["**/*.ts", "**/*.tsx", "public/script*.js"],
3+
"compilerOptions": {
4+
"strict": true,
5+
"esModuleInterop": true,
6+
"jsx": "react-jsx",
7+
"module": "ESNext",
8+
"moduleResolution": "Bundler",
9+
"lib": ["DOM", "DOM.Iterable", "ES2022"],
10+
"isolatedModules": true,
11+
"resolveJsonModule": true,
12+
"skipLibCheck": true,
13+
"target": "ES2022",
14+
"allowJs": true,
15+
"forceConsistentCasingInFileNames": true,
16+
"baseUrl": ".",
17+
"paths": {
18+
"~/*": ["./src/*"]
19+
},
20+
"noEmit": true
21+
}
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
2+
import { defineConfig } from 'vite'
3+
import tsConfigPaths from 'vite-tsconfig-paths'
4+
import viteReact from '@vitejs/plugin-react'
5+
6+
export default defineConfig({
7+
server: {
8+
port: 3000,
9+
},
10+
plugins: [
11+
tsConfigPaths({
12+
projects: ['./tsconfig.json'],
13+
}),
14+
tanstackStart(),
15+
viteReact(),
16+
],
17+
})

pnpm-lock.yaml

Lines changed: 43 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)