magic: added a way for running async functions in the top level jsx code

pull/1/head
Antonio De Lucreziis 2 years ago
parent 71280fba5c
commit fab8398a7a

@ -1,20 +1,26 @@
import renderToString from 'preact-render-to-string' import renderToString from 'preact-render-to-string'
// import { App } from './App' // import { App } from './App'
import { MetadataContext, ServerContext } from './hooks' import { ServerAsyncCallbacksContext, DatabaseContext, MetadataContext, ServerContext } from './hooks'
import { RenderedPage } from '../shared/ssr' import { RenderedPage, ServerAsyncCallback } from '../shared/ssr'
import { App } from './App' import { App } from './App'
import { DatabaseConnection } from '../shared/database'
export default (url: string): RenderedPage => { export default (url: string, db: DatabaseConnection): RenderedPage => {
const metadata = {} const metadata = {}
const asyncCallbacks: ServerAsyncCallback[] = []
const html = renderToString( const html = renderToString(
<MetadataContext.Provider value={metadata}> <MetadataContext.Provider value={metadata}>
<ServerContext.Provider value={true}> <ServerContext.Provider value={true}>
<DatabaseContext.Provider value={db}>
<ServerAsyncCallbacksContext.Provider value={asyncCallbacks}>
<App url={url} /> <App url={url} />
</ServerAsyncCallbacksContext.Provider>
</DatabaseContext.Provider>
</ServerContext.Provider> </ServerContext.Provider>
</MetadataContext.Provider> </MetadataContext.Provider>
) )
return { html, metadata } return { html, metadata, asyncCallbacks }
} }

@ -1,7 +1,9 @@
import { StateUpdater, useEffect, useState } from 'preact/hooks' import { StateUpdater, useContext, useEffect, useState } from 'preact/hooks'
import { createContext } from 'preact' import { createContext } from 'preact'
import { prependBaseUrl } from '../shared/utils' import { prependBaseUrl } from '../shared/utils'
import { DatabaseConnection } from '../shared/database'
import { ServerAsyncCallback } from '../shared/ssr'
type Metadata = { type Metadata = {
title?: string title?: string
@ -13,6 +15,8 @@ export const MetadataContext = createContext<Metadata>({})
export const ServerContext = createContext<boolean>(false) export const ServerContext = createContext<boolean>(false)
export const ClientContext = createContext<boolean>(false) export const ClientContext = createContext<boolean>(false)
export const DatabaseContext = createContext<DatabaseConnection | null>(null)
type RefreshFunction = () => AbortController type RefreshFunction = () => AbortController
type HeuristicStateUpdater<S> = StateUpdater<S> type HeuristicStateUpdater<S> = StateUpdater<S>
@ -73,3 +77,10 @@ export const useListResource = <T,>(
return [list, refreshList, setItemHeuristic, setListHeuristic] return [list, refreshList, setItemHeuristic, setListHeuristic]
} }
export const ServerAsyncCallbacksContext = createContext<ServerAsyncCallback[]>([])
export const useServerAsyncCallback = (fn: () => Promise<void>) => {
const registeredAsyncCallbacks = useContext(ServerAsyncCallbacksContext)
registeredAsyncCallbacks.push(fn)
}

@ -7,7 +7,7 @@ import { Header } from '../components/Header'
import { MarkdownEditor } from '../components/MarkdownEditor' import { MarkdownEditor } from '../components/MarkdownEditor'
import { Problem } from '../components/Problem' import { Problem } from '../components/Problem'
import { Solution } from '../components/Solution' import { Solution } from '../components/Solution'
import { MetadataContext, useListResource, useResource } from '../hooks' import { MetadataContext, useListResource, useResource, ServerContext, DatabaseContext, useServerAsyncCallback } from '../hooks'
import { useCurrentUser } from '../hooks/useCurrentUser' import { useCurrentUser } from '../hooks/useCurrentUser'
type RouteProps = { type RouteProps = {
@ -16,8 +16,16 @@ type RouteProps = {
export const ProblemPage = ({ id }: RouteProps) => { export const ProblemPage = ({ id }: RouteProps) => {
const metadata = useContext(MetadataContext) const metadata = useContext(MetadataContext)
metadata.title = `Problem ${id}` const db = useContext(DatabaseContext)
metadata.description = 'Bacheca di problemi del PHC' if (db) {
useServerAsyncCallback(async () => {
const problem = await db.getProblem(id)
if (problem) {
metadata.title = `PHC Problemi | ${problem.title}`
metadata.description = problem.content
}
})
}
const [user] = useCurrentUser() const [user] = useCurrentUser()
@ -25,7 +33,7 @@ export const ProblemPage = ({ id }: RouteProps) => {
const [problem, refreshProblem] = useResource<ProblemModel | null>(`/api/problem/${id}`, null, problem => { const [problem, refreshProblem] = useResource<ProblemModel | null>(`/api/problem/${id}`, null, problem => {
if (problem === null) { if (problem === null) {
route(prependBaseUrl('/')) route(prependBaseUrl(`/error?message=${encodeURIComponent(`Il problema "${id}" non esiste`)}`))
} }
}) })

@ -10,6 +10,7 @@ import { createServer as createViteServer } from 'vite'
import { createApiRouter } from './server/routes' import { createApiRouter } from './server/routes'
import { RenderFunction } from './shared/ssr' import { RenderFunction } from './shared/ssr'
import { DatabaseConnection } from './shared/database'
// Load ".env" // Load ".env"
dotenv.config() dotenv.config()
@ -24,7 +25,7 @@ if (config.isDevelopment) {
console.log(`[Config] PORT = ${config.port}`) console.log(`[Config] PORT = ${config.port}`)
} }
async function createDevRouter() { async function createDevRouter(db: DatabaseConnection) {
const r = express.Router() const r = express.Router()
const vite = await createViteServer({ const vite = await createViteServer({
@ -41,7 +42,8 @@ async function createDevRouter() {
// Load (to be bundled) entry point for server side rendering // Load (to be bundled) entry point for server side rendering
const render: RenderFunction = (await vite.ssrLoadModule('./client/entry-server.tsx')).default const render: RenderFunction = (await vite.ssrLoadModule('./client/entry-server.tsx')).default
const { html, metadata } = render(req.originalUrl) const { html, metadata, asyncCallbacks } = render(req.originalUrl, db)
await Promise.all(asyncCallbacks.map(fn => fn()))
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}` const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const metaTagsHtml = const metaTagsHtml =
@ -64,7 +66,7 @@ async function createDevRouter() {
return r return r
} }
async function createProductionRouter() { async function createProductionRouter(db: DatabaseConnection) {
// Load bundled entry point for server side rendering // Load bundled entry point for server side rendering
// @ts-ignore // @ts-ignore
@ -75,11 +77,8 @@ async function createProductionRouter() {
const handleSSR = async (req, res) => { const handleSSR = async (req, res) => {
const transformedTemplate = await fs.readFile(path.resolve('./dist/entry-client/index.html'), 'utf-8') const transformedTemplate = await fs.readFile(path.resolve('./dist/entry-client/index.html'), 'utf-8')
const { html, metadata } = render(req.originalUrl) const { html, metadata, asyncCallbacks } = render(req.originalUrl, db)
await Promise.all(asyncCallbacks.map(fn => fn()))
console.dir(transformedTemplate, { depth: null })
console.dir(html, { depth: null })
console.dir(metadata, { depth: null })
const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}` const fullUrl = `${req.protocol}://${req.get('host')}${req.originalUrl}`
const metaTagsHtml = const metaTagsHtml =
@ -112,12 +111,14 @@ async function main() {
app.use(morgan('[Request] :method :url :status :response-time ms - :res[content-length]')) app.use(morgan('[Request] :method :url :status :response-time ms - :res[content-length]'))
app.use('/', await createApiRouter()) const [r, db] = await createApiRouter()
app.use('/', r)
if (config.isDevelopment) { if (config.isDevelopment) {
app.use('/', await createDevRouter()) app.use('/', await createDevRouter(db))
} else { } else {
app.use('/', await createProductionRouter()) app.use('/', await createProductionRouter(db))
} }
app.listen(config.port, () => { app.listen(config.port, () => {

@ -1,6 +1,7 @@
import crypto from 'crypto' import crypto from 'crypto'
import { readFile, writeFile, access, constants } from 'fs/promises' import { readFile, writeFile, access, constants } from 'fs/promises'
import { Database, DatabaseConnection } from '../../shared/database'
import { MetadataProps as MetaProps, Problem, ProblemId, Solution, SolutionId, User, UserId, UserRole } from '../../shared/model' import { MetadataProps as MetaProps, Problem, ProblemId, Solution, SolutionId, User, UserId, UserRole } from '../../shared/model'
@ -53,16 +54,18 @@ function createMutex(): Mutex {
return { lock } return { lock }
} }
export type DatabaseConnection = { type DatabaseInternal = {
path: string path: string
initialValue: Database initialValue: Database
mu: Mutex mu: Mutex
} }
export type Database = { export function createDatabaseWrapper(db: DatabaseInternal): DatabaseConnection {
users: Record<string, User> return {
problems: Record<string, Problem> getProblem(id: string): Promise<Problem | null> {
solutions: Record<string, Solution> return getProblem(db, id)
},
}
} }
export function createDatabase(path: string, initialValue: Database) { export function createDatabase(path: string, initialValue: Database) {
@ -73,7 +76,7 @@ export function createDatabase(path: string, initialValue: Database) {
} }
} }
async function withDatabase<R>({ path, initialValue, mu }: DatabaseConnection, fn: (db: Database) => R | Promise<R>): Promise<R> { async function withDatabase<R>({ path, initialValue, mu }: DatabaseInternal, fn: (db: Database) => R | Promise<R>): Promise<R> {
const unlock: Lock = await mu.lock() const unlock: Lock = await mu.lock()
try { try {
@ -100,17 +103,17 @@ async function withDatabase<R>({ path, initialValue, mu }: DatabaseConnection, f
// Users // Users
// //
export const getUsers = (db: DatabaseConnection) => export const getUsers = (db: DatabaseInternal) =>
withDatabase(db, state => { withDatabase(db, state => {
return Object.values(state.users) return Object.values(state.users)
}) })
export const getUser: (db: DatabaseConnection, id: string) => Promise<User | null> = (db, id) => export const getUser: (db: DatabaseInternal, id: string) => Promise<User | null> = (db, id) =>
withDatabase(db, state => { withDatabase(db, state => {
return state.users[id] ?? null return state.users[id] ?? null
}) })
export const createUser: (db: DatabaseConnection, user: User) => Promise<void> = (db, user) => export const createUser: (db: DatabaseInternal, user: User) => Promise<void> = (db, user) =>
withDatabase(db, state => { withDatabase(db, state => {
state.users[user.id] = user state.users[user.id] = user
}) })
@ -119,7 +122,7 @@ export const createUser: (db: DatabaseConnection, user: User) => Promise<void> =
// Problems // Problems
// //
export const createProblem = (db: DatabaseConnection, { title, content, createdBy }: Omit<Problem, MetaProps>): Promise<ProblemId> => export const createProblem = (db: DatabaseInternal, { title, content, createdBy }: Omit<Problem, MetaProps>): Promise<ProblemId> =>
withDatabase(db, state => { withDatabase(db, state => {
const id = crypto.randomBytes(4).toString('hex') as ProblemId const id = crypto.randomBytes(4).toString('hex') as ProblemId
@ -136,18 +139,18 @@ export const createProblem = (db: DatabaseConnection, { title, content, createdB
return id return id
}) })
export const getProblem = (db: DatabaseConnection, id: string): Promise<Problem | null> => export const getProblem = (db: DatabaseInternal, id: string): Promise<Problem | null> =>
withDatabase(db, state => { withDatabase(db, state => {
const problem = (state.problems[id] ?? null) as Problem | null const problem = (state.problems[id] ?? null) as Problem | null
return problem && !problem.deleted ? problem : null return problem && !problem.deleted ? problem : null
}) })
export const getProblems = (db: DatabaseConnection): Promise<Problem[]> => export const getProblems = (db: DatabaseInternal): Promise<Problem[]> =>
withDatabase(db, state => { withDatabase(db, state => {
return Object.values(state.problems).filter(p => !p.deleted) return Object.values(state.problems).filter(p => !p.deleted)
}) })
export const updateProblem = (db: DatabaseConnection, id: ProblemId, problem: Partial<Omit<Problem, MetaProps>>): Promise<Problem> => export const updateProblem = (db: DatabaseInternal, id: ProblemId, problem: Partial<Omit<Problem, MetaProps>>): Promise<Problem> =>
withDatabase(db, state => { withDatabase(db, state => {
state.problems[id] = { state.problems[id] = {
...state.problems[id], ...state.problems[id],
@ -157,7 +160,7 @@ export const updateProblem = (db: DatabaseConnection, id: ProblemId, problem: Pa
return state.problems[id] return state.problems[id]
}) })
export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise<void> => export const deleteProblem = (db: DatabaseInternal, id: ProblemId): Promise<void> =>
withDatabase(db, state => { withDatabase(db, state => {
state.problems[id].deleted = true state.problems[id].deleted = true
}) })
@ -167,7 +170,7 @@ export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise<vo
// //
export const createSolution = ( export const createSolution = (
db: DatabaseConnection, db: DatabaseInternal,
{ sentBy, forProblem, content }: Omit<Solution, MetaProps | 'status' | 'visible'> { sentBy, forProblem, content }: Omit<Solution, MetaProps | 'status' | 'visible'>
): Promise<SolutionId> => ): Promise<SolutionId> =>
withDatabase(db, state => { withDatabase(db, state => {
@ -188,25 +191,25 @@ export const createSolution = (
return id return id
}) })
export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise<Solution | null> => export const getSolution = (db: DatabaseInternal, id: SolutionId): Promise<Solution | null> =>
withDatabase(db, state => { withDatabase(db, state => {
const solution = (state.solutions[id] ?? null) as Solution | null const solution = (state.solutions[id] ?? null) as Solution | null
return solution && !solution.deleted ? solution : null return solution && !solution.deleted ? solution : null
}) })
export const getSolutions = (db: DatabaseConnection) => export const getSolutions = (db: DatabaseInternal) =>
withDatabase(db, state => { withDatabase(db, state => {
return Object.values(state.solutions).filter(s => !s.deleted) return Object.values(state.solutions).filter(s => !s.deleted)
}) })
export const getVisibleSolutions = (db: DatabaseConnection): Promise<Solution[]> => export const getVisibleSolutions = (db: DatabaseInternal): Promise<Solution[]> =>
withDatabase(db, state => { withDatabase(db, state => {
return Object.values(state.solutions) return Object.values(state.solutions)
.filter(s => !s.deleted) .filter(s => !s.deleted)
.filter(s => s.visible) .filter(s => s.visible)
}) })
export const updateSolution = (db: DatabaseConnection, id: SolutionId, solution: Partial<Omit<Solution, MetaProps>>): Promise<Solution> => export const updateSolution = (db: DatabaseInternal, id: SolutionId, solution: Partial<Omit<Solution, MetaProps>>): Promise<Solution> =>
withDatabase(db, state => { withDatabase(db, state => {
state.solutions[id] = { state.solutions[id] = {
...state.solutions[id], ...state.solutions[id],
@ -216,7 +219,7 @@ export const updateSolution = (db: DatabaseConnection, id: SolutionId, solution:
return state.solutions[id] return state.solutions[id]
}) })
export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise<void> => export const deleteSolution = (db: DatabaseInternal, id: SolutionId): Promise<void> =>
withDatabase(db, state => { withDatabase(db, state => {
state.solutions[id].deleted = true state.solutions[id].deleted = true
}) })

@ -9,6 +9,7 @@ import { StatusCodes } from 'http-status-codes'
import { import {
createDatabase, createDatabase,
createDatabaseWrapper,
createProblem, createProblem,
createSolution, createSolution,
deleteProblem, deleteProblem,
@ -39,6 +40,7 @@ import { initialDatabaseValue } from './db/example-data'
import { validateObjectKeys } from '../shared/utils' import { validateObjectKeys } from '../shared/utils'
import { setupOAuth } from './auth' import { setupOAuth } from './auth'
import { DatabaseConnection } from '../shared/database'
type SessionId = Opaque<string, string, 'session'> type SessionId = Opaque<string, string, 'session'>
@ -47,7 +49,7 @@ export interface SessionService {
getUserForSession(sid: SessionId): UserId | null getUserForSession(sid: SessionId): UserId | null
} }
export async function createApiRouter() { export async function createApiRouter(): Promise<[Router, DatabaseConnection]> {
const sessionStore: Record<SessionId, UserId> = {} const sessionStore: Record<SessionId, UserId> = {}
const sessions: SessionService = { const sessions: SessionService = {
createSession(userId: UserId) { createSession(userId: UserId) {
@ -428,5 +430,5 @@ export async function createApiRouter() {
res.json(requestedUser) res.json(requestedUser)
}) })
return r return [r, createDatabaseWrapper(db)]
} }

@ -0,0 +1,11 @@
import { Problem, Solution, User } from './model'
export type DatabaseConnection = {
getProblem(id: string): Promise<Problem | null>
}
export type Database = {
users: Record<string, User>
problems: Record<string, Problem>
solutions: Record<string, Solution>
}

@ -1,6 +1,11 @@
import { DatabaseConnection } from './database'
export type ServerAsyncCallback = () => Promise<void>
export type RenderedPage = { export type RenderedPage = {
html: string html: string
metadata: any metadata: any
asyncCallbacks: ServerAsyncCallback[]
} }
export type RenderFunction = (url: string) => RenderedPage export type RenderFunction = (url: string, db: DatabaseConnection) => RenderedPage

Loading…
Cancel
Save