diff --git a/packages/vortex-core/src/std/awaited.ts b/packages/vortex-core/src/std/awaited.ts index f83eda6..c5e83f2 100644 --- a/packages/vortex-core/src/std/awaited.ts +++ b/packages/vortex-core/src/std/awaited.ts @@ -5,40 +5,53 @@ import { type Signal, useState } from "../signal"; * @deprecated This is a very delicate API, it's reccommended that unless you're running exclusively on the client, you use `useAwait` to get a callable await function. */ export function awaited(value: Promise): Signal { - const result = useState(undefined); - const streaming = useOptionalStreaming(); + const result = useState(undefined); + const streaming = useOptionalStreaming(); - async function fetchValue() { - if (streaming) { - using _loading = streaming.markLoading(); - result.set(await value); - } else { - result.set(await value); - } - } + async function fetchValue() { + if (streaming) { + using _loading = streaming.markLoading(); + result.set(await value); + } else { + result.set(await value); + } + } - fetchValue(); + fetchValue(); - return result; + return result; } -export function useAwait(): (value: Promise) => Signal { - const streaming = useOptionalStreaming(); +export function useAwait(): (value: Promise | T) => Signal { + const streaming = useOptionalStreaming(); - return (value: Promise) => { - const result = useState(undefined); + return (value: Promise | T) => { + const result = useState(undefined); - async function fetchValue() { - if (streaming) { - using _loading = streaming.markLoading(); - result.set(await value); - } else { - result.set(await value); - } - } + if (!(value instanceof Promise)) { + result.set(value); + return result; + } - fetchValue(); + if (typeof Bun !== "undefined") { + const peeked = Bun.peek(value); - return result; - } + if (!(peeked instanceof Promise)) { + result.set(peeked); + } + } + + async function fetchValue() { + if (streaming) { + using _loading = streaming.markLoading(); + result.set(await value); + } else { + result.set(await value); + } + } + + fetchValue(); + + return result; + } } diff --git a/packages/wormhole/src/build/adapters/dev.ts b/packages/wormhole/src/build/adapters/dev.ts index 78620aa..c50c390 100644 --- a/packages/wormhole/src/build/adapters/dev.ts +++ b/packages/wormhole/src/build/adapters/dev.ts @@ -56,7 +56,7 @@ export function DevAdapter(): DevAdapter { codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; - codegenSource += `export function main(props) {`; + codegenSource += `export async function main(props) {`; codegenSource += 'const loaders = ['; @@ -78,14 +78,15 @@ export function DevAdapter(): DevAdapter { codegenSource += `const root = document.documentElement;`; } - codegenSource += `return INTERNAL_entrypoint({ + codegenSource += `return await INTERNAL_entrypoint({ props: entrypointProps, loaders, renderer, root, pathname: props.pathname, context: props.context, - lifetime: props.lifetime ?? new Lifetime(),`; + lifetime: props.lifetime ?? new Lifetime(), + preload: true,`; if (location === "client") { codegenSource += `supplement: props.supplement,`; diff --git a/packages/wormhole/src/build/adapters/vercel.ts b/packages/wormhole/src/build/adapters/vercel.ts index 122fe39..8fb6403 100644 --- a/packages/wormhole/src/build/adapters/vercel.ts +++ b/packages/wormhole/src/build/adapters/vercel.ts @@ -248,6 +248,7 @@ export function VercelAdapter(): VercelAdapter { pathname, context, lifetime, + preload: true });`; codegenSource += `const streamutil = INTERNAL_createStreamUtility();`; codegenSource += `const html = printHTML(root);`; diff --git a/packages/wormhole/src/dev/dev-server.ts b/packages/wormhole/src/dev/dev-server.ts index c31d4fa..cab8f3b 100644 --- a/packages/wormhole/src/dev/dev-server.ts +++ b/packages/wormhole/src/dev/dev-server.ts @@ -9,190 +9,194 @@ import { watch } from "node:fs"; import type { HTTPMethod } from "~/shared/http-method"; export interface DevServer { - lifetime: Lifetime; - server: Bun.Server; - processRequest(request: Request, tags: RequestTag[]): Promise; - rebuild(): Promise; - buildResult: Promise; - project: Project; + lifetime: Lifetime; + server: Bun.Server; + processRequest(request: Request, tags: RequestTag[]): Promise; + rebuild(): Promise; + buildResult: Promise; + project: Project; } export function DevServer(project: Project): DevServer { - const server = Bun.serve({ - port: 3141, - routes: { - "/*": async (req) => { - return new Response(); - } - }, - development: true - }); - - project.lt.onClosed(server.stop); - - const self: DevServer = { - lifetime: project.lt, - server, - processRequest: DevServer_processRequest, - rebuild: DevServer_rebuild, - buildResult: new Promise(() => { }), - project - } - - server.reload({ - routes: { - "/*": async (req) => { - try { - const tags: RequestTag[] = []; - const response = await self.processRequest(req, tags); - - addLog({ - type: "request", - url: new URL(req.url).pathname, - method: req.method as HTTPMethod, - responseCode: response.status, - tags - }) - - return response; - } catch (e) { - console.error(e); - - addLog({ - type: "request", - url: new URL(req.url).pathname, - method: req.method as HTTPMethod, - responseCode: 500, - tags: [] - }) - - return new Response("Internal Server Error", { status: 500 }); - } - } - } - }); - - const devServerTask = addTask({ - name: `Development server running @ ${server.url.toString()}` - }); - - project.lt.onClosed(devServerTask[Symbol.dispose]); - - self.rebuild(); - - // Watch sourcedir - const watcher = watch(join(project.projectDir, "src"), { recursive: true }); - - let isWaitingForRebuild = false; - - watcher.on("change", async (eventType, filename) => { - if (isWaitingForRebuild) return; - isWaitingForRebuild = true; - await self.buildResult; - isWaitingForRebuild = false; - self.rebuild(); - }); - - project.lt.onClosed(() => { - watcher.close(); - }); - - return self; + const server = Bun.serve({ + port: 3141, + routes: { + "/*": async (req) => { + return new Response(); + } + }, + development: true + }); + + project.lt.onClosed(server.stop); + + const self: DevServer = { + lifetime: project.lt, + server, + processRequest: DevServer_processRequest, + rebuild: DevServer_rebuild, + buildResult: new Promise(() => { }), + project + } + + server.reload({ + routes: { + "/*": async (req) => { + try { + const tags: RequestTag[] = []; + const response = await self.processRequest(req, tags); + + addLog({ + type: "request", + url: new URL(req.url).pathname, + method: req.method as HTTPMethod, + responseCode: response.status, + tags + }) + + return response; + } catch (e) { + console.error(e); + + addLog({ + type: "request", + url: new URL(req.url).pathname, + method: req.method as HTTPMethod, + responseCode: 500, + tags: [] + }) + + return new Response("Internal Server Error", { status: 500 }); + } + } + } + }); + + const devServerTask = addTask({ + name: `Development server running @ ${server.url.toString()}` + }); + + project.lt.onClosed(devServerTask[Symbol.dispose]); + + self.rebuild(); + + // Watch sourcedir + const watcher = watch(join(project.projectDir, "src"), { recursive: true }); + + let isWaitingForRebuild = false; + + watcher.on("change", async (eventType, filename) => { + if (isWaitingForRebuild) return; + isWaitingForRebuild = true; + await self.buildResult; + isWaitingForRebuild = false; + self.rebuild(); + }); + + project.lt.onClosed(() => { + watcher.close(); + }); + + return self; } async function DevServer_rebuild(this: DevServer): Promise { - const build = new Build(this.project, DevAdapter()); - this.buildResult = build.run(); - await this.buildResult; + const build = new Build(this.project, DevAdapter()); + this.buildResult = build.run(); + try { + await this.buildResult; + } catch (e) { + console.error(e); + } } interface ServerEntrypoint { - main(props: { - renderer: Renderer, - root: RendererNode, - pathname: string, - context: ContextScope, - lifetime: Lifetime - }): void; - tryHandleAPI(request: Request): Promise; - isRoute404(pathname: string): boolean; + main(props: { + renderer: Renderer, + root: RendererNode, + pathname: string, + context: ContextScope, + lifetime: Lifetime + }): Promise; + tryHandleAPI(request: Request): Promise; + isRoute404(pathname: string): boolean; } async function DevServer_processRequest(this: DevServer, request: Request, tags: RequestTag[]): Promise { - const built = await this.buildResult; + const built = await this.buildResult; - const serverPath = built.serverEntry; - const serverEntrypoint = (await import(serverPath + `?v=${Date.now()}`)) as ServerEntrypoint; + const serverPath = built.serverEntry; + const serverEntrypoint = (await import(serverPath + `?v=${Date.now()}`)) as ServerEntrypoint; - // Priority 1: API routes - const apiResponse = await serverEntrypoint.tryHandleAPI(request); + // Priority 1: API routes + const apiResponse = await serverEntrypoint.tryHandleAPI(request); - if (apiResponse !== undefined && apiResponse !== null) { - tags.push("api"); - return apiResponse; - } + if (apiResponse !== undefined && apiResponse !== null) { + tags.push("api"); + return apiResponse; + } - // Priority 2: Static files - const filePath = join(built.outdir, new URL(request.url).pathname); + // Priority 2: Static files + const filePath = join(built.outdir, new URL(request.url).pathname); - if (await Bun.file(filePath).exists()) { - tags.push("static"); - return new Response(Bun.file(filePath)); - } + if (await Bun.file(filePath).exists()) { + tags.push("static"); + return new Response(Bun.file(filePath)); + } - // Priority 3: SSR - const root = createHTMLRoot(); - const renderer = ssr(); + // Priority 3: SSR + const root = createHTMLRoot(); + const renderer = ssr(); - const lifetime = new Lifetime(); - const context = new ContextScope(lifetime); + const lifetime = new Lifetime(); + const context = new ContextScope(lifetime); - serverEntrypoint.main({ - root, - renderer, - pathname: new URL(request.url).pathname, - context, - lifetime - }); + await serverEntrypoint.main({ + root, + renderer, + pathname: new URL(request.url).pathname, + context, + lifetime + }); - const html = printHTML(root); + const html = printHTML(root); - const { readable, writable } = new TransformStream(); + const { readable, writable } = new TransformStream(); - async function load() { - const writer = writable.getWriter(); + async function load() { + const writer = writable.getWriter(); - writer.write(html); + writer.write(html); - let currentSnapshot = structuredClone(root); + let currentSnapshot = structuredClone(root); - context.streaming.updated(); + context.streaming.updated(); - context.streaming.onUpdate(() => { - const codegen = diffInto(currentSnapshot, root); + context.streaming.onUpdate(() => { + const codegen = diffInto(currentSnapshot, root); - const code = codegen.getCode(); + const code = codegen.getCode(); - currentSnapshot = structuredClone(root); + currentSnapshot = structuredClone(root); - writer.write(``); - }); + writer.write(``); + }); - await context.streaming.onDoneLoading; + await context.streaming.onDoneLoading; - writer.write(``); - writer.close(); - lifetime.close(); - } + writer.write(``); + writer.close(); + lifetime.close(); + } - load(); + load(); - tags.push("ssr"); + tags.push("ssr"); - return new Response(readable, { - status: serverEntrypoint.isRoute404(new URL(request.url).pathname) ? 404 : 200, - headers: { - 'Content-Type': "text/html" - } - }) + return new Response(readable, { + status: serverEntrypoint.isRoute404(new URL(request.url).pathname) ? 404 : 200, + headers: { + 'Content-Type': "text/html" + } + }) } diff --git a/packages/wormhole/src/runtime/entrypoint.tsx b/packages/wormhole/src/runtime/entrypoint.tsx index e53d786..5a5ef56 100644 --- a/packages/wormhole/src/runtime/entrypoint.tsx +++ b/packages/wormhole/src/runtime/entrypoint.tsx @@ -1,132 +1,146 @@ import { unwrap } from "@vortexjs/common"; import { - useAwait, - ContextScope, - flatten, - type JSXNode, - Lifetime, - render, - type Renderer, - useDerived, - useState, - useStreaming, - when, - type QuerySupplement + useAwait, + ContextScope, + flatten, + type JSXNode, + Lifetime, + render, + type Renderer, + useDerived, + useState, + useStreaming, + when, + type QuerySupplement } from "@vortexjs/core"; import { matchPath, type RoutePath } from "~/build/router"; import { initializeClientSideRouting, usePathname } from "~/runtime/csr"; export interface EntrypointImport { - index: number; + index: number; } export interface EntrypointRoute { - matcher: RoutePath; - frames: EntrypointImport[]; - is404: boolean; + matcher: RoutePath; + frames: EntrypointImport[]; + is404: boolean; } export interface EntrypointProps { - routes: EntrypointRoute[]; + routes: EntrypointRoute[]; } function App({ - pathname: pathnameToUse, - props, - loaders + pathname: pathnameToUse, + props, + loaders }: { - pathname?: string, - props: EntrypointProps - loaders: (() => Promise)[], + pathname?: string, + props: EntrypointProps + loaders: (() => Promise)[], }) { - if ("location" in globalThis) { - initializeClientSideRouting(); - } - - useStreaming(); - - const awaited = useAwait(); - const pathname = (pathnameToUse && typeof window === "undefined") ? useState(pathnameToUse) : usePathname(); - const route = useDerived((get) => { - const path = get(pathname); - return props.routes.find((r) => matchPath(r.matcher, path).matched); - }); - const framesPromise = useDerived(async (get) => { - const rot = unwrap(get(route)); - const frames = []; - - for (const frame of rot.frames) { - frames.push(await unwrap(loaders[frame.index])()); - } - - return frames; - }); - const frames = flatten(useDerived((get) => { - return awaited(get(framesPromise)) - })); - const hierarchy = useDerived((get) => { - let node = <>; - - const framesResolved = get(frames); - - if (!framesResolved) { - return

loading

- } - - for (const Frame of framesResolved.toReversed()) { - node = - {node} - - } - - return node; - }); - - return - - - - - - {hierarchy} - - ; + if ("location" in globalThis) { + initializeClientSideRouting(); + } + + useStreaming(); + + const awaited = useAwait(); + const pathname = (pathnameToUse && typeof window === "undefined") ? useState(pathnameToUse) : usePathname(); + const route = useDerived((get) => { + const path = get(pathname); + return props.routes.find((r) => matchPath(r.matcher, path).matched); + }); + const frames = useDerived((get) => { + return get(route)!.frames.map(frame => awaited(unwrap(loaders[frame.index])())); + }) + const hierarchy = useDerived((get) => { + let node = <>; + + const framesResolved = get(frames); + + for (const fr of framesResolved.toReversed()) { + const Frame = get(fr); + if (!Frame) { + node =

loading

; + } else { + node = + {node} + + } + } + + return node; + }, { dynamic: true }); + + return <> + + + + + + {hierarchy} + + ; +} + +function makePromiseGenerator( + loaders: (() => Promise)[] +): (() => any)[] { + + return loaders.map((loader, index) => { + let cache: any = undefined; + + return () => { + if (cache) return cache; + cache = loader(); + return cache; + } + }); } export async function INTERNAL_entrypoint({ - props, - loaders, - renderer, - root, - pathname, - lifetime = new Lifetime(), - context: _context, - supplement + props, + loaders, + renderer, + root, + pathname, + lifetime = new Lifetime(), + context: _context, + supplement, + preload = false }: { - props: EntrypointProps, loaders: (() => Promise)[], renderer: Renderer, root: Root, pathname?: string, - lifetime?: Lifetime, context?: ContextScope, supplement?: QuerySupplement + props: EntrypointProps, loaders: (() => Promise)[], renderer: Renderer, root: Root, pathname?: string, + lifetime?: Lifetime, context?: ContextScope, supplement?: QuerySupplement, + preload?: boolean }) { - const context = _context ?? new ContextScope(lifetime); - - preload: if ("window" in globalThis) { - // We need to preload the routes so we don't flash the 'loading' state - const path = pathname ?? window.location.pathname; - const route = props.routes.find((r) => matchPath(r.matcher, path)); - if (!route) break preload; - - for (const frame of route.frames) { - await unwrap(loaders[frame.index])(); - } - } - - if (supplement) { - context.query.hydrationSupplement = supplement; - } - - render({ - context, - renderer, - root, - component: - }).cascadesFrom(lifetime); + const context = _context ?? new ContextScope(lifetime); + const promises: Promise[] = []; + const ldrs = makePromiseGenerator(loaders); + + if ("window" in globalThis || preload) { + // We need to preload the routes so we don't flash the 'loading' state + const path = pathname ?? window.location.pathname; + const route = props.routes.find((r) => matchPath(r.matcher, path)); + if (!route) { + throw new Error("No route matched"); + } + + for (const frame of route.frames) { + promises.push(unwrap(ldrs[frame.index])()); + } + } + + await Promise.all(promises); + + if (supplement) { + context.query.hydrationSupplement = supplement; + } + + render({ + context, + renderer, + root, + component: , + }).cascadesFrom(lifetime); }