From 82eda8b9e5f028a4c9b2f5989110de7edee410ed Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 27 Feb 2023 08:25:33 -0700 Subject: [PATCH] fix: isomorphic server exec --- packages/bling/src/babel.ts | 11 ++- packages/bling/src/client.ts | 113 +++++++++++++--------- packages/bling/src/server.ts | 150 ++++++++++++++++-------------- packages/bling/src/types.ts | 19 ++-- packages/bling/src/utils/utils.ts | 25 ++++- 5 files changed, 192 insertions(+), 126 deletions(-) diff --git a/packages/bling/src/babel.ts b/packages/bling/src/babel.ts index af5ffdc..2b3e466 100644 --- a/packages/bling/src/babel.ts +++ b/packages/bling/src/babel.ts @@ -201,7 +201,8 @@ function transformServer({ types: t, template }) { server$.registerHandler("${pathname}", $$server_module${serverIndex}); `)({ source: serverFn.node, - options: serverFnOpts.node, + options: + serverFnOpts?.node || t.identifier('undefined'), }) ) } else { @@ -221,9 +222,13 @@ function transformServer({ types: t, template }) { process.env.TEST_ENV === 'client' ? { source: serverFn.node, - options: serverFnOpts.node, + options: + serverFnOpts?.node || t.identifier('undefined'), + } + : { + options: + serverFnOpts?.node || t.identifier('undefined'), } - : {} ) ) } diff --git a/packages/bling/src/client.ts b/packages/bling/src/client.ts index 92fe6f4..c40d68a 100644 --- a/packages/bling/src/client.ts +++ b/packages/bling/src/client.ts @@ -1,8 +1,9 @@ import { - createFetcher, mergeRequestInits, + mergeServerOpts, parseResponse, payloadRequestInit, + resolveRequestHref, XBlingOrigin, XBlingResponseTypeHeader, } from './utils/utils' @@ -10,9 +11,11 @@ import { import type { AnyServerFn, Serializer, - ServerFnOpts, - Fetcher, - CreateFetcherFn, + FetcherFn, + FetcherMethods, + ServerFnReturn, + ServerFnCtxOptions, + ServerFnCtx, } from './types' export * from './utils/utils' @@ -25,58 +28,78 @@ export function addSerializer({ apply, serialize }: Serializer) { serializers.push({ apply, serialize }) } -export type ClientFetcherMethods = { - createFetcher(route: string, defualtOpts: ServerFnOpts): Fetcher +export type CreateClientFetcherFn = ( + fn: T, + opts?: ServerFnCtxOptions +) => ClientFetcher + +export type CreateClientFetcherMethods = { + createFetcher( + route: string, + defualtOpts: ServerFnCtxOptions + ): ClientFetcher +} + +export type ClientFetcher = FetcherFn & + FetcherMethods + +export type ClientFetcherMethods = FetcherMethods & { + fetch: ( + init: RequestInit, + opts?: ServerFnCtxOptions + ) => Promise>> } -export type ClientServerFn = CreateFetcherFn & ClientFetcherMethods +export type ClientServerFn = CreateClientFetcherFn & CreateClientFetcherMethods const serverImpl = (() => { throw new Error('Should be compiled away') }) as any -const serverMethods: ClientFetcherMethods = { - createFetcher: (pathname: string, defaultOpts?: ServerFnOpts) => { - return createFetcher( - pathname, - async (payload: any, opts?: ServerFnOpts) => { - const method = opts?.method || defaultOpts?.method || 'POST' - const baseInit: RequestInit = { - method, - headers: { - [XBlingOrigin]: 'client', - }, - } - - let payloadInit = payloadRequestInit(payload, serializers) - - const resolvedRoute = - method === 'GET' - ? payloadInit.body === 'string' - ? `${pathname}?payload=${encodeURIComponent(payloadInit.body)}` - : pathname - : pathname - - const request = new Request( - new URL(resolvedRoute, window.location.href).href, - mergeRequestInits( - baseInit, - payloadInit, - defaultOpts?.request, - opts?.request - ) +const serverMethods: CreateClientFetcherMethods = { + createFetcher: (pathname: string, defaultOpts?: ServerFnCtxOptions) => { + const fetcherImpl = async (payload: any, opts?: ServerFnCtxOptions) => { + const method = opts?.method || defaultOpts?.method || 'POST' + + const baseInit: RequestInit = { + method, + headers: { + [XBlingOrigin]: 'client', + }, + } + + let payloadInit = payloadRequestInit(payload, serializers) + + const resolvedHref = resolveRequestHref(pathname, method, payloadInit) + + const request = new Request( + resolvedHref, + mergeRequestInits( + baseInit, + payloadInit, + defaultOpts?.request, + opts?.request ) + ) - const response = await fetch(request) + const response = await fetch(request) - // // throws response, error, form error, json object, string - if (response.headers.get(XBlingResponseTypeHeader) === 'throw') { - throw await parseResponse(response) - } else { - return await parseResponse(response) - } + // // throws response, error, form error, json object, string + if (response.headers.get(XBlingResponseTypeHeader) === 'throw') { + throw await parseResponse(response) + } else { + return await parseResponse(response) } - ) + } + + const fetcherMethods: ClientFetcherMethods = { + url: pathname, + fetch: (request: RequestInit, opts?: ServerFnCtxOptions) => { + return fetcherImpl(undefined, mergeServerOpts({ request }, opts)) + }, + } + + return Object.assign(fetcherImpl, fetcherMethods) as ClientFetcher }, } diff --git a/packages/bling/src/server.ts b/packages/bling/src/server.ts index 1e1b5f5..223fdc3 100644 --- a/packages/bling/src/server.ts +++ b/packages/bling/src/server.ts @@ -11,16 +11,17 @@ import { mergeRequestInits, parseResponse, payloadRequestInit, + resolveRequestHref, } from './utils/utils' import type { AnyServerFn, Deserializer, - ServerFnOpts, Fetcher, ServerFnCtx, CreateFetcherFn, + ServerFnCtxOptions, + ServerFnCtxWithRequest, } from './types' -import { ClientServerFn } from './client' export * from './utils/utils' @@ -33,9 +34,10 @@ export function addDeserializer(deserializer: Deserializer) { export type ServerFetcherMethods = { createHandler( fn: AnyServerFn, - route: string, - opts: ServerFnOpts + pathame: string, + opts: ServerFnCtxOptions ): Fetcher + registerHandler(pathname: string, handler: Fetcher): void } export type ServerFn = CreateFetcherFn & ServerFetcherMethods @@ -48,20 +50,27 @@ const serverMethods: ServerFetcherMethods = { createHandler: ( fn: AnyServerFn, pathname: string, - defaultOpts?: ServerFnOpts + defaultOpts?: ServerFnCtxOptions ): Fetcher => { - return createFetcher( - pathname, - async (payload: any, opts?: ServerFnOpts) => { - console.log(`Executing server function: ${pathname}`) - if (payload) console.log(` Fn Payload: ${payload}`) + return createFetcher(pathname, async (payload: any, opts?: ServerFnCtx) => { + const method = opts?.method || defaultOpts?.method || 'POST' + const ssr = !opts?.request - let payloadInit = payloadRequestInit(payload, false) + console.log(`Executing server function: ${method} ${pathname}`) + if (payload) console.log(` Fn Payload: ${payload}`) + + opts = opts ?? {} + if (!opts.__hasRequest) { + // This will happen if the server function is called directly during SSR // Even though we're not crossing the network, we still need to - // create a Request object to pass to the server function - const request = new Request( - pathname, + // create a Request object to pass to the server function as if it was + + let payloadInit = payloadRequestInit(payload, false) + + const resolvedHref = resolveRequestHref(pathname, method, payloadInit) + opts.request = new Request( + resolvedHref, mergeRequestInits( { method: 'POST', @@ -74,40 +83,73 @@ const serverMethods: ServerFetcherMethods = { opts?.request ) ) + } + + try { + // Do the same parsing of the result as we do on the client + const response = await fn(payload, opts) - try { - // Do the same parsing of the result as we do on the client - return parseResponse( - await fn(payload, { - request: request, - }) + if (!opts.__hasRequest) { + // If we're on the server during SSR, we can skip to + // parsing the response directly + return parseResponse(response) + } + + // Otherwise, the client-side code will parse the response properly + return response + } catch (e) { + if (e instanceof Error && /[A-Za-z]+ is not defined/.test(e.message)) { + const error = new Error( + e.message + + '\n' + + ' You probably are using a variable defined in a closure in your server function. Make sure you pass any variables needed to the server function as arguments. These arguments must be serializable.' ) - } catch (e) { - if ( - e instanceof Error && - /[A-Za-z]+ is not defined/.test(e.message) - ) { - const error = new Error( - e.message + - '\n' + - ' You probably are using a variable defined in a closure in your server function. Make sure you pass any variables needed to the server function as arguments. These arguments must be serializable.' - ) - error.stack = e.stack ?? '' - throw error - } - throw e + error.stack = e.stack ?? '' + throw error } + throw e } - ) + }) + }, + registerHandler(pathname: string, handler: Fetcher): any { + console.log('Registering handler', pathname) + handlers.set(pathname, handler) }, } export const server$: ServerFn = Object.assign(serverImpl, serverMethods) -async function parseRequest(event: ServerFnCtx) { +export async function handleEvent(ctx: ServerFnCtxWithRequest) { + if (!ctx.request) { + throw new Error('handleEvent must be called with a request.') + } + + const url = new URL(ctx.request.url) + + if (hasHandler(url.pathname)) { + try { + let [pathname, payload] = await parseRequest(ctx) + let handler = getHandler(pathname) + if (!handler) { + throw { + status: 404, + message: 'Handler Not Found for ' + pathname, + } + } + const data = await handler(payload, ctx) + return respondWith(ctx, data, 'return') + } catch (error) { + return respondWith(ctx, error as Error, 'throw') + } + } + + return null +} + +async function parseRequest(event: ServerFnCtxWithRequest) { let request = event.request let contentType = request.headers.get(ContentTypeHeader) - let name = new URL(request.url).pathname, + let pathname = new URL(request.url).pathname, payload // Get request have their payload in the query string @@ -144,18 +186,18 @@ async function parseRequest(event: ServerFnCtx) { } } - return [name, payload] + return [pathname, payload] } function respondWith( - { request }: ServerFnCtx, + ctx: ServerFnCtxWithRequest, data: Response | Error | string | object, responseType: 'throw' | 'return' ) { if (data instanceof Response) { if ( isRedirectResponse(data) && - request.headers.get(XBlingOrigin) === 'client' + ctx.request.headers.get(XBlingOrigin) === 'client' ) { let headers = new Headers(data.headers) headers.set(XBlingOrigin, 'server') @@ -231,36 +273,8 @@ function respondWith( }) } -export async function handleEvent(ctx: ServerFnCtx) { - const url = new URL(ctx.request.url) - - if (hasHandler(url.pathname)) { - try { - let [name, payload] = await parseRequest(ctx) - let handler = getHandler(name) - if (!handler) { - throw { - status: 404, - message: 'Handler Not Found for ' + name, - } - } - const data = await handler(payload, ctx) - return respondWith(ctx, data, 'return') - } catch (error) { - return respondWith(ctx, error as Error, 'throw') - } - } - - return null -} - const handlers = new Map>() -export function registerHandler(pathname: string, handler: Fetcher): any { - console.log('Registering handler', pathname) - handlers.set(pathname, handler) -} - export function getHandler(pathname: string) { return handlers.get(pathname) } diff --git a/packages/bling/src/types.ts b/packages/bling/src/types.ts index 64445d0..b24fc2e 100644 --- a/packages/bling/src/types.ts +++ b/packages/bling/src/types.ts @@ -21,19 +21,19 @@ export type ServerFnReturn = Awaited< export type CreateFetcherFn = ( fn: T, - opts?: ServerFnOpts + opts?: ServerFnCtx ) => Fetcher export type FetcherFn = ( payload: Parameters['0'], - opts?: ServerFnOpts + opts?: ServerFnCtx ) => Promise>> export type FetcherMethods = { url: string fetch: ( init: RequestInit, - opts?: ServerFnOpts + opts?: ServerFnCtxOptions ) => Promise>> } @@ -41,15 +41,22 @@ export type Fetcher = FetcherFn & FetcherMethods export interface JsonResponse extends Response {} -export type ServerFnOpts = { - method?: 'POST' | 'GET' +export type ServerFnCtxBase = { + method?: 'GET' | 'POST' +} + +export type ServerFnCtxOptions = ServerFnCtxBase & { request?: RequestInit + __hasRequest?: never } -export type ServerFnCtx = { +export type ServerFnCtxWithRequest = ServerFnCtxBase & { request: Request + __hasRequest: true } +export type ServerFnCtx = ServerFnCtxOptions | ServerFnCtxWithRequest + export type NonFnProps = { [TKey in keyof T]: TKey extends (...args: any[]) => any ? never : T[TKey] } diff --git a/packages/bling/src/utils/utils.ts b/packages/bling/src/utils/utils.ts index 08fae75..91f4fe0 100644 --- a/packages/bling/src/utils/utils.ts +++ b/packages/bling/src/utils/utils.ts @@ -5,7 +5,8 @@ import { Fetcher, FetcherFn, FetcherMethods, - ServerFnOpts, + ServerFnCtx, + ServerFnCtxOptions, } from '../types' export const XBlingStatusCodeHeader = 'x-bling-status-code' @@ -200,7 +201,7 @@ export async function parseResponse(response: Response) { return response } -export function mergeServerOpts(...objs: (ServerFnOpts | undefined)[]) { +export function mergeServerOpts(...objs: (ServerFnCtxOptions | undefined)[]) { return Object.assign.call(null, [ {}, ...objs, @@ -246,10 +247,26 @@ export function createFetcher( ): Fetcher { const fetcherMethods: FetcherMethods = { url: route, - fetch: (request: RequestInit, opts?: ServerFnOpts) => { - return fetcherImpl(undefined, mergeServerOpts({ request }, opts)) + fetch: (request: RequestInit, ctx?: ServerFnCtxOptions) => { + return fetcherImpl(undefined, mergeServerOpts({ request }, ctx)) }, } return Object.assign(fetcherImpl, fetcherMethods) as Fetcher } + +export function resolveRequestHref( + pathname: string, + method: 'GET' | 'POST', + payloadInit: RequestInit +) { + const resolved = + method.toLowerCase() === 'get' + ? `${pathname}?payload=${encodeURIComponent(payloadInit.body as string)}` + : pathname + + return new URL( + resolved, + typeof document !== 'undefined' ? window.location.href : `http://localhost` + ).href +}