create-fluxstack 1.21.1 → 1.22.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/app/client/src/App.tsx +9 -11
  2. package/app/client/src/components/AppLayout.tsx +291 -290
  3. package/app/client/src/components/BackButton.tsx +17 -16
  4. package/app/client/src/components/ColorWheel.tsx +1 -0
  5. package/app/client/src/components/DemoPage.tsx +136 -135
  6. package/app/client/src/components/ErrorBoundary.tsx +2 -1
  7. package/app/client/src/components/LiveErrorBoundary.tsx +2 -1
  8. package/app/client/src/components/ThemePicker.tsx +1 -0
  9. package/app/client/src/framework/ClientOnly.tsx +14 -0
  10. package/app/client/src/framework/LivePage.tsx +55 -0
  11. package/app/client/src/framework/RscHomePage.tsx +125 -0
  12. package/app/client/src/framework/RscLink.tsx +31 -0
  13. package/app/client/src/framework/RscNav.tsx +42 -0
  14. package/app/client/src/framework/RscRoot.tsx +54 -0
  15. package/app/client/src/framework/csrf.ts +22 -0
  16. package/app/client/src/framework/entry.browser.tsx +51 -0
  17. package/app/client/src/framework/entry.rsc.tsx +41 -0
  18. package/app/client/src/framework/entry.ssr.tsx +12 -0
  19. package/app/client/src/framework/navigation.ts +29 -0
  20. package/app/client/src/framework/params.tsx +22 -0
  21. package/app/client/src/framework/routes.ts +143 -0
  22. package/app/client/src/framework/vite-rsc.d.ts +21 -0
  23. package/app/client/src/hooks/useThemeClock.ts +16 -1
  24. package/app/client/src/live/AuthDemo.tsx +271 -270
  25. package/app/client/src/live/CounterDemo.tsx +152 -151
  26. package/app/client/src/live/FormDemo.tsx +141 -140
  27. package/app/client/src/live/PingPongDemo.tsx +181 -180
  28. package/app/client/src/live/RoomChatDemo.tsx +398 -397
  29. package/app/client/src/live/SharedCounterDemo.tsx +2 -1
  30. package/app/client/src/pages/ApiTestPage.tsx +1 -0
  31. package/app/client/src/pages/auth.tsx +13 -0
  32. package/app/client/src/pages/blog/[slug].tsx +23 -0
  33. package/app/client/src/pages/counter.tsx +15 -0
  34. package/app/client/src/pages/form.tsx +13 -0
  35. package/app/client/src/pages/index.tsx +9 -0
  36. package/app/client/src/pages/ping-pong.tsx +13 -0
  37. package/app/client/src/pages/room-chat.tsx +13 -0
  38. package/app/client/src/pages/shared-counter.tsx +13 -0
  39. package/app/client/src/pages/sobre.tsx +19 -0
  40. package/app/server/index.ts +5 -1
  41. package/app/server/live/rooms/index.ts +14 -0
  42. package/config/system/plugins.config.ts +12 -2
  43. package/core/cli/commands/dev.ts +9 -1
  44. package/core/plugins/built-in/rsc/index.ts +156 -0
  45. package/core/plugins/built-in/ssr/bun-asset-loader.ts +46 -0
  46. package/core/plugins/built-in/ssr/index.ts +143 -0
  47. package/core/plugins/built-in/ssr/registry.ts +38 -0
  48. package/core/plugins/built-in/vite/index.ts +7 -0
  49. package/core/server/live/websocket-plugin.ts +29 -10
  50. package/core/utils/version.ts +6 -6
  51. package/create-fluxstack.ts +64 -5
  52. package/package.json +112 -108
  53. package/playwright.config.ts +6 -2
  54. package/vite.config.ts +22 -0
  55. package/app/client/.live-stubs/LiveUpload.js +0 -15
@@ -0,0 +1,22 @@
1
+ // CSRF via SSR — gera o token no server e o entrega já no HTML.
2
+ //
3
+ // Em vez do client fazer fetch('/api/__csrf') após carregar (round-trip extra),
4
+ // o render SSR gera o token, injeta <meta name="csrf-token"> no <head> e seta o
5
+ // cookie XSRF-TOKEN na resposta. O client nasce com o token — zero round-trip.
6
+ // (Padrão Rails/Laravel/Livewire: token no HTML inicial.)
7
+
8
+ import { randomBytes } from 'crypto'
9
+
10
+ export const CSRF_COOKIE = 'XSRF-TOKEN'
11
+
12
+ /** Gera um token CSRF hex (mesmo formato do CsrfService do plugin). */
13
+ export function generateCsrfToken(bytes = 32): string {
14
+ return randomBytes(bytes).toString('hex')
15
+ }
16
+
17
+ /** Monta o Set-Cookie do token (NÃO httpOnly — o JS client precisa ler). */
18
+ export function buildCsrfCookie(token: string, secure: boolean): string {
19
+ const parts = [`${CSRF_COOKIE}=${token}`, 'Path=/', 'SameSite=Lax']
20
+ if (secure) parts.push('Secure')
21
+ return parts.join('; ')
22
+ }
@@ -0,0 +1,51 @@
1
+ // ENTRY BROWSER do FluxStack — controlador client puro do RSC.
2
+ // Roda UMA vez no load. Responsável por:
3
+ // 1. Hidratar o documento RSC inicial.
4
+ // 2. Registrar a navegação SPA (sem reload) no singleton — busca o .rsc da
5
+ // rota e re-renderiza só o conteúdo, mantendo o processo client vivo.
6
+ // 3. Manter um keep-alive da conexão WebSocket fora da árvore RSC, num root
7
+ // próprio que nunca é tocado pela navegação → 1 conexão para a sessão toda.
8
+ //
9
+ // Por que aqui e não no RootClient: o RootClient (client component dentro do
10
+ // payload RSC) não monta seu useEffect de forma confiável na hidratação. O
11
+ // entry.browser é client puro e SEMPRE roda — é o lugar certo para o controle.
12
+ import { createFromFetch } from '@vitejs/plugin-rsc/browser'
13
+ import { hydrateRoot, createRoot, type Root } from 'react-dom/client'
14
+ import { StrictMode, startTransition, type ReactNode } from 'react'
15
+ import { LiveComponentsProvider } from '@/core/client'
16
+ import { setNavigate } from './navigation'
17
+
18
+ // createFromFetch retorna o payload RSC desserializado como árvore React,
19
+ // mas tipado como Promise<unknown>. Cast seguro para ReactNode.
20
+ const fetchRsc = (url: string) => createFromFetch(fetch(url)) as Promise<ReactNode>
21
+
22
+ async function main() {
23
+ // 1. Hidratação inicial do documento.
24
+ const initial = await fetchRsc(window.location.href + '.rsc')
25
+ const documentRoot: Root = hydrateRoot(document, initial)
26
+
27
+ // 2. Navegação SPA: re-renderiza o documento com o payload da nova rota.
28
+ // Como é o MESMO root React, o processo client (e o keep-alive) persistem.
29
+ async function navigate(href: string, push = true) {
30
+ const payload = await fetchRsc(href + '.rsc')
31
+ startTransition(() => documentRoot.render(payload))
32
+ if (push) window.history.pushState(null, '', href)
33
+ }
34
+ setNavigate(navigate)
35
+ window.addEventListener('popstate', () => navigate(window.location.pathname, false))
36
+
37
+ // 3. Keep-alive da conexão WS — root separado, nunca tocado pela navegação.
38
+ const keepAliveEl = document.createElement('div')
39
+ keepAliveEl.style.display = 'none'
40
+ keepAliveEl.setAttribute('data-ws-keepalive', '')
41
+ document.body.appendChild(keepAliveEl)
42
+ createRoot(keepAliveEl).render(
43
+ <StrictMode>
44
+ <LiveComponentsProvider autoConnect reconnectInterval={1000} heartbeatInterval={30000}>
45
+ {null}
46
+ </LiveComponentsProvider>
47
+ </StrictMode>,
48
+ )
49
+ }
50
+
51
+ main()
@@ -0,0 +1,41 @@
1
+ // ENTRY RSC do FluxStack. Default export = handler chamado pelo Elysia.
2
+ import { renderToReadableStream } from '@vitejs/plugin-rsc/rsc'
3
+ import { RscDocument } from './RscRoot'
4
+ import { generateCsrfToken, buildCsrfCookie, CSRF_COOKIE } from './csrf'
5
+
6
+ export default async function handler(request: Request): Promise<Response> {
7
+ const url = new URL(request.url)
8
+ const isRscNav = url.pathname.endsWith('.rsc')
9
+ const pathname = url.pathname.replace(/\.rsc$/, '') || '/'
10
+
11
+ // CSRF via SSR: reusa o token do cookie se já existe; senão gera um novo.
12
+ // O token vai como <meta name="csrf-token"> no HTML, então o client não
13
+ // precisa fazer fetch('/api/__csrf') — nasce com o token (zero round-trip).
14
+ const cookieHeader = request.headers.get('cookie') ?? ''
15
+ const existing = cookieHeader.match(new RegExp(`${CSRF_COOKIE}=([^;]+)`))?.[1]
16
+ const csrfToken = existing ?? generateCsrfToken()
17
+
18
+ const rscStream = renderToReadableStream(<RscDocument pathname={pathname} csrfToken={csrfToken} />)
19
+
20
+ if (isRscNav) {
21
+ return new Response(rscStream, {
22
+ headers: { 'Content-type': 'text/x-component;charset=utf-8' },
23
+ })
24
+ }
25
+
26
+ const ssrEntry = await import.meta.viteRsc.loadModule<
27
+ typeof import('./entry.ssr')
28
+ >('ssr', 'index')
29
+ const htmlStream = await ssrEntry.handleSsr(rscStream)
30
+
31
+ const headers: Record<string, string> = { 'Content-type': 'text/html' }
32
+ if (!existing) {
33
+ // Seta o cookie só na primeira vez (secure em https).
34
+ headers['Set-Cookie'] = buildCsrfCookie(csrfToken, url.protocol === 'https:')
35
+ }
36
+ return new Response(htmlStream, { headers })
37
+ }
38
+
39
+ if (import.meta.hot) {
40
+ import.meta.hot.accept()
41
+ }
@@ -0,0 +1,12 @@
1
+ // ENTRY SSR do FluxStack. Payload RSC -> HTML, com bootstrap do client.
2
+ import { createFromReadableStream } from '@vitejs/plugin-rsc/ssr'
3
+ import { renderToReadableStream } from 'react-dom/server.edge'
4
+ import type { ReactNode } from 'react'
5
+
6
+ export async function handleSsr(rscStream: ReadableStream) {
7
+ // payload RSC desserializado é uma árvore React (tipado como unknown).
8
+ const root = (await createFromReadableStream(rscStream)) as ReactNode
9
+ const bootstrapScriptContent =
10
+ await import.meta.viteRsc.loadBootstrapScriptContent('index')
11
+ return renderToReadableStream(root, { bootstrapScriptContent })
12
+ }
@@ -0,0 +1,29 @@
1
+ // Navegação RSC via window (global verdadeiro), NÃO via singleton de módulo.
2
+ //
3
+ // Por quê window e não um let de módulo: no RSC, o mesmo módulo pode ser
4
+ // carregado em ambientes/grafos diferentes (o RscLink vem do grafo do payload;
5
+ // o entry.browser é o grafo client). Um `let` de módulo teria instâncias
6
+ // separadas — setNavigate no entry não seria visto pelo RscLink. window é o
7
+ // único estado compartilhado garantido entre eles no browser.
8
+
9
+ type NavigateFn = (href: string, push?: boolean) => void
10
+
11
+ declare global {
12
+ interface Window { __rscNavigate?: NavigateFn }
13
+ }
14
+
15
+ /** O entry.browser registra a função de navegação. */
16
+ export function setNavigate(fn: NavigateFn | null) {
17
+ if (typeof window !== 'undefined') {
18
+ window.__rscNavigate = fn ?? undefined
19
+ }
20
+ }
21
+
22
+ /** O RscLink chama isto no clique. Fallback: navegação nativa (full reload). */
23
+ export function navigate(href: string) {
24
+ if (typeof window !== 'undefined' && window.__rscNavigate) {
25
+ window.__rscNavigate(href)
26
+ } else if (typeof window !== 'undefined') {
27
+ window.location.href = href
28
+ }
29
+ }
@@ -0,0 +1,22 @@
1
+ 'use client'
2
+ // useParams() — conveniência CLIENT para acessar os params da rota.
3
+ //
4
+ // A fonte da verdade é o `params` PROP (passado pelo roteador, funciona em server
5
+ // E client component, 0 JS preservado). Este hook é açúcar opcional para client
6
+ // components que preferem o estilo react-router. Só funciona dentro de um
7
+ // <ParamsProvider> (provido automaticamente por client boundaries como LivePage).
8
+ //
9
+ // Regra: `params` prop em qualquer página; useParams() só em 'use client'.
10
+ import { createContext, useContext } from 'react'
11
+ import type { ReactNode } from 'react'
12
+ import type { RouteParams } from './routes'
13
+
14
+ const ParamsContext = createContext<RouteParams>({})
15
+
16
+ export function ParamsProvider({ params, children }: { params: RouteParams; children: ReactNode }) {
17
+ return <ParamsContext.Provider value={params}>{children}</ParamsContext.Provider>
18
+ }
19
+
20
+ export function useParams<T extends RouteParams = RouteParams>(): T {
21
+ return useContext(ParamsContext) as T
22
+ }
@@ -0,0 +1,143 @@
1
+ // File-based routing — descoberta automática de páginas (com rotas dinâmicas).
2
+ //
3
+ // O dev cria um arquivo em app/client/src/pages/ exportando default um componente
4
+ // React. Ele vira uma rota automaticamente. SEM editar RscRoot, entries, nav.
5
+ //
6
+ // Convenção de nomes (relativo a pages/):
7
+ // index.tsx -> /
8
+ // sobre.tsx -> /sobre
9
+ // blog/index.tsx -> /blog
10
+ // blog/[slug].tsx -> /blog/:slug (dinâmica — params.slug)
11
+ // user/[id].tsx -> /user/:id (dinâmica — params.id)
12
+ // docs/[...rest].tsx -> /docs/* (catch-all — params.rest = array)
13
+ //
14
+ // A página recebe `{ params }` como prop (funciona em server E client component).
15
+ // No client, também pode usar useParams() (ver ParamsContext).
16
+ //
17
+ // Metadata opcional: export const title = '...'; export const nav = false|'Label'.
18
+
19
+ import type { ComponentType } from 'react'
20
+
21
+ export type RouteParams = Record<string, string | string[]>
22
+
23
+ export interface PageProps {
24
+ params: RouteParams
25
+ }
26
+
27
+ export interface PageModule {
28
+ default: ComponentType<PageProps>
29
+ title?: string
30
+ nav?: boolean | string
31
+ }
32
+
33
+ export interface RouteEntry {
34
+ /** path estilo express (ex: /blog/:slug, /docs/*) — só p/ exibição/debug */
35
+ path: string
36
+ Component: ComponentType<PageProps>
37
+ title: string
38
+ inNav: boolean
39
+ navLabel: string
40
+ /** regex que casa o pathname; grupos nomeados = params */
41
+ matcher: RegExp
42
+ /** nomes dos params na ordem (p/ catch-all marcamos com flag) */
43
+ paramNames: { name: string; catchAll: boolean }[]
44
+ /** true se a rota tem segmentos dinâmicos */
45
+ dynamic: boolean
46
+ }
47
+
48
+ const allModules = import.meta.glob<PageModule>('../pages/**/*.tsx', { eager: true })
49
+
50
+ // Convenção: arquivos de ROTA são kebab-case/lowercase. PascalCase (HomePage.tsx)
51
+ // são COMPONENTES auxiliares, ignorados. [slug] começa com '[', também válido.
52
+ const modules = Object.fromEntries(
53
+ Object.entries(allModules).filter(([file]) => {
54
+ const base = file.split('/').pop() ?? ''
55
+ return !/^[A-Z]/.test(base)
56
+ }),
57
+ )
58
+
59
+ /** Converte o caminho do arquivo no path estilo express. */
60
+ function fileToPath(file: string): string {
61
+ let p = file.replace('../pages', '').replace(/\.tsx$/, '')
62
+ p = p.replace(/\/index$/, '')
63
+ if (p === '') p = '/'
64
+ // [...rest] -> * (catch-all); [slug] -> :slug
65
+ p = p.replace(/\[\.\.\.([^\]]+)\]/g, '*').replace(/\[([^\]]+)\]/g, ':$1')
66
+ return p || '/'
67
+ }
68
+
69
+ /** Compila o path em regex + lista de params. */
70
+ function compile(path: string): { matcher: RegExp; paramNames: { name: string; catchAll: boolean }[] } {
71
+ const paramNames: { name: string; catchAll: boolean }[] = []
72
+ // capturar o nome original do arquivo p/ catch-all: refazemos a partir do file
73
+ // mas aqui derivamos do próprio path: ':x' e '*' (catch-all usa nome 'rest' fixo
74
+ // quando vier de [...rest]; preservamos abaixo no map principal).
75
+ const pattern = path
76
+ .split('/')
77
+ .map((seg) => {
78
+ if (seg === '*') {
79
+ paramNames.push({ name: '*', catchAll: true })
80
+ return '(?<catchall>.*)'
81
+ }
82
+ if (seg.startsWith(':')) {
83
+ const name = seg.slice(1)
84
+ paramNames.push({ name, catchAll: false })
85
+ return `(?<${name}>[^/]+)`
86
+ }
87
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
88
+ })
89
+ .join('/')
90
+ const matcher = new RegExp(`^${pattern}/?$`)
91
+ return { matcher, paramNames }
92
+ }
93
+
94
+ export const routes: RouteEntry[] = Object.entries(modules)
95
+ .map(([file, mod]) => {
96
+ const path = fileToPath(file)
97
+ // nome real do catch-all vem do arquivo ([...rest] -> 'rest')
98
+ const catchAllName = file.match(/\[\.\.\.([^\]]+)\]/)?.[1]
99
+ const { matcher, paramNames } = compile(path)
100
+ // substitui o placeholder '*' pelo nome real do catch-all
101
+ for (const p of paramNames) if (p.catchAll && catchAllName) p.name = catchAllName
102
+ const dynamic = paramNames.length > 0
103
+ const title = mod.title ?? deriveTitle(path)
104
+ const inNav = mod.nav !== false && !dynamic // rotas dinâmicas não vão na navbar por padrão
105
+ const navLabel = typeof mod.nav === 'string' ? mod.nav : title
106
+ return { path, Component: mod.default, title, inNav, navLabel, matcher, paramNames, dynamic }
107
+ })
108
+ // ESTÁTICAS antes de DINÂMICAS (senão /blog/novo casaria /blog/:slug antes de /blog/novo);
109
+ // entre estáticas, / primeiro depois alfabético.
110
+ .sort((a, b) => {
111
+ if (a.dynamic !== b.dynamic) return a.dynamic ? 1 : -1
112
+ if (a.path === '/') return -1
113
+ if (b.path === '/') return 1
114
+ return a.path.localeCompare(b.path)
115
+ })
116
+
117
+ function deriveTitle(path: string): string {
118
+ if (path === '/') return 'Home'
119
+ const last = path.split('/').filter(Boolean).pop() ?? ''
120
+ const clean = last.replace(/^[:*]/, '')
121
+ return clean.charAt(0).toUpperCase() + clean.slice(1)
122
+ }
123
+
124
+ export interface MatchResult {
125
+ route: RouteEntry
126
+ params: RouteParams
127
+ }
128
+
129
+ /** Procura a rota que casa com um pathname, extraindo os params. */
130
+ export function matchRoute(pathname: string): MatchResult | undefined {
131
+ for (const route of routes) {
132
+ const m = route.matcher.exec(pathname)
133
+ if (!m) continue
134
+ const params: RouteParams = {}
135
+ for (const p of route.paramNames) {
136
+ const raw = p.catchAll ? m.groups?.catchall : m.groups?.[p.name]
137
+ if (raw === undefined) continue
138
+ params[p.name] = p.catchAll ? raw.split('/').filter(Boolean) : raw
139
+ }
140
+ return { route, params }
141
+ }
142
+ return undefined
143
+ }
@@ -0,0 +1,21 @@
1
+ // Tipos da extensão `import.meta.viteRsc` do @vitejs/plugin-rsc.
2
+ // O plugin injeta essas APIs em build/dev, mas os tipos nem sempre são
3
+ // captados pelo tsconfig do app — declaramos aqui para o type-check passar.
4
+ import type { ReactNode } from 'react'
5
+
6
+ declare global {
7
+ interface ImportMeta {
8
+ readonly viteRsc: {
9
+ /** Carrega um módulo de outro ambiente (ex: 'ssr', 'index'). */
10
+ loadModule<T = unknown>(environment: string, entry: string): Promise<T>
11
+ /** Conteúdo JS do bootstrap do client entry (para renderToReadableStream). */
12
+ loadBootstrapScriptContent(entry: string): Promise<string>
13
+ }
14
+ }
15
+ }
16
+
17
+ // createFromReadableStream / createFromFetch retornam a árvore React desserializada
18
+ // tipada como unknown; usamos este alias quando precisamos de ReactNode.
19
+ export type RscNode = ReactNode
20
+
21
+ export {}
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  /**
2
3
  * React hook for the palette system — respects theme.config.ts
3
4
  *
@@ -44,8 +45,22 @@ function getConfiguredPalette(): ColorPalette {
44
45
  return generatePalette()
45
46
  }
46
47
 
48
+ /**
49
+ * Paleta inicial DETERMINÍSTICA para o primeiro render (SSR + primeira render
50
+ * do client). Em modo 'auto', generatePalette() depende de new Date(), o que
51
+ * difere entre server e client e causa hydration mismatch. Por isso o estado
52
+ * inicial usa sempre 'midday' (fixo); a paleta real baseada na hora é aplicada
53
+ * no useEffect, que só roda no client após o mount.
54
+ */
55
+ function getInitialPalette(): ColorPalette {
56
+ if (themeConfig.mode === 'auto') {
57
+ return buildPaletteFromHues(themeConfig.hue ?? 270, 'midday')
58
+ }
59
+ return getConfiguredPalette()
60
+ }
61
+
47
62
  export function useThemeClock(): ColorPalette {
48
- const [palette, setPalette] = useState<ColorPalette>(getConfiguredPalette)
63
+ const [palette, setPalette] = useState<ColorPalette>(getInitialPalette)
49
64
 
50
65
  useEffect(() => {
51
66
  const initial = getConfiguredPalette()