From 64ef2b319ffb593025c3eedb6a50569db41eda1c Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 6 May 2026 12:39:39 -0700 Subject: [PATCH 1/2] fix(templates): guard Hono RPC response inference - Anchor default JSON responses to schema-derived types - Derive template web types from `InferResponseType` - Probe default and auth Hono RPC response contracts --- scripts/test-templates.ts | 124 +++++++++++++++++++++++++++++ templates/auth/src/web/App.tsx | 11 +-- templates/default/src/api/index.ts | 22 +++-- templates/default/src/web/App.tsx | 40 +++------- 4 files changed, 155 insertions(+), 42 deletions(-) diff --git a/scripts/test-templates.ts b/scripts/test-templates.ts index bde24143b..6591d28aa 100644 --- a/scripts/test-templates.ts +++ b/scripts/test-templates.ts @@ -534,6 +534,105 @@ async function typecheckProject(projectDir: string): Promise<{ success: boolean; return { success: true }; } +function getTemplateHonoRpcResponseTypeProbe(templateId: string): string | undefined { + switch (templateId) { + case 'default': + return `import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; +import type { ApiRouter } from '../api/index'; + +const client = hc('/api'); + +interface ExpectedHistoryEntry { +\treadonly model: string; +\treadonly sessionId: string; +\treadonly text: string; +\treadonly timestamp: string; +\treadonly tokens: number; +\treadonly toLanguage: string; +\treadonly translation: string; +} + +interface ExpectedHistoryData { +\treadonly history: readonly ExpectedHistoryEntry[]; +\treadonly threadId?: string; +\treadonly translationCount: number; +} + +interface ExpectedTranslateResult extends ExpectedHistoryData { +\treadonly sessionId: string; +\treadonly tokens: number; +\treadonly translation: string; +} + +type HistoryData = InferResponseType; +type TranslateResult = InferResponseType; +type ClearHistoryResult = InferResponseType; + +declare const historyData: HistoryData; +declare const translateResult: TranslateResult; +declare const clearHistoryResult: ClearHistoryResult; + +declare function expectType(value: T): void; + +expectType(historyData); +expectType(translateResult); +expectType(clearHistoryResult); +`; + + case 'auth': + return `import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; +import type { ApiRouter } from '../api/index'; + +const client = hc('/api'); + +interface ExpectedHealthData { +\treadonly status: string; +\treadonly timestamp: string; +} + +interface ExpectedProtectedRouteResult { +\treadonly authMethod: string; +\treadonly email: string; +\treadonly id: string; +\treadonly memberSince: string | null; +\treadonly name: string | null; +} + +type HealthData = InferResponseType; + +declare const healthData: HealthData; + +declare function expectType(value: T): void; + +expectType(healthData); + +async function verifyProtectedRouteResult(): Promise { +\tconst res = await client.me.$get(); +\tif (!res.ok) { +\t\treturn; +\t} + +\texpectType(await res.json()); +} +`; + + default: + return undefined; + } +} + +async function verifyHonoRpcResponseTypes( + projectDir: string, + probeSource: string +): Promise<{ success: boolean; error?: string }> { + const probePath = join(projectDir, 'src', 'web', 'hono-rpc-response-type-probe.ts'); + writeFileSync(probePath, probeSource); + + return typecheckProject(projectDir); +} + async function startServer( projectDir: string, port: number, @@ -794,6 +893,31 @@ async function testTemplate( } logSuccess('Typecheck passed'); + // Step 4.5: Verify template Hono RPC response inference + const honoRpcResponseTypeProbe = getTemplateHonoRpcResponseTypeProbe(template.id); + if (honoRpcResponseTypeProbe) { + logStep(`Verifying ${template.name} Hono RPC response types...`); + stepStart = Date.now(); + const rpcTypeResult = await verifyHonoRpcResponseTypes( + projectDir, + honoRpcResponseTypeProbe + ); + result.steps.push({ + name: 'Verify Hono RPC response types', + passed: rpcTypeResult.success, + error: rpcTypeResult.error, + duration: Date.now() - stepStart, + }); + if (!rpcTypeResult.success) { + result.passed = false; + logError( + `${template.name} Hono RPC response type check failed: ${rpcTypeResult.error}` + ); + return result; + } + logSuccess(`${template.name} Hono RPC response types verified`); + } + // Step 5: Start server and test endpoints logStep('Starting server...'); stepStart = Date.now(); diff --git a/templates/auth/src/web/App.tsx b/templates/auth/src/web/App.tsx index 1fbc4c4d4..74149c52e 100644 --- a/templates/auth/src/web/App.tsx +++ b/templates/auth/src/web/App.tsx @@ -7,6 +7,7 @@ import { useAuthenticate, } from '@daveyplate/better-auth-ui'; import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; import { AnimatePresence, motion } from 'motion/react'; import { Toaster, toast } from 'sonner'; import type { ApiRouter } from '../api/index'; @@ -15,6 +16,8 @@ import './App.css'; const client = hc('/api'); +type ProtectedRouteResult = InferResponseType; + const NEXT_STEPS: ReadonlyArray<{ readonly key: string; readonly title: string; @@ -61,14 +64,6 @@ const NEXT_STEPS: ReadonlyArray<{ }, ]; -interface ProtectedRouteResult { - readonly authMethod: string; - readonly email: string; - readonly id: string; - readonly memberSince: string | null; - readonly name: string | null; -} - function formatDate(value: string | null | undefined): string { if (!value) return 'Not set'; try { diff --git a/templates/default/src/api/index.ts b/templates/default/src/api/index.ts index 16493dabc..aa3c13143 100644 --- a/templates/default/src/api/index.ts +++ b/templates/default/src/api/index.ts @@ -60,8 +60,12 @@ const TranslateOutput = s.object({ translationCount: s.number().describe('Total translations in this thread'), }); +type TranslateResult = s.infer; + const HistoryOutput = TranslateOutput.pick(['history', 'threadId', 'translationCount']); +type HistoryData = s.infer; + const api = new Hono() // Translate text .post( @@ -132,14 +136,16 @@ const api = new Hono() translationCount, }); - return c.json({ + const result: TranslateResult = { history, sessionId: c.var.sessionId, threadId: c.var.thread.id, tokens, translation, translationCount, - }); + }; + + return c.json(result); } ) // Retrieve translation history @@ -148,22 +154,26 @@ const api = new Hono() const translationCount = (await c.var.thread.state.get(TRANSLATION_COUNT_KEY)) ?? history.length; - return c.json({ + const result: HistoryData = { history, threadId: c.var.thread.id, translationCount, - }); + }; + + return c.json(result); }) // Clear translation history .delete('/translate/history', validator({ output: HistoryOutput }), async (c) => { await c.var.thread.state.delete(HISTORY_KEY); await c.var.thread.state.delete(TRANSLATION_COUNT_KEY); - return c.json({ + const result: HistoryData = { history: [], threadId: c.var.thread.id, translationCount: 0, - }); + }; + + return c.json(result); }); export type ApiRouter = typeof api; diff --git a/templates/default/src/web/App.tsx b/templates/default/src/web/App.tsx index 8ed4a821c..46bddd813 100644 --- a/templates/default/src/web/App.tsx +++ b/templates/default/src/web/App.tsx @@ -1,7 +1,9 @@ import { useAnalytics } from '@agentuity/react'; import { hc } from 'hono/client'; +import type { InferResponseType } from 'hono/client'; +import { Fragment, useCallback, useEffect, useState } from 'react'; +import type { ChangeEvent } from 'react'; import type { ApiRouter } from '../api/index'; -import { type ChangeEvent, Fragment, useCallback, useEffect, useState } from 'react'; import './App.css'; const LANGUAGES = ['Spanish', 'French', 'German', 'Chinese'] as const; @@ -11,27 +13,9 @@ const DEFAULT_TEXT = const client = hc('/api'); -interface HistoryEntry { - readonly model: string; - readonly sessionId: string; - readonly text: string; - readonly timestamp: string; - readonly tokens: number; - readonly toLanguage: string; - readonly translation: string; -} - -interface HistoryData { - readonly history: readonly HistoryEntry[]; - readonly threadId?: string; - readonly translationCount: number; -} - -interface TranslateResult extends HistoryData { - readonly sessionId: string; - readonly tokens: number; - readonly translation: string; -} +type HistoryData = InferResponseType; +type TranslateResult = InferResponseType; +type HistoryEntry = HistoryData['history'][number]; export function App() { const [text, setText] = useState(DEFAULT_TEXT); @@ -47,7 +31,7 @@ export function App() { // Fetch history on mount const fetchHistory = useCallback(async () => { const res = await client.translate.history.$get(); - setHistoryData((await res.json()) as HistoryData); + setHistoryData(await res.json()); }, []); useEffect(() => { @@ -55,7 +39,7 @@ export function App() { }, [fetchHistory]); // Prefer fresh data from translation, fall back to initial fetch - const history = translateResult?.history ?? historyData?.history ?? []; + const history: readonly HistoryEntry[] = translateResult?.history ?? historyData?.history ?? []; const threadId = translateResult?.threadId ?? historyData?.threadId; const handleTranslate = useCallback(async () => { @@ -63,7 +47,7 @@ export function App() { setIsLoading(true); try { const res = await client.translate.$post({ json: { text, toLanguage, model } }); - setTranslateResult((await res.json()) as TranslateResult); + setTranslateResult(await res.json()); } finally { setIsLoading(false); } @@ -71,10 +55,10 @@ export function App() { const handleClearHistory = useCallback(async () => { track('clear_history'); - await client.translate.history.$delete(); + const res = await client.translate.history.$delete(); setTranslateResult(null); - await fetchHistory(); - }, [fetchHistory, track]); + setHistoryData(await res.json()); + }, [track]); return (
From 99082ab3aa3cb496fa6f71f7cc3706d3905b017c Mon Sep 17 00:00:00 2001 From: Parteek Singh Date: Wed, 6 May 2026 13:00:27 -0700 Subject: [PATCH 2/2] test(templates): refine Hono RPC probe generation - Share the Hono RPC probe prelude across templates - Carry probe source with its relative output path - Create probe directories before writing temp files --- scripts/test-templates.ts | 61 +++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/scripts/test-templates.ts b/scripts/test-templates.ts index 6591d28aa..5a72c40e0 100644 --- a/scripts/test-templates.ts +++ b/scripts/test-templates.ts @@ -19,7 +19,7 @@ import { spawn, type Subprocess } from 'bun'; import { existsSync, mkdirSync, rmSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; // Path to local CLI bin (use development version, not npm) @@ -534,16 +534,42 @@ async function typecheckProject(projectDir: string): Promise<{ success: boolean; return { success: true }; } -function getTemplateHonoRpcResponseTypeProbe(templateId: string): string | undefined { - switch (templateId) { - case 'default': - return `import { hc } from 'hono/client'; +interface TemplateHonoRpcResponseTypeProbe { + readonly relativePath: string; + readonly source: string; +} + +const DEFAULT_HONO_RPC_RESPONSE_TYPE_PROBE_PATH = join( + 'src', + 'web', + 'hono-rpc-response-type-probe.ts' +); + +function buildHonoRpcResponseTypeProbe( + body: string, + relativePath: string = DEFAULT_HONO_RPC_RESPONSE_TYPE_PROBE_PATH +): TemplateHonoRpcResponseTypeProbe { + return { + relativePath, + source: `import { hc } from 'hono/client'; import type { InferResponseType } from 'hono/client'; import type { ApiRouter } from '../api/index'; const client = hc('/api'); -interface ExpectedHistoryEntry { +declare function expectType(value: T): void; + +${body} +`, + }; +} + +function getTemplateHonoRpcResponseTypeProbe( + templateId: string +): TemplateHonoRpcResponseTypeProbe | undefined { + switch (templateId) { + case 'default': + return buildHonoRpcResponseTypeProbe(`interface ExpectedHistoryEntry { \treadonly model: string; \treadonly sessionId: string; \treadonly text: string; @@ -573,21 +599,13 @@ declare const historyData: HistoryData; declare const translateResult: TranslateResult; declare const clearHistoryResult: ClearHistoryResult; -declare function expectType(value: T): void; - expectType(historyData); expectType(translateResult); expectType(clearHistoryResult); -`; +`); case 'auth': - return `import { hc } from 'hono/client'; -import type { InferResponseType } from 'hono/client'; -import type { ApiRouter } from '../api/index'; - -const client = hc('/api'); - -interface ExpectedHealthData { + return buildHonoRpcResponseTypeProbe(`interface ExpectedHealthData { \treadonly status: string; \treadonly timestamp: string; } @@ -604,8 +622,6 @@ type HealthData = InferResponseType; declare const healthData: HealthData; -declare function expectType(value: T): void; - expectType(healthData); async function verifyProtectedRouteResult(): Promise { @@ -616,7 +632,7 @@ async function verifyProtectedRouteResult(): Promise { \texpectType(await res.json()); } -`; +`); default: return undefined; @@ -625,10 +641,11 @@ async function verifyProtectedRouteResult(): Promise { async function verifyHonoRpcResponseTypes( projectDir: string, - probeSource: string + probe: TemplateHonoRpcResponseTypeProbe ): Promise<{ success: boolean; error?: string }> { - const probePath = join(projectDir, 'src', 'web', 'hono-rpc-response-type-probe.ts'); - writeFileSync(probePath, probeSource); + const probePath = join(projectDir, probe.relativePath); + mkdirSync(dirname(probePath), { recursive: true }); + writeFileSync(probePath, probe.source); return typecheckProject(projectDir); }