Skip to content
Merged
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
143 changes: 142 additions & 1 deletion scripts/test-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -534,6 +534,122 @@ async function typecheckProject(projectDir: string): Promise<{ success: boolean;
return { success: true };
}

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<ApiRouter>('/api');

declare function expectType<T>(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;
\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<typeof client.translate.history.$get>;
type TranslateResult = InferResponseType<typeof client.translate.$post>;
type ClearHistoryResult = InferResponseType<typeof client.translate.history.$delete>;

declare const historyData: HistoryData;
declare const translateResult: TranslateResult;
declare const clearHistoryResult: ClearHistoryResult;

expectType<ExpectedHistoryData>(historyData);
expectType<ExpectedTranslateResult>(translateResult);
expectType<ExpectedHistoryData>(clearHistoryResult);
`);

case 'auth':
return buildHonoRpcResponseTypeProbe(`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<typeof client.health.$get>;

declare const healthData: HealthData;

expectType<ExpectedHealthData>(healthData);

async function verifyProtectedRouteResult(): Promise<void> {
\tconst res = await client.me.$get();
\tif (!res.ok) {
\t\treturn;
\t}

\texpectType<ExpectedProtectedRouteResult>(await res.json());
}
`);

default:
return undefined;
}
}

async function verifyHonoRpcResponseTypes(
projectDir: string,
probe: TemplateHonoRpcResponseTypeProbe
): Promise<{ success: boolean; error?: string }> {
const probePath = join(projectDir, probe.relativePath);
mkdirSync(dirname(probePath), { recursive: true });
writeFileSync(probePath, probe.source);

return typecheckProject(projectDir);
}

async function startServer(
projectDir: string,
port: number,
Expand Down Expand Up @@ -794,6 +910,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();
Expand Down
11 changes: 3 additions & 8 deletions templates/auth/src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +16,8 @@ import './App.css';

const client = hc<ApiRouter>('/api');

type ProtectedRouteResult = InferResponseType<typeof client.me.$get>;

const NEXT_STEPS: ReadonlyArray<{
readonly key: string;
readonly title: string;
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 16 additions & 6 deletions templates/default/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ const TranslateOutput = s.object({
translationCount: s.number().describe('Total translations in this thread'),
});

type TranslateResult = s.infer<typeof TranslateOutput>;

const HistoryOutput = TranslateOutput.pick(['history', 'threadId', 'translationCount']);

type HistoryData = s.infer<typeof HistoryOutput>;

const api = new Hono<Env>()
// Translate text
.post(
Expand Down Expand Up @@ -132,14 +136,16 @@ const api = new Hono<Env>()
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
Expand All @@ -148,22 +154,26 @@ const api = new Hono<Env>()
const translationCount =
(await c.var.thread.state.get<number>(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;
Expand Down
40 changes: 12 additions & 28 deletions templates/default/src/web/App.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -11,27 +13,9 @@ const DEFAULT_TEXT =

const client = hc<ApiRouter>('/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<typeof client.translate.history.$get>;
type TranslateResult = InferResponseType<typeof client.translate.$post>;
type HistoryEntry = HistoryData['history'][number];

export function App() {
const [text, setText] = useState(DEFAULT_TEXT);
Expand All @@ -47,34 +31,34 @@ 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(() => {
fetchHistory();
}, [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 () => {
track('translate', { text, toLanguage, model });
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);
}
}, [text, toLanguage, model, track]);

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 (
<div className="text-white flex font-sans justify-center min-h-screen">
Expand Down
Loading