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
+ };
+}