@@ -2,6 +2,7 @@ import fs from 'fs';
22import os from 'os' ;
33import path from 'path' ;
44import crypto from 'crypto' ;
5+ import * as nodeHttp from 'node:http' ;
56import { spawnSync } from 'child_process' ;
67import { createRequire } from 'module' ;
78import { 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+
92100function 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
849864function 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+
8531058program
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.' ) ;
0 commit comments