Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 48 additions & 50 deletions adex/runtime/handler.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { CONSTANTS, emitToHooked } from 'adex/hook'
import { prepareRequest, prepareResponse } from 'adex/http'
import { toStatic } from 'adex/ssr'
import { renderToStringAsync } from 'adex/utils/isomorphic'
import { h } from 'preact'
Expand All @@ -14,47 +13,42 @@ import { routes as pageRoutes } from '~routes'

const html = String.raw

export async function handler(req, res) {
res.statusCode = 200

prepareRequest(req)
prepareResponse(res)

const [url, search] = req.url.split('?')
const baseURL = normalizeRequestUrl(url)
/**
* Core request handler — Fetch API native.
* Receives a standard Request, returns a standard Response.
* Page responses carry an `x-adex-page-route` header so the adapter
* kernel can inject manifest assets before sending to the client.
* @param {Request} request
* @returns {Promise<Response>}
*/
export async function handler(request) {
const { pathname } = new URL(request.url)
const baseURL = normalizeRequestUrl(pathname)

const { metas, links, title, lang } = toStatic()

if (baseURL.startsWith('/api') || baseURL.startsWith('api')) {
const matchedInAPI = apiRoutes.find(d => {
return d.regex.pattern.test(baseURL)
})

if (matchedInAPI) {
const module = await matchedInAPI.module()
const routeParams = getRouteParams(baseURL, matchedInAPI)
req.params = routeParams
const modifiableContext = {
req: req,
res: res,
}
await emitToHooked(CONSTANTS.beforeApiCall, modifiableContext)
const context = { request }
await emitToHooked(CONSTANTS.beforeApiCall, context)
const handlerFn =
'default' in module ? module.default : (_, res) => res.end()
const serverHandler = async (req, res) => {
await handlerFn(req, res)
await emitToHooked(CONSTANTS.afterApiCall, { req, res })
}
return {
serverHandler,
}
}
return {
serverHandler: async (_, res) => {
res.statusCode = 404
res.end('Not found')
await emitToHooked(CONSTANTS.afterApiCall, { req, res })
},
'default' in module
? module.default
: () => new Response('Not found', { status: 404 })
const response = await handlerFn(context.request)
await emitToHooked(CONSTANTS.afterApiCall, {
request: context.request,
response,
})
return response
}

return new Response('Not found', { status: 404 })
}

const matchedInPages = pageRoutes.find(d => {
Expand All @@ -65,15 +59,13 @@ export async function handler(req, res) {
const routeParams = getRouteParams(baseURL, matchedInPages)

// @ts-expect-error
global.location = new URL(req.url, 'http://localhost')
globalThis.location = new URL(request.url)

const modifiableContext = {
req: req,
}
await emitToHooked(CONSTANTS.beforePageRender, modifiableContext)
const context = { request }
await emitToHooked(CONSTANTS.beforePageRender, context)

const rendered = await renderToStringAsync(
h(App, { url: modifiableContext.req.url }),
h(App, { url: new URL(context.request.url).pathname }),
{}
)

Expand All @@ -83,30 +75,36 @@ export async function handler(req, res) {
title,
lang,
entryPage: matchedInPages.route,
routeParams: Buffer.from(JSON.stringify(routeParams), 'utf8').toString(
'base64'
),
routeParams: btoa(JSON.stringify(routeParams)),
body: rendered,
})

modifiableContext.html = htmlString
await emitToHooked(CONSTANTS.afterPageRender, modifiableContext)
htmlString = modifiableContext.html
return {
html: htmlString,
pageRoute: matchedInPages.route,
}
const pageContext = { request: context.request, html: htmlString }
await emitToHooked(CONSTANTS.afterPageRender, pageContext)
htmlString = pageContext.html

return new Response(htmlString, {
status: 200,
headers: {
'content-type': 'text/html',
'x-adex-page-route': matchedInPages.route,
},
})
}

return {
html: HTMLTemplate({
return new Response(
HTMLTemplate({
metas,
links,
title,
lang,
body: '404 | Not Found',
}),
}
{
status: 404,
headers: { 'content-type': 'text/html' },
}
)
}

function HTMLTemplate({
Expand Down
10 changes: 8 additions & 2 deletions adex/snapshots/tests/minimal-no-ssr.spec.snap.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ exports["devMode ssr minimal > gives a static response 1"] = `"



</head>

<link rel="preload" href="/virtual:adex:global.css" as="style" onload="this.rel='stylesheet'" />

</head>
<body>
<div id="app"><h1>Hello World</h1></div>
<script type='module' src="/virtual:adex:client"></script></body>
Expand All @@ -31,7 +34,10 @@ exports["devMode ssr minimal > gives a static response 2"] = `"



</head>

<link rel="preload" href="/virtual:adex:global.css" as="style" onload="this.rel='stylesheet'" />

</head>
<body>
<div id="app"><h2>About</h2></div>
<script type='module' src="/virtual:adex:client"></script></body>
Expand Down
10 changes: 8 additions & 2 deletions adex/snapshots/tests/minimal-tailwind.spec.snap.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ exports["devMode ssr minimal with styles > gives a non-static ssr response 1"] =



</head>

<link rel="preload" href="/virtual:adex:global.css" as="style" onload="this.rel='stylesheet'" />

</head>
<body>
<div id="app"><h1 class="text-red-500">Hello World</h1></div>
<script type='module' src="/virtual:adex:client"></script></body>
Expand All @@ -31,7 +34,10 @@ exports["devMode ssr minimal with styles > gives a static SSR response 1"] = `"



</head>

<link rel="preload" href="/virtual:adex:global.css" as="style" onload="this.rel='stylesheet'" />

</head>
<body>
<div id="app"><h2>About</h2></div>
<script type='module' src="/virtual:adex:client"></script></body>
Expand Down
10 changes: 8 additions & 2 deletions adex/snapshots/tests/minimal.spec.snap.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ exports["devMode ssr minimal > gives a non-static ssr response 1"] = `"



</head>

<link rel="preload" href="/virtual:adex:global.css" as="style" onload="this.rel='stylesheet'" />

</head>
<body>
<div id="app"><h1>Hello World</h1></div>
<script type='module' src="/virtual:adex:client"></script></body>
Expand All @@ -38,7 +41,10 @@ exports["devMode ssr minimal > gives a static SSR response 1"] = `"



</head>

<link rel="preload" href="/virtual:adex:global.css" as="style" onload="this.rel='stylesheet'" />

</head>
<body>
<div id="app"><h2>About</h2></div>
<script type='module' src="/virtual:adex:client"></script></body>
Expand Down
17 changes: 8 additions & 9 deletions adex/src/hook.d.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { IncomingMessage } from 'node:http'

export type Context = {
req: IncomingMessage
html: string
export type PageRenderContext = {
request: Request
html?: string
}

export type APIContext = {
req: IncomingMessage
request: Request
response?: Response
}

export declare const CONSTANTS: {
Expand All @@ -22,15 +21,15 @@ export declare function hook(
): void

export declare function beforePageRender(
fn: (ctx: Omit<Context, 'html'>) => void
fn: (ctx: Omit<PageRenderContext, 'html'>) => void
): Promise<void>

export declare function afterPageRender(
fn: (ctx: Context) => void
fn: (ctx: PageRenderContext) => void
): Promise<void>

export declare function beforeAPICall(
fn: (ctx: APIContext) => void
fn: (ctx: Omit<APIContext, 'response'>) => void
): Promise<void>

export declare function afterAPICall(
Expand Down
15 changes: 15 additions & 0 deletions adex/src/http.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,18 @@ export type ServerResponse = HTTPServerResponse & {

export function prepareRequest(req: IncomingMessage): void
export function prepareResponse(res: ServerResponse): void

/**
* Convert a Node.js IncomingMessage to a Fetch API Request.
* Used by adapter kernels to bridge from Node HTTP to Fetch.
*/
export function nodeRequestToFetch(req: HTTPIncomingMessage): Promise<Request>

/**
* Write a Fetch API Response to a Node.js ServerResponse.
* Skips internal x-adex-* headers. Used by adapter kernels.
*/
export function fetchResponseToNode(
response: Response,
res: HTTPServerResponse
): Promise<void>
59 changes: 59 additions & 0 deletions adex/src/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,65 @@ export function prepareRequest(req) {
}
}

/**
* Convert a Node.js IncomingMessage to a Fetch API Request.
* Reconstructs the full URL from req.url + Host header.
* Reads and buffers the body stream.
* @param {import("./http.js").IncomingMessage} req
* @returns {Promise<Request>}
*/
export async function nodeRequestToFetch(req) {
const protocol = req.socket?.encrypted ? 'https' : 'http'
const host = req.headers['host'] ?? 'localhost'
const url = new URL(req.url, `${protocol}://${host}`)

const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (Array.isArray(value)) {
for (const v of value) headers.append(key, v)
} else if (value != null) {
headers.set(key, value)
}
}

const hasBody = req.method !== 'GET' && req.method !== 'HEAD'
let body = undefined
if (hasBody) {
body = await new Promise((resolve, reject) => {
const chunks = []
req.on('data', chunk => chunks.push(chunk))
req.on('end', () => resolve(Buffer.concat(chunks)))
req.on('error', reject)
})
}

return new Request(url.href, {
method: req.method,
headers,
body: body ?? null,
})
}

/**
* Write a Fetch API Response to a Node.js ServerResponse.
* Copies status, headers (skipping x-adex-* internal headers), and streams body.
* @param {Response} response
* @param {import("./http.js").ServerResponse} res
* @returns {Promise<void>}
*/
export async function fetchResponseToNode(response, res) {
res.statusCode = response.status
for (const [key, value] of response.headers.entries()) {
if (key.startsWith('x-adex-')) continue
res.setHeader(key, value)
}
if (response.body) {
const buf = Buffer.from(await response.arrayBuffer())
res.write(buf)
}
res.end()
}

/**
* @param {import("./http.js").ServerResponse} res
*/
Expand Down
34 changes: 31 additions & 3 deletions adex/src/vite.d.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,45 @@
import { UserConfig, Plugin } from 'vite'
import type { Options as FontOptions } from './fonts.js'
import type { RollupOptions } from 'rollup'

export type Adapters = 'node'
export interface AdapterClientInfo {
bundle: boolean
islands: boolean
manifestPath: string
outDir: string
}

export interface AdapterConfig {
/** npm package name — added to ssr.noExternal so it bundles into the server output */
name: string
/**
* Returns a Vite plugin that handles dev-mode request serving for this adapter.
* Called by the core adex() plugin with the same islands flag.
*/
devServerPlugin: (options: { islands: boolean }) => Plugin
/**
* Returns the source code string for the virtual:adex:server entry point.
* Core injects this verbatim — all runtime bootstrap logic lives here.
*/
serverEntry: (options: { islands: boolean }) => string
/**
* Optional hook to extend or override the Rollup options used in the SSR
* server build. The base options are passed in; return the final options.
* Use this to add extra `external` patterns (e.g. /^https?:\/\//) or set
* `output.preserveModules: true` for runtimes like Deno.
*/
rollupOptions?: (base: RollupOptions) => RollupOptions
}

export interface AdexOptions {
fonts?: FontOptions
islands?: boolean
adapter?: Adapters
adapter?: AdapterConfig
ssr?: boolean
__clientConfig?: UserConfig
}

export function adex(options: AdexOptions): Plugin[]
export function adex(options?: AdexOptions): Plugin[]

declare module 'vite' {
interface Plugin {
Expand Down
Loading
Loading