From fab8398a7a4f21548314fe2b14f226179ed1d423 Mon Sep 17 00:00:00 2001 From: Antonio De Lucreziis Date: Wed, 11 Jan 2023 21:37:39 +0100 Subject: [PATCH] magic: added a way for running async functions in the top level jsx code --- client/entry-server.tsx | 16 +++++++++----- client/hooks.tsx | 13 ++++++++++- client/pages/ProblemPage.tsx | 16 ++++++++++---- server.ts | 23 ++++++++++--------- server/db/database.ts | 43 +++++++++++++++++++----------------- server/routes.ts | 6 +++-- shared/database.ts | 11 +++++++++ shared/ssr.ts | 7 +++++- 8 files changed, 91 insertions(+), 44 deletions(-) create mode 100644 shared/database.ts diff --git a/client/entry-server.tsx b/client/entry-server.tsx index 3f9aca2..7fe1a16 100644 --- a/client/entry-server.tsx +++ b/client/entry-server.tsx @@ -1,20 +1,26 @@ import renderToString from 'preact-render-to-string' // 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 { DatabaseConnection } from '../shared/database' -export default (url: string): RenderedPage => { +export default (url: string, db: DatabaseConnection): RenderedPage => { const metadata = {} + const asyncCallbacks: ServerAsyncCallback[] = [] const html = renderToString( - + + + + + ) - return { html, metadata } + return { html, metadata, asyncCallbacks } } diff --git a/client/hooks.tsx b/client/hooks.tsx index ae4ea83..084f8eb 100644 --- a/client/hooks.tsx +++ b/client/hooks.tsx @@ -1,7 +1,9 @@ -import { StateUpdater, useEffect, useState } from 'preact/hooks' +import { StateUpdater, useContext, useEffect, useState } from 'preact/hooks' import { createContext } from 'preact' import { prependBaseUrl } from '../shared/utils' +import { DatabaseConnection } from '../shared/database' +import { ServerAsyncCallback } from '../shared/ssr' type Metadata = { title?: string @@ -13,6 +15,8 @@ export const MetadataContext = createContext({}) export const ServerContext = createContext(false) export const ClientContext = createContext(false) +export const DatabaseContext = createContext(null) + type RefreshFunction = () => AbortController type HeuristicStateUpdater = StateUpdater @@ -73,3 +77,10 @@ export const useListResource = ( return [list, refreshList, setItemHeuristic, setListHeuristic] } + +export const ServerAsyncCallbacksContext = createContext([]) + +export const useServerAsyncCallback = (fn: () => Promise) => { + const registeredAsyncCallbacks = useContext(ServerAsyncCallbacksContext) + registeredAsyncCallbacks.push(fn) +} diff --git a/client/pages/ProblemPage.tsx b/client/pages/ProblemPage.tsx index 5ee0bb6..b54f2af 100644 --- a/client/pages/ProblemPage.tsx +++ b/client/pages/ProblemPage.tsx @@ -7,7 +7,7 @@ import { Header } from '../components/Header' import { MarkdownEditor } from '../components/MarkdownEditor' import { Problem } from '../components/Problem' 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' type RouteProps = { @@ -16,8 +16,16 @@ type RouteProps = { export const ProblemPage = ({ id }: RouteProps) => { const metadata = useContext(MetadataContext) - metadata.title = `Problem ${id}` - metadata.description = 'Bacheca di problemi del PHC' + const db = useContext(DatabaseContext) + 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() @@ -25,7 +33,7 @@ export const ProblemPage = ({ id }: RouteProps) => { const [problem, refreshProblem] = useResource(`/api/problem/${id}`, null, problem => { if (problem === null) { - route(prependBaseUrl('/')) + route(prependBaseUrl(`/error?message=${encodeURIComponent(`Il problema "${id}" non esiste`)}`)) } }) diff --git a/server.ts b/server.ts index b8643fe..e21118a 100644 --- a/server.ts +++ b/server.ts @@ -10,6 +10,7 @@ import { createServer as createViteServer } from 'vite' import { createApiRouter } from './server/routes' import { RenderFunction } from './shared/ssr' +import { DatabaseConnection } from './shared/database' // Load ".env" dotenv.config() @@ -24,7 +25,7 @@ if (config.isDevelopment) { console.log(`[Config] PORT = ${config.port}`) } -async function createDevRouter() { +async function createDevRouter(db: DatabaseConnection) { const r = express.Router() const vite = await createViteServer({ @@ -41,7 +42,8 @@ async function createDevRouter() { // Load (to be bundled) entry point for server side rendering 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 metaTagsHtml = @@ -64,7 +66,7 @@ async function createDevRouter() { return r } -async function createProductionRouter() { +async function createProductionRouter(db: DatabaseConnection) { // Load bundled entry point for server side rendering // @ts-ignore @@ -75,11 +77,8 @@ async function createProductionRouter() { const handleSSR = async (req, res) => { const transformedTemplate = await fs.readFile(path.resolve('./dist/entry-client/index.html'), 'utf-8') - const { html, metadata } = render(req.originalUrl) - - console.dir(transformedTemplate, { depth: null }) - console.dir(html, { depth: null }) - console.dir(metadata, { depth: null }) + 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 metaTagsHtml = @@ -112,12 +111,14 @@ async function main() { 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) { - app.use('/', await createDevRouter()) + app.use('/', await createDevRouter(db)) } else { - app.use('/', await createProductionRouter()) + app.use('/', await createProductionRouter(db)) } app.listen(config.port, () => { diff --git a/server/db/database.ts b/server/db/database.ts index 2221bf2..9730771 100644 --- a/server/db/database.ts +++ b/server/db/database.ts @@ -1,6 +1,7 @@ import crypto from 'crypto' 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' @@ -53,16 +54,18 @@ function createMutex(): Mutex { return { lock } } -export type DatabaseConnection = { +type DatabaseInternal = { path: string initialValue: Database mu: Mutex } -export type Database = { - users: Record - problems: Record - solutions: Record +export function createDatabaseWrapper(db: DatabaseInternal): DatabaseConnection { + return { + getProblem(id: string): Promise { + return getProblem(db, id) + }, + } } export function createDatabase(path: string, initialValue: Database) { @@ -73,7 +76,7 @@ export function createDatabase(path: string, initialValue: Database) { } } -async function withDatabase({ path, initialValue, mu }: DatabaseConnection, fn: (db: Database) => R | Promise): Promise { +async function withDatabase({ path, initialValue, mu }: DatabaseInternal, fn: (db: Database) => R | Promise): Promise { const unlock: Lock = await mu.lock() try { @@ -100,17 +103,17 @@ async function withDatabase({ path, initialValue, mu }: DatabaseConnection, f // Users // -export const getUsers = (db: DatabaseConnection) => +export const getUsers = (db: DatabaseInternal) => withDatabase(db, state => { return Object.values(state.users) }) -export const getUser: (db: DatabaseConnection, id: string) => Promise = (db, id) => +export const getUser: (db: DatabaseInternal, id: string) => Promise = (db, id) => withDatabase(db, state => { return state.users[id] ?? null }) -export const createUser: (db: DatabaseConnection, user: User) => Promise = (db, user) => +export const createUser: (db: DatabaseInternal, user: User) => Promise = (db, user) => withDatabase(db, state => { state.users[user.id] = user }) @@ -119,7 +122,7 @@ export const createUser: (db: DatabaseConnection, user: User) => Promise = // Problems // -export const createProblem = (db: DatabaseConnection, { title, content, createdBy }: Omit): Promise => +export const createProblem = (db: DatabaseInternal, { title, content, createdBy }: Omit): Promise => withDatabase(db, state => { const id = crypto.randomBytes(4).toString('hex') as ProblemId @@ -136,18 +139,18 @@ export const createProblem = (db: DatabaseConnection, { title, content, createdB return id }) -export const getProblem = (db: DatabaseConnection, id: string): Promise => +export const getProblem = (db: DatabaseInternal, id: string): Promise => withDatabase(db, state => { const problem = (state.problems[id] ?? null) as Problem | null return problem && !problem.deleted ? problem : null }) -export const getProblems = (db: DatabaseConnection): Promise => +export const getProblems = (db: DatabaseInternal): Promise => withDatabase(db, state => { return Object.values(state.problems).filter(p => !p.deleted) }) -export const updateProblem = (db: DatabaseConnection, id: ProblemId, problem: Partial>): Promise => +export const updateProblem = (db: DatabaseInternal, id: ProblemId, problem: Partial>): Promise => withDatabase(db, state => { state.problems[id] = { ...state.problems[id], @@ -157,7 +160,7 @@ export const updateProblem = (db: DatabaseConnection, id: ProblemId, problem: Pa return state.problems[id] }) -export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise => +export const deleteProblem = (db: DatabaseInternal, id: ProblemId): Promise => withDatabase(db, state => { state.problems[id].deleted = true }) @@ -167,7 +170,7 @@ export const deleteProblem = (db: DatabaseConnection, id: ProblemId): Promise ): Promise => withDatabase(db, state => { @@ -188,25 +191,25 @@ export const createSolution = ( return id }) -export const getSolution = (db: DatabaseConnection, id: SolutionId): Promise => +export const getSolution = (db: DatabaseInternal, id: SolutionId): Promise => withDatabase(db, state => { const solution = (state.solutions[id] ?? null) as Solution | null return solution && !solution.deleted ? solution : null }) -export const getSolutions = (db: DatabaseConnection) => +export const getSolutions = (db: DatabaseInternal) => withDatabase(db, state => { return Object.values(state.solutions).filter(s => !s.deleted) }) -export const getVisibleSolutions = (db: DatabaseConnection): Promise => +export const getVisibleSolutions = (db: DatabaseInternal): Promise => withDatabase(db, state => { return Object.values(state.solutions) .filter(s => !s.deleted) .filter(s => s.visible) }) -export const updateSolution = (db: DatabaseConnection, id: SolutionId, solution: Partial>): Promise => +export const updateSolution = (db: DatabaseInternal, id: SolutionId, solution: Partial>): Promise => withDatabase(db, state => { state.solutions[id] = { ...state.solutions[id], @@ -216,7 +219,7 @@ export const updateSolution = (db: DatabaseConnection, id: SolutionId, solution: return state.solutions[id] }) -export const deleteSolution = (db: DatabaseConnection, id: SolutionId): Promise => +export const deleteSolution = (db: DatabaseInternal, id: SolutionId): Promise => withDatabase(db, state => { state.solutions[id].deleted = true }) diff --git a/server/routes.ts b/server/routes.ts index eb7045f..106a8af 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -9,6 +9,7 @@ import { StatusCodes } from 'http-status-codes' import { createDatabase, + createDatabaseWrapper, createProblem, createSolution, deleteProblem, @@ -39,6 +40,7 @@ import { initialDatabaseValue } from './db/example-data' import { validateObjectKeys } from '../shared/utils' import { setupOAuth } from './auth' +import { DatabaseConnection } from '../shared/database' type SessionId = Opaque @@ -47,7 +49,7 @@ export interface SessionService { getUserForSession(sid: SessionId): UserId | null } -export async function createApiRouter() { +export async function createApiRouter(): Promise<[Router, DatabaseConnection]> { const sessionStore: Record = {} const sessions: SessionService = { createSession(userId: UserId) { @@ -428,5 +430,5 @@ export async function createApiRouter() { res.json(requestedUser) }) - return r + return [r, createDatabaseWrapper(db)] } diff --git a/shared/database.ts b/shared/database.ts new file mode 100644 index 0000000..1332f34 --- /dev/null +++ b/shared/database.ts @@ -0,0 +1,11 @@ +import { Problem, Solution, User } from './model' + +export type DatabaseConnection = { + getProblem(id: string): Promise +} + +export type Database = { + users: Record + problems: Record + solutions: Record +} diff --git a/shared/ssr.ts b/shared/ssr.ts index 85b1c9b..7690073 100644 --- a/shared/ssr.ts +++ b/shared/ssr.ts @@ -1,6 +1,11 @@ +import { DatabaseConnection } from './database' + +export type ServerAsyncCallback = () => Promise + export type RenderedPage = { html: string metadata: any + asyncCallbacks: ServerAsyncCallback[] } -export type RenderFunction = (url: string) => RenderedPage +export type RenderFunction = (url: string, db: DatabaseConnection) => RenderedPage