@avelonjs/next 0.1.0 → 0.3.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/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) => PageComponent` | Next page wrapper used by generated `page.tsx`. |
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 render functions. |
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.0",
3
+ "version": "0.3.0",
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) => unknown>>
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
- const adapter = await ensureAdapter()
18
- const kernel = adapter.kernel
19
- if (kernel === undefined) {
20
- throw new Error('Next adapter kernel is missing. Mount the adapter before dispatch().')
21
- }
22
- const cookies = await readIncomingCookies()
23
- return await withCookies(cookies, async () => {
24
- const request = await adapter.toRequest({ kind: 'form', uri, formData, cookies })
25
- const route = findMountedRoute(controller, action, uri)
26
- try {
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
- } catch (error) {
32
- if (isNextControlFlow(error)) throw error
33
- throw error
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
- return await withCookies(cookies, async () => {
62
- const http = await adapter.toRequest({ kind: 'http', request, params })
63
- const route = findMountedRoute(controller, action, uri)
64
- try {
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
- } catch (error) {
70
- if (isNextControlFlow(error)) throw error
71
- throw error
72
- }
73
- })
95
+ })
96
+ } catch (error) {
97
+ if (isNextControlFlow(error)) throw error
98
+ return failedAction('route', uri, error)
99
+ }
74
100
  }
@@ -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<unknown> {
33
- const adapter = await ensureAdapter()
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 response
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 { KernelResult, ViewRef } from '@avelonjs/core'
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: unknown; status: number }
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 renderView(view: unknown, props: object, views?: ViewRegistry): unknown {
47
- if (typeof view === 'function') return (view as (value: object) => unknown)(props)
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
- return { view, props }
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
  /**