diff --git a/bun.lock b/bun.lock
index 0bcb172..96ec48f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -136,6 +136,20 @@
"typescript": "catalog:",
},
},
+ "packages/vindicator": {
+ "name": "@vortexjs/vindicator",
+ "version": "0.0.1",
+ "dependencies": {
+ "@vortexjs/common": "workspace:*",
+ },
+ "devDependencies": {
+ "@types/bun": "catalog:",
+ "tsdown": "catalog:",
+ },
+ "peerDependencies": {
+ "typescript": "catalog:",
+ },
+ },
"packages/vortex-cache": {
"name": "@vortexjs/cache",
"version": "0.0.1",
@@ -273,6 +287,7 @@
"@vortexjs/pippin": "workspace:*",
"@vortexjs/pippin-plugin-tailwind": "workspace:",
"@vortexjs/ssr": "workspace:*",
+ "@vortexjs/vindicator": "workspace:*",
"chalk": "catalog:",
},
"devDependencies": {
@@ -571,6 +586,8 @@
"@vortexjs/ssr": ["@vortexjs/ssr@workspace:packages/vortex-ssr"],
+ "@vortexjs/vindicator": ["@vortexjs/vindicator@workspace:packages/vindicator"],
+
"@vortexjs/wormhole": ["@vortexjs/wormhole@workspace:packages/wormhole"],
"ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="],
diff --git a/packages/example-wormhole/src/features/home/index.tsx b/packages/example-wormhole/src/features/home/index.tsx
index d51da56..e83e280 100644
--- a/packages/example-wormhole/src/features/home/index.tsx
+++ b/packages/example-wormhole/src/features/home/index.tsx
@@ -5,80 +5,79 @@ import route, { query } from "@vortexjs/wormhole/route";
import * as v from "valibot";
route("/", {
- page() {
- useAction({
- name: "Show Alert",
- shortcut: "shift+b",
- run() {
- alert("Action triggered!");
- }
- })
+ page() {
+ useAction({
+ name: "Show Alert",
+ shortcut: "shift+b",
+ run() {
+ alert("Action triggered!");
+ }
+ })
- const currentTime = time.use({});
+ const currentTime = time.use({});
- return (
- <>
-
-
- Welcome to Wormhole, {Object.entries(globalThis).length}
-
-
- This is an example app, go to the{" "}
- docs, current time is {currentTime}
-
-
- >
- );
- },
- layout({ children }) {
- return (
- <>
- Wormhole Example
- {children}
- >
- );
- },
- notFound() {
- return (
- <>
- 404 not found
- >
- )
- }
+ return (
+ <>
+
+
+ Welcome to Wormhole, {Object.entries(globalThis).length}
+
+
+ This is an example app, go to the{" "}
+ docs, current time is {currentTime}
+
+
+ >
+ );
+ },
+ layout({ children }) {
+ return (
+ <>
+ Wormhole Example
+ {children}
+ >
+ );
+ },
+ notFound() {
+ return (
+ <>
+ 404 not found
+ >
+ )
+ }
});
-route("/docs", {
- page({ }) {
- const page = "introduction";
- return (
- <>
- Documentation for {page}
- This is the documentation page for {page}.
- >
- );
- },
+route("/docs/[page]", {
+ page({ page }) {
+ 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;
+ }
})
export const time = query("/api/time", {
- impl() {
- return new Date().toISOString();
- },
- schema: v.object({})
+ impl() {
+ return new Date().toISOString();
+ },
+ schema: v.object({})
});
diff --git a/packages/vindicator/.gitignore b/packages/vindicator/.gitignore
new file mode 100644
index 0000000..a14702c
--- /dev/null
+++ b/packages/vindicator/.gitignore
@@ -0,0 +1,34 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+dist
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/packages/vindicator/.npmignore b/packages/vindicator/.npmignore
new file mode 100644
index 0000000..dfe1eec
--- /dev/null
+++ b/packages/vindicator/.npmignore
@@ -0,0 +1,33 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/packages/vindicator/README.md b/packages/vindicator/README.md
new file mode 100644
index 0000000..88a1edc
--- /dev/null
+++ b/packages/vindicator/README.md
@@ -0,0 +1,9 @@
+# Vindicator
+
+Vercel's Build Output API is quite possible the most poorly documented, unintuitive, and downright frustrating protocol I've ever had the displeasure of working with. Vindicator is my attempt to make it a little less painful.
+
+## What does it do?
+
+Vindicator takes nice, clean steps for your routing code, and converts them into the bizarre, convoluted JSON format that Vercel expects.
+
+Polar is to Stripe as Vindicator is to Vercel's Build Output API.
diff --git a/packages/vindicator/package.json b/packages/vindicator/package.json
new file mode 100644
index 0000000..650c912
--- /dev/null
+++ b/packages/vindicator/package.json
@@ -0,0 +1,29 @@
+{
+ "name": "@vortexjs/vindicator",
+ "type": "module",
+ "license": "MIT-0",
+ "repository": {
+ "url": "https://github.com/andylovescode/vortex"
+ },
+ "devDependencies": {
+ "@types/bun": "catalog:",
+ "tsdown": "catalog:"
+ },
+ "dependencies": {
+ "@vortexjs/common": "workspace:*"
+ },
+ "peerDependencies": {
+ "typescript": "catalog:"
+ },
+ "scripts": {
+ "build": "tsdown ./src/index.ts --format esm --dts --out-dir dist"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "require": "./dist/index.cjs"
+ }
+ },
+ "version": "0.0.1"
+}
diff --git a/packages/vindicator/src/index.ts b/packages/vindicator/src/index.ts
new file mode 100644
index 0000000..6100d36
--- /dev/null
+++ b/packages/vindicator/src/index.ts
@@ -0,0 +1,28 @@
+import type { Config, Route } from "./types-vercel";
+import type { MatchStep } from "./types-vindicator";
+
+export * as Vercel from "./types-vercel";
+export * from "./types-vindicator";
+
+export function vindicate(props: { steps: MatchStep[] }): Config {
+ const routes: Route[] = [];
+
+ for (const step of props.steps) {
+ if (step.type === "all-filesystem") {
+ routes.push({
+ handle: "filesystem",
+ });
+ }
+ if (step.type === "route") {
+ routes.push({
+ src: step.path,
+ dest: step.func,
+ });
+ }
+ }
+
+ return {
+ routes,
+ version: 3,
+ };
+}
diff --git a/packages/vindicator/src/types-vercel.ts b/packages/vindicator/src/types-vercel.ts
new file mode 100644
index 0000000..5689499
--- /dev/null
+++ b/packages/vindicator/src/types-vercel.ts
@@ -0,0 +1,141 @@
+// A message to Triangle Company
+// Your product is great, but why the fuck is the Build Output API so bad.
+// There's no official types, I had to copy paste this from the docs.
+
+export interface Config {
+ version: 3;
+ routes?: Route[];
+ images?: ImagesConfig;
+ wildcard?: WildcardConfig;
+ overrides?: OverrideConfig;
+ cache?: string[];
+ framework?: Framework;
+ crons?: CronsConfig;
+}
+
+export type Route = Source | Handler;
+
+export interface Source {
+ src: string;
+ dest?: string;
+ headers?: Record;
+ methods?: string[];
+ continue?: boolean;
+ caseSensitive?: boolean;
+ check?: boolean;
+ status?: number;
+ has?: HasField;
+ missing?: HasField;
+ locale?: Locale;
+ middlewareRawSrc?: string[];
+ middlewarePath?: string;
+ mitigate?: Mitigate;
+ transforms?: Transform[];
+}
+
+export interface Handler {
+ handle: HandleValue;
+ src?: string;
+ dest?: string;
+ status?: number;
+}
+
+export type HandleValue =
+ | "rewrite"
+ | "filesystem"
+ | "resource"
+ | "miss"
+ | "hit"
+ | "error";
+
+export interface MatchableValue {
+ eq?: string | number;
+ neq?: string;
+ inc?: string[];
+ ninc?: string[];
+ pre?: string;
+ suf?: string;
+ re?: string;
+ gt?: number;
+ gte?: number;
+ lt?: number;
+ lte?: number;
+}
+
+export type HasField = Array<
+ | { type: "host"; value: string | MatchableValue }
+ | {
+ type: "header" | "cookie" | "query";
+ key: string;
+ value?: string | MatchableValue;
+ }
+>;
+
+export interface Locale {
+ redirect?: Record;
+ cookie?: string;
+}
+
+export interface Mitigate {
+ action: "challenge" | "deny";
+}
+
+export interface Transform {
+ type: "request.headers" | "request.query" | "response.headers";
+ op: "append" | "set" | "delete";
+ target: { key: string | Omit };
+ args?: string | string[];
+}
+
+export type ImageFormat = "image/avif" | "image/webp";
+
+export interface RemotePattern {
+ protocol?: "http" | "https";
+ hostname: string;
+ port?: string;
+ pathname?: string;
+ search?: string;
+}
+
+export interface LocalPattern {
+ pathname?: string;
+ search?: string;
+}
+
+export interface ImagesConfig {
+ sizes: number[];
+ domains: string[];
+ remotePatterns?: RemotePattern[];
+ localPatterns?: LocalPattern[];
+ qualities?: number[];
+ minimumCacheTTL?: number;
+ formats?: ImageFormat[];
+ dangerouslyAllowSVG?: boolean;
+ contentSecurityPolicy?: string;
+ contentDispositionType?: string;
+}
+
+export interface WildCard {
+ domain: string;
+ value: string;
+}
+
+export type WildcardConfig = WildCard[];
+
+export interface Override {
+ path?: string;
+ contentType?: string;
+}
+
+export type OverrideConfig = Record;
+
+export interface Framework {
+ version: string;
+}
+
+export interface Cron {
+ path: string;
+ schedule: string;
+}
+
+export type CronsConfig = Cron[];
diff --git a/packages/vindicator/src/types-vindicator.ts b/packages/vindicator/src/types-vindicator.ts
new file mode 100644
index 0000000..d7649b8
--- /dev/null
+++ b/packages/vindicator/src/types-vindicator.ts
@@ -0,0 +1,13 @@
+export type MatchStep = FilesystemMatchStep | RewriteMatchStep;
+export interface FilesystemMatchStep {
+ type: "all-filesystem";
+}
+export interface RewriteMatchStep {
+ type: "route";
+ path: string;
+ func: string;
+ /**
+ * @default false
+ */
+ runNext?: boolean;
+}
diff --git a/packages/vindicator/tsconfig.json b/packages/vindicator/tsconfig.json
new file mode 100644
index 0000000..d715a95
--- /dev/null
+++ b/packages/vindicator/tsconfig.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext", "DOM"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ },
+ "include": ["src/**/*", "test/**/*"]
+}
diff --git a/packages/wormhole/package.json b/packages/wormhole/package.json
index 46b4761..c360c7a 100644
--- a/packages/wormhole/package.json
+++ b/packages/wormhole/package.json
@@ -25,6 +25,7 @@
"@vortexjs/cli": "workspace:*",
"@vortexjs/intrinsics": "workspace:*",
"@vortexjs/args": "workspace:*",
+ "@vortexjs/vindicator": "workspace:*",
"chalk": "catalog:"
},
"peerDependencies": {
diff --git a/packages/wormhole/src/build/adapters/vercel.ts b/packages/wormhole/src/build/adapters/vercel.ts
index c344e3f..5398f74 100644
--- a/packages/wormhole/src/build/adapters/vercel.ts
+++ b/packages/wormhole/src/build/adapters/vercel.ts
@@ -5,78 +5,82 @@ import { addTask } from "~/cli/statusboard";
import { join, dirname } from "node:path";
import { mkdir, rmdir } from "node:fs/promises";
import { printRoutePath, type RoutePath } from "../router";
+import { vindicate, type MatchStep } from "@vortexjs/vindicator";
export interface VercelAdapterResult {
- outputDir: string;
- staticDir: string;
- functionsDir: string;
- configFile: string;
+ 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;
+ 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';
+ return printRoutePath(matcher).replaceAll(/\\|\//gi, "-").replaceAll(" ", "-") || 'index';
}
+type HandlePhase = "filesystem" | "hit" | "miss" | "error" | "rewrite";
+type VercelRoute = { src: string, dest: string, check?: boolean } | { handle: HandlePhase };
+
export function VercelAdapter(): VercelAdapter {
- return {
- async buildClientBundle(build: Build) {
- using _task = addTask({
- name: "Building client bundle for Vercel"
- });
+ return {
+ async buildClientBundle(build: Build) {
+ using _task = addTask({
+ name: "Building client bundle for Vercel"
+ });
- let codegenSource = "";
+ let codegenSource = "";
- codegenSource += `import { INTERNAL_entrypoint } from "@vortexjs/wormhole";`;
- codegenSource += `import { Lifetime } from "@vortexjs/core";`;
- codegenSource += `import { html } from "@vortexjs/dom";`;
+ codegenSource += `import { INTERNAL_entrypoint } from "@vortexjs/wormhole";`;
+ codegenSource += `import { Lifetime } from "@vortexjs/core";`;
+ codegenSource += `import { html } from "@vortexjs/dom";`;
- const imports: Export[] = [];
+ 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;
- }
+ 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,
- }))
- };
+ 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 += `const entrypointProps = JSON.parse(${JSON.stringify(JSON.stringify(entrypointProps))});`;
- codegenSource += `function main(props) {`;
+ codegenSource += `function main(props) {`;
- codegenSource += 'const loaders = [';
+ codegenSource += 'const loaders = [';
- for (const exp of imports) {
- const reexporterName = "proxy-" + Bun.hash(`${exp.file}-${exp.name}`).toString(36);
+ 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)}`);
+ 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 += `(async () => (await import(${JSON.stringify(path)}))[${JSON.stringify(exp.name)}]),`;
+ }
- codegenSource += '];';
+ codegenSource += '];';
- codegenSource += `const renderer = html();`;
- codegenSource += `const root = document.documentElement;`;
+ codegenSource += `const renderer = html();`;
+ codegenSource += `const root = document.documentElement;`;
- codegenSource += `return INTERNAL_entrypoint({
+ codegenSource += `return INTERNAL_entrypoint({
props: entrypointProps,
loaders,
renderer,
@@ -87,155 +91,155 @@ export function VercelAdapter(): VercelAdapter {
supplement: props.supplement
});`;
- codegenSource += `}`;
+ codegenSource += `}`;
- codegenSource += `window.wormhole = {};`;
- codegenSource += `window.wormhole.hydrate = main;`;
-
- 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(lifetime);`;
- codegenSource += `await INTERNAL_entrypoint({
+ codegenSource += `window.wormhole = {};`;
+ codegenSource += `window.wormhole.hydrate = main;`;
+
+ 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(lifetime);`;
+ codegenSource += `await INTERNAL_entrypoint({
props: entrypointProps,
loaders,
renderer,
@@ -244,103 +248,133 @@ export function VercelAdapter(): VercelAdapter {
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
- };
- }
- };
+ 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);
+
+ let currentPhase: HandlePhase | null = null;
+ let vindicatorSteps: MatchStep[] = [];
+
+ vindicatorSteps.push({ type: "all-filesystem" });
+
+ // 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));
+
+ let srcStr = "";
+ let destStr = "";
+
+ for (const seg of route.matcher) {
+ if (seg.type === "static") {
+ srcStr += `/${seg.match}`;
+ destStr += `/${seg.match}`;
+ } else if (seg.type === "slug") {
+ srcStr += "/[^/]*";
+ destStr += `/[${seg.name}]`;
+ } else if (seg.type === "spread") {
+ srcStr += "/.*";
+ destStr += `/[...${seg.name}]`;
+ }
+ }
+
+ if (srcStr === "") srcStr = "/";
+ if (destStr === "") destStr = "/";
+
+ vindicatorSteps.push({
+ type: "route",
+ path: srcStr,
+ func: destStr,
+ })
+ }
+
+ const config = vindicate({
+ steps: vindicatorSteps,
+ })
+
+ const configPath = join(outputDir, "config.json");
+ await Bun.write(configPath, JSON.stringify(config, null, 2));
+
+ return {
+ outputDir,
+ staticDir,
+ functionsDir,
+ configFile: configPath
+ };
+ }
+ };
}