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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

# Build output
**/dist/
**/dist-new/
**/.vitepress/cache/
**/dist-exe/
**/*.tsbuildinfo
Expand Down Expand Up @@ -42,6 +43,8 @@ execplan/
# Generated npm bundle output (local)
cli/npm/main/
.ace-tool/
.hermes/
.hermes-prompts/

# Playwright e2e artifacts
test-results/
Expand Down
56 changes: 56 additions & 0 deletions hub/src/web/previewStatic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'bun:test'
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { Hono } from 'hono'
import { mountMissingPreviewRoutes, mountPreviewStaticRoutes } from './server'

function createPreviewDist(): string {
const distDir = mkdtempSync(join(tmpdir(), 'hapi-preview-dist-'))
mkdirSync(join(distDir, 'assets'), { recursive: true })
writeFileSync(join(distDir, 'index.html'), '<!doctype html><div id="root">preview</div>')
writeFileSync(join(distDir, 'assets', 'app.js'), 'console.log("preview")')
writeFileSync(join(distDir, 'manifest.webmanifest'), '{"name":"HAPI Preview"}')
return distDir
}

describe('preview static routes', () => {
it('serves preview assets and deep links under /new without shadowing root', async () => {
const app = new Hono()
mountPreviewStaticRoutes(app, createPreviewDist())
app.get('/', (c) => c.text('root'))

const asset = await app.request('/new/assets/app.js')
expect(asset.status).toBe(200)
expect(await asset.text()).toBe('console.log("preview")')

const manifest = await app.request('/new/manifest.webmanifest')
expect(manifest.status).toBe(200)
expect(await manifest.text()).toContain('HAPI Preview')

const deepLink = await app.request('/new/sessions/session-1')
expect(deepLink.status).toBe(200)
expect(await deepLink.text()).toContain('preview')

const root = await app.request('/')
expect(root.status).toBe(200)
expect(await root.text()).toBe('root')
})

it('does not mount preview routes when index.html is missing', async () => {
const distDir = mkdtempSync(join(tmpdir(), 'hapi-preview-missing-'))
const app = new Hono()
expect(mountPreviewStaticRoutes(app, distDir)).toBe(false)
})

it('returns an explicit 503 for /new when the preview artifact is missing', async () => {
const app = new Hono()
mountMissingPreviewRoutes(app)
app.get('*', (c) => c.text('root fallback'))

const response = await app.request('/new/sessions/session-1')

expect(response.status).toBe(503)
expect(await response.text()).toContain('Preview app is not built')
})
})
60 changes: 59 additions & 1 deletion hub/src/web/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Hono } from 'hono'
import { Hono, type Env, type Schema } from 'hono'
import { cors } from 'hono/cors'
import { logger } from 'hono/logger'
import { join } from 'node:path'
Expand Down Expand Up @@ -190,6 +190,60 @@ function findWebappDistDir(): { distDir: string; indexHtmlPath: string } {
return { distDir, indexHtmlPath: join(distDir, 'index.html') }
}

function findPreviewWebappDistDir(): string | null {
const configured = process.env.HAPI_WEB_PREVIEW_DIST_DIR?.trim()
const candidates = [
...(configured ? [configured] : []),
join(process.cwd(), '..', 'web', 'dist-new'),
join(import.meta.dir, '..', '..', '..', 'web', 'dist-new'),
join(process.cwd(), 'web', 'dist-new')
]

for (const distDir of candidates) {
if (existsSync(join(distDir, 'index.html'))) {
return distDir
}
}

return null
}

export function mountPreviewStaticRoutes<E extends Env, S extends Schema, BasePath extends string>(
app: Hono<E, S, BasePath>,
distDir: string | null
): boolean {
if (!distDir || !existsSync(join(distDir, 'index.html'))) {
return false
}

app.use('/new/*', async (c, next) => {
if (c.req.method !== 'GET' && c.req.method !== 'HEAD') {
await next()
return
}

return await serveStatic({
root: distDir,
rewriteRequestPath: (path) => path.replace(/^\/new\//, '/')
})(c, next)
})
app.get('/new', async (c, next) => {
return await serveStatic({ root: distDir, path: 'index.html' })(c, next)
})
app.get('/new/*', async (c, next) => {
return await serveStatic({ root: distDir, path: 'index.html' })(c, next)
})

return true
}

export function mountMissingPreviewRoutes<E extends Env, S extends Schema, BasePath extends string>(
app: Hono<E, S, BasePath>
): void {
app.get('/new', (c) => c.text('Preview app is not built.\n\nRun:\n cd web\n bun run build:preview\n', 503))
app.get('/new/*', (c) => c.text('Preview app is not built.\n\nRun:\n cd web\n bun run build:preview\n', 503))
}

function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response {
return new Response(Bun.file(asset.sourcePath), {
headers: {
Expand Down Expand Up @@ -316,6 +370,10 @@ from GitHub Pages instead of through the relay tunnel.
return app
}

if (!mountPreviewStaticRoutes(app, findPreviewWebappDistDir())) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This /new mount never runs for compiled/single-exe hubs because the options.embeddedAssetMap branch above returns at line 365. The release pipeline embeds only web/dist, so /new falls through to the existing root index.html rather than the preview artifact/503.

Suggested fix:

if (!options.embeddedAssetMap) {
    if (!mountPreviewStaticRoutes(app, findPreviewWebappDistDir())) {
        mountMissingPreviewRoutes(app)
    }
} else {
    mountMissingPreviewRoutes(app)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This /new mount still never runs for compiled/single-exe hubs because the options.embeddedAssetMap branch above returns before this line. The release pipeline embeds only web/dist, so /new falls through to the existing root index.html instead of the preview artifact or the explicit missing-preview 503.

Suggested fix:

const previewMounted = options.embeddedAssetMap
    ? false
    : mountPreviewStaticRoutes(app, findPreviewWebappDistDir())
if (!previewMounted) {
    mountMissingPreviewRoutes(app)
}

if (options.embeddedAssetMap) {
    // existing embedded root app handling
}

mountMissingPreviewRoutes(app)
}

const { distDir, indexHtmlPath } = findWebappDistDir()

if (!existsSync(indexHtmlPath)) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"download:tunwg": "bun run hub/scripts/download-tunwg.ts",
"build:hub": "cd hub && bun run build",
"build:web": "cd web && bun run build",
"build:web:preview": "cd web && bun run build:preview",
"dev:hub": "cd hub && bun run dev",
"dev:web": "cd web && bun run dev",
"typecheck": "bun run typecheck:cli && bun run typecheck:web && bun run typecheck:hub",
Expand Down
Loading
Loading