diff --git a/.changeset/dirty-eels-refuse.md b/.changeset/dirty-eels-refuse.md new file mode 100644 index 000000000000..01aa2f0aa168 --- /dev/null +++ b/.changeset/dirty-eels-refuse.md @@ -0,0 +1,10 @@ +--- +'@modern-js/create-request': patch +'@modern-js/plugin-express': patch +'@modern-js/plugin-koa': patch +'@modern-js/plugin-bff': patch +'@modern-js/server-core': patch +--- + +feat: bff supports hono runtime framework +feat: bff 支持 hono 运行时框架 diff --git a/.changeset/new-wasps-accept.md b/.changeset/new-wasps-accept.md new file mode 100644 index 000000000000..c429e85b214c --- /dev/null +++ b/.changeset/new-wasps-accept.md @@ -0,0 +1,9 @@ +--- +'@modern-js/plugin-express': patch +'@modern-js/app-tools': patch +'@modern-js/types': patch +'@modern-js/server-core': patch +--- + +refactor: avoid only one of "req" and "request" has a request body +refactor: 避免 req 和 request 只有一个有请求体 diff --git a/packages/cli/core/src/types/context.ts b/packages/cli/core/src/types/context.ts index 224cd37807ab..8d8b64081307 100644 --- a/packages/cli/core/src/types/context.ts +++ b/packages/cli/core/src/types/context.ts @@ -83,4 +83,9 @@ export interface IAppContext { * @private */ partialsByEntrypoint?: Record; + /** + * Identification for bff runtime framework + * @private + */ + bffRuntimeFramework?: string; } diff --git a/packages/cli/plugin-bff/package.json b/packages/cli/plugin-bff/package.json index 8a83e7b71107..5ee9130de178 100644 --- a/packages/cli/plugin-bff/package.json +++ b/packages/cli/plugin-bff/package.json @@ -42,6 +42,11 @@ "jsnext:source": "./src/loader.ts", "default": "./dist/cjs/loader.js" }, + "./hono": { + "types": "./dist/types/runtime/hono/index.d.ts", + "jsnext:source": "./src/runtime/hono/index.ts", + "default": "./dist/cjs/runtime/hono/index.js" + }, "./runtime/create-request": { "types": "./dist/types/create-request/index.d.ts", "jsnext:source": "./src/runtime/create-request/index.ts", @@ -59,6 +64,9 @@ "server": [ "./dist/types/server.d.ts" ], + "hono": [ + "./dist/types/runtime/hono/index.d.ts" + ], "runtime/create-request": [ "./dist/types/runtime/create-request/index.d.ts" ] @@ -78,6 +86,7 @@ "@modern-js/server-core": "workspace:*", "@modern-js/server-utils": "workspace:*", "@modern-js/utils": "workspace:*", + "type-is": "^1.6.18", "@swc/helpers": "0.5.13" }, "devDependencies": { @@ -92,11 +101,13 @@ "@types/babel__core": "^7.20.5", "@types/jest": "^29", "@types/node": "^14", + "@types/type-is": "^1.6.3", "jest": "^29", "memfs": "^3.5.1", "ts-jest": "^29.1.0", "typescript": "^5", - "webpack": "^5.98.0" + "webpack": "^5.98.0", + "zod": "^3.22.3" }, "sideEffects": false, "publishConfig": { diff --git a/packages/cli/plugin-bff/src/cli.ts b/packages/cli/plugin-bff/src/cli.ts index 408a626f3a92..9ac3ae22142a 100644 --- a/packages/cli/plugin-bff/src/cli.ts +++ b/packages/cli/plugin-bff/src/cli.ts @@ -11,6 +11,7 @@ import runtimeGenerator from './utils/runtimeGenerator'; const DEFAULT_API_PREFIX = '/api'; const TS_CONFIG_FILENAME = 'tsconfig.json'; const RUNTIME_CREATE_REQUEST = '@modern-js/plugin-bff/runtime/create-request'; +const RUNTIME_HONO = '@modern-js/plugin-bff/hono'; export const bffPlugin = (): CliPlugin => ({ name: '@modern-js/plugin-bff', @@ -125,8 +126,17 @@ export const bffPlugin = (): CliPlugin => ({ } }; + const isHono = () => { + const { bffRuntimeFramework } = api.useAppContext(); + return bffRuntimeFramework === 'hono'; + }; + return { config() { + const honoRuntimePath = isHono() + ? { [RUNTIME_HONO]: RUNTIME_HONO } + : undefined; + return { tools: { bundlerChain: (chain, { CHAIN_ID, isServer }) => { @@ -184,6 +194,9 @@ export const bffPlugin = (): CliPlugin => ({ source: { moduleScopes: [`./${API_DIR}`, /create-request/], }, + output: { + externals: honoRuntimePath, + }, }; }, modifyServerRoutes({ routes }) { @@ -207,7 +220,7 @@ export const bffPlugin = (): CliPlugin => ({ isSSR: false, })) as ServerRoute[]; - if (bff?.enableHandleWeb) { + if (!isHono() && bff?.enableHandleWeb) { return { routes: ( routes.map(route => { @@ -227,7 +240,6 @@ export const bffPlugin = (): CliPlugin => ({ plugins.push({ name: '@modern-js/plugin-bff/server', }); - return { plugins }; }, async beforeDev() { diff --git a/packages/cli/plugin-bff/src/runtime/hono/adapter.ts b/packages/cli/plugin-bff/src/runtime/hono/adapter.ts new file mode 100644 index 000000000000..cf57b04e0e07 --- /dev/null +++ b/packages/cli/plugin-bff/src/runtime/hono/adapter.ts @@ -0,0 +1,109 @@ +import type { APIHandlerInfo } from '@modern-js/bff-core'; +import type { + Context, + MiddlewareHandler, + Next, + PluginAPI, + ServerMiddleware, +} from '@modern-js/server-core'; +import { Hono } from '@modern-js/server-core'; + +import { isProd } from '@modern-js/utils'; +import createHonoRoutes from '../../utils/createHonoRoutes'; + +const before = ['custom-server-hook', 'custom-server-middleware', 'render']; + +interface MiddlewareOptions { + prefix: string; + enableHandleWeb?: boolean; +} + +export class HonoAdapter { + apiMiddleware: ServerMiddleware[] = []; + apiServer: Hono | null = null; + api: PluginAPI; + isHono = true; + constructor(api: PluginAPI) { + this.api = api; + } + + setHandlers = async () => { + if (!this.isHono) { + return; + } + const { apiHandlerInfos } = this.api.useAppContext(); + + const honoHandlers = createHonoRoutes(apiHandlerInfos as APIHandlerInfo[]); + this.apiMiddleware = honoHandlers.map(({ path, method, handler }) => ({ + name: 'hono-bff-api', + path, + method, + handler, + order: 'post', + before, + })); + }; + + registerApiRoutes = async () => { + if (!this.isHono) { + return; + } + this.apiServer = new Hono(); + this.apiMiddleware.forEach(({ path = '*', method = 'all', handler }) => { + const handlers = this.wrapInArray(handler); + this.apiServer?.[method](path, ...handlers); + }); + }; + + registerMiddleware = async (options: MiddlewareOptions) => { + const { prefix } = options; + + const { bffRuntimeFramework } = this.api.useAppContext(); + + if (bffRuntimeFramework !== 'hono') { + this.isHono = false; + return; + } + + const { middlewares: globalMiddlewares } = this.api.useAppContext(); + + await this.setHandlers(); + + if (isProd()) { + globalMiddlewares.push(...this.apiMiddleware); + } else { + await this.registerApiRoutes(); + /** api hot update */ + const dynamicApiMiddleware: ServerMiddleware = { + name: 'dynamic-bff-handler', + path: `${prefix}/*`, + method: 'all', + order: 'post', + before, + handler: async (c: Context, next: Next) => { + if (this.apiServer) { + const response = await this.apiServer.fetch( + c.req as unknown as Request, + c.env, + ); + + if (response.status !== 404) { + return new Response(response.body, response); + } + } + await next(); + }, + }; + globalMiddlewares.push(dynamicApiMiddleware); + } + }; + wrapInArray( + handler: MiddlewareHandler[] | MiddlewareHandler, + ): MiddlewareHandler[] { + if (Array.isArray(handler)) { + return handler; + } else { + return [handler]; + } + } +} diff --git a/packages/cli/plugin-bff/src/runtime/hono/index.ts b/packages/cli/plugin-bff/src/runtime/hono/index.ts new file mode 100644 index 000000000000..af6af014b34c --- /dev/null +++ b/packages/cli/plugin-bff/src/runtime/hono/index.ts @@ -0,0 +1,3 @@ +export * from '@modern-js/bff-core'; +export { useHonoContext } from '@modern-js/server-core'; +export * from './operators'; diff --git a/packages/cli/plugin-bff/src/runtime/hono/operators.ts b/packages/cli/plugin-bff/src/runtime/hono/operators.ts new file mode 100644 index 000000000000..348ac62792b5 --- /dev/null +++ b/packages/cli/plugin-bff/src/runtime/hono/operators.ts @@ -0,0 +1,64 @@ +import type { Operator } from '@modern-js/bff-core'; +import { + type Context, + type Next, + useHonoContext, +} from '@modern-js/server-core'; + +export type EndFunction = ((func: (res: Response) => void) => void) & + ((data: unknown) => void); + +type MaybeAsync = T | Promise; +type PipeFunction = ( + value: T, + end: EndFunction, +) => MaybeAsync | MaybeAsync; + +export const Pipe = (func: PipeFunction): Operator => { + return { + name: 'pipe', + async execute(executeHelper, next) { + const { inputs } = executeHelper; + const ctx = useHonoContext(); + const { res } = ctx; + if (typeof func === 'function') { + let isPiped = true; + const end: EndFunction = value => { + isPiped = false; + if (typeof value === 'function') { + value(res); + return; + } + return value; + }; + const output = await func(inputs, end); + if (!isPiped) { + if (output) { + return (executeHelper.result = output); + } else { + return; + } + } + executeHelper.inputs = output as T; + await next(); + } + }, + }; +}; + +export type Pipe = typeof Pipe; + +export const Middleware = ( + middleware: (c: Context, next: Next) => void, +): Operator => { + return { + name: 'middleware', + metadata(helper) { + const middlewares = helper.getMetadata('pipe') || []; + middlewares.push(middleware); + helper.setMetadata('middleware', middlewares); + }, + }; +}; + +export type Middleware = typeof Middleware; diff --git a/packages/cli/plugin-bff/src/server.ts b/packages/cli/plugin-bff/src/server.ts index aec097b7bd6a..d1542ade6ad1 100644 --- a/packages/cli/plugin-bff/src/server.ts +++ b/packages/cli/plugin-bff/src/server.ts @@ -8,7 +8,9 @@ import { isWebOnly, requireExistModule, } from '@modern-js/utils'; +import { isFunction } from '@modern-js/utils'; import { API_APP_NAME } from './constants'; +import { HonoAdapter } from './runtime/hono/adapter'; type SF = (args: any) => void; class Storage { @@ -32,6 +34,9 @@ export default (): ServerPluginLegacy => ({ const transformAPI = createTransformAPI(storage); let apiAppPath = ''; let apiRouter: ApiRouter; + + const honoAdapter = new HonoAdapter(api); + return { async prepare() { const appContext = api.useAppContext(); @@ -83,7 +88,7 @@ export default (): ServerPluginLegacy => ({ ); } - if (handler) { + if (handler && isFunction(handler)) { globalMiddlewares.push({ name: 'bind-bff', handler: (c, next) => { @@ -101,6 +106,11 @@ export default (): ServerPluginLegacy => ({ ], }); } + + honoAdapter.registerMiddleware({ + prefix, + enableHandleWeb, + }); }, async reset({ event }) { storage.reset(); @@ -123,6 +133,9 @@ export default (): ServerPluginLegacy => ({ ...appContext, apiHandlerInfos, }); + + await honoAdapter.setHandlers(); + await honoAdapter.registerApiRoutes(); } }, diff --git a/packages/cli/plugin-bff/src/utils/createHonoRoutes.ts b/packages/cli/plugin-bff/src/utils/createHonoRoutes.ts new file mode 100644 index 000000000000..e7c7589aa59a --- /dev/null +++ b/packages/cli/plugin-bff/src/utils/createHonoRoutes.ts @@ -0,0 +1,130 @@ +import type { APIHandlerInfo } from '@modern-js/bff-core'; +import { + HttpMetadata, + type ResponseMeta, + ResponseMetaType, + ValidationError, + isWithMetaHandler, +} from '@modern-js/bff-core'; +import type { Context, Next } from '@modern-js/server-core'; +import typeIs from 'type-is'; + +type Handler = APIHandlerInfo['handler']; + +const createHonoRoutes = (handlerInfos: APIHandlerInfo[]) => { + return handlerInfos.map(({ routePath, handler, httpMethod }) => { + const routeMiddlwares = Reflect.getMetadata('middleware', handler) || []; + const honoHandler = createHonoHandler(handler); + + return { + method: httpMethod.toLowerCase() as any, + path: routePath, + handler: + routeMiddlwares.length > 0 + ? [...routeMiddlwares, honoHandler] + : honoHandler, + }; + }); +}; + +const handleResponseMeta = (c: Context, handler: Handler) => { + const responseMeta: ResponseMeta[] = Reflect.getMetadata( + HttpMetadata.Response, + handler, + ); + if (Array.isArray(responseMeta)) { + for (const meta of responseMeta) { + switch (meta.type) { + case ResponseMetaType.Headers: + for (const [key, value] of Object.entries(meta.value as object)) { + c.header(key, value as string); + } + break; + case ResponseMetaType.Redirect: + return c.redirect(meta.value as string); + case ResponseMetaType.StatusCode: + c.status(meta.value as number); + break; + default: + break; + } + } + } + return null; +}; + +export const createHonoHandler = (handler: Handler) => { + return async (c: Context, next: Next) => { + try { + const input = await getHonoInput(c); + + if (isWithMetaHandler(handler)) { + try { + const response = handleResponseMeta(c, handler); + if (response) { + return response; + } + if (c.finalized) return; + + const result = await handler(input); + return result && typeof result === 'object' + ? c.json(result) + : c.body(result); + } catch (error) { + if (error instanceof ValidationError) { + c.status((error as any).status); + + return c.json({ + message: error.message, + }); + } + throw error; + } + } else { + const args = Object.values(input.params).concat(input); + try { + const body = await handler(...args); + if (c.finalized) { + return await Promise.resolve(); + } + + if (typeof body !== 'undefined') { + return c.json(body); + } + } catch { + return next(); + } + } + } catch (error) { + next(); + } + }; +}; + +const getHonoInput = async (c: Context) => { + const draft: Record = { + params: c.req.param(), + query: c.req.query(), + headers: c.req.header(), + cookies: c.req.header('cookie'), + }; + + try { + const contentType = c.req.header('content-type') || ''; + + if (typeIs.is(contentType, ['application/json'])) { + draft.data = await c.req.json(); + } else if (typeIs.is(contentType, ['multipart/form-data'])) { + draft.formData = await c.req.parseBody(); + } else if (typeIs.is(contentType, ['application/x-www-form-urlencoded'])) { + draft.formUrlencoded = await c.req.parseBody(); + } else { + draft.body = await c.req.json(); + } + } catch (error) { + draft.body = null; + } + return draft; +}; + +export default createHonoRoutes; diff --git a/packages/cli/plugin-bff/tests/__snapshots__/cli.test.ts.snap b/packages/cli/plugin-bff/tests/__snapshots__/cli.test.ts.snap index 3a92d899524c..92119b733b5a 100644 --- a/packages/cli/plugin-bff/tests/__snapshots__/cli.test.ts.snap +++ b/packages/cli/plugin-bff/tests/__snapshots__/cli.test.ts.snap @@ -3,6 +3,9 @@ exports[`bff cli plugin config 1`] = ` [ { + "output": { + "externals": undefined, + }, "source": { "moduleScopes": [ "api", diff --git a/packages/server/core/package.json b/packages/server/core/package.json index aa0db11c8a4e..e20d71338c48 100644 --- a/packages/server/core/package.json +++ b/packages/server/core/package.json @@ -56,13 +56,14 @@ }, "dependencies": { "@modern-js/plugin": "workspace:*", - "@modern-js/runtime-utils": "workspace:*", "@modern-js/plugin-v2": "workspace:*", + "@modern-js/runtime-utils": "workspace:*", "@modern-js/utils": "workspace:*", "@swc/helpers": "0.5.13", "@web-std/fetch": "^4.2.1", "@web-std/file": "^3.0.3", "@web-std/stream": "^1.0.3", + "cloneable-readable": "^3.0.0", "flatted": "^3.2.9", "hono": "^3.12.2", "ts-deepmerge": "7.0.2" @@ -71,6 +72,7 @@ "@modern-js/types": "workspace:*", "@scripts/build": "workspace:*", "@scripts/jest-config": "workspace:*", + "@types/cloneable-readable": "^2.0.3", "@types/jest": "^29", "@types/merge-deep": "^3.0.0", "@types/node": "^14", diff --git a/packages/server/core/src/adapters/node/node.ts b/packages/server/core/src/adapters/node/node.ts index 20b0719a3de1..a7bf90ce7e7b 100644 --- a/packages/server/core/src/adapters/node/node.ts +++ b/packages/server/core/src/adapters/node/node.ts @@ -6,6 +6,7 @@ import type { } from 'node:http2'; import type { Server as NodeHttpsServer } from 'node:https'; import type { NodeRequest, NodeResponse } from '@modern-js/types/server'; +import cloneable from 'cloneable-readable'; import type { RequestHandler } from '../../types'; import { isResFinalized } from './helper'; import { installGlobals } from './polyfills/install'; @@ -43,25 +44,38 @@ export const createWebRequest = ( res.on('close', () => controller.abort('res closed')); const url = `http://${req.headers.host}${req.url}`; - const fullUrl = new URL(url); - // Since we don't want break changes and now node.req.body will be consumed in bff, custom server, render, so we don't create a stream and consume node.req here by default. - if ( - body || - (!(method === 'GET' || method === 'HEAD') && - fullUrl.searchParams.has('__loader')) || - fullUrl.searchParams.has('__pass_body') || - req.headers['x-mf-micro'] || - req.headers['x-rsc-action'] || - req.headers['x-parse-through-body'] - ) { - init.body = body ?? createReadableStreamFromReadable(req); + const needsRequestBody = body || !(method === 'GET' || method === 'HEAD'); + const cloneableReq = needsRequestBody ? cloneable(req) : null; + + if (needsRequestBody) { + if (body) { + init.body = body; + } else { + const stream = cloneableReq!.clone(); + init.body = createReadableStreamFromReadable(stream); + } (init as { duplex: 'half' }).duplex = 'half'; } - const request = new Request(url, init); + const originalRequest = new Request(url, init); + + if (needsRequestBody) { + return new Proxy(originalRequest, { + get(target, prop) { + if ( + ['json', 'text', 'blob', 'arrayBuffer', 'formData', 'body'].includes( + prop as string, + ) + ) { + cloneableReq!.resume(); + } + return target[prop as keyof Request]; + }, + }); + } - return request; + return originalRequest; }; export const sendResponse = async (response: Response, res: NodeResponse) => { @@ -124,6 +138,7 @@ const getRequestListener = (handler: RequestHandler) => { return async (req: NodeRequest, res: NodeResponse) => { try { const request = createWebRequest(req, res); + const response = await handler(request, { node: { req, diff --git a/packages/server/core/src/context.ts b/packages/server/core/src/context.ts new file mode 100644 index 000000000000..9836693de16e --- /dev/null +++ b/packages/server/core/src/context.ts @@ -0,0 +1,6 @@ +import type { Context } from 'hono'; +import { createStorage } from './utils/storage'; + +const { run, useHonoContext } = createStorage(); + +export { run, useHonoContext }; diff --git a/packages/server/core/src/index.ts b/packages/server/core/src/index.ts index 1a88c103f44d..335c16fe0736 100644 --- a/packages/server/core/src/index.ts +++ b/packages/server/core/src/index.ts @@ -4,6 +4,8 @@ export { AGGRED_DIR } from './constants'; export type { ServerBase, ServerBaseOptions } from './serverBase'; export { createServerBase } from './serverBase'; +export { useHonoContext } from './context'; +export { Hono, type MiddlewareHandler } from 'hono'; export type { Middleware, diff --git a/packages/server/core/src/serverBase.ts b/packages/server/core/src/serverBase.ts index 498f7fb3988a..6f82c905ae80 100644 --- a/packages/server/core/src/serverBase.ts +++ b/packages/server/core/src/serverBase.ts @@ -1,7 +1,9 @@ import type { Plugin } from '@modern-js/plugin-v2'; import { type ServerCreateOptions, server } from '@modern-js/plugin-v2/server'; import { Hono, type MiddlewareHandler } from 'hono'; +import { run } from './context'; import { handleSetupResult } from './plugins/compat/hooks'; + import type { Env, ServerConfig, @@ -39,6 +41,7 @@ export class ServerBase { this.options = options; this.app = new Hono(); + this.app.use('*', run); } /** diff --git a/packages/server/core/src/types/plugins/base.ts b/packages/server/core/src/types/plugins/base.ts index 7905097a4a4a..e094bbbcd483 100644 --- a/packages/server/core/src/types/plugins/base.ts +++ b/packages/server/core/src/types/plugins/base.ts @@ -80,6 +80,8 @@ export type Middleware = { order?: MiddlewareOrder; }; +export type ServerMiddleware = Middleware; + export interface GetRenderHandlerOptions { pwd: string; routes: ServerRoute[]; diff --git a/packages/server/core/src/types/plugins/index.ts b/packages/server/core/src/types/plugins/index.ts index 3d62c340edaa..56e18b7b5c5b 100644 --- a/packages/server/core/src/types/plugins/index.ts +++ b/packages/server/core/src/types/plugins/index.ts @@ -10,4 +10,5 @@ export type { FallbackInput, WebServerStartInput, APIServerStartInput, + ServerMiddleware, } from './base'; diff --git a/packages/server/core/src/utils/storage.ts b/packages/server/core/src/utils/storage.ts new file mode 100644 index 000000000000..fdf0c78f2556 --- /dev/null +++ b/packages/server/core/src/utils/storage.ts @@ -0,0 +1,46 @@ +import * as ah from 'async_hooks'; + +const createStorage = () => { + let storage: ah.AsyncLocalStorage; + + if (typeof ah.AsyncLocalStorage !== 'undefined') { + storage = new ah.AsyncLocalStorage(); + } + + const run = (context: T, cb: () => O | Promise): Promise => { + if (!storage) { + throw new Error(`Unable to use async_hook, please confirm the node version >= 12.17 + `); + } + + return new Promise((resolve, reject) => { + storage.run(context, () => { + try { + return resolve(cb()); + } catch (error) { + return reject(error); + } + }); + }); + }; + + const useHonoContext: () => T = () => { + if (!storage) { + throw new Error(`Unable to use async_hook, please confirm the node version >= 12.17 + `); + } + const context = storage.getStore(); + if (!context) { + throw new Error(`Can't call useContext out of server scope`); + } + + return context; + }; + + return { + run, + useHonoContext, + }; +}; + +export { createStorage }; diff --git a/packages/server/plugin-express/src/cli/index.ts b/packages/server/plugin-express/src/cli/index.ts index bd0df03f4925..c88dd012f9e2 100644 --- a/packages/server/plugin-express/src/cli/index.ts +++ b/packages/server/plugin-express/src/cli/index.ts @@ -9,6 +9,16 @@ export const expressPlugin = (): CliPlugin => ({ let bffExportsUtils: any; const { useAppContext } = api; const runtimeModulePath = path.resolve(__dirname, '../runtime'); + + const useConfig = api.useConfigContext(); + + const appContext = api.useAppContext(); + + api.setAppContext({ + ...appContext, + bffRuntimeFramework: 'express', + }); + return { config() { const appContext = useAppContext(); @@ -17,8 +27,6 @@ export const expressPlugin = (): CliPlugin => ({ 'server', ); - const useConfig = api.useConfigContext(); - const runtimePath = process.env.NODE_ENV === 'development' && !useConfig?.bff?.crossProject diff --git a/packages/server/plugin-express/src/plugin.ts b/packages/server/plugin-express/src/plugin.ts index db917b1bbbf4..6570a79c4c2c 100644 --- a/packages/server/plugin-express/src/plugin.ts +++ b/packages/server/plugin-express/src/plugin.ts @@ -214,13 +214,14 @@ export default (): ServerPluginLegacy => { return resolve(); }; - res.on('finish', (err: Error) => { - if (err) { - return reject(err); - } + res.on('finish', () => { return resolve(); }); + res.on('error', (err: Error) => { + return reject(err); + }); + return app(req as Request, res as Response, handler); }); return httpCallBack2HonoMid( diff --git a/packages/server/plugin-koa/src/cli/index.ts b/packages/server/plugin-koa/src/cli/index.ts index e1b23c0d555c..2778e69c9ca2 100644 --- a/packages/server/plugin-koa/src/cli/index.ts +++ b/packages/server/plugin-koa/src/cli/index.ts @@ -9,6 +9,15 @@ export const koaPlugin = (): CliPlugin => ({ let bffExportsUtils: any; const { useAppContext } = api; const runtimeModulePath = path.resolve(__dirname, '../runtime'); + const useConfig = api.useConfigContext(); + + const appContext = api.useAppContext(); + + api.setAppContext({ + ...appContext, + bffRuntimeFramework: 'koa', + }); + return { config() { const appContext = useAppContext(); @@ -17,12 +26,10 @@ export const koaPlugin = (): CliPlugin => ({ 'server', ); - const modernConfig = api.useResolvedConfigContext(); - const runtimePath = '@modern-js/plugin-koa/runtime'; const alias = process.env.NODE_ENV === 'production' || - !!modernConfig?.bff?.crossProject + !!useConfig?.bff?.crossProject ? runtimePath : require.resolve(runtimePath); diff --git a/packages/solutions/app-tools/src/commands/dev.ts b/packages/solutions/app-tools/src/commands/dev.ts index 3cabc992a886..e6e838127d10 100644 --- a/packages/solutions/app-tools/src/commands/dev.ts +++ b/packages/solutions/app-tools/src/commands/dev.ts @@ -110,6 +110,7 @@ export const dev = async ( apiDirectory: appContext.apiDirectory, lambdaDirectory: appContext.lambdaDirectory, sharedDirectory: appContext.sharedDirectory, + bffRuntimeFramework: appContext.bffRuntimeFramework, }, serverConfigPath, routes: serverRoutes, diff --git a/packages/solutions/app-tools/src/commands/serve.ts b/packages/solutions/app-tools/src/commands/serve.ts index 3401fbeeaab9..911dc526bb47 100644 --- a/packages/solutions/app-tools/src/commands/serve.ts +++ b/packages/solutions/app-tools/src/commands/serve.ts @@ -95,6 +95,7 @@ export const serve = async ( appContext.appDirectory, appContext.distDirectory, ), + bffRuntimeFramework: appContext.bffRuntimeFramework, }, runMode, }); diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify-handler.cjs b/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify-handler.cjs index 433b866645de..040196b3fb4e 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify-handler.cjs +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify-handler.cjs @@ -39,6 +39,7 @@ async function initServer() { sharedDirectory: p_sharedDirectory, apiDirectory: p_apiDirectory, lambdaDirectory: p_lambdaDirectory, + bffRuntimeFramework: p_bffRuntimeFramework, }, plugins: p_plugins, ...dynamicProdOptions, diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts b/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts index f0dac5bfcb08..b0b74f7de041 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/netlify.ts @@ -138,6 +138,10 @@ export const createNetlifyPreset: CreatePreset = ( .replace('p_ROUTE_SPEC_FILE', `"${ROUTE_SPEC_FILE}"`) .replace('p_dynamicProdOptions', JSON.stringify(dynamicProdOptions)) .replace('p_plugins', pluginsCode) + .replace( + 'p_bffRuntimeFramework', + `"${serverAppContext.bffRuntimeFramework}"`, + ) .replace('p_sharedDirectory', serverAppContext.sharedDirectory) .replace('p_apiDirectory', serverAppContext.apiDirectory) .replace('p_lambdaDirectory', serverAppContext.lambdaDirectory); diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/node-entry.js b/packages/solutions/app-tools/src/plugins/deploy/platforms/node-entry.js index 85dcc811ed4f..a6b7cb5df3ee 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/node-entry.js +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/node-entry.js @@ -35,6 +35,7 @@ async function main() { sharedDirectory: p_sharedDirectory, apiDirectory: p_apiDirectory, lambdaDirectory: p_lambdaDirectory, + bffRuntimeFramework: p_bffRuntimeFramework, }, plugins: p_plugins, ...dynamicProdOptions, diff --git a/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts b/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts index cd155ec49cc4..cd59c7e0c5fa 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/platforms/node.ts @@ -70,6 +70,10 @@ export const createNodePreset: CreatePreset = (appContext, config) => { .replace('p_plugins', pluginsCode) .replace('p_sharedDirectory', serverAppContext.sharedDirectory) .replace('p_apiDirectory', serverAppContext.apiDirectory) + .replace( + 'p_bffRuntimeFramework', + `"${serverAppContext.bffRuntimeFramework}"`, + ) .replace('p_lambdaDirectory', serverAppContext.lambdaDirectory); if (isEsmProject) { diff --git a/packages/solutions/app-tools/src/plugins/deploy/utils.ts b/packages/solutions/app-tools/src/plugins/deploy/utils.ts index 3867a05b7d7a..360dbb770a65 100644 --- a/packages/solutions/app-tools/src/plugins/deploy/utils.ts +++ b/packages/solutions/app-tools/src/plugins/deploy/utils.ts @@ -8,6 +8,7 @@ export type ServerAppContext = { apiDirectory: string; lambdaDirectory: string; metaName: string; + bffRuntimeFramework: string; }; export const serverAppContenxtTemplate = ( @@ -19,6 +20,7 @@ export const serverAppContenxtTemplate = ( apiDirectory, lambdaDirectory, metaName, + bffRuntimeFramework, } = appContext; return { sharedDirectory: `path.join(__dirname, "${path.relative( @@ -34,6 +36,7 @@ export const serverAppContenxtTemplate = ( lambdaDirectory, )}")`, metaName, + bffRuntimeFramework: bffRuntimeFramework || 'hono', }; }; diff --git a/packages/solutions/app-tools/src/types/new.ts b/packages/solutions/app-tools/src/types/new.ts index 3e30f72adaa6..0648b082984d 100644 --- a/packages/solutions/app-tools/src/types/new.ts +++ b/packages/solutions/app-tools/src/types/new.ts @@ -162,6 +162,11 @@ export interface AppToolsExtendContext { * @deprecated compat old plugin, default is app tools */ toolsType?: string; + /** + * Identification for bff runtime framework + * @private + */ + bffRuntimeFramework?: string; } export type AppToolsContext = AppContext< diff --git a/packages/solutions/app-tools/src/utils/initAppContext.ts b/packages/solutions/app-tools/src/utils/initAppContext.ts index 0705e852221f..e2ecead69cd1 100644 --- a/packages/solutions/app-tools/src/utils/initAppContext.ts +++ b/packages/solutions/app-tools/src/utils/initAppContext.ts @@ -46,5 +46,6 @@ export const initAppContext = ({ apiOnly: false, internalDirAlias: `@_${metaName.replace(/-/g, '_')}_internal`, internalSrcAlias: `@_${metaName.replace(/-/g, '_')}_src`, + bffRuntimeFramework: 'hono', }; }; diff --git a/packages/toolkit/plugin-v2/src/server/context.ts b/packages/toolkit/plugin-v2/src/server/context.ts index 422a58e95661..3686de4c2c0e 100644 --- a/packages/toolkit/plugin-v2/src/server/context.ts +++ b/packages/toolkit/plugin-v2/src/server/context.ts @@ -28,6 +28,7 @@ export function initServerContext(params: { metaName: options.metaName || 'modern-js', plugins: plugins, middlewares: [], + bffRuntimeFramework: options.appContext.bffRuntimeFramework, }; } diff --git a/packages/toolkit/plugin-v2/src/server/run/types.ts b/packages/toolkit/plugin-v2/src/server/run/types.ts index 6403641a8d75..46a520d38ccf 100644 --- a/packages/toolkit/plugin-v2/src/server/run/types.ts +++ b/packages/toolkit/plugin-v2/src/server/run/types.ts @@ -12,6 +12,7 @@ export type ServerCreateOptions = { sharedDirectory?: string; apiDirectory?: string; lambdaDirectory?: string; + bffRuntimeFramework?: string; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa04b08ba3b2..bedb17318b34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -203,6 +203,9 @@ importers: '@swc/helpers': specifier: 0.5.13 version: 0.5.13 + type-is: + specifier: ^1.6.18 + version: 1.6.18 devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -237,6 +240,9 @@ importers: '@types/node': specifier: ^14 version: 14.18.35 + '@types/type-is': + specifier: ^1.6.3 + version: 1.6.7 jest: specifier: ^29 version: 29.5.0(@types/node@14.18.35)(ts-node@10.9.2(@swc/core@1.10.18(@swc/helpers@0.5.13))(@types/node@14.18.35)(typescript@5.6.3)) @@ -252,6 +258,9 @@ importers: webpack: specifier: ^5.98.0 version: 5.98.0(@swc/core@1.10.18(@swc/helpers@0.5.13))(esbuild@0.17.19) + zod: + specifier: ^3.22.3 + version: 3.22.3 packages/cli/plugin-changeset: dependencies: @@ -2983,6 +2992,9 @@ importers: '@web-std/stream': specifier: ^1.0.3 version: 1.0.3 + cloneable-readable: + specifier: ^3.0.0 + version: 3.0.0 flatted: specifier: ^3.2.9 version: 3.3.3 @@ -3002,6 +3014,9 @@ importers: '@scripts/jest-config': specifier: workspace:* version: link:../../../scripts/jest-config + '@types/cloneable-readable': + specifier: ^2.0.3 + version: 2.0.3 '@types/jest': specifier: ^29 version: 29.5.14 @@ -5626,6 +5641,52 @@ importers: specifier: ^5 version: 5.6.3 + tests/integration/bff-hono: + dependencies: + '@modern-js/plugin-bff': + specifier: workspace:* + version: link:../../../packages/cli/plugin-bff + '@modern-js/runtime': + specifier: workspace:* + version: link:../../../packages/runtime/plugin-runtime + hono: + specifier: ^3.12.2 + version: 3.12.12 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + ts-node: + specifier: ^10.9.1 + version: 10.9.2(@swc/core@1.10.18(@swc/helpers@0.5.17))(@types/node@14.18.35)(typescript@5.6.3) + tsconfig-paths: + specifier: 3.14.1 + version: 3.14.1 + zod: + specifier: ^3.22.3 + version: 3.22.3 + devDependencies: + '@modern-js/app-tools': + specifier: workspace:* + version: link:../../../packages/solutions/app-tools + '@types/jest': + specifier: ^29 + version: 29.5.14 + '@types/node': + specifier: ^14 + version: 14.18.35 + '@types/react': + specifier: ^18.3.11 + version: 18.3.18 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.5(@types/react@18.3.18) + typescript: + specifier: ^5 + version: 5.6.3 + tests/integration/bff-koa: dependencies: '@modern-js/plugin-bff': @@ -13509,6 +13570,9 @@ packages: '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + '@types/cloneable-readable@2.0.3': + resolution: {integrity: sha512-+Ihof4L4iu9k4WTzYbJSkzUxt6f1wzXn6u48fZYxgST+BsC9bBHTOJ59Buy1/4sC9j7ZWF7bxDf/n/mrtk/nzw==} + '@types/configstore@2.1.1': resolution: {integrity: sha512-YY+hm3afkDHeSM2rsFXxeZtu0garnusBWNG1+7MknmDWQHqcH2w21/xOU9arJUi8ch4qyFklidANLCu3ihhVwQ==} @@ -15107,6 +15171,9 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cloneable-readable@3.0.0: + resolution: {integrity: sha512-Lkfd9IRx1nfiBr7UHNxJSl/x7DOeUfYmxzCkxYJC2tyc/9vKgV75msgLGurGQsak/NvJDHMWcshzEXRlxfvhqg==} + clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} @@ -30028,6 +30095,10 @@ snapshots: '@types/node': 18.19.74 '@types/responselike': 1.0.3 + '@types/cloneable-readable@2.0.3': + dependencies: + '@types/node': 18.19.74 + '@types/configstore@2.1.1': {} '@types/connect-history-api-fallback@1.5.4': @@ -32063,6 +32134,10 @@ snapshots: clone@1.0.4: {} + cloneable-readable@3.0.0: + dependencies: + readable-stream: 4.5.2 + clsx@1.2.1: {} co-body@5.2.0: diff --git a/tests/integration/bff-hono/CHANGELOG.md b/tests/integration/bff-hono/CHANGELOG.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/tests/integration/bff-hono/api/context/index.ts b/tests/integration/bff-hono/api/context/index.ts new file mode 100644 index 000000000000..d67fb6fc00ee --- /dev/null +++ b/tests/integration/bff-hono/api/context/index.ts @@ -0,0 +1,10 @@ +import { useHonoContext } from '@modern-js/plugin-bff/hono'; + +export default async () => { + const ctx = useHonoContext(); + const { res } = ctx; + res.headers.set('x-id', '1'); + return { + message: 'Hello Modern.js', + }; +}; diff --git a/tests/integration/bff-hono/api/index.ts b/tests/integration/bff-hono/api/index.ts new file mode 100644 index 000000000000..1e52c3109412 --- /dev/null +++ b/tests/integration/bff-hono/api/index.ts @@ -0,0 +1,95 @@ +import { + Api, + Data, + Get, + Headers, + Middleware, + Params, + Pipe, + Post, + Query, + useHonoContext, +} from '@modern-js/plugin-bff/hono'; +import { z } from 'zod'; + +export default async () => { + return { + message: 'Hello Modern.js', + }; +}; + +export const post = async ({ formUrlencoded }: { formUrlencoded: any }) => { + return { + message: 'formUrlencoded data', + formUrlencoded, + }; +}; + +const QuerySchema = z.object({ + user: z.string().email(), +}); + +const DataSchema = z.object({ + message: z.string(), +}); + +const ParamsSchema = z.object({ + id: z.string(), +}); + +const HeadersSchema = z.object({ + 'x-header': z.string(), +}); + +export const postHello = Api( + Post('/hello/:id'), + Params(ParamsSchema), + Query(QuerySchema), + Data(DataSchema), + Headers(HeadersSchema), + Middleware(async (c, next) => { + c.res.headers.set('x-bff-fn-middleware', '1'); + await next(); + }), + Pipe<{ + params: z.infer; + query: z.infer; + data: z.infer; + headers: z.infer; + }>(input => { + const { data } = input; + if (!data.message.startsWith('msg: ')) { + data.message = `msg: ${data.message}`; + } + return input; + }), + async ({ query, data, params, headers }) => { + const c = useHonoContext(); + c.res.headers.set('x-bff-api', c.req.path); + return { + path: c.req.path, + params, + query, + data, + headers, + }; + }, +); + +export const getHello = Api( + Get('/hello/get'), + Query(QuerySchema), + async ({ query }) => { + try { + const c = useHonoContext(); + c.res.headers.set('x-bff-api', c.req.path); + } catch (error) { + return { + query: 0, + }; + } + return { + query, + }; + }, +); diff --git a/tests/integration/bff-hono/api/upload.ts b/tests/integration/bff-hono/api/upload.ts new file mode 100644 index 000000000000..a963f17a8ced --- /dev/null +++ b/tests/integration/bff-hono/api/upload.ts @@ -0,0 +1,19 @@ +import { Api, Upload } from '@modern-js/plugin-bff/hono'; +import { z } from 'zod'; + +const FileSchema = z.object({ + images: z.record(z.string(), z.any()), +}); + +export const upload = Api( + Upload('/upload', FileSchema), + async ({ formData }) => { + // do somethings + return { + data: { + code: 10, + file_name: formData.images.name, + }, + }; + }, +); diff --git a/tests/integration/bff-hono/modern.config.ts b/tests/integration/bff-hono/modern.config.ts new file mode 100644 index 000000000000..8cdb25e2e16d --- /dev/null +++ b/tests/integration/bff-hono/modern.config.ts @@ -0,0 +1,17 @@ +import { bffPlugin } from '@modern-js/plugin-bff'; +import { applyBaseConfig } from '../../utils/applyBaseConfig'; + +export default applyBaseConfig({ + server: { + ssr: { + mode: 'stream', + }, + }, + runtime: { + router: true, + }, + bff: { + prefix: '/bff-api', + }, + plugins: [bffPlugin()], +}); diff --git a/tests/integration/bff-hono/package.json b/tests/integration/bff-hono/package.json new file mode 100644 index 000000000000..e13e29b41265 --- /dev/null +++ b/tests/integration/bff-hono/package.json @@ -0,0 +1,103 @@ +{ + "private": true, + "name": "bff-hono", + "version": "2.65.2", + "scripts": { + "dev": "modern dev", + "dev:bff": "modern dev --api-only", + "build": "modern build", + "serve": "modern serve", + "start:bff": "modern serve --api-only", + "new": "modern new" + }, + "engines": { + "node": ">=14.17.6" + }, + "dependencies": { + "@modern-js/plugin-bff": "workspace:*", + "@modern-js/runtime": "workspace:*", + "hono": "^3.12.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "ts-node": "^10.9.1", + "tsconfig-paths": "3.14.1", + "zod": "^3.22.3" + }, + "devDependencies": { + "@modern-js/app-tools": "workspace:*", + "@types/jest": "^29", + "@types/node": "^14", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "typescript": "^5", + "tsconfig-paths": "~3.14.1" + }, + "exports": { + "./context/index": { + "import": "./dist/client/context/index.js", + "types": "./dist/api/context/index.d.ts" + }, + "./index": { + "import": "./dist/client/index.js", + "types": "./dist/api/index.d.ts" + }, + "./upload": { + "import": "./dist/client/upload.js", + "types": "./dist/api/upload.d.ts" + }, + "./server-plugin": "./dist/server-plugin/index.js", + "./runtime": { + "import": "./dist/runtime/index.js", + "types": "./dist/runtime/index.d.ts" + }, + "./plugin": { + "require": "./dist/plugin/index.js", + "types": "./dist/plugin/index.d.ts" + }, + "./api/context/index": { + "import": "./dist/client/context/index.js", + "types": "./dist/client/context/index.d.ts" + }, + "./api/index": { + "import": "./dist/client/index.js", + "types": "./dist/client/index.d.ts" + }, + "./api/upload": { + "import": "./dist/client/upload.js", + "types": "./dist/client/upload.d.ts" + } + }, + "typesVersions": { + "*": { + "context/index": [ + "./dist/api/context/index.d.ts" + ], + "index": [ + "./dist/api/index.d.ts" + ], + "upload": [ + "./dist/api/upload.d.ts" + ], + "runtime": [ + "./dist/runtime/index.d.ts" + ], + "plugin": [ + "./dist/plugin/index.d.ts" + ], + "api/context/index": [ + "./dist/client/context/index.d.ts" + ], + "api/index": [ + "./dist/client/index.d.ts" + ], + "api/upload": [ + "./dist/client/upload.d.ts" + ] + } + }, + "files": [ + "dist/client/**/*", + "dist/runtime/**/*", + "dist/plugin/**/*" + ] +} diff --git a/tests/integration/bff-hono/src/modern-app-env.d.ts b/tests/integration/bff-hono/src/modern-app-env.d.ts new file mode 100644 index 000000000000..7ef776b13595 --- /dev/null +++ b/tests/integration/bff-hono/src/modern-app-env.d.ts @@ -0,0 +1,4 @@ +/// +/// +/// +/// diff --git a/tests/integration/bff-hono/src/routes/base/page.tsx b/tests/integration/bff-hono/src/routes/base/page.tsx new file mode 100644 index 000000000000..5ffb95079c1e --- /dev/null +++ b/tests/integration/bff-hono/src/routes/base/page.tsx @@ -0,0 +1,72 @@ +import hello, { post, postHello, getHello } from '@api/index'; +import { configure } from '@modern-js/runtime/bff'; +import { useEffect, useState } from 'react'; + +configure({ + interceptor(request) { + return async (url, params) => { + const res = await request(url, params); + return res.json(); + }; + }, +}); + +const Page = () => { + const [message, setMessage] = useState('bff-hono'); + const [username, setUsername] = useState('username'); + + useEffect(() => { + const fetchData = async () => { + const res = await hello(); + // 加一个延时,帮助集测取第一次的 message 值 + await new Promise(resolve => setTimeout(resolve, 50)); + setMessage(res.message); + }; + + fetchData(); + postHello({ + params: { + id: '1111', + }, + query: { + user: 'modern@email.com', + }, + data: { + message: '3333', + }, + headers: { + 'x-header': '3333', + }, + }); + + getHello({ + query: { + user: 'modern@email.com', + }, + }); + }, []); + + useEffect(() => { + const data = { + username: 'user123', + password: 'pass456', + }; + + const params = new URLSearchParams(); + params.append('username', data.username); + params.append('password', data.password); + + post({ + formUrlencoded: params.toString(), + }).then(res => setUsername(res.formUrlencoded.username)); + }, []); + + return ( +
+
{message}
+
{username}
+
+ ); +}; + +export default Page; diff --git a/tests/integration/bff-hono/src/routes/custom-sdk/page.tsx b/tests/integration/bff-hono/src/routes/custom-sdk/page.tsx new file mode 100644 index 000000000000..b687fc13ac34 --- /dev/null +++ b/tests/integration/bff-hono/src/routes/custom-sdk/page.tsx @@ -0,0 +1,27 @@ +import hello from '@api/index'; +import { configure } from '@modern-js/runtime/bff'; +import { useEffect, useState } from 'react'; + +configure({ + interceptor(request) { + return async (url, params) => { + const res = await request(url, params); + const data = await res.json(); + data.message = 'Hello Custom SDK'; + return data; + }; + }, +}); + +const Page = () => { + const [data, setData] = useState<{ message: string }>(); + useEffect(() => { + hello().then(res => { + setData(res); + }); + }, []); + const { message = 'bff-hono' } = data || {}; + return
{message}
; +}; + +export default Page; diff --git a/tests/integration/bff-hono/src/routes/layout.tsx b/tests/integration/bff-hono/src/routes/layout.tsx new file mode 100644 index 000000000000..5b1742692f47 --- /dev/null +++ b/tests/integration/bff-hono/src/routes/layout.tsx @@ -0,0 +1,9 @@ +import { Outlet } from '@modern-js/runtime/router'; + +export default () => { + return ( +
+ +
+ ); +}; diff --git a/tests/integration/bff-hono/src/routes/page.loader.ts b/tests/integration/bff-hono/src/routes/page.loader.ts new file mode 100644 index 000000000000..059b25c43d84 --- /dev/null +++ b/tests/integration/bff-hono/src/routes/page.loader.ts @@ -0,0 +1,23 @@ +import { useHonoContext } from '@modern-js/plugin-bff/hono'; +import { defer } from '@modern-js/runtime/router'; + +interface Ctx { + path: string; +} + +export interface Data { + data: Ctx; +} + +export default () => { + const ctx = useHonoContext(); + const _ctx = new Promise(resolve => { + setTimeout(() => { + resolve({ + path: ctx.req.path, + }); + }, 200); + }); + + return defer({ data: _ctx }); +}; diff --git a/tests/integration/bff-hono/src/routes/page.tsx b/tests/integration/bff-hono/src/routes/page.tsx new file mode 100644 index 000000000000..430ce79c0e11 --- /dev/null +++ b/tests/integration/bff-hono/src/routes/page.tsx @@ -0,0 +1,21 @@ +import { Await, useLoaderData } from '@modern-js/runtime/router'; +import { Suspense } from 'react'; +import type { Data } from './page.loader'; + +const Page = () => { + const data = useLoaderData() as Data; + return ( +
+ ctx.req info: + loading user data ...
}> + + {ctx => { + return
path: {ctx.path}
; + }} +
+ + + ); +}; + +export default Page; diff --git a/tests/integration/bff-hono/src/routes/ssr/page.data.ts b/tests/integration/bff-hono/src/routes/ssr/page.data.ts new file mode 100644 index 000000000000..4eb17c27fc8c --- /dev/null +++ b/tests/integration/bff-hono/src/routes/ssr/page.data.ts @@ -0,0 +1,10 @@ +import hello from '@api/index'; + +export type ProfileData = { + message: string; +}; + +export const loader = async (): Promise => { + const res = await hello(); + return res; +}; diff --git a/tests/integration/bff-hono/src/routes/ssr/page.tsx b/tests/integration/bff-hono/src/routes/ssr/page.tsx new file mode 100644 index 000000000000..35e77cde96d3 --- /dev/null +++ b/tests/integration/bff-hono/src/routes/ssr/page.tsx @@ -0,0 +1,10 @@ +import { useLoaderData } from '@modern-js/runtime/router'; +import type { ProfileData } from './page.data'; + +const Page = () => { + const data = useLoaderData() as ProfileData; + const { message = 'bff-hono' } = data || {}; + return
{message}
; +}; + +export default Page; diff --git a/tests/integration/bff-hono/src/routes/upload/page.tsx b/tests/integration/bff-hono/src/routes/upload/page.tsx new file mode 100644 index 000000000000..ef49b7e322aa --- /dev/null +++ b/tests/integration/bff-hono/src/routes/upload/page.tsx @@ -0,0 +1,74 @@ +import { upload } from '@api/upload'; +import React, { useEffect } from 'react'; + +const getMockImage = () => { + const imageData = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=='; + const blob = new Blob( + [Uint8Array.from(atob(imageData.split(',')[1]), c => c.charCodeAt(0))], + { type: 'image/png' }, + ); + + return new File([blob], 'mock_image.png', { type: 'image/png' }); +}; + +const Page = () => { + const [file, setFile] = React.useState(); + const [fileName, setFileName] = React.useState(''); + + const handleChange = (e: React.ChangeEvent) => { + setFile(e.target.files); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + const formData = new FormData(); + if (file) { + for (let i = 0; i < file.length; i++) { + formData.append('images', file[i]); + } + await fetch('/bff-api/upload', { + method: 'POST', + body: formData, + }); + } + }; + + const click = () => { + if (!file) { + return; + } + upload({ + files: { + images: file, + }, + }); + }; + + useEffect(() => { + upload({ + files: { + images: getMockImage(), + }, + }).then(res => { + setFileName(res.data.file_name); + }); + }, []); + + return ( + <> +

File Upload

+

{fileName}

+
+ + +
+
+ + +
+ + ); +}; + +export default Page; diff --git a/tests/integration/bff-hono/tests/index.test.ts b/tests/integration/bff-hono/tests/index.test.ts new file mode 100644 index 000000000000..0778b90bd13e --- /dev/null +++ b/tests/integration/bff-hono/tests/index.test.ts @@ -0,0 +1,149 @@ +import dns from 'node:dns'; +import path from 'path'; +import puppeteer, { type Browser, type Page } from 'puppeteer'; +import { + getPort, + killApp, + launchApp, + launchOptions, + modernBuild, + modernServe, +} from '../../../utils/modernTestUtils'; +import 'isomorphic-fetch'; + +dns.setDefaultResultOrder('ipv4first'); + +const appDir = path.resolve(__dirname, '../'); + +describe('bff hono in dev', () => { + let port = 8080; + const SSR_PAGE = 'ssr'; + const BASE_PAGE = 'base'; + const CUSTOM_PAGE = 'custom-sdk'; + const UPLOAD_PAGE = 'upload'; + const host = `http://localhost`; + const prefix = '/bff-api'; + let app: any; + let page: Page; + let browser: Browser; + + beforeAll(async () => { + jest.setTimeout(1000 * 60 * 2); + port = await getPort(); + app = await launchApp(appDir, port, {}); + browser = await puppeteer.launch(launchOptions as any); + page = await browser.newPage(); + }); + + test('basic usage', async () => { + await page.goto(`${host}:${port}/${BASE_PAGE}`, { + timeout: 50000, + }); + await new Promise(resolve => setTimeout(resolve, 3000)); + const text = await page.$eval('.hello', el => el?.textContent); + const username = await page.$eval('.username', el => el?.textContent); + + expect(text).toBe('Hello Modern.js'); + expect(username).toBe('user123'); + }); + + test('basic usage with ssr', async () => { + await page.goto(`${host}:${port}/${SSR_PAGE}`); + await new Promise(resolve => setTimeout(resolve, 3000)); + const text1 = await page.$eval('.hello', el => el?.textContent); + expect(text1).toBe('Hello Modern.js'); + }); + + test('support useContext', async () => { + const res = await fetch(`${host}:${port}${prefix}/context`); + const info = await res.json(); + expect(res.headers.get('x-id')).toBe('1'); + expect(info.message).toBe('Hello Modern.js'); + }); + + test('support custom sdk', async () => { + await page.goto(`${host}:${port}/${CUSTOM_PAGE}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + const text = await page.$eval('.hello', el => el?.textContent); + expect(text).toBe('Hello Custom SDK'); + }); + + test('support uoload', async () => { + await page.goto(`${host}:${port}/${UPLOAD_PAGE}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + const text = await page.$eval('.mock_file', el => el?.textContent); + expect(text).toBe('mock_image.png'); + }); + + afterAll(async () => { + await killApp(app); + await page.close(); + await browser.close(); + }); +}); + +describe('bff hono in prod', () => { + let port = 8080; + const SSR_PAGE = 'ssr'; + const BASE_PAGE = 'base'; + const CUSTOM_PAGE = 'custom-sdk'; + const UPLOAD_PAGE = 'upload'; + const host = `http://localhost`; + const prefix = '/bff-api'; + let app: any; + let page: Page; + let browser: Browser; + + beforeAll(async () => { + port = await getPort(); + + await modernBuild(appDir, [], {}); + + app = await modernServe(appDir, port, {}); + + browser = await puppeteer.launch(launchOptions as any); + page = await browser.newPage(); + }); + + test('basic usage', async () => { + await page.goto(`${host}:${port}/${BASE_PAGE}`); + const text1 = await page.$eval('.hello', el => el?.textContent); + expect(text1).toBe('bff-hono'); + await new Promise(resolve => setTimeout(resolve, 300)); + const text2 = await page.$eval('.hello', el => el?.textContent); + expect(text2).toBe('Hello Modern.js'); + }); + + test('basic usage with ssr', async () => { + await page.goto(`${host}:${port}/${SSR_PAGE}`); + const text1 = await page.$eval('.hello', el => el?.textContent); + expect(text1).toBe('Hello Modern.js'); + }); + + test('support useContext', async () => { + const res = await fetch(`${host}:${port}${prefix}/context`); + const info = await res.json(); + expect(res.headers.get('x-id')).toBe('1'); + expect(info.message).toBe('Hello Modern.js'); + }); + + test('support custom sdk', async () => { + await page.goto(`${host}:${port}/${CUSTOM_PAGE}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + const text = await page.$eval('.hello', el => el?.textContent); + expect(text).toBe('Hello Custom SDK'); + }); + + test('support uoload', async () => { + await page.goto(`${host}:${port}/${UPLOAD_PAGE}`); + await new Promise(resolve => setTimeout(resolve, 1000)); + const text = await page.$eval('.mock_file', el => el?.textContent); + expect(text).toBe('mock_image.png'); + }); + + afterAll(async () => { + await killApp(app); + await page.close(); + await browser.close(); + }); +}); diff --git a/tests/integration/bff-hono/tests/tsconfig.json b/tests/integration/bff-hono/tests/tsconfig.json new file mode 100644 index 000000000000..10f49432232c --- /dev/null +++ b/tests/integration/bff-hono/tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": true, + "jsx": "preserve", + "baseUrl": "./", + "emitDeclarationOnly": true, + "isolatedModules": true, + "paths": {}, + "types": ["node", "jest"] + } +} diff --git a/tests/integration/bff-hono/tsconfig.json b/tests/integration/bff-hono/tsconfig.json new file mode 100644 index 000000000000..1eda07e6e5f7 --- /dev/null +++ b/tests/integration/bff-hono/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@modern-js/tsconfig/base", + "compilerOptions": { + "declaration": false, + "jsx": "preserve", + "baseUrl": "./", + "paths": { + "@/*": ["./src/*"], + "@shared/*": ["./shared/*"], + "@api/*": ["./api/*"] + }, + "types": ["jest"] + }, + "include": ["src", "shared", "server", "config", "api", "modern.config.ts"] +}