create-fluxstack 1.21.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/app/client/src/App.tsx +9 -11
  2. package/app/client/src/components/AppLayout.tsx +1 -0
  3. package/app/client/src/components/BackButton.tsx +1 -0
  4. package/app/client/src/components/ColorWheel.tsx +1 -0
  5. package/app/client/src/components/DemoPage.tsx +1 -0
  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 +1 -0
  25. package/app/client/src/live/CounterDemo.tsx +1 -0
  26. package/app/client/src/live/FormDemo.tsx +1 -0
  27. package/app/client/src/live/PingPongDemo.tsx +1 -0
  28. package/app/client/src/live/RoomChatDemo.tsx +1 -0
  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,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()
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  import { useState } from 'react'
2
3
  import { Live, useLiveComponents } from '@/core/client'
3
4
  import { LiveCounter } from '@server/live/LiveCounter'
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  import { useMemo } from 'react'
2
3
  import { Live } from '@/core/client'
3
4
  import { LiveCounter } from '@server/live/LiveCounter'
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  import { Live } from '@/core/client'
2
3
  import { LiveForm } from '@server/live/LiveForm'
3
4
  import { FaCheck, FaCode, FaEnvelope, FaRegMessage, FaUser } from 'react-icons/fa6'
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  import { useMemo, useState, useEffect, useRef, useCallback } from 'react'
2
3
  import { Live } from '@/core/client'
3
4
  import { LivePingPong } from '@server/live/LivePingPong'
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  import { useEffect, useMemo, useReducer, useRef } from 'react'
2
3
  import { Live } from '@/core/client'
3
4
  import { LiveRoomChat } from '@server/live/LiveRoomChat'
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  // SharedCounterDemo - Contador compartilhado entre todas as abas
2
3
  //
3
4
  // Abra em varias abas - todos veem o mesmo valor!
@@ -26,7 +27,7 @@ export function SharedCounterDemo() {
26
27
  initialState: { ...LiveSharedCounter.defaultState, username }
27
28
  })
28
29
 
29
- // Client-side room events floating animation
30
+ // Client-side room events — floating animation
30
31
  const [floats, setFloats] = useState<FloatingEvent[]>([])
31
32
  const floatIdRef = useRef(0)
32
33
 
@@ -1,3 +1,4 @@
1
+ 'use client'
1
2
  import type { ReactNode } from 'react'
2
3
  import { BackButton } from '../components/BackButton'
3
4
 
@@ -0,0 +1,13 @@
1
+ // Página /auth (file-based). Demo Live — React normal.
2
+ import { LivePage } from '../framework/LivePage'
3
+ import { AuthDemo } from '../live/AuthDemo'
4
+
5
+ export const title = 'Auth'
6
+
7
+ export default function AuthPage() {
8
+ return (
9
+ <LivePage title="Auth" description="Autenticação declarativa para Live Components com $auth.">
10
+ <AuthDemo />
11
+ </LivePage>
12
+ )
13
+ }
@@ -0,0 +1,23 @@
1
+ // Rota DINÂMICA /blog/:slug (file-based: pages/blog/[slug].tsx).
2
+ // Server component (0 JS) — recebe os params como PROP. Funciona no server.
3
+ import type { PageProps } from '../../framework/routes'
4
+
5
+ export const title = 'Blog Post'
6
+
7
+ export default function BlogPost({ params }: PageProps) {
8
+ const slug = params.slug as string
9
+ return (
10
+ <div className="relative min-h-[calc(100vh-57px)] px-4 py-10">
11
+ <div className="absolute inset-0 app-grid-bg opacity-70" />
12
+ <div className="relative z-10 mx-auto max-w-3xl">
13
+ <a href="/blog" className="text-sm text-gray-400 hover:text-white">← Blog</a>
14
+ <h1 className="mt-4 text-4xl font-semibold text-white">Post: {slug}</h1>
15
+ <p className="mt-4 text-gray-400">
16
+ Rota dinâmica renderizada no <strong>server</strong> (0 JS). O slug
17
+ <code className="text-theme"> {slug} </code> veio de <code className="text-theme">params</code>,
18
+ extraído da URL pelo roteador file-based — arquivo <code className="text-theme">pages/blog/[slug].tsx</code>.
19
+ </p>
20
+ </div>
21
+ </div>
22
+ )
23
+ }
@@ -0,0 +1,15 @@
1
+ // Página /counter (file-based). Demo Live — React normal.
2
+ // O CounterDemo usa Live.use(); no server vira placeholder (guarda SSR),
3
+ // no client monta e conecta. O LivePage cuida do Provider/ClientOnly.
4
+ import { LivePage } from '../framework/LivePage'
5
+ import { CounterDemo } from '../live/CounterDemo'
6
+
7
+ export const title = 'Counters'
8
+
9
+ export default function CounterPage() {
10
+ return (
11
+ <LivePage title="Counters" description="Estado local, sala isolada e sala compartilhada em tempo real.">
12
+ <CounterDemo />
13
+ </LivePage>
14
+ )
15
+ }
@@ -0,0 +1,13 @@
1
+ // Página /form (file-based). Demo Live — React normal.
2
+ import { LivePage } from '../framework/LivePage'
3
+ import { FormDemo } from '../live/FormDemo'
4
+
5
+ export const title = 'Form'
6
+
7
+ export default function FormPage() {
8
+ return (
9
+ <LivePage title="Live Form" description="Formulário com campos sincronizados pelo servidor via proxy Live.">
10
+ <FormDemo />
11
+ </LivePage>
12
+ )
13
+ }
@@ -0,0 +1,9 @@
1
+ // Página HOME — rota / (file-based: app/client/src/pages/index.tsx)
2
+ // Componente React normal. Server component (sem Live) → 0 JS no client.
3
+ import { RscHomePage } from '../framework/RscHomePage'
4
+
5
+ export const title = 'Home'
6
+
7
+ export default function HomePage() {
8
+ return <RscHomePage />
9
+ }
@@ -0,0 +1,13 @@
1
+ // Página /ping-pong (file-based). Demo Live — React normal.
2
+ import { LivePage } from '../framework/LivePage'
3
+ import { PingPongDemo } from '../live/PingPongDemo'
4
+
5
+ export const title = 'Ping-Pong'
6
+
7
+ export default function PingPongPage() {
8
+ return (
9
+ <LivePage title="Ping-Pong" description="Latency demo com codec binário msgpack no WebSocket.">
10
+ <PingPongDemo />
11
+ </LivePage>
12
+ )
13
+ }
@@ -0,0 +1,13 @@
1
+ // Página /room-chat (file-based). Demo Live — React normal.
2
+ import { LivePage } from '../framework/LivePage'
3
+ import { RoomChatDemo } from '../live/RoomChatDemo'
4
+
5
+ export const title = 'Chat'
6
+
7
+ export default function RoomChatPage() {
8
+ return (
9
+ <LivePage title="Room Chat" description="Chat multi-sala usando o sistema $room.">
10
+ <RoomChatDemo />
11
+ </LivePage>
12
+ )
13
+ }
@@ -0,0 +1,13 @@
1
+ // Página /shared-counter (file-based). Demo Live — React normal.
2
+ import { LivePage } from '../framework/LivePage'
3
+ import { SharedCounterDemo } from '../live/SharedCounterDemo'
4
+
5
+ export const title = 'Shared'
6
+
7
+ export default function SharedCounterPage() {
8
+ return (
9
+ <LivePage title="Shared Counter" description="Sala global sincroniza usuários e estado entre abas.">
10
+ <SharedCounterDemo />
11
+ </LivePage>
12
+ )
13
+ }
@@ -0,0 +1,19 @@
1
+ // Página /sobre — PROVA do file-based routing. Só criar este arquivo já cria a
2
+ // rota /sobre e o link na navbar. React normal, server component (0 JS).
3
+ export const title = 'Sobre'
4
+
5
+ export default function SobrePage() {
6
+ return (
7
+ <div className="relative min-h-[calc(100vh-57px)] px-4 py-10">
8
+ <div className="absolute inset-0 app-grid-bg opacity-70" />
9
+ <div className="relative z-10 mx-auto max-w-3xl">
10
+ <h1 className="text-4xl font-semibold text-white">Sobre</h1>
11
+ <p className="mt-4 text-gray-400">
12
+ Esta página foi criada só adicionando <code className="text-theme">app/client/src/pages/sobre.tsx</code>.
13
+ Sem editar router, sem registrar rota, sem tocar em nada. É React normal — e roda
14
+ como Server Component (zero JS no client).
15
+ </p>
16
+ </div>
17
+ </div>
18
+ )
19
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { FluxStackFramework } from "@core/server"
14
14
  import { vitePlugin } from "@core/plugins/built-in/vite"
15
+ import { rscPlugin } from "@core/plugins/built-in/rsc"
15
16
  import { swaggerPlugin } from "@core/plugins/built-in/swagger"
16
17
  import { liveComponentsPlugin, registerAuthProvider } from "@core/server/live"
17
18
  import { appInstance } from "@server/app"
@@ -42,8 +43,11 @@ const framework = new FluxStackFramework()
42
43
  .use(liveComponentsPlugin)
43
44
  .use(csrfProtectionPlugin)
44
45
 
45
- // Vite apenas em full-stack
46
+ // Vite + RSC apenas em full-stack. O rscPlugin (priority 860) intercepta rotas
47
+ // de página antes do vite (800); só age se RSC_ENABLED=true. RSC é o modo SSR
48
+ // oficial (o "Caminho A"/AppShell foi descontinuado — ver .ai-notes).
46
49
  if (appConfig.mode !== 'backend-only') {
50
+ framework.use(rscPlugin)
47
51
  framework.use(vitePlugin)
48
52
  }
49
53
 
@@ -0,0 +1,14 @@
1
+ // Registro ESTÁTICO das LiveRoom classes — usado no build de produção.
2
+ //
3
+ // Em dev, as rooms são auto-descobertas varrendo este diretório (import dinâmico).
4
+ // Em PROD isso quebraria (import do disco carrega uma instância separada do
5
+ // @fluxstack/live → context null). Aqui declaramos as rooms estaticamente para
6
+ // entrarem no bundle e serem registradas no LiveServer({ rooms }).
7
+ //
8
+ // Ao adicionar uma room nova em rooms/, inclua-a aqui também (ou rode o gerador).
9
+ import { ChatRoom } from './ChatRoom'
10
+ import { CounterRoom } from './CounterRoom'
11
+ import { DirectoryRoom } from './DirectoryRoom'
12
+ import { PingRoom } from './PingRoom'
13
+
14
+ export const liveRoomClasses = [ChatRoom, CounterRoom, DirectoryRoom, PingRoom]
@@ -75,8 +75,18 @@ export const pluginsConfig = defineConfig({
75
75
  viteEnabled: config.boolean('VITE_PLUGIN_ENABLED', true),
76
76
  viteExcludePaths: config.array('VITE_EXCLUDE_PATHS', [
77
77
  '/api',
78
- '/swagger'
79
- ])
78
+ '/swagger',
79
+ '/ssr-demo',
80
+ '/ssr'
81
+ ]),
82
+
83
+ // SSR (server-side rendering das rotas de página). Off por padrão: ligar
84
+ // troca o SPA vazio do `/` por HTML pré-renderizado + hydration.
85
+ // RSC (React Server Components) — modo de renderização PADRÃO do FluxStack.
86
+ // Server components (0 JS) + client islands (Live Components) servidos pelo
87
+ // proxy interno. Para usar SPA puro, defina RSC_ENABLED=false no .env.
88
+ // Requer o rsc() no vite.config (também lê RSC_ENABLED). Ver core/plugins/built-in/rsc.
89
+ rscEnabled: config.boolean('RSC_ENABLED', true)
80
90
  })
81
91
 
82
92
  export type PluginsConfig = typeof pluginsConfig
@@ -70,7 +70,15 @@ export const devCommand: CLICommand = {
70
70
 
71
71
  buildLogger.info(`⚡ Starting ${mode} development server...`)
72
72
 
73
- const devProcess = spawn("bun", ["--watch", entryPoint], {
73
+ // SSR: o loader de asset DEVE ser preloaded (antes do grafo de imports),
74
+ // senão imports de .svg no AppShell resolvem para caminho de arquivo (Bun)
75
+ // em vez da URL do Vite — causando hydration mismatch. Só em backend que
76
+ // serve frontend (não frontend-only) e quando SSR está habilitado.
77
+ const ssrPreload = !frontendOnly && process.env.SSR_ENABLED === 'true'
78
+ ? ['--preload', './core/plugins/built-in/ssr/bun-asset-loader.ts']
79
+ : []
80
+
81
+ const devProcess = spawn("bun", ["--watch", ...ssrPreload, entryPoint], {
74
82
  stdio: "inherit",
75
83
  cwd: process.cwd(),
76
84
  env: {
@@ -0,0 +1,156 @@
1
+ /**
2
+ * FluxStack RSC Plugin
3
+ *
4
+ * Serve React Server Components pelo proxy interno do FluxStack (Elysia), em vez
5
+ * do middleware próprio do @vitejs/plugin-rsc. Prioridade 860 > ssr (850) >
6
+ * vite (800): intercepta rotas de página antes deles. Só age se RSC_ENABLED=true.
7
+ *
8
+ * DEV: chama o handler do ambiente Vite `rsc` via runner (Vite dev server).
9
+ * PROD: importa o handler RSC BUILDADO (app/client/dist/rsc/index.js, default) e
10
+ * serve os assets buildados (app/client/dist/client/assets). Sem Vite em prod.
11
+ *
12
+ * Em erro NÃO trata (fallback pro Vite/SPA). Requer o Vite criado com
13
+ * rsc({serverHandler:false}) — ver vite.config.ts (condicionado a RSC_ENABLED).
14
+ */
15
+
16
+ import type { FluxStack, PluginContext, RequestContext } from '@core/plugins/types'
17
+ import { FLUXSTACK_VERSION } from '@core/utils/version'
18
+ import { isDevelopment } from '@core/utils/helpers'
19
+ import { clientConfig, pluginsConfig } from '@config'
20
+ import { isRunnableDevEnvironment } from 'vite'
21
+ import { join } from 'path'
22
+ import { existsSync } from 'fs'
23
+
24
+ type Plugin = FluxStack.Plugin
25
+ type RscHandler = (request: Request) => Promise<Response>
26
+
27
+ const PLUGIN_PRIORITY = 860 // > ssr (850) > vite (800)
28
+ const IS_DEV = isDevelopment()
29
+
30
+ /** id do entry rsc no runner (dev) — relativo ao root app/client */
31
+ const RSC_ENTRY_ID = '/src/framework/entry.rsc.tsx'
32
+ /** caminho do handler RSC buildado (prod) e dos assets do client */
33
+ const PROD_RSC_HANDLER = join(process.cwd(), 'app', 'client', 'dist', 'rsc', 'index.js')
34
+ const PROD_CLIENT_DIR = join(process.cwd(), 'app', 'client', 'dist', 'client')
35
+
36
+ const ASSET_EXT = /\.(js|mjs|ts|tsx|jsx|css|map|json|svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|eot|wasm|txt|xml|webmanifest)$/i
37
+ const PASSTHROUGH_PREFIXES = ['/@', '/src/', '/node_modules/', '/api', '/swagger', '/__plugins']
38
+
39
+ function isPageRoute(path: string): boolean {
40
+ if (ASSET_EXT.test(path)) return false
41
+ return !PASSTHROUGH_PREFIXES.some((p) => path === p || path.startsWith(p))
42
+ }
43
+
44
+ /** Vite dev server (dev) — capturado do vitePlugin. */
45
+ let viteServer: { environments: Record<string, unknown> } | null = null
46
+ /** Handler RSC buildado (prod) — importado uma vez e cacheado. */
47
+ let prodHandler: RscHandler | null = null
48
+
49
+ /** Normaliza a URL da request para absoluta (handler RSC precisa de URL completa). */
50
+ function absoluteUrl(ctx: RequestContext): string {
51
+ try {
52
+ return new URL(ctx.request.url).href
53
+ } catch {
54
+ const host = ctx.request.headers.get('host') || `${clientConfig.vite.host}:3000`
55
+ const proto = ctx.request.headers.get('x-forwarded-proto') || 'http'
56
+ const q = ctx.request.url.indexOf('?')
57
+ const search = q === -1 ? '' : ctx.request.url.slice(q)
58
+ return `${proto}://${host}${ctx.path}${search}`
59
+ }
60
+ }
61
+
62
+ /** Resolve o handler RSC (dev via runner, prod via import do build). */
63
+ async function getHandler(): Promise<RscHandler | null> {
64
+ if (IS_DEV) {
65
+ if (!viteServer) return null
66
+ const rscEnv = viteServer.environments.rsc
67
+ if (!rscEnv || !isRunnableDevEnvironment(rscEnv as never)) return null
68
+ const mod = await (rscEnv as { runner: { import: (id: string) => Promise<{ default: RscHandler }> } })
69
+ .runner.import(RSC_ENTRY_ID)
70
+ return mod.default
71
+ }
72
+ // prod: importa o handler buildado uma vez
73
+ if (!prodHandler) {
74
+ if (!existsSync(PROD_RSC_HANDLER)) return null
75
+ const mod = await import(/* @vite-ignore */ PROD_RSC_HANDLER) as { default: RscHandler }
76
+ prodHandler = mod.default
77
+ }
78
+ return prodHandler
79
+ }
80
+
81
+ export const rscPlugin: Plugin = {
82
+ name: 'rsc',
83
+ version: FLUXSTACK_VERSION,
84
+ description: 'React Server Components served through the FluxStack proxy',
85
+ author: 'FluxStack Team',
86
+ priority: PLUGIN_PRIORITY,
87
+ category: 'rendering',
88
+ tags: ['rsc', 'react', 'server-components'],
89
+ dependencies: [],
90
+
91
+ setup: async (context: PluginContext) => {
92
+ if (!(pluginsConfig as Record<string, unknown>).rscEnabled) {
93
+ context.logger.debug('RSC plugin disabled (set RSC_ENABLED=true)')
94
+ return
95
+ }
96
+ if (!IS_DEV && !existsSync(PROD_RSC_HANDLER)) {
97
+ context.logger.warn(`RSC: handler buildado não encontrado (${PROD_RSC_HANDLER}). Rode o build com RSC_ENABLED=true.`)
98
+ return
99
+ }
100
+ context.logger.debug(`RSC plugin active (priority ${PLUGIN_PRIORITY}, ${IS_DEV ? 'dev' : 'prod'})`)
101
+ },
102
+
103
+ // DEV: captura o Vite dev server (do vitePlugin) para o runner.
104
+ onServerStart: async (context: PluginContext) => {
105
+ if (!(pluginsConfig as Record<string, unknown>).rscEnabled || !IS_DEV) return
106
+ const cfg = (context as PluginContext & { viteConfig?: { server?: unknown } }).viteConfig
107
+ if (cfg?.server) {
108
+ viteServer = cfg.server as { environments: Record<string, unknown> }
109
+ context.logger.debug('RSC: Vite dev server capturado para o runner')
110
+ }
111
+ },
112
+
113
+ onBeforeRoute: async (ctx: RequestContext) => {
114
+ if (!(pluginsConfig as Record<string, unknown>).rscEnabled) return
115
+ if (ctx.method !== 'GET') return
116
+
117
+ // PROD: servir assets buildados (/assets/*, /favicon.svg) do dist/client.
118
+ if (!IS_DEV && !isPageRoute(ctx.path)) {
119
+ const assetPath = join(PROD_CLIENT_DIR, ctx.path)
120
+ if (ctx.path !== '/' && existsSync(assetPath)) {
121
+ ctx.handled = true
122
+ ctx.response = new Response(Bun.file(assetPath))
123
+ }
124
+ return
125
+ }
126
+ if (!isPageRoute(ctx.path)) return // dev: assets vão pro Vite
127
+
128
+ try {
129
+ const handler = await getHandler()
130
+ if (!handler) return // sem handler → fallback
131
+
132
+ // Cold-start (dev): a 1ª render pode suspender enquanto o grafo client
133
+ // carrega; um retry cobre. Em prod o handler já está pronto (1 tentativa).
134
+ const attempts = IS_DEV ? 2 : 1
135
+ let response: Response | null = null
136
+ for (let i = 0; i < attempts; i++) {
137
+ try {
138
+ const request = new Request(absoluteUrl(ctx), { method: ctx.method, headers: ctx.request.headers })
139
+ response = await handler(request)
140
+ break
141
+ } catch (e) {
142
+ if (i === attempts - 1) throw e
143
+ }
144
+ }
145
+ if (response) {
146
+ ctx.handled = true
147
+ ctx.response = response
148
+ }
149
+ } catch (err) {
150
+ const msg = err instanceof Error ? err.message : String(err)
151
+ console.error(`[RSC] render failed for ${ctx.path}, falling back: ${msg}`)
152
+ }
153
+ },
154
+ }
155
+
156
+ export default rscPlugin