@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/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
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -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
|
+
}
|
package/src/response.ts
ADDED
|
@@ -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
|
+
}
|