From 70be870853b83fefa82c9b110061050dd8ba2fc1 Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Tue, 30 Jun 2026 22:44:55 +0000 Subject: [PATCH 1/9] feat: add Vercel deployment adapter Add @marko/run-adapter-vercel for deploying Marko Run apps to Vercel via the Build Output API (v3), emitting a `.vercel/output` directory. - Node.js Serverless Functions by default; `{ edge: true }` targets the Edge runtime (webworker SSR target with the edge-light export condition) - Node mode wraps the fetch handler with createMiddleware as the function handler; static assets are served by Vercel's filesystem layer - Generates functions/index.func with the appropriate .vc-config.json, copies static assets to output/static, and writes a routing config.json - Exposes platform info per runtime via VercelEdgePlatformInfo / VercelNodePlatformInfo - Adds run-package test fixtures for both modes (preview skipped; it needs the Vercel CLI) --- .changeset/vercel-adapter.md | 5 + package-lock.json | 447 ++++++++++++++++++ packages/adapters/vercel/README.md | 75 +++ packages/adapters/vercel/package.json | 33 ++ packages/adapters/vercel/scripts/build.ts | 51 ++ .../adapters/vercel/scripts/importMetaURL.js | 2 + .../adapters/vercel/src/default-edge-entry.ts | 13 + .../adapters/vercel/src/default-node-entry.ts | 7 + packages/adapters/vercel/src/index.ts | 194 ++++++++ packages/adapters/vercel/src/types.ts | 20 + packages/adapters/vercel/tsconfig.json | 10 + .../fixtures/vercel-adapter-edge/.gitignore | 2 + .../.marko-run/routes.d.ts | 36 ++ .../__snapshots__/dev.expected.md | 35 ++ .../fixtures/vercel-adapter-edge/package.json | 5 + .../src/components/counter.marko | 11 + .../src/routes/+page.marko | 12 + .../vercel-adapter-edge/test.config.ts | 13 + .../vercel-adapter-edge/tsconfig.json | 4 + .../vercel-adapter-edge/vite.config.ts | 10 + .../fixtures/vercel-adapter-node/.gitignore | 2 + .../.marko-run/routes.d.ts | 36 ++ .../__snapshots__/dev.expected.md | 35 ++ .../fixtures/vercel-adapter-node/package.json | 5 + .../src/components/counter.marko | 11 + .../src/routes/+page.marko | 12 + .../vercel-adapter-node/test.config.ts | 13 + .../vercel-adapter-node/tsconfig.json | 4 + .../vercel-adapter-node/vite.config.ts | 10 + 29 files changed, 1113 insertions(+) create mode 100644 .changeset/vercel-adapter.md create mode 100644 packages/adapters/vercel/README.md create mode 100644 packages/adapters/vercel/package.json create mode 100644 packages/adapters/vercel/scripts/build.ts create mode 100644 packages/adapters/vercel/scripts/importMetaURL.js create mode 100644 packages/adapters/vercel/src/default-edge-entry.ts create mode 100644 packages/adapters/vercel/src/default-node-entry.ts create mode 100644 packages/adapters/vercel/src/index.ts create mode 100644 packages/adapters/vercel/src/types.ts create mode 100644 packages/adapters/vercel/tsconfig.json create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/.gitignore create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/.marko-run/routes.d.ts create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/dev.expected.md create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/package.json create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/src/components/counter.marko create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/src/routes/+page.marko create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/tsconfig.json create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/vite.config.ts diff --git a/.changeset/vercel-adapter.md b/.changeset/vercel-adapter.md new file mode 100644 index 00000000..7ef227d4 --- /dev/null +++ b/.changeset/vercel-adapter.md @@ -0,0 +1,5 @@ +--- +"@marko/run-adapter-vercel": minor +--- + +Add a Vercel adapter for deploying @marko/run apps to Vercel Node.js Serverless Functions (default) or Edge Functions via the Build Output API. diff --git a/package-lock.json b/package-lock.json index a01363b5..35490d7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2497,6 +2497,10 @@ "resolved": "packages/adapters/static", "link": true }, + "node_modules/@marko/run-adapter-vercel": { + "resolved": "packages/adapters/vercel", + "link": true + }, "node_modules/@marko/run-explorer": { "resolved": "packages/explorer", "link": true @@ -14038,6 +14042,381 @@ } } }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/netlify-cli/node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, "node_modules/netlify-cli/node_modules/@sec-ant/readable-stream": { "version": "0.4.1", "dev": true, @@ -17283,6 +17662,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/netlify-cli/node_modules/fsevents": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/netlify-cli/node_modules/function-bind": { "version": "1.1.2", "dev": true, @@ -21065,6 +21457,53 @@ "dev": true, "license": "MIT" }, + "node_modules/netlify-cli/node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, "node_modules/netlify-cli/node_modules/router": { "version": "2.2.0", "dev": true, @@ -25590,6 +26029,14 @@ "@marko/run": "^0.7.7||^0.8||^0.9||^0.10" } }, + "packages/adapters/vercel": { + "name": "@marko/run-adapter-vercel", + "version": "0.1.0", + "license": "MIT", + "peerDependencies": { + "@marko/run": "^0.7||^0.8||^0.9||^0.10" + } + }, "packages/explorer": { "name": "@marko/run-explorer", "version": "2.0.3", diff --git a/packages/adapters/vercel/README.md b/packages/adapters/vercel/README.md new file mode 100644 index 00000000..fca468bd --- /dev/null +++ b/packages/adapters/vercel/README.md @@ -0,0 +1,75 @@ +

+ + + + + Marko Run Logo + +
+ @marko/run-adapter-vercel +
+

+ +Preview and deploy [@marko/run](../../run/README.md) apps to Vercel Functions/Edge Functions + +## Installation + +```sh +npm install @marko/run-adapter-vercel +``` + +## Usage + +In your application's Vite config file (eg. `vite.config.js`), import and register this adapter with the `@marko/run` Vite plugin: + +```ts +import { defineConfig } from "vite"; +import marko from "@marko/run/vite"; +import vercelAdapter from "@marko/run-adapter-vercel"; + +export default defineConfig({ + plugins: [ + marko({ + adapter: vercelAdapter(), + }), + ], +}); +``` + +Running `marko-run build` produces a [Vercel Build Output API](https://vercel.com/docs/build-output-api/v3) directory at `.vercel/output`, which Vercel uses directly when deploying. Set your project's build command to `marko-run build` and Vercel will pick it up — no output directory configuration required. + +## Edge Functions + +This adapter can be configured to build for Vercel [Edge Functions](https://vercel.com/docs/functions/edge-functions) instead of the default Node.js Serverless Functions: + +```ts +import { defineConfig } from "vite"; +import marko from "@marko/run/vite"; +import vercelAdapter from "@marko/run-adapter-vercel"; + +export default defineConfig({ + plugins: [ + marko({ + adapter: vercelAdapter({ edge: true }), + }), + ], +}); +``` + +## Platform info + +The route handler `platform` argument differs by runtime. + +For Edge Functions it exposes the request context: + +```ts +import type { VercelEdgePlatformInfo } from "@marko/run-adapter-vercel"; + +export const GET = (context, { platform }) => { + const { waitUntil } = platform as VercelEdgePlatformInfo; + waitUntil(logRequest()); + return new Response("Hello from the edge!"); +}; +``` + +For Node.js Serverless Functions it exposes the underlying Node request and response objects (`VercelNodePlatformInfo`). diff --git a/packages/adapters/vercel/package.json b/packages/adapters/vercel/package.json new file mode 100644 index 00000000..665fe43f --- /dev/null +++ b/packages/adapters/vercel/package.json @@ -0,0 +1,33 @@ +{ + "name": "@marko/run-adapter-vercel", + "version": "0.1.0", + "description": "Preview and deploy @marko/run apps to Vercel Functions/Edge Functions", + "keywords": [ + "marko" + ], + "homepage": "https://github.com/marko-js/run/tree/main/packages/adapters/vercel", + "repository": { + "type": "git", + "url": "https://github.com/marko-js/run.git" + }, + "license": "MIT", + "author": "Ryan Turnquist ", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "types": "./src/index.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -b && tsx scripts/build.ts", + "test": "" + }, + "peerDependencies": { + "@marko/run": "^0.7||^0.8||^0.9||^0.10" + }, + "logo": { + "url": "https://github.com/marko-js/run/raw/main/assets/marko-run.png" + } +} diff --git a/packages/adapters/vercel/scripts/build.ts b/packages/adapters/vercel/scripts/build.ts new file mode 100644 index 00000000..2d7fcb91 --- /dev/null +++ b/packages/adapters/vercel/scripts/build.ts @@ -0,0 +1,51 @@ +import { build, BuildOptions } from "esbuild"; +import path from "path"; + +const srcdir = path.resolve("src"); +const outdir = path.resolve("dist"); + +const opts: BuildOptions = { + entryPoints: ["src/index.ts"], + outdir, + outbase: srcdir, + platform: "node", + target: ["node14"], + bundle: true, + plugins: [ + { + name: "external-modules", + setup(build) { + build.onResolve( + { filter: /^[^./]|^\.[^./]|^\.\.[^/]/ }, + ({ path }) => ({ + path, + external: true, + }), + ); + }, + }, + ], +}; + +await Promise.all([ + build({ + ...opts, + format: "cjs", + outExtension: { ".js": ".cjs" }, + define: { + "import.meta.url": "__importMetaURL", + }, + inject: ["./scripts/importMetaURL.js"], + }), + build({ + ...opts, + format: "esm", + splitting: true, + }), + build({ + ...opts, + entryPoints: ["src/default-edge-entry.ts", "src/default-node-entry.ts"], + format: "esm", + outExtension: { ".js": ".mjs" }, + }), +]); diff --git a/packages/adapters/vercel/scripts/importMetaURL.js b/packages/adapters/vercel/scripts/importMetaURL.js new file mode 100644 index 00000000..c993f7f5 --- /dev/null +++ b/packages/adapters/vercel/scripts/importMetaURL.js @@ -0,0 +1,2 @@ +import { pathToFileURL } from "url"; +export const __importMetaURL = pathToFileURL(__filename); diff --git a/packages/adapters/vercel/src/default-edge-entry.ts b/packages/adapters/vercel/src/default-edge-entry.ts new file mode 100644 index 00000000..66e24e68 --- /dev/null +++ b/packages/adapters/vercel/src/default-edge-entry.ts @@ -0,0 +1,13 @@ +import { fetch } from "@marko/run/router"; + +import type { VercelEdgePlatformInfo } from "./types"; + +export default async function ( + request: Request, + context: VercelEdgePlatformInfo, +) { + return ( + (await fetch(request, context)) || + new Response(null, { status: 404 }) + ); +} diff --git a/packages/adapters/vercel/src/default-node-entry.ts b/packages/adapters/vercel/src/default-node-entry.ts new file mode 100644 index 00000000..8fbcfec4 --- /dev/null +++ b/packages/adapters/vercel/src/default-node-entry.ts @@ -0,0 +1,7 @@ +import { createMiddleware } from "@marko/run/adapter/middleware"; +import { fetch } from "@marko/run/router"; + +// Vercel's Node.js runtime invokes the default export as a standard +// `(req, res)` request listener. Static assets are served by Vercel's +// filesystem layer, so the function only needs to handle dynamic routes. +export default createMiddleware(fetch); diff --git a/packages/adapters/vercel/src/index.ts b/packages/adapters/vercel/src/index.ts new file mode 100644 index 00000000..8f03a033 --- /dev/null +++ b/packages/adapters/vercel/src/index.ts @@ -0,0 +1,194 @@ +import baseAdapter, { type Adapter } from "@marko/run/adapter"; +import { execSync, spawn } from "child_process"; +import fs from "fs/promises"; +import path from "path"; +import { fileURLToPath } from "url"; + +export type { VercelEdgePlatformInfo, VercelNodePlatformInfo } from "./types"; + +const __dirname = fileURLToPath(path.dirname(import.meta.url)); +const defaultEdgeEntry = path.join(__dirname, "default-edge-entry"); +const defaultNodeEntry = path.join(__dirname, "default-node-entry"); + +/** Used for the generated Node Serverless Function. */ +const NODE_RUNTIME = "nodejs20.x"; + +export interface Options { + /** + * Build for Vercel's Edge runtime instead of the Node.js Serverless + * runtime. Edge Functions run on a web-standard runtime at the edge. + */ + edge?: boolean; +} + +export default function vercelAdapter(options: Options = {}): Adapter { + const { edge = false } = options; + const { startDev } = baseAdapter(); + const defaultEntry = edge ? defaultEdgeEntry : defaultNodeEntry; + + return { + name: "vercel-adapter", + + viteConfig(config) { + if (config.build?.ssr) { + return edge + ? { + ssr: { + target: "webworker", + resolve: { + dedupe: ["marko"], + conditions: [ + "edge-light", + "worker", + "browser", + "import", + "require", + "production", + "default", + ], + }, + noExternal: true, + }, + } + : { + ssr: { + target: "node", + noExternal: true, + }, + }; + } + }, + + getEntryFile() { + return defaultEntry; + }, + + startDev(event) { + return startDev!({ + ...event, + entry: event.entry === defaultEntry ? undefined : event.entry, + }); + }, + + async startPreview({ options: previewOptions }) { + assertVercelCLI(); + + const { port = 3000, cwd } = previewOptions; + + const args = ["dev", "--listen", port.toString()]; + args.push(...parseVercelArgs(previewOptions.args)); + + const proc = spawn("vercel", args, { + cwd, + env: process.env, + shell: true, + }); + + if (process.env.NODE_ENV !== "test") { + proc.stdout.pipe(process.stdout); + } + proc.stderr.pipe(process.stderr); + + return { + port, + close() { + proc.unref(); + proc.kill(); + }, + }; + }, + + async buildEnd({ config, builtEntries }) { + const serverEntry = builtEntries[0]; + if (!serverEntry) { + return; + } + + // During the client build `outDir` points at the static assets dir + // (`/public`); its parent is the SSR build dir that holds the + // built server entry. + const publicDir = path.resolve(config.root, config.build.outDir); + + // Assemble a Vercel Build Output API (v3) directory. Vercel uses this + // directly when it exists after the build. + // https://vercel.com/docs/build-output-api/v3 + const outputDir = path.join(config.root, ".vercel", "output"); + const staticDir = path.join(outputDir, "static"); + const funcDir = path.join(outputDir, "functions", "index.func"); + + await fs.rm(outputDir, { recursive: true, force: true }); + await fs.mkdir(funcDir, { recursive: true }); + await fs.cp(publicDir, staticDir, { recursive: true }); + + await fs.rename(serverEntry, path.join(funcDir, "index.js")); + await fs.writeFile( + path.join(funcDir, "package.json"), + JSON.stringify({ type: "module" }, null, 2), + ); + await fs.writeFile( + path.join(funcDir, ".vc-config.json"), + JSON.stringify( + edge + ? { runtime: "edge", entrypoint: "index.js" } + : { + runtime: NODE_RUNTIME, + handler: "index.js", + launcherType: "Nodejs", + shouldAddHelpers: false, + }, + null, + 2, + ), + ); + + await fs.writeFile( + path.join(outputDir, "config.json"), + JSON.stringify( + { + version: 3, + routes: [ + { handle: "filesystem" }, + { src: "/(.*)", dest: "/index" }, + ], + }, + null, + 2, + ), + ); + }, + + typeInfo(writer) { + if (edge) { + writer( + `import type { VercelEdgePlatformInfo } from '@marko/run-adapter-vercel';`, + ); + return "VercelEdgePlatformInfo"; + } + writer( + `import type { VercelNodePlatformInfo } from '@marko/run-adapter-vercel';`, + ); + return "VercelNodePlatformInfo"; + }, + }; +} + +const devFlags = new RegExp( + ["debug", "env=.+", "build-env=.+", "yes", "token=.+", "scope=.+"] + .map((flag) => `--${flag}`) + .join("|"), +); + +function parseVercelArgs(args: string[]) { + return args.filter((arg) => devFlags.test(arg)); +} + +function assertVercelCLI() { + try { + execSync("vercel --version"); + } catch (error) { + console.warn( + `Vercel CLI not found. Please install it with \`npm install -g vercel\``, + ); + process.exit(1); + } +} diff --git a/packages/adapters/vercel/src/types.ts b/packages/adapters/vercel/src/types.ts new file mode 100644 index 00000000..36023c2c --- /dev/null +++ b/packages/adapters/vercel/src/types.ts @@ -0,0 +1,20 @@ +import type { NodePlatformInfo } from "@marko/run/adapter"; + +/** + * The platform object passed to Marko Run route handlers when deployed as a + * Vercel Edge Function. Matches the Edge runtime's request context. + */ +export interface VercelEdgePlatformInfo { + /** + * Extend the lifetime of the function until the given promise settles, + * without blocking the response. + */ + waitUntil(promise: Promise): void; +} + +/** + * The platform object passed to Marko Run route handlers when deployed as a + * Vercel (Node.js) Serverless Function. Exposes the underlying Node request + * and response objects. + */ +export interface VercelNodePlatformInfo extends NodePlatformInfo {} diff --git a/packages/adapters/vercel/tsconfig.json b/packages/adapters/vercel/tsconfig.json new file mode 100644 index 00000000..ed8bbf1a --- /dev/null +++ b/packages/adapters/vercel/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "include": ["src/**/*"], + "exclude": ["src/**/__tests__/**/*", "src/default-*-entry.ts"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/.tsbuildinfo" + } +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore new file mode 100644 index 00000000..c49ff664 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore @@ -0,0 +1,2 @@ +dist +.vercel diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts new file mode 100644 index 00000000..ddf25f4f --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts @@ -0,0 +1,36 @@ +/* + WARNING: This file is automatically generated and any changes made to it will be overwritten without warning. + Do NOT manually edit this file or your changes will be lost. +*/ + +import { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform } from "@marko/run/namespace"; +import type * as Run from "@marko/run"; +import type { VercelEdgePlatformInfo } from '@marko/run-adapter-vercel'; + +declare module "@marko/run" { + interface Platform extends VercelEdgePlatformInfo {} + + interface AppData extends Run.DefineApp<{ + routes: { + "/": { verb: "get"; }; + } + }> {} +} + +declare module "../src/routes/+page.marko" { + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = Run.Routes["/"]; + export type Context = Run.MultiRouteContext & Marko.Global; + export type Handler = Run.HandlerLike; + export type GET = Run.HandlerLike; + export type HEAD = Run.HandlerLike; + export type POST = Run.HandlerLike; + export type PUT = Run.HandlerLike; + export type DELETE = Run.HandlerLike; + export type PATCH = Run.HandlerLike; + export type OPTIONS = Run.HandlerLike; + /** @deprecated use `((context, next) => { ... }) satisfies MarkoRun.Handler` instead */ + export const route: Run.HandlerTypeFn; + } +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md new file mode 100644 index 00000000..1ec2ae91 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md @@ -0,0 +1,35 @@ +# Loading + +```html + +``` + +# Step 0 +ctx=>click(ctx) + +```diff +- 0 ++ 1 + +``` + +# Step 1 +ctx=>click(ctx) + +```diff +- 1 ++ 2 + +``` + +# Step 2 +ctx=>click(ctx) + +```diff +- 2 ++ 3 + +``` + diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json new file mode 100644 index 00000000..f123febd --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json @@ -0,0 +1,5 @@ +{ + "name": "vercel-adapter-edge", + "version": "1.0.0", + "scripts": {} +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko new file mode 100644 index 00000000..436a9eb5 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko @@ -0,0 +1,11 @@ +class { + declare state: { count: number }; + onCreate() { + this.state = { count: 0 }; + } + handleIncrement() { + this.state.count++; + } +} + + \ No newline at end of file diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko new file mode 100644 index 00000000..5dd8370a --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko @@ -0,0 +1,12 @@ + + + + + @marko/run Test Fixture + + + + + + + diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts new file mode 100644 index 00000000..939b8cd2 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts @@ -0,0 +1,13 @@ +import { Step, StepContext } from "../../main.test"; + +export const steps: Step[] = [ + (ctx) => click(ctx), + (ctx) => click(ctx), + (ctx) => click(ctx), +]; +// Preview requires the Vercel CLI, which isn't available in CI. +export const skip_preview = true; + +async function click({ page }: StepContext) { + await page.click("button"); +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json new file mode 100644 index 00000000..03ff88a5 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig-base.json", + "include": ["src/**/*", ".marko-run/*"], +} \ No newline at end of file diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts new file mode 100644 index 00000000..c6175af5 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts @@ -0,0 +1,10 @@ +import marko from "@marko/run/vite"; +import adapter from "@marko/run-adapter-vercel"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [marko({ adapter: adapter({ edge: true }) })], + server: { + hmr: false, + }, +}); diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/.gitignore b/packages/run/src/__tests__/fixtures/vercel-adapter-node/.gitignore new file mode 100644 index 00000000..c49ff664 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/.gitignore @@ -0,0 +1,2 @@ +dist +.vercel diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/.marko-run/routes.d.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-node/.marko-run/routes.d.ts new file mode 100644 index 00000000..13e057f6 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/.marko-run/routes.d.ts @@ -0,0 +1,36 @@ +/* + WARNING: This file is automatically generated and any changes made to it will be overwritten without warning. + Do NOT manually edit this file or your changes will be lost. +*/ + +import { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform } from "@marko/run/namespace"; +import type * as Run from "@marko/run"; +import type { VercelNodePlatformInfo } from '@marko/run-adapter-vercel'; + +declare module "@marko/run" { + interface Platform extends VercelNodePlatformInfo {} + + interface AppData extends Run.DefineApp<{ + routes: { + "/": { verb: "get"; }; + } + }> {} +} + +declare module "../src/routes/+page.marko" { + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = Run.Routes["/"]; + export type Context = Run.MultiRouteContext & Marko.Global; + export type Handler = Run.HandlerLike; + export type GET = Run.HandlerLike; + export type HEAD = Run.HandlerLike; + export type POST = Run.HandlerLike; + export type PUT = Run.HandlerLike; + export type DELETE = Run.HandlerLike; + export type PATCH = Run.HandlerLike; + export type OPTIONS = Run.HandlerLike; + /** @deprecated use `((context, next) => { ... }) satisfies MarkoRun.Handler` instead */ + export const route: Run.HandlerTypeFn; + } +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/dev.expected.md b/packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/dev.expected.md new file mode 100644 index 00000000..1ec2ae91 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/dev.expected.md @@ -0,0 +1,35 @@ +# Loading + +```html + +``` + +# Step 0 +ctx=>click(ctx) + +```diff +- 0 ++ 1 + +``` + +# Step 1 +ctx=>click(ctx) + +```diff +- 1 ++ 2 + +``` + +# Step 2 +ctx=>click(ctx) + +```diff +- 2 ++ 3 + +``` + diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/package.json b/packages/run/src/__tests__/fixtures/vercel-adapter-node/package.json new file mode 100644 index 00000000..075a7cad --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/package.json @@ -0,0 +1,5 @@ +{ + "name": "vercel-adapter-node", + "version": "1.0.0", + "scripts": {} +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/src/components/counter.marko b/packages/run/src/__tests__/fixtures/vercel-adapter-node/src/components/counter.marko new file mode 100644 index 00000000..436a9eb5 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/src/components/counter.marko @@ -0,0 +1,11 @@ +class { + declare state: { count: number }; + onCreate() { + this.state = { count: 0 }; + } + handleIncrement() { + this.state.count++; + } +} + + \ No newline at end of file diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/src/routes/+page.marko b/packages/run/src/__tests__/fixtures/vercel-adapter-node/src/routes/+page.marko new file mode 100644 index 00000000..5dd8370a --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/src/routes/+page.marko @@ -0,0 +1,12 @@ + + + + + @marko/run Test Fixture + + + + + + + diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts new file mode 100644 index 00000000..939b8cd2 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts @@ -0,0 +1,13 @@ +import { Step, StepContext } from "../../main.test"; + +export const steps: Step[] = [ + (ctx) => click(ctx), + (ctx) => click(ctx), + (ctx) => click(ctx), +]; +// Preview requires the Vercel CLI, which isn't available in CI. +export const skip_preview = true; + +async function click({ page }: StepContext) { + await page.click("button"); +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/tsconfig.json b/packages/run/src/__tests__/fixtures/vercel-adapter-node/tsconfig.json new file mode 100644 index 00000000..03ff88a5 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig-base.json", + "include": ["src/**/*", ".marko-run/*"], +} \ No newline at end of file diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/vite.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-node/vite.config.ts new file mode 100644 index 00000000..6bd8def2 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/vite.config.ts @@ -0,0 +1,10 @@ +import marko from "@marko/run/vite"; +import adapter from "@marko/run-adapter-vercel"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [marko({ adapter: adapter() })], + server: { + hmr: false, + }, +}); From 7ac7e2bd8c8cbb4287036cd9ddc681a7d8a5a88a Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 04:58:37 +0000 Subject: [PATCH 2/9] fix: address review feedback for the Vercel adapter - Add package.toggle.json so publish points exports/types at dist/ - Make the injected import.meta.url shim a string (.href) for the CJS build - Fix the README platform example to read from context.platform - Replace fs.cp() with a recursive copy helper so the adapter works on older Node versions (fs.cp is unavailable before Node 16.7) - Throw instead of exiting the process when the Vercel CLI is missing --- packages/adapters/vercel/README.md | 4 ++-- packages/adapters/vercel/package.toggle.json | 15 ++++++++++++ .../adapters/vercel/scripts/importMetaURL.js | 2 +- packages/adapters/vercel/src/index.ts | 24 +++++++++++++++---- 4 files changed, 37 insertions(+), 8 deletions(-) create mode 100644 packages/adapters/vercel/package.toggle.json diff --git a/packages/adapters/vercel/README.md b/packages/adapters/vercel/README.md index fca468bd..257c913a 100644 --- a/packages/adapters/vercel/README.md +++ b/packages/adapters/vercel/README.md @@ -65,8 +65,8 @@ For Edge Functions it exposes the request context: ```ts import type { VercelEdgePlatformInfo } from "@marko/run-adapter-vercel"; -export const GET = (context, { platform }) => { - const { waitUntil } = platform as VercelEdgePlatformInfo; +export const GET = (context) => { + const { waitUntil } = context.platform as VercelEdgePlatformInfo; waitUntil(logRequest()); return new Response("Hello from the edge!"); }; diff --git a/packages/adapters/vercel/package.toggle.json b/packages/adapters/vercel/package.toggle.json new file mode 100644 index 00000000..a2f5767a --- /dev/null +++ b/packages/adapters/vercel/package.toggle.json @@ -0,0 +1,15 @@ +{ + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "types": "./dist/index.d.ts", + "typesVersions": { + "*": { + "*": ["./dist/index.d.ts"] + } + } +} diff --git a/packages/adapters/vercel/scripts/importMetaURL.js b/packages/adapters/vercel/scripts/importMetaURL.js index c993f7f5..b1277af3 100644 --- a/packages/adapters/vercel/scripts/importMetaURL.js +++ b/packages/adapters/vercel/scripts/importMetaURL.js @@ -1,2 +1,2 @@ import { pathToFileURL } from "url"; -export const __importMetaURL = pathToFileURL(__filename); +export const __importMetaURL = pathToFileURL(__filename).href; diff --git a/packages/adapters/vercel/src/index.ts b/packages/adapters/vercel/src/index.ts index 8f03a033..e702c595 100644 --- a/packages/adapters/vercel/src/index.ts +++ b/packages/adapters/vercel/src/index.ts @@ -118,7 +118,7 @@ export default function vercelAdapter(options: Options = {}): Adapter { await fs.rm(outputDir, { recursive: true, force: true }); await fs.mkdir(funcDir, { recursive: true }); - await fs.cp(publicDir, staticDir, { recursive: true }); + await copyDir(publicDir, staticDir); await fs.rename(serverEntry, path.join(funcDir, "index.js")); await fs.writeFile( @@ -185,10 +185,24 @@ function parseVercelArgs(args: string[]) { function assertVercelCLI() { try { execSync("vercel --version"); - } catch (error) { - console.warn( - `Vercel CLI not found. Please install it with \`npm install -g vercel\``, + } catch { + throw new Error( + "Vercel CLI not found. Please install it with `npm install -g vercel`.", ); - process.exit(1); + } +} + +/** Recursively copy a directory (kept `fs.cp`-free for older Node support). */ +async function copyDir(from: string, to: string) { + await fs.mkdir(to, { recursive: true }); + const entries = await fs.readdir(from, { withFileTypes: true }); + for (const entry of entries) { + const src = path.join(from, entry.name); + const dest = path.join(to, entry.name); + if (entry.isDirectory()) { + await copyDir(src, dest); + } else { + await fs.copyFile(src, dest); + } } } From 3abf6b6a7526d568b891e307fcbcf6fc168cef26 Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 16:05:58 +0000 Subject: [PATCH 3/9] fix: don't spawn the Vercel preview through a shell on POSIX Use `shell: true` only on Windows (where `vercel` is a `.cmd` shim that requires it). On POSIX the CLI is spawned directly, so `close()` kills the CLI itself instead of an orphaned shell wrapper and argument values are no longer re-parsed by the shell. Also handle the child's error event. --- packages/adapters/vercel/src/index.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/adapters/vercel/src/index.ts b/packages/adapters/vercel/src/index.ts index e702c595..b497c423 100644 --- a/packages/adapters/vercel/src/index.ts +++ b/packages/adapters/vercel/src/index.ts @@ -78,10 +78,18 @@ export default function vercelAdapter(options: Options = {}): Adapter { const args = ["dev", "--listen", port.toString()]; args.push(...parseVercelArgs(previewOptions.args)); + // Spawn through a shell only on Windows, where `vercel` is a `.cmd` shim + // that can't be spawned directly. On POSIX, spawning directly means + // `close()` terminates the CLI itself (not a shell wrapper) and avoids + // the shell re-parsing the argument values. const proc = spawn("vercel", args, { cwd, env: process.env, - shell: true, + shell: process.platform === "win32", + }); + + proc.on("error", (err) => { + console.error("Failed to start vercel preview server:", err); }); if (process.env.NODE_ENV !== "test") { From 134494fc239443940fd81599c6a3449bc4c71164 Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 19:15:40 +0000 Subject: [PATCH 4/9] docs: note adapter auto-discovery and add a Deploying section (Vercel) Clarify that Marko Run automatically uses an installed adapter with no Vite config, and add a Deploying section right after installation covering the Git integration and `npm run build` + `npx vercel deploy --prebuilt`. --- packages/adapters/vercel/README.md | 29 +++++++++++++---------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/adapters/vercel/README.md b/packages/adapters/vercel/README.md index 257c913a..12af8aba 100644 --- a/packages/adapters/vercel/README.md +++ b/packages/adapters/vercel/README.md @@ -18,29 +18,26 @@ Preview and deploy [@marko/run](../../run/README.md) apps to Vercel Functions/Ed npm install @marko/run-adapter-vercel ``` -## Usage +That's all the setup required — Marko Run automatically discovers an installed adapter and uses it, so you **don't** need to register it in your Vite config. See [Configuration](#configuration) if you want to build for the Edge runtime instead of the default Node.js Serverless runtime. -In your application's Vite config file (eg. `vite.config.js`), import and register this adapter with the `@marko/run` Vite plugin: +## Deploying -```ts -import { defineConfig } from "vite"; -import marko from "@marko/run/vite"; -import vercelAdapter from "@marko/run-adapter-vercel"; +`marko-run build` produces a [Vercel Build Output API](https://vercel.com/docs/build-output-api/v3) directory at `.vercel/output`, which Vercel deploys directly. -export default defineConfig({ - plugins: [ - marko({ - adapter: vercelAdapter(), - }), - ], -}); +The simplest option is Vercel's Git integration: set your project's build command to `marko-run build` and Vercel picks up `.vercel/output` automatically — no output directory configuration required. + +To deploy from your machine with the [Vercel CLI](https://vercel.com/docs/cli) (`npm i -g vercel`), build and upload the prebuilt output: + +```sh +npm run build +npx vercel deploy --prebuilt ``` -Running `marko-run build` produces a [Vercel Build Output API](https://vercel.com/docs/build-output-api/v3) directory at `.vercel/output`, which Vercel uses directly when deploying. Set your project's build command to `marko-run build` and Vercel will pick it up — no output directory configuration required. +Add `--prod` to deploy to production. -## Edge Functions +## Configuration -This adapter can be configured to build for Vercel [Edge Functions](https://vercel.com/docs/functions/edge-functions) instead of the default Node.js Serverless Functions: +Marko Run uses this adapter automatically, but you can register it in your Vite config (eg. `vite.config.js`) to pass options — for example, to build for Vercel [Edge Functions](https://vercel.com/docs/functions/edge-functions) instead of the default Node.js Serverless Functions: ```ts import { defineConfig } from "vite"; From 4bf01a78c70c28a156823925b375ddf944232d2a Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 19:49:37 +0000 Subject: [PATCH 5/9] fix: serve Vercel build output directly for local preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `marko-run preview` previously shelled out to `vercel dev`, which requires linking to a real Vercel project (hence the `--yes` prompt) and, worse, cannot serve a Build Output API v3 directory at all — it removes `.vercel/output` and rebuilds from source, failing when there is no build script it can run. Replace it with a small in-process server that serves the generated `.vercel/output` directly: static assets from `static/` (the `filesystem` handler) and everything else through the built Node/Edge function, mirroring the routing the adapter's `config.json` describes. Preview now runs entirely locally with no Vercel account, project link, or CLI required. Open sockets are tracked and destroyed on `close()` so keep-alive connections don't block shutdown. The node/edge preview fixtures no longer need to be skipped, so they now exercise this path in CI. --- packages/adapters/vercel/README.md | 11 + packages/adapters/vercel/src/index.ts | 58 +---- packages/adapters/vercel/src/preview.ts | 239 ++++++++++++++++++ .../__snapshots__/preview.expected.md | 35 +++ .../vercel-adapter-edge/test.config.ts | 2 - .../__snapshots__/preview.expected.md | 35 +++ .../vercel-adapter-node/test.config.ts | 2 - 7 files changed, 326 insertions(+), 56 deletions(-) create mode 100644 packages/adapters/vercel/src/preview.ts create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md create mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/preview.expected.md diff --git a/packages/adapters/vercel/README.md b/packages/adapters/vercel/README.md index 12af8aba..0cbf8ae8 100644 --- a/packages/adapters/vercel/README.md +++ b/packages/adapters/vercel/README.md @@ -20,6 +20,17 @@ npm install @marko/run-adapter-vercel That's all the setup required — Marko Run automatically discovers an installed adapter and uses it, so you **don't** need to register it in your Vite config. See [Configuration](#configuration) if you want to build for the Edge runtime instead of the default Node.js Serverless runtime. +## Previewing locally + +After building, `marko-run preview` serves the generated `.vercel/output` locally — static assets are served from disk and everything else is handled by the built function, mirroring how Vercel routes requests in production: + +```sh +npm run build +npm run preview +``` + +This runs entirely on your machine: no Vercel account, project link, or Vercel CLI is required. + ## Deploying `marko-run build` produces a [Vercel Build Output API](https://vercel.com/docs/build-output-api/v3) directory at `.vercel/output`, which Vercel deploys directly. diff --git a/packages/adapters/vercel/src/index.ts b/packages/adapters/vercel/src/index.ts index b497c423..4552ea91 100644 --- a/packages/adapters/vercel/src/index.ts +++ b/packages/adapters/vercel/src/index.ts @@ -1,9 +1,10 @@ import baseAdapter, { type Adapter } from "@marko/run/adapter"; -import { execSync, spawn } from "child_process"; import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; +import { startPreviewServer } from "./preview"; + export type { VercelEdgePlatformInfo, VercelNodePlatformInfo } from "./types"; const __dirname = fileURLToPath(path.dirname(import.meta.url)); @@ -71,39 +72,12 @@ export default function vercelAdapter(options: Options = {}): Adapter { }, async startPreview({ options: previewOptions }) { - assertVercelCLI(); - const { port = 3000, cwd } = previewOptions; - const args = ["dev", "--listen", port.toString()]; - args.push(...parseVercelArgs(previewOptions.args)); - - // Spawn through a shell only on Windows, where `vercel` is a `.cmd` shim - // that can't be spawned directly. On POSIX, spawning directly means - // `close()` terminates the CLI itself (not a shell wrapper) and avoids - // the shell re-parsing the argument values. - const proc = spawn("vercel", args, { - cwd, - env: process.env, - shell: process.platform === "win32", - }); - - proc.on("error", (err) => { - console.error("Failed to start vercel preview server:", err); - }); - - if (process.env.NODE_ENV !== "test") { - proc.stdout.pipe(process.stdout); - } - proc.stderr.pipe(process.stderr); - - return { - port, - close() { - proc.unref(); - proc.kill(); - }, - }; + // The build writes a Vercel Build Output API directory here; serve it + // directly (see `startPreviewServer` for why the Vercel CLI can't). + const outputDir = path.join(cwd, ".vercel", "output"); + return startPreviewServer({ outputDir, port, edge }); }, async buildEnd({ config, builtEntries }) { @@ -180,26 +154,6 @@ export default function vercelAdapter(options: Options = {}): Adapter { }; } -const devFlags = new RegExp( - ["debug", "env=.+", "build-env=.+", "yes", "token=.+", "scope=.+"] - .map((flag) => `--${flag}`) - .join("|"), -); - -function parseVercelArgs(args: string[]) { - return args.filter((arg) => devFlags.test(arg)); -} - -function assertVercelCLI() { - try { - execSync("vercel --version"); - } catch { - throw new Error( - "Vercel CLI not found. Please install it with `npm install -g vercel`.", - ); - } -} - /** Recursively copy a directory (kept `fs.cp`-free for older Node support). */ async function copyDir(from: string, to: string) { await fs.mkdir(to, { recursive: true }); diff --git a/packages/adapters/vercel/src/preview.ts b/packages/adapters/vercel/src/preview.ts new file mode 100644 index 00000000..31c008fb --- /dev/null +++ b/packages/adapters/vercel/src/preview.ts @@ -0,0 +1,239 @@ +import type { SpawnedServer } from "@marko/run/adapter"; +import { copyResponseHeaders, getOrigin } from "@marko/run/adapter/middleware"; +import fs from "fs"; +import { createServer, type IncomingMessage, type ServerResponse } from "http"; +import type { Socket } from "net"; +import path from "path"; +import { pathToFileURL } from "url"; + +import type { VercelEdgePlatformInfo } from "./types"; + +/** A Node.js request listener, as exported by the generated Node function. */ +type NodeHandler = ( + req: IncomingMessage, + res: ServerResponse, + next: (err?: unknown) => void, +) => void | Promise; + +/** A web `fetch`-style handler, as exported by the generated Edge function. */ +type EdgeHandler = ( + request: Request, + context: VercelEdgePlatformInfo, +) => Promise | Response; + +export interface PreviewServerOptions { + /** The `.vercel/output` Build Output API directory produced by the build. */ + outputDir: string; + /** Port to listen on. */ + port: number; + /** Whether the output targets the Edge runtime instead of Node.js. */ + edge: boolean; +} + +// Content types for the handful of extensions a static build emits. Kept as a +// small map rather than a dependency to match the adapter's zero-dep style. +const contentTypes: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".txt": "text/plain; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".avif": "image/avif", + ".ico": "image/x-icon", + ".wasm": "application/wasm", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", +}; + +/** + * Serve a built `.vercel/output` directory locally, without the Vercel CLI. + * + * `vercel dev` cannot serve the Build Output API — it rebuilds from source and + * removes `.vercel/output`. So preview emulates the two rules the adapter's + * generated `config.json` describes: serve a matching static asset if one + * exists (the `filesystem` handler), otherwise invoke the function. + */ +export async function startPreviewServer({ + outputDir, + port, + edge, +}: PreviewServerOptions): Promise { + const staticDir = path.join(outputDir, "static"); + const funcEntry = path.join(outputDir, "functions", "index.func", "index.js"); + + if (!fs.existsSync(funcEntry)) { + throw new Error( + `Vercel build output not found at '${outputDir}'. Run \`marko-run build\` before previewing.`, + ); + } + + const handler: NodeHandler | EdgeHandler = ( + await import(pathToFileURL(funcEntry).href) + ).default; + + const server = createServer((req, res) => { + handleRequest(req, res).catch((err) => { + console.error(err); + if (!res.headersSent) { + res.statusCode = 500; + } + if (!res.writableEnded) { + res.end("Internal Server Error"); + } + }); + }); + + // Track open sockets so `close()` can terminate keep-alive connections; + // otherwise `server.close()` waits indefinitely for idle clients to hang up. + const sockets = new Set(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + + async function handleRequest(req: IncomingMessage, res: ServerResponse) { + const pathname = (req.url || "/").split(/[?#]/, 1)[0]; + const staticFile = await resolveStaticFile(staticDir, pathname); + if (staticFile) { + return serveStaticFile(staticFile, res); + } + return edge + ? invokeEdge(handler as EdgeHandler, req, res) + : invokeNode(handler as NodeHandler, req, res); + } + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, () => { + server.off("error", reject); + resolve(); + }); + }); + + return { + port, + close() { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + for (const socket of sockets) { + socket.destroy(); + } + }); + }, + }; +} + +/** + * Resolve a request path to a file inside the static directory, mirroring + * Vercel's `filesystem` handler: exact match, then `.html`, then a directory + * `index.html`. Returns `undefined` when nothing matches. + */ +async function resolveStaticFile(staticDir: string, pathname: string) { + let decoded: string; + try { + decoded = decodeURIComponent(pathname); + } catch { + return undefined; // malformed percent-encoding + } + + const candidates = + decoded === "" || decoded.endsWith("/") + ? [path.join(decoded, "index.html")] + : [decoded, `${decoded}.html`, path.join(decoded, "index.html")]; + + for (const candidate of candidates) { + const filePath = path.join(staticDir, candidate); + // Guard against `..` escaping the static directory. + if (filePath !== staticDir && !filePath.startsWith(staticDir + path.sep)) { + continue; + } + try { + if ((await fs.promises.stat(filePath)).isFile()) { + return filePath; + } + } catch { + // Not found — try the next candidate. + } + } + + return undefined; +} + +function serveStaticFile(filePath: string, res: ServerResponse) { + const type = contentTypes[path.extname(filePath).toLowerCase()]; + if (type) { + res.setHeader("content-type", type); + } + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(filePath); + stream.on("error", reject); + res.on("close", resolve); + stream.pipe(res); + }); +} + +function invokeNode( + handler: NodeHandler, + req: IncomingMessage, + res: ServerResponse, +) { + // The generated Node function handles everything itself; `next` only runs + // when it declines the request (unmatched route) or bubbles an error. + return handler(req, res, (err) => { + if (res.writableEnded) { + return; + } + if (err) { + console.error(err); + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.statusCode = 404; + res.end("Not Found"); + } + }); +} + +async function invokeEdge( + handler: EdgeHandler, + req: IncomingMessage, + res: ServerResponse, +) { + let body: BodyInit | undefined; + switch (req.method) { + case "POST": + case "PUT": + case "PATCH": + body = req as unknown as ReadableStream; + break; + } + + const request = new Request(new URL(req.url!, getOrigin(req)), { + method: req.method, + headers: req.headers as Record, + body, + // @ts-expect-error: Node requires this for streaming request bodies. + duplex: "half", + }); + + const response = await handler(request, { waitUntil() {} }); + + res.statusCode = response.status; + copyResponseHeaders(res, response.headers); + if (response.body) { + for await (const chunk of response.body as unknown as AsyncIterable) { + res.write(chunk); + } + } + res.end(); +} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md new file mode 100644 index 00000000..1ec2ae91 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md @@ -0,0 +1,35 @@ +# Loading + +```html + +``` + +# Step 0 +ctx=>click(ctx) + +```diff +- 0 ++ 1 + +``` + +# Step 1 +ctx=>click(ctx) + +```diff +- 1 ++ 2 + +``` + +# Step 2 +ctx=>click(ctx) + +```diff +- 2 ++ 3 + +``` + diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts index 939b8cd2..2b2c7170 100644 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts @@ -5,8 +5,6 @@ export const steps: Step[] = [ (ctx) => click(ctx), (ctx) => click(ctx), ]; -// Preview requires the Vercel CLI, which isn't available in CI. -export const skip_preview = true; async function click({ page }: StepContext) { await page.click("button"); diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/preview.expected.md b/packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/preview.expected.md new file mode 100644 index 00000000..1ec2ae91 --- /dev/null +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/__snapshots__/preview.expected.md @@ -0,0 +1,35 @@ +# Loading + +```html + +``` + +# Step 0 +ctx=>click(ctx) + +```diff +- 0 ++ 1 + +``` + +# Step 1 +ctx=>click(ctx) + +```diff +- 1 ++ 2 + +``` + +# Step 2 +ctx=>click(ctx) + +```diff +- 2 ++ 3 + +``` + diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts index 939b8cd2..2b2c7170 100644 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts +++ b/packages/run/src/__tests__/fixtures/vercel-adapter-node/test.config.ts @@ -5,8 +5,6 @@ export const steps: Step[] = [ (ctx) => click(ctx), (ctx) => click(ctx), ]; -// Preview requires the Vercel CLI, which isn't available in CI. -export const skip_preview = true; async function click({ page }: StepContext) { await page.click("button"); From d2dd2a7ff9dc176d8dcf0a855e2eff3f43725d3d Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 20:16:11 +0000 Subject: [PATCH 6/9] feat!: drop Edge runtime support from the Vercel adapter Vercel deprecated Edge Functions in 2025 in favor of the Node.js runtime on Fluid Compute, so the adapter's `edge` option targeted a runtime Vercel is winding down. Remove it and everything downstream: the edge entry, the webworker SSR build config, the edge `.vc-config.json` variant, the `VercelEdgePlatformInfo` type, and the edge preview path. The adapter now takes no options and always builds for the Node.js runtime. The preview server loses its runtime branch and always invokes the built function as a `(req, res)` listener, still serving static assets from the build output first. --- packages/adapters/vercel/README.md | 21 ++--- packages/adapters/vercel/package.json | 2 +- packages/adapters/vercel/scripts/build.ts | 2 +- .../adapters/vercel/src/default-edge-entry.ts | 13 --- packages/adapters/vercel/src/index.ts | 70 ++++---------- packages/adapters/vercel/src/preview.ts | 94 ++++--------------- packages/adapters/vercel/src/types.ts | 12 --- .../fixtures/vercel-adapter-edge/.gitignore | 2 - .../.marko-run/routes.d.ts | 36 ------- .../__snapshots__/dev.expected.md | 35 ------- .../__snapshots__/preview.expected.md | 35 ------- .../fixtures/vercel-adapter-edge/package.json | 5 - .../src/components/counter.marko | 11 --- .../src/routes/+page.marko | 12 --- .../vercel-adapter-edge/test.config.ts | 11 --- .../vercel-adapter-edge/tsconfig.json | 4 - .../vercel-adapter-edge/vite.config.ts | 10 -- 17 files changed, 45 insertions(+), 330 deletions(-) delete mode 100644 packages/adapters/vercel/src/default-edge-entry.ts delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json delete mode 100644 packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts diff --git a/packages/adapters/vercel/README.md b/packages/adapters/vercel/README.md index 0cbf8ae8..42a926ed 100644 --- a/packages/adapters/vercel/README.md +++ b/packages/adapters/vercel/README.md @@ -10,7 +10,7 @@
-Preview and deploy [@marko/run](../../run/README.md) apps to Vercel Functions/Edge Functions +Preview and deploy [@marko/run](../../run/README.md) apps to [Vercel Functions](https://vercel.com/docs/functions) ## Installation @@ -18,7 +18,7 @@ Preview and deploy [@marko/run](../../run/README.md) apps to Vercel Functions/Ed npm install @marko/run-adapter-vercel ``` -That's all the setup required — Marko Run automatically discovers an installed adapter and uses it, so you **don't** need to register it in your Vite config. See [Configuration](#configuration) if you want to build for the Edge runtime instead of the default Node.js Serverless runtime. +That's all the setup required — Marko Run automatically discovers an installed adapter and uses it, so you **don't** need to register it in your Vite config. Builds target Vercel's Node.js runtime. (The Edge runtime is not supported — Vercel has [deprecated Edge Functions](https://vercel.com/docs/functions/runtimes/edge/edge-functions) in favor of the Node.js runtime.) ## Previewing locally @@ -48,7 +48,7 @@ Add `--prod` to deploy to production. ## Configuration -Marko Run uses this adapter automatically, but you can register it in your Vite config (eg. `vite.config.js`) to pass options — for example, to build for Vercel [Edge Functions](https://vercel.com/docs/functions/edge-functions) instead of the default Node.js Serverless Functions: +Marko Run uses this adapter automatically, but you can also register it explicitly in your Vite config (eg. `vite.config.js`): ```ts import { defineConfig } from "vite"; @@ -58,7 +58,7 @@ import vercelAdapter from "@marko/run-adapter-vercel"; export default defineConfig({ plugins: [ marko({ - adapter: vercelAdapter({ edge: true }), + adapter: vercelAdapter(), }), ], }); @@ -66,18 +66,13 @@ export default defineConfig({ ## Platform info -The route handler `platform` argument differs by runtime. - -For Edge Functions it exposes the request context: +The route handler `platform` argument exposes the underlying Node request and response objects: ```ts -import type { VercelEdgePlatformInfo } from "@marko/run-adapter-vercel"; +import type { VercelNodePlatformInfo } from "@marko/run-adapter-vercel"; export const GET = (context) => { - const { waitUntil } = context.platform as VercelEdgePlatformInfo; - waitUntil(logRequest()); - return new Response("Hello from the edge!"); + const { request, response } = context.platform as VercelNodePlatformInfo; + return new Response(`Hello from ${request.url}!`); }; ``` - -For Node.js Serverless Functions it exposes the underlying Node request and response objects (`VercelNodePlatformInfo`). diff --git a/packages/adapters/vercel/package.json b/packages/adapters/vercel/package.json index 665fe43f..0dd17fb4 100644 --- a/packages/adapters/vercel/package.json +++ b/packages/adapters/vercel/package.json @@ -1,7 +1,7 @@ { "name": "@marko/run-adapter-vercel", "version": "0.1.0", - "description": "Preview and deploy @marko/run apps to Vercel Functions/Edge Functions", + "description": "Preview and deploy @marko/run apps to Vercel Functions", "keywords": [ "marko" ], diff --git a/packages/adapters/vercel/scripts/build.ts b/packages/adapters/vercel/scripts/build.ts index 2d7fcb91..9b994a30 100644 --- a/packages/adapters/vercel/scripts/build.ts +++ b/packages/adapters/vercel/scripts/build.ts @@ -44,7 +44,7 @@ await Promise.all([ }), build({ ...opts, - entryPoints: ["src/default-edge-entry.ts", "src/default-node-entry.ts"], + entryPoints: ["src/default-node-entry.ts"], format: "esm", outExtension: { ".js": ".mjs" }, }), diff --git a/packages/adapters/vercel/src/default-edge-entry.ts b/packages/adapters/vercel/src/default-edge-entry.ts deleted file mode 100644 index 66e24e68..00000000 --- a/packages/adapters/vercel/src/default-edge-entry.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { fetch } from "@marko/run/router"; - -import type { VercelEdgePlatformInfo } from "./types"; - -export default async function ( - request: Request, - context: VercelEdgePlatformInfo, -) { - return ( - (await fetch(request, context)) || - new Response(null, { status: 404 }) - ); -} diff --git a/packages/adapters/vercel/src/index.ts b/packages/adapters/vercel/src/index.ts index 4552ea91..3b22b351 100644 --- a/packages/adapters/vercel/src/index.ts +++ b/packages/adapters/vercel/src/index.ts @@ -5,58 +5,28 @@ import { fileURLToPath } from "url"; import { startPreviewServer } from "./preview"; -export type { VercelEdgePlatformInfo, VercelNodePlatformInfo } from "./types"; +export type { VercelNodePlatformInfo } from "./types"; const __dirname = fileURLToPath(path.dirname(import.meta.url)); -const defaultEdgeEntry = path.join(__dirname, "default-edge-entry"); -const defaultNodeEntry = path.join(__dirname, "default-node-entry"); +const defaultEntry = path.join(__dirname, "default-node-entry"); /** Used for the generated Node Serverless Function. */ const NODE_RUNTIME = "nodejs20.x"; -export interface Options { - /** - * Build for Vercel's Edge runtime instead of the Node.js Serverless - * runtime. Edge Functions run on a web-standard runtime at the edge. - */ - edge?: boolean; -} - -export default function vercelAdapter(options: Options = {}): Adapter { - const { edge = false } = options; +export default function vercelAdapter(): Adapter { const { startDev } = baseAdapter(); - const defaultEntry = edge ? defaultEdgeEntry : defaultNodeEntry; return { name: "vercel-adapter", viteConfig(config) { if (config.build?.ssr) { - return edge - ? { - ssr: { - target: "webworker", - resolve: { - dedupe: ["marko"], - conditions: [ - "edge-light", - "worker", - "browser", - "import", - "require", - "production", - "default", - ], - }, - noExternal: true, - }, - } - : { - ssr: { - target: "node", - noExternal: true, - }, - }; + return { + ssr: { + target: "node", + noExternal: true, + }, + }; } }, @@ -77,7 +47,7 @@ export default function vercelAdapter(options: Options = {}): Adapter { // The build writes a Vercel Build Output API directory here; serve it // directly (see `startPreviewServer` for why the Vercel CLI can't). const outputDir = path.join(cwd, ".vercel", "output"); - return startPreviewServer({ outputDir, port, edge }); + return startPreviewServer({ outputDir, port }); }, async buildEnd({ config, builtEntries }) { @@ -110,14 +80,12 @@ export default function vercelAdapter(options: Options = {}): Adapter { await fs.writeFile( path.join(funcDir, ".vc-config.json"), JSON.stringify( - edge - ? { runtime: "edge", entrypoint: "index.js" } - : { - runtime: NODE_RUNTIME, - handler: "index.js", - launcherType: "Nodejs", - shouldAddHelpers: false, - }, + { + runtime: NODE_RUNTIME, + handler: "index.js", + launcherType: "Nodejs", + shouldAddHelpers: false, + }, null, 2, ), @@ -140,12 +108,6 @@ export default function vercelAdapter(options: Options = {}): Adapter { }, typeInfo(writer) { - if (edge) { - writer( - `import type { VercelEdgePlatformInfo } from '@marko/run-adapter-vercel';`, - ); - return "VercelEdgePlatformInfo"; - } writer( `import type { VercelNodePlatformInfo } from '@marko/run-adapter-vercel';`, ); diff --git a/packages/adapters/vercel/src/preview.ts b/packages/adapters/vercel/src/preview.ts index 31c008fb..9406b939 100644 --- a/packages/adapters/vercel/src/preview.ts +++ b/packages/adapters/vercel/src/preview.ts @@ -1,33 +1,22 @@ import type { SpawnedServer } from "@marko/run/adapter"; -import { copyResponseHeaders, getOrigin } from "@marko/run/adapter/middleware"; import fs from "fs"; import { createServer, type IncomingMessage, type ServerResponse } from "http"; import type { Socket } from "net"; import path from "path"; import { pathToFileURL } from "url"; -import type { VercelEdgePlatformInfo } from "./types"; - -/** A Node.js request listener, as exported by the generated Node function. */ +/** A Node.js request listener, as exported by the generated function. */ type NodeHandler = ( req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void, ) => void | Promise; -/** A web `fetch`-style handler, as exported by the generated Edge function. */ -type EdgeHandler = ( - request: Request, - context: VercelEdgePlatformInfo, -) => Promise | Response; - export interface PreviewServerOptions { /** The `.vercel/output` Build Output API directory produced by the build. */ outputDir: string; /** Port to listen on. */ port: number; - /** Whether the output targets the Edge runtime instead of Node.js. */ - edge: boolean; } // Content types for the handful of extensions a static build emits. Kept as a @@ -66,7 +55,6 @@ const contentTypes: Record = { export async function startPreviewServer({ outputDir, port, - edge, }: PreviewServerOptions): Promise { const staticDir = path.join(outputDir, "static"); const funcEntry = path.join(outputDir, "functions", "index.func", "index.js"); @@ -77,9 +65,8 @@ export async function startPreviewServer({ ); } - const handler: NodeHandler | EdgeHandler = ( - await import(pathToFileURL(funcEntry).href) - ).default; + const handler: NodeHandler = (await import(pathToFileURL(funcEntry).href)) + .default; const server = createServer((req, res) => { handleRequest(req, res).catch((err) => { @@ -107,9 +94,22 @@ export async function startPreviewServer({ if (staticFile) { return serveStaticFile(staticFile, res); } - return edge - ? invokeEdge(handler as EdgeHandler, req, res) - : invokeNode(handler as NodeHandler, req, res); + + // The generated function handles everything itself; `next` only runs + // when it declines the request (unmatched route) or bubbles an error. + return handler(req, res, (err) => { + if (res.writableEnded) { + return; + } + if (err) { + console.error(err); + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.statusCode = 404; + res.end("Not Found"); + } + }); } await new Promise((resolve, reject) => { @@ -181,59 +181,3 @@ function serveStaticFile(filePath: string, res: ServerResponse) { stream.pipe(res); }); } - -function invokeNode( - handler: NodeHandler, - req: IncomingMessage, - res: ServerResponse, -) { - // The generated Node function handles everything itself; `next` only runs - // when it declines the request (unmatched route) or bubbles an error. - return handler(req, res, (err) => { - if (res.writableEnded) { - return; - } - if (err) { - console.error(err); - res.statusCode = 500; - res.end("Internal Server Error"); - } else { - res.statusCode = 404; - res.end("Not Found"); - } - }); -} - -async function invokeEdge( - handler: EdgeHandler, - req: IncomingMessage, - res: ServerResponse, -) { - let body: BodyInit | undefined; - switch (req.method) { - case "POST": - case "PUT": - case "PATCH": - body = req as unknown as ReadableStream; - break; - } - - const request = new Request(new URL(req.url!, getOrigin(req)), { - method: req.method, - headers: req.headers as Record, - body, - // @ts-expect-error: Node requires this for streaming request bodies. - duplex: "half", - }); - - const response = await handler(request, { waitUntil() {} }); - - res.statusCode = response.status; - copyResponseHeaders(res, response.headers); - if (response.body) { - for await (const chunk of response.body as unknown as AsyncIterable) { - res.write(chunk); - } - } - res.end(); -} diff --git a/packages/adapters/vercel/src/types.ts b/packages/adapters/vercel/src/types.ts index 36023c2c..437d3ef4 100644 --- a/packages/adapters/vercel/src/types.ts +++ b/packages/adapters/vercel/src/types.ts @@ -1,17 +1,5 @@ import type { NodePlatformInfo } from "@marko/run/adapter"; -/** - * The platform object passed to Marko Run route handlers when deployed as a - * Vercel Edge Function. Matches the Edge runtime's request context. - */ -export interface VercelEdgePlatformInfo { - /** - * Extend the lifetime of the function until the given promise settles, - * without blocking the response. - */ - waitUntil(promise: Promise): void; -} - /** * The platform object passed to Marko Run route handlers when deployed as a * Vercel (Node.js) Serverless Function. Exposes the underlying Node request diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore deleted file mode 100644 index c49ff664..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist -.vercel diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts deleted file mode 100644 index ddf25f4f..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/.marko-run/routes.d.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - WARNING: This file is automatically generated and any changes made to it will be overwritten without warning. - Do NOT manually edit this file or your changes will be lost. -*/ - -import { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform } from "@marko/run/namespace"; -import type * as Run from "@marko/run"; -import type { VercelEdgePlatformInfo } from '@marko/run-adapter-vercel'; - -declare module "@marko/run" { - interface Platform extends VercelEdgePlatformInfo {} - - interface AppData extends Run.DefineApp<{ - routes: { - "/": { verb: "get"; }; - } - }> {} -} - -declare module "../src/routes/+page.marko" { - namespace MarkoRun { - export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; - export type Route = Run.Routes["/"]; - export type Context = Run.MultiRouteContext & Marko.Global; - export type Handler = Run.HandlerLike; - export type GET = Run.HandlerLike; - export type HEAD = Run.HandlerLike; - export type POST = Run.HandlerLike; - export type PUT = Run.HandlerLike; - export type DELETE = Run.HandlerLike; - export type PATCH = Run.HandlerLike; - export type OPTIONS = Run.HandlerLike; - /** @deprecated use `((context, next) => { ... }) satisfies MarkoRun.Handler` instead */ - export const route: Run.HandlerTypeFn; - } -} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md deleted file mode 100644 index 1ec2ae91..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/dev.expected.md +++ /dev/null @@ -1,35 +0,0 @@ -# Loading - -```html - -``` - -# Step 0 -ctx=>click(ctx) - -```diff -- 0 -+ 1 - -``` - -# Step 1 -ctx=>click(ctx) - -```diff -- 1 -+ 2 - -``` - -# Step 2 -ctx=>click(ctx) - -```diff -- 2 -+ 3 - -``` - diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md deleted file mode 100644 index 1ec2ae91..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/__snapshots__/preview.expected.md +++ /dev/null @@ -1,35 +0,0 @@ -# Loading - -```html - -``` - -# Step 0 -ctx=>click(ctx) - -```diff -- 0 -+ 1 - -``` - -# Step 1 -ctx=>click(ctx) - -```diff -- 1 -+ 2 - -``` - -# Step 2 -ctx=>click(ctx) - -```diff -- 2 -+ 3 - -``` - diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json deleted file mode 100644 index f123febd..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "vercel-adapter-edge", - "version": "1.0.0", - "scripts": {} -} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko deleted file mode 100644 index 436a9eb5..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/components/counter.marko +++ /dev/null @@ -1,11 +0,0 @@ -class { - declare state: { count: number }; - onCreate() { - this.state = { count: 0 }; - } - handleIncrement() { - this.state.count++; - } -} - - \ No newline at end of file diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko deleted file mode 100644 index 5dd8370a..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/src/routes/+page.marko +++ /dev/null @@ -1,12 +0,0 @@ - - - - - @marko/run Test Fixture - - - - - - - diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts deleted file mode 100644 index 2b2c7170..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/test.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Step, StepContext } from "../../main.test"; - -export const steps: Step[] = [ - (ctx) => click(ctx), - (ctx) => click(ctx), - (ctx) => click(ctx), -]; - -async function click({ page }: StepContext) { - await page.click("button"); -} diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json deleted file mode 100644 index 03ff88a5..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/tsconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "../../tsconfig-base.json", - "include": ["src/**/*", ".marko-run/*"], -} \ No newline at end of file diff --git a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts b/packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts deleted file mode 100644 index c6175af5..00000000 --- a/packages/run/src/__tests__/fixtures/vercel-adapter-edge/vite.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import marko from "@marko/run/vite"; -import adapter from "@marko/run-adapter-vercel"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [marko({ adapter: adapter({ edge: true }) })], - server: { - hmr: false, - }, -}); From a5b52ebb60b62243930e0738ce8a2a1510e8a88f Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 20:32:50 +0000 Subject: [PATCH 7/9] refactor: reuse the base adapter preview for the Vercel adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled in-process preview server with the same pattern the base adapter uses: `startPreview` delegates to the base adapter with a small `preview-entry.mjs`, which it spawns as a child process (gaining `--env` file loading and node args like `--inspect` for free). The entry mirrors the base `default-entry.mjs` — `serve-static` + `compression` over `.vercel/output/static` with immutable asset caching — and falls back to the built function imported from `.vercel/output/functions`, preserving Vercel's static-first routing. This deletes the custom HTTP server, static file resolver, and MIME map in favor of the off-the-shelf libraries already used elsewhere in the repo. --- package-lock.json | 4 + packages/adapters/vercel/package.json | 4 + packages/adapters/vercel/scripts/build.ts | 2 +- packages/adapters/vercel/src/index.ts | 16 +- .../adapters/vercel/src/preview-entry.mjs | 77 ++++++++ packages/adapters/vercel/src/preview.ts | 183 ------------------ 6 files changed, 92 insertions(+), 194 deletions(-) create mode 100644 packages/adapters/vercel/src/preview-entry.mjs delete mode 100644 packages/adapters/vercel/src/preview.ts diff --git a/package-lock.json b/package-lock.json index 35490d7e..102cf8d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26033,6 +26033,10 @@ "name": "@marko/run-adapter-vercel", "version": "0.1.0", "license": "MIT", + "dependencies": { + "compression": "^1.8.1", + "serve-static": "^2.2.1" + }, "peerDependencies": { "@marko/run": "^0.7||^0.8||^0.9||^0.10" } diff --git a/packages/adapters/vercel/package.json b/packages/adapters/vercel/package.json index 0dd17fb4..d7004294 100644 --- a/packages/adapters/vercel/package.json +++ b/packages/adapters/vercel/package.json @@ -24,6 +24,10 @@ "build": "tsc -b && tsx scripts/build.ts", "test": "" }, + "dependencies": { + "compression": "^1.8.1", + "serve-static": "^2.2.1" + }, "peerDependencies": { "@marko/run": "^0.7||^0.8||^0.9||^0.10" }, diff --git a/packages/adapters/vercel/scripts/build.ts b/packages/adapters/vercel/scripts/build.ts index 9b994a30..455b8653 100644 --- a/packages/adapters/vercel/scripts/build.ts +++ b/packages/adapters/vercel/scripts/build.ts @@ -44,7 +44,7 @@ await Promise.all([ }), build({ ...opts, - entryPoints: ["src/default-node-entry.ts"], + entryPoints: ["src/default-node-entry.ts", "src/preview-entry.mjs"], format: "esm", outExtension: { ".js": ".mjs" }, }), diff --git a/packages/adapters/vercel/src/index.ts b/packages/adapters/vercel/src/index.ts index 3b22b351..29c48c26 100644 --- a/packages/adapters/vercel/src/index.ts +++ b/packages/adapters/vercel/src/index.ts @@ -3,18 +3,17 @@ import fs from "fs/promises"; import path from "path"; import { fileURLToPath } from "url"; -import { startPreviewServer } from "./preview"; - export type { VercelNodePlatformInfo } from "./types"; const __dirname = fileURLToPath(path.dirname(import.meta.url)); const defaultEntry = path.join(__dirname, "default-node-entry"); +const previewEntry = path.join(__dirname, "preview-entry.mjs"); /** Used for the generated Node Serverless Function. */ const NODE_RUNTIME = "nodejs20.x"; export default function vercelAdapter(): Adapter { - const { startDev } = baseAdapter(); + const { startDev, startPreview } = baseAdapter(); return { name: "vercel-adapter", @@ -41,13 +40,10 @@ export default function vercelAdapter(): Adapter { }); }, - async startPreview({ options: previewOptions }) { - const { port = 3000, cwd } = previewOptions; - - // The build writes a Vercel Build Output API directory here; serve it - // directly (see `startPreviewServer` for why the Vercel CLI can't). - const outputDir = path.join(cwd, ".vercel", "output"); - return startPreviewServer({ outputDir, port }); + startPreview(event) { + // Ignore the entry the CLI resolved from the build dir — the build + // moved it into `.vercel/output`, which `preview-entry.mjs` serves. + return startPreview!({ ...event, entry: previewEntry }); }, async buildEnd({ config, builtEntries }) { diff --git a/packages/adapters/vercel/src/preview-entry.mjs b/packages/adapters/vercel/src/preview-entry.mjs new file mode 100644 index 00000000..2b29a5a6 --- /dev/null +++ b/packages/adapters/vercel/src/preview-entry.mjs @@ -0,0 +1,77 @@ +import compression from "compression"; +import fs from "fs"; +import { createServer } from "http"; +import path from "path"; +import createStaticServe from "serve-static"; +import { pathToFileURL } from "url"; +import zlib from "zlib"; + +const { PORT = 3000 } = process.env; + +// `marko-run preview` spawns this entry with the app root as the working +// directory, where the build wrote the Vercel Build Output API directory. +// `vercel dev` cannot serve that output — it rebuilds from source and removes +// `.vercel/output` — so preview emulates the two rules the adapter's generated +// `config.json` describes: serve a matching static asset if one exists (the +// `filesystem` handler), otherwise invoke the function. +const outputDir = path.resolve(".vercel", "output"); +const funcEntry = path.join(outputDir, "functions", "index.func", "index.js"); +const staticDir = path.join(outputDir, "static"); + +if (!fs.existsSync(funcEntry)) { + console.error( + `Vercel build output not found at '${outputDir}'. Run \`marko-run build\` before previewing.`, + ); + process.exit(1); +} + +const compress = compression({ + flush: zlib.constants.Z_PARTIAL_FLUSH, + threshold: 500, +}); + +const servePublic = createStaticServe(staticDir, { + index: false, + redirect: false, + maxAge: "10 minutes", +}); + +const serveAssets = createStaticServe(`${staticDir}/assets`, { + index: false, + redirect: false, + immutable: true, + fallthrough: false, + maxAge: "365 days", +}); + +import(pathToFileURL(funcEntry).href).then(({ default: middleware }) => { + createServer((req, res) => + compress(req, res, () => { + if (req.url.startsWith("/assets/")) { + req.url = req.url.slice(7); + serveAssets(req, res, () => { + res.statusCode = 404; + res.end(); + }); + } else { + servePublic(req, res, () => + // The function handles everything itself; `next` only runs when it + // declines the request (unmatched route) or bubbles an error. + middleware(req, res, (err) => { + if (res.writableEnded) { + return; + } + if (err) { + console.error(err); + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.statusCode = 404; + res.end("Not Found"); + } + }), + ); + } + }), + ).listen(PORT); +}); diff --git a/packages/adapters/vercel/src/preview.ts b/packages/adapters/vercel/src/preview.ts deleted file mode 100644 index 9406b939..00000000 --- a/packages/adapters/vercel/src/preview.ts +++ /dev/null @@ -1,183 +0,0 @@ -import type { SpawnedServer } from "@marko/run/adapter"; -import fs from "fs"; -import { createServer, type IncomingMessage, type ServerResponse } from "http"; -import type { Socket } from "net"; -import path from "path"; -import { pathToFileURL } from "url"; - -/** A Node.js request listener, as exported by the generated function. */ -type NodeHandler = ( - req: IncomingMessage, - res: ServerResponse, - next: (err?: unknown) => void, -) => void | Promise; - -export interface PreviewServerOptions { - /** The `.vercel/output` Build Output API directory produced by the build. */ - outputDir: string; - /** Port to listen on. */ - port: number; -} - -// Content types for the handful of extensions a static build emits. Kept as a -// small map rather than a dependency to match the adapter's zero-dep style. -const contentTypes: Record = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript; charset=utf-8", - ".mjs": "text/javascript; charset=utf-8", - ".css": "text/css; charset=utf-8", - ".json": "application/json; charset=utf-8", - ".map": "application/json; charset=utf-8", - ".txt": "text/plain; charset=utf-8", - ".xml": "application/xml; charset=utf-8", - ".svg": "image/svg+xml", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".avif": "image/avif", - ".ico": "image/x-icon", - ".wasm": "application/wasm", - ".woff": "font/woff", - ".woff2": "font/woff2", - ".ttf": "font/ttf", -}; - -/** - * Serve a built `.vercel/output` directory locally, without the Vercel CLI. - * - * `vercel dev` cannot serve the Build Output API — it rebuilds from source and - * removes `.vercel/output`. So preview emulates the two rules the adapter's - * generated `config.json` describes: serve a matching static asset if one - * exists (the `filesystem` handler), otherwise invoke the function. - */ -export async function startPreviewServer({ - outputDir, - port, -}: PreviewServerOptions): Promise { - const staticDir = path.join(outputDir, "static"); - const funcEntry = path.join(outputDir, "functions", "index.func", "index.js"); - - if (!fs.existsSync(funcEntry)) { - throw new Error( - `Vercel build output not found at '${outputDir}'. Run \`marko-run build\` before previewing.`, - ); - } - - const handler: NodeHandler = (await import(pathToFileURL(funcEntry).href)) - .default; - - const server = createServer((req, res) => { - handleRequest(req, res).catch((err) => { - console.error(err); - if (!res.headersSent) { - res.statusCode = 500; - } - if (!res.writableEnded) { - res.end("Internal Server Error"); - } - }); - }); - - // Track open sockets so `close()` can terminate keep-alive connections; - // otherwise `server.close()` waits indefinitely for idle clients to hang up. - const sockets = new Set(); - server.on("connection", (socket) => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - }); - - async function handleRequest(req: IncomingMessage, res: ServerResponse) { - const pathname = (req.url || "/").split(/[?#]/, 1)[0]; - const staticFile = await resolveStaticFile(staticDir, pathname); - if (staticFile) { - return serveStaticFile(staticFile, res); - } - - // The generated function handles everything itself; `next` only runs - // when it declines the request (unmatched route) or bubbles an error. - return handler(req, res, (err) => { - if (res.writableEnded) { - return; - } - if (err) { - console.error(err); - res.statusCode = 500; - res.end("Internal Server Error"); - } else { - res.statusCode = 404; - res.end("Not Found"); - } - }); - } - - await new Promise((resolve, reject) => { - server.once("error", reject); - server.listen(port, () => { - server.off("error", reject); - resolve(); - }); - }); - - return { - port, - close() { - return new Promise((resolve, reject) => { - server.close((err) => (err ? reject(err) : resolve())); - for (const socket of sockets) { - socket.destroy(); - } - }); - }, - }; -} - -/** - * Resolve a request path to a file inside the static directory, mirroring - * Vercel's `filesystem` handler: exact match, then `.html`, then a directory - * `index.html`. Returns `undefined` when nothing matches. - */ -async function resolveStaticFile(staticDir: string, pathname: string) { - let decoded: string; - try { - decoded = decodeURIComponent(pathname); - } catch { - return undefined; // malformed percent-encoding - } - - const candidates = - decoded === "" || decoded.endsWith("/") - ? [path.join(decoded, "index.html")] - : [decoded, `${decoded}.html`, path.join(decoded, "index.html")]; - - for (const candidate of candidates) { - const filePath = path.join(staticDir, candidate); - // Guard against `..` escaping the static directory. - if (filePath !== staticDir && !filePath.startsWith(staticDir + path.sep)) { - continue; - } - try { - if ((await fs.promises.stat(filePath)).isFile()) { - return filePath; - } - } catch { - // Not found — try the next candidate. - } - } - - return undefined; -} - -function serveStaticFile(filePath: string, res: ServerResponse) { - const type = contentTypes[path.extname(filePath).toLowerCase()]; - if (type) { - res.setHeader("content-type", type); - } - return new Promise((resolve, reject) => { - const stream = fs.createReadStream(filePath); - stream.on("error", reject); - res.on("close", resolve); - stream.pipe(res); - }); -} From 6141d34116e244247bc3416954f666ed57ececcd Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 21:25:03 +0000 Subject: [PATCH 8/9] feat: log the preview server URL once it starts listening --- packages/adapters/vercel/src/preview-entry.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/adapters/vercel/src/preview-entry.mjs b/packages/adapters/vercel/src/preview-entry.mjs index 2b29a5a6..60ba94f6 100644 --- a/packages/adapters/vercel/src/preview-entry.mjs +++ b/packages/adapters/vercel/src/preview-entry.mjs @@ -73,5 +73,7 @@ import(pathToFileURL(funcEntry).href).then(({ default: middleware }) => { ); } }), - ).listen(PORT); + ).listen(PORT, () => { + console.log(`Serving Vercel build output at http://localhost:${PORT}`); + }); }); From 8406d06e273270934c7c301e803614509f1d7179 Mon Sep 17 00:00:00 2001 From: Ryan Turnquist Date: Wed, 1 Jul 2026 21:39:17 +0000 Subject: [PATCH 9/9] fix: surface errors loading the built function in the preview server --- .../adapters/vercel/src/preview-entry.mjs | 69 ++++++++++--------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/packages/adapters/vercel/src/preview-entry.mjs b/packages/adapters/vercel/src/preview-entry.mjs index 60ba94f6..693f1d38 100644 --- a/packages/adapters/vercel/src/preview-entry.mjs +++ b/packages/adapters/vercel/src/preview-entry.mjs @@ -44,36 +44,41 @@ const serveAssets = createStaticServe(`${staticDir}/assets`, { maxAge: "365 days", }); -import(pathToFileURL(funcEntry).href).then(({ default: middleware }) => { - createServer((req, res) => - compress(req, res, () => { - if (req.url.startsWith("/assets/")) { - req.url = req.url.slice(7); - serveAssets(req, res, () => { - res.statusCode = 404; - res.end(); - }); - } else { - servePublic(req, res, () => - // The function handles everything itself; `next` only runs when it - // declines the request (unmatched route) or bubbles an error. - middleware(req, res, (err) => { - if (res.writableEnded) { - return; - } - if (err) { - console.error(err); - res.statusCode = 500; - res.end("Internal Server Error"); - } else { - res.statusCode = 404; - res.end("Not Found"); - } - }), - ); - } - }), - ).listen(PORT, () => { - console.log(`Serving Vercel build output at http://localhost:${PORT}`); +import(pathToFileURL(funcEntry).href) + .then(({ default: middleware }) => { + createServer((req, res) => + compress(req, res, () => { + if (req.url.startsWith("/assets/")) { + req.url = req.url.slice(7); + serveAssets(req, res, () => { + res.statusCode = 404; + res.end(); + }); + } else { + servePublic(req, res, () => + // The function handles everything itself; `next` only runs when it + // declines the request (unmatched route) or bubbles an error. + middleware(req, res, (err) => { + if (res.writableEnded) { + return; + } + if (err) { + console.error(err); + res.statusCode = 500; + res.end("Internal Server Error"); + } else { + res.statusCode = 404; + res.end("Not Found"); + } + }), + ); + } + }), + ).listen(PORT, () => { + console.log(`Serving Vercel build output at http://localhost:${PORT}`); + }); + }) + .catch((err) => { + console.error("Failed to load Vercel function entry:", err); + process.exit(1); }); -});