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
|
@@ -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>(
|
|
63
|
+
const [palette, setPalette] = useState<ColorPalette>(getInitialPalette)
|
|
49
64
|
|
|
50
65
|
useEffect(() => {
|
|
51
66
|
const initial = getConfiguredPalette()
|