@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 +21 -0
- package/README.md +150 -0
- package/package.json +60 -0
- package/src/adapter.ts +194 -0
- package/src/codegen.ts +159 -0
- package/src/cookies.ts +137 -0
- package/src/dispatch.ts +74 -0
- package/src/index.ts +25 -0
- package/src/middleware.ts +75 -0
- package/src/page.ts +77 -0
- package/src/request.ts +139 -0
- package/src/response.ts +88 -0
- package/src/routing.ts +275 -0
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
|
+
}
|