@avelonjs/next 0.1.0

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 Ryan Yannelli
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/README.md ADDED
@@ -0,0 +1,150 @@
1
+ # @avelonjs/next
2
+
3
+ `@avelonjs/next` is the v1 adapter. It turns a `RouteManifest` into Next's file router, serves writes as generated server actions, wraps pages around the core kernel, and bridges `middleware.ts`. Reach for it when the application is a Next app and you want typed `routes/web.ts` without putting Next types in `@avelonjs/core`.
4
+
5
+ The adapter is a translation layer at three seams: `mount`, `toRequest`, and `toResponse`. Middleware, binding, validation, and exception mapping stay in the kernel (D26).
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ bun add @avelonjs/next
11
+ ```
12
+
13
+ Wire the adapter in `avelon.config.ts` and generate the router during `dev` and `build`.
14
+
15
+ ```ts
16
+ import { NextAdapter } from '@avelonjs/next'
17
+ import { createKernel, defineConfig } from '@avelonjs/core'
18
+
19
+ const adapter = new NextAdapter({ root: process.cwd() })
20
+ defineConfig({
21
+ name: 'app',
22
+ adapter,
23
+ drivers: {},
24
+ })
25
+
26
+ await adapter.mount(Route.manifest(), { kernel: createKernel() })
27
+ ```
28
+
29
+ ## Basic Usage
30
+
31
+ ```ts
32
+ import { Route } from '@avelonjs/next'
33
+ import { PostController } from '@/app/Http/Controllers/PostController'
34
+ import { Post } from '@/app/Models/Post'
35
+
36
+ Route.get('/', HomeController, 'index').name('home')
37
+
38
+ Route.middleware('auth').group(() => {
39
+ Route.resource('posts', PostController).bind('post', Post)
40
+ })
41
+ ```
42
+
43
+ `reeve route:sync` (and `NextAdapter.mount`) wipe `app/(web)` and rewrite it. Generated pages are four lines and contain no logic:
44
+
45
+ ```ts
46
+ // Generated by `reeve route:sync`. Do not edit.
47
+ import { PostController } from '@/app/Http/Controllers/PostController'
48
+ import { page } from '@avelonjs/next'
49
+
50
+ export const dynamic = 'force-dynamic'
51
+
52
+ export default page(PostController, 'show', '/posts/{post}')
53
+ ```
54
+
55
+ Writes become server actions in `framework/routing/actions.generated.ts`. `.api()` additionally generates `app/(api)/api/.../route.ts` so JSON URLs do not collide with pages.
56
+
57
+ ## Capabilities
58
+
59
+ | Capability | Value | Meaning |
60
+ | ------------------- | ------- | ------------------------------------------------------ |
61
+ | `fileSystemRouting` | `true` | `mount()` writes framework-owned files. |
62
+ | `serverActions` | `true` | `Route.post()` is served as a generated server action. |
63
+ | `streaming` | `true` | The kernel may return `StreamResult`. |
64
+ | `edgeMiddleware` | `false` | Next 16 Proxy middleware defaults to Node (D27). |
65
+
66
+ `serverActions` selects the write transport. It does not gate whether a write route exists.
67
+
68
+ ## Request Lifecycle
69
+
70
+ `page()` and `dispatch()` convert native Next inputs with `toRequest`, call `kernel.dispatch`, then `toResponse`. Redirects throw `NextRedirect` with a `NEXT_REDIRECT` digest so Next navigation keeps propagating. Validation failures return `{ kind: 'action', ok: false, errors }` for `useActionState`.
71
+
72
+ `middleware.ts` calls `middleware(request)`. Routes that declare `auth` redirect to `/login` when no cookies or `Authorization` header are present; everything else returns `{ kind: 'next' }`.
73
+
74
+ ## Method Reference
75
+
76
+ | Method / export | Signature | Description |
77
+ | --------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
78
+ | `NextAdapter` | `class NextAdapter` | File-routing adapter implementing the frozen `Adapter` contract. |
79
+ | `NextAdapter.mount` | `(manifest, options) => Promise<MountResult>` | Writes the router and binds the kernel. |
80
+ | `NextAdapter.attach` | `(manifest, options) => void` | Binds kernel and manifest without rewriting generated files. |
81
+ | `NextAdapter.registerViews` | `(views) => void` | Maps string view tokens to React components for `toResponse`. |
82
+ | `NextAdapter.toRequest` | `(native) => Promise<HttpRequest>` | Converts page props, FormData, or `Request`. Unparseable bodies become `body: null`. |
83
+ | `NextAdapter.toResponse` | `(result) => Promise<NextNativeResponse>` | Converts kernel results. Redirects throw `NextRedirect`. |
84
+ | `nextCapabilities` | `typeof nextCapabilities` | Literal capability object used by CLI and codegen. |
85
+ | `bindAdapter` | `(adapter \| undefined) => void` | Records the adapter generated helpers dispatch through. |
86
+ | `getAdapter` | `() => NextAdapter` | Returns the bound adapter or throws. |
87
+ | `setRuntimeBoot` | `(boot \| undefined) => void` | Registers a boot function `page` and `dispatch` call when unbound. |
88
+ | `ensureAdapter` | `() => Promise<NextAdapter>` | Returns the bound adapter, booting first when needed. |
89
+ | `page` | `(controller, action, uri) => PageComponent` | Next page wrapper used by generated `page.tsx`. |
90
+ | `dispatch` | `(controller, action, uri, formData) => Promise<NextNativeResponse>` | Server-action entry used by generated writes. |
91
+ | `handleRoute` | `(controller, action, uri, request, params?) => Promise<NextNativeResponse>` | JSON route-handler entry used by `.api()` files. |
92
+ | `middleware` | `(request, routes?) => Promise<NextNativeResponse>` | `proxy.ts` / `middleware.ts` bridge. Pass generated routes on the Node proxy. |
93
+ | `MiddlewareRoute` | `interface` | Method, path, and middleware aliases for Edge auth. |
94
+ | `setCookie` | `(name, value, options?) => void` | Queues a cookie write for the current Next request. |
95
+ | `clearCookie` | `(name, options?) => void` | Queues a cookie deletion for the current Next request. |
96
+ | `PendingCookie` | `interface` | Queued cookie name, value, and options. |
97
+ | `ViewRegistry` | `type` | String view tokens mapped to render functions. |
98
+ | `formProps` | `(name, params?) => { action, method, fields }` | Hidden fields and action export for a named write route. |
99
+ | `generateRouter` | `(manifest, root, options) => Promise<readonly string[]>` | Destructive codegen used by `mount`. |
100
+ | `findMountedRoute` | `(controller, action, uri) => RouteDefinition` | Resolves a generated helper back to its manifest entry. |
101
+ | `Route.get` | `(path, controller, action?) => RouteBuilder` | Registers a GET route. |
102
+ | `Route.post` | `(path, controller, action?) => RouteBuilder` | Registers a POST route. |
103
+ | `Route.put` | `(path, controller, action?) => RouteBuilder` | Registers a PUT route. |
104
+ | `Route.patch` | `(path, controller, action?) => RouteBuilder` | Registers a PATCH route. |
105
+ | `Route.delete` | `(path, controller, action?) => RouteBuilder` | Registers a DELETE route. |
106
+ | `Route.resource` | `(name, controller) => ResourceBuilder` | Expands the seven resource routes. |
107
+ | `Route.middleware` | `(...names) => { group }` | Applies middleware inside a group. |
108
+ | `Route.prefix` | `(prefix) => { group }` | Prefixes routes inside a group. |
109
+ | `Route.all` | `() => readonly RouteDefinition[]` | Returns registrations. |
110
+ | `Route.manifest` | `(version?) => RouteManifest` | Builds a mountable manifest. |
111
+ | `Route.find` | `(name) => RouteDefinition` | Finds a named route or throws. |
112
+ | `Route.reset` | `() => void` | Clears registrations. |
113
+ | `RouteBuilder.name` | `(name) => this` | Sets the stable route name. |
114
+ | `RouteBuilder.middleware` | `(...names) => this` | Appends middleware aliases. |
115
+ | `RouteBuilder.bind` | `(param, model) => this` | Marks a path parameter for binding. |
116
+ | `RouteBuilder.api` | `() => this` | Additionally generates a JSON route handler. |
117
+ | `ResourceBuilder.bind` | `(param, model) => this` | Binds member parameters. |
118
+ | `ResourceBuilder.only` | `(...actions) => this` | Keeps named resource actions. |
119
+ | `ResourceBuilder.except` | `(...actions) => this` | Drops named resource actions. |
120
+ | `ResourceBuilder.api` | `() => this` | Marks remaining resource routes as `.api()`. |
121
+ | `route` | `(name, params?) => string` | Fills `{param}` placeholders. |
122
+ | `exportName` | `(definition) => string` | Stable generated export for a write route. |
123
+ | `uriToSegments` | `(uri) => string` | Converts `/posts/{post}` to `posts/[post]`. |
124
+ | `nativeToRequest` | `(native) => Promise<HttpRequest>` | Low-level native conversion. |
125
+ | `kernelToResponse` | `(result) => Promise<NextNativeResponse>` | Low-level result conversion. |
126
+ | `NextRedirect` | `class NextRedirect extends Error` | Control-flow throw with a `NEXT_REDIRECT` digest. |
127
+ | `isNextControlFlow` | `(error: unknown) => boolean` | True for Next navigation throws. |
128
+ | `PageProps` | `interface` | Async Next page props. |
129
+ | `NextNativeRequest` | `type` | `page`, `form`, or `http` native inputs. |
130
+ | `NextNativeResponse` | `type` | View, action, redirect, stream, or `next`. |
131
+ | `NextAdapterOptions` | `interface` | `root` plus optional capability overrides. |
132
+
133
+ ## Testing
134
+
135
+ Mount the adapter against a temp directory and call `page` / `dispatch` with the same controllers the generator would import. Point the kernel at `FakeDatabase` when a page loads models.
136
+
137
+ ```ts
138
+ import { NextAdapter, Route, page } from '@avelonjs/next'
139
+ import { createKernel } from '@avelonjs/core'
140
+
141
+ const adapter = new NextAdapter({ root: tempDir })
142
+ await adapter.mount(Route.manifest(), { kernel: createKernel() })
143
+ const Page = page(PostController, 'index', '/posts')
144
+ await Page({})
145
+ ```
146
+
147
+ ```sh
148
+ bun test
149
+ bun run typecheck
150
+ ```
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@avelonjs/next",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Next.js adapter for Avelon routing, server actions, and pages.",
6
+ "license": "MIT",
7
+ "author": "Ryan Yannelli <ryanyannelli@gmail.com>",
8
+ "homepage": "https://github.com/yannelli/avelon",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/yannelli/avelon.git",
12
+ "directory": "packages/next"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/yannelli/avelon/issues"
16
+ },
17
+ "keywords": [
18
+ "avelon",
19
+ "typescript",
20
+ "nextjs",
21
+ "adapter"
22
+ ],
23
+ "type": "module",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "scripts": {
36
+ "test": "bun test",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "@avelonjs/core": "workspace:*"
41
+ },
42
+ "peerDependencies": {
43
+ "next": ">=16.0.0"
44
+ },
45
+ "peerDependenciesMeta": {
46
+ "next": {
47
+ "optional": true
48
+ }
49
+ },
50
+ "devDependencies": {
51
+ "@avelonjs/assay": "workspace:*",
52
+ "@avelonjs/conformance": "workspace:*",
53
+ "@types/bun": "1.3.14",
54
+ "next": "16.2.12",
55
+ "typescript": "5.9.3"
56
+ },
57
+ "engines": {
58
+ "bun": ">=1.3.14"
59
+ }
60
+ }
package/src/adapter.ts ADDED
@@ -0,0 +1,194 @@
1
+ import type {
2
+ Adapter,
3
+ ControllerClass,
4
+ HttpMethod,
5
+ HttpRequest,
6
+ Kernel,
7
+ KernelResult,
8
+ MountOptions,
9
+ MountResult,
10
+ RouteDefinition,
11
+ RouteManifest,
12
+ ViewRef,
13
+ } from '@avelonjs/core'
14
+ import { Invalid } from '@avelonjs/core'
15
+
16
+ import { generateRouter } from './codegen'
17
+ import { flushOutgoingCookies, type ViewRegistry } from './cookies'
18
+ import { nativeToRequest } from './request'
19
+ import { kernelToResponse, type NextNativeResponse } from './response'
20
+ import { exportName } from './routing'
21
+
22
+ /** Capabilities the Next adapter reports. `serverActions` selects write transport (D26). */
23
+ export const nextCapabilities = {
24
+ fileSystemRouting: true,
25
+ serverActions: true,
26
+ streaming: true,
27
+ edgeMiddleware: false,
28
+ } as const
29
+
30
+ /** Native request shapes the Next adapter accepts at `toRequest`. */
31
+ export type NextNativeRequest =
32
+ | {
33
+ kind: 'page'
34
+ uri: string
35
+ params?: Record<string, string | string[] | undefined>
36
+ searchParams?: Record<string, string | string[] | undefined>
37
+ url?: string
38
+ cookies?: Record<string, string>
39
+ }
40
+ | {
41
+ kind: 'form'
42
+ uri: string
43
+ formData: FormData
44
+ url?: string
45
+ cookies?: Record<string, string>
46
+ }
47
+ | { kind: 'http'; request: Request; params?: Readonly<Record<string, string>> }
48
+
49
+ /** Options for {@link NextAdapter}. */
50
+ export interface NextAdapterOptions {
51
+ /** Application root where `app/(web)` and `framework/routing` are written. */
52
+ root: string
53
+ /** Override capabilities in tests. */
54
+ capabilities?: Partial<typeof nextCapabilities>
55
+ }
56
+
57
+ let boundAdapter: NextAdapter | undefined
58
+ let runtimeBoot: (() => Promise<void>) | undefined
59
+
60
+ /** Returns the adapter last passed to {@link bindAdapter}. */
61
+ export function getAdapter(): NextAdapter {
62
+ if (boundAdapter === undefined) {
63
+ throw new Invalid('Next adapter has not been mounted.', {
64
+ metadata: { fields: { adapter: ['Call NextAdapter.mount() before page() or dispatch().'] } },
65
+ })
66
+ }
67
+ return boundAdapter
68
+ }
69
+
70
+ /** Records the adapter generated code and runtime helpers dispatch through. */
71
+ export function bindAdapter(adapter: NextAdapter | undefined): void {
72
+ boundAdapter = adapter
73
+ }
74
+
75
+ /**
76
+ * Registers a one-time boot function `page` and `dispatch` call when the adapter
77
+ * is not yet attached. Next may render a page before the root layout finishes.
78
+ */
79
+ export function setRuntimeBoot(boot: (() => Promise<void>) | undefined): void {
80
+ runtimeBoot = boot
81
+ }
82
+
83
+ /** Returns the bound adapter, booting the runtime first when necessary. */
84
+ export async function ensureAdapter(): Promise<NextAdapter> {
85
+ if (boundAdapter !== undefined) return boundAdapter
86
+ if (runtimeBoot !== undefined) await runtimeBoot()
87
+ return getAdapter()
88
+ }
89
+
90
+ /** Next.js file-routing adapter. Translation only; the kernel owns the request pipeline. */
91
+ export class NextAdapter<TController extends ControllerClass = ControllerClass> implements Adapter<
92
+ TController,
93
+ ViewRef,
94
+ NextNativeRequest,
95
+ NextNativeResponse
96
+ > {
97
+ readonly name = 'next'
98
+ readonly capabilities: typeof nextCapabilities
99
+ readonly root: string
100
+ kernel: Kernel<TController> | undefined
101
+ manifest: RouteManifest<TController> | undefined
102
+ views: ViewRegistry | undefined
103
+
104
+ constructor(options: NextAdapterOptions) {
105
+ this.root = options.root
106
+ this.capabilities = { ...nextCapabilities, ...options.capabilities }
107
+ }
108
+
109
+ async mount(
110
+ manifest: RouteManifest<TController>,
111
+ options: MountOptions<TController>,
112
+ ): Promise<MountResult> {
113
+ this.kernel = options.kernel
114
+ this.manifest = manifest
115
+ bindAdapter(this as unknown as NextAdapter)
116
+ const generated = await generateRouter(manifest, this.root, {
117
+ basePath: options.basePath,
118
+ serverActions: this.capabilities.serverActions,
119
+ })
120
+ return {
121
+ mounted: manifest.routes.map((route) => route.name),
122
+ generated,
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Binds the kernel and manifest without rewriting generated files.
128
+ *
129
+ * Use this from Next instrumentation or the root layout. `mount()` stays the codegen path
130
+ * for `reeve route:sync`.
131
+ */
132
+ attach(
133
+ manifest: RouteManifest<TController>,
134
+ options: MountOptions<TController> & { views?: ViewRegistry },
135
+ ): void {
136
+ this.kernel = options.kernel
137
+ this.manifest = manifest
138
+ if (options.views !== undefined) this.views = options.views
139
+ bindAdapter(this as unknown as NextAdapter)
140
+ }
141
+
142
+ /** Registers string view tokens so `toResponse` can render React components. */
143
+ registerViews(views: ViewRegistry): void {
144
+ this.views = views
145
+ }
146
+
147
+ async toRequest(native: NextNativeRequest): Promise<HttpRequest> {
148
+ return nativeToRequest(native)
149
+ }
150
+
151
+ async toResponse(result: KernelResult<ViewRef>): Promise<NextNativeResponse> {
152
+ await flushOutgoingCookies()
153
+ return kernelToResponse(result, this.views)
154
+ }
155
+ }
156
+
157
+ /** Looks up a route by controller, action, and path pattern. */
158
+ export function findMountedRoute(
159
+ controller: ControllerClass,
160
+ action: string,
161
+ uri: string,
162
+ ): RouteDefinition<ControllerClass> {
163
+ const adapter = getAdapter()
164
+ const routes = adapter.manifest?.routes ?? []
165
+ const match = routes.find(
166
+ (route) => route.controller === controller && route.action === action && route.path === uri,
167
+ )
168
+ if (match !== undefined) return match as RouteDefinition<ControllerClass>
169
+ const byName = routes.find((route) => route.action === action && route.path === uri)
170
+ if (byName !== undefined) return byName as RouteDefinition<ControllerClass>
171
+ throw new Invalid(`No mounted route for ${controller.name}.${action} ${uri}.`, {
172
+ metadata: { fields: { route: [`${controller.name}.${action} is not mounted.`] } },
173
+ })
174
+ }
175
+
176
+ /** Hidden fields a generated form posts alongside `_method`. */
177
+ export function formProps(
178
+ name: string,
179
+ params: Readonly<Record<string, string | number>> = {},
180
+ ): { action: string; method: HttpMethod; fields: Record<string, string> } {
181
+ const adapter = getAdapter()
182
+ const route = adapter.manifest?.routes.find((entry) => entry.name === name)
183
+ if (route === undefined) {
184
+ throw new Invalid(`Route ${name} was not found.`, {
185
+ metadata: { fields: { route: [`${name} is not mounted.`] } },
186
+ })
187
+ }
188
+ const fields: Record<string, string> = {}
189
+ for (const [key, value] of Object.entries(params)) fields[key] = String(value)
190
+ if (route.method !== 'POST' && route.method !== 'GET') fields._method = route.method
191
+ return { action: exportName(route), method: route.method, fields }
192
+ }
193
+
194
+ export type { HttpMethod }
package/src/codegen.ts ADDED
@@ -0,0 +1,159 @@
1
+ import { mkdir, rm, writeFile } from 'node:fs/promises'
2
+ import { dirname, join } from 'node:path'
3
+ import {
4
+ Invalid,
5
+ type ControllerClass,
6
+ type RouteDefinition,
7
+ type RouteManifest,
8
+ } from '@avelonjs/core'
9
+
10
+ import { exportName, uriToSegments } from './routing'
11
+
12
+ const BANNER = `// Generated by \`reeve route:sync\`. Do not edit.\n`
13
+
14
+ function controllerImport(name: string): string {
15
+ return `@/app/Http/Controllers/${name}`
16
+ }
17
+
18
+ function webFile(root: string, uri: string): string {
19
+ const segments = uriToSegments(uri)
20
+ return join(root, 'app', '(web)', segments, 'page.tsx')
21
+ }
22
+
23
+ function apiFile(root: string, uri: string): string {
24
+ const path =
25
+ uri.startsWith('/api/') || uri === '/api' ? uri : `/api${uri.startsWith('/') ? uri : `/${uri}`}`
26
+ const segments = uriToSegments(path)
27
+ return join(root, 'app', '(api)', segments, 'route.ts')
28
+ }
29
+
30
+ function publicPath(root: string, file: string): string {
31
+ return file.slice(root.length).replaceAll('\\', '/').replace(/^\//, '')
32
+ }
33
+
34
+ async function writeGenerated(file: string, contents: string): Promise<void> {
35
+ await mkdir(dirname(file), { recursive: true })
36
+ await writeFile(file, contents)
37
+ }
38
+
39
+ /** Wipes and rewrites the Next router from a route manifest. Destructive by design. */
40
+ export async function generateRouter<TController extends ControllerClass>(
41
+ manifest: RouteManifest<TController>,
42
+ root: string,
43
+ options: { basePath?: string; serverActions: boolean },
44
+ ): Promise<readonly string[]> {
45
+ const webRoot = join(root, 'app', '(web)')
46
+ const apiRoot = join(root, 'app', '(api)')
47
+ const routingRoot = join(root, 'framework', 'routing')
48
+ await rm(webRoot, { recursive: true, force: true })
49
+ await rm(apiRoot, { recursive: true, force: true })
50
+ await mkdir(routingRoot, { recursive: true })
51
+
52
+ const generated: string[] = []
53
+ const seenPages = new Set<string>()
54
+ const apiBundles = new Map<string, RouteDefinition<TController>[]>()
55
+
56
+ for (const definition of manifest.routes) {
57
+ const path = options.basePath
58
+ ? `${options.basePath.replace(/\/$/, '')}${definition.path}`
59
+ : definition.path
60
+
61
+ if (definition.method === 'GET') {
62
+ const file = webFile(root, path)
63
+ if (seenPages.has(file)) {
64
+ throw new Invalid(`Two GET routes resolve to the same page file: ${definition.path}.`, {
65
+ metadata: { fields: { route: [`${definition.path} collides with another GET route.`] } },
66
+ })
67
+ }
68
+ seenPages.add(file)
69
+ await writeGenerated(
70
+ file,
71
+ BANNER +
72
+ `import { ${definition.controller.name} } from '${controllerImport(definition.controller.name)}'\n` +
73
+ `import { page } from '@avelonjs/next'\n\n` +
74
+ `export const dynamic = 'force-dynamic'\n\n` +
75
+ `export default page(${definition.controller.name}, '${definition.action}', '${definition.path}')\n`,
76
+ )
77
+ generated.push(publicPath(root, file))
78
+ }
79
+
80
+ if (definition.api) {
81
+ const file = apiFile(root, path)
82
+ const bundle = apiBundles.get(file) ?? []
83
+ bundle.push(definition)
84
+ apiBundles.set(file, bundle)
85
+ }
86
+ }
87
+
88
+ for (const [file, bundle] of apiBundles) {
89
+ const controllers = [...new Set(bundle.map((definition) => definition.controller.name))]
90
+ const lines = [
91
+ BANNER.trim(),
92
+ ...controllers.map((name) => `import { ${name} } from '${controllerImport(name)}'`),
93
+ `import { handleRoute } from '@avelonjs/next'`,
94
+ '',
95
+ ...bundle.map((definition) => {
96
+ const method = definition.method === 'DELETE' ? 'DELETE' : definition.method
97
+ return (
98
+ `export async function ${method}(request: Request, context: { params: Promise<Record<string, string>> }) {\n` +
99
+ ` return handleRoute(${definition.controller.name}, '${definition.action}', '${definition.path}', request, await context.params)\n` +
100
+ `}\n`
101
+ )
102
+ }),
103
+ ]
104
+ await writeGenerated(file, `${lines.join('\n')}\n`)
105
+ generated.push(publicPath(root, file))
106
+ }
107
+
108
+ const writes = manifest.routes.filter((definition) => definition.method !== 'GET')
109
+ if (options.serverActions && writes.length > 0) {
110
+ const controllers = [...new Set(writes.map((definition) => definition.controller.name))]
111
+ const body = [
112
+ `'use server'`,
113
+ BANNER.trim(),
114
+ ...controllers.map((name) => `import { ${name} } from '${controllerImport(name)}'`),
115
+ `import { dispatch } from '@avelonjs/next'`,
116
+ '',
117
+ ...writes.map(
118
+ (definition) =>
119
+ `export async function ${exportName(definition)}(formData: FormData) {\n` +
120
+ ` return dispatch(${definition.controller.name}, '${definition.action}', '${definition.path}', formData)\n` +
121
+ `}`,
122
+ ),
123
+ '',
124
+ ].join('\n')
125
+ const file = join(routingRoot, 'actions.generated.ts')
126
+ await writeFile(file, body)
127
+ generated.push(publicPath(root, file))
128
+ }
129
+
130
+ const entries = manifest.routes.map((definition) => ({
131
+ method: definition.method,
132
+ path: definition.path,
133
+ name: definition.name,
134
+ controller: definition.controller.name,
135
+ action: definition.action,
136
+ middleware: definition.middleware,
137
+ api: definition.api,
138
+ export: definition.method === 'GET' ? null : exportName(definition),
139
+ }))
140
+ const manifestFile = join(routingRoot, 'manifest.generated.ts')
141
+ await writeFile(
142
+ manifestFile,
143
+ BANNER + `export const manifest = ${JSON.stringify(entries, null, 2)} as const\n`,
144
+ )
145
+ generated.push(publicPath(root, manifestFile))
146
+
147
+ const layout = join(webRoot, 'layout.tsx')
148
+ await mkdir(webRoot, { recursive: true })
149
+ await writeFile(
150
+ layout,
151
+ BANNER +
152
+ `export { default } from '@/resources/views/layouts/AppLayout'\n` +
153
+ `export { metadata } from '@/resources/views/layouts/AppLayout'\n` +
154
+ `export { viewport } from '@/resources/views/layouts/AppLayout'\n`,
155
+ )
156
+ generated.push(publicPath(root, layout))
157
+
158
+ return generated
159
+ }