Skip to content

Commit

Permalink
implementing dev live reload
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin committed Nov 17, 2023
1 parent 6092e34 commit d1b0e30
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 15 deletions.
4 changes: 2 additions & 2 deletions demo/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@
from pydantic import BaseModel, Field

from fastui import components as c
from fastui import FastUI, AnyComponent
from fastui import FastUI, AnyComponent, dev_fastapi_app
from fastui.forms import fastui_form, FormResponse, FormFile
from fastui.display import Display
from fastui.events import PageEvent, GoToEvent

app = FastAPI()
app = dev_fastapi_app()


@app.get('/api/', response_model=FastUI, response_model_exclude_none=True)
Expand Down
2 changes: 1 addition & 1 deletion demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { FastUI, ClassNameGenerator, CustomRender } from 'fastui'
export default function App() {
return (
<div className="app">
<FastUI rootUrl="/api" classNameGenerator={bootstrapClassName} customRender={customRender} />
<FastUI rootUrl="/api" classNameGenerator={bootstrapClassName} customRender={customRender} dev />
</div>
)
}
Expand Down
4 changes: 3 additions & 1 deletion python/fastui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import pydantic

from .components import AnyComponent
from .dev import dev_fastapi_app
from .display import Display

__all__ = 'AnyComponent', 'FastUI'
__all__ = 'AnyComponent', 'FastUI', 'dev_fastapi_app', 'Display'


class FastUI(pydantic.RootModel):
Expand Down
51 changes: 51 additions & 0 deletions python/fastui/dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import asyncio
import os
import signal
import typing
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from starlette import types


def dev_fastapi_app(reload_path: str = '/api/__dev__/reload', **fastapi_kwargs) -> FastAPI:
dev_reload = DevReload(fastapi_kwargs.pop('lifespan', None))

app = FastAPI(lifespan=dev_reload.lifespan)
app.get(reload_path, include_in_schema=False)(dev_reload.dev_reload_endpoints)
return app


class DevReload:
def __init__(self, default_lifespan: types.Lifespan[FastAPI] | None):
self.default_lifespan = default_lifespan
self.stop = asyncio.Event()

@asynccontextmanager
async def lifespan(self, app: FastAPI):
signal.signal(signal.SIGTERM, self._on_signal)
if self.default_lifespan:
async with self.default_lifespan(app):
yield
else:
yield

async def dev_reload_endpoints(self):
return StreamingResponse(self.ping())

def _on_signal(self, *_args: typing.Any):
# print('setting stop', _args)
self.stop.set()

async def ping(self):
# print('connected', os.getpid())
yield b'.'
while True:
try:
await asyncio.wait_for(self.stop.wait(), timeout=2)
except asyncio.TimeoutError:
yield b'.'
else:
yield b'%d' % os.getpid()
break
4 changes: 3 additions & 1 deletion react/fastui/controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { DefaultLoading } from './DefaultLoading'
import { LocationContext } from './hooks/locationContext'
import { ErrorContext } from './hooks/error'
import { request } from './tools'
import { ReloadContext } from './hooks/dev'

type Props = Omit<FastUIProps, 'defaultClassName' | 'OnError' | 'customRender'>

Expand All @@ -15,6 +16,7 @@ export function FastUIController({ rootUrl, pathSendMode, loading }: Props) {
const { fullPath } = useContext(LocationContext)

const { error, setError } = useContext(ErrorContext)
const reloadValue = useContext(ReloadContext)

useEffect(() => {
// setViewData(null)
Expand All @@ -35,7 +37,7 @@ export function FastUIController({ rootUrl, pathSendMode, loading }: Props) {
return () => {
promise.then(() => null).catch(() => null)
}
}, [rootUrl, pathSendMode, fullPath, setError])
}, [rootUrl, pathSendMode, fullPath, setError, reloadValue])

if (componentProps === null) {
if (error) {
Expand Down
52 changes: 52 additions & 0 deletions react/fastui/hooks/dev.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { createContext, FC, ReactNode, useEffect, useState } from 'react'

export const ReloadContext = createContext<number>(0)
let devConnected = false

export const DevReloadProvider: FC<{ children: ReactNode; enabled: boolean }> = ({ children, enabled }) => {
const [value, setValue] = useState<number>(0)

useEffect(() => {
let listening = true
async function listen() {
let failCount = 0
while (true) {
const response = await fetch('/api/__dev__/reload')
// await like this means we wait for the entire response to be received
const text = await response.text()
const value = parseInt(text.replace(/\./g, '')) || 0
if (response.ok) {
failCount = 0
} else {
failCount++
}
// wait long enough for the server to be back online
await sleep(500)
if (!listening || failCount >= 4) {
return value
}
console.log('dev reload...')
setValue(value)
}
}

if (enabled && !devConnected) {
devConnected = true
listening = true
listen().then((value) => {
console.debug('dev reload disconnected.')
setValue(value)
})
return () => {
listening = false
devConnected = false
}
}
}, [enabled])

return <ReloadContext.Provider value={value}>{children}</ReloadContext.Provider>
}

async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
24 changes: 14 additions & 10 deletions react/fastui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ErrorContextProvider, ErrorDisplayType } from './hooks/error'
import { CustomRender, CustomRenderContext } from './hooks/customRender'
import { FastProps } from './components'
import { DisplayChoices } from './display'
import { DevReloadProvider } from './hooks/dev'

export type { ClassNameGenerator, CustomRender, ErrorDisplayType, FastProps, DisplayChoices }

Expand All @@ -18,21 +19,24 @@ export interface FastUIProps {
DisplayError?: ErrorDisplayType
classNameGenerator?: ClassNameGenerator
customRender?: CustomRender
dev?: boolean
}

export function FastUI(props: FastUIProps) {
const { classNameGenerator, DisplayError, customRender, ...rest } = props
const { classNameGenerator, DisplayError, customRender, dev, ...rest } = props
return (
<div className="fastui">
<ErrorContextProvider DisplayError={DisplayError}>
<LocationProvider>
<ClassNameContext.Provider value={classNameGenerator ?? null}>
<CustomRenderContext.Provider value={customRender ?? null}>
<FastUIController {...rest} />
</CustomRenderContext.Provider>
</ClassNameContext.Provider>
</LocationProvider>
</ErrorContextProvider>
<DevReloadProvider enabled={dev ?? false}>
<ErrorContextProvider DisplayError={DisplayError}>
<LocationProvider>
<ClassNameContext.Provider value={classNameGenerator ?? null}>
<CustomRenderContext.Provider value={customRender ?? null}>
<FastUIController {...rest} />
</CustomRenderContext.Provider>
</ClassNameContext.Provider>
</LocationProvider>
</ErrorContextProvider>
</DevReloadProvider>
</div>
)
}

0 comments on commit d1b0e30

Please sign in to comment.