@emeryld/manager 0.5.1 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -19
- package/dist/create-package/index.js +43 -1
- package/dist/create-package/variants/client/client_expo_rn.js +530 -0
- package/dist/create-package/variants/client/client_vite_r.js +583 -0
- package/dist/create-package/variants/client/shared.js +56 -0
- package/dist/create-package/variants/client.js +1 -122
- package/dist/create-package/variants/docker.js +21 -142
- package/dist/create-package/variants/empty.js +1 -0
- package/dist/create-package/variants/fullstack.js +262 -10
- package/dist/docker.js +128 -0
- package/dist/menu.js +44 -10
- package/dist/packages.js +12 -1
- package/package.json +1 -1
|
@@ -0,0 +1,583 @@
|
|
|
1
|
+
import { BASE_LINT_DEV_DEPENDENCIES, basePackageFiles, basePackageJson, baseScripts, buildReadme, isWorkspaceRoot, packageTsConfig, writeFileIfMissing, } from '../../shared.js';
|
|
2
|
+
const VITE_CLIENT_SCRIPTS = [
|
|
3
|
+
'dev',
|
|
4
|
+
'build',
|
|
5
|
+
'preview',
|
|
6
|
+
'typecheck',
|
|
7
|
+
'lint',
|
|
8
|
+
'lint:fix',
|
|
9
|
+
'format',
|
|
10
|
+
'format:check',
|
|
11
|
+
'clean',
|
|
12
|
+
'test',
|
|
13
|
+
];
|
|
14
|
+
const CLIENT_ENV_EXAMPLE = `VITE_API_URL=http://localhost:4000
|
|
15
|
+
VITE_SOCKET_URL=http://localhost:4000
|
|
16
|
+
VITE_SOCKET_PATH=/socket.io
|
|
17
|
+
`;
|
|
18
|
+
function vitePackageJson(name, contractName, includePrepare) {
|
|
19
|
+
const dependencies = {
|
|
20
|
+
'@emeryld/rrroutes-client': '^2.5.3',
|
|
21
|
+
'@tanstack/react-query': '^5.90.12',
|
|
22
|
+
react: '^18.3.1',
|
|
23
|
+
'react-dom': '^18.3.1',
|
|
24
|
+
'socket.io-client': '^4.8.3',
|
|
25
|
+
zod: '^4.2.1',
|
|
26
|
+
};
|
|
27
|
+
if (contractName)
|
|
28
|
+
dependencies[contractName] = 'workspace:*';
|
|
29
|
+
const devDependencies = {
|
|
30
|
+
...BASE_LINT_DEV_DEPENDENCIES,
|
|
31
|
+
'@types/node': '^24.10.2',
|
|
32
|
+
'@types/react': '^18.3.27',
|
|
33
|
+
'@types/react-dom': '^18.3.7',
|
|
34
|
+
'@vitejs/plugin-react': '^4.3.4',
|
|
35
|
+
typescript: '^5.9.3',
|
|
36
|
+
vite: '^6.4.1',
|
|
37
|
+
};
|
|
38
|
+
return basePackageJson({
|
|
39
|
+
name,
|
|
40
|
+
useDefaults: false,
|
|
41
|
+
type: 'module',
|
|
42
|
+
scripts: baseScripts('vite', {
|
|
43
|
+
build: 'vite build',
|
|
44
|
+
preview: 'vite preview',
|
|
45
|
+
typecheck: 'tsc -p tsconfig.json --noEmit',
|
|
46
|
+
}, { includePrepare }),
|
|
47
|
+
dependencies,
|
|
48
|
+
devDependencies,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function viteConfig() {
|
|
52
|
+
return `import { defineConfig } from 'vite'
|
|
53
|
+
import react from '@vitejs/plugin-react'
|
|
54
|
+
|
|
55
|
+
export default defineConfig({
|
|
56
|
+
plugins: [react()],
|
|
57
|
+
})
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
const MAIN_TSX = `import React from 'react'
|
|
61
|
+
import ReactDOM from 'react-dom/client'
|
|
62
|
+
import App from './App'
|
|
63
|
+
import './styles.css'
|
|
64
|
+
|
|
65
|
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
66
|
+
<React.StrictMode>
|
|
67
|
+
<App />
|
|
68
|
+
</React.StrictMode>,
|
|
69
|
+
)
|
|
70
|
+
`;
|
|
71
|
+
function appTsx() {
|
|
72
|
+
return `import React from 'react'
|
|
73
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
74
|
+
import { queryClient } from './lib/queryClient'
|
|
75
|
+
import { AppSocketProvider } from './lib/socket'
|
|
76
|
+
import { HealthPage } from './pages/HealthPage'
|
|
77
|
+
|
|
78
|
+
export default function App() {
|
|
79
|
+
return (
|
|
80
|
+
<QueryClientProvider client={queryClient}>
|
|
81
|
+
<AppSocketProvider>
|
|
82
|
+
<HealthPage />
|
|
83
|
+
</AppSocketProvider>
|
|
84
|
+
</QueryClientProvider>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
`;
|
|
88
|
+
}
|
|
89
|
+
const STYLES = `:root {
|
|
90
|
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
91
|
+
color: #0b1021;
|
|
92
|
+
background: #f7f8fb;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
body {
|
|
96
|
+
margin: 0;
|
|
97
|
+
background: radial-gradient(circle at 20% 20%, #eef2ff, #f7f8fb 45%);
|
|
98
|
+
min-height: 100vh;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.health {
|
|
102
|
+
max-width: 960px;
|
|
103
|
+
margin: 0 auto;
|
|
104
|
+
padding: 32px;
|
|
105
|
+
display: flex;
|
|
106
|
+
flex-direction: column;
|
|
107
|
+
gap: 16px;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.grid {
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
|
113
|
+
gap: 16px;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.card {
|
|
117
|
+
background: #fff;
|
|
118
|
+
border: 1px solid #e5e7eb;
|
|
119
|
+
border-radius: 12px;
|
|
120
|
+
padding: 16px;
|
|
121
|
+
box-shadow: 0 8px 24px rgba(12, 18, 32, 0.05);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.notice {
|
|
125
|
+
background: #fff7ed;
|
|
126
|
+
border: 1px solid #fed7aa;
|
|
127
|
+
color: #9a3412;
|
|
128
|
+
padding: 12px;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.muted {
|
|
133
|
+
color: #6b7280;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
button {
|
|
137
|
+
background: #0f172a;
|
|
138
|
+
color: #fff;
|
|
139
|
+
border: none;
|
|
140
|
+
border-radius: 8px;
|
|
141
|
+
padding: 10px 12px;
|
|
142
|
+
cursor: pointer;
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
button.secondary {
|
|
147
|
+
background: #e2e8f0;
|
|
148
|
+
color: #0f172a;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
button + button {
|
|
152
|
+
margin-left: 8px;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
textarea {
|
|
156
|
+
width: 100%;
|
|
157
|
+
min-height: 80px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.logs {
|
|
161
|
+
font-family: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', Menlo,
|
|
162
|
+
monospace;
|
|
163
|
+
background: #0f172a;
|
|
164
|
+
color: #e2e8f0;
|
|
165
|
+
padding: 12px;
|
|
166
|
+
border-radius: 8px;
|
|
167
|
+
min-height: 160px;
|
|
168
|
+
white-space: pre-line;
|
|
169
|
+
}
|
|
170
|
+
`;
|
|
171
|
+
function queryClient(contractImport) {
|
|
172
|
+
if (contractImport) {
|
|
173
|
+
return `import { QueryClient } from '@tanstack/react-query'
|
|
174
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
175
|
+
import { registry } from '${contractImport}'
|
|
176
|
+
|
|
177
|
+
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
|
178
|
+
|
|
179
|
+
export const queryClient = new QueryClient()
|
|
180
|
+
|
|
181
|
+
export const routeClient = createRouteClient({
|
|
182
|
+
baseUrl,
|
|
183
|
+
queryClient,
|
|
184
|
+
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
export const healthGet = routeClient.build(registry.byKey['GET /api/health'])
|
|
188
|
+
export const healthPost = routeClient.build(registry.byKey['POST /api/health'])
|
|
189
|
+
export const hasContract = true as const
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
return `import { QueryClient } from '@tanstack/react-query'
|
|
193
|
+
import { createRouteClient } from '@emeryld/rrroutes-client'
|
|
194
|
+
|
|
195
|
+
const baseUrl = import.meta.env.VITE_API_URL ?? 'http://localhost:4000'
|
|
196
|
+
|
|
197
|
+
export const queryClient = new QueryClient()
|
|
198
|
+
|
|
199
|
+
export const routeClient = createRouteClient({
|
|
200
|
+
baseUrl,
|
|
201
|
+
queryClient,
|
|
202
|
+
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
type RouteEndpoint = ReturnType<typeof routeClient.build>
|
|
206
|
+
|
|
207
|
+
function placeholderUseEndpoint() {
|
|
208
|
+
const error = new Error('Add your contract import to src/lib/queryClient.ts')
|
|
209
|
+
return {
|
|
210
|
+
data: undefined,
|
|
211
|
+
error,
|
|
212
|
+
refetch: () => undefined,
|
|
213
|
+
mutateAsync: async () => Promise.reject(error),
|
|
214
|
+
isPending: false,
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const placeholderEndpoint = {
|
|
219
|
+
useEndpoint: placeholderUseEndpoint,
|
|
220
|
+
} as unknown as RouteEndpoint
|
|
221
|
+
|
|
222
|
+
export const healthGet: RouteEndpoint = placeholderEndpoint
|
|
223
|
+
export const healthPost: RouteEndpoint = placeholderEndpoint
|
|
224
|
+
export const hasContract = false as const
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
function socketProvider(contractImport) {
|
|
228
|
+
if (contractImport) {
|
|
229
|
+
return `import React from 'react'
|
|
230
|
+
import { io, type Socket } from 'socket.io-client'
|
|
231
|
+
import {
|
|
232
|
+
buildSocketProvider,
|
|
233
|
+
type SocketClientOptions,
|
|
234
|
+
} from '@emeryld/rrroutes-client'
|
|
235
|
+
import { socketConfig, socketEvents } from '${contractImport}'
|
|
236
|
+
|
|
237
|
+
const socketUrl = import.meta.env.VITE_SOCKET_URL ?? 'http://localhost:4000'
|
|
238
|
+
const socketPath = import.meta.env.VITE_SOCKET_PATH ?? '/socket.io'
|
|
239
|
+
|
|
240
|
+
const sysEvents: SocketClientOptions<
|
|
241
|
+
typeof socketEvents,
|
|
242
|
+
typeof socketConfig
|
|
243
|
+
>['sys'] = {
|
|
244
|
+
'sys:connect': async ({ socket }) => {
|
|
245
|
+
console.info('socket connected', socket.id)
|
|
246
|
+
},
|
|
247
|
+
'sys:disconnect': async ({ reason }) => {
|
|
248
|
+
console.info('socket disconnected', reason)
|
|
249
|
+
},
|
|
250
|
+
'sys:reconnect': async ({ attempt, socket }) => {
|
|
251
|
+
console.info('socket reconnect', attempt, socket?.id)
|
|
252
|
+
},
|
|
253
|
+
'sys:connect_error': async ({ error }) => {
|
|
254
|
+
console.warn('socket connect error', error)
|
|
255
|
+
},
|
|
256
|
+
'sys:ping': () => ({
|
|
257
|
+
note: 'client-heartbeat',
|
|
258
|
+
sentAt: new Date().toISOString(),
|
|
259
|
+
}),
|
|
260
|
+
'sys:pong': async ({ payload }) => {
|
|
261
|
+
console.info('socket pong', payload)
|
|
262
|
+
},
|
|
263
|
+
'sys:room_join': async ({ rooms }) => {
|
|
264
|
+
console.info('joining rooms', rooms)
|
|
265
|
+
return true
|
|
266
|
+
},
|
|
267
|
+
'sys:room_leave': async ({ rooms }) => {
|
|
268
|
+
console.info('leaving rooms', rooms)
|
|
269
|
+
return true
|
|
270
|
+
},
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const baseOptions: Omit<
|
|
274
|
+
SocketClientOptions<typeof socketEvents, typeof socketConfig>,
|
|
275
|
+
'socket'
|
|
276
|
+
> = {
|
|
277
|
+
config: socketConfig,
|
|
278
|
+
sys: sysEvents,
|
|
279
|
+
environment: import.meta.env.MODE === 'production' ? 'production' : 'development',
|
|
280
|
+
heartbeat: { intervalMs: 15_000, timeoutMs: 7_500 },
|
|
281
|
+
debug: {
|
|
282
|
+
connection: true,
|
|
283
|
+
heartbeat: true,
|
|
284
|
+
},
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const { SocketProvider, useSocketClient, useSocketConnection } =
|
|
288
|
+
buildSocketProvider({
|
|
289
|
+
events: socketEvents,
|
|
290
|
+
options: baseOptions,
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
function getSocket(): Promise<Socket> {
|
|
294
|
+
return Promise.resolve(
|
|
295
|
+
io(socketUrl, {
|
|
296
|
+
path: socketPath,
|
|
297
|
+
transports: ['websocket'],
|
|
298
|
+
}),
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const roomMeta = { room: 'health' }
|
|
303
|
+
export const socketReady = true as const
|
|
304
|
+
|
|
305
|
+
export function AppSocketProvider(props: React.PropsWithChildren) {
|
|
306
|
+
return (
|
|
307
|
+
<SocketProvider
|
|
308
|
+
getSocket={getSocket}
|
|
309
|
+
destroyLeaveMeta={roomMeta}
|
|
310
|
+
fallback={<p>Connecting socket…</p>}
|
|
311
|
+
>
|
|
312
|
+
{props.children}
|
|
313
|
+
</SocketProvider>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export { useSocketClient, useSocketConnection }
|
|
318
|
+
`;
|
|
319
|
+
}
|
|
320
|
+
return `import React from 'react'
|
|
321
|
+
|
|
322
|
+
export const roomMeta = { room: 'health' }
|
|
323
|
+
export const socketReady = false as const
|
|
324
|
+
|
|
325
|
+
const stubSocket = {
|
|
326
|
+
connect: () =>
|
|
327
|
+
console.info('Socket disabled; add your contract import in src/lib/socket.tsx.'),
|
|
328
|
+
disconnect: () => undefined,
|
|
329
|
+
emit: () => undefined,
|
|
330
|
+
joinRooms: () => undefined,
|
|
331
|
+
leaveRooms: () => undefined,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function AppSocketProvider(props: React.PropsWithChildren) {
|
|
335
|
+
return <>{props.children}</>
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function useSocketClient() {
|
|
339
|
+
return stubSocket
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function useSocketConnection() {
|
|
343
|
+
// no-op when sockets are not configured
|
|
344
|
+
}
|
|
345
|
+
`;
|
|
346
|
+
}
|
|
347
|
+
function healthPage(contractImport) {
|
|
348
|
+
return `import React from 'react'
|
|
349
|
+
import { healthGet, healthPost, hasContract } from '../lib/queryClient'
|
|
350
|
+
import { roomMeta, useSocketClient, useSocketConnection, socketReady } from '../lib/socket'
|
|
351
|
+
|
|
352
|
+
const now = () => new Date().toLocaleTimeString()
|
|
353
|
+
|
|
354
|
+
function useLogs() {
|
|
355
|
+
const [logs, setLogs] = React.useState<string[]>([])
|
|
356
|
+
return {
|
|
357
|
+
logs,
|
|
358
|
+
push: (msg: string) =>
|
|
359
|
+
setLogs((prev) => [\`[\${now()}] \${msg}\`, ...prev].slice(0, 60)),
|
|
360
|
+
clear: () => setLogs([]),
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function HealthPage() {
|
|
365
|
+
const [echo, setEcho] = React.useState('hello rrroute')
|
|
366
|
+
const httpGet = healthGet.useEndpoint()
|
|
367
|
+
const httpPost = healthPost.useEndpoint()
|
|
368
|
+
const socket = useSocketClient()
|
|
369
|
+
const { logs, push, clear } = useLogs()
|
|
370
|
+
|
|
371
|
+
useSocketConnection({
|
|
372
|
+
event: 'health:connected',
|
|
373
|
+
rooms: ['health'],
|
|
374
|
+
joinMeta: roomMeta,
|
|
375
|
+
leaveMeta: roomMeta,
|
|
376
|
+
onMessage: (payload) => {
|
|
377
|
+
push(\`socket connected (\${payload.socketId})\`)
|
|
378
|
+
},
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
useSocketConnection({
|
|
382
|
+
event: 'health:pong',
|
|
383
|
+
rooms: ['health'],
|
|
384
|
+
joinMeta: roomMeta,
|
|
385
|
+
leaveMeta: roomMeta,
|
|
386
|
+
onMessage: (payload) => {
|
|
387
|
+
push(
|
|
388
|
+
\`pong at \${payload.at}\${payload.echo ? \` (echo: \${payload.echo})\` : ''}\`,
|
|
389
|
+
)
|
|
390
|
+
},
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
return (
|
|
394
|
+
<div className="health">
|
|
395
|
+
<h1>RRRoutes health sandbox</h1>
|
|
396
|
+
{!hasContract ? (
|
|
397
|
+
<p className="notice">
|
|
398
|
+
Add your contract import in <code>src/lib/queryClient.ts</code> and{' '}
|
|
399
|
+
<code>src/lib/socket.tsx</code> to call the real API and socket events.
|
|
400
|
+
</p>
|
|
401
|
+
) : null}
|
|
402
|
+
<div className="grid">
|
|
403
|
+
<div className="card">
|
|
404
|
+
<h2>HTTP endpoints</h2>
|
|
405
|
+
{!hasContract ? (
|
|
406
|
+
<p className="muted">Wire up a contract to enable HTTP calls.</p>
|
|
407
|
+
) : (
|
|
408
|
+
<>
|
|
409
|
+
<p>GET and POST against the shared contract.</p>
|
|
410
|
+
<div>
|
|
411
|
+
<button onClick={() => httpGet.refetch()}>GET /health</button>
|
|
412
|
+
{httpGet.error ? (
|
|
413
|
+
<p style={{ color: 'crimson', marginTop: 6 }}>
|
|
414
|
+
GET error: {String(httpGet.error)}
|
|
415
|
+
</p>
|
|
416
|
+
) : null}
|
|
417
|
+
</div>
|
|
418
|
+
<div style={{ marginTop: 12 }}>
|
|
419
|
+
<input
|
|
420
|
+
value={echo}
|
|
421
|
+
onChange={(e) => setEcho(e.target.value)}
|
|
422
|
+
placeholder="echo payload"
|
|
423
|
+
style={{ width: '100%', padding: '8px' }}
|
|
424
|
+
/>
|
|
425
|
+
<div style={{ marginTop: 8 }}>
|
|
426
|
+
<button
|
|
427
|
+
onClick={() =>
|
|
428
|
+
httpPost.mutateAsync({ echo }).then(() => {
|
|
429
|
+
push('POST /health ok')
|
|
430
|
+
})
|
|
431
|
+
}
|
|
432
|
+
>
|
|
433
|
+
POST /health
|
|
434
|
+
</button>
|
|
435
|
+
</div>
|
|
436
|
+
{httpPost.error ? (
|
|
437
|
+
<p style={{ color: 'crimson', marginTop: 6 }}>
|
|
438
|
+
POST error: {String(httpPost.error)}
|
|
439
|
+
</p>
|
|
440
|
+
) : null}
|
|
441
|
+
</div>
|
|
442
|
+
<div style={{ marginTop: 12 }}>
|
|
443
|
+
<strong>GET data</strong>
|
|
444
|
+
<pre>{JSON.stringify(httpGet.data, null, 2) ?? 'none'}</pre>
|
|
445
|
+
<strong>POST data</strong>
|
|
446
|
+
<pre>{JSON.stringify(httpPost.data, null, 2) ?? 'none'}</pre>
|
|
447
|
+
</div>
|
|
448
|
+
</>
|
|
449
|
+
)}
|
|
450
|
+
</div>
|
|
451
|
+
|
|
452
|
+
<div className="card">
|
|
453
|
+
<h2>Socket</h2>
|
|
454
|
+
{!socketReady ? (
|
|
455
|
+
<p className="muted">
|
|
456
|
+
Wire up socket events via your contract to enable live updates.
|
|
457
|
+
</p>
|
|
458
|
+
) : (
|
|
459
|
+
<>
|
|
460
|
+
<p>Connect, ping, and watch lifecycle events.</p>
|
|
461
|
+
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
|
462
|
+
<button onClick={() => socket.connect()}>Connect</button>
|
|
463
|
+
<button className="secondary" onClick={() => socket.disconnect()}>
|
|
464
|
+
Disconnect
|
|
465
|
+
</button>
|
|
466
|
+
<button
|
|
467
|
+
onClick={() =>
|
|
468
|
+
socket.emit('health:ping', { note: 'ping from client' })
|
|
469
|
+
}
|
|
470
|
+
>
|
|
471
|
+
Emit ping
|
|
472
|
+
</button>
|
|
473
|
+
<button
|
|
474
|
+
className="secondary"
|
|
475
|
+
onClick={() => socket.joinRooms(['health'], roomMeta)}
|
|
476
|
+
>
|
|
477
|
+
Join room
|
|
478
|
+
</button>
|
|
479
|
+
<button
|
|
480
|
+
className="secondary"
|
|
481
|
+
onClick={() => socket.leaveRooms(['health'], roomMeta)}
|
|
482
|
+
>
|
|
483
|
+
Leave room
|
|
484
|
+
</button>
|
|
485
|
+
</div>
|
|
486
|
+
{httpGet.error || httpPost.error ? (
|
|
487
|
+
<p style={{ color: 'crimson', marginTop: 8 }}>
|
|
488
|
+
Check server logs; errors will also show above.
|
|
489
|
+
</p>
|
|
490
|
+
) : null}
|
|
491
|
+
</>
|
|
492
|
+
)}
|
|
493
|
+
</div>
|
|
494
|
+
|
|
495
|
+
<div className="card">
|
|
496
|
+
<h2>Socket logs</h2>
|
|
497
|
+
<button className="secondary" onClick={clear}>
|
|
498
|
+
Clear
|
|
499
|
+
</button>
|
|
500
|
+
<div className="logs">
|
|
501
|
+
{logs.length === 0 ? 'No messages yet' : logs.join('\\n')}
|
|
502
|
+
</div>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
)
|
|
507
|
+
}
|
|
508
|
+
`;
|
|
509
|
+
}
|
|
510
|
+
function indexHtml() {
|
|
511
|
+
return `<!doctype html>
|
|
512
|
+
<html lang="en">
|
|
513
|
+
<head>
|
|
514
|
+
<meta charset="UTF-8" />
|
|
515
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
516
|
+
<title>RRRoutes starter</title>
|
|
517
|
+
</head>
|
|
518
|
+
<body>
|
|
519
|
+
<div id="root"></div>
|
|
520
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
521
|
+
</body>
|
|
522
|
+
</html>
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
export async function scaffoldViteReactClient(ctx) {
|
|
526
|
+
const includePrepare = isWorkspaceRoot(ctx.targetDir);
|
|
527
|
+
const tsconfig = await packageTsConfig(ctx.targetDir, {
|
|
528
|
+
include: ['src/**/*', 'vite.config.ts'],
|
|
529
|
+
lib: ['ES2022', 'DOM'],
|
|
530
|
+
types: ['vite/client'],
|
|
531
|
+
jsx: 'react-jsx',
|
|
532
|
+
});
|
|
533
|
+
const files = {
|
|
534
|
+
'package.json': vitePackageJson(ctx.pkgName, ctx.contractName, includePrepare),
|
|
535
|
+
'tsconfig.json': tsconfig,
|
|
536
|
+
'vite.config.ts': viteConfig(),
|
|
537
|
+
'src/main.tsx': MAIN_TSX,
|
|
538
|
+
'src/App.tsx': appTsx(),
|
|
539
|
+
'src/lib/queryClient.ts': queryClient(ctx.contractName),
|
|
540
|
+
'src/lib/socket.tsx': socketProvider(ctx.contractName),
|
|
541
|
+
'src/pages/HealthPage.tsx': healthPage(ctx.contractName),
|
|
542
|
+
'src/styles.css': STYLES,
|
|
543
|
+
'src/env.d.ts': '/// <reference types="vite/client" />\n',
|
|
544
|
+
'index.html': indexHtml(),
|
|
545
|
+
'.env.example': CLIENT_ENV_EXAMPLE,
|
|
546
|
+
'README.md': buildReadme({
|
|
547
|
+
name: ctx.pkgName,
|
|
548
|
+
description: 'Vite + React RRRoutes client scaffold.',
|
|
549
|
+
scripts: [...VITE_CLIENT_SCRIPTS],
|
|
550
|
+
sections: [
|
|
551
|
+
{
|
|
552
|
+
title: 'Getting Started',
|
|
553
|
+
lines: [
|
|
554
|
+
'- Install deps: `npm install` (or `pnpm install`).',
|
|
555
|
+
'- Copy `.env.example` to `.env` and tweak URLs as needed.',
|
|
556
|
+
'- Start dev mode: `npm run dev`.',
|
|
557
|
+
'- Build output: `npm run build`, preview with `npm run preview`.',
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
{
|
|
561
|
+
title: 'Contract wiring',
|
|
562
|
+
lines: [
|
|
563
|
+
ctx.contractName
|
|
564
|
+
? `- Contract wired to ${ctx.contractName}; adjust the import in \`src/lib/queryClient.ts\` if needed.`
|
|
565
|
+
: '- Update `src/lib/queryClient.ts` and `src/lib/socket.tsx` with your contract import to enable HTTP + socket helpers.',
|
|
566
|
+
],
|
|
567
|
+
},
|
|
568
|
+
{
|
|
569
|
+
title: 'Environment',
|
|
570
|
+
lines: [
|
|
571
|
+
'- `VITE_API_URL` sets the API base URL (default http://localhost:4000).',
|
|
572
|
+
'- `VITE_SOCKET_URL` and `VITE_SOCKET_PATH` configure socket connections.',
|
|
573
|
+
],
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
}),
|
|
577
|
+
...basePackageFiles(),
|
|
578
|
+
};
|
|
579
|
+
for (const [relative, contents] of Object.entries(files)) {
|
|
580
|
+
// eslint-disable-next-line no-await-in-loop
|
|
581
|
+
await writeFileIfMissing(ctx.targetDir, relative, contents);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { scaffoldExpoReactNativeClient } from './client_expo_rn.js';
|
|
2
|
+
import { scaffoldViteReactClient } from './client_vite_r.js';
|
|
3
|
+
export const CLIENT_KIND_OPTIONS = [
|
|
4
|
+
{
|
|
5
|
+
id: 'vite-react',
|
|
6
|
+
label: 'Vite + React (web)',
|
|
7
|
+
summary: 'Vite SPA with React Query and socket helpers.',
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
id: 'expo-react-native',
|
|
11
|
+
label: 'Expo + React Native (mobile)',
|
|
12
|
+
summary: 'Expo-managed React Native app with RRRoutes client wiring.',
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
export const DEFAULT_CLIENT_KIND = 'vite-react';
|
|
16
|
+
export function normalizeClientKind(input) {
|
|
17
|
+
if (!input)
|
|
18
|
+
return undefined;
|
|
19
|
+
const normalized = input.trim().toLowerCase();
|
|
20
|
+
if (!normalized)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (['vite', 'vite-react', 'react', 'web', 'spa', 'v'].includes(normalized)) {
|
|
23
|
+
return 'vite-react';
|
|
24
|
+
}
|
|
25
|
+
if (['expo', 'rn', 'react-native', 'expo-react-native', 'native', 'e'].includes(normalized)) {
|
|
26
|
+
return 'expo-react-native';
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const scaffolders = {
|
|
31
|
+
'vite-react': scaffoldViteReactClient,
|
|
32
|
+
'expo-react-native': scaffoldExpoReactNativeClient,
|
|
33
|
+
};
|
|
34
|
+
export const clientVariant = {
|
|
35
|
+
id: 'rrr-client',
|
|
36
|
+
label: 'rrr client',
|
|
37
|
+
defaultDir: 'packages/rrr-client',
|
|
38
|
+
summary: 'RRRoutes client scaffold (pick Vite + React web or Expo + React Native).',
|
|
39
|
+
keyFiles: [
|
|
40
|
+
'package.json',
|
|
41
|
+
'tsconfig.json',
|
|
42
|
+
'src/lib/queryClient.ts or src/queryClient.ts',
|
|
43
|
+
'src/lib/socket.tsx or src/socket.tsx',
|
|
44
|
+
],
|
|
45
|
+
scripts: ['dev', 'build', 'typecheck', 'lint', 'format'],
|
|
46
|
+
notes: [
|
|
47
|
+
'Pick a contract from discovered workspace packages (or skip) during scaffolding.',
|
|
48
|
+
'Choose between a Vite-powered web client or an Expo React Native mobile client.',
|
|
49
|
+
'If you skip a contract, stub endpoints/sockets are generated so the project still builds.',
|
|
50
|
+
],
|
|
51
|
+
async scaffold(ctx) {
|
|
52
|
+
const kind = ctx.clientKind ?? DEFAULT_CLIENT_KIND;
|
|
53
|
+
const scaffolder = scaffolders[kind] ?? scaffoldViteReactClient;
|
|
54
|
+
await scaffolder({ ...ctx, clientKind: kind });
|
|
55
|
+
},
|
|
56
|
+
};
|