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.
- package/app/client/src/App.tsx +9 -11
- package/app/client/src/components/AppLayout.tsx +291 -290
- package/app/client/src/components/BackButton.tsx +17 -16
- package/app/client/src/components/ColorWheel.tsx +1 -0
- package/app/client/src/components/DemoPage.tsx +136 -135
- package/app/client/src/components/ErrorBoundary.tsx +2 -1
- package/app/client/src/components/LiveErrorBoundary.tsx +2 -1
- package/app/client/src/components/ThemePicker.tsx +1 -0
- package/app/client/src/framework/ClientOnly.tsx +14 -0
- package/app/client/src/framework/LivePage.tsx +55 -0
- package/app/client/src/framework/RscHomePage.tsx +125 -0
- package/app/client/src/framework/RscLink.tsx +31 -0
- package/app/client/src/framework/RscNav.tsx +42 -0
- package/app/client/src/framework/RscRoot.tsx +54 -0
- package/app/client/src/framework/csrf.ts +22 -0
- package/app/client/src/framework/entry.browser.tsx +51 -0
- package/app/client/src/framework/entry.rsc.tsx +41 -0
- package/app/client/src/framework/entry.ssr.tsx +12 -0
- package/app/client/src/framework/navigation.ts +29 -0
- package/app/client/src/framework/params.tsx +22 -0
- package/app/client/src/framework/routes.ts +143 -0
- package/app/client/src/framework/vite-rsc.d.ts +21 -0
- package/app/client/src/hooks/useThemeClock.ts +16 -1
- package/app/client/src/live/AuthDemo.tsx +271 -270
- package/app/client/src/live/CounterDemo.tsx +152 -151
- package/app/client/src/live/FormDemo.tsx +141 -140
- package/app/client/src/live/PingPongDemo.tsx +181 -180
- package/app/client/src/live/RoomChatDemo.tsx +398 -397
- package/app/client/src/live/SharedCounterDemo.tsx +2 -1
- package/app/client/src/pages/ApiTestPage.tsx +1 -0
- package/app/client/src/pages/auth.tsx +13 -0
- package/app/client/src/pages/blog/[slug].tsx +23 -0
- package/app/client/src/pages/counter.tsx +15 -0
- package/app/client/src/pages/form.tsx +13 -0
- package/app/client/src/pages/index.tsx +9 -0
- package/app/client/src/pages/ping-pong.tsx +13 -0
- package/app/client/src/pages/room-chat.tsx +13 -0
- package/app/client/src/pages/shared-counter.tsx +13 -0
- package/app/client/src/pages/sobre.tsx +19 -0
- package/app/server/index.ts +5 -1
- package/app/server/live/rooms/index.ts +14 -0
- package/config/system/plugins.config.ts +12 -2
- package/core/cli/commands/dev.ts +9 -1
- package/core/plugins/built-in/rsc/index.ts +156 -0
- package/core/plugins/built-in/ssr/bun-asset-loader.ts +46 -0
- package/core/plugins/built-in/ssr/index.ts +143 -0
- package/core/plugins/built-in/ssr/registry.ts +38 -0
- package/core/plugins/built-in/vite/index.ts +7 -0
- package/core/server/live/websocket-plugin.ts +29 -10
- package/core/utils/version.ts +6 -6
- package/create-fluxstack.ts +64 -5
- package/package.json +112 -108
- package/playwright.config.ts +6 -2
- package/vite.config.ts +22 -0
- package/app/client/.live-stubs/LiveUpload.js +0 -15
|
@@ -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
|
|
30
|
+
// Client-side room events — floating animation
|
|
30
31
|
const [floats, setFloats] = useState<FloatingEvent[]>([])
|
|
31
32
|
const floatIdRef = useRef(0)
|
|
32
33
|
|
|
@@ -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
|
+
}
|
package/app/server/index.ts
CHANGED
|
@@ -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
|
package/core/cli/commands/dev.ts
CHANGED
|
@@ -70,7 +70,15 @@ export const devCommand: CLICommand = {
|
|
|
70
70
|
|
|
71
71
|
buildLogger.info(`⚡ Starting ${mode} development server...`)
|
|
72
72
|
|
|
73
|
-
|
|
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
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bun asset loader para SSR — alinha imports de asset com o Vite.
|
|
3
|
+
*
|
|
4
|
+
* Problema: no client, o Vite resolve `import logo from '...x.svg'` para a URL
|
|
5
|
+
* `/src/assets/x.svg`. No server, o Bun resolve o MESMO import para o caminho
|
|
6
|
+
* de arquivo absoluto (C:\...\x.svg). Strings diferentes no atributo src/href
|
|
7
|
+
* → hydration mismatch (o React descarta o HTML do server e re-renderiza tudo).
|
|
8
|
+
*
|
|
9
|
+
* Solução: registrar um plugin no Bun que, ao carregar um asset, exporta a
|
|
10
|
+
* MESMA URL que o Vite geraria — relativa ao root do client (app/client).
|
|
11
|
+
* Em dev essa URL é servida pelo Vite; em prod, pelos assets buildados.
|
|
12
|
+
*
|
|
13
|
+
* Registrar via bunfig.toml [run].preload ANTES de qualquer import do app.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { plugin } from 'bun'
|
|
17
|
+
import { sep } from 'path'
|
|
18
|
+
|
|
19
|
+
/** Root do client onde o Vite baseia as URLs (app/client) */
|
|
20
|
+
const CLIENT_ROOT = 'app' + sep + 'client' + sep
|
|
21
|
+
|
|
22
|
+
/** Extensões que o Vite trata como asset-URL (não código) */
|
|
23
|
+
const ASSET_RE = /\.(svg|png|jpe?g|gif|webp|avif|ico|woff2?|ttf|eot|mp4|webm|mp3|wav)$/i
|
|
24
|
+
|
|
25
|
+
plugin({
|
|
26
|
+
name: 'ssr-asset-url',
|
|
27
|
+
setup(build) {
|
|
28
|
+
build.onLoad({ filter: ASSET_RE }, (args) => {
|
|
29
|
+
// args.path é absoluto. Converter para a URL que o Vite usa:
|
|
30
|
+
// .../app/client/src/assets/x.svg -> /src/assets/x.svg
|
|
31
|
+
const idx = args.path.replace(/\//g, sep).indexOf(CLIENT_ROOT)
|
|
32
|
+
let url: string
|
|
33
|
+
if (idx !== -1) {
|
|
34
|
+
const rel = args.path.slice(idx + CLIENT_ROOT.length).replaceAll(sep, '/')
|
|
35
|
+
url = '/' + rel
|
|
36
|
+
} else {
|
|
37
|
+
// fallback: usa o basename
|
|
38
|
+
url = '/' + args.path.split(/[\\/]/).pop()
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
exports: { default: url },
|
|
42
|
+
loader: 'object',
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
},
|
|
46
|
+
})
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FluxStack SSR Plugin
|
|
3
|
+
*
|
|
4
|
+
* Renderiza o HTML do app no servidor (renderToString) para rotas de PÁGINA,
|
|
5
|
+
* substituindo o `<div id="root"></div>` vazio do SPA por markup pronto.
|
|
6
|
+
* Os assets (JS/CSS/imagens, /@vite, /src/*) continuam servidos pelo Vite (dev)
|
|
7
|
+
* ou pelo static fallback (prod) — o SSR só intercepta navegação de página.
|
|
8
|
+
*
|
|
9
|
+
* Ordem: prioridade 850 > vite (800), então o onBeforeRoute do SSR roda ANTES
|
|
10
|
+
* do proxy do Vite. Se o SSR tratar a request (handled=true), o Vite não vê.
|
|
11
|
+
*
|
|
12
|
+
* O renderer do app é injetado via registry (registerSsrRenderer) — o core não
|
|
13
|
+
* conhece o React do app. Ver ./registry.ts.
|
|
14
|
+
*
|
|
15
|
+
* LIMITAÇÃO ATUAL (PoC): pipeline de hydration montado para DEV (Vite :5173).
|
|
16
|
+
* Produção (sem Vite dev) precisa apontar para os assets buildados — TODO abaixo.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { FluxStack, PluginContext, RequestContext } from '@core/plugins/types'
|
|
20
|
+
import { FLUXSTACK_VERSION } from '@core/utils/version'
|
|
21
|
+
import { isDevelopment } from '@core/utils/helpers'
|
|
22
|
+
import { clientConfig, pluginsConfig } from '@config'
|
|
23
|
+
import { getSsrRenderer } from './registry'
|
|
24
|
+
|
|
25
|
+
type Plugin = FluxStack.Plugin
|
|
26
|
+
|
|
27
|
+
const PLUGIN_PRIORITY = 850 // > vite (800): intercepta páginas antes do proxy Vite
|
|
28
|
+
const IS_DEV = isDevelopment()
|
|
29
|
+
|
|
30
|
+
/** Extensões de asset — nunca devem ser tratadas como página */
|
|
31
|
+
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
|
|
32
|
+
|
|
33
|
+
/** Prefixos internos (Vite/API/dev) que NUNCA são páginas — passam direto */
|
|
34
|
+
const PASSTHROUGH_PREFIXES = ['/@', '/src/', '/node_modules/', '/api', '/swagger', '/__plugins']
|
|
35
|
+
|
|
36
|
+
/** Decide se um path é uma rota de PÁGINA (candidata a SSR) */
|
|
37
|
+
function isPageRoute(path: string): boolean {
|
|
38
|
+
if (ASSET_EXT.test(path)) return false
|
|
39
|
+
return !PASSTHROUGH_PREFIXES.some((p) => path === p || path.startsWith(p))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Monta o documento HTML completo com o markup SSR + pipeline de hydration */
|
|
43
|
+
function buildDocument(opts: {
|
|
44
|
+
appHtml: string
|
|
45
|
+
bootstrapData?: Record<string, unknown>
|
|
46
|
+
headTags?: string
|
|
47
|
+
}): string {
|
|
48
|
+
const { appHtml, bootstrapData, headTags = '' } = opts
|
|
49
|
+
|
|
50
|
+
const viteHost = (clientConfig.vite.host as string) || 'localhost'
|
|
51
|
+
const vitePort = (clientConfig.vite.port as number) || 5173
|
|
52
|
+
const viteBase = `http://${viteHost}:${vitePort}`
|
|
53
|
+
const entry = (pluginsConfig as Record<string, unknown>).ssrClientEntry as string | undefined
|
|
54
|
+
?? '/src/entry-ssr.tsx'
|
|
55
|
+
|
|
56
|
+
const dataScript = bootstrapData
|
|
57
|
+
? `<script>window.__SSR_DATA__ = ${JSON.stringify(bootstrapData).replace(/</g, '\\u003c')};</script>`
|
|
58
|
+
: ''
|
|
59
|
+
|
|
60
|
+
// DEV: pipeline do Vite (react-refresh preamble + client + entry TSX)
|
|
61
|
+
const devScripts = IS_DEV
|
|
62
|
+
? `<script type="module">
|
|
63
|
+
import RefreshRuntime from "${viteBase}/@react-refresh"
|
|
64
|
+
RefreshRuntime.injectIntoGlobalHook(window)
|
|
65
|
+
window.$RefreshReg$ = () => {}
|
|
66
|
+
window.$RefreshSig$ = () => (type) => type
|
|
67
|
+
window.__vite_plugin_react_preamble_installed__ = true
|
|
68
|
+
</script>
|
|
69
|
+
<script type="module" src="${viteBase}/@vite/client"></script>
|
|
70
|
+
<script type="module" src="${viteBase}${entry}"></script>`
|
|
71
|
+
// PROD: TODO — apontar para o entry buildado (ler do manifest do Vite).
|
|
72
|
+
// Por ora, sem script em prod (HTML estático apenas, sem interatividade).
|
|
73
|
+
: `<!-- TODO(SSR prod): injetar /assets/entry-ssr.[hash].js do build manifest -->`
|
|
74
|
+
|
|
75
|
+
return `<!doctype html>
|
|
76
|
+
<html lang="en">
|
|
77
|
+
<head>
|
|
78
|
+
<meta charset="UTF-8" />
|
|
79
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
80
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
81
|
+
<title>FluxStack</title>
|
|
82
|
+
${headTags}
|
|
83
|
+
${IS_DEV ? devScripts : ''}
|
|
84
|
+
</head>
|
|
85
|
+
<body>
|
|
86
|
+
<div id="root">${appHtml}</div>
|
|
87
|
+
${dataScript}
|
|
88
|
+
${IS_DEV ? '' : devScripts}
|
|
89
|
+
</body>
|
|
90
|
+
</html>`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export const ssrPlugin: Plugin = {
|
|
94
|
+
name: 'ssr',
|
|
95
|
+
version: FLUXSTACK_VERSION,
|
|
96
|
+
description: 'Server-side rendering of the React app for page routes',
|
|
97
|
+
author: 'FluxStack Team',
|
|
98
|
+
priority: PLUGIN_PRIORITY,
|
|
99
|
+
category: 'rendering',
|
|
100
|
+
tags: ['ssr', 'react', 'hydration'],
|
|
101
|
+
dependencies: [],
|
|
102
|
+
|
|
103
|
+
setup: async (context: PluginContext) => {
|
|
104
|
+
const enabled = (pluginsConfig as Record<string, unknown>).ssrEnabled
|
|
105
|
+
if (!enabled) {
|
|
106
|
+
context.logger.debug('SSR plugin disabled (set SSR_ENABLED=true)')
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
context.logger.debug(`SSR plugin active (priority ${PLUGIN_PRIORITY})`)
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
onBeforeRoute: async (ctx: RequestContext) => {
|
|
113
|
+
if (!(pluginsConfig as Record<string, unknown>).ssrEnabled) return
|
|
114
|
+
if (ctx.method !== 'GET') return
|
|
115
|
+
if (!isPageRoute(ctx.path)) return
|
|
116
|
+
|
|
117
|
+
const renderer = getSsrRenderer()
|
|
118
|
+
if (!renderer) return // app não registrou renderer → deixa o SPA fallback agir
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const result = await renderer({ url: ctx.path, headers: ctx.headers })
|
|
122
|
+
const html = buildDocument({
|
|
123
|
+
appHtml: result.html,
|
|
124
|
+
bootstrapData: result.bootstrapData,
|
|
125
|
+
headTags: result.headTags,
|
|
126
|
+
})
|
|
127
|
+
ctx.handled = true
|
|
128
|
+
ctx.response = new Response(html, {
|
|
129
|
+
status: result.status ?? 200,
|
|
130
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
131
|
+
})
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// SSR falhou → NÃO trata (handled fica false), deixa o Vite/SPA servir.
|
|
134
|
+
// Falhar para o SPA é melhor que mostrar 500: o client ainda renderiza.
|
|
135
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
136
|
+
;(ctx as unknown as { logger?: { error: (m: string) => void } }).logger?.error?.(
|
|
137
|
+
`SSR render failed for ${ctx.path}, falling back to SPA: ${msg}`
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default ssrPlugin
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FluxStack SSR — Renderer Registry
|
|
3
|
+
*
|
|
4
|
+
* Inversão de dependência: o CORE (framework) não conhece o App do usuário.
|
|
5
|
+
* O app registra sua função de render (que importa o AppShell client) aqui,
|
|
6
|
+
* e o plugin SSR a consome. Assim o core fica agnóstico ao React do app.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface SsrRenderInput {
|
|
10
|
+
/** Caminho da rota a renderizar (ex: '/', '/counter') */
|
|
11
|
+
url: string
|
|
12
|
+
/** Headers da request original (útil para cookies/auth no futuro) */
|
|
13
|
+
headers: Record<string, string>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SsrRenderOutput {
|
|
17
|
+
/** HTML do corpo do app (vai dentro de <div id="root">) */
|
|
18
|
+
html: string
|
|
19
|
+
/** Status HTTP (default 200; use 404 para rota não encontrada) */
|
|
20
|
+
status?: number
|
|
21
|
+
/** Dados serializáveis injetados como window.__SSR_DATA__ para hydration */
|
|
22
|
+
bootstrapData?: Record<string, unknown>
|
|
23
|
+
/** <head> extra opcional (meta tags, title dinâmico) */
|
|
24
|
+
headTags?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type SsrRenderer = (input: SsrRenderInput) => SsrRenderOutput | Promise<SsrRenderOutput>
|
|
28
|
+
|
|
29
|
+
let registeredRenderer: SsrRenderer | null = null
|
|
30
|
+
|
|
31
|
+
/** O app chama isto no boot para registrar seu renderer (AppShell). */
|
|
32
|
+
export function registerSsrRenderer(renderer: SsrRenderer): void {
|
|
33
|
+
registeredRenderer = renderer
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getSsrRenderer(): SsrRenderer | null {
|
|
37
|
+
return registeredRenderer
|
|
38
|
+
}
|
|
@@ -226,6 +226,13 @@ export const vitePlugin: Plugin = {
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
if (!IS_DEV) {
|
|
229
|
+
// Em prod com RSC, o rscPlugin serve o HTML (handler buildado) e os assets
|
|
230
|
+
// (app/client/dist/client). O static fallback do vite procuraria dist/client
|
|
231
|
+
// (que não existe nesse layout) e quebraria — então pulamos.
|
|
232
|
+
if ((pluginsConfig as Record<string, unknown>).rscEnabled) {
|
|
233
|
+
context.logger.debug('Production + RSC: static serving handled by rsc plugin')
|
|
234
|
+
return
|
|
235
|
+
}
|
|
229
236
|
context.logger.debug("Production mode: static file serving enabled")
|
|
230
237
|
;(context.app as Record<string, Function>).all('*', createStaticFallback())
|
|
231
238
|
return
|