@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/src/cookies.ts ADDED
@@ -0,0 +1,137 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks'
2
+ import type { CookieOptions } from '@avelonjs/core'
3
+
4
+ /** One cookie write queued during kernel dispatch. */
5
+ export interface PendingCookie {
6
+ /** Cookie name. */
7
+ name: string
8
+ /** Cookie value. `null` deletes the cookie. */
9
+ value: string | null
10
+ /** Transport-neutral cookie options. */
11
+ options?: CookieOptions
12
+ }
13
+
14
+ interface CookieStore {
15
+ incoming: Record<string, string>
16
+ outgoing: PendingCookie[]
17
+ }
18
+
19
+ const storage = new AsyncLocalStorage<CookieStore>()
20
+ const fallbackOutgoing: PendingCookie[] = []
21
+
22
+ /** 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
+
25
+ function currentStore(): CookieStore | undefined {
26
+ return storage.getStore()
27
+ }
28
+
29
+ /** Runs `fn` with a request-scoped cookie jar. */
30
+ export async function withCookies<T>(
31
+ incoming: Record<string, string>,
32
+ fn: () => Promise<T>,
33
+ ): Promise<T> {
34
+ return await storage.run({ incoming, outgoing: [] }, fn)
35
+ }
36
+
37
+ /** Queues a cookie write for the current Next request. No-op outside a request when unused. */
38
+ export function setCookie(name: string, value: string, options?: CookieOptions): void {
39
+ const entry: PendingCookie = { name, value, options }
40
+ const store = currentStore()
41
+ if (store !== undefined) {
42
+ store.outgoing.push(entry)
43
+ return
44
+ }
45
+ fallbackOutgoing.push(entry)
46
+ }
47
+
48
+ /** Queues a cookie deletion for the current Next request. */
49
+ export function clearCookie(name: string, options?: Pick<CookieOptions, 'domain' | 'path'>): void {
50
+ const entry: PendingCookie = { name, value: null, options }
51
+ const store = currentStore()
52
+ if (store !== undefined) {
53
+ store.outgoing.push(entry)
54
+ return
55
+ }
56
+ fallbackOutgoing.push(entry)
57
+ }
58
+
59
+ /** Returns and clears queued cookie writes. */
60
+ export function takePendingCookies(): readonly PendingCookie[] {
61
+ const store = currentStore()
62
+ if (store !== undefined) {
63
+ const queued = store.outgoing.splice(0)
64
+ return queued
65
+ }
66
+ const queued = fallbackOutgoing.splice(0)
67
+ return queued
68
+ }
69
+
70
+ /** Incoming cookies for the current request, or an empty map. */
71
+ export function incomingCookies(): Record<string, string> {
72
+ return currentStore()?.incoming ?? {}
73
+ }
74
+
75
+ function cookieHeader(cookies: Record<string, string>): string {
76
+ return Object.entries(cookies)
77
+ .map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
78
+ .join('; ')
79
+ }
80
+
81
+ /**
82
+ * Reads cookies from `next/headers` when the Next runtime is present.
83
+ * Returns an empty map in tests and other non-Next processes.
84
+ */
85
+ export async function readIncomingCookies(): Promise<Record<string, string>> {
86
+ try {
87
+ const headers = (await import('next/headers')) as {
88
+ cookies: () => Promise<{ getAll: () => readonly { name: string; value: string }[] }>
89
+ }
90
+ const jar = await headers.cookies()
91
+ const result: Record<string, string> = {}
92
+ for (const cookie of jar.getAll()) result[cookie.name] = cookie.value
93
+ return result
94
+ } catch {
95
+ return {}
96
+ }
97
+ }
98
+
99
+ function toNextCookieOptions(options?: CookieOptions): Record<string, unknown> {
100
+ if (options === undefined) return { path: '/', httpOnly: true, sameSite: 'lax' }
101
+ return {
102
+ domain: options.domain,
103
+ expires: options.expires,
104
+ httpOnly: options.httpOnly ?? true,
105
+ maxAge: options.maxAge,
106
+ partitioned: options.partitioned,
107
+ path: options.path ?? '/',
108
+ sameSite: options.sameSite ?? 'lax',
109
+ secure: options.secure,
110
+ }
111
+ }
112
+
113
+ /** Writes queued cookies through `next/headers` when available. */
114
+ export async function flushOutgoingCookies(): Promise<void> {
115
+ const pending = takePendingCookies()
116
+ if (pending.length === 0) return
117
+ try {
118
+ const headers = (await import('next/headers')) as {
119
+ cookies: () => Promise<{
120
+ set: (name: string, value: string, options?: Record<string, unknown>) => void
121
+ delete: (name: string) => void
122
+ }>
123
+ }
124
+ const jar = await headers.cookies()
125
+ for (const cookie of pending) {
126
+ if (cookie.value === null) jar.delete(cookie.name)
127
+ else jar.set(cookie.name, cookie.value, toNextCookieOptions(cookie.options))
128
+ }
129
+ } catch {
130
+ // Tests and non-Next hosts keep the queue discarded after takePendingCookies.
131
+ }
132
+ }
133
+
134
+ /** Serializes incoming cookies for a Fetch `Request` header. */
135
+ export function serializeCookieHeader(cookies: Record<string, string>): string {
136
+ return cookieHeader(cookies)
137
+ }
@@ -0,0 +1,74 @@
1
+ import { finishResponse, type ControllerClass } from '@avelonjs/core'
2
+
3
+ import { ensureAdapter, findMountedRoute } from './adapter'
4
+ import { readIncomingCookies, withCookies } from './cookies'
5
+ import { isNextControlFlow, type NextNativeResponse } from './response'
6
+
7
+ /**
8
+ * Runs a write action behind a generated server action. Validation failures return action state
9
+ * for `useActionState`; redirects keep propagating.
10
+ */
11
+ export async function dispatch(
12
+ controller: ControllerClass,
13
+ action: string,
14
+ uri: string,
15
+ formData: FormData,
16
+ ): 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 {
27
+ const result = await kernel.dispatch(route, request)
28
+ const response = await adapter.toResponse(result)
29
+ await finishResponse()
30
+ return response
31
+ } catch (error) {
32
+ if (isNextControlFlow(error)) throw error
33
+ throw error
34
+ }
35
+ })
36
+ }
37
+
38
+ /** JSON route-handler entry used by generated `app/(api)` files. */
39
+ export async function handleRoute(
40
+ controller: ControllerClass,
41
+ action: string,
42
+ uri: string,
43
+ request: Request,
44
+ params: Readonly<Record<string, string>> = {},
45
+ ): 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
+ const cookies: Record<string, string> = {}
52
+ const header = request.headers.get('cookie')
53
+ if (header !== null && header.length > 0) {
54
+ for (const part of header.split(';')) {
55
+ const trimmed = part.trim()
56
+ const eq = trimmed.indexOf('=')
57
+ if (eq <= 0) continue
58
+ cookies[trimmed.slice(0, eq)] = decodeURIComponent(trimmed.slice(eq + 1))
59
+ }
60
+ }
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 {
65
+ const result = await kernel.dispatch(route, http)
66
+ const response = await adapter.toResponse(result)
67
+ await finishResponse()
68
+ return response
69
+ } catch (error) {
70
+ if (isNextControlFlow(error)) throw error
71
+ throw error
72
+ }
73
+ })
74
+ }
package/src/index.ts ADDED
@@ -0,0 +1,25 @@
1
+ export {
2
+ NextAdapter,
3
+ bindAdapter,
4
+ ensureAdapter,
5
+ findMountedRoute,
6
+ formProps,
7
+ getAdapter,
8
+ setRuntimeBoot,
9
+ nextCapabilities,
10
+ type NextAdapterOptions,
11
+ type NextNativeRequest,
12
+ } from './adapter'
13
+ export { clearCookie, setCookie, type PendingCookie, type ViewRegistry } from './cookies'
14
+ export { generateRouter } from './codegen'
15
+ export { dispatch, handleRoute } from './dispatch'
16
+ export { middleware, type MiddlewareRoute } from './middleware'
17
+ export { page, type PageProps } from './page'
18
+ export {
19
+ isNextControlFlow,
20
+ kernelToResponse,
21
+ NextRedirect,
22
+ type NextNativeResponse,
23
+ } from './response'
24
+ export { nativeToRequest } from './request'
25
+ export { exportName, ResourceBuilder, Route, RouteBuilder, route, uriToSegments } from './routing'
@@ -0,0 +1,75 @@
1
+ import type { HttpMethod } from '@avelonjs/core'
2
+
3
+ import { getAdapter } from './adapter'
4
+ import type { NextNativeResponse } from './response'
5
+
6
+ function pathnameOf(url: string): string {
7
+ return new URL(url).pathname
8
+ }
9
+
10
+ function scorePath(pattern: string, pathname: string): number | undefined {
11
+ const patternParts = pattern.split('/').filter(Boolean)
12
+ const pathParts = pathname.split('/').filter(Boolean)
13
+ if (patternParts.length !== pathParts.length) return undefined
14
+ let score = 0
15
+ for (let i = 0; i < patternParts.length; i += 1) {
16
+ const expected = patternParts[i]
17
+ const actual = pathParts[i]
18
+ if (expected === undefined || actual === undefined) return undefined
19
+ if (expected.startsWith('{') && expected.endsWith('}')) continue
20
+ if (expected !== actual) return undefined
21
+ score += 1
22
+ }
23
+ return score
24
+ }
25
+
26
+ /** Route slice middleware needs to decide an auth redirect. */
27
+ export interface MiddlewareRoute {
28
+ /** Transport-neutral HTTP method. */
29
+ readonly method: string
30
+ /** Route path with named parameters. */
31
+ readonly path: string
32
+ /** Middleware aliases declared on the route. */
33
+ readonly middleware: readonly string[]
34
+ }
35
+
36
+ /**
37
+ * Next `middleware.ts` entry. Delegates auth cookies to the kernel's later dispatch; at the edge
38
+ * it only redirects unauthenticated requests onto routes that declare the `auth` alias.
39
+ *
40
+ * Pass `routes` from `manifest.generated.ts` so Edge middleware does not need a bound adapter.
41
+ */
42
+ export async function middleware(
43
+ request: Request,
44
+ routes?: readonly MiddlewareRoute[],
45
+ ): Promise<NextNativeResponse> {
46
+ const resolved =
47
+ routes ??
48
+ ((() => {
49
+ try {
50
+ return getAdapter().manifest?.routes ?? []
51
+ } catch {
52
+ return []
53
+ }
54
+ })() as readonly MiddlewareRoute[])
55
+ const method = request.method.toUpperCase() as HttpMethod
56
+ const pathname = pathnameOf(request.url)
57
+ let best: { path: string; middleware: readonly string[]; score: number } | undefined
58
+ for (const route of resolved) {
59
+ if (route.method !== method && !(method === 'GET' && route.method === 'GET')) continue
60
+ const score = scorePath(route.path, pathname)
61
+ if (score === undefined) continue
62
+ if (best === undefined || score > best.score) {
63
+ best = { path: route.path, middleware: route.middleware, score }
64
+ }
65
+ }
66
+ if (best === undefined) return { kind: 'next' }
67
+ const cookieHeader = request.headers.get('cookie')
68
+ const signedIn =
69
+ (cookieHeader !== null && cookieHeader.length > 0) ||
70
+ (request.headers.get('authorization') ?? '').length > 0
71
+ if (best.middleware.includes('auth') && !signedIn) {
72
+ return { kind: 'redirect', location: '/login', status: 302 }
73
+ }
74
+ return { kind: 'next' }
75
+ }
package/src/page.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { finishResponse, type ControllerClass } from '@avelonjs/core'
2
+
3
+ import { ensureAdapter, findMountedRoute } from './adapter'
4
+ import { readIncomingCookies, withCookies } from './cookies'
5
+ import { isNextControlFlow } from './response'
6
+
7
+ /** Next page props after awaiting the async params/searchParams contracts. */
8
+ export interface PageProps {
9
+ params?:
10
+ | Promise<Record<string, string | string[] | undefined>>
11
+ | Record<string, string | string[] | undefined>
12
+ searchParams?:
13
+ | Promise<Record<string, string | string[] | undefined>>
14
+ | Record<string, string | string[] | undefined>
15
+ }
16
+
17
+ async function resolveRecord(
18
+ value:
19
+ | Promise<Record<string, string | string[] | undefined>>
20
+ | Record<string, string | string[] | undefined>
21
+ | undefined,
22
+ ): Promise<Record<string, string | string[] | undefined>> {
23
+ if (value === undefined) return {}
24
+ return await value
25
+ }
26
+
27
+ /**
28
+ * Wraps a controller action as a Next page component. Generated `page.tsx` files are four lines
29
+ * that call this helper.
30
+ */
31
+ export function page(controller: ControllerClass, action: string, uri: string) {
32
+ return async function Page(props: PageProps): Promise<unknown> {
33
+ const adapter = await ensureAdapter()
34
+ const kernel = adapter.kernel
35
+ if (kernel === undefined) {
36
+ throw new Error('Next adapter kernel is missing. Mount the adapter before rendering a page.')
37
+ }
38
+ const cookies = await readIncomingCookies()
39
+ return await withCookies(cookies, async () => {
40
+ const request = await adapter.toRequest({
41
+ kind: 'page',
42
+ uri,
43
+ params: await resolveRecord(props.params),
44
+ searchParams: await resolveRecord(props.searchParams),
45
+ cookies,
46
+ })
47
+ const route = findMountedRoute(controller, action, uri)
48
+ try {
49
+ const result = await kernel.dispatch(route, request)
50
+ const response = await adapter.toResponse(result)
51
+ await finishResponse()
52
+ if (response.kind === 'view') return response.body
53
+ if (response.kind === 'action' && response.ok === false) {
54
+ const message =
55
+ response.errors?._form?.[0] ??
56
+ Object.values(response.errors ?? {})[0]?.[0] ??
57
+ 'The page could not be rendered.'
58
+ if (response.status === 404) {
59
+ try {
60
+ const navigation = (await import('next/navigation')) as {
61
+ notFound: () => never
62
+ }
63
+ navigation.notFound()
64
+ } catch (fallback) {
65
+ if (isNextControlFlow(fallback)) throw fallback
66
+ }
67
+ }
68
+ throw new Error(message)
69
+ }
70
+ return response
71
+ } catch (error) {
72
+ if (isNextControlFlow(error)) throw error
73
+ throw error
74
+ }
75
+ })
76
+ }
77
+ }
package/src/request.ts ADDED
@@ -0,0 +1,139 @@
1
+ import type { HttpMethod, HttpRequest } from '@avelonjs/core'
2
+
3
+ import type { NextNativeRequest } from './adapter'
4
+
5
+ function flatten(
6
+ source: Record<string, string | string[] | undefined> | undefined,
7
+ ): Record<string, string> {
8
+ const result: Record<string, string> = {}
9
+ if (source === undefined) return result
10
+ for (const [key, value] of Object.entries(source)) {
11
+ if (typeof value === 'string') result[key] = value
12
+ else if (Array.isArray(value) && value[0] !== undefined) result[key] = value[0]
13
+ }
14
+ return result
15
+ }
16
+
17
+ function cookiesFromHeader(header: string | null): Record<string, string> {
18
+ const cookies: Record<string, string> = {}
19
+ if (header === null || header.length === 0) return cookies
20
+ for (const part of header.split(';')) {
21
+ const trimmed = part.trim()
22
+ const eq = trimmed.indexOf('=')
23
+ if (eq <= 0) continue
24
+ const name = trimmed.slice(0, eq)
25
+ const value = trimmed.slice(eq + 1)
26
+ cookies[name] = decodeURIComponent(value)
27
+ }
28
+ return cookies
29
+ }
30
+
31
+ function headersFrom(request: Request): Record<string, string> {
32
+ const headers: Record<string, string> = {}
33
+ request.headers.forEach((value, key) => {
34
+ headers[key] = value
35
+ })
36
+ return headers
37
+ }
38
+
39
+ function queryFrom(url: URL): Record<string, string | readonly string[]> {
40
+ const query: Record<string, string | string[]> = {}
41
+ for (const [key, value] of url.searchParams.entries()) {
42
+ const existing = query[key]
43
+ if (existing === undefined) query[key] = value
44
+ else if (typeof existing === 'string') query[key] = [existing, value]
45
+ else existing.push(value)
46
+ }
47
+ return query
48
+ }
49
+
50
+ function parameterNames(uri: string): string[] {
51
+ return [...uri.matchAll(/\{(\w+)\}/g)]
52
+ .map((match) => match[1])
53
+ .filter((name) => name !== undefined)
54
+ }
55
+
56
+ /** Converts a Next page, FormData, or Fetch request into a kernel `HttpRequest`. */
57
+ export async function nativeToRequest(native: NextNativeRequest): Promise<HttpRequest> {
58
+ if (native.kind === 'page') {
59
+ const params = flatten(native.params)
60
+ const query = flatten(native.searchParams)
61
+ const url =
62
+ native.url ??
63
+ `http://localhost${native.uri.replace(/\{(\w+)\}/g, (_, name: string) => params[name] ?? '')}`
64
+ return {
65
+ method: 'GET',
66
+ url,
67
+ params,
68
+ query,
69
+ headers: {},
70
+ cookies: native.cookies ?? {},
71
+ body: null,
72
+ rawBody: async () => new Uint8Array(),
73
+ aborted: false,
74
+ }
75
+ }
76
+
77
+ if (native.kind === 'form') {
78
+ const body: Record<string, string> = {}
79
+ for (const [key, value] of native.formData.entries()) {
80
+ if (typeof value === 'string') body[key] = value
81
+ }
82
+ const params: Record<string, string> = {}
83
+ for (const name of parameterNames(native.uri)) {
84
+ const value = body[name]
85
+ if (value !== undefined) {
86
+ params[name] = value
87
+ delete body[name]
88
+ }
89
+ }
90
+ const spoofed = body._method
91
+ const method = (spoofed ?? 'POST').toUpperCase() as HttpMethod satisfies HttpMethod
92
+ delete body._method
93
+ const raw = new TextEncoder().encode(JSON.stringify(body))
94
+ return {
95
+ method,
96
+ url: native.url ?? `http://localhost${native.uri}`,
97
+ params,
98
+ query: {},
99
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
100
+ cookies: native.cookies ?? {},
101
+ body,
102
+ rawBody: async () => raw,
103
+ aborted: false,
104
+ }
105
+ }
106
+
107
+ const url = new URL(native.request.url)
108
+ let body: unknown = null
109
+ const bytes = new Uint8Array(await native.request.clone().arrayBuffer())
110
+ const contentType = native.request.headers.get('content-type') ?? ''
111
+ if (bytes.byteLength > 0) {
112
+ try {
113
+ if (contentType.includes('application/json'))
114
+ body = JSON.parse(new TextDecoder().decode(bytes))
115
+ else if (contentType.includes('form')) {
116
+ const form = await native.request.clone().formData()
117
+ const parsed: Record<string, string> = {}
118
+ for (const [key, value] of form.entries()) {
119
+ if (typeof value === 'string') parsed[key] = value
120
+ }
121
+ body = parsed
122
+ } else body = new TextDecoder().decode(bytes)
123
+ } catch {
124
+ body = null
125
+ }
126
+ }
127
+ const method = native.request.method.toUpperCase() as HttpMethod
128
+ return {
129
+ method,
130
+ url: native.request.url,
131
+ params: native.params ?? {},
132
+ query: queryFrom(url),
133
+ headers: headersFrom(native.request),
134
+ cookies: cookiesFromHeader(native.request.headers.get('cookie')),
135
+ body,
136
+ rawBody: async () => bytes,
137
+ aborted: native.request.signal.aborted,
138
+ }
139
+ }
@@ -0,0 +1,88 @@
1
+ import type { KernelResult, ViewRef } from '@avelonjs/core'
2
+
3
+ import type { ViewRegistry } from './cookies'
4
+
5
+ /** Native response shapes the Next adapter returns from `toResponse`. */
6
+ export type NextNativeResponse =
7
+ | { kind: 'view'; body: unknown; status: number }
8
+ | {
9
+ kind: 'action'
10
+ ok: boolean
11
+ data?: unknown
12
+ errors?: Readonly<Record<string, readonly string[]>>
13
+ status: number
14
+ }
15
+ | { kind: 'redirect'; location: string; status: 302 | 303 | 307 | 308 }
16
+ | { kind: 'stream'; body: AsyncIterable<Uint8Array>; contentType: string; status: number }
17
+ | { kind: 'next' }
18
+
19
+ const REDIRECT_DIGEST = 'NEXT_REDIRECT'
20
+
21
+ /** Error Next navigation uses to unwind the server component tree. */
22
+ export class NextRedirect extends Error {
23
+ readonly digest: string
24
+ readonly location: string
25
+ readonly status: 302 | 303 | 307 | 308
26
+
27
+ constructor(location: string, status: 302 | 303 | 307 | 308) {
28
+ super(REDIRECT_DIGEST)
29
+ this.name = 'NextRedirect'
30
+ this.location = location
31
+ this.status = status
32
+ // Next 16 `isRedirectError` requires a trailing semicolon and only
33
+ // recognizes 303/307/308. Kernel redirects are 302; map those to 307.
34
+ const nextStatus = status === 302 ? 307 : status
35
+ this.digest = `${REDIRECT_DIGEST};replace;${location};${nextStatus};`
36
+ }
37
+ }
38
+
39
+ /** Returns true when `error` is a Next navigation control-flow throw. */
40
+ export function isNextControlFlow(error: unknown): boolean {
41
+ if (error instanceof NextRedirect) return true
42
+ const digest = (error as { digest?: unknown })?.digest
43
+ return typeof digest === 'string' && /^NEXT_(REDIRECT|HTTP_ERROR_FALLBACK|NOT_FOUND)/.test(digest)
44
+ }
45
+
46
+ function renderView(view: unknown, props: object, views?: ViewRegistry): unknown {
47
+ if (typeof view === 'function') return (view as (value: object) => unknown)(props)
48
+ if (typeof view === 'string' && views !== undefined) {
49
+ const component = views[view]
50
+ if (component !== undefined) return component(props)
51
+ }
52
+ return { view, props }
53
+ }
54
+
55
+ /**
56
+ * Converts a kernel result. Redirects throw {@link NextRedirect} so generated server actions
57
+ * propagate navigation the way Next's `redirect()` does.
58
+ */
59
+ export async function kernelToResponse(
60
+ result: KernelResult<ViewRef>,
61
+ views?: ViewRegistry,
62
+ ): Promise<NextNativeResponse> {
63
+ if (result.type === 'redirect') {
64
+ throw new NextRedirect(result.location, result.status)
65
+ }
66
+ if (result.type === 'action') {
67
+ return {
68
+ kind: 'action',
69
+ ok: result.ok,
70
+ data: result.data,
71
+ errors: result.errors,
72
+ status: result.status ?? (result.ok ? 200 : 422),
73
+ }
74
+ }
75
+ if (result.type === 'stream') {
76
+ return {
77
+ kind: 'stream',
78
+ body: result.body,
79
+ contentType: result.contentType,
80
+ status: result.status ?? 200,
81
+ }
82
+ }
83
+ return {
84
+ kind: 'view',
85
+ body: renderView(result.view, result.props, views),
86
+ status: result.status ?? 200,
87
+ }
88
+ }