ras-stack 0.39.5 → 0.40.0
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.
- package/README.md +17 -8
- package/dist/auth/settings.d.ts +1 -1
- package/dist/auth/settings.js +1 -1
- package/dist/auth/settings.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +3 -1
- package/dist/cli.js.map +1 -1
- package/dist/create/index.d.ts +1 -0
- package/dist/create/index.js +69 -0
- package/dist/create/index.js.map +1 -0
- package/dist/runtime/dev.js +1 -1
- package/dist/runtime/dev.js.map +1 -1
- package/dist/runtime/index.js +77 -13
- package/dist/runtime/index.js.map +1 -1
- package/examples/full-stack/.env.example +16 -0
- package/examples/full-stack/.oxfmtrc.json +7 -0
- package/examples/full-stack/Dockerfile +37 -0
- package/examples/full-stack/Dockerfile.standalone +28 -0
- package/examples/full-stack/centrifugo.json +14 -0
- package/examples/full-stack/dockerignore.template +8 -0
- package/examples/full-stack/drizzle/0000_production_reference.sql +114 -0
- package/examples/full-stack/drizzle/meta/_journal.json +13 -0
- package/examples/full-stack/e2e/full-stack.spec.ts +45 -0
- package/examples/full-stack/gitignore.template +9 -0
- package/examples/full-stack/oxlint.json +4 -0
- package/examples/full-stack/package.json +58 -0
- package/examples/full-stack/playwright.config.ts +7 -0
- package/examples/full-stack/pnpm-workspace.template.yaml +12 -0
- package/examples/full-stack/ras-stack.assets.json +4 -0
- package/examples/full-stack/scripts/containerRuntime.ts +29 -0
- package/examples/full-stack/scripts/database.test.ts +121 -0
- package/examples/full-stack/scripts/database.ts +105 -0
- package/examples/full-stack/src/client/auth.ts +3 -0
- package/examples/full-stack/src/client/queries.ts +4 -0
- package/examples/full-stack/src/client/queryClient.ts +1 -0
- package/examples/full-stack/src/client/useRealtime.ts +20 -0
- package/examples/full-stack/src/posthog.ts +17 -0
- package/examples/full-stack/src/routeTree.gen.ts +230 -0
- package/examples/full-stack/src/router.tsx +17 -0
- package/examples/full-stack/src/routes/__root.tsx +38 -0
- package/examples/full-stack/src/routes/api/auth.$.ts +8 -0
- package/examples/full-stack/src/routes/api/centrifugo.connect.ts +29 -0
- package/examples/full-stack/src/routes/api/health.ts +6 -0
- package/examples/full-stack/src/routes/api/live.ts +5 -0
- package/examples/full-stack/src/routes/api/ready.ts +30 -0
- package/examples/full-stack/src/routes/api/uploads.$id.ts +40 -0
- package/examples/full-stack/src/routes/api/uploads.ts +25 -0
- package/examples/full-stack/src/routes/index.tsx +187 -0
- package/examples/full-stack/src/server/app.test.ts +24 -0
- package/examples/full-stack/src/server/app.ts +172 -0
- package/examples/full-stack/src/server/auth-flow.test.ts +148 -0
- package/examples/full-stack/src/server/auth.ts +60 -0
- package/examples/full-stack/src/server/environment.test.ts +43 -0
- package/examples/full-stack/src/server/environment.ts +67 -0
- package/examples/full-stack/src/server/fns.ts +38 -0
- package/examples/full-stack/src/server/messages.test.ts +38 -0
- package/examples/full-stack/src/server/messages.ts +28 -0
- package/examples/full-stack/src/server/migration.test.ts +34 -0
- package/examples/full-stack/src/server/outbox.test.ts +130 -0
- package/examples/full-stack/src/server/outbox.ts +110 -0
- package/examples/full-stack/src/server/posthog.test.ts +30 -0
- package/examples/full-stack/src/server/rate-limit.test.ts +32 -0
- package/examples/full-stack/src/server/rate-limit.ts +29 -0
- package/examples/full-stack/src/server/rpc.ts +16 -0
- package/examples/full-stack/src/server/schema.ts +134 -0
- package/examples/full-stack/src/server/session.test.ts +56 -0
- package/examples/full-stack/src/server/session.ts +13 -0
- package/examples/full-stack/src/server/uploads.test.ts +142 -0
- package/examples/full-stack/src/server/uploads.ts +199 -0
- package/examples/full-stack/src/start.ts +12 -0
- package/examples/full-stack/src/styles.css +42 -0
- package/examples/full-stack/tsconfig.json +8 -0
- package/examples/full-stack/vite.config.ts +31 -0
- package/examples/full-stack/vitest.config.ts +3 -0
- package/package.json +10 -8
|
@@ -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,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
|
+
})
|