@emeryld/create-rrroutes 0.1.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 +33 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1524 -0
- package/dist/index.js.map +1 -0
- package/package.json +23 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
|
|
4
|
+
// src/index.ts
|
|
5
|
+
import path6 from "path";
|
|
6
|
+
|
|
7
|
+
// src/generators/client.ts
|
|
8
|
+
import path2 from "path";
|
|
9
|
+
|
|
10
|
+
// src/utils/fs.ts
|
|
11
|
+
import fs from "fs/promises";
|
|
12
|
+
import path from "path";
|
|
13
|
+
async function ensureDir(dir) {
|
|
14
|
+
await fs.mkdir(dir, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
async function pathExists(target) {
|
|
17
|
+
try {
|
|
18
|
+
await fs.access(target);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
async function writeFileIfMissing(filePath, contents) {
|
|
25
|
+
const dir = path.dirname(filePath);
|
|
26
|
+
await ensureDir(dir);
|
|
27
|
+
if (await pathExists(filePath)) {
|
|
28
|
+
return "skipped";
|
|
29
|
+
}
|
|
30
|
+
await fs.writeFile(filePath, contents, "utf8");
|
|
31
|
+
return "created";
|
|
32
|
+
}
|
|
33
|
+
async function writeFileForce(filePath, contents) {
|
|
34
|
+
const dir = path.dirname(filePath);
|
|
35
|
+
await ensureDir(dir);
|
|
36
|
+
await fs.writeFile(filePath, contents, "utf8");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/utils/log.ts
|
|
40
|
+
var log = {
|
|
41
|
+
info(msg) {
|
|
42
|
+
console.log(msg);
|
|
43
|
+
},
|
|
44
|
+
step(msg) {
|
|
45
|
+
console.log(`\u2022 ${msg}`);
|
|
46
|
+
},
|
|
47
|
+
created(target) {
|
|
48
|
+
console.log(` created ${target}`);
|
|
49
|
+
},
|
|
50
|
+
skipped(target) {
|
|
51
|
+
console.log(` skipped ${target} (already exists)`);
|
|
52
|
+
},
|
|
53
|
+
warn(msg) {
|
|
54
|
+
console.warn(` warning: ${msg}`);
|
|
55
|
+
},
|
|
56
|
+
error(msg) {
|
|
57
|
+
console.error(` error: ${msg}`);
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/generators/client.ts
|
|
62
|
+
function baseTsConfig() {
|
|
63
|
+
return {
|
|
64
|
+
extends: "../../tsconfig.base.json",
|
|
65
|
+
compilerOptions: {
|
|
66
|
+
outDir: "dist",
|
|
67
|
+
rootDir: "src",
|
|
68
|
+
types: ["vite/client"]
|
|
69
|
+
},
|
|
70
|
+
include: ["src/**/*", "vite.config.ts"]
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function viteConfig() {
|
|
74
|
+
return `import { defineConfig } from 'vite'
|
|
75
|
+
import react from '@vitejs/plugin-react'
|
|
76
|
+
|
|
77
|
+
export default defineConfig({
|
|
78
|
+
plugins: [react()],
|
|
79
|
+
})
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
function queryClient(contractImport) {
|
|
83
|
+
return `import { QueryClient } from '@tanstack/react-query'
|
|
84
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
85
|
+
import { registry } from '${contractImport}'
|
|
86
|
+
|
|
87
|
+
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
|
88
|
+
|
|
89
|
+
export const queryClient = new QueryClient()
|
|
90
|
+
|
|
91
|
+
export const routeClient = createRouteClient({
|
|
92
|
+
baseUrl,
|
|
93
|
+
queryClient,
|
|
94
|
+
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
98
|
+
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
function socketProvider(contractImport) {
|
|
102
|
+
return `import React from 'react'
|
|
103
|
+
import { io, type Socket } from 'socket.io-client'
|
|
104
|
+
import {
|
|
105
|
+
buildSocketProvider,
|
|
106
|
+
type SocketClientOptions,
|
|
107
|
+
} from '@emeryld/rrroutes-client'
|
|
108
|
+
import { socketConfig, socketEvents } from '${contractImport}'
|
|
109
|
+
|
|
110
|
+
const socketUrl = import.meta.env.VITE_SOCKET_URL ?? 'http://localhost:4000'
|
|
111
|
+
const socketPath = import.meta.env.VITE_SOCKET_PATH ?? '/socket.io'
|
|
112
|
+
|
|
113
|
+
const sysEvents: SocketClientOptions<
|
|
114
|
+
typeof socketEvents,
|
|
115
|
+
typeof socketConfig
|
|
116
|
+
>['sys'] = {
|
|
117
|
+
'sys:connect': async ({ socket }) => {
|
|
118
|
+
console.info('socket connected', socket.id)
|
|
119
|
+
},
|
|
120
|
+
'sys:disconnect': async ({ reason }) => {
|
|
121
|
+
console.info('socket disconnected', reason)
|
|
122
|
+
},
|
|
123
|
+
'sys:reconnect': async ({ attempt, socket }) => {
|
|
124
|
+
console.info('socket reconnect', attempt, socket?.id)
|
|
125
|
+
},
|
|
126
|
+
'sys:connect_error': async ({ error }) => {
|
|
127
|
+
console.warn('socket connect error', error)
|
|
128
|
+
},
|
|
129
|
+
'sys:ping': () => ({
|
|
130
|
+
note: 'client-heartbeat',
|
|
131
|
+
sentAt: new Date().toISOString(),
|
|
132
|
+
}),
|
|
133
|
+
'sys:pong': async ({ payload }) => {
|
|
134
|
+
console.info('socket pong', payload)
|
|
135
|
+
},
|
|
136
|
+
'sys:room_join': async ({ rooms }) => {
|
|
137
|
+
console.info('joining rooms', rooms)
|
|
138
|
+
return true
|
|
139
|
+
},
|
|
140
|
+
'sys:room_leave': async ({ rooms }) => {
|
|
141
|
+
console.info('leaving rooms', rooms)
|
|
142
|
+
return true
|
|
143
|
+
},
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const baseOptions: Omit<
|
|
147
|
+
SocketClientOptions<typeof socketEvents, typeof socketConfig>,
|
|
148
|
+
'socket'
|
|
149
|
+
> = {
|
|
150
|
+
config: socketConfig,
|
|
151
|
+
sys: sysEvents,
|
|
152
|
+
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
153
|
+
heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
|
|
154
|
+
debug: {
|
|
155
|
+
connection: true,
|
|
156
|
+
heartbeat: true,
|
|
157
|
+
},
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { SocketProvider, useSocketClient, useSocketConnection } =
|
|
161
|
+
buildSocketProvider({
|
|
162
|
+
events: socketEvents,
|
|
163
|
+
options: baseOptions,
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
function getSocket(): Promise<Socket> {
|
|
167
|
+
return Promise.resolve(
|
|
168
|
+
io(socketUrl, {
|
|
169
|
+
path: socketPath,
|
|
170
|
+
transports: ['websocket'],
|
|
171
|
+
}),
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const roomMeta = { room: 'health' }
|
|
176
|
+
|
|
177
|
+
export function AppSocketProvider(props: React.PropsWithChildren) {
|
|
178
|
+
return (
|
|
179
|
+
<SocketProvider
|
|
180
|
+
getSocket={getSocket}
|
|
181
|
+
destroyLeaveMeta={roomMeta}
|
|
182
|
+
fallback={<p>Connecting socket\u2026</p>}
|
|
183
|
+
>
|
|
184
|
+
{props.children}
|
|
185
|
+
</SocketProvider>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export { useSocketClient, useSocketConnection }
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
var MAIN_TSX = `import React from 'react'
|
|
193
|
+
import ReactDOM from 'react-dom/client'
|
|
194
|
+
import App from './App'
|
|
195
|
+
import './styles.css'
|
|
196
|
+
|
|
197
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
198
|
+
<React.StrictMode>
|
|
199
|
+
<App />
|
|
200
|
+
</React.StrictMode>,
|
|
201
|
+
)
|
|
202
|
+
`;
|
|
203
|
+
function appTsx() {
|
|
204
|
+
return `import React from 'react'
|
|
205
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
206
|
+
import { queryClient } from './lib/queryClient'
|
|
207
|
+
import { AppSocketProvider } from './lib/socket'
|
|
208
|
+
import { HealthPage } from './pages/HealthPage'
|
|
209
|
+
|
|
210
|
+
export default function App() {
|
|
211
|
+
return (
|
|
212
|
+
<QueryClientProvider client={queryClient}>
|
|
213
|
+
<AppSocketProvider>
|
|
214
|
+
<HealthPage />
|
|
215
|
+
</AppSocketProvider>
|
|
216
|
+
</QueryClientProvider>
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
`;
|
|
220
|
+
}
|
|
221
|
+
var STYLES = `:root {
|
|
222
|
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
223
|
+
color: #0b1021;
|
|
224
|
+
background: #f7f8fb;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
body {
|
|
228
|
+
margin: 0;
|
|
229
|
+
background: radial-gradient(circle at 20% 20%, #eef2ff, #f7f8fb 45%);
|
|
230
|
+
min-height: 100vh;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.health {
|
|
234
|
+
max-width: 960px;
|
|
235
|
+
margin: 0 auto;
|
|
236
|
+
padding: 32px;
|
|
237
|
+
display: flex;
|
|
238
|
+
flex-direction: column;
|
|
239
|
+
gap: 16px;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.grid {
|
|
243
|
+
display: grid;
|
|
244
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
245
|
+
gap: 16px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.card {
|
|
249
|
+
background: #fff;
|
|
250
|
+
border: 1px solid #e5e7eb;
|
|
251
|
+
border-radius: 12px;
|
|
252
|
+
padding: 16px;
|
|
253
|
+
box-shadow: 0 8px 24px rgba(12, 18, 32, 0.05);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
button {
|
|
257
|
+
background: #0f172a;
|
|
258
|
+
color: #fff;
|
|
259
|
+
border: none;
|
|
260
|
+
border-radius: 8px;
|
|
261
|
+
padding: 10px 12px;
|
|
262
|
+
cursor: pointer;
|
|
263
|
+
font-weight: 600;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
button.secondary {
|
|
267
|
+
background: #e2e8f0;
|
|
268
|
+
color: #0f172a;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
button + button {
|
|
272
|
+
margin-left: 8px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
textarea {
|
|
276
|
+
width: 100%;
|
|
277
|
+
min-height: 80px;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
.logs {
|
|
281
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo,
|
|
282
|
+
monospace;
|
|
283
|
+
background: #0f172a;
|
|
284
|
+
color: #e2e8f0;
|
|
285
|
+
padding: 12px;
|
|
286
|
+
border-radius: 8px;
|
|
287
|
+
min-height: 160px;
|
|
288
|
+
white-space: pre-line;
|
|
289
|
+
}
|
|
290
|
+
`;
|
|
291
|
+
function healthPage(contractImport) {
|
|
292
|
+
return `import React from 'react'
|
|
293
|
+
import { healthGet, healthPost } from '../lib/queryClient'
|
|
294
|
+
import { roomMeta, useSocketClient, useSocketConnection } from '../lib/socket'
|
|
295
|
+
|
|
296
|
+
const now = () => new Date().toLocaleTimeString()
|
|
297
|
+
|
|
298
|
+
function useLogs() {
|
|
299
|
+
const [logs, setLogs] = React.useState<string[]>([])
|
|
300
|
+
return {
|
|
301
|
+
logs,
|
|
302
|
+
push: (msg: string) =>
|
|
303
|
+
setLogs((prev) => [\`[\${now()}] \${msg}\`, ...prev].slice(0, 60)),
|
|
304
|
+
clear: () => setLogs([]),
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function HealthPage() {
|
|
309
|
+
const [echo, setEcho] = React.useState('hello rrroute')
|
|
310
|
+
const httpGet = healthGet.useEndpoint()
|
|
311
|
+
const httpPost = healthPost.useEndpoint()
|
|
312
|
+
const socket = useSocketClient<typeof import('${contractImport}').socketEvents>()
|
|
313
|
+
const { logs, push, clear } = useLogs()
|
|
314
|
+
|
|
315
|
+
useSocketConnection({
|
|
316
|
+
event: 'health:connected',
|
|
317
|
+
rooms: ['health'],
|
|
318
|
+
joinMeta: roomMeta,
|
|
319
|
+
leaveMeta: roomMeta,
|
|
320
|
+
onMessage: (payload) => {
|
|
321
|
+
push(\`socket connected (\${payload.socketId})\`)
|
|
322
|
+
},
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
useSocketConnection({
|
|
326
|
+
event: 'health:pong',
|
|
327
|
+
rooms: ['health'],
|
|
328
|
+
joinMeta: roomMeta,
|
|
329
|
+
leaveMeta: roomMeta,
|
|
330
|
+
onMessage: (payload) => {
|
|
331
|
+
push(
|
|
332
|
+
\`pong at \${payload.at}\${payload.echo ? \` (echo: \${payload.echo})\` : ''}\`,
|
|
333
|
+
)
|
|
334
|
+
},
|
|
335
|
+
})
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<div className="health">
|
|
339
|
+
<h1>RRRoutes health sandbox</h1>
|
|
340
|
+
<div className="grid">
|
|
341
|
+
<div className="card">
|
|
342
|
+
<h2>HTTP endpoints</h2>
|
|
343
|
+
<p>GET and POST against the shared contract.</p>
|
|
344
|
+
<div>
|
|
345
|
+
<button onClick={() => httpGet.refetch()}>GET /health</button>
|
|
346
|
+
</div>
|
|
347
|
+
<div style={{ marginTop: 12 }}>
|
|
348
|
+
<input
|
|
349
|
+
value={echo}
|
|
350
|
+
onChange={(e) => setEcho(e.target.value)}
|
|
351
|
+
placeholder="echo payload"
|
|
352
|
+
style={{ width: '100%', padding: '8px' }}
|
|
353
|
+
/>
|
|
354
|
+
<div style={{ marginTop: 8 }}>
|
|
355
|
+
<button
|
|
356
|
+
onClick={() =>
|
|
357
|
+
httpPost.mutateAsync({ echo }).then(() => {
|
|
358
|
+
push('POST /health ok')
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
>
|
|
362
|
+
POST /health
|
|
363
|
+
</button>
|
|
364
|
+
</div>
|
|
365
|
+
</div>
|
|
366
|
+
<div style={{ marginTop: 12 }}>
|
|
367
|
+
<strong>GET data</strong>
|
|
368
|
+
<pre>{JSON.stringify(httpGet.data, null, 2) ?? 'none'}</pre>
|
|
369
|
+
<strong>POST data</strong>
|
|
370
|
+
<pre>{JSON.stringify(httpPost.data, null, 2) ?? 'none'}</pre>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
|
|
374
|
+
<div className="card">
|
|
375
|
+
<h2>Socket</h2>
|
|
376
|
+
<p>Connect, ping, and watch lifecycle events.</p>
|
|
377
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
378
|
+
<button onClick={() => socket.connect()}>Connect</button>
|
|
379
|
+
<button className="secondary" onClick={() => socket.disconnect()}>
|
|
380
|
+
Disconnect
|
|
381
|
+
</button>
|
|
382
|
+
<button
|
|
383
|
+
onClick={() =>
|
|
384
|
+
socket.emit('health:ping', { note: 'ping from client' })
|
|
385
|
+
}
|
|
386
|
+
>
|
|
387
|
+
Emit ping
|
|
388
|
+
</button>
|
|
389
|
+
<button
|
|
390
|
+
className="secondary"
|
|
391
|
+
onClick={() => socket.joinRooms(['health'], roomMeta)}
|
|
392
|
+
>
|
|
393
|
+
Join room
|
|
394
|
+
</button>
|
|
395
|
+
<button
|
|
396
|
+
className="secondary"
|
|
397
|
+
onClick={() => socket.leaveRooms(['health'], roomMeta)}
|
|
398
|
+
>
|
|
399
|
+
Leave room
|
|
400
|
+
</button>
|
|
401
|
+
</div>
|
|
402
|
+
</div>
|
|
403
|
+
|
|
404
|
+
<div className="card">
|
|
405
|
+
<h2>Socket logs</h2>
|
|
406
|
+
<button className="secondary" onClick={clear}>
|
|
407
|
+
Clear
|
|
408
|
+
</button>
|
|
409
|
+
<div className="logs">
|
|
410
|
+
{logs.length === 0 ? 'No messages yet' : logs.join('\\n')}
|
|
411
|
+
</div>
|
|
412
|
+
</div>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
)
|
|
416
|
+
}
|
|
417
|
+
`;
|
|
418
|
+
}
|
|
419
|
+
function indexHtml() {
|
|
420
|
+
return `<!doctype html>
|
|
421
|
+
<html lang="en">
|
|
422
|
+
<head>
|
|
423
|
+
<meta charset="UTF-8" />
|
|
424
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
425
|
+
<title>RRRoutes starter</title>
|
|
426
|
+
</head>
|
|
427
|
+
<body>
|
|
428
|
+
<div id="root"></div>
|
|
429
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
430
|
+
</body>
|
|
431
|
+
</html>
|
|
432
|
+
`;
|
|
433
|
+
}
|
|
434
|
+
var CLIENT_ENV = `VITE_API_URL=http://localhost:4000
|
|
435
|
+
VITE_SOCKET_URL=http://localhost:4000
|
|
436
|
+
VITE_SOCKET_PATH=/socket.io
|
|
437
|
+
`;
|
|
438
|
+
function nativePackageJson(clientName, contractName) {
|
|
439
|
+
return {
|
|
440
|
+
name: clientName,
|
|
441
|
+
version: "0.1.0",
|
|
442
|
+
private: true,
|
|
443
|
+
main: "expo/AppEntry",
|
|
444
|
+
scripts: {
|
|
445
|
+
start: "expo start",
|
|
446
|
+
android: "expo start --android",
|
|
447
|
+
ios: "expo start --ios",
|
|
448
|
+
web: "expo start --web",
|
|
449
|
+
test: 'echo "add tests"'
|
|
450
|
+
},
|
|
451
|
+
dependencies: {
|
|
452
|
+
[contractName]: "workspace:*",
|
|
453
|
+
"@emeryld/rrroutes-client": "^2.5.3",
|
|
454
|
+
"@tanstack/react-query": "^5.90.12",
|
|
455
|
+
expo: "~52.0.8",
|
|
456
|
+
"expo-constants": "~16.0.2",
|
|
457
|
+
"expo-status-bar": "~2.0.1",
|
|
458
|
+
react: "18.3.1",
|
|
459
|
+
"react-native": "0.76.6",
|
|
460
|
+
"react-native-safe-area-context": "4.12.0",
|
|
461
|
+
"react-native-screens": "4.4.0",
|
|
462
|
+
"socket.io-client": "^4.8.3",
|
|
463
|
+
zod: "^4.2.1"
|
|
464
|
+
},
|
|
465
|
+
devDependencies: {
|
|
466
|
+
"@babel/core": "^7.25.2",
|
|
467
|
+
"@types/react": "~18.2.79",
|
|
468
|
+
"@types/react-native": "~0.73.0",
|
|
469
|
+
typescript: "^5.9.3"
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
function nativeAppJson(appSlug) {
|
|
474
|
+
return {
|
|
475
|
+
expo: {
|
|
476
|
+
name: `${appSlug}-client`,
|
|
477
|
+
slug: `${appSlug}-client`,
|
|
478
|
+
version: "0.1.0",
|
|
479
|
+
orientation: "portrait",
|
|
480
|
+
scheme: appSlug,
|
|
481
|
+
platforms: ["ios", "android", "web"],
|
|
482
|
+
userInterfaceStyle: "light",
|
|
483
|
+
extra: {
|
|
484
|
+
apiUrl: "http://localhost:4000",
|
|
485
|
+
socketUrl: "http://localhost:4000",
|
|
486
|
+
socketPath: "/socket.io"
|
|
487
|
+
},
|
|
488
|
+
experiments: {
|
|
489
|
+
typedRoutes: false
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
var NATIVE_BABEL = `module.exports = function (api) {
|
|
495
|
+
api.cache(true)
|
|
496
|
+
return {
|
|
497
|
+
presets: ['babel-preset-expo'],
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
`;
|
|
501
|
+
var NATIVE_TS_CONFIG = {
|
|
502
|
+
extends: "../../tsconfig.base.json",
|
|
503
|
+
compilerOptions: {
|
|
504
|
+
jsx: "react-native",
|
|
505
|
+
types: ["react-native", "react"]
|
|
506
|
+
},
|
|
507
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
508
|
+
exclude: ["node_modules", "babel.config.js", "metro.config.js"]
|
|
509
|
+
};
|
|
510
|
+
function nativeQueryClient(contractImport) {
|
|
511
|
+
return `import Constants from 'expo-constants'
|
|
512
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
513
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
514
|
+
import { registry } from '${contractImport}'
|
|
515
|
+
|
|
516
|
+
const { apiUrl } = (Constants.expoConfig?.extra ?? {}) as Record<string, string>
|
|
517
|
+
|
|
518
|
+
export const queryClient = new QueryClient()
|
|
519
|
+
|
|
520
|
+
export const routeClient = createRouteClient({
|
|
521
|
+
baseUrl: apiUrl ?? 'http://localhost:4000',
|
|
522
|
+
queryClient,
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
526
|
+
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
|
|
527
|
+
`;
|
|
528
|
+
}
|
|
529
|
+
function nativeSocket(contractImport) {
|
|
530
|
+
return `import React from 'react'
|
|
531
|
+
import Constants from 'expo-constants'
|
|
532
|
+
import { io, type Socket } from 'socket.io-client'
|
|
533
|
+
import {
|
|
534
|
+
buildSocketProvider,
|
|
535
|
+
type SocketClientOptions,
|
|
536
|
+
} from '@emeryld/rrroutes-client'
|
|
537
|
+
import { socketConfig, socketEvents } from '${contractImport}'
|
|
538
|
+
|
|
539
|
+
const extras = (Constants.expoConfig?.extra ?? {}) as Record<string, string>
|
|
540
|
+
const socketUrl = extras.socketUrl ?? 'http://localhost:4000'
|
|
541
|
+
const socketPath = extras.socketPath ?? '/socket.io'
|
|
542
|
+
|
|
543
|
+
const sysEvents: SocketClientOptions<
|
|
544
|
+
typeof socketEvents,
|
|
545
|
+
typeof socketConfig
|
|
546
|
+
>['sys'] = {
|
|
547
|
+
'sys:connect': async ({ socket }) => {
|
|
548
|
+
console.log('socket connected', socket.id)
|
|
549
|
+
},
|
|
550
|
+
'sys:disconnect': async ({ reason }) => console.log('disconnected', reason),
|
|
551
|
+
'sys:reconnect': async ({ attempt, socket }) =>
|
|
552
|
+
console.log('reconnect', attempt, socket?.id),
|
|
553
|
+
'sys:connect_error': async ({ error }) =>
|
|
554
|
+
console.warn('socket connect error', error),
|
|
555
|
+
'sys:ping': () => ({
|
|
556
|
+
note: 'client-heartbeat',
|
|
557
|
+
sentAt: new Date().toISOString(),
|
|
558
|
+
}),
|
|
559
|
+
'sys:pong': async ({ payload }) => console.log('pong', payload),
|
|
560
|
+
'sys:room_join': async ({ rooms }) => {
|
|
561
|
+
console.log('join rooms', rooms)
|
|
562
|
+
return true
|
|
563
|
+
},
|
|
564
|
+
'sys:room_leave': async ({ rooms }) => {
|
|
565
|
+
console.log('leave rooms', rooms)
|
|
566
|
+
return true
|
|
567
|
+
},
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const baseOptions: Omit<
|
|
571
|
+
SocketClientOptions<typeof socketEvents, typeof socketConfig>,
|
|
572
|
+
'socket'
|
|
573
|
+
> = {
|
|
574
|
+
config: socketConfig,
|
|
575
|
+
sys: sysEvents,
|
|
576
|
+
heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
|
|
577
|
+
debug: { connection: true },
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const { SocketProvider, useSocketClient, useSocketConnection } =
|
|
581
|
+
buildSocketProvider({
|
|
582
|
+
events: socketEvents,
|
|
583
|
+
options: baseOptions,
|
|
584
|
+
})
|
|
585
|
+
|
|
586
|
+
function getSocket(): Promise<Socket> {
|
|
587
|
+
return Promise.resolve(
|
|
588
|
+
io(socketUrl, {
|
|
589
|
+
path: socketPath,
|
|
590
|
+
transports: ['websocket'],
|
|
591
|
+
}),
|
|
592
|
+
)
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export const roomMeta = { room: 'health' }
|
|
596
|
+
|
|
597
|
+
export function AppSocketProvider(props: React.PropsWithChildren) {
|
|
598
|
+
return (
|
|
599
|
+
<SocketProvider
|
|
600
|
+
getSocket={getSocket}
|
|
601
|
+
destroyLeaveMeta={roomMeta}
|
|
602
|
+
fallback={<></>}
|
|
603
|
+
>
|
|
604
|
+
{props.children}
|
|
605
|
+
</SocketProvider>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
export { useSocketClient, useSocketConnection }
|
|
610
|
+
`;
|
|
611
|
+
}
|
|
612
|
+
function nativeAppTsx() {
|
|
613
|
+
return `import React from 'react'
|
|
614
|
+
import { SafeAreaView, ScrollView, StatusBar, StyleSheet } from 'react-native'
|
|
615
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
616
|
+
import { queryClient } from './src/queryClient'
|
|
617
|
+
import { AppSocketProvider } from './src/socket'
|
|
618
|
+
import { HealthScreen } from './src/screens/HealthScreen'
|
|
619
|
+
|
|
620
|
+
export default function App() {
|
|
621
|
+
return (
|
|
622
|
+
<QueryClientProvider client={queryClient}>
|
|
623
|
+
<AppSocketProvider>
|
|
624
|
+
<StatusBar barStyle="dark-content" />
|
|
625
|
+
<SafeAreaView style={styles.container}>
|
|
626
|
+
<ScrollView contentInsetAdjustmentBehavior="automatic">
|
|
627
|
+
<HealthScreen />
|
|
628
|
+
</ScrollView>
|
|
629
|
+
</SafeAreaView>
|
|
630
|
+
</AppSocketProvider>
|
|
631
|
+
</QueryClientProvider>
|
|
632
|
+
)
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const styles = StyleSheet.create({
|
|
636
|
+
container: {
|
|
637
|
+
flex: 1,
|
|
638
|
+
padding: 16,
|
|
639
|
+
backgroundColor: '#f4f5fb',
|
|
640
|
+
},
|
|
641
|
+
})
|
|
642
|
+
`;
|
|
643
|
+
}
|
|
644
|
+
function nativeHealthScreen(contractImport) {
|
|
645
|
+
return `import React from 'react'
|
|
646
|
+
import {
|
|
647
|
+
Button,
|
|
648
|
+
StyleSheet,
|
|
649
|
+
Text,
|
|
650
|
+
TextInput,
|
|
651
|
+
View,
|
|
652
|
+
} from 'react-native'
|
|
653
|
+
import { healthGet, healthPost } from '../queryClient'
|
|
654
|
+
import { roomMeta, useSocketClient, useSocketConnection } from '../socket'
|
|
655
|
+
|
|
656
|
+
const now = () => new Date().toLocaleTimeString()
|
|
657
|
+
|
|
658
|
+
function useLogs() {
|
|
659
|
+
const [logs, setLogs] = React.useState<string[]>([])
|
|
660
|
+
return {
|
|
661
|
+
logs,
|
|
662
|
+
push: (msg: string) =>
|
|
663
|
+
setLogs((prev) => [\`[\${now()}] \${msg}\`, ...prev].slice(0, 60)),
|
|
664
|
+
clear: () => setLogs([]),
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function HealthScreen() {
|
|
669
|
+
const [echo, setEcho] = React.useState('hello rrroute')
|
|
670
|
+
const httpGet = healthGet.useEndpoint()
|
|
671
|
+
const httpPost = healthPost.useEndpoint()
|
|
672
|
+
const socket = useSocketClient<typeof import('${contractImport}').socketEvents>()
|
|
673
|
+
const { logs, push, clear } = useLogs()
|
|
674
|
+
|
|
675
|
+
useSocketConnection({
|
|
676
|
+
event: 'health:connected',
|
|
677
|
+
rooms: ['health'],
|
|
678
|
+
joinMeta: roomMeta,
|
|
679
|
+
leaveMeta: roomMeta,
|
|
680
|
+
onMessage: (payload) => push(\`connected \${payload.socketId}\`),
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
useSocketConnection({
|
|
684
|
+
event: 'health:pong',
|
|
685
|
+
rooms: ['health'],
|
|
686
|
+
joinMeta: roomMeta,
|
|
687
|
+
leaveMeta: roomMeta,
|
|
688
|
+
onMessage: (payload) =>
|
|
689
|
+
push(\`pong at \${payload.at}\${payload.echo ? \` (echo: \${payload.echo})\` : ''}\`),
|
|
690
|
+
})
|
|
691
|
+
|
|
692
|
+
return (
|
|
693
|
+
<View style={styles.card}>
|
|
694
|
+
<Text style={styles.title}>RRRoutes health sandbox</Text>
|
|
695
|
+
<View style={styles.section}>
|
|
696
|
+
<Text style={styles.heading}>HTTP</Text>
|
|
697
|
+
<Button title="GET /health" onPress={() => httpGet.refetch()} />
|
|
698
|
+
<View style={{ height: 12 }} />
|
|
699
|
+
<TextInput
|
|
700
|
+
style={styles.input}
|
|
701
|
+
value={echo}
|
|
702
|
+
onChangeText={setEcho}
|
|
703
|
+
placeholder="echo payload"
|
|
704
|
+
/>
|
|
705
|
+
<Button
|
|
706
|
+
title="POST /health"
|
|
707
|
+
onPress={() =>
|
|
708
|
+
httpPost
|
|
709
|
+
.mutateAsync({ echo })
|
|
710
|
+
.then(() => push('POST /health ok'))
|
|
711
|
+
}
|
|
712
|
+
/>
|
|
713
|
+
<Text style={styles.label}>GET data</Text>
|
|
714
|
+
<Text style={styles.code}>
|
|
715
|
+
{JSON.stringify(httpGet.data ?? {}, null, 2)}
|
|
716
|
+
</Text>
|
|
717
|
+
<Text style={styles.label}>POST data</Text>
|
|
718
|
+
<Text style={styles.code}>
|
|
719
|
+
{JSON.stringify(httpPost.data ?? {}, null, 2)}
|
|
720
|
+
</Text>
|
|
721
|
+
</View>
|
|
722
|
+
|
|
723
|
+
<View style={styles.section}>
|
|
724
|
+
<Text style={styles.heading}>Socket</Text>
|
|
725
|
+
<View style={styles.row}>
|
|
726
|
+
<Button title="Connect" onPress={() => socket.connect()} />
|
|
727
|
+
<Button title="Disconnect" onPress={() => socket.disconnect()} />
|
|
728
|
+
</View>
|
|
729
|
+
<View style={{ height: 8 }} />
|
|
730
|
+
<Button
|
|
731
|
+
title="Emit ping"
|
|
732
|
+
onPress={() => socket.emit('health:ping', { note: 'ping from app' })}
|
|
733
|
+
/>
|
|
734
|
+
<View style={{ height: 8 }} />
|
|
735
|
+
<View style={styles.row}>
|
|
736
|
+
<Button
|
|
737
|
+
title="Join room"
|
|
738
|
+
onPress={() => socket.joinRooms(['health'], roomMeta)}
|
|
739
|
+
/>
|
|
740
|
+
<Button
|
|
741
|
+
title="Leave room"
|
|
742
|
+
onPress={() => socket.leaveRooms(['health'], roomMeta)}
|
|
743
|
+
/>
|
|
744
|
+
</View>
|
|
745
|
+
</View>
|
|
746
|
+
|
|
747
|
+
<View style={styles.section}>
|
|
748
|
+
<Text style={styles.heading}>Socket logs</Text>
|
|
749
|
+
<Button title="Clear logs" onPress={clear} />
|
|
750
|
+
<Text style={styles.code}>
|
|
751
|
+
{logs.length === 0 ? 'No messages yet' : logs.join('\\n')}
|
|
752
|
+
</Text>
|
|
753
|
+
</View>
|
|
754
|
+
</View>
|
|
755
|
+
)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const styles = StyleSheet.create({
|
|
759
|
+
card: {
|
|
760
|
+
backgroundColor: '#fff',
|
|
761
|
+
borderRadius: 12,
|
|
762
|
+
padding: 16,
|
|
763
|
+
gap: 12,
|
|
764
|
+
shadowColor: '#000',
|
|
765
|
+
shadowOpacity: 0.05,
|
|
766
|
+
shadowRadius: 8,
|
|
767
|
+
},
|
|
768
|
+
title: { fontSize: 20, fontWeight: '700' },
|
|
769
|
+
section: { gap: 8 },
|
|
770
|
+
heading: { fontWeight: '600', fontSize: 16 },
|
|
771
|
+
row: { flexDirection: 'row', gap: 8, justifyContent: 'space-between' },
|
|
772
|
+
input: {
|
|
773
|
+
borderWidth: 1,
|
|
774
|
+
borderColor: '#d0d4de',
|
|
775
|
+
borderRadius: 8,
|
|
776
|
+
padding: 8,
|
|
777
|
+
},
|
|
778
|
+
label: { marginTop: 6, fontWeight: '600' },
|
|
779
|
+
code: {
|
|
780
|
+
backgroundColor: '#0f172a',
|
|
781
|
+
color: '#e2e8f0',
|
|
782
|
+
padding: 8,
|
|
783
|
+
borderRadius: 8,
|
|
784
|
+
fontFamily: 'Courier',
|
|
785
|
+
},
|
|
786
|
+
})
|
|
787
|
+
`;
|
|
788
|
+
}
|
|
789
|
+
var NATIVE_ENV = `API_URL=http://localhost:4000
|
|
790
|
+
SOCKET_URL=http://localhost:4000
|
|
791
|
+
SOCKET_PATH=/socket.io
|
|
792
|
+
`;
|
|
793
|
+
async function scaffoldClient(ctx) {
|
|
794
|
+
return ctx.clientKind === "react-native" ? scaffoldNativeClient(ctx) : scaffoldWebClient(ctx);
|
|
795
|
+
}
|
|
796
|
+
async function scaffoldWebClient(ctx) {
|
|
797
|
+
const baseDir = path2.join(ctx.rootDir, "packages", "client");
|
|
798
|
+
await ensureDir(path2.join(baseDir, "src", "lib"));
|
|
799
|
+
await ensureDir(path2.join(baseDir, "src", "pages"));
|
|
800
|
+
const pkgJson = {
|
|
801
|
+
name: ctx.packageNames.client,
|
|
802
|
+
version: "0.1.0",
|
|
803
|
+
private: true,
|
|
804
|
+
type: "module",
|
|
805
|
+
main: "dist/index.js",
|
|
806
|
+
scripts: {
|
|
807
|
+
dev: "vite",
|
|
808
|
+
build: "vite build",
|
|
809
|
+
preview: "vite preview",
|
|
810
|
+
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
811
|
+
},
|
|
812
|
+
dependencies: {
|
|
813
|
+
[ctx.packageNames.contract]: "workspace:*",
|
|
814
|
+
"@emeryld/rrroutes-client": "^2.5.3",
|
|
815
|
+
"@tanstack/react-query": "^5.90.12",
|
|
816
|
+
react: "^18.3.1",
|
|
817
|
+
"react-dom": "^18.3.1",
|
|
818
|
+
"socket.io-client": "^4.8.3",
|
|
819
|
+
zod: "^4.2.1"
|
|
820
|
+
},
|
|
821
|
+
devDependencies: {
|
|
822
|
+
"@types/react": "^18.3.27",
|
|
823
|
+
"@types/react-dom": "^18.3.7",
|
|
824
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
825
|
+
typescript: "^5.9.3",
|
|
826
|
+
vite: "^6.4.1"
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
const files = {
|
|
830
|
+
"package.json": `${JSON.stringify(pkgJson, null, 2)}
|
|
831
|
+
`,
|
|
832
|
+
"tsconfig.json": `${JSON.stringify(baseTsConfig(), null, 2)}
|
|
833
|
+
`,
|
|
834
|
+
"vite.config.ts": viteConfig(),
|
|
835
|
+
"src/main.tsx": MAIN_TSX,
|
|
836
|
+
"src/App.tsx": appTsx(),
|
|
837
|
+
"src/lib/queryClient.ts": queryClient(ctx.packageNames.contract),
|
|
838
|
+
"src/lib/socket.tsx": socketProvider(ctx.packageNames.contract),
|
|
839
|
+
"src/pages/HealthPage.tsx": healthPage(ctx.packageNames.contract),
|
|
840
|
+
"src/styles.css": STYLES,
|
|
841
|
+
"src/env.d.ts": '/// <reference types="vite/client" />\n',
|
|
842
|
+
"index.html": indexHtml(),
|
|
843
|
+
".env": CLIENT_ENV
|
|
844
|
+
};
|
|
845
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
846
|
+
const fullPath = path2.join(baseDir, name);
|
|
847
|
+
const writer = name === ".env" ? writeFileForce : writeFileIfMissing;
|
|
848
|
+
const result = await writer(fullPath, contents);
|
|
849
|
+
const rel = path2.relative(ctx.rootDir, fullPath);
|
|
850
|
+
if (result === "created" || result === void 0) log.created(rel);
|
|
851
|
+
else log.skipped(rel);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
async function scaffoldNativeClient(ctx) {
|
|
855
|
+
const baseDir = path2.join(ctx.rootDir, "packages", "client");
|
|
856
|
+
await ensureDir(path2.join(baseDir, "src", "screens"));
|
|
857
|
+
const pkgJson = nativePackageJson(
|
|
858
|
+
ctx.packageNames.client,
|
|
859
|
+
ctx.packageNames.contract
|
|
860
|
+
);
|
|
861
|
+
const files = {
|
|
862
|
+
"package.json": `${JSON.stringify(pkgJson, null, 2)}
|
|
863
|
+
`,
|
|
864
|
+
"tsconfig.json": `${JSON.stringify(NATIVE_TS_CONFIG, null, 2)}
|
|
865
|
+
`,
|
|
866
|
+
"app.json": `${JSON.stringify(nativeAppJson(ctx.appSlug), null, 2)}
|
|
867
|
+
`,
|
|
868
|
+
"babel.config.js": NATIVE_BABEL,
|
|
869
|
+
"App.tsx": nativeAppTsx(),
|
|
870
|
+
"src/queryClient.ts": nativeQueryClient(ctx.packageNames.contract),
|
|
871
|
+
"src/socket.tsx": nativeSocket(ctx.packageNames.contract),
|
|
872
|
+
"src/screens/HealthScreen.tsx": nativeHealthScreen(
|
|
873
|
+
ctx.packageNames.contract
|
|
874
|
+
),
|
|
875
|
+
".env": NATIVE_ENV
|
|
876
|
+
};
|
|
877
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
878
|
+
const fullPath = path2.join(baseDir, name);
|
|
879
|
+
const writer = name === ".env" ? writeFileForce : writeFileIfMissing;
|
|
880
|
+
const result = await writer(fullPath, contents);
|
|
881
|
+
const rel = path2.relative(ctx.rootDir, fullPath);
|
|
882
|
+
if (result === "created" || result === void 0) log.created(rel);
|
|
883
|
+
else log.skipped(rel);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/generators/contract.ts
|
|
888
|
+
import path3 from "path";
|
|
889
|
+
var CONTRACT_TS = `import { defineSocketEvents, finalize, resource } from '@emeryld/rrroutes-contract'
|
|
890
|
+
import { z } from 'zod'
|
|
891
|
+
|
|
892
|
+
const routes = resource('/api')
|
|
893
|
+
.sub(
|
|
894
|
+
resource('health')
|
|
895
|
+
.get({
|
|
896
|
+
outputSchema: z.object({
|
|
897
|
+
status: z.literal('ok'),
|
|
898
|
+
html: z.string().describe('Tiny HTML heartbeat'),
|
|
899
|
+
}),
|
|
900
|
+
description: 'Basic GET health probe for uptime + docs.',
|
|
901
|
+
})
|
|
902
|
+
.post({
|
|
903
|
+
bodySchema: z.object({
|
|
904
|
+
echo: z.string().optional(),
|
|
905
|
+
}),
|
|
906
|
+
outputSchema: z.object({
|
|
907
|
+
status: z.literal('ok'),
|
|
908
|
+
received: z.string().optional(),
|
|
909
|
+
}),
|
|
910
|
+
description: 'POST health probe that echoes a payload.',
|
|
911
|
+
})
|
|
912
|
+
.done(),
|
|
913
|
+
)
|
|
914
|
+
.done()
|
|
915
|
+
|
|
916
|
+
export const registry = finalize(routes)
|
|
917
|
+
|
|
918
|
+
const sockets = defineSocketEvents(
|
|
919
|
+
{
|
|
920
|
+
joinMetaMessage: z.object({ room: z.string().optional() }),
|
|
921
|
+
leaveMetaMessage: z.object({ room: z.string().optional() }),
|
|
922
|
+
pingPayload: z.object({
|
|
923
|
+
note: z.string().default('ping'),
|
|
924
|
+
sentAt: z.string(),
|
|
925
|
+
}),
|
|
926
|
+
pongPayload: z.object({
|
|
927
|
+
ok: z.literal(true),
|
|
928
|
+
receivedAt: z.string(),
|
|
929
|
+
echo: z.string().optional(),
|
|
930
|
+
}),
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
'health:connected': {
|
|
934
|
+
message: z.object({
|
|
935
|
+
socketId: z.string(),
|
|
936
|
+
at: z.string(),
|
|
937
|
+
message: z.string(),
|
|
938
|
+
}),
|
|
939
|
+
},
|
|
940
|
+
'health:ping': {
|
|
941
|
+
message: z.object({
|
|
942
|
+
note: z.string().default('ping'),
|
|
943
|
+
}),
|
|
944
|
+
},
|
|
945
|
+
'health:pong': {
|
|
946
|
+
message: z.object({
|
|
947
|
+
ok: z.literal(true),
|
|
948
|
+
at: z.string(),
|
|
949
|
+
echo: z.string().optional(),
|
|
950
|
+
}),
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
export const socketConfig = sockets.config
|
|
956
|
+
export const socketEvents = sockets.events
|
|
957
|
+
export type AppRegistry = typeof registry
|
|
958
|
+
`;
|
|
959
|
+
async function scaffoldContract(ctx) {
|
|
960
|
+
const baseDir = path3.join(ctx.rootDir, "packages", "contract");
|
|
961
|
+
await ensureDir(path3.join(baseDir, "src"));
|
|
962
|
+
const pkgJson = {
|
|
963
|
+
name: ctx.packageNames.contract,
|
|
964
|
+
version: "0.1.0",
|
|
965
|
+
private: false,
|
|
966
|
+
type: "module",
|
|
967
|
+
main: "dist/index.js",
|
|
968
|
+
types: "dist/index.d.ts",
|
|
969
|
+
files: ["dist"],
|
|
970
|
+
scripts: {
|
|
971
|
+
build: "tsc -p tsconfig.json",
|
|
972
|
+
typecheck: "tsc -p tsconfig.json --noEmit"
|
|
973
|
+
},
|
|
974
|
+
dependencies: {
|
|
975
|
+
"@emeryld/rrroutes-contract": "^2.5.2",
|
|
976
|
+
zod: "^4.2.1"
|
|
977
|
+
},
|
|
978
|
+
devDependencies: {
|
|
979
|
+
typescript: "^5.9.3"
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
const tsconfig = {
|
|
983
|
+
extends: "../../tsconfig.base.json",
|
|
984
|
+
compilerOptions: {
|
|
985
|
+
outDir: "dist",
|
|
986
|
+
rootDir: "src",
|
|
987
|
+
declaration: true,
|
|
988
|
+
sourceMap: true
|
|
989
|
+
},
|
|
990
|
+
include: ["src/**/*"]
|
|
991
|
+
};
|
|
992
|
+
const files = {
|
|
993
|
+
"package.json": `${JSON.stringify(pkgJson, null, 2)}
|
|
994
|
+
`,
|
|
995
|
+
"tsconfig.json": `${JSON.stringify(tsconfig, null, 2)}
|
|
996
|
+
`,
|
|
997
|
+
"src/index.ts": CONTRACT_TS
|
|
998
|
+
};
|
|
999
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
1000
|
+
const fullPath = path3.join(baseDir, name);
|
|
1001
|
+
const result = await writeFileIfMissing(fullPath, contents);
|
|
1002
|
+
if (result === "created") log.created(path3.relative(ctx.rootDir, fullPath));
|
|
1003
|
+
else log.skipped(path3.relative(ctx.rootDir, fullPath));
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// src/generators/root.ts
|
|
1008
|
+
import path4 from "path";
|
|
1009
|
+
function formatJson(obj) {
|
|
1010
|
+
return `${JSON.stringify(obj, null, 2)}
|
|
1011
|
+
`;
|
|
1012
|
+
}
|
|
1013
|
+
async function scaffoldRoot(ctx) {
|
|
1014
|
+
await ensureDir(ctx.rootDir);
|
|
1015
|
+
const scripts = {
|
|
1016
|
+
build: "npm run build --workspaces",
|
|
1017
|
+
typecheck: "npm run typecheck --workspaces --if-present",
|
|
1018
|
+
lint: "npm run lint --workspaces --if-present"
|
|
1019
|
+
};
|
|
1020
|
+
if (ctx.targets.server) {
|
|
1021
|
+
scripts["dev:server"] = `npm run dev --workspace ${ctx.packageNames.server}`;
|
|
1022
|
+
}
|
|
1023
|
+
if (ctx.targets.client) {
|
|
1024
|
+
scripts["dev:client"] = `npm run dev --workspace ${ctx.packageNames.client}`;
|
|
1025
|
+
}
|
|
1026
|
+
const highlights = ["- Shared contract package (RRRoutes registry + sockets)"];
|
|
1027
|
+
if (ctx.targets.server) {
|
|
1028
|
+
highlights.unshift(
|
|
1029
|
+
"- Backend (Express + Socket.IO + RRRoutes server bindings)"
|
|
1030
|
+
);
|
|
1031
|
+
}
|
|
1032
|
+
if (ctx.targets.client) {
|
|
1033
|
+
highlights.splice(
|
|
1034
|
+
ctx.targets.server ? 1 : 0,
|
|
1035
|
+
0,
|
|
1036
|
+
"- Frontend client (React or React Native) with React Query + sockets"
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
const quickstart = ["1) Install deps: `npm install` (workspaces)"];
|
|
1040
|
+
if (ctx.targets.server) {
|
|
1041
|
+
quickstart.push(
|
|
1042
|
+
`${quickstart.length + 1}) Backend env: edit \`packages/server/.env\``
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
if (ctx.targets.client) {
|
|
1046
|
+
quickstart.push(
|
|
1047
|
+
`${quickstart.length + 1}) Client env: edit \`packages/client/.env\``
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
if (ctx.targets.server || ctx.targets.client) {
|
|
1051
|
+
quickstart.push(
|
|
1052
|
+
`${quickstart.length + 1}) Run dev servers:${ctx.targets.server ? `
|
|
1053
|
+
- API: \`npm run dev:server\`` : ""}${ctx.targets.client ? `
|
|
1054
|
+
- Client: \`npm run dev:client\`` : ""}`
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
const pkgJson = {
|
|
1058
|
+
name: `${ctx.appSlug}-stack`,
|
|
1059
|
+
private: true,
|
|
1060
|
+
workspaces: ["packages/*"],
|
|
1061
|
+
scripts,
|
|
1062
|
+
devDependencies: {
|
|
1063
|
+
typescript: "^5.9.3"
|
|
1064
|
+
}
|
|
1065
|
+
};
|
|
1066
|
+
const files = {
|
|
1067
|
+
"package.json": formatJson(pkgJson),
|
|
1068
|
+
"pnpm-workspace.yaml": "packages:\n - 'packages/*'\n",
|
|
1069
|
+
"tsconfig.base.json": formatJson({
|
|
1070
|
+
$schema: "https://json.schemastore.org/tsconfig",
|
|
1071
|
+
compilerOptions: {
|
|
1072
|
+
target: "ES2020",
|
|
1073
|
+
module: "ESNext",
|
|
1074
|
+
moduleResolution: "Bundler",
|
|
1075
|
+
jsx: "react-jsx",
|
|
1076
|
+
strict: true,
|
|
1077
|
+
skipLibCheck: true,
|
|
1078
|
+
resolveJsonModule: true,
|
|
1079
|
+
forceConsistentCasingInFileNames: true,
|
|
1080
|
+
sourceMap: true,
|
|
1081
|
+
baseUrl: "."
|
|
1082
|
+
}
|
|
1083
|
+
}),
|
|
1084
|
+
".gitignore": [
|
|
1085
|
+
"node_modules",
|
|
1086
|
+
"dist",
|
|
1087
|
+
".turbo",
|
|
1088
|
+
".expo",
|
|
1089
|
+
".DS_Store",
|
|
1090
|
+
".env",
|
|
1091
|
+
".env.local",
|
|
1092
|
+
"npm-debug.log*",
|
|
1093
|
+
"yarn-error.log"
|
|
1094
|
+
].join("\n"),
|
|
1095
|
+
README: [
|
|
1096
|
+
`# ${ctx.projectName} (RRRoutes starter)`,
|
|
1097
|
+
"",
|
|
1098
|
+
"Generated via `create-rrroutes`. Includes:",
|
|
1099
|
+
...highlights,
|
|
1100
|
+
"",
|
|
1101
|
+
"## Quickstart",
|
|
1102
|
+
...quickstart,
|
|
1103
|
+
"",
|
|
1104
|
+
ctx.targets.server ? "Dockerfile for the API lives in `packages/server/Dockerfile`." : "",
|
|
1105
|
+
""
|
|
1106
|
+
].filter(Boolean).join("\n")
|
|
1107
|
+
};
|
|
1108
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
1109
|
+
const fullPath = path4.join(ctx.rootDir, name);
|
|
1110
|
+
const result = await writeFileIfMissing(fullPath, contents);
|
|
1111
|
+
if (result === "created") log.created(path4.relative(ctx.rootDir, fullPath));
|
|
1112
|
+
else log.skipped(path4.relative(ctx.rootDir, fullPath));
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
// src/generators/server.ts
|
|
1117
|
+
import path5 from "path";
|
|
1118
|
+
function serverIndexTs() {
|
|
1119
|
+
return `import 'dotenv/config'
|
|
1120
|
+
import http from 'node:http'
|
|
1121
|
+
import { app } from './http'
|
|
1122
|
+
import { createSockets } from './socket'
|
|
1123
|
+
import { prisma } from './prisma'
|
|
1124
|
+
|
|
1125
|
+
async function main() {
|
|
1126
|
+
const PORT = Number.parseInt(process.env.PORT ?? '4000', 10)
|
|
1127
|
+
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? 'http://localhost:5173'
|
|
1128
|
+
const SOCKET_PATH = process.env.SOCKET_PATH ?? '/socket.io'
|
|
1129
|
+
|
|
1130
|
+
await prisma.$connect()
|
|
1131
|
+
|
|
1132
|
+
const httpServer = http.createServer(app)
|
|
1133
|
+
const sockets = createSockets(httpServer, {
|
|
1134
|
+
corsOrigin: CORS_ORIGIN,
|
|
1135
|
+
socketPath: SOCKET_PATH,
|
|
1136
|
+
})
|
|
1137
|
+
|
|
1138
|
+
httpServer.listen(PORT, () => {
|
|
1139
|
+
console.log(
|
|
1140
|
+
\`API listening on http://localhost:\${PORT} (CORS origin: \${CORS_ORIGIN})\`,
|
|
1141
|
+
)
|
|
1142
|
+
})
|
|
1143
|
+
|
|
1144
|
+
const shutdown = async () => {
|
|
1145
|
+
await sockets.destroy()
|
|
1146
|
+
await prisma.$disconnect()
|
|
1147
|
+
}
|
|
1148
|
+
process.on('SIGTERM', shutdown)
|
|
1149
|
+
process.on('SIGINT', shutdown)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
main().catch(async (err) => {
|
|
1153
|
+
console.error(err)
|
|
1154
|
+
await prisma.$disconnect().catch(() => {})
|
|
1155
|
+
process.exit(1)
|
|
1156
|
+
})
|
|
1157
|
+
`;
|
|
1158
|
+
}
|
|
1159
|
+
function httpTs(contractImport) {
|
|
1160
|
+
return `import express from 'express'
|
|
1161
|
+
import cors from 'cors'
|
|
1162
|
+
import { createRRRoute } from '@emeryld/rrroutes-server'
|
|
1163
|
+
import { registry } from '${contractImport}'
|
|
1164
|
+
import { controllers, type RequestCtx } from './controllers'
|
|
1165
|
+
import { prisma } from './prisma'
|
|
1166
|
+
|
|
1167
|
+
const CORS_ORIGIN = process.env.CORS_ORIGIN ?? 'http://localhost:5173'
|
|
1168
|
+
|
|
1169
|
+
export const app = express()
|
|
1170
|
+
app.use(cors({ origin: CORS_ORIGIN, credentials: true }))
|
|
1171
|
+
app.use(express.json())
|
|
1172
|
+
|
|
1173
|
+
app.get('/', (_req, res) => {
|
|
1174
|
+
res.send('<h1>RRRoutes starter API</h1>')
|
|
1175
|
+
})
|
|
1176
|
+
|
|
1177
|
+
const routes = createRRRoute<RequestCtx>(app, {
|
|
1178
|
+
buildCtx: async () => ({
|
|
1179
|
+
requestId: Math.random().toString(36).slice(2),
|
|
1180
|
+
prisma,
|
|
1181
|
+
routesLogger: console,
|
|
1182
|
+
}),
|
|
1183
|
+
debug:
|
|
1184
|
+
process.env.NODE_ENV === 'development'
|
|
1185
|
+
? { request: true, handler: true }
|
|
1186
|
+
: undefined,
|
|
1187
|
+
})
|
|
1188
|
+
|
|
1189
|
+
routes.registerControllers(registry, controllers)
|
|
1190
|
+
routes.warnMissingControllers(registry, console)
|
|
1191
|
+
export { routes }
|
|
1192
|
+
`;
|
|
1193
|
+
}
|
|
1194
|
+
function controllersTs(contractImport) {
|
|
1195
|
+
return `import { defineControllers } from '@emeryld/rrroutes-server'
|
|
1196
|
+
import type { PrismaClient } from '@prisma/client'
|
|
1197
|
+
import { registry } from '${contractImport}'
|
|
1198
|
+
|
|
1199
|
+
export type RequestCtx = {
|
|
1200
|
+
prisma: PrismaClient
|
|
1201
|
+
requestId: string
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
export const controllers = defineControllers<typeof registry, RequestCtx>()({
|
|
1205
|
+
'GET /api/health': {
|
|
1206
|
+
handler: async ({ ctx }) => {
|
|
1207
|
+
await ctx.prisma.healthCheck.create({
|
|
1208
|
+
data: { note: 'GET health' },
|
|
1209
|
+
})
|
|
1210
|
+
return {
|
|
1211
|
+
status: 'ok',
|
|
1212
|
+
html: '<!doctype html><html><body><h1>healthy</h1></body></html>',
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
},
|
|
1216
|
+
'POST /api/health': {
|
|
1217
|
+
handler: async ({ ctx, body }) => {
|
|
1218
|
+
await ctx.prisma.healthCheck.create({
|
|
1219
|
+
data: { note: body?.echo ?? 'POST health' },
|
|
1220
|
+
})
|
|
1221
|
+
return {
|
|
1222
|
+
status: 'ok',
|
|
1223
|
+
received: body?.echo ?? 'pong',
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
},
|
|
1227
|
+
})
|
|
1228
|
+
`;
|
|
1229
|
+
}
|
|
1230
|
+
function socketTs(contractImport) {
|
|
1231
|
+
return `import { Server as SocketIOServer } from 'socket.io'
|
|
1232
|
+
import type { Server } from 'node:http'
|
|
1233
|
+
import { createSocketConnections } from '@emeryld/rrroutes-server'
|
|
1234
|
+
import { socketConfig, socketEvents } from '${contractImport}'
|
|
1235
|
+
|
|
1236
|
+
export function createSockets(
|
|
1237
|
+
httpServer: Server,
|
|
1238
|
+
opts: { corsOrigin: string; socketPath: string },
|
|
1239
|
+
) {
|
|
1240
|
+
const io = new SocketIOServer(httpServer, {
|
|
1241
|
+
cors: { origin: opts.corsOrigin, credentials: true },
|
|
1242
|
+
path: opts.socketPath,
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
const sockets = createSocketConnections(io, socketEvents, {
|
|
1246
|
+
config: socketConfig,
|
|
1247
|
+
heartbeat: { enabled: true },
|
|
1248
|
+
sys: {
|
|
1249
|
+
'sys:connect': async ({ socket, helper, complete }) => {
|
|
1250
|
+
helper.emit('health:connected', {
|
|
1251
|
+
socketId: socket.id,
|
|
1252
|
+
at: new Date().toISOString(),
|
|
1253
|
+
message: 'connected',
|
|
1254
|
+
})
|
|
1255
|
+
complete()
|
|
1256
|
+
},
|
|
1257
|
+
'sys:disconnect': async ({ cleanup }) => cleanup(),
|
|
1258
|
+
'sys:ping': async ({ ping }) => ({
|
|
1259
|
+
ok: true,
|
|
1260
|
+
receivedAt: new Date().toISOString(),
|
|
1261
|
+
echo: ping.note,
|
|
1262
|
+
}),
|
|
1263
|
+
'sys:room_join': async ({ rooms, join }) => {
|
|
1264
|
+
await Promise.all(rooms.map((room) => join(room)))
|
|
1265
|
+
},
|
|
1266
|
+
'sys:room_leave': async ({ rooms, leave }) => {
|
|
1267
|
+
await Promise.all(rooms.map((room) => leave(room)))
|
|
1268
|
+
},
|
|
1269
|
+
},
|
|
1270
|
+
})
|
|
1271
|
+
|
|
1272
|
+
sockets.on('health:ping', async (payload, ctx) => {
|
|
1273
|
+
sockets.emit(
|
|
1274
|
+
'health:pong',
|
|
1275
|
+
{ ok: true, at: new Date().toISOString(), echo: payload.note },
|
|
1276
|
+
ctx.socketId,
|
|
1277
|
+
)
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
return sockets
|
|
1281
|
+
}
|
|
1282
|
+
`;
|
|
1283
|
+
}
|
|
1284
|
+
function prismaTs() {
|
|
1285
|
+
return `import { PrismaClient } from '@prisma/client'
|
|
1286
|
+
|
|
1287
|
+
export const prisma = new PrismaClient({
|
|
1288
|
+
log: ['warn', 'error'],
|
|
1289
|
+
})
|
|
1290
|
+
`;
|
|
1291
|
+
}
|
|
1292
|
+
function prismaSchema() {
|
|
1293
|
+
return `generator client {
|
|
1294
|
+
provider = "prisma-client-js"
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
datasource db {
|
|
1298
|
+
provider = "postgresql"
|
|
1299
|
+
url = env("DATABASE_URL")
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
model HealthCheck {
|
|
1303
|
+
id String @id @default(uuid())
|
|
1304
|
+
note String?
|
|
1305
|
+
createdAt DateTime @default(now())
|
|
1306
|
+
}
|
|
1307
|
+
`;
|
|
1308
|
+
}
|
|
1309
|
+
function serverEnv() {
|
|
1310
|
+
return [
|
|
1311
|
+
"PORT=4000",
|
|
1312
|
+
"NODE_ENV=development",
|
|
1313
|
+
"CORS_ORIGIN=http://localhost:5173",
|
|
1314
|
+
"SOCKET_PATH=/socket.io",
|
|
1315
|
+
"DATABASE_URL=postgresql://postgres:postgres@localhost:5432/rrroutes"
|
|
1316
|
+
].join("\n");
|
|
1317
|
+
}
|
|
1318
|
+
function dockerfile(serverName, contractName) {
|
|
1319
|
+
return `FROM node:20-alpine
|
|
1320
|
+
WORKDIR /app
|
|
1321
|
+
|
|
1322
|
+
# Install workspace deps (hoists to /app/node_modules)
|
|
1323
|
+
COPY package.json pnpm-workspace.yaml tsconfig.base.json ./
|
|
1324
|
+
COPY packages/contract/package.json packages/contract/package.json
|
|
1325
|
+
COPY packages/server/package.json packages/server/package.json
|
|
1326
|
+
COPY packages/server/prisma/schema.prisma packages/server/prisma/schema.prisma
|
|
1327
|
+
RUN npm install
|
|
1328
|
+
|
|
1329
|
+
# Copy source and build server + contract
|
|
1330
|
+
COPY packages ./packages
|
|
1331
|
+
|
|
1332
|
+
# Generate Prisma client
|
|
1333
|
+
RUN npx prisma generate --schema packages/server/prisma/schema.prisma
|
|
1334
|
+
|
|
1335
|
+
RUN npm run build --workspace ${contractName} && npm run build --workspace ${serverName}
|
|
1336
|
+
|
|
1337
|
+
EXPOSE 4000
|
|
1338
|
+
CMD ["node", "packages/server/dist/index.js"]
|
|
1339
|
+
`;
|
|
1340
|
+
}
|
|
1341
|
+
async function scaffoldServer(ctx) {
|
|
1342
|
+
const baseDir = path5.join(ctx.rootDir, "packages", "server");
|
|
1343
|
+
await ensureDir(path5.join(baseDir, "src"));
|
|
1344
|
+
await ensureDir(path5.join(baseDir, "prisma"));
|
|
1345
|
+
const pkgJson = {
|
|
1346
|
+
name: ctx.packageNames.server,
|
|
1347
|
+
version: "0.1.0",
|
|
1348
|
+
private: false,
|
|
1349
|
+
type: "module",
|
|
1350
|
+
main: "dist/index.js",
|
|
1351
|
+
types: "dist/index.d.ts",
|
|
1352
|
+
files: ["dist"],
|
|
1353
|
+
scripts: {
|
|
1354
|
+
dev: "ts-node --esm src/index.ts",
|
|
1355
|
+
build: "tsc -p tsconfig.json",
|
|
1356
|
+
typecheck: "tsc -p tsconfig.json --noEmit",
|
|
1357
|
+
start: "node dist/index.js",
|
|
1358
|
+
"prisma:generate": "prisma generate --schema prisma/schema.prisma",
|
|
1359
|
+
"prisma:migrate": "prisma migrate dev --schema prisma/schema.prisma --name init"
|
|
1360
|
+
},
|
|
1361
|
+
dependencies: {
|
|
1362
|
+
[ctx.packageNames.contract]: "workspace:*",
|
|
1363
|
+
"@emeryld/rrroutes-server": "^2.4.1",
|
|
1364
|
+
"@prisma/client": "^5.22.0",
|
|
1365
|
+
"socket.io": "^4.8.1",
|
|
1366
|
+
express: "^5.1.0",
|
|
1367
|
+
cors: "^2.8.5",
|
|
1368
|
+
dotenv: "^16.4.5",
|
|
1369
|
+
zod: "^4.2.1"
|
|
1370
|
+
},
|
|
1371
|
+
devDependencies: {
|
|
1372
|
+
"@types/express": "^5.0.6",
|
|
1373
|
+
"@types/node": "^24.10.2",
|
|
1374
|
+
prisma: "^5.22.0",
|
|
1375
|
+
typescript: "^5.9.3",
|
|
1376
|
+
"ts-node": "^10.9.2"
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
const tsconfig = {
|
|
1380
|
+
extends: "../../tsconfig.base.json",
|
|
1381
|
+
compilerOptions: {
|
|
1382
|
+
outDir: "dist",
|
|
1383
|
+
rootDir: "src",
|
|
1384
|
+
declaration: true,
|
|
1385
|
+
sourceMap: true
|
|
1386
|
+
},
|
|
1387
|
+
include: ["src/**/*"]
|
|
1388
|
+
};
|
|
1389
|
+
const files = {
|
|
1390
|
+
"package.json": `${JSON.stringify(pkgJson, null, 2)}
|
|
1391
|
+
`,
|
|
1392
|
+
"tsconfig.json": `${JSON.stringify(tsconfig, null, 2)}
|
|
1393
|
+
`,
|
|
1394
|
+
"src/index.ts": serverIndexTs(),
|
|
1395
|
+
"src/http.ts": httpTs(ctx.packageNames.contract),
|
|
1396
|
+
"src/controllers.ts": controllersTs(ctx.packageNames.contract),
|
|
1397
|
+
"src/socket.ts": socketTs(ctx.packageNames.contract),
|
|
1398
|
+
"src/prisma.ts": prismaTs(),
|
|
1399
|
+
"prisma/schema.prisma": prismaSchema(),
|
|
1400
|
+
".env": `${serverEnv()}
|
|
1401
|
+
`,
|
|
1402
|
+
Dockerfile: dockerfile(ctx.packageNames.server, ctx.packageNames.contract)
|
|
1403
|
+
};
|
|
1404
|
+
for (const [name, contents] of Object.entries(files)) {
|
|
1405
|
+
const fullPath = path5.join(baseDir, name);
|
|
1406
|
+
const writer = name === ".env" ? writeFileForce : writeFileIfMissing;
|
|
1407
|
+
const result = await writer(fullPath, contents);
|
|
1408
|
+
const rel = path5.relative(ctx.rootDir, fullPath);
|
|
1409
|
+
if (result === "created" || result === void 0) log.created(rel);
|
|
1410
|
+
else log.skipped(rel);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
// src/utils/prompt.ts
|
|
1415
|
+
import { createInterface } from "readline/promises";
|
|
1416
|
+
import { stdin as input, stdout as output } from "process";
|
|
1417
|
+
function createPrompt() {
|
|
1418
|
+
const rl = createInterface({ input, output });
|
|
1419
|
+
return {
|
|
1420
|
+
async text(message, fallback) {
|
|
1421
|
+
const suffix = fallback ? ` (${fallback})` : "";
|
|
1422
|
+
const answer = (await rl.question(`${message}${suffix}: `)).trim();
|
|
1423
|
+
return answer.length > 0 ? answer : fallback;
|
|
1424
|
+
},
|
|
1425
|
+
async confirm(message, fallback = true) {
|
|
1426
|
+
const suffix = fallback ? " (Y/n)" : " (y/N)";
|
|
1427
|
+
const answer = (await rl.question(`${message}${suffix}: `)).trim();
|
|
1428
|
+
if (!answer) return fallback;
|
|
1429
|
+
return ["y", "yes"].includes(answer.toLowerCase());
|
|
1430
|
+
},
|
|
1431
|
+
async select(message, options, fallbackIndex = 0) {
|
|
1432
|
+
console.log(message);
|
|
1433
|
+
options.forEach(
|
|
1434
|
+
(opt, idx2) => console.log(` [${idx2 + 1}] ${opt.label}`)
|
|
1435
|
+
);
|
|
1436
|
+
const answer = (await rl.question(`Choose 1-${options.length}: `)).trim();
|
|
1437
|
+
const idx = Number.parseInt(answer, 10);
|
|
1438
|
+
const normalized = Number.isNaN(idx) || idx < 1 || idx > options.length ? fallbackIndex : idx - 1;
|
|
1439
|
+
return options[normalized].value;
|
|
1440
|
+
},
|
|
1441
|
+
close() {
|
|
1442
|
+
rl.close();
|
|
1443
|
+
}
|
|
1444
|
+
};
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
// src/utils/strings.ts
|
|
1448
|
+
function slugify(input2, fallback = "rrroutes-app") {
|
|
1449
|
+
const slug = input2.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1450
|
+
return slug.length > 0 ? slug : fallback;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// src/index.ts
|
|
1454
|
+
function resolveTargets(choice) {
|
|
1455
|
+
if (choice === "all")
|
|
1456
|
+
return { client: true, server: true, contract: true };
|
|
1457
|
+
if (choice === "client")
|
|
1458
|
+
return { client: true, server: false, contract: true };
|
|
1459
|
+
if (choice === "server")
|
|
1460
|
+
return { client: false, server: true, contract: true };
|
|
1461
|
+
return { client: false, server: false, contract: true };
|
|
1462
|
+
}
|
|
1463
|
+
async function gatherContext() {
|
|
1464
|
+
const prompt = createPrompt();
|
|
1465
|
+
try {
|
|
1466
|
+
const projectName = await prompt.text("Project name", "rrroutes-starter") ?? "rrroutes-starter";
|
|
1467
|
+
const appSlug = slugify(projectName);
|
|
1468
|
+
const targetDirInput = await prompt.text("Target directory", `./${appSlug}`) ?? `./${appSlug}`;
|
|
1469
|
+
const rootDir = path6.resolve(process.cwd(), targetDirInput);
|
|
1470
|
+
const scopeInput = await prompt.text("NPM scope (without @)", appSlug) ?? appSlug;
|
|
1471
|
+
const packageScope = slugify(scopeInput);
|
|
1472
|
+
const selection = await prompt.select("What should we create?", [
|
|
1473
|
+
{
|
|
1474
|
+
label: "Everything (contract + server + client)",
|
|
1475
|
+
value: "all"
|
|
1476
|
+
},
|
|
1477
|
+
{ label: "Contract only", value: "contract" },
|
|
1478
|
+
{ label: "Backend only", value: "server" },
|
|
1479
|
+
{ label: "Frontend only", value: "client" }
|
|
1480
|
+
]);
|
|
1481
|
+
const targets = resolveTargets(selection);
|
|
1482
|
+
let clientKind = "react";
|
|
1483
|
+
if (targets.client) {
|
|
1484
|
+
clientKind = await prompt.select(
|
|
1485
|
+
"Frontend runtime",
|
|
1486
|
+
[
|
|
1487
|
+
{ label: "React (web, Vite)", value: "react" },
|
|
1488
|
+
{ label: "React Native (Expo)", value: "react-native" }
|
|
1489
|
+
],
|
|
1490
|
+
0
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
const packageNames = {
|
|
1494
|
+
contract: `@${packageScope}/contract`,
|
|
1495
|
+
server: `@${packageScope}/server`,
|
|
1496
|
+
client: `@${packageScope}/client`
|
|
1497
|
+
};
|
|
1498
|
+
return {
|
|
1499
|
+
projectName,
|
|
1500
|
+
appSlug,
|
|
1501
|
+
packageScope,
|
|
1502
|
+
rootDir,
|
|
1503
|
+
clientKind,
|
|
1504
|
+
targets,
|
|
1505
|
+
packageNames
|
|
1506
|
+
};
|
|
1507
|
+
} finally {
|
|
1508
|
+
prompt.close();
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
async function main() {
|
|
1512
|
+
const ctx = await gatherContext();
|
|
1513
|
+
log.info(`Scaffolding RRRoutes starter in ${ctx.rootDir}`);
|
|
1514
|
+
await scaffoldRoot(ctx);
|
|
1515
|
+
if (ctx.targets.contract) await scaffoldContract(ctx);
|
|
1516
|
+
if (ctx.targets.server) await scaffoldServer(ctx);
|
|
1517
|
+
if (ctx.targets.client) await scaffoldClient(ctx);
|
|
1518
|
+
log.info("Done. Install dependencies and start hacking!");
|
|
1519
|
+
}
|
|
1520
|
+
main().catch((err) => {
|
|
1521
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
1522
|
+
process.exit(1);
|
|
1523
|
+
});
|
|
1524
|
+
//# sourceMappingURL=index.js.map
|