Skip to content

Commit 12dec2a

Browse files
authored
Merge pull request #19 from tokenhost/feat/dx-preview-ui
DX: One-command UI preview, auto-published manifest, and smoother Anvil/MetaMask flow
2 parents 470024b + cb5d428 commit 12dec2a

8 files changed

Lines changed: 316 additions & 21 deletions

File tree

Readme.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
### Token Host Builder
22

3-
Turns a Token Host Schema (THS) document into deterministic Solidity artifacts (and, later, a generated UI) that can be deployed and self-hosted.
3+
Turns a Token Host Schema (THS) document into deterministic Solidity artifacts and a generated UI bundle that can be deployed and self-hosted.
44

55
- Canonical product spec: `SPEC.md`
66
- Spec-to-code backlog: `AGENTS.md`
@@ -27,6 +27,12 @@ anvil
2727

2828
# Deploy to local anvil (uses Anvil's default dev key unless ANVIL_PRIVATE_KEY is set)
2929
pnpm th deploy artifacts/job-board --chain anvil
30+
31+
# Serve the generated UI locally (no Python required)
32+
pnpm th preview artifacts/job-board
33+
34+
# Open http://127.0.0.1:3000/
35+
# MetaMask: approve switching/adding the Anvil network (chainId 31337).
3036
```
3137

3238
Environment examples:

packages/cli/src/index.ts

Lines changed: 231 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'fs';
22
import os from 'os';
33
import path from 'path';
44
import crypto from 'crypto';
5+
import * as nodeHttp from 'node:http';
56
import { spawnSync } from 'child_process';
67
import { createRequire } from 'module';
78
import { fileURLToPath, pathToFileURL } from 'url';
@@ -89,6 +90,13 @@ function copyDir(srcDir: string, destDir: string) {
8990
}
9091
}
9192

93+
function publishManifestToUiSite(uiSiteDir: string, manifestJson: string) {
94+
ensureDir(uiSiteDir);
95+
ensureDir(path.join(uiSiteDir, '.well-known', 'tokenhost'));
96+
fs.writeFileSync(path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'), manifestJson);
97+
fs.writeFileSync(path.join(uiSiteDir, 'manifest.json'), manifestJson);
98+
}
99+
92100
function resolveNextExportUiTemplateDir(): string {
93101
const here = path.dirname(fileURLToPath(import.meta.url));
94102
const candidates = [
@@ -477,6 +485,7 @@ program
477485
`pnpm th validate ${schemaPath}`,
478486
`pnpm th build ${schemaPath} --out ${path.join(outDir, 'build')}`,
479487
`pnpm th deploy ${path.join(outDir, 'build')} --chain anvil`,
488+
`pnpm th preview ${path.join(outDir, 'build')}`,
480489
'```',
481490
''
482491
].join('\n')
@@ -650,7 +659,7 @@ program
650659
return;
651660
}
652661

653-
const outDir = opts.out;
662+
const outDir = path.resolve(opts.out);
654663
ensureDir(outDir);
655664

656665
// 1) Generate Solidity source
@@ -831,9 +840,7 @@ program
831840
if (uiBundleDir && uiSiteDir) {
832841
fs.rmSync(uiSiteDir, { recursive: true, force: true });
833842
copyDir(uiBundleDir, uiSiteDir);
834-
ensureDir(path.join(uiSiteDir, '.well-known', 'tokenhost'));
835-
fs.writeFileSync(path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'), manifestJsonOut);
836-
fs.writeFileSync(path.join(uiSiteDir, 'manifest.json'), manifestJsonOut);
843+
publishManifestToUiSite(uiSiteDir, manifestJsonOut);
837844
}
838845
console.log(`Wrote ${appSol.path}`);
839846
console.log(`Wrote compiled/App.json`);
@@ -844,12 +851,210 @@ program
844851
console.log(`Wrote ui-site/ (self-hostable static root)`);
845852
}
846853
console.log(`Wrote manifest.json`);
854+
855+
console.log('');
856+
console.log('Next steps:');
857+
console.log(` th deploy ${outDir} --chain anvil # start anvil first`);
858+
console.log(` th deploy ${outDir} --chain sepolia # requires RPC + funded key`);
859+
if (uiBundleDir) {
860+
console.log(` th preview ${outDir} # open http://127.0.0.1:3000/`);
861+
}
847862
});
848863

849864
function anyPaidCreates(schema: ThsSchema): boolean {
850865
return schema.collections.some((c) => Boolean(c.createRules.payment));
851866
}
852867

868+
program
869+
.command('preview')
870+
.argument('<buildDir>', 'Directory created by `th build` (contains ui-site/)')
871+
.description('Serve the generated static UI locally (no Python required)')
872+
.option('--port <n>', 'Port to listen on', '3000')
873+
.option('--host <host>', 'Host to bind (default: 127.0.0.1)', '127.0.0.1')
874+
.action((buildDir: string, opts: { port: string; host: string }) => {
875+
const resolvedBuildDir = path.resolve(buildDir);
876+
const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');
877+
878+
if (!fs.existsSync(uiSiteDir)) {
879+
console.error(`Missing ui-site/ in ${resolvedBuildDir}.`);
880+
console.error('Re-run `th build` without `--no-ui` to generate the UI bundle.');
881+
process.exitCode = 1;
882+
return;
883+
}
884+
885+
const port = Number(opts.port);
886+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
887+
console.error(`Invalid --port: ${opts.port}`);
888+
process.exitCode = 1;
889+
return;
890+
}
891+
892+
const host = String(opts.host || '127.0.0.1');
893+
const rootAbs = path.resolve(uiSiteDir);
894+
895+
function contentTypeForPath(filePath: string): string {
896+
const ext = path.extname(filePath).toLowerCase();
897+
switch (ext) {
898+
case '.html':
899+
return 'text/html; charset=utf-8';
900+
case '.js':
901+
return 'application/javascript; charset=utf-8';
902+
case '.css':
903+
return 'text/css; charset=utf-8';
904+
case '.json':
905+
case '.map':
906+
return 'application/json; charset=utf-8';
907+
case '.svg':
908+
return 'image/svg+xml';
909+
case '.png':
910+
return 'image/png';
911+
case '.jpg':
912+
case '.jpeg':
913+
return 'image/jpeg';
914+
case '.gif':
915+
return 'image/gif';
916+
case '.webp':
917+
return 'image/webp';
918+
case '.ico':
919+
return 'image/x-icon';
920+
case '.woff2':
921+
return 'font/woff2';
922+
case '.woff':
923+
return 'font/woff';
924+
case '.ttf':
925+
return 'font/ttf';
926+
case '.txt':
927+
return 'text/plain; charset=utf-8';
928+
default:
929+
return 'application/octet-stream';
930+
}
931+
}
932+
933+
function sendText(res: nodeHttp.ServerResponse, status: number, text: string) {
934+
res.statusCode = status;
935+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
936+
res.setHeader('Cache-Control', 'no-store');
937+
res.end(text);
938+
}
939+
940+
const server = nodeHttp.createServer((req, res) => {
941+
if (!req.url) return sendText(res, 400, 'Bad Request');
942+
943+
if (req.method && req.method !== 'GET' && req.method !== 'HEAD') {
944+
res.setHeader('Allow', 'GET, HEAD');
945+
return sendText(res, 405, 'Method Not Allowed');
946+
}
947+
948+
let pathname = '/';
949+
try {
950+
pathname = new URL(req.url, `http://${host}:${port}`).pathname || '/';
951+
} catch {
952+
return sendText(res, 400, 'Bad Request');
953+
}
954+
955+
try {
956+
pathname = decodeURIComponent(pathname);
957+
} catch {
958+
return sendText(res, 400, 'Bad Request');
959+
}
960+
961+
if (!pathname.startsWith('/')) pathname = `/${pathname}`;
962+
const rel = pathname.replace(/^\/+/, '');
963+
const unsafeAbs = path.resolve(rootAbs, rel);
964+
const withinRoot = unsafeAbs === rootAbs || unsafeAbs.startsWith(rootAbs + path.sep);
965+
if (!withinRoot) return sendText(res, 400, 'Bad Request');
966+
967+
// Redirect to trailing-slash routes (Next export uses trailingSlash: true).
968+
if (!pathname.endsWith('/') && fs.existsSync(unsafeAbs) && fs.statSync(unsafeAbs).isDirectory()) {
969+
res.statusCode = 308;
970+
res.setHeader('Location', pathname + '/');
971+
res.setHeader('Cache-Control', 'no-store');
972+
res.end();
973+
return;
974+
}
975+
976+
let filePath = unsafeAbs;
977+
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
978+
filePath = path.join(filePath, 'index.html');
979+
} else if (!fs.existsSync(filePath)) {
980+
// Convenience: allow /foo -> /foo/index.html if present.
981+
const dirIndex = path.join(filePath, 'index.html');
982+
if (fs.existsSync(dirIndex)) {
983+
res.statusCode = 308;
984+
res.setHeader('Location', pathname.endsWith('/') ? pathname : pathname + '/');
985+
res.setHeader('Cache-Control', 'no-store');
986+
res.end();
987+
return;
988+
}
989+
990+
return sendText(res, 404, 'Not Found');
991+
}
992+
993+
try {
994+
const stat = fs.statSync(filePath);
995+
if (!stat.isFile()) return sendText(res, 404, 'Not Found');
996+
997+
res.statusCode = 200;
998+
res.setHeader('Content-Type', contentTypeForPath(filePath));
999+
res.setHeader('Content-Length', String(stat.size));
1000+
1001+
// Disable caching so manifest updates (e.g. after `th deploy`) are reflected immediately.
1002+
res.setHeader('Cache-Control', 'no-store');
1003+
1004+
if (req.method === 'HEAD') {
1005+
res.end();
1006+
return;
1007+
}
1008+
1009+
fs.createReadStream(filePath).pipe(res);
1010+
} catch (e: any) {
1011+
return sendText(res, 500, String(e?.message ?? e ?? 'Internal Server Error'));
1012+
}
1013+
});
1014+
1015+
server.on('error', (e: any) => {
1016+
console.error(String(e?.message ?? e ?? e));
1017+
process.exitCode = 1;
1018+
});
1019+
1020+
server.listen(port, host, () => {
1021+
const url = `http://${host}:${port}/`;
1022+
console.log(`Serving ${uiSiteDir}`);
1023+
console.log(url);
1024+
1025+
const manifestCandidates = [
1026+
path.join(uiSiteDir, '.well-known', 'tokenhost', 'manifest.json'),
1027+
path.join(uiSiteDir, 'manifest.json'),
1028+
path.join(resolvedBuildDir, 'manifest.json')
1029+
];
1030+
const manifestPath = manifestCandidates.find((p) => fs.existsSync(p)) || null;
1031+
if (manifestPath) {
1032+
try {
1033+
const manifest = readJsonFile(manifestPath) as any;
1034+
const deployments = Array.isArray(manifest?.deployments) ? manifest.deployments : [];
1035+
const deployment = deployments.find((d: any) => d && d.role === 'primary') ?? deployments[0] ?? null;
1036+
const addr = String(deployment?.deploymentEntrypointAddress ?? '');
1037+
const chainId = deployment?.chainId ?? null;
1038+
console.log(`manifest: ${manifestPath}`);
1039+
console.log(`deployment: chainId=${chainId ?? 'unknown'} address=${addr || 'unknown'}`);
1040+
const zeroAddress = '0x0000000000000000000000000000000000000000';
1041+
if (addr && addr.toLowerCase() === zeroAddress) {
1042+
console.log('');
1043+
console.log('Not deployed: deploymentEntrypointAddress is 0x0.');
1044+
console.log(`Run: th deploy ${resolvedBuildDir} --chain anvil`);
1045+
console.log('Then refresh this page.');
1046+
}
1047+
} catch {
1048+
// Ignore manifest parse errors; the UI will surface them at runtime.
1049+
}
1050+
}
1051+
});
1052+
1053+
process.on('SIGINT', () => {
1054+
server.close(() => process.exit(0));
1055+
});
1056+
});
1057+
8531058
program
8541059
.command('deploy')
8551060
.argument('<buildDir>', 'Directory created by `th build` (contains manifest.json)')
@@ -1006,8 +1211,21 @@ program
10061211
throw new Error(`Updated manifest failed validation:\n${JSON.stringify(validation.errors, null, 2)}`);
10071212
}
10081213

1009-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1214+
const manifestJsonOut = JSON.stringify(manifest, null, 2);
1215+
fs.writeFileSync(manifestPath, manifestJsonOut);
10101216
console.log(`Updated ${manifestPath}`);
1217+
1218+
const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');
1219+
if (fs.existsSync(uiSiteDir)) {
1220+
publishManifestToUiSite(uiSiteDir, manifestJsonOut);
1221+
console.log(`Published manifest to ui-site/`);
1222+
}
1223+
1224+
if (fs.existsSync(uiSiteDir)) {
1225+
console.log('');
1226+
console.log('Next steps:');
1227+
console.log(` th preview ${resolvedBuildDir} # open http://127.0.0.1:3000/`);
1228+
}
10111229
} catch (e: any) {
10121230
console.error(String(e?.message ?? e));
10131231
process.exitCode = 1;
@@ -1271,7 +1489,14 @@ program
12711489
return;
12721490
}
12731491

1274-
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
1492+
const manifestJsonOut = JSON.stringify(manifest, null, 2);
1493+
fs.writeFileSync(manifestPath, manifestJsonOut);
1494+
1495+
const uiSiteDir = path.join(resolvedBuildDir, 'ui-site');
1496+
if (fs.existsSync(uiSiteDir)) {
1497+
publishManifestToUiSite(uiSiteDir, manifestJsonOut);
1498+
console.log(`Published manifest to ui-site/`);
1499+
}
12751500

12761501
if (!verified) {
12771502
console.error('Verification did not fully succeed.');

packages/templates/next-export-ui/app/[collection]/ClientPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,17 @@ export default function CollectionListPage(props: { params: { collection: string
183183
return (
184184
<div className="card">
185185
<h2>Not deployed</h2>
186-
<div className="muted">Run <span className="badge">th deploy</span> and re-publish the manifest for this UI.</div>
187-
<div className="pre">manifest deploymentEntrypointAddress is 0x0</div>
186+
<div className="muted">
187+
This UI reads <span className="badge">/.well-known/tokenhost/manifest.json</span> at runtime, but the manifest still has a placeholder
188+
deployment address (<span className="badge">deploymentEntrypointAddress = 0x0</span>).
189+
</div>
190+
<div className="muted" style={{ marginTop: 12 }}>
191+
Run <span className="badge">th deploy {'<buildDir>'} --chain anvil</span>, then refresh this page.
192+
</div>
193+
<div className="muted" style={{ marginTop: 12 }}>
194+
If you are hosting this UI remotely, publish the updated <span className="badge">manifest.json</span> to{' '}
195+
<span className="badge">/.well-known/tokenhost/manifest.json</span>.
196+
</div>
188197
</div>
189198
);
190199
}
@@ -231,4 +240,3 @@ export default function CollectionListPage(props: { params: { collection: string
231240
</>
232241
);
233242
}
234-

packages/templates/next-export-ui/app/[collection]/delete/ClientPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -174,8 +174,17 @@ export default function DeleteRecordPage(props: { params: { collection: string }
174174
return (
175175
<div className="card">
176176
<h2>Not deployed</h2>
177-
<div className="muted">Run <span className="badge">th deploy</span> and re-publish the manifest for this UI.</div>
178-
<div className="pre">manifest deploymentEntrypointAddress is 0x0</div>
177+
<div className="muted">
178+
This UI reads <span className="badge">/.well-known/tokenhost/manifest.json</span> at runtime, but the manifest still has a placeholder
179+
deployment address (<span className="badge">deploymentEntrypointAddress = 0x0</span>).
180+
</div>
181+
<div className="muted" style={{ marginTop: 12 }}>
182+
Run <span className="badge">th deploy {'<buildDir>'} --chain anvil</span>, then refresh this page.
183+
</div>
184+
<div className="muted" style={{ marginTop: 12 }}>
185+
If you are hosting this UI remotely, publish the updated <span className="badge">manifest.json</span> to{' '}
186+
<span className="badge">/.well-known/tokenhost/manifest.json</span>.
187+
</div>
179188
</div>
180189
);
181190
}
@@ -234,4 +243,3 @@ export default function DeleteRecordPage(props: { params: { collection: string }
234243
</div>
235244
);
236245
}
237-

packages/templates/next-export-ui/app/[collection]/edit/ClientPage.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,17 @@ export default function EditRecordPage(props: { params: { collection: string } }
215215
return (
216216
<div className="card">
217217
<h2>Not deployed</h2>
218-
<div className="muted">Run <span className="badge">th deploy</span> and re-publish the manifest for this UI.</div>
219-
<div className="pre">manifest deploymentEntrypointAddress is 0x0</div>
218+
<div className="muted">
219+
This UI reads <span className="badge">/.well-known/tokenhost/manifest.json</span> at runtime, but the manifest still has a placeholder
220+
deployment address (<span className="badge">deploymentEntrypointAddress = 0x0</span>).
221+
</div>
222+
<div className="muted" style={{ marginTop: 12 }}>
223+
Run <span className="badge">th deploy {'<buildDir>'} --chain anvil</span>, then refresh this page.
224+
</div>
225+
<div className="muted" style={{ marginTop: 12 }}>
226+
If you are hosting this UI remotely, publish the updated <span className="badge">manifest.json</span> to{' '}
227+
<span className="badge">/.well-known/tokenhost/manifest.json</span>.
228+
</div>
220229
</div>
221230
);
222231
}
@@ -300,4 +309,3 @@ export default function EditRecordPage(props: { params: { collection: string } }
300309
</div>
301310
);
302311
}
303-

0 commit comments

Comments
 (0)