Skip to content

Commit

Permalink
feat: Script and HasIsland available at multiple runtimes with `A…
Browse files Browse the repository at this point in the history
…syncLocalStorage` (#168)

* feat: Use AsyncLocalStorage to share context in same request

* feat: replace reactApiImportSource also in server components

* feat: use `any` instead of `FC` for components for multi-runtime compatibility

* test: add test for server/components
  • Loading branch information
usualoma authored May 10, 2024
1 parent 668e86a commit 8abdc53
Show file tree
Hide file tree
Showing 8 changed files with 86 additions and 29 deletions.
13 changes: 8 additions & 5 deletions src/server/components/has-islands.tsx
Original file line number Diff line number Diff line change
@@ -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 : <></>}</>
// 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')
}
return <>{c.get(IMPORTING_ISLANDS_ID) && children}</>
}
4 changes: 2 additions & 2 deletions src/server/components/script.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { FC } from 'hono/jsx'
import type { Manifest } from 'vite'
import { HasIslands } from './has-islands.js'

Expand All @@ -10,7 +9,8 @@ type Options = {
nonce?: string
}

export const Script: FC<Options> = 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
Expand Down
3 changes: 3 additions & 0 deletions src/server/context-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import type { Context } from 'hono'
export const contextStorage = new AsyncLocalStorage<Context>()
6 changes: 6 additions & 0 deletions src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -62,6 +63,11 @@ export const createApp = <E extends Env>(options: BaseServerOptions<E>): Hono<E>
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)
}
Expand Down
81 changes: 60 additions & 21 deletions src/vite/island-components.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import fs from 'fs'
import os from 'os'
import path from 'path'
import { transformJsxTags, islandComponents } from './island-components'

Expand Down Expand Up @@ -218,30 +220,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, '/')

// 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'/)
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('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'/)
})
})
})
2 changes: 1 addition & 1 deletion src/vite/island-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,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
}
Expand Down
1 change: 1 addition & 0 deletions test-integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/*',
Expand Down
5 changes: 5 additions & 0 deletions test-integration/apps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ describe('Basic', () => {

it('Should have correct routes', () => {
const routes = [
{
path: '/*',
method: 'ALL',
handler: expect.any(Function), // ShareContext
},
{
path: '/*',
method: 'ALL',
Expand Down

0 comments on commit 8abdc53

Please sign in to comment.