ras-stack 0.39.5 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +17 -8
  2. package/dist/auth/settings.d.ts +1 -1
  3. package/dist/auth/settings.js +1 -1
  4. package/dist/auth/settings.js.map +1 -1
  5. package/dist/cli.d.ts +2 -0
  6. package/dist/cli.js +3 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/create/index.d.ts +1 -0
  9. package/dist/create/index.js +69 -0
  10. package/dist/create/index.js.map +1 -0
  11. package/dist/runtime/dev.js +1 -1
  12. package/dist/runtime/dev.js.map +1 -1
  13. package/dist/runtime/index.js +77 -13
  14. package/dist/runtime/index.js.map +1 -1
  15. package/examples/full-stack/.env.example +16 -0
  16. package/examples/full-stack/.oxfmtrc.json +7 -0
  17. package/examples/full-stack/Dockerfile +37 -0
  18. package/examples/full-stack/Dockerfile.standalone +28 -0
  19. package/examples/full-stack/centrifugo.json +14 -0
  20. package/examples/full-stack/dockerignore.template +8 -0
  21. package/examples/full-stack/drizzle/0000_production_reference.sql +114 -0
  22. package/examples/full-stack/drizzle/meta/_journal.json +13 -0
  23. package/examples/full-stack/e2e/full-stack.spec.ts +45 -0
  24. package/examples/full-stack/gitignore.template +9 -0
  25. package/examples/full-stack/oxlint.json +4 -0
  26. package/examples/full-stack/package.json +58 -0
  27. package/examples/full-stack/playwright.config.ts +7 -0
  28. package/examples/full-stack/pnpm-workspace.template.yaml +13 -0
  29. package/examples/full-stack/ras-stack.assets.json +4 -0
  30. package/examples/full-stack/scripts/containerRuntime.ts +29 -0
  31. package/examples/full-stack/scripts/database.test.ts +121 -0
  32. package/examples/full-stack/scripts/database.ts +105 -0
  33. package/examples/full-stack/src/client/auth.ts +3 -0
  34. package/examples/full-stack/src/client/queries.ts +4 -0
  35. package/examples/full-stack/src/client/queryClient.ts +1 -0
  36. package/examples/full-stack/src/client/useRealtime.ts +20 -0
  37. package/examples/full-stack/src/posthog.ts +17 -0
  38. package/examples/full-stack/src/routeTree.gen.ts +230 -0
  39. package/examples/full-stack/src/router.tsx +17 -0
  40. package/examples/full-stack/src/routes/__root.tsx +38 -0
  41. package/examples/full-stack/src/routes/api/auth.$.ts +8 -0
  42. package/examples/full-stack/src/routes/api/centrifugo.connect.ts +29 -0
  43. package/examples/full-stack/src/routes/api/health.ts +6 -0
  44. package/examples/full-stack/src/routes/api/live.ts +5 -0
  45. package/examples/full-stack/src/routes/api/ready.ts +30 -0
  46. package/examples/full-stack/src/routes/api/uploads.$id.ts +40 -0
  47. package/examples/full-stack/src/routes/api/uploads.ts +25 -0
  48. package/examples/full-stack/src/routes/index.tsx +187 -0
  49. package/examples/full-stack/src/server/app.test.ts +24 -0
  50. package/examples/full-stack/src/server/app.ts +172 -0
  51. package/examples/full-stack/src/server/auth-flow.test.ts +148 -0
  52. package/examples/full-stack/src/server/auth.ts +60 -0
  53. package/examples/full-stack/src/server/environment.test.ts +43 -0
  54. package/examples/full-stack/src/server/environment.ts +67 -0
  55. package/examples/full-stack/src/server/fns.ts +38 -0
  56. package/examples/full-stack/src/server/messages.test.ts +38 -0
  57. package/examples/full-stack/src/server/messages.ts +28 -0
  58. package/examples/full-stack/src/server/migration.test.ts +34 -0
  59. package/examples/full-stack/src/server/outbox.test.ts +130 -0
  60. package/examples/full-stack/src/server/outbox.ts +110 -0
  61. package/examples/full-stack/src/server/posthog.test.ts +30 -0
  62. package/examples/full-stack/src/server/rate-limit.test.ts +32 -0
  63. package/examples/full-stack/src/server/rate-limit.ts +29 -0
  64. package/examples/full-stack/src/server/rpc.ts +16 -0
  65. package/examples/full-stack/src/server/schema.ts +134 -0
  66. package/examples/full-stack/src/server/session.test.ts +56 -0
  67. package/examples/full-stack/src/server/session.ts +13 -0
  68. package/examples/full-stack/src/server/uploads.test.ts +142 -0
  69. package/examples/full-stack/src/server/uploads.ts +199 -0
  70. package/examples/full-stack/src/start.ts +12 -0
  71. package/examples/full-stack/src/styles.css +42 -0
  72. package/examples/full-stack/tsconfig.json +8 -0
  73. package/examples/full-stack/vite.config.ts +31 -0
  74. package/examples/full-stack/vitest.config.ts +3 -0
  75. package/package.json +27 -22
@@ -0,0 +1,230 @@
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
+ import { Route as ApiHealthRouteImport } from './routes/api/health'
14
+ import { Route as ApiLiveRouteImport } from './routes/api/live'
15
+ import { Route as ApiReadyRouteImport } from './routes/api/ready'
16
+ import { Route as ApiUploadsRouteImport } from './routes/api/uploads'
17
+ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
18
+ import { Route as ApiCentrifugoConnectRouteImport } from './routes/api/centrifugo.connect'
19
+ import { Route as ApiUploadsIdRouteImport } from './routes/api/uploads.$id'
20
+
21
+ const IndexRoute = IndexRouteImport.update({
22
+ id: '/',
23
+ path: '/',
24
+ getParentRoute: () => rootRouteImport,
25
+ } as any)
26
+ const ApiHealthRoute = ApiHealthRouteImport.update({
27
+ id: '/api/health',
28
+ path: '/api/health',
29
+ getParentRoute: () => rootRouteImport,
30
+ } as any)
31
+ const ApiLiveRoute = ApiLiveRouteImport.update({
32
+ id: '/api/live',
33
+ path: '/api/live',
34
+ getParentRoute: () => rootRouteImport,
35
+ } as any)
36
+ const ApiReadyRoute = ApiReadyRouteImport.update({
37
+ id: '/api/ready',
38
+ path: '/api/ready',
39
+ getParentRoute: () => rootRouteImport,
40
+ } as any)
41
+ const ApiUploadsRoute = ApiUploadsRouteImport.update({
42
+ id: '/api/uploads',
43
+ path: '/api/uploads',
44
+ getParentRoute: () => rootRouteImport,
45
+ } as any)
46
+ const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
47
+ id: '/api/auth/$',
48
+ path: '/api/auth/$',
49
+ getParentRoute: () => rootRouteImport,
50
+ } as any)
51
+ const ApiCentrifugoConnectRoute = ApiCentrifugoConnectRouteImport.update({
52
+ id: '/api/centrifugo/connect',
53
+ path: '/api/centrifugo/connect',
54
+ getParentRoute: () => rootRouteImport,
55
+ } as any)
56
+ const ApiUploadsIdRoute = ApiUploadsIdRouteImport.update({
57
+ id: '/$id',
58
+ path: '/$id',
59
+ getParentRoute: () => ApiUploadsRoute,
60
+ } as any)
61
+
62
+ export interface FileRoutesByFullPath {
63
+ '/': typeof IndexRoute
64
+ '/api/health': typeof ApiHealthRoute
65
+ '/api/live': typeof ApiLiveRoute
66
+ '/api/ready': typeof ApiReadyRoute
67
+ '/api/uploads': typeof ApiUploadsRouteWithChildren
68
+ '/api/auth/$': typeof ApiAuthSplatRoute
69
+ '/api/centrifugo/connect': typeof ApiCentrifugoConnectRoute
70
+ '/api/uploads/$id': typeof ApiUploadsIdRoute
71
+ }
72
+ export interface FileRoutesByTo {
73
+ '/': typeof IndexRoute
74
+ '/api/health': typeof ApiHealthRoute
75
+ '/api/live': typeof ApiLiveRoute
76
+ '/api/ready': typeof ApiReadyRoute
77
+ '/api/uploads': typeof ApiUploadsRouteWithChildren
78
+ '/api/auth/$': typeof ApiAuthSplatRoute
79
+ '/api/centrifugo/connect': typeof ApiCentrifugoConnectRoute
80
+ '/api/uploads/$id': typeof ApiUploadsIdRoute
81
+ }
82
+ export interface FileRoutesById {
83
+ __root__: typeof rootRouteImport
84
+ '/': typeof IndexRoute
85
+ '/api/health': typeof ApiHealthRoute
86
+ '/api/live': typeof ApiLiveRoute
87
+ '/api/ready': typeof ApiReadyRoute
88
+ '/api/uploads': typeof ApiUploadsRouteWithChildren
89
+ '/api/auth/$': typeof ApiAuthSplatRoute
90
+ '/api/centrifugo/connect': typeof ApiCentrifugoConnectRoute
91
+ '/api/uploads/$id': typeof ApiUploadsIdRoute
92
+ }
93
+ export interface FileRouteTypes {
94
+ fileRoutesByFullPath: FileRoutesByFullPath
95
+ fullPaths:
96
+ | '/'
97
+ | '/api/health'
98
+ | '/api/live'
99
+ | '/api/ready'
100
+ | '/api/uploads'
101
+ | '/api/auth/$'
102
+ | '/api/centrifugo/connect'
103
+ | '/api/uploads/$id'
104
+ fileRoutesByTo: FileRoutesByTo
105
+ to:
106
+ | '/'
107
+ | '/api/health'
108
+ | '/api/live'
109
+ | '/api/ready'
110
+ | '/api/uploads'
111
+ | '/api/auth/$'
112
+ | '/api/centrifugo/connect'
113
+ | '/api/uploads/$id'
114
+ id:
115
+ | '__root__'
116
+ | '/'
117
+ | '/api/health'
118
+ | '/api/live'
119
+ | '/api/ready'
120
+ | '/api/uploads'
121
+ | '/api/auth/$'
122
+ | '/api/centrifugo/connect'
123
+ | '/api/uploads/$id'
124
+ fileRoutesById: FileRoutesById
125
+ }
126
+ export interface RootRouteChildren {
127
+ IndexRoute: typeof IndexRoute
128
+ ApiHealthRoute: typeof ApiHealthRoute
129
+ ApiLiveRoute: typeof ApiLiveRoute
130
+ ApiReadyRoute: typeof ApiReadyRoute
131
+ ApiUploadsRoute: typeof ApiUploadsRouteWithChildren
132
+ ApiAuthSplatRoute: typeof ApiAuthSplatRoute
133
+ ApiCentrifugoConnectRoute: typeof ApiCentrifugoConnectRoute
134
+ }
135
+
136
+ declare module '@tanstack/react-router' {
137
+ interface FileRoutesByPath {
138
+ '/': {
139
+ id: '/'
140
+ path: '/'
141
+ fullPath: '/'
142
+ preLoaderRoute: typeof IndexRouteImport
143
+ parentRoute: typeof rootRouteImport
144
+ }
145
+ '/api/health': {
146
+ id: '/api/health'
147
+ path: '/api/health'
148
+ fullPath: '/api/health'
149
+ preLoaderRoute: typeof ApiHealthRouteImport
150
+ parentRoute: typeof rootRouteImport
151
+ }
152
+ '/api/live': {
153
+ id: '/api/live'
154
+ path: '/api/live'
155
+ fullPath: '/api/live'
156
+ preLoaderRoute: typeof ApiLiveRouteImport
157
+ parentRoute: typeof rootRouteImport
158
+ }
159
+ '/api/ready': {
160
+ id: '/api/ready'
161
+ path: '/api/ready'
162
+ fullPath: '/api/ready'
163
+ preLoaderRoute: typeof ApiReadyRouteImport
164
+ parentRoute: typeof rootRouteImport
165
+ }
166
+ '/api/uploads': {
167
+ id: '/api/uploads'
168
+ path: '/api/uploads'
169
+ fullPath: '/api/uploads'
170
+ preLoaderRoute: typeof ApiUploadsRouteImport
171
+ parentRoute: typeof rootRouteImport
172
+ }
173
+ '/api/auth/$': {
174
+ id: '/api/auth/$'
175
+ path: '/api/auth/$'
176
+ fullPath: '/api/auth/$'
177
+ preLoaderRoute: typeof ApiAuthSplatRouteImport
178
+ parentRoute: typeof rootRouteImport
179
+ }
180
+ '/api/centrifugo/connect': {
181
+ id: '/api/centrifugo/connect'
182
+ path: '/api/centrifugo/connect'
183
+ fullPath: '/api/centrifugo/connect'
184
+ preLoaderRoute: typeof ApiCentrifugoConnectRouteImport
185
+ parentRoute: typeof rootRouteImport
186
+ }
187
+ '/api/uploads/$id': {
188
+ id: '/api/uploads/$id'
189
+ path: '/$id'
190
+ fullPath: '/api/uploads/$id'
191
+ preLoaderRoute: typeof ApiUploadsIdRouteImport
192
+ parentRoute: typeof ApiUploadsRoute
193
+ }
194
+ }
195
+ }
196
+
197
+ interface ApiUploadsRouteChildren {
198
+ ApiUploadsIdRoute: typeof ApiUploadsIdRoute
199
+ }
200
+
201
+ const ApiUploadsRouteChildren: ApiUploadsRouteChildren = {
202
+ ApiUploadsIdRoute: ApiUploadsIdRoute,
203
+ }
204
+
205
+ const ApiUploadsRouteWithChildren = ApiUploadsRoute._addFileChildren(
206
+ ApiUploadsRouteChildren,
207
+ )
208
+
209
+ const rootRouteChildren: RootRouteChildren = {
210
+ IndexRoute: IndexRoute,
211
+ ApiHealthRoute: ApiHealthRoute,
212
+ ApiLiveRoute: ApiLiveRoute,
213
+ ApiReadyRoute: ApiReadyRoute,
214
+ ApiUploadsRoute: ApiUploadsRouteWithChildren,
215
+ ApiAuthSplatRoute: ApiAuthSplatRoute,
216
+ ApiCentrifugoConnectRoute: ApiCentrifugoConnectRoute,
217
+ }
218
+ export const routeTree = rootRouteImport
219
+ ._addFileChildren(rootRouteChildren)
220
+ ._addFileTypes<FileRouteTypes>()
221
+
222
+ import type { getRouter } from './router.tsx'
223
+ import type { startInstance } from './start.ts'
224
+ declare module '@tanstack/react-start' {
225
+ interface Register {
226
+ ssr: true
227
+ router: Awaited<ReturnType<typeof getRouter>>
228
+ config: Awaited<ReturnType<typeof startInstance.getOptions>>
229
+ }
230
+ }
@@ -0,0 +1,17 @@
1
+ import { createRouter } from '@tanstack/react-router'
2
+ import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query'
3
+ import { createQueryClient } from './client/queryClient'
4
+ import { routeTree } from './routeTree.gen'
5
+
6
+ export function getRouter() {
7
+ const queryClient = createQueryClient()
8
+ const router = createRouter({ routeTree, context: { queryClient }, defaultPreload: 'intent' })
9
+ setupRouterSsrQueryIntegration({ router, queryClient })
10
+ return router
11
+ }
12
+
13
+ declare module '@tanstack/react-router' {
14
+ interface Register {
15
+ router: ReturnType<typeof getRouter>
16
+ }
17
+ }
@@ -0,0 +1,38 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+ import { HeadContent, Outlet, Scripts, createRootRouteWithContext } from '@tanstack/react-router'
3
+ import { postHogEnvironment } from 'ras-stack/posthog'
4
+ import { PostHogIntegration } from 'ras-stack/posthog/react'
5
+ import styles from '../styles.css?url'
6
+
7
+ const posthog = postHogEnvironment({
8
+ projectToken: import.meta.env.VITE_POSTHOG_PROJECT_TOKEN,
9
+ host: import.meta.env.VITE_POSTHOG_HOST,
10
+ })
11
+
12
+ export const Route = createRootRouteWithContext<{ queryClient: QueryClient }>()({
13
+ head: () => ({
14
+ meta: [
15
+ { charSet: 'utf-8' },
16
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
17
+ { title: 'ras-stack full-stack example' },
18
+ ],
19
+ links: [{ rel: 'stylesheet', href: styles }],
20
+ }),
21
+ component: Root,
22
+ })
23
+
24
+ function Root() {
25
+ return (
26
+ <html lang="en">
27
+ <head>
28
+ <HeadContent />
29
+ </head>
30
+ <body>
31
+ <PostHogIntegration environment={posthog}>
32
+ <Outlet />
33
+ </PostHogIntegration>
34
+ <Scripts />
35
+ </body>
36
+ </html>
37
+ )
38
+ }
@@ -0,0 +1,8 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { app } from '../../server/app'
3
+
4
+ const handler = ({ request }: { request: Request }) => app().auth.handler(request)
5
+
6
+ export const Route = createFileRoute('/api/auth/$')({
7
+ server: { handlers: { GET: handler, POST: handler } },
8
+ })
@@ -0,0 +1,29 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import crypto from 'node:crypto'
3
+ import { requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
4
+ import { currentUser } from '../../server/session'
5
+
6
+ export const Route = createFileRoute('/api/centrifugo/connect')({
7
+ server: {
8
+ handlers: {
9
+ POST: async ({ request }) => {
10
+ if (!proxySecret(request)) return Response.json({ disconnect: { code: 4501, reason: 'unauthorized' } })
11
+ try {
12
+ requireTanStackMutationOrigin({ configured: [process.env.APP_URL], trustForwardedHeaders: true }, request)
13
+ } catch {
14
+ return Response.json({ disconnect: { code: 4501, reason: 'unauthorized' } })
15
+ }
16
+ const user = await currentUser(request)
17
+ return user
18
+ ? Response.json({ result: { user: user.id, info: { name: user.name } } })
19
+ : Response.json({ disconnect: { code: 4501, reason: 'unauthorized' } })
20
+ },
21
+ },
22
+ },
23
+ })
24
+
25
+ function proxySecret(request: Request) {
26
+ const expected = Buffer.from(process.env.CENTRIFUGO_PROXY_SECRET ?? '')
27
+ const received = Buffer.from(request.headers.get('x-proxy-secret') ?? '')
28
+ return expected.length > 0 && expected.length === received.length && crypto.timingSafeEqual(expected, received)
29
+ }
@@ -0,0 +1,6 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { readinessHandler } from './ready'
3
+
4
+ export const Route = createFileRoute('/api/health')({
5
+ server: { handlers: { GET: readinessHandler } },
6
+ })
@@ -0,0 +1,5 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+
3
+ export const Route = createFileRoute('/api/live')({
4
+ server: { handlers: { GET: () => Response.json({ status: 'ok' }, { headers: { 'Cache-Control': 'no-store' } }) } },
5
+ })
@@ -0,0 +1,30 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { sql } from 'drizzle-orm'
3
+ import { infrastructureFailure } from 'ras-stack/server'
4
+ import { tanStackHealthHandler } from 'ras-stack/tanstack/server'
5
+ import { app } from '../../server/app'
6
+ import { outboxStatus } from '../../server/outbox'
7
+
8
+ export const readinessHandler = tanStackHealthHandler(
9
+ async () => {
10
+ try {
11
+ app().database.get(sql`SELECT 1`)
12
+ const status = outboxStatus(app().database)
13
+ if (status.failed > 0) throw new Error(`Realtime outbox has ${status.failed} dead-lettered item(s)`)
14
+ await app().email?.verify()
15
+ } catch (error) {
16
+ console.error({ event: 'example_readiness_failed', error })
17
+ throw error
18
+ }
19
+ },
20
+ {
21
+ failure: (error) =>
22
+ infrastructureFailure(error, { code: 'dependency_unavailable', message: 'required dependency unavailable', retryable: true }),
23
+ },
24
+ )
25
+
26
+ export const Route = createFileRoute('/api/ready')({
27
+ server: {
28
+ handlers: { GET: readinessHandler },
29
+ },
30
+ })
@@ -0,0 +1,40 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
3
+ import { app } from '../../server/app'
4
+ import { limitAuthenticatedRequest } from '../../server/rate-limit'
5
+ import { requireCurrentUser } from '../../server/session'
6
+
7
+ export const Route = createFileRoute('/api/uploads/$id')({
8
+ server: {
9
+ handlers: {
10
+ HEAD: async ({ request, params }) => {
11
+ const user = await requireCurrentUser(request)
12
+ const upload = app().uploadStore.getOwned(params.id, user.id)
13
+ return upload ? new Response(null, { headers: headers(upload.offset, upload.length) }) : new Response(null, { status: 404 })
14
+ },
15
+ PATCH: async ({ request, params }) => {
16
+ requireTanStackMutationOrigin(
17
+ { configured: [app().environment.appUrl], trustForwardedHeaders: app().environment.trustProxy },
18
+ request,
19
+ )
20
+ const user = await requireCurrentUser(request)
21
+ await limitAuthenticatedRequest(request, 'upload-chunk', user.id, { window: 60, max: 120 })
22
+ const declaredLength = Number(request.headers.get('content-length'))
23
+ if (!Number.isSafeInteger(declaredLength) || declaredLength < 1 || declaredLength > app().environment.uploadMaxBytes) {
24
+ return new Response('Invalid content length', { status: 400 })
25
+ }
26
+ const chunk = new Uint8Array(await request.arrayBuffer())
27
+ if (chunk.length !== declaredLength) return new Response('Content length mismatch', { status: 400 })
28
+ const upload = await app().uploadStore.append(params.id, user.id, Number(request.headers.get('upload-offset')), chunk)
29
+ return new Response(null, { status: 204, headers: headers(upload.offset, upload.length) })
30
+ },
31
+ },
32
+ },
33
+ })
34
+
35
+ const headers = (offset: number, length: number) => ({
36
+ 'Cache-Control': 'no-store',
37
+ 'Tus-Resumable': '1.0.0',
38
+ 'Upload-Length': String(length),
39
+ 'Upload-Offset': String(offset),
40
+ })
@@ -0,0 +1,25 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { requireTanStackMutationOrigin } from 'ras-stack/tanstack/server'
3
+ import { app } from '../../server/app'
4
+ import { limitAuthenticatedRequest } from '../../server/rate-limit'
5
+ import { requireCurrentUser } from '../../server/session'
6
+
7
+ export const Route = createFileRoute('/api/uploads')({
8
+ server: {
9
+ handlers: {
10
+ POST: async ({ request }) => {
11
+ requireTanStackMutationOrigin(
12
+ { configured: [app().environment.appUrl], trustForwardedHeaders: app().environment.trustProxy },
13
+ request,
14
+ )
15
+ const user = await requireCurrentUser(request)
16
+ await limitAuthenticatedRequest(request, 'upload-create', user.id, { window: 60, max: 10 })
17
+ const id = app().uploadStore.create(user.id, Number(request.headers.get('upload-length')), request.headers.get('upload-metadata'))
18
+ return new Response(null, {
19
+ status: 201,
20
+ headers: { Location: `/api/uploads/${id}`, 'Tus-Resumable': '1.0.0', 'Cache-Control': 'no-store' },
21
+ })
22
+ },
23
+ },
24
+ },
25
+ })
@@ -0,0 +1,187 @@
1
+ import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import { useAuthAction } from 'ras-stack/auth/react'
4
+ import { useState } from 'react'
5
+ import { authClient } from '../client/auth'
6
+ import { snapshotQuery } from '../client/queries'
7
+ import { queryErrorMessage } from '../client/queryClient'
8
+ import { useRealtime } from '../client/useRealtime'
9
+ import { addMessage } from '../server/fns'
10
+
11
+ export const Route = createFileRoute('/')({
12
+ loader: ({ context }) => context.queryClient.ensureQueryData(snapshotQuery()),
13
+ component: Home,
14
+ })
15
+
16
+ function Home() {
17
+ const { data } = useSuspenseQuery(snapshotQuery())
18
+ const queryClient = useQueryClient()
19
+ useRealtime(Boolean(data.user) && data.realtimeEnabled)
20
+ return (
21
+ <main>
22
+ <h1>ras-stack full-stack example</h1>
23
+ <p className="status">Better Auth, Drizzle migrations, durable uploads, transactional realtime, SMTP, and production lifecycle</p>
24
+ {data.user ? (
25
+ <Messages
26
+ name={data.user.name}
27
+ signOut={async () => {
28
+ await authClient.signOut()
29
+ await queryClient.invalidateQueries()
30
+ }}
31
+ />
32
+ ) : (
33
+ <Authentication emailConfigured={data.emailConfigured} />
34
+ )}
35
+ <MessageList />
36
+ </main>
37
+ )
38
+ }
39
+
40
+ function Authentication({ emailConfigured }: { emailConfigured: boolean }) {
41
+ const [mode, setMode] = useState<'sign-up' | 'sign-in'>('sign-up')
42
+ const [notice, setNotice] = useState('')
43
+ const queryClient = useQueryClient()
44
+ const action = useAuthAction()
45
+ return (
46
+ <form
47
+ onSubmit={async (event) => {
48
+ event.preventDefault()
49
+ setNotice('')
50
+ const form = new FormData(event.currentTarget)
51
+ const name = formText(form, 'name')
52
+ const email = formText(form, 'email')
53
+ const password = formText(form, 'password')
54
+ const result = await action.run(async () => {
55
+ const response =
56
+ mode === 'sign-up'
57
+ ? await authClient.signUp.email({ name, email, password })
58
+ : await authClient.signIn.email({ email, password })
59
+ return response.error ? { error: { message: response.error.message } } : { data: response.data }
60
+ })
61
+ if (!result.error) {
62
+ if (mode === 'sign-up' && emailConfigured) setNotice('Check your email to verify your account.')
63
+ await queryClient.invalidateQueries()
64
+ }
65
+ }}
66
+ >
67
+ <h2>{mode === 'sign-up' ? 'Create account' : 'Sign in'}</h2>
68
+ {mode === 'sign-up' && (
69
+ <label>
70
+ Name
71
+ <input aria-label="Name" autoComplete="name" name="name" />
72
+ </label>
73
+ )}
74
+ <label>
75
+ Email
76
+ <input aria-label="Email" autoComplete="email" name="email" type="email" />
77
+ </label>
78
+ <label>
79
+ Password
80
+ <input
81
+ aria-label="Password"
82
+ autoComplete={mode === 'sign-up' ? 'new-password' : 'current-password'}
83
+ name="password"
84
+ type="password"
85
+ />
86
+ </label>
87
+ <button disabled={action.busy}>{mode === 'sign-up' ? 'Create account' : 'Sign in'}</button>
88
+ <button type="button" onClick={() => setMode(mode === 'sign-up' ? 'sign-in' : 'sign-up')}>
89
+ {mode === 'sign-up' ? 'Use an existing account' : 'Create a new account'}
90
+ </button>
91
+ {emailConfigured && (
92
+ <button
93
+ type="button"
94
+ onClick={async (event) => {
95
+ const form = event.currentTarget.form
96
+ if (!form) return
97
+ const email = formText(new FormData(form), 'email')
98
+ await authClient.requestPasswordReset({ email, redirectTo: '/' })
99
+ setNotice('If that account exists, a reset link has been sent.')
100
+ }}
101
+ >
102
+ Reset password
103
+ </button>
104
+ )}
105
+ {notice && <p className="status">{notice}</p>}
106
+ {action.error && <p className="error">{action.error}</p>}
107
+ </form>
108
+ )
109
+ }
110
+
111
+ function formText(form: FormData, name: string) {
112
+ const value = form.get(name)
113
+ return typeof value === 'string' ? value : ''
114
+ }
115
+
116
+ function Messages({ name, signOut }: { name: string; signOut: () => Promise<void> }) {
117
+ const [body, setBody] = useState('')
118
+ const queryClient = useQueryClient()
119
+ const mutation = useMutation({
120
+ mutationFn: () => addMessage({ data: { body } }),
121
+ onSuccess: async () => {
122
+ setBody('')
123
+ await queryClient.invalidateQueries({ queryKey: snapshotQuery().queryKey })
124
+ },
125
+ })
126
+ return (
127
+ <section>
128
+ <h2>Hello, {name}</h2>
129
+ <button onClick={() => void signOut()}>Sign out</button>
130
+ <form
131
+ onSubmit={(event) => {
132
+ event.preventDefault()
133
+ mutation.mutate()
134
+ }}
135
+ >
136
+ <label>
137
+ Message
138
+ <input aria-label="Message" value={body} onChange={(event) => setBody(event.target.value)} />
139
+ </label>
140
+ <button disabled={mutation.isPending}>Add message</button>
141
+ {mutation.error && <p className="error">{queryErrorMessage(mutation.error)}</p>}
142
+ </form>
143
+ <Upload />
144
+ </section>
145
+ )
146
+ }
147
+
148
+ function Upload() {
149
+ const [status, setStatus] = useState('No upload yet.')
150
+ return (
151
+ <label>
152
+ Upload a text file
153
+ <input
154
+ aria-label="Upload"
155
+ type="file"
156
+ accept="text/plain"
157
+ onChange={async (event) => {
158
+ const file = event.target.files?.[0]
159
+ if (!file) return
160
+ const { uploadWithTus } = await import('ras-stack/uploads')
161
+ const result = await uploadWithTus(
162
+ { endpoint: '/api/uploads', file, metadata: { filename: file.name, filetype: file.type } },
163
+ false,
164
+ )
165
+ setStatus(`Uploaded to ${result.uploadUrl}`)
166
+ }}
167
+ />
168
+ <span className="status">{status}</span>
169
+ </label>
170
+ )
171
+ }
172
+
173
+ function MessageList() {
174
+ const { data } = useSuspenseQuery(snapshotQuery())
175
+ return (
176
+ <section>
177
+ <h2>Messages</h2>
178
+ <ul>
179
+ {data.messages.map((message) => (
180
+ <li key={message.id}>
181
+ <strong>{message.author}:</strong> {message.body}
182
+ </li>
183
+ ))}
184
+ </ul>
185
+ </section>
186
+ )
187
+ }
@@ -0,0 +1,24 @@
1
+ import { expect, it, vi } from 'vitest'
2
+ import { app, closeApp } from './app'
3
+
4
+ it('closes every resource once and reports aggregate shutdown failure', async () => {
5
+ const failure = new Error('outbox failed')
6
+ const uploadClose = vi.fn().mockResolvedValue(undefined)
7
+ const publisherClose = vi.fn().mockResolvedValue(undefined)
8
+ const telemetryShutdown = vi.fn().mockResolvedValue(undefined)
9
+ const databaseClose = vi.fn()
10
+ const resources = {
11
+ outbox: { close: vi.fn().mockRejectedValue(failure) },
12
+ uploadStore: { close: uploadClose },
13
+ publisher: { close: publisherClose },
14
+ telemetry: { shutdown: telemetryShutdown },
15
+ database: { $client: { close: databaseClose } },
16
+ } as unknown as ReturnType<typeof app>
17
+ const closing = closeApp(resources)
18
+ expect(closeApp(resources)).toBe(closing)
19
+ await expect(closing).rejects.toMatchObject({ errors: [failure] })
20
+ expect(uploadClose).toHaveBeenCalledOnce()
21
+ expect(publisherClose).toHaveBeenCalledOnce()
22
+ expect(telemetryShutdown).toHaveBeenCalledOnce()
23
+ expect(databaseClose).toHaveBeenCalledOnce()
24
+ })