Skip to content

Commit

Permalink
feat: standalone devtools
Browse files Browse the repository at this point in the history
  • Loading branch information
tmm committed Mar 20, 2024
1 parent dcede18 commit eb5b53e
Show file tree
Hide file tree
Showing 29 changed files with 774 additions and 797 deletions.
112 changes: 66 additions & 46 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions src/cli/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'

import { routes } from '../dev/devtools.js'
import { neynar } from '../hubs/neynar.js'

export const app = new Hono()

app.route(
'/',
routes({
basePath: '',
hub: neynar({ apiKey: 'NEYNAR_FROG_FM' }),
publicPath: '.',
routes: [],
secret: undefined,
serveStatic,
}),
)
103 changes: 72 additions & 31 deletions src/cli/commands/dev.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,43 @@
import type { Hono } from 'hono'
import { existsSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { dirname, relative, join, resolve } from 'node:path'
import pc from 'picocolors'
import { createLogger, createServer } from 'vite'

import type { Frog } from '../../frog.js'
import { version } from '../../version.js'
import { findEntrypoint } from '../utils/findEntrypoint.js'
import { defaultOptions, devServer } from '../vite/dev.js'
import { devServer } from '../vite/dev.js'
import { printServerUrls, type ServerUrls } from '../utils/logger.js'

type DevOptions = {
host?: boolean
port?: number
staticDir?: string
host?: boolean | undefined
port?: number | undefined
staticDir?: string | undefined
ui?: boolean | undefined
}

export async function dev(
entry_: string | undefined,
options: DevOptions = {},
) {
const { host, port, staticDir } = options
const entry = entry_ || (await findEntrypoint())
export async function dev(path: string | undefined, options: DevOptions = {}) {
const { host, port, staticDir, ui } = options

const entry_resolved = resolve(join(process.cwd(), entry))
if (!existsSync(entry_resolved))
throw new Error(`entrypoint not found: ${entry_resolved}`)
const entryPath = path || (await findEntrypoint())
let entry = resolve(join(process.cwd(), entryPath))
let injectClientScript = true
const entryExists = existsSync(entry)
if (!entryExists || ui) {
entry = relative(
'./',
resolve(dirname(fileURLToPath(import.meta.url)), '../app.ts'),
)
injectClientScript = false
}

const server = await createServer({
plugins: [
devServer({
entry: entry_resolved,
exclude: [
...defaultOptions.exclude,
/.+\.(gif|jpe?g|tiff?|png|webp|bmp|woff|eot|woff2|ttf|otf|ico|txt)$/,
],
// Note: we are not relying on the default export so we can be compatible with
// runtimes that rely on it (e.g. Vercel Serverless Functions).
export: 'app',
entry,
injectClientScript,
}),
],
publicDir: staticDir ?? 'public',
Expand All @@ -46,13 +48,17 @@ export async function dev(
},
})

const module = await server.ssrLoadModule(entry_resolved)
const app = module.app as Frog | undefined
const basePath = app?.basePath || '/'
const module = (await server.ssrLoadModule(entry)) as {
app: Frog | Hono | undefined
}
const app = module.app
if (!app) {
await server.close()
throw new Error(`app export not found: ${entry}`)
}

await server.listen()
server.bindCLIShortcuts()
const url = `http://localhost:${server.config.server.port}`

const logger = createLogger()
logger.clearScreen('info')
Expand All @@ -61,11 +67,46 @@ export async function dev(
` ${pc.green('[running]')} ${pc.bold('frog')}@${pc.dim(`v${version}`)}`,
)
logger.info('')
const appUrl = `${url}${basePath}`
logger.info(` ${pc.green('➜')} ${pc.bold('Local')}: ${pc.cyan(appUrl)}`)

if (app?._dev) {
const devUrl = `${url}${app._dev}`
logger.info(` ${pc.green('➜')} ${pc.bold('Inspect')}: ${pc.cyan(devUrl)}`)
if (!entryExists) {
logger.info(
pc.yellow(
` Using standalone devtools. No entry found at ${pc.bold(entryPath)}.`,
),
)
logger.info('')
}

let devBasePath: string | false | undefined = false
let resolvedUrls: ServerUrls = {
local: server.resolvedUrls?.local ?? [],
network: server.resolvedUrls?.network ?? [],
dev: [],
}
if ('version' in app && app.version === version) {
const basePath = app.basePath === '/' ? '' : app.basePath
devBasePath = app._dev ? app._dev.replace(/^\//, '') : undefined
resolvedUrls = {
local: (server.resolvedUrls?.local ?? []).map(
(url) => `${url}${basePath}`,
),
network: (server.resolvedUrls?.network ?? []).map(
(url) => `${url}${basePath}`,
),
dev: devBasePath
? (server.resolvedUrls?.local ?? []).map(
(url) => `${url}${devBasePath}`,
)
: [],
}
}

printServerUrls(
resolvedUrls,
{
dev: devBasePath,
host: server.config.server.host,
},
logger.info,
)
}
7 changes: 5 additions & 2 deletions src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { cac } from 'cac'

import { version } from '../version.js'
import { dev } from './commands/dev.js'
import { build as build_vercel } from './commands/vercel-build.js'
import { build } from './commands/vercel-build.js'

export const cli = cac('frog')

Expand All @@ -16,15 +16,18 @@ cli
.option('-h, --host', 'Expose host URL')
.option('-p, --port <number>', 'Port used by the server (default: 5173)')
.option('-s, --staticDir [string]', 'Path to static files (default: public)')
.option('--ui', 'Run standalone devtools')
.example((name) => `${name} dev --host`)
.example((name) => `${name} dev --port 6969`)
.action(dev)

cli
.command(
'vercel-build',
'Builds an output conforming to the Vercel Build Output API.',
)
.action(build_vercel)
.example((name) => `${name} vercel-build`)
.action(build)

cli.help()
cli.version(version)
Expand Down
41 changes: 41 additions & 0 deletions src/cli/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Forked from https://github.com/vitejs/vite/blob/1a3b1d73d7babdab6a52a5fb1ef193fd63666877/packages/vite/src/node/logger.ts#L161
import type { Logger, ResolvedServerUrls } from 'vite'
import colors from 'picocolors'

export type ServerUrls = ResolvedServerUrls & { dev: string[] }

export function printServerUrls(
urls: ServerUrls,
options: {
dev: string | boolean | undefined
host: string | boolean | undefined
},
info: Logger['info'],
): void {
const colorUrl = (url: string) =>
colors.cyan(url.replace(/:(\d+)\//, (_, port) => `:${colors.bold(port)}/`))

for (const url of urls.local) {
info(` ${colors.green('➜')} ${colors.bold('Local')}: ${colorUrl(url)}`)
}
for (const url of urls.network) {
info(` ${colors.green('➜')} ${colors.bold('Network')}: ${colorUrl(url)}`)
}
for (const url of urls.dev) {
info(` ${colors.green('➜')} ${colors.bold('Inspect')}: ${colorUrl(url)}`)
}

if (urls.dev.length === 0 && options.dev === undefined)
info(
colors.dim(` ${colors.green('➜')} ${colors.bold('Inspect')}: add `) +
colors.bold('devtools') +
colors.dim(' to app'),
)

if (urls.network.length === 0 && options.host === undefined)
info(
colors.dim(` ${colors.green('➜')} ${colors.bold('Network')}: use `) +
colors.bold('--host') +
colors.dim(' to expose'),
)
}
6 changes: 5 additions & 1 deletion src/cli/vite/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export type DevServerOptions = {

export const defaultOptions = {
entry: './src/index.ts',
export: 'default',
// Note: we are not relying on the default export so we can be compatible with
// runtimes that rely on it (e.g. Vercel Serverless Functions).
export: 'app',
injectClientScript: true,
exclude: [
/.*\.css$/,
Expand All @@ -26,6 +28,8 @@ export const defaultOptions = {
/^\/favicon\.ico$/,
/^\/static\/.+/,
/^\/node_modules\/.*/,
///
/.+\.(gif|jpe?g|tiff?|png|webp|bmp|woff|eot|woff2|ttf|otf|ico|txt)$/,
],
ignoreWatching: [/\.wrangler/],
plugins: [],
Expand Down
30 changes: 14 additions & 16 deletions src/dev/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import {
getUserDataByFid,
postSignedKeyRequest,
} from './utils/warpcast.js'
import type { inspectRoutes } from 'hono/dev'
import type { Hub } from '../types/hub.js'

export type ApiRoutesOptions = {
/** Custom app fid to auth with. */
Expand All @@ -37,17 +39,7 @@ export type ApiRoutesOptions = {
appMnemonic?: string | undefined
}

type Options = ApiRoutesOptions & {
hubApiUrl?: string | undefined
routes: Route[]
secret?: string | undefined
}

type Route = {
path: string
method: string
isMiddleware: boolean
}
export type RouteData = ReturnType<typeof inspectRoutes>[number]

export type User = {
displayName?: string | undefined
Expand All @@ -58,8 +50,14 @@ export type User = {
username?: string | undefined
}

export function apiRoutes(options: Options) {
const { appFid, appMnemonic, hubApiUrl, routes, secret } = options
export function apiRoutes(
options: ApiRoutesOptions & {
hub: Hub | undefined
routes: RouteData[]
secret: string | undefined
},
) {
const { appFid, appMnemonic, hub, routes, secret } = options

return new Hono<{
Variables: {
Expand Down Expand Up @@ -219,8 +217,8 @@ export function apiRoutes(options: Options) {

if (state === 'completed') {
let user: User = { state, token, userFid: userFid as number }
if (hubApiUrl && userFid) {
const data = await getUserDataByFid(hubApiUrl, userFid)
if (hub && userFid) {
const data = await getUserDataByFid(hub, userFid)
user = { ...user, ...data }
}

Expand Down Expand Up @@ -261,7 +259,7 @@ export type Bootstrap = {
user: User | undefined
}

export function getFrameUrls(origin: string, routes: Route[]) {
export function getFrameUrls(origin: string, routes: RouteData[]) {
const frameUrls: string[] = []
for (const route of routes) {
if (route.isMiddleware) continue
Expand Down
2 changes: 2 additions & 0 deletions src/dev/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ export const defaultCookieOptions = {
sameSite: 'Strict',
secure: true,
} as CookieOptions

export const uiDistDir = '.frog'
Loading

0 comments on commit eb5b53e

Please sign in to comment.