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