From 30b0190505545ebee8a5b79e3d2208c096d92063 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Fri, 3 May 2024 21:18:48 +0900 Subject: [PATCH 1/4] feat: Use AsyncLocalStorage to share context in same request --- src/server/components/has-islands.tsx | 9 ++++++--- src/server/context-storage.ts | 3 +++ src/server/server.ts | 6 ++++++ test-integration/api.test.ts | 1 + test-integration/apps.test.ts | 5 +++++ 5 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/server/context-storage.ts diff --git a/src/server/components/has-islands.tsx b/src/server/components/has-islands.tsx index b100b3b..6e6e9ed 100644 --- a/src/server/components/has-islands.tsx +++ b/src/server/components/has-islands.tsx @@ -1,8 +1,11 @@ import type { FC } from 'hono/jsx' -import { useRequestContext } from 'hono/jsx-renderer' import { IMPORTING_ISLANDS_ID } from '../../constants.js' +import { contextStorage } from '../context-storage.js' export const HasIslands: FC = ({ children }) => { - const c = useRequestContext() - return <>{c.get(IMPORTING_ISLANDS_ID) ? children : <>} + const c = contextStorage.getStore() + if (!c) { + throw new Error('No context found') + } + return <>{c.get(IMPORTING_ISLANDS_ID) && children} } diff --git a/src/server/context-storage.ts b/src/server/context-storage.ts new file mode 100644 index 0000000..4e958cf --- /dev/null +++ b/src/server/context-storage.ts @@ -0,0 +1,3 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import type { Context } from 'hono' +export const contextStorage = new AsyncLocalStorage() diff --git a/src/server/server.ts b/src/server/server.ts index ec79a31..480ba57 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -10,6 +10,7 @@ import { listByDirectory, sortDirectoriesByDepth, } from '../utils/file.js' +import { contextStorage } from './context-storage.js' const NOTFOUND_FILENAME = '_404.tsx' const ERROR_FILENAME = '_error.tsx' @@ -62,6 +63,11 @@ export const createApp = (options: BaseServerOptions): Hono const app = options.app ?? new Hono() const trailingSlash = options.trailingSlash ?? false + // Share context by AsyncLocalStorage + app.use(async function ShareContext(c, next) { + await contextStorage.run(c, () => next()) + }) + if (options.init) { options.init(app) } diff --git a/test-integration/api.test.ts b/test-integration/api.test.ts index 4b95232..1487762 100644 --- a/test-integration/api.test.ts +++ b/test-integration/api.test.ts @@ -17,6 +17,7 @@ describe('Basic', () => { it('Should have correct routes', () => { const routes = [ + { path: '/*', method: 'ALL', handler: expect.anything() }, // ShareContext { path: '/*', method: 'ALL', handler: expect.anything() }, { path: '/about/*', diff --git a/test-integration/apps.test.ts b/test-integration/apps.test.ts index 56c51ff..9fbb308 100644 --- a/test-integration/apps.test.ts +++ b/test-integration/apps.test.ts @@ -18,6 +18,11 @@ describe('Basic', () => { it('Should have correct routes', () => { const routes = [ + { + path: '/*', + method: 'ALL', + handler: expect.any(Function), // ShareContext + }, { path: '/*', method: 'ALL', From fa52babe26cc67a6395d45ba89bf2ae587765318 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Fri, 3 May 2024 21:20:04 +0900 Subject: [PATCH 2/4] feat: replace reactApiImportSource also in server components --- src/vite/island-components.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vite/island-components.ts b/src/vite/island-components.ts index b1656bd..c557463 100644 --- a/src/vite/island-components.ts +++ b/src/vite/island-components.ts @@ -196,7 +196,7 @@ export function islandComponents(options?: IslandComponentsOptions): Plugin { }, async load(id) { - if (/\/honox\/.*?\/vite\/components\//.test(id)) { + if (/\/honox\/.*?\/(?:server|vite)\/components\//.test(id)) { if (!reactApiImportSource) { return } From 4e41724d6de4ddbb58a947101f4c1fb7e47d6129 Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Fri, 3 May 2024 21:44:52 +0900 Subject: [PATCH 3/4] feat: use `any` instead of `FC` for components for multi-runtime compatibility --- src/server/components/has-islands.tsx | 4 ++-- src/server/components/script.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/server/components/has-islands.tsx b/src/server/components/has-islands.tsx index 6e6e9ed..dbfb21a 100644 --- a/src/server/components/has-islands.tsx +++ b/src/server/components/has-islands.tsx @@ -1,8 +1,8 @@ -import type { FC } from 'hono/jsx' import { IMPORTING_ISLANDS_ID } from '../../constants.js' import { contextStorage } from '../context-storage.js' -export const HasIslands: FC = ({ children }) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const HasIslands = ({ children }: { children: any }): any => { const c = contextStorage.getStore() if (!c) { throw new Error('No context found') diff --git a/src/server/components/script.tsx b/src/server/components/script.tsx index 3552d4f..062071c 100644 --- a/src/server/components/script.tsx +++ b/src/server/components/script.tsx @@ -1,4 +1,3 @@ -import type { FC } from 'hono/jsx' import type { Manifest } from 'vite' import { HasIslands } from './has-islands.js' @@ -10,7 +9,8 @@ type Options = { nonce?: string } -export const Script: FC = async (options) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const Script = (options: Options): any => { const src = options.src if (options.prod ?? import.meta.env.PROD) { let manifest: Manifest | undefined = options.manifest From 4d52781bbfcab28d145f69a03a2359fe73cd663e Mon Sep 17 00:00:00 2001 From: Taku Amano Date: Thu, 9 May 2024 20:12:12 +0900 Subject: [PATCH 4/4] test: add test for server/components --- src/vite/island-components.test.ts | 79 ++++++++++++++++++++++-------- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/src/vite/island-components.test.ts b/src/vite/island-components.test.ts index d798fcb..4b2758c 100644 --- a/src/vite/island-components.test.ts +++ b/src/vite/island-components.test.ts @@ -1,3 +1,5 @@ +import fs from 'fs' +import os from 'os' import path from 'path' import { transformJsxTags, islandComponents } from './island-components' @@ -118,30 +120,67 @@ export { utilityFn, WrappedExportViaVariable as default };` describe('options', () => { describe('reactApiImportSource', () => { - // get full path of honox-island.tsx - const component = path - .resolve(__dirname, '../vite/components/honox-island.tsx') - // replace backslashes for Windows - .replace(/\\/g, '/') + describe('vite/components', () => { + // get full path of honox-island.tsx + const component = path + .resolve(__dirname, '../vite/components/honox-island.tsx') + // replace backslashes for Windows + .replace(/\\/g, '/') - // prettier-ignore - it('use \'hono/jsx\' by default', async () => { - const plugin = islandComponents() - await (plugin.configResolved as Function)({ root: 'root' }) - const res = await (plugin.load as Function)(component) - expect(res.code).toMatch(/'hono\/jsx'/) - expect(res.code).not.toMatch(/'react'/) + // prettier-ignore + it('use \'hono/jsx\' by default', async () => { + const plugin = islandComponents() + await (plugin.configResolved as Function)({ root: 'root' }) + const res = await (plugin.load as Function)(component) + expect(res.code).toMatch(/'hono\/jsx'/) + expect(res.code).not.toMatch(/'react'/) + }) + + // prettier-ignore + it('enable to specify \'react\'', async () => { + const plugin = islandComponents({ + reactApiImportSource: 'react', + }) + await (plugin.configResolved as Function)({ root: 'root' }) + const res = await (plugin.load as Function)(component) + expect(res.code).not.toMatch(/'hono\/jsx'/) + expect(res.code).toMatch(/'react'/) + }) }) - // prettier-ignore - it('enable to specify \'react\'', async () => { - const plugin = islandComponents({ - reactApiImportSource: 'react', + describe('server/components', () => { + const tmpdir = os.tmpdir() + + // has-islands.tsx under src/server/components does not contain 'hono/jsx' + // 'hono/jsx' is injected by `npm run build` + // so we need to create a file with 'hono/jsx' manually for testing + const component = path + .resolve(tmpdir, 'honox/dist/server/components/has-islands.js') + // replace backslashes for Windows + .replace(/\\/g, '/') + fs.mkdirSync(path.dirname(component), { recursive: true }) + // prettier-ignore + fs.writeFileSync(component, 'import { jsx } from \'hono/jsx/jsx-runtime\'') + + // prettier-ignore + it('use \'hono/jsx\' by default', async () => { + const plugin = islandComponents() + await (plugin.configResolved as Function)({ root: 'root' }) + const res = await (plugin.load as Function)(component) + expect(res.code).toMatch(/'hono\/jsx\/jsx-runtime'/) + expect(res.code).not.toMatch(/'react\/jsx-runtime'/) + }) + + // prettier-ignore + it('enable to specify \'react\'', async () => { + const plugin = islandComponents({ + reactApiImportSource: 'react', + }) + await (plugin.configResolved as Function)({ root: 'root' }) + const res = await (plugin.load as Function)(component) + expect(res.code).not.toMatch(/'hono\/jsx\/jsx-runtime'/) + expect(res.code).toMatch(/'react\/jsx-runtime'/) }) - await (plugin.configResolved as Function)({ root: 'root' }) - const res = await (plugin.load as Function)(component) - expect(res.code).not.toMatch(/'hono\/jsx'/) - expect(res.code).toMatch(/'react'/) }) }) })