@avelonjs/next 0.1.0 → 0.3.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/README.md +4 -2
- package/package.json +8 -2
- package/src/cookies.ts +2 -1
- package/src/dispatch.ts +56 -30
- package/src/failures.ts +36 -0
- package/src/index.ts +2 -1
- package/src/page.ts +22 -5
- package/src/response.ts +13 -5
package/README.md
CHANGED
|
@@ -86,15 +86,17 @@ Writes become server actions in `framework/routing/actions.generated.ts`. `.api(
|
|
|
86
86
|
| `getAdapter` | `() => NextAdapter` | Returns the bound adapter or throws. |
|
|
87
87
|
| `setRuntimeBoot` | `(boot \| undefined) => void` | Registers a boot function `page` and `dispatch` call when unbound. |
|
|
88
88
|
| `ensureAdapter` | `() => Promise<NextAdapter>` | Returns the bound adapter, booting first when needed. |
|
|
89
|
-
| `page` | `(controller, action, uri) =>
|
|
89
|
+
| `page` | `(controller, action, uri) => NextPage` | Next page wrapper used by generated `page.tsx`. |
|
|
90
|
+
| `NextPage` | `(props: PageProps) => Promise<ReactNode>` | Return type Next's AppPageConfig accepts. |
|
|
90
91
|
| `dispatch` | `(controller, action, uri, formData) => Promise<NextNativeResponse>` | Server-action entry used by generated writes. |
|
|
91
92
|
| `handleRoute` | `(controller, action, uri, request, params?) => Promise<NextNativeResponse>` | JSON route-handler entry used by `.api()` files. |
|
|
93
|
+
| `reportFailure` | `(scope, uri, error) => void` | Writes a request failure and its cause chain to stderr. |
|
|
92
94
|
| `middleware` | `(request, routes?) => Promise<NextNativeResponse>` | `proxy.ts` / `middleware.ts` bridge. Pass generated routes on the Node proxy. |
|
|
93
95
|
| `MiddlewareRoute` | `interface` | Method, path, and middleware aliases for Edge auth. |
|
|
94
96
|
| `setCookie` | `(name, value, options?) => void` | Queues a cookie write for the current Next request. |
|
|
95
97
|
| `clearCookie` | `(name, options?) => void` | Queues a cookie deletion for the current Next request. |
|
|
96
98
|
| `PendingCookie` | `interface` | Queued cookie name, value, and options. |
|
|
97
|
-
| `ViewRegistry` | `type` | String view tokens mapped to
|
|
99
|
+
| `ViewRegistry` | `type` | String view tokens mapped to functions returning `ReactNode`. |
|
|
98
100
|
| `formProps` | `(name, params?) => { action, method, fields }` | Hidden fields and action export for a named write route. |
|
|
99
101
|
| `generateRouter` | `(manifest, root, options) => Promise<readonly string[]>` | Destructive codegen used by `mount`. |
|
|
100
102
|
| `findMountedRoute` | `(controller, action, uri) => RouteDefinition` | Resolves a generated helper back to its manifest entry. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avelonjs/next",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Next.js adapter for Avelon routing, server actions, and pages.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,18 +40,24 @@
|
|
|
40
40
|
"@avelonjs/core": "workspace:*"
|
|
41
41
|
},
|
|
42
42
|
"peerDependencies": {
|
|
43
|
-
"next": ">=16.0.0"
|
|
43
|
+
"next": ">=16.0.0",
|
|
44
|
+
"react": ">=19.0.0"
|
|
44
45
|
},
|
|
45
46
|
"peerDependenciesMeta": {
|
|
46
47
|
"next": {
|
|
47
48
|
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"react": {
|
|
51
|
+
"optional": true
|
|
48
52
|
}
|
|
49
53
|
},
|
|
50
54
|
"devDependencies": {
|
|
51
55
|
"@avelonjs/assay": "workspace:*",
|
|
52
56
|
"@avelonjs/conformance": "workspace:*",
|
|
53
57
|
"@types/bun": "1.3.14",
|
|
58
|
+
"@types/react": "19.2.2",
|
|
54
59
|
"next": "16.2.12",
|
|
60
|
+
"react": "19.2.0",
|
|
55
61
|
"typescript": "5.9.3"
|
|
56
62
|
},
|
|
57
63
|
"engines": {
|
package/src/cookies.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
2
|
import type { CookieOptions } from '@avelonjs/core'
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
3
4
|
|
|
4
5
|
/** One cookie write queued during kernel dispatch. */
|
|
5
6
|
export interface PendingCookie {
|
|
@@ -20,7 +21,7 @@ const storage = new AsyncLocalStorage<CookieStore>()
|
|
|
20
21
|
const fallbackOutgoing: PendingCookie[] = []
|
|
21
22
|
|
|
22
23
|
/** View and form components the Next adapter can render from a string view token. */
|
|
23
|
-
export type ViewRegistry = Readonly<Record<string, (props: object) =>
|
|
24
|
+
export type ViewRegistry = Readonly<Record<string, (props: object) => ReactNode>>
|
|
24
25
|
|
|
25
26
|
function currentStore(): CookieStore | undefined {
|
|
26
27
|
return storage.getStore()
|
package/src/dispatch.ts
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
import { finishResponse, type ControllerClass } from '@avelonjs/core'
|
|
1
|
+
import { finishResponse, mapKernelException, type ControllerClass } from '@avelonjs/core'
|
|
2
2
|
|
|
3
3
|
import { ensureAdapter, findMountedRoute } from './adapter'
|
|
4
4
|
import { readIncomingCookies, withCookies } from './cookies'
|
|
5
|
+
import { reportFailure } from './failures'
|
|
5
6
|
import { isNextControlFlow, type NextNativeResponse } from './response'
|
|
6
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Reports a failure the kernel never saw, and answers with it when the taxonomy can.
|
|
10
|
+
*
|
|
11
|
+
* `ensureAdapter` boots the application on the first request, so a driver that refuses its
|
|
12
|
+
* environment throws here rather than inside `kernel.dispatch`, where `mapKernelException` would
|
|
13
|
+
* have turned it into a result. Unhandled, it reaches the host as a 500 with an empty log line and
|
|
14
|
+
* a blank screen — the state a first deploy with one missing variable lands in.
|
|
15
|
+
*/
|
|
16
|
+
function failedAction(
|
|
17
|
+
scope: string,
|
|
18
|
+
uri: string,
|
|
19
|
+
error: unknown,
|
|
20
|
+
): NextNativeResponse & { kind: 'action' } {
|
|
21
|
+
reportFailure(scope, uri, error)
|
|
22
|
+
const mapped = mapKernelException(error)
|
|
23
|
+
if (mapped?.type !== 'action') throw error
|
|
24
|
+
return {
|
|
25
|
+
kind: 'action',
|
|
26
|
+
ok: mapped.ok,
|
|
27
|
+
data: mapped.data,
|
|
28
|
+
errors: mapped.errors,
|
|
29
|
+
status: mapped.status ?? 422,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
7
33
|
/**
|
|
8
34
|
* Runs a write action behind a generated server action. Validation failures return action state
|
|
9
35
|
* for `useActionState`; redirects keep propagating.
|
|
@@ -14,25 +40,25 @@ export async function dispatch(
|
|
|
14
40
|
uri: string,
|
|
15
41
|
formData: FormData,
|
|
16
42
|
): Promise<NextNativeResponse> {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
43
|
+
try {
|
|
44
|
+
const adapter = await ensureAdapter()
|
|
45
|
+
const kernel = adapter.kernel
|
|
46
|
+
if (kernel === undefined) {
|
|
47
|
+
throw new Error('Next adapter kernel is missing. Mount the adapter before dispatch().')
|
|
48
|
+
}
|
|
49
|
+
const cookies = await readIncomingCookies()
|
|
50
|
+
return await withCookies(cookies, async () => {
|
|
51
|
+
const request = await adapter.toRequest({ kind: 'form', uri, formData, cookies })
|
|
52
|
+
const route = findMountedRoute(controller, action, uri)
|
|
27
53
|
const result = await kernel.dispatch(route, request)
|
|
28
54
|
const response = await adapter.toResponse(result)
|
|
29
55
|
await finishResponse()
|
|
30
56
|
return response
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (isNextControlFlow(error)) throw error
|
|
60
|
+
return failedAction('action', uri, error)
|
|
61
|
+
}
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
/** JSON route-handler entry used by generated `app/(api)` files. */
|
|
@@ -43,11 +69,6 @@ export async function handleRoute(
|
|
|
43
69
|
request: Request,
|
|
44
70
|
params: Readonly<Record<string, string>> = {},
|
|
45
71
|
): Promise<NextNativeResponse> {
|
|
46
|
-
const adapter = await ensureAdapter()
|
|
47
|
-
const kernel = adapter.kernel
|
|
48
|
-
if (kernel === undefined) {
|
|
49
|
-
throw new Error('Next adapter kernel is missing. Mount the adapter before handleRoute().')
|
|
50
|
-
}
|
|
51
72
|
const cookies: Record<string, string> = {}
|
|
52
73
|
const header = request.headers.get('cookie')
|
|
53
74
|
if (header !== null && header.length > 0) {
|
|
@@ -58,17 +79,22 @@ export async function handleRoute(
|
|
|
58
79
|
cookies[trimmed.slice(0, eq)] = decodeURIComponent(trimmed.slice(eq + 1))
|
|
59
80
|
}
|
|
60
81
|
}
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
82
|
+
try {
|
|
83
|
+
const adapter = await ensureAdapter()
|
|
84
|
+
const kernel = adapter.kernel
|
|
85
|
+
if (kernel === undefined) {
|
|
86
|
+
throw new Error('Next adapter kernel is missing. Mount the adapter before handleRoute().')
|
|
87
|
+
}
|
|
88
|
+
return await withCookies(cookies, async () => {
|
|
89
|
+
const http = await adapter.toRequest({ kind: 'http', request, params })
|
|
90
|
+
const route = findMountedRoute(controller, action, uri)
|
|
65
91
|
const result = await kernel.dispatch(route, http)
|
|
66
92
|
const response = await adapter.toResponse(result)
|
|
67
93
|
await finishResponse()
|
|
68
94
|
return response
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
95
|
+
})
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (isNextControlFlow(error)) throw error
|
|
98
|
+
return failedAction('route', uri, error)
|
|
99
|
+
}
|
|
74
100
|
}
|
package/src/failures.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AvelonError } from '@avelonjs/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Writes a request failure to stderr.
|
|
5
|
+
*
|
|
6
|
+
* A framework error thrown before the kernel exists — a driver refusing the environment it was
|
|
7
|
+
* given, a missing key — leaves `dispatch` and `page` with nothing to map, so it reaches the host
|
|
8
|
+
* as a bare 500. A serverless host records that status and no reason: the exception never crossed
|
|
9
|
+
* a boundary that logs. This is that boundary.
|
|
10
|
+
*/
|
|
11
|
+
export function reportFailure(scope: string, uri: string, error: unknown): void {
|
|
12
|
+
const lines = [`[avelon] ${scope} ${uri} failed: ${describe(error)}`]
|
|
13
|
+
for (const cause of causes(error)) lines.push(`[avelon] caused by: ${describe(cause)}`)
|
|
14
|
+
const stack = error instanceof Error ? error.stack : undefined
|
|
15
|
+
if (stack !== undefined) lines.push(stack)
|
|
16
|
+
console.error(lines.join('\n'))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function describe(error: unknown): string {
|
|
20
|
+
if (error instanceof AvelonError) {
|
|
21
|
+
return `${error.name}(${error.code}) ${error.message} ${JSON.stringify(error.metadata)}`
|
|
22
|
+
}
|
|
23
|
+
if (error instanceof Error) return `${error.name}: ${error.message}`
|
|
24
|
+
return String(error)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function* causes(error: unknown): Generator<unknown> {
|
|
28
|
+
let current: unknown = error
|
|
29
|
+
// A driver keeps the vendor payload as `.cause`; a config failure keeps the value it rejected.
|
|
30
|
+
for (let depth = 0; depth < 4; depth += 1) {
|
|
31
|
+
const cause = (current as { cause?: unknown } | null)?.cause
|
|
32
|
+
if (cause === undefined || cause === null) return
|
|
33
|
+
yield cause
|
|
34
|
+
current = cause
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,10 +11,11 @@ export {
|
|
|
11
11
|
type NextNativeRequest,
|
|
12
12
|
} from './adapter'
|
|
13
13
|
export { clearCookie, setCookie, type PendingCookie, type ViewRegistry } from './cookies'
|
|
14
|
+
export { reportFailure } from './failures'
|
|
14
15
|
export { generateRouter } from './codegen'
|
|
15
16
|
export { dispatch, handleRoute } from './dispatch'
|
|
16
17
|
export { middleware, type MiddlewareRoute } from './middleware'
|
|
17
|
-
export { page, type PageProps } from './page'
|
|
18
|
+
export { page, type NextPage, type PageProps } from './page'
|
|
18
19
|
export {
|
|
19
20
|
isNextControlFlow,
|
|
20
21
|
kernelToResponse,
|
package/src/page.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { finishResponse, type ControllerClass } from '@avelonjs/core'
|
|
1
|
+
import { finishResponse, Invalid, type ControllerClass } from '@avelonjs/core'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
2
3
|
|
|
3
4
|
import { ensureAdapter, findMountedRoute } from './adapter'
|
|
4
5
|
import { readIncomingCookies, withCookies } from './cookies'
|
|
6
|
+
import { reportFailure } from './failures'
|
|
5
7
|
import { isNextControlFlow } from './response'
|
|
6
8
|
|
|
7
9
|
/** Next page props after awaiting the async params/searchParams contracts. */
|
|
@@ -14,6 +16,9 @@ export interface PageProps {
|
|
|
14
16
|
| Record<string, string | string[] | undefined>
|
|
15
17
|
}
|
|
16
18
|
|
|
19
|
+
/** Generated `page.tsx` default export. Next's AppPageConfig requires `Promise<ReactNode>`. */
|
|
20
|
+
export type NextPage = (props: PageProps) => Promise<ReactNode>
|
|
21
|
+
|
|
17
22
|
async function resolveRecord(
|
|
18
23
|
value:
|
|
19
24
|
| Promise<Record<string, string | string[] | undefined>>
|
|
@@ -28,9 +33,18 @@ async function resolveRecord(
|
|
|
28
33
|
* Wraps a controller action as a Next page component. Generated `page.tsx` files are four lines
|
|
29
34
|
* that call this helper.
|
|
30
35
|
*/
|
|
31
|
-
export function page(controller: ControllerClass, action: string, uri: string) {
|
|
32
|
-
return async function Page(props: PageProps): Promise<
|
|
33
|
-
|
|
36
|
+
export function page(controller: ControllerClass, action: string, uri: string): NextPage {
|
|
37
|
+
return async function Page(props: PageProps): Promise<ReactNode> {
|
|
38
|
+
// `ensureAdapter` boots the application here on the first request, so a driver refusing its
|
|
39
|
+
// environment throws before there is a kernel to map it. Reporting it is the only record a
|
|
40
|
+
// serverless host keeps of why the page 500'd.
|
|
41
|
+
let adapter
|
|
42
|
+
try {
|
|
43
|
+
adapter = await ensureAdapter()
|
|
44
|
+
} catch (error) {
|
|
45
|
+
reportFailure('page', uri, error)
|
|
46
|
+
throw error
|
|
47
|
+
}
|
|
34
48
|
const kernel = adapter.kernel
|
|
35
49
|
if (kernel === undefined) {
|
|
36
50
|
throw new Error('Next adapter kernel is missing. Mount the adapter before rendering a page.')
|
|
@@ -67,9 +81,12 @@ export function page(controller: ControllerClass, action: string, uri: string) {
|
|
|
67
81
|
}
|
|
68
82
|
throw new Error(message)
|
|
69
83
|
}
|
|
70
|
-
return
|
|
84
|
+
throw new Invalid('The page action did not return a view.', {
|
|
85
|
+
metadata: { fields: { view: ['Expected a view result.'] } },
|
|
86
|
+
})
|
|
71
87
|
} catch (error) {
|
|
72
88
|
if (isNextControlFlow(error)) throw error
|
|
89
|
+
reportFailure('page', uri, error)
|
|
73
90
|
throw error
|
|
74
91
|
}
|
|
75
92
|
})
|
package/src/response.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Invalid, type KernelResult, type ViewRef } from '@avelonjs/core'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
2
3
|
|
|
3
4
|
import type { ViewRegistry } from './cookies'
|
|
4
5
|
|
|
5
6
|
/** Native response shapes the Next adapter returns from `toResponse`. */
|
|
6
7
|
export type NextNativeResponse =
|
|
7
|
-
| { kind: 'view'; body:
|
|
8
|
+
| { kind: 'view'; body: ReactNode; status: number }
|
|
8
9
|
| {
|
|
9
10
|
kind: 'action'
|
|
10
11
|
ok: boolean
|
|
@@ -43,13 +44,20 @@ export function isNextControlFlow(error: unknown): boolean {
|
|
|
43
44
|
return typeof digest === 'string' && /^NEXT_(REDIRECT|HTTP_ERROR_FALLBACK|NOT_FOUND)/.test(digest)
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
function
|
|
47
|
-
|
|
47
|
+
function isViewRenderer(view: unknown): view is (props: object) => ReactNode {
|
|
48
|
+
return typeof view === 'function'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderView(view: unknown, props: object, views?: ViewRegistry): ReactNode {
|
|
52
|
+
if (isViewRenderer(view)) return view(props)
|
|
48
53
|
if (typeof view === 'string' && views !== undefined) {
|
|
49
54
|
const component = views[view]
|
|
50
55
|
if (component !== undefined) return component(props)
|
|
51
56
|
}
|
|
52
|
-
|
|
57
|
+
const token = typeof view === 'string' ? view : 'anonymous'
|
|
58
|
+
throw new Invalid(`No view is registered for ${token}.`, {
|
|
59
|
+
metadata: { fields: { view: [`${token} is not in the view registry.`] } },
|
|
60
|
+
})
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/**
|