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.
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 +12 -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 +10 -8
@@ -0,0 +1,172 @@
1
+ import { existsSync, mkdirSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { persistedSecret, standardRateLimitOptions, standardSessionOptions } from 'ras-stack/auth'
4
+ import { openDrizzleSqlite, openSqliteClient, type OpenDrizzleSqliteOptions } from 'ras-stack/database/sqlite'
5
+ import { createSmtpDelivery, smtpConfigFromEnvironment } from 'ras-stack/email'
6
+ import { postHogEnvironment } from 'ras-stack/posthog'
7
+ import { createManagedPostHogServerTelemetry } from 'ras-stack/posthog/server'
8
+ import { CentrifugoPublisher } from 'ras-stack/realtime'
9
+ import { globalSingleton } from 'ras-stack/server'
10
+ import { createAuth } from './auth'
11
+ import { loadEnvironment } from './environment'
12
+ import { OutboxWorker } from './outbox'
13
+ import * as schema from './schema'
14
+ import { UploadStore } from './uploads'
15
+
16
+ export function app() {
17
+ return globalSingleton('ras-stack.example.full-stack', createApp)
18
+ }
19
+
20
+ function createApp() {
21
+ const environment = loadEnvironment()
22
+ mkdirSync(environment.dataDirectory, { recursive: true })
23
+ const databaseFile = path.join(environment.dataDirectory, 'example.sqlite')
24
+ prepareLegacyMessages(databaseFile)
25
+ const options: OpenDrizzleSqliteOptions<typeof schema> = {
26
+ file: databaseFile,
27
+ schema,
28
+ migrationsFolder: migrationsDirectory(),
29
+ }
30
+ const database = openDrizzleSqlite(options)
31
+ importLegacyMessages(database.$client)
32
+ const smtp = smtpConfigFromEnvironment()
33
+ const email = smtp ? createSmtpDelivery(smtp) : undefined
34
+ let publishFailure: unknown
35
+ const publisher = new CentrifugoPublisher({
36
+ apiUrl: environment.centrifugoApiUrl,
37
+ apiKey: environment.centrifugoApiKey,
38
+ maxConcurrentChannels: 1,
39
+ maxPendingChannels: 32,
40
+ onError: (error, channel) => {
41
+ publishFailure = error
42
+ console.error({ event: 'example_realtime_publish_failed', channel, error })
43
+ },
44
+ })
45
+ const telemetry = createManagedPostHogServerTelemetry({
46
+ environment: postHogEnvironment({
47
+ projectToken: process.env.VITE_POSTHOG_PROJECT_TOKEN,
48
+ host: process.env.VITE_POSTHOG_HOST,
49
+ }),
50
+ serviceName: 'ras-stack-example',
51
+ deploymentEnvironment: process.env.NODE_ENV,
52
+ onError: (error) => console.error({ event: 'example_telemetry_failed', error }),
53
+ })
54
+ const auth = createAuth({
55
+ database,
56
+ email,
57
+ environment,
58
+ secret: process.env.BETTER_AUTH_SECRET ?? persistedSecret({ directory: environment.dataDirectory, filename: 'auth.secret' }),
59
+ })
60
+ const outbox = new OutboxWorker({
61
+ database,
62
+ enabled: environment.realtimeEnabled,
63
+ onError: (error) => console.error({ event: 'example_outbox_drain_failed', error }),
64
+ publish: async (channel, payload) => {
65
+ publishFailure = undefined
66
+ if (!publisher.publish(channel, payload)) throw new Error('Realtime publisher rejected the outbox item')
67
+ await publisher.idle()
68
+ if (publishFailure) throw publishFailure
69
+ },
70
+ })
71
+ const uploadStore = new UploadStore({
72
+ database,
73
+ directory: path.join(environment.dataDirectory, 'uploads'),
74
+ globalMaxFiles: environment.uploadGlobalMaxFiles,
75
+ globalQuotaBytes: environment.uploadGlobalQuotaBytes,
76
+ maxBytes: environment.uploadMaxBytes,
77
+ onError: (error) => console.error({ event: 'example_upload_cleanup_failed', error }),
78
+ quotaBytes: environment.uploadQuotaBytes,
79
+ })
80
+ void telemetry.start()
81
+ outbox.start()
82
+ uploadStore.startCleanup()
83
+ const application = { auth, database, email, environment, outbox, publisher, telemetry, uploadStore }
84
+ if (!process.env.VITEST) installShutdown(application)
85
+ return application
86
+ }
87
+
88
+ function prepareLegacyMessages(file: string) {
89
+ const client = openSqliteClient(file)
90
+ try {
91
+ if (!tableExists(client, 'messages')) return
92
+ const columns = client.pragma('table_info(messages)') as Array<{ name: string }>
93
+ if (columns.some((column) => column.name === 'author_id')) return
94
+ if (tableExists(client, 'messages_legacy')) throw new Error('Both messages and messages_legacy exist; refusing ambiguous migration')
95
+ client.exec('ALTER TABLE messages RENAME TO messages_legacy')
96
+ } finally {
97
+ client.close()
98
+ }
99
+ }
100
+
101
+ function importLegacyMessages(client: ReturnType<typeof openSqliteClient>) {
102
+ if (!tableExists(client, 'messages_legacy')) return
103
+ client.transaction(() => {
104
+ const now = Date.now()
105
+ client
106
+ .prepare('INSERT OR IGNORE INTO user (id, name, email, email_verified, created_at, updated_at) VALUES (?, ?, ?, false, ?, ?)')
107
+ .run('legacy-import', 'Legacy import', 'legacy-import@invalid.example', now, now)
108
+ client.exec(`INSERT INTO messages (id, author_id, author, body, created_at)
109
+ SELECT id, 'legacy-import', author, body, created_at FROM messages_legacy;
110
+ DROP TABLE messages_legacy;`)
111
+ })()
112
+ }
113
+
114
+ function tableExists(client: ReturnType<typeof openSqliteClient>, name: string) {
115
+ return Boolean(client.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name))
116
+ }
117
+
118
+ function migrationsDirectory() {
119
+ const directories = [
120
+ path.resolve(import.meta.dirname, 'drizzle'),
121
+ path.resolve(import.meta.dirname, '..', 'drizzle'),
122
+ path.resolve(process.cwd(), 'drizzle'),
123
+ ]
124
+ const directory = directories.find((candidate) => existsSync(path.join(candidate, 'meta', '_journal.json')))
125
+ if (!directory) throw new Error(`Drizzle migrations are missing from ${directories.join(', ')}`)
126
+ return directory
127
+ }
128
+
129
+ const closing = new WeakMap<object, Promise<void>>()
130
+
131
+ export function closeApp(value = app()) {
132
+ const existing = closing.get(value)
133
+ if (existing) return existing
134
+ const promise = (async () => {
135
+ const failures: unknown[] = []
136
+ try {
137
+ await value.outbox.close()
138
+ } catch (error) {
139
+ failures.push(error)
140
+ }
141
+ for (const result of await Promise.allSettled([value.uploadStore.close(), value.publisher.close(), value.telemetry.shutdown()])) {
142
+ if (result.status === 'rejected') failures.push(result.reason)
143
+ }
144
+ try {
145
+ value.database.$client.close()
146
+ } catch (error) {
147
+ failures.push(error)
148
+ }
149
+ if (failures.length) throw new AggregateError(failures, 'Failed to close the full-stack example cleanly')
150
+ })()
151
+ closing.set(value, promise)
152
+ return promise
153
+ }
154
+
155
+ export { standardRateLimitOptions, standardSessionOptions }
156
+
157
+ function installShutdown(application: ReturnType<typeof createApp>) {
158
+ let started = false
159
+ for (const signal of ['SIGINT', 'SIGTERM'] as const) {
160
+ process.once(signal, () => {
161
+ if (started) return
162
+ started = true
163
+ void closeApp(application).then(
164
+ () => process.exit(0),
165
+ (error) => {
166
+ console.error({ event: 'example_shutdown_failed', error })
167
+ process.exit(1)
168
+ },
169
+ )
170
+ })
171
+ }
172
+ }
@@ -0,0 +1,148 @@
1
+ import { createServer, type Server, type Socket } from 'node:net'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { clearGlobalSingleton } from 'ras-stack/server'
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
7
+ import { app, closeApp } from './app'
8
+ import { currentUser } from './session'
9
+
10
+ let directory: string
11
+ let smtp: TestSmtp | undefined
12
+
13
+ beforeEach(async () => {
14
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-auth-flow-'))
15
+ process.env.DATA_DIR = directory
16
+ })
17
+
18
+ afterEach(async () => {
19
+ await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
20
+ await smtp?.close()
21
+ smtp = undefined
22
+ await rm(directory, { recursive: true, force: true })
23
+ for (const name of ['APP_URL', 'SMTP_HOST', 'SMTP_PORT', 'EMAIL_FROM', 'EMAIL_REQUIRE_VERIFICATION']) delete process.env[name]
24
+ })
25
+
26
+ describe('production auth flows', () => {
27
+ it('verifies an email over SMTP and completes a password reset', async () => {
28
+ smtp = await TestSmtp.start()
29
+ process.env.APP_URL = 'http://localhost:3100'
30
+ process.env.SMTP_HOST = '127.0.0.1'
31
+ process.env.SMTP_PORT = String(smtp.port)
32
+ process.env.EMAIL_FROM = 'auth@example.test'
33
+ process.env.EMAIL_REQUIRE_VERIFICATION = 'true'
34
+
35
+ expect(
36
+ (
37
+ await authRequest('/sign-up/email', {
38
+ name: 'Ada',
39
+ email: 'ada@example.test',
40
+ password: 'correct horse battery staple',
41
+ })
42
+ ).status,
43
+ ).toBe(200)
44
+ expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'correct horse battery staple' })).status).toBe(403)
45
+
46
+ const verification = await app().auth.handler(new Request(messageUrl(smtp.messages[0]!)))
47
+ const cookie = verification.headers.get('set-cookie')
48
+ expect(cookie).toContain('ras_stack_example.session_token=')
49
+ expect((await currentUser(new Request('http://localhost:3100', { headers: { cookie: cookie! } })))?.email).toBe('ada@example.test')
50
+
51
+ expect((await authRequest('/request-password-reset', { email: 'ada@example.test', redirectTo: '/' })).status).toBe(200)
52
+ const resetLink = new URL(messageUrl(smtp.messages[1]!))
53
+ const token = resetLink.pathname.split('/').at(-1)
54
+ expect((await authRequest('/reset-password', { newPassword: 'new correct horse battery staple', token })).status).toBe(200)
55
+ expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'correct horse battery staple' })).status).toBe(401)
56
+ expect((await authRequest('/sign-in/email', { email: 'ada@example.test', password: 'new correct horse battery staple' })).status).toBe(
57
+ 200,
58
+ )
59
+ })
60
+
61
+ it('marks session cookies Secure for an HTTPS origin', async () => {
62
+ process.env.APP_URL = 'https://example.test'
63
+ const response = await authRequest('/sign-up/email', {
64
+ name: 'Grace',
65
+ email: 'grace@example.test',
66
+ password: 'correct horse battery staple',
67
+ })
68
+ expect(response.headers.get('set-cookie')).toContain('; Secure')
69
+ })
70
+ })
71
+
72
+ function authRequest(endpoint: string, body: object) {
73
+ const origin = process.env.APP_URL!
74
+ return app().auth.handler(
75
+ new Request(`${origin}/api/auth${endpoint}`, {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json', Origin: origin },
78
+ body: JSON.stringify(body),
79
+ }),
80
+ )
81
+ }
82
+
83
+ function messageUrl(message: string) {
84
+ const decoded = message
85
+ .replace(/=\r?\n/g, '')
86
+ .replace(/=([\dA-F]{2})/gi, (_, hex: string) => String.fromCharCode(Number.parseInt(hex, 16)))
87
+ const match = decoded.match(/https?:\/\/[^\s]+/)
88
+ if (!match) throw new Error(`SMTP message contains no URL: ${decoded}`)
89
+ return match[0]
90
+ }
91
+
92
+ class TestSmtp {
93
+ private constructor(
94
+ private readonly server: Server,
95
+ readonly port: number,
96
+ readonly messages: string[],
97
+ ) {}
98
+
99
+ static async start() {
100
+ const messages: string[] = []
101
+ const server = createServer((socket) => acceptSmtp(socket, messages))
102
+ await new Promise<void>((resolve, reject) => server.listen(0, '127.0.0.1', resolve).once('error', reject))
103
+ const address = server.address()
104
+ if (!address || typeof address === 'string') throw new Error('SMTP sink did not bind a TCP port')
105
+ return new TestSmtp(server, address.port, messages)
106
+ }
107
+
108
+ close() {
109
+ return new Promise<void>((resolve, reject) => this.server.close((error) => (error ? reject(error) : resolve())))
110
+ }
111
+ }
112
+
113
+ function acceptSmtp(socket: Socket, messages: string[]) {
114
+ socket.setEncoding('utf8')
115
+ socket.write('220 localhost ESMTP\r\n')
116
+ let buffer = ''
117
+ let data = false
118
+ let message = ''
119
+ socket.on('data', (chunk: string) => {
120
+ buffer += chunk
121
+ while (buffer.includes('\n')) {
122
+ const index = buffer.indexOf('\n')
123
+ const line = buffer.slice(0, index).replace(/\r$/, '')
124
+ buffer = buffer.slice(index + 1)
125
+ if (data) {
126
+ if (line === '.') {
127
+ messages.push(message)
128
+ message = ''
129
+ data = false
130
+ socket.write('250 queued\r\n')
131
+ } else {
132
+ message += `${line.startsWith('..') ? line.slice(1) : line}\r\n`
133
+ }
134
+ } else if (/^(EHLO|HELO)/i.test(line)) {
135
+ socket.write('250-localhost\r\n250 PIPELINING\r\n')
136
+ } else if (/^(MAIL FROM|RCPT TO|RSET|NOOP)/i.test(line)) {
137
+ socket.write('250 ok\r\n')
138
+ } else if (/^DATA/i.test(line)) {
139
+ data = true
140
+ socket.write('354 end with .\r\n')
141
+ } else if (/^QUIT/i.test(line)) {
142
+ socket.end('221 bye\r\n')
143
+ } else {
144
+ socket.write('502 unsupported\r\n')
145
+ }
146
+ }
147
+ })
148
+ }
@@ -0,0 +1,60 @@
1
+ import { drizzleAdapter } from '@better-auth/drizzle-adapter'
2
+ import { betterAuth } from 'better-auth'
3
+ import { tanstackStartCookies } from 'better-auth/tanstack-start'
4
+ import { standardRateLimitOptions, standardSessionOptions } from 'ras-stack/auth'
5
+ import type { EmailDelivery } from 'ras-stack/email'
6
+ import type { AppEnvironment } from './environment'
7
+ import * as schema from './schema'
8
+
9
+ type Database = Parameters<typeof drizzleAdapter>[0]
10
+
11
+ export function createAuth(options: { database: Database; email?: EmailDelivery; environment: AppEnvironment; secret: string }) {
12
+ const email = options.email
13
+ const mail = email
14
+ ? {
15
+ emailVerification: {
16
+ sendOnSignUp: true,
17
+ autoSignInAfterVerification: true,
18
+ sendVerificationEmail: async ({ user, url }: { user: { email: string }; url: string }) => {
19
+ await email.send({
20
+ to: user.email,
21
+ subject: 'Verify your ras-stack example account',
22
+ text: `Verify your email: ${url}`,
23
+ })
24
+ },
25
+ },
26
+ }
27
+ : {}
28
+ return betterAuth({
29
+ appName: 'ras-stack full-stack example',
30
+ baseURL: options.environment.appUrl,
31
+ secret: options.secret,
32
+ trustedOrigins: [options.environment.appUrl],
33
+ database: drizzleAdapter(options.database, { provider: 'sqlite', schema }),
34
+ emailAndPassword: {
35
+ enabled: true,
36
+ requireEmailVerification: Boolean(email) && options.environment.requireEmailVerification,
37
+ revokeSessionsOnPasswordReset: true,
38
+ ...(email
39
+ ? {
40
+ sendResetPassword: async ({ user, url }: { user: { email: string }; url: string }) => {
41
+ await email.send({
42
+ to: user.email,
43
+ subject: 'Reset your ras-stack example password',
44
+ text: `Reset your password: ${url}`,
45
+ })
46
+ },
47
+ }
48
+ : {}),
49
+ },
50
+ ...mail,
51
+ session: standardSessionOptions(),
52
+ rateLimit: standardRateLimitOptions(),
53
+ advanced: {
54
+ cookiePrefix: 'ras_stack_example',
55
+ useSecureCookies: options.environment.appUrl.startsWith('https://'),
56
+ disableOriginCheck: false,
57
+ },
58
+ plugins: [tanstackStartCookies()],
59
+ })
60
+ }
@@ -0,0 +1,43 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { loadEnvironment } from './environment'
3
+
4
+ describe('startup environment', () => {
5
+ it('requires production state and proxy secrets', () => {
6
+ expect(() => loadEnvironment({ NODE_ENV: 'production', APP_URL: 'https://example.test', DATA_DIR: 'relative' })).toThrow(
7
+ 'DATA_DIR must be absolute in production',
8
+ )
9
+ expect(() =>
10
+ loadEnvironment({
11
+ NODE_ENV: 'production',
12
+ APP_URL: 'https://example.test',
13
+ DATA_DIR: '/data',
14
+ CENTRIFUGO_API_URL: 'https://realtime.example.test/api',
15
+ CENTRIFUGO_API_KEY: 'key',
16
+ }),
17
+ ).toThrow('CENTRIFUGO_PROXY_SECRET is required when realtime is enabled in production')
18
+ })
19
+
20
+ it('rejects ambiguous origins and partial realtime credentials', () => {
21
+ expect(() => loadEnvironment({ APP_URL: 'https://example.test/path' })).toThrow('APP_URL must be an HTTP origin')
22
+ expect(() => loadEnvironment({ CENTRIFUGO_API_URL: 'http://localhost/api' })).toThrow(
23
+ 'CENTRIFUGO_API_URL and CENTRIFUGO_API_KEY must be configured together',
24
+ )
25
+ })
26
+
27
+ it.each([
28
+ ['UPLOAD_GLOBAL_QUOTA_BYTES', '0'],
29
+ ['UPLOAD_GLOBAL_MAX_FILES', '0'],
30
+ ])('rejects an invalid %s', (name, value) => {
31
+ expect(() => loadEnvironment({ [name]: value })).toThrow(`${name} must be a positive integer`)
32
+ })
33
+
34
+ it('requires the global byte cap to accept one maximum-size upload', () => {
35
+ expect(() => loadEnvironment({ UPLOAD_MAX_BYTES: '10', UPLOAD_GLOBAL_QUOTA_BYTES: '9' })).toThrow(
36
+ 'UPLOAD_GLOBAL_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES',
37
+ )
38
+ })
39
+
40
+ it('supports production startup with realtime disabled', () => {
41
+ expect(loadEnvironment({ NODE_ENV: 'production', APP_URL: 'https://example.test', DATA_DIR: '/data' }).realtimeEnabled).toBe(false)
42
+ })
43
+ })
@@ -0,0 +1,67 @@
1
+ import path from 'node:path'
2
+
3
+ export type AppEnvironment = ReturnType<typeof loadEnvironment>
4
+
5
+ export function loadEnvironment(environment: NodeJS.ProcessEnv = process.env) {
6
+ const production = environment.NODE_ENV === 'production'
7
+ const appUrl = validUrl(environment.APP_URL ?? (production ? '' : 'http://localhost:3100'), 'APP_URL')
8
+ const dataDirectory = path.resolve(environment.DATA_DIR ?? '.data/example-full-stack')
9
+ if (production && !path.isAbsolute(environment.DATA_DIR ?? '')) throw new Error('DATA_DIR must be absolute in production')
10
+ const trustProxy = booleanValue(environment.TRUST_PROXY, 'TRUST_PROXY', false)
11
+ const requireEmailVerification = booleanValue(environment.EMAIL_REQUIRE_VERIFICATION, 'EMAIL_REQUIRE_VERIFICATION', true)
12
+ const centrifugoApiUrl = environment.CENTRIFUGO_API_URL?.trim() ?? ''
13
+ const centrifugoApiKey = environment.CENTRIFUGO_API_KEY?.trim() ?? ''
14
+ if (Boolean(centrifugoApiUrl) !== Boolean(centrifugoApiKey)) {
15
+ throw new Error('CENTRIFUGO_API_URL and CENTRIFUGO_API_KEY must be configured together')
16
+ }
17
+ const realtimeEnabled = Boolean(centrifugoApiUrl)
18
+ if (production && realtimeEnabled && !environment.CENTRIFUGO_PROXY_SECRET?.trim()) {
19
+ throw new Error('CENTRIFUGO_PROXY_SECRET is required when realtime is enabled in production')
20
+ }
21
+ const uploadMaxBytes = positiveInteger(environment.UPLOAD_MAX_BYTES ?? '1000000', 'UPLOAD_MAX_BYTES')
22
+ const uploadQuotaBytes = positiveInteger(environment.UPLOAD_QUOTA_BYTES ?? '5000000', 'UPLOAD_QUOTA_BYTES')
23
+ const uploadGlobalQuotaBytes = positiveInteger(environment.UPLOAD_GLOBAL_QUOTA_BYTES ?? '100000000', 'UPLOAD_GLOBAL_QUOTA_BYTES')
24
+ const uploadGlobalMaxFiles = positiveInteger(environment.UPLOAD_GLOBAL_MAX_FILES ?? '1024', 'UPLOAD_GLOBAL_MAX_FILES')
25
+ if (uploadQuotaBytes < uploadMaxBytes) throw new Error('UPLOAD_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES')
26
+ if (uploadGlobalQuotaBytes < uploadMaxBytes) throw new Error('UPLOAD_GLOBAL_QUOTA_BYTES must be at least UPLOAD_MAX_BYTES')
27
+ return {
28
+ appUrl,
29
+ centrifugoApiKey,
30
+ centrifugoApiUrl,
31
+ dataDirectory,
32
+ production,
33
+ realtimeEnabled,
34
+ requireEmailVerification,
35
+ trustProxy,
36
+ uploadGlobalMaxFiles,
37
+ uploadGlobalQuotaBytes,
38
+ uploadMaxBytes,
39
+ uploadQuotaBytes,
40
+ }
41
+ }
42
+
43
+ function validUrl(value: string, name: string) {
44
+ let url: URL
45
+ try {
46
+ url = new URL(value)
47
+ } catch {
48
+ throw new Error(`${name} must be an absolute HTTP URL`)
49
+ }
50
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) {
51
+ throw new Error(`${name} must be an HTTP origin without credentials, path, query, or fragment`)
52
+ }
53
+ return url.origin
54
+ }
55
+
56
+ function booleanValue(value: string | undefined, name: string, fallback: boolean) {
57
+ if (value === undefined) return fallback
58
+ if (value === 'true') return true
59
+ if (value === 'false') return false
60
+ throw new Error(`${name} must be true or false`)
61
+ }
62
+
63
+ function positiveInteger(value: string, name: string) {
64
+ const parsed = Number(value)
65
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`${name} must be a positive integer`)
66
+ return parsed
67
+ }
@@ -0,0 +1,38 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+ import { getRequest } from '@tanstack/react-start/server'
3
+ import { desc } from 'drizzle-orm'
4
+ import { z } from 'zod'
5
+ import { app } from './app'
6
+ import { writeMessage } from './messages'
7
+ import { messages } from './schema'
8
+ import { currentUser } from './session'
9
+ import { mutationRpc, rpc } from './rpc'
10
+ import { limitAuthenticatedRequest } from './rate-limit'
11
+
12
+ export const snapshot = createServerFn({ method: 'GET' }).handler(() =>
13
+ rpc(async () => ({
14
+ user: await currentUser(),
15
+ messages: app().database.select().from(messages).orderBy(desc(messages.id)).limit(50).all(),
16
+ emailConfigured: Boolean(app().email),
17
+ realtimeEnabled: app().environment.realtimeEnabled,
18
+ })),
19
+ )
20
+
21
+ export const addMessage = createServerFn({ method: 'POST' })
22
+ .validator(z.object({ body: z.string().trim().min(1).max(280) }))
23
+ .handler(({ data }) =>
24
+ mutationRpc(async () => {
25
+ const request = getRequest()
26
+ const user = await currentUser(request)
27
+ if (!user) throw new Response('Sign in first', { status: 401 })
28
+ await limitAuthenticatedRequest(request, 'messages', user.id, { window: 60, max: 30 })
29
+ const now = new Date()
30
+ const application = app()
31
+ return writeMessage(application.database, application.environment.realtimeEnabled, {
32
+ authorId: user.id,
33
+ author: user.name,
34
+ body: data.body,
35
+ createdAt: now,
36
+ })
37
+ }),
38
+ )
@@ -0,0 +1,38 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { clearGlobalSingleton } from 'ras-stack/server'
5
+ import { afterEach, beforeEach, expect, it } from 'vitest'
6
+ import { app, closeApp } from './app'
7
+ import { writeMessage } from './messages'
8
+ import { outboxCapacity } from './outbox'
9
+ import { messages, outbox, user } from './schema'
10
+
11
+ let directory: string
12
+
13
+ beforeEach(async () => {
14
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-messages-'))
15
+ process.env.DATA_DIR = directory
16
+ process.env.APP_URL = 'http://localhost:3100'
17
+ const now = new Date()
18
+ app().database.insert(user).values({ id: 'alice', name: 'Alice', email: 'alice@example.test', createdAt: now, updatedAt: now }).run()
19
+ })
20
+
21
+ afterEach(async () => {
22
+ await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
23
+ await rm(directory, { recursive: true, force: true })
24
+ delete process.env.DATA_DIR
25
+ delete process.env.APP_URL
26
+ })
27
+
28
+ it('writes without adding to a full outbox when realtime is disabled', () => {
29
+ const now = new Date()
30
+ app().database.transaction((transaction) => {
31
+ for (let index = 0; index < outboxCapacity; index += 1) {
32
+ transaction.insert(outbox).values({ channel: 'messages:all', payload: '{}', availableAt: now, createdAt: now }).run()
33
+ }
34
+ })
35
+ writeMessage(app().database, false, { authorId: 'alice', author: 'Alice', body: 'stored without realtime', createdAt: now })
36
+ expect(app().database.select().from(messages).all()).toHaveLength(1)
37
+ expect(app().database.select().from(outbox).all()).toHaveLength(outboxCapacity)
38
+ })
@@ -0,0 +1,28 @@
1
+ import type { app } from './app'
2
+ import { assertOutboxCapacity } from './outbox'
3
+ import { messages, outbox } from './schema'
4
+
5
+ type Database = ReturnType<typeof app>['database']
6
+
7
+ export function writeMessage(
8
+ database: Database,
9
+ realtimeEnabled: boolean,
10
+ values: { authorId: string; author: string; body: string; createdAt: Date },
11
+ ) {
12
+ return database.transaction((transaction) => {
13
+ if (realtimeEnabled) assertOutboxCapacity(transaction)
14
+ const [message] = transaction.insert(messages).values(values).returning().all()
15
+ if (realtimeEnabled) {
16
+ transaction
17
+ .insert(outbox)
18
+ .values({
19
+ channel: 'messages:all',
20
+ payload: JSON.stringify({ type: 'message-added', id: message!.id }),
21
+ availableAt: values.createdAt,
22
+ createdAt: values.createdAt,
23
+ })
24
+ .run()
25
+ }
26
+ return message!
27
+ })
28
+ }
@@ -0,0 +1,34 @@
1
+ import Database from 'better-sqlite3'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { clearGlobalSingleton } from 'ras-stack/server'
6
+ import { afterEach, expect, it } from 'vitest'
7
+ import { app, closeApp } from './app'
8
+ import { messages } from './schema'
9
+
10
+ let directory: string | undefined
11
+
12
+ afterEach(async () => {
13
+ await clearGlobalSingleton('ras-stack.example.full-stack', closeApp)
14
+ if (directory) await rm(directory, { recursive: true, force: true })
15
+ delete process.env.DATA_DIR
16
+ delete process.env.APP_URL
17
+ })
18
+
19
+ it('preserves rows from the pre-migration messages table', async () => {
20
+ directory = await mkdtemp(path.join(os.tmpdir(), 'ras-stack-example-legacy-'))
21
+ process.env.DATA_DIR = directory
22
+ process.env.APP_URL = 'http://localhost:3100'
23
+ const legacy = new Database(path.join(directory, 'example.sqlite'))
24
+ legacy.exec(`CREATE TABLE messages (
25
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
26
+ author TEXT NOT NULL,
27
+ body TEXT NOT NULL,
28
+ created_at INTEGER NOT NULL
29
+ ); INSERT INTO messages (author, body, created_at) VALUES ('Ada', 'legacy row', 1234);`)
30
+ legacy.close()
31
+ expect(app().database.select().from(messages).all()).toEqual([
32
+ { id: 1, authorId: 'legacy-import', author: 'Ada', body: 'legacy row', createdAt: new Date(1234) },
33
+ ])
34
+ })