diff --git a/.gitignore b/.gitignore index adacbf2..8bddff6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ **/node_modules **/dist **/.turbo +**/.vercel diff --git a/bun.lock b/bun.lock index 4562d86..0bcb172 100644 --- a/bun.lock +++ b/bun.lock @@ -59,7 +59,7 @@ }, "packages/example": { "name": "@vortexjs/bun-example", - "version": "1.5.4", + "version": "1.5.5", "dependencies": { "@vortexjs/core": "workspace:*", "@vortexjs/dom": "workspace:*", @@ -180,7 +180,7 @@ }, "packages/vortex-core": { "name": "@vortexjs/core", - "version": "2.5.0", + "version": "2.6.0", "dependencies": { "@vortexjs/common": "workspace:*", }, @@ -194,7 +194,7 @@ }, "packages/vortex-dom": { "name": "@vortexjs/dom", - "version": "2.0.4", + "version": "2.0.5", "dependencies": { "@vortexjs/common": "workspace:*", "@vortexjs/core": "workspace:*", @@ -224,7 +224,7 @@ }, "packages/vortex-prime": { "name": "@vortexjs/prime", - "version": "1.3.4", + "version": "1.3.5", "dependencies": { "@vortexjs/common": "workspace:*", "@vortexjs/core": "workspace:*", @@ -240,7 +240,7 @@ }, "packages/vortex-ssr": { "name": "@vortexjs/ssr", - "version": "0.0.4", + "version": "0.0.5", "dependencies": { "@vortexjs/common": "workspace:*", "@vortexjs/core": "workspace:*", @@ -256,7 +256,7 @@ }, "packages/wormhole": { "name": "@vortexjs/wormhole", - "version": "0.2.0", + "version": "0.3.0", "bin": { "wormhole": "./dist/cli.js", "wh": "./dist/cli.js", diff --git a/packages/cataloger/src/index.ts b/packages/cataloger/src/index.ts old mode 100644 new mode 100755 diff --git a/packages/example-wormhole/.gitignore b/packages/example-wormhole/.gitignore index e61b97a..d2b9bc7 100644 --- a/packages/example-wormhole/.gitignore +++ b/packages/example-wormhole/.gitignore @@ -33,3 +33,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store .wormhole + +.vercel diff --git a/packages/example-wormhole/package.json b/packages/example-wormhole/package.json index 5932785..58b6580 100644 --- a/packages/example-wormhole/package.json +++ b/packages/example-wormhole/package.json @@ -1,23 +1,24 @@ { - "name": "@vortexjs/example-wormhole", - "module": "index.ts", - "type": "module", - "license": "MIT-0", - "private": true, - "devDependencies": { - "@types/bun": "catalog:" - }, - "dependencies": { - "@vortexjs/core": "workspace:*", - "@vortexjs/dom": "workspace:*", - "@vortexjs/wormhole": "workspace:*", - "tailwindcss": "catalog:", - "valibot": "catalog:" - }, - "scripts": { - "dev": "wormhole dev" - }, - "peerDependencies": { - "typescript": "catalog:" - } + "name": "@vortexjs/example-wormhole", + "module": "index.ts", + "type": "module", + "license": "MIT-0", + "private": true, + "devDependencies": { + "@types/bun": "catalog:" + }, + "dependencies": { + "@vortexjs/core": "workspace:*", + "@vortexjs/dom": "workspace:*", + "@vortexjs/wormhole": "workspace:*", + "tailwindcss": "catalog:", + "valibot": "catalog:" + }, + "scripts": { + "dev": "wormhole dev", + "vbuild": "wormhole build vercel" + }, + "peerDependencies": { + "typescript": "catalog:" + } } diff --git a/packages/example-wormhole/src/features/home/index.tsx b/packages/example-wormhole/src/features/home/index.tsx index e7715a7..d11861e 100644 --- a/packages/example-wormhole/src/features/home/index.tsx +++ b/packages/example-wormhole/src/features/home/index.tsx @@ -1,62 +1,67 @@ +import { useAwait } from "@vortexjs/core"; import route, { query } from "@vortexjs/wormhole/route"; import * as v from "valibot"; route("/", { - page() { - return ( - <> -

- Welcome to Wormhole, {Object.entries(globalThis).length} -

-

- This is an example app, go to the{" "} - docs -

- - - ); - }, - layout({ children }) { - return ( - <> - Wormhole Example - {children} - - ); - }, - notFound() { - return ( - <> -

404 not found

- - ) - } + page() { + const pause = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + const awaited = useAwait(); + + return ( + <> +

+ Welcome to Wormhole, {Object.entries(globalThis).length} +

+

+ This is an example app, go to the{" "} + docs +

+ + + ); + }, + layout({ children }) { + return ( + <> + Wormhole Example + {children} + + ); + }, + notFound() { + return ( + <> +

404 not found

+ + ) + } }); -route("/docs/[...page]", { - page({ page }) { - return ( - <> -

Documentation for {page.join(", ")}

-

This is the documentation page for {page.join(", ")}.

- - ); - }, +route("/docs", { + page({ }) { + const page = "introduction"; + return ( + <> +

Documentation for {page}

+

This is the documentation page for {page}.

+ + ); + }, }); export const add = query("/api/add", { - schema: v.object({ - a: v.number(), - b: v.number() - }), - impl({ a, b }) { - return a + b; - } + schema: v.object({ + a: v.number(), + b: v.number() + }), + impl({ a, b }) { + return a + b; + } }) diff --git a/packages/locounter/src/index.ts b/packages/locounter/src/index.ts old mode 100644 new mode 100755 diff --git a/packages/vortex-core/src/context.ts b/packages/vortex-core/src/context.ts index 233e9a9..1cc27a3 100644 --- a/packages/vortex-core/src/context.ts +++ b/packages/vortex-core/src/context.ts @@ -42,7 +42,7 @@ export class StreamingContext { private updateCallbackImmediate = 0; private updateCallbacks = new Set<() => void>(); private loadingCounter = 0; - private onDoneLoadingCallback = () => {}; + private onDoneLoadingCallback = () => { }; onDoneLoading: Promise; constructor() { diff --git a/packages/wormhole/README.md b/packages/wormhole/README.md index d6b86be..05d5412 100644 --- a/packages/wormhole/README.md +++ b/packages/wormhole/README.md @@ -8,6 +8,32 @@ Wormhole is the metaframework for Vortex, providing an opinionated way to build - What we believe is the best way to structure applications - Provides a set of tools that synergize with said reccommended structure +## Build Adapters + +Wormhole supports different build adapters for various deployment targets: + +### Development +```bash +wormhole dev +``` +Uses the DevAdapter for local development with hot reloading and debugging features. + +### Vercel +```bash +wormhole build vercel +``` +Uses the VercelAdapter with the Vercel Build Output API for production deployment to Vercel. This adapter: +- Generates production-optimized builds (minified, no dev flags) +- Creates lightweight edge functions for each route and API endpoint +- Outputs in the `.vercel/output` directory structure +- Handles static assets separately from serverless functions +- Supports both server-side rendering and API routes + +The build output follows the Vercel Build Output API format: +- `.vercel/output/static/`: Client-side JavaScript and CSS bundles +- `.vercel/output/functions/`: Individual edge functions for each route +- `.vercel/output/config.json`: Vercel configuration and routing rules + ## Who this isn't for - People who don't want to use Vortex diff --git a/packages/wormhole/src/build/adapters/vercel.ts b/packages/wormhole/src/build/adapters/vercel.ts new file mode 100644 index 0000000..a694dd5 --- /dev/null +++ b/packages/wormhole/src/build/adapters/vercel.ts @@ -0,0 +1,351 @@ +import type { EntrypointProps } from "~/runtime"; +import type { Build, BuildAdapter, TargetLocation, BuildRoute } from "../build"; +import type { Export } from "~/local/export"; +import { addTask } from "~/cli/statusboard"; +import { join, dirname } from "node:path"; +import { mkdir, rmdir } from "node:fs/promises"; +import { printRoutePath, type RoutePath } from "../router"; + +export interface VercelAdapterResult { + outputDir: string; + staticDir: string; + functionsDir: string; + configFile: string; +} + +export interface VercelAdapter extends BuildAdapter { + buildClientBundle(build: Build): Promise; + buildCSS(build: Build): Promise; + buildRouteFunction(build: Build, route: BuildRoute): Promise; +} + +export function getRouteId(matcher: RoutePath) { + return printRoutePath(matcher).replaceAll(/\\|\//gi, "-").replaceAll(" ", "-") || 'index'; +} + +export function VercelAdapter(): VercelAdapter { + return { + async buildClientBundle(build: Build) { + using _task = addTask({ + name: "Building client bundle for Vercel" + }); + + let codegenSource = ""; + + codegenSource += `import { INTERNAL_entrypoint } from "@vortexjs/wormhole";`; + codegenSource += `import { Lifetime } from "@vortexjs/core";`; + codegenSource += `import { html } from "@vortexjs/dom";`; + + const imports: Export[] = []; + + function getExportIndex(exp: Export): number { + const index = imports.findIndex(x => x.file === exp.file && x.name === exp.name); + if (index === -1) { + imports.push(exp); + return imports.length - 1; + } + return index; + } + + const entrypointProps: EntrypointProps = { + routes: build.routes.filter(x => x.type === "route").map(x => ({ + matcher: x.matcher, + frames: x.frames.map((frame) => ({ + index: getExportIndex(frame), + })), + is404: x.is404, + })) + }; + + codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; + + codegenSource += `function main(props) {`; + + codegenSource += 'const loaders = ['; + + for (const exp of imports) { + const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36); + + const path = await build.writeCodegenned(reexporterName, `export { ${JSON.stringify(exp.name)} } from ${JSON.stringify(exp.file)}`); + + codegenSource += `(async () => (await import(${JSON.stringify(path)}))[${JSON.stringify(exp.name)}]),`; + } + + codegenSource += '];'; + + codegenSource += `const renderer = html();`; + codegenSource += `const root = document.documentElement;`; + + codegenSource += `return INTERNAL_entrypoint({ + props: entrypointProps, + loaders, + renderer, + root, + pathname: props.pathname, + context: props.context, + lifetime: props.lifetime ?? new Lifetime(), + });`; + + codegenSource += `}`; + + codegenSource += `window.wormhole = {};`; + codegenSource += `window.wormhole.hydrate = main;`; + + // Add client-side hydration initialization + codegenSource += `document.addEventListener('DOMContentLoaded', () => {`; + codegenSource += `const pathname = window.location.pathname;`; + codegenSource += `main({ pathname, context: {}, lifetime: new Lifetime() });`; + codegenSource += `});`; + + const path = await build.writeCodegenned("entrypoint-client", codegenSource); + + const bundled = await build.bundle({ + target: "client", + inputPaths: { + app: path, + }, + outdir: join(build.project.projectDir, ".vercel", "output", "static"), + dev: false + }); + + return bundled.outputs.app; + }, + + async buildCSS(build: Build) { + using _task = addTask({ + name: "Building CSS for Vercel" + }); + + let codegenCSS = ""; + + const appCSSPath = join(build.project.projectDir, "src", "app.css"); + + if (await Bun.file(appCSSPath).exists()) { + codegenCSS += `@import "${appCSSPath}";`; + } + + const cssPath = await build.writeCodegenned("styles", codegenCSS, "css"); + + const bundled = await build.bundle({ + target: "client", + inputPaths: { + app: cssPath, + }, + outdir: join(build.project.projectDir, ".vercel", "output", "static"), + dev: false + }); + + return bundled.outputs.app; + }, + + async buildRouteFunction(build: Build, route: BuildRoute) { + using _task = addTask({ + name: `Building function for route: ${printRoutePath(route.matcher)}` + }); + + let codegenSource = ""; + + if (route.type === "api") { + // API route function + codegenSource += `import {INTERNAL_tryHandleAPI} from "@vortexjs/wormhole";`; + + codegenSource += `import { ${JSON.stringify(route.schema.name)} as schema } from ${JSON.stringify(route.schema.file)};`; + codegenSource += `import { ${JSON.stringify(route.impl.name)} as impl } from ${JSON.stringify(route.impl.file)};`; + codegenSource += `import { SKL } from "@vortexjs/common";`; + + codegenSource += `export default async function handler(request) {`; + codegenSource += `const text = `; + if (route.method === "GET") { + codegenSource += `new URL(request.url).searchParams.get("props")`; + } else { + codegenSource += `await request.text()`; + } + codegenSource += `;`; + + codegenSource += `if (!text) { return new Response("Missing body", { status: 400 }); }`; + + codegenSource += `let body;`; + codegenSource += `try { body = SKL.parse(text); } catch (e) { return new Response("Invalid SKL", { status: 400 }); }`; + + // check against standard schema + codegenSource += `const parsed = await schema["~standard"].validate(body);`; + + codegenSource += `if ("issues" in parsed && parsed.issues != null && parsed.issues.length > 0) {`; + codegenSource += `return new Response("Request did not match schema", { status: 400 })`; + codegenSource += `}`; + + codegenSource += `try {`; + codegenSource += `const result = await impl(parsed.value);`; + codegenSource += `return new Response(SKL.stringify(result), { status: 200, headers: { "Content-Type": "application/skl" } });`; + codegenSource += `} catch (e) {`; + codegenSource += `console.error(e);`; + codegenSource += `return new Response("Internal Server Error", { status: 500 });`; + codegenSource += `}`; + + codegenSource += `}`; + } else { + // Page route function + codegenSource += `import { INTERNAL_entrypoint, INTERNAL_createStreamUtility } from "@vortexjs/wormhole";`; + codegenSource += `import { Lifetime, ContextScope } from "@vortexjs/core";`; + codegenSource += `import { createHTMLRoot, ssr, printHTML, diffInto } from "@vortexjs/ssr";`; + + const imports: Export[] = []; + + function getExportIndex(exp: Export): number { + const index = imports.findIndex(x => x.file === exp.file && x.name === exp.name); + if (index === -1) { + imports.push(exp); + return imports.length - 1; + } + return index; + } + + const entrypointProps: EntrypointProps = { + routes: [{ + matcher: route.matcher, + frames: route.frames.map((frame) => ({ + index: getExportIndex(frame), + })), + is404: route.is404, + }] + }; + + codegenSource += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`; + + let idx = 0; + for (const exp of imports) { + const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36); + + const path = await build.writeCodegenned(reexporterName, `export { ${JSON.stringify(exp.name)} } from ${JSON.stringify(exp.file)}`); + + codegenSource += `import {${JSON.stringify(exp.name)} as imp${idx}} from ${JSON.stringify(path)};`; + idx++; + } + + codegenSource += 'const loaders = ['; + + idx = 0; + for (const exp of imports) { + codegenSource += `(()=>imp${idx}),`; + idx++; + } + + codegenSource += '];'; + + codegenSource += `export default async function handler(request) {`; + codegenSource += `const url = new URL(request.url);`; + codegenSource += `const pathname = url.pathname;`; + + codegenSource += `const renderer = ssr();`; + codegenSource += `const root = createHTMLRoot();`; + codegenSource += `const lifetime = new Lifetime();`; + codegenSource += `const context = new ContextScope();`; + codegenSource += `await INTERNAL_entrypoint({ + props: entrypointProps, + loaders, + renderer, + root, + pathname, + context, + lifetime, + });`; + codegenSource += `const streamutil = INTERNAL_createStreamUtility();`; + codegenSource += `const html = printHTML(root);`; + codegenSource += `async function load() {`; + codegenSource += `streamutil.write(html);`; + codegenSource += `let currentSnapshot = structuredClone(root);`; + codegenSource += `context.streaming.updated();`; + codegenSource += `context.streaming.onUpdate(() => {`; + codegenSource += `const codegen = diffInto(currentSnapshot, root);`; + codegenSource += `const code = codegen.getCode();`; + codegenSource += `currentSnapshot = structuredClone(root);`; + codegenSource += "streamutil.write(``);"; + codegenSource += `});`; + codegenSource += `await context.streaming.onDoneLoading;`; + codegenSource += "streamutil.write(``);"; + codegenSource += `streamutil.end();`; + codegenSource += `lifetime.close();`; + codegenSource += `}`; + codegenSource += `load();`; + codegenSource += `return new Response(streamutil.readable.pipeThrough(new TextEncoderStream()), {`; + codegenSource += `status: 200,`; + codegenSource += `headers: { 'Content-Type': 'text/html; charset=utf-8', 'X-Content-Type-Options': 'nosniff', 'Transfer-Encoding': 'chunked', Connection: 'keep-alive', }`; + codegenSource += `});`; + codegenSource += `}`; + } + + const routeId = getRouteId(route.matcher); + const filename = `function-${route.type}-${routeId}`; + const path = await build.writeCodegenned(filename, codegenSource); + + const bundled = await build.bundle({ + target: "server", + inputPaths: { + main: path, + }, + dev: false, + noSplitting: true + }); + + return bundled.outputs.main; + }, + + async run(build) { + using _task = addTask({ + name: "Building for Vercel Build Output API" + }); + + const outputDir = join(build.project.projectDir, ".vercel", "output"); + + await rmdir(outputDir, { recursive: true }).catch(() => { /* ignore */ }); + + const staticDir = join(outputDir, "static"); + const functionsDir = join(outputDir, "functions"); + + // Ensure directories exist + await mkdir(outputDir, { recursive: true }); + await mkdir(staticDir, { recursive: true }); + await mkdir(functionsDir, { recursive: true }); + + // Build client bundle and CSS + await this.buildClientBundle(build); + await this.buildCSS(build); + + // Build individual route functions + const routeFunctions: string[] = []; + for (const route of build.routes) { + const functionPath = await this.buildRouteFunction(build, route); + routeFunctions.push(functionPath); + + // Create function directory in Vercel output + const functionDir = join(functionsDir, `${printRoutePath(route.matcher) || "index"}.func`); + await mkdir(functionDir, { recursive: true }); + + // Copy function file + const functionIndexPath = join(functionDir, "index.js"); + await Bun.write(functionIndexPath, await Bun.file(functionPath).text()); + + // Create .vc-config.json for each function + const vcConfig = { + runtime: "edge", + entrypoint: "index.js" + }; + await Bun.write(join(functionDir, ".vc-config.json"), JSON.stringify(vcConfig, null, 2)); + } + + const config = { + version: 3 + }; + + const configPath = join(outputDir, "config.json"); + await Bun.write(configPath, JSON.stringify(config, null, 2)); + + return { + outputDir, + staticDir, + functionsDir, + configFile: configPath + }; + } + }; +} diff --git a/packages/wormhole/src/build/build.ts b/packages/wormhole/src/build/build.ts index dfa2cfd..63f2d37 100644 --- a/packages/wormhole/src/build/build.ts +++ b/packages/wormhole/src/build/build.ts @@ -73,10 +73,12 @@ export class Build { analyze = Build_analyze; async bundle( - { inputPaths, target, dev = false }: { + { inputPaths, target, dev = false, noSplitting = false, outdir = this.outputPath }: { inputPaths: Record, target: TargetLocation; dev?: boolean; + noSplitting?: boolean; + outdir?: string; } ): Promise<{ outputs: Record; @@ -95,9 +97,9 @@ export class Build { const build = await Bun.build({ plugins: [p], - splitting: true, + splitting: !noSplitting, entrypoints, - outdir: this.outputPath, + outdir, target: target === "server" ? "bun" : "browser", sourcemap: dev ? "inline" : "none", naming: { @@ -111,7 +113,15 @@ export class Build { for (const [id, entry] of Object.entries(inputPaths)) { const name = basename(entry as string); const fileName = name.slice(0, name.lastIndexOf(".")); - const path = join(this.outputPath, fileName + ".js"); + const originalExt = extname(entry as string); + + // Use correct extension based on input file type + let outputExt = ".js"; // default + if (originalExt === ".css") { + outputExt = ".css"; + } + + const path = join(outdir, fileName + outputExt); results[id as Files] = path; } diff --git a/packages/wormhole/src/cli/entry.ts b/packages/wormhole/src/cli/entry.ts index ffa5265..a318b40 100644 --- a/packages/wormhole/src/cli/entry.ts +++ b/packages/wormhole/src/cli/entry.ts @@ -2,11 +2,13 @@ import chalk from "chalk"; import { createPrinter } from "./printer"; import { colors } from "@vortexjs/cli"; import { version } from "../../package.json" assert { type: "json" }; -import { command, parseArgs } from "@vortexjs/args"; +import { command, parseArgs, optional, positional } from "@vortexjs/args"; import { Project } from "~/state"; import { Lifetime } from "@vortexjs/core"; import { DevServer } from "~/dev/dev-server"; import { StatusBoard } from "./statusboard"; +import { Build } from "~/build/build"; +import { VercelAdapter } from "~/build/adapters/vercel"; function showHelp() { const printer = createPrinter(); @@ -28,7 +30,7 @@ function showHelp() { const commands = [ ["wh help", "Show this help command"], ["wh dev", "Start the development server"], - ["wh build [platform]", "Build for a certain platform"] + ["wh build vercel", "Build for Vercel deployment"] ]; const firstColumnWidth = Math.max(...commands.map(c => c[0]!.length)) + 2; @@ -53,7 +55,32 @@ const commands = [ DevServer(state); StatusBoard(state); - }, "dev") + }, "dev"), + command(async ({ platform }: { platform?: string }) => { + const lt = new Lifetime(); + const state = new Project(process.cwd(), lt); + + await state.init(); + + let adapter; + switch (platform) { + case "vercel": + adapter = VercelAdapter(); + break; + default: + console.error(`Unknown platform: ${platform}. Supported platforms: vercel`); + process.exit(1); + } + + const build = new Build(state, adapter); + const result = await build.run(); + + console.log(`Build completed successfully!`); + console.log(`Output directory: ${result.outputDir}`); + console.log(`Static directory: ${result.staticDir}`); + console.log(`Functions directory: ${result.functionsDir}`); + console.log(`Config file: ${result.configFile}`); + }, "build", optional(positional("platform"))) ] export async function cliMain(args: string[]) { diff --git a/packages/wormhole/src/dev/dev-server.ts b/packages/wormhole/src/dev/dev-server.ts index 2566d90..202c7fb 100644 --- a/packages/wormhole/src/dev/dev-server.ts +++ b/packages/wormhole/src/dev/dev-server.ts @@ -9,170 +9,175 @@ 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) => { - 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; - } - } - }); - - 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) => { + 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; + } + } + }); + + 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(); + await this.buildResult; } 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 + }): void; + 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 context = new ContextScope(); + const context = new ContextScope(); - const lifetime = new Lifetime(); + const lifetime = new Lifetime(); - serverEntrypoint.main({ - root, - renderer, - pathname: new URL(request.url).pathname, - context, - lifetime - }); + 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 writer = writable.getWriter(); + const { readable, writable } = new TransformStream(); - writer.write(html); + async function load() { + const writer = writable.getWriter(); - let currentSnapshot = structuredClone(root); + writer.write(html); - context.streaming.onUpdate(() => { - const codegen = diffInto(currentSnapshot, root); + let currentSnapshot = structuredClone(root); - const code = codegen.getCode(); + context.streaming.onUpdate(() => { + const codegen = diffInto(currentSnapshot, root); - currentSnapshot = structuredClone(root); + const code = codegen.getCode(); - writer.write(``); - }); + currentSnapshot = structuredClone(root); - await context.streaming.onDoneLoading; + writer.write(``); + }); - writer.write(``); - writer.close(); - lifetime.close(); + await context.streaming.onDoneLoading; - tags.push("ssr"); + writer.write(``); + writer.close(); + lifetime.close(); + } - return new Response(readable, { - status: serverEntrypoint.isRoute404(new URL(request.url).pathname) ? 404 : 200, - headers: { - 'Content-Type': "text/html" - } - }) + load(); + + tags.push("ssr"); + + 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/index.ts b/packages/wormhole/src/runtime/index.ts index 01b7320..7ce326c 100644 --- a/packages/wormhole/src/runtime/index.ts +++ b/packages/wormhole/src/runtime/index.ts @@ -1,2 +1,3 @@ export * from "~/runtime/entrypoint"; export * from "~/runtime/api"; +export * from "~/runtime/stream"; diff --git a/packages/wormhole/src/runtime/stream.ts b/packages/wormhole/src/runtime/stream.ts new file mode 100644 index 0000000..6d4b1dd --- /dev/null +++ b/packages/wormhole/src/runtime/stream.ts @@ -0,0 +1,29 @@ +export interface StreamUtility { + write(data: string): Promise; + end(): Promise; + readable: ReadableStream; +} + +export function INTERNAL_createStreamUtility(): StreamUtility { + let streamController: ReadableStreamDefaultController; + const readable = new ReadableStream({ + start(controller) { + streamController = controller; + } + }); + return { + write(data: string): Promise { + return new Promise(resolve => { + streamController.enqueue(data); + resolve(); + }); + }, + end(): Promise { + return new Promise(resolve => { + streamController.close(); + resolve(); + }); + }, + readable + }; +}