@octanejs/adapter-vercel 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dominic Gannaway
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@octanejs/adapter-vercel",
3
+ "version": "0.0.1",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "Vercel adapter for octane apps (Build Output API v3)",
7
+ "author": {
8
+ "name": "Dominic Gannaway",
9
+ "email": "dg@domgan.com"
10
+ },
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/octanejs/octane.git",
17
+ "directory": "packages/adapter-vercel"
18
+ },
19
+ "module": "src/index.js",
20
+ "main": "src/index.js",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./types/index.d.ts",
24
+ "import": "./src/index.js",
25
+ "default": "./src/index.js"
26
+ }
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^24.3.0",
30
+ "@octanejs/vite-plugin": "0.1.3"
31
+ }
32
+ }
package/src/adapt.js ADDED
@@ -0,0 +1,216 @@
1
+ // @ts-check
2
+ /**
3
+ * Vercel Build Output API v3 emitter.
4
+ *
5
+ * Restructures an octane production build (`dist/client` + `dist/server`,
6
+ * produced by @octanejs/vite-plugin) into `.vercel/output/`:
7
+ *
8
+ * static/ ← dist/client verbatim (hashed assets; the build
9
+ * already moved index.html — the SSR template —
10
+ * to dist/server, so nothing shadows SSR at '/')
11
+ * functions/index.func/ ← dist/server verbatim + a generated index.js that
12
+ * wraps the entry's fetch `handler` in Vercel's
13
+ * Web-Standard function shape
14
+ * config.json ← routing: immutable asset headers → filesystem →
15
+ * catch-all to the function
16
+ *
17
+ * Modeled on @ripple-ts/adapter-vercel (itself after @sveltejs/adapter-vercel),
18
+ * with one octane simplification: the server entry is SELF-CONTAINED (the
19
+ * plugin bundles the app + octane with `ssr.noExternal: true`), so there is no
20
+ * dependency tracing — the function is dist/server plus one wrapper file.
21
+ */
22
+
23
+ /** @import { VercelAdapterOptions, VercelConfig, VercelRoute } from '@octanejs/adapter-vercel' */
24
+ /** @import { AdaptContext } from '@octanejs/vite-plugin' */
25
+
26
+ import { existsSync, mkdirSync, cpSync, writeFileSync, rmSync } from 'node:fs';
27
+ import { join, dirname } from 'node:path';
28
+ import { createRequire } from 'node:module';
29
+
30
+ const VERCEL_OUTPUT_DIR = '.vercel/output';
31
+
32
+ /**
33
+ * The adapter's own version (for .vc-config.json `framework`). MUST stay lazy:
34
+ * octane.config.ts is compiled into the server bundle, so this module's scope
35
+ * executes when the deployed function boots — where `../package.json` does not
36
+ * exist. adapt() itself only ever runs at build time from the real package.
37
+ * @returns {string}
38
+ */
39
+ function get_adapter_version() {
40
+ try {
41
+ const require = createRequire(import.meta.url);
42
+ return require('../package.json').version;
43
+ } catch {
44
+ return '0.0.0';
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Default Node.js runtime for the function, from the build machine's major.
50
+ * @returns {string}
51
+ */
52
+ function get_default_runtime() {
53
+ const major = Number(process.version.slice(1).split('.')[0]);
54
+ const valid = [20, 22, 24];
55
+ if (!valid.includes(major)) {
56
+ throw new Error(
57
+ `[@octanejs/adapter-vercel] Unsupported Node.js version: ${process.version}. ` +
58
+ `Build with Node ${valid.join('/')} or set serverless.runtime explicitly.`,
59
+ );
60
+ }
61
+ return `nodejs${major}.x`;
62
+ }
63
+
64
+ /**
65
+ * @param {string} file_path
66
+ * @param {string} data
67
+ */
68
+ function write(file_path, data) {
69
+ mkdirSync(dirname(file_path), { recursive: true });
70
+ writeFileSync(file_path, data);
71
+ }
72
+
73
+ /**
74
+ * The serverless function entry: Vercel's Web-Standard shape
75
+ * (`export default { fetch(Request) => Response }`) over the built handler.
76
+ * @returns {string}
77
+ */
78
+ function generate_handler_source() {
79
+ return `// Auto-generated by @octanejs/adapter-vercel — the Vercel function entry.
80
+ // The imported handler is the self-contained octane SSR bundle's fetch handler.
81
+ import { handler } from './entry.js';
82
+
83
+ export default {
84
+ async fetch(request) {
85
+ try {
86
+ return await handler(request);
87
+ } catch (error) {
88
+ console.error('[octane] Serverless handler error:', error);
89
+ return new Response('Internal Server Error', { status: 500 });
90
+ }
91
+ },
92
+ };
93
+ `;
94
+ }
95
+
96
+ /**
97
+ * Build Output API v3 config.json.
98
+ *
99
+ * @param {VercelAdapterOptions} options
100
+ * @returns {VercelConfig}
101
+ */
102
+ function generate_vercel_config(options) {
103
+ const { cleanUrls = true, trailingSlash, images, headers = [], redirects = [] } = options;
104
+
105
+ /** @type {VercelRoute[]} */
106
+ const routes = [];
107
+
108
+ for (const redirect of redirects) {
109
+ routes.push({
110
+ src: redirect.source,
111
+ headers: { Location: redirect.destination },
112
+ status: redirect.permanent ? 308 : 307,
113
+ });
114
+ }
115
+
116
+ // Hashed build assets are immutable by construction.
117
+ routes.push({
118
+ src: '/assets/.+',
119
+ headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
120
+ continue: true,
121
+ });
122
+
123
+ for (const header of headers) {
124
+ routes.push({
125
+ src: header.source,
126
+ headers: Object.fromEntries(header.headers.map((h) => [h.key, h.value])),
127
+ continue: true,
128
+ });
129
+ }
130
+
131
+ // Static files win first; everything else — every page, including the
132
+ // catch-all 404 route — is server-rendered by the function.
133
+ routes.push({ handle: 'filesystem' });
134
+ routes.push({ src: '/.*', dest: '/index' });
135
+
136
+ /** @type {VercelConfig} */
137
+ const config = { version: 3, routes };
138
+ if (cleanUrls !== undefined) config.cleanUrls = cleanUrls;
139
+ if (trailingSlash !== undefined) config.trailingSlash = trailingSlash;
140
+ if (images) config.images = images;
141
+ return config;
142
+ }
143
+
144
+ /**
145
+ * Emit `.vercel/output` from a completed octane build.
146
+ *
147
+ * Callable two ways: by @octanejs/vite-plugin's closeBundle (which passes its
148
+ * {@link AdaptContext}) or directly with explicit dirs.
149
+ *
150
+ * @param {AdaptContext} ctx
151
+ * @param {VercelAdapterOptions} [options]
152
+ * @returns {Promise<void>}
153
+ */
154
+ export async function adapt(ctx, options = {}) {
155
+ const { root, clientDir, serverDir } = ctx;
156
+ const log = ctx.log ?? ((/** @type {string} */ msg) => console.log(msg));
157
+
158
+ const server_entry = join(serverDir, 'entry.js');
159
+ if (!existsSync(clientDir)) {
160
+ throw new Error(`[@octanejs/adapter-vercel] Client build output not found at ${clientDir}.`);
161
+ }
162
+ if (!existsSync(server_entry)) {
163
+ throw new Error(`[@octanejs/adapter-vercel] Server entry not found at ${server_entry}.`);
164
+ }
165
+
166
+ const output_dir = join(root, VERCEL_OUTPUT_DIR);
167
+ rmSync(output_dir, { recursive: true, force: true });
168
+ mkdirSync(output_dir, { recursive: true });
169
+
170
+ // 1. Static assets — dist/client verbatim (no index.html in it: the build
171
+ // moved the SSR template to dist/server).
172
+ const static_dir = join(output_dir, 'static');
173
+ mkdirSync(static_dir, { recursive: true });
174
+ cpSync(clientDir, static_dir, { recursive: true });
175
+
176
+ // 2. The serverless function — dist/server verbatim (self-contained entry +
177
+ // the SSR template + any dynamic-import chunks) plus the wrapper.
178
+ const func_dir = join(output_dir, 'functions', 'index.func');
179
+ mkdirSync(func_dir, { recursive: true });
180
+ cpSync(serverDir, func_dir, { recursive: true });
181
+ write(join(func_dir, 'index.js'), generate_handler_source());
182
+ write(join(func_dir, 'package.json'), JSON.stringify({ type: 'module' }));
183
+
184
+ const runtime = options.serverless?.runtime ?? get_default_runtime();
185
+ /** @type {Record<string, unknown>} */
186
+ const vc_config = {
187
+ runtime,
188
+ handler: 'index.js',
189
+ launcherType: 'Nodejs',
190
+ experimentalResponseStreaming: true,
191
+ framework: { slug: 'octane', version: get_adapter_version() },
192
+ };
193
+ if (options.serverless?.regions) vc_config.regions = options.serverless.regions;
194
+ if (options.serverless?.memory) vc_config.memory = options.serverless.memory;
195
+ if (options.serverless?.maxDuration) vc_config.maxDuration = options.serverless.maxDuration;
196
+
197
+ // ISR: cache the function's response at the edge, revalidate after
198
+ // `expiration` seconds (false = never expire).
199
+ if (options.isr) {
200
+ /** @type {Record<string, unknown>} */
201
+ const prerender = { expiration: options.isr.expiration };
202
+ if (options.isr.bypassToken) prerender.bypassToken = options.isr.bypassToken;
203
+ if (options.isr.allowQuery !== undefined) prerender.allowQuery = options.isr.allowQuery;
204
+ vc_config.prerender = prerender;
205
+ }
206
+
207
+ write(join(func_dir, '.vc-config.json'), JSON.stringify(vc_config, null, '\t'));
208
+
209
+ // 3. Routing config.
210
+ write(
211
+ join(output_dir, 'config.json'),
212
+ JSON.stringify(generate_vercel_config(options), null, '\t'),
213
+ );
214
+
215
+ log(`[@octanejs/adapter-vercel] Build Output written to ${VERCEL_OUTPUT_DIR} (${runtime})`);
216
+ }
package/src/index.js ADDED
@@ -0,0 +1,42 @@
1
+ // @ts-check
2
+ /**
3
+ * @octanejs/adapter-vercel — Vercel adapter for octane apps.
4
+ *
5
+ * Usage (octane.config.ts):
6
+ *
7
+ * import { vercel } from '@octanejs/adapter-vercel';
8
+ * export default defineConfig({
9
+ * adapter: vercel(),
10
+ * router: { … },
11
+ * });
12
+ *
13
+ * `vercel(options)` returns the plugin's adapter contract: after `vite build`
14
+ * produces both bundles, @octanejs/vite-plugin calls `adapt(ctx)` and this
15
+ * adapter restructures them into Vercel's Build Output API v3 under
16
+ * `.vercel/output/` — no vercel.json rewrites, no hand-written api/ function.
17
+ * The same contract shape (`{ name, adapt }`) is what a Cloudflare/Netlify
18
+ * adapter implements to target other platforms.
19
+ *
20
+ * No `serve`/`runtime` overrides are provided: Vercel functions run on Node,
21
+ * and the generated server entry's built-in Node defaults (AsyncLocalStorage
22
+ * async context, sha-256 hash) are exactly right — `octane-preview` keeps
23
+ * serving dist/server locally, untouched by the adapter.
24
+ */
25
+
26
+ /** @import { VercelAdapterOptions } from '@octanejs/adapter-vercel' */
27
+ /** @import { OctaneAdapter } from '@octanejs/vite-plugin' */
28
+
29
+ import { adapt } from './adapt.js';
30
+
31
+ export { adapt };
32
+
33
+ /**
34
+ * @param {VercelAdapterOptions} [options]
35
+ * @returns {OctaneAdapter}
36
+ */
37
+ export function vercel(options = {}) {
38
+ return {
39
+ name: 'vercel',
40
+ adapt: (ctx) => adapt(ctx, options),
41
+ };
42
+ }
@@ -0,0 +1,100 @@
1
+ // @vitest-environment node
2
+ //
3
+ // adapt() emitter test — feeds a fake completed octane build (the dist/client +
4
+ // dist/server layout @octanejs/vite-plugin produces) through adapt() and
5
+ // asserts the emitted `.vercel/output` is valid Build Output API v3: static
6
+ // assets, one self-contained Node function, and the routing config. The
7
+ // emitted function entry is then actually imported and invoked. The end-to-end
8
+ // wiring (closeBundle → adapt via `adapter: vercel()`) is covered by the
9
+ // website's ssr-smoke test, which runs the real `vite build`.
10
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { pathToFileURL, fileURLToPath } from 'node:url';
14
+ import { adapt } from '../src/index.js';
15
+
16
+ // The tests dir doubles as the fake project root: the fake build goes in
17
+ // `dist/` and adapt() writes `.vercel/output/` next to it — both gitignored.
18
+ const root = fileURLToPath(new URL('.', import.meta.url));
19
+ const clientDir = path.join(root, 'dist/client');
20
+ const serverDir = path.join(root, 'dist/server');
21
+ const outputDir = path.join(root, '.vercel/output');
22
+ const funcDir = path.join(outputDir, 'functions/index.func');
23
+
24
+ // A stand-in for the plugin's self-contained server bundle: same shape (fetch
25
+ // `handler` export next to the SSR template), no dependencies.
26
+ const FAKE_ENTRY = `export const handler = async (req) => new Response('ok:' + new URL(req.url).pathname);\n`;
27
+
28
+ function write(file: string, data: string) {
29
+ fs.mkdirSync(path.dirname(file), { recursive: true });
30
+ fs.writeFileSync(file, data);
31
+ }
32
+
33
+ beforeAll(async () => {
34
+ fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
35
+ fs.rmSync(path.join(root, '.vercel'), { recursive: true, force: true });
36
+
37
+ write(path.join(clientDir, 'assets/app-abc.js'), 'console.log("app");\n');
38
+ write(path.join(serverDir, 'entry.js'), FAKE_ENTRY);
39
+ write(path.join(serverDir, 'index.html'), '<!doctype html><html><!--ssr-body--></html>\n');
40
+
41
+ await adapt({ root, outDir: 'dist', clientDir, serverDir, log: () => {} });
42
+ });
43
+
44
+ afterAll(() => {
45
+ fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
46
+ fs.rmSync(path.join(root, '.vercel'), { recursive: true, force: true });
47
+ });
48
+
49
+ describe('adapt()', () => {
50
+ it('emits Build Output API v3 routing config', () => {
51
+ const config = JSON.parse(fs.readFileSync(path.join(outputDir, 'config.json'), 'utf-8'));
52
+ expect(config.version).toBe(3);
53
+
54
+ // Static files first, then EVERYTHING else (including the 404 catch-all
55
+ // route) goes to the SSR function.
56
+ const filesystemIndex = config.routes.findIndex(
57
+ (r: Record<string, unknown>) => r.handle === 'filesystem',
58
+ );
59
+ const catchAllIndex = config.routes.findIndex(
60
+ (r: Record<string, unknown>) => r.src === '/.*' && r.dest === '/index',
61
+ );
62
+ expect(filesystemIndex).toBeGreaterThanOrEqual(0);
63
+ expect(catchAllIndex).toBe(filesystemIndex + 1);
64
+
65
+ // Hashed assets are immutable, applied BEFORE the filesystem handler.
66
+ const assetsRoute = config.routes.find((r: Record<string, unknown>) => r.src === '/assets/.+');
67
+ expect(assetsRoute).toMatchObject({
68
+ headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
69
+ continue: true,
70
+ });
71
+ expect(config.routes.indexOf(assetsRoute)).toBeLessThan(filesystemIndex);
72
+ });
73
+
74
+ it('copies the client bundle to static/ (no index.html to shadow SSR)', () => {
75
+ expect(fs.existsSync(path.join(outputDir, 'static/assets/app-abc.js'))).toBe(true);
76
+ expect(fs.existsSync(path.join(outputDir, 'static/index.html'))).toBe(false);
77
+ });
78
+
79
+ it('emits the serverless function: dist/server verbatim + wrapper + config', () => {
80
+ for (const file of ['index.js', 'entry.js', 'index.html', '.vc-config.json', 'package.json']) {
81
+ expect(fs.existsSync(path.join(funcDir, file)), file).toBe(true);
82
+ }
83
+
84
+ const vcConfig = JSON.parse(fs.readFileSync(path.join(funcDir, '.vc-config.json'), 'utf-8'));
85
+ expect(vcConfig.handler).toBe('index.js');
86
+ expect(vcConfig.launcherType).toBe('Nodejs');
87
+ expect(vcConfig.runtime).toMatch(/^nodejs(20|22|24)\.x$/);
88
+
89
+ // The wrapper imports as ESM — the function dir must say so.
90
+ const pkg = JSON.parse(fs.readFileSync(path.join(funcDir, 'package.json'), 'utf-8'));
91
+ expect(pkg.type).toBe('module');
92
+ });
93
+
94
+ it('emitted function entry serves requests through the bundled handler', async () => {
95
+ const mod = await import(pathToFileURL(path.join(funcDir, 'index.js')).href);
96
+ const response = await mod.default.fetch(new Request('http://x/hello'));
97
+ expect(response.status).toBe(200);
98
+ expect(await response.text()).toBe('ok:/hello');
99
+ });
100
+ });
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "module": "nodenext",
4
+ "moduleResolution": "nodenext",
5
+ "target": "es2022",
6
+ "lib": ["esnext", "dom", "dom.iterable"],
7
+ "noEmit": true,
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "resolveJsonModule": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "types": ["node"],
13
+ "allowJs": true,
14
+ "checkJs": false
15
+ },
16
+ "include": ["src/**/*", "types/**/*", "tests/**/*.ts"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }
@@ -0,0 +1,79 @@
1
+ import type { OctaneAdapter, AdaptContext } from '@octanejs/vite-plugin';
2
+
3
+ // ============================================================================
4
+ // Adapter options
5
+ // ============================================================================
6
+
7
+ /** Serverless function configuration (functions/index.func/.vc-config.json). */
8
+ export interface ServerlessConfig {
9
+ /**
10
+ * Node.js runtime version for the function.
11
+ * @default auto-detected from the build machine's Node major (20/22/24)
12
+ */
13
+ runtime?: string;
14
+ /** Regions to deploy the function to (e.g. ['iad1']). */
15
+ regions?: string[];
16
+ /** Maximum execution duration in seconds. */
17
+ maxDuration?: number;
18
+ /** Memory in MB allocated to the function. */
19
+ memory?: number;
20
+ }
21
+
22
+ /** Incremental Static Regeneration — edge-cache the SSR response. */
23
+ export interface ISRConfig {
24
+ /** Seconds before the cached response regenerates; `false` = never expires. */
25
+ expiration: number | false;
26
+ /** Token bypassing the cache (`__prerender_bypass` cookie / revalidate header). */
27
+ bypassToken?: string;
28
+ /** Query params that are part of the cache key. */
29
+ allowQuery?: string[];
30
+ }
31
+
32
+ export interface VercelAdapterOptions {
33
+ serverless?: ServerlessConfig;
34
+ isr?: ISRConfig;
35
+ /** @default true */
36
+ cleanUrls?: boolean;
37
+ trailingSlash?: boolean;
38
+ /** Vercel image optimization config (passed through to config.json). */
39
+ images?: Record<string, unknown>;
40
+ /** Extra response headers, matched by regex source. */
41
+ headers?: Array<{ source: string; headers: Array<{ key: string; value: string }> }>;
42
+ /** Redirects, matched by regex source. */
43
+ redirects?: Array<{ source: string; destination: string; permanent?: boolean }>;
44
+ }
45
+
46
+ // ============================================================================
47
+ // Build Output API v3 shapes (what adapt() writes)
48
+ // ============================================================================
49
+
50
+ export interface VercelRoute {
51
+ src?: string;
52
+ dest?: string;
53
+ headers?: Record<string, string>;
54
+ status?: number;
55
+ continue?: boolean;
56
+ handle?: string;
57
+ }
58
+
59
+ export interface VercelConfig {
60
+ version: 3;
61
+ routes: VercelRoute[];
62
+ cleanUrls?: boolean;
63
+ trailingSlash?: boolean;
64
+ images?: Record<string, unknown>;
65
+ }
66
+
67
+ // ============================================================================
68
+ // Entry points
69
+ // ============================================================================
70
+
71
+ /**
72
+ * The octane.config.ts adapter: @octanejs/vite-plugin runs its `adapt()` after
73
+ * `vite build` produces dist/client + dist/server, emitting `.vercel/output/`
74
+ * (Build Output API v3).
75
+ */
76
+ export function vercel(options?: VercelAdapterOptions): OctaneAdapter;
77
+
78
+ /** The emitter itself — callable directly with an explicit build context. */
79
+ export function adapt(ctx: AdaptContext, options?: VercelAdapterOptions): Promise<void>;