lacis
Version:
Zero-dependency TypeScript web framework
24 lines (21 loc) • 8.8 kB
JavaScript
import {resolve,join,relative,dirname}from'path';import {unlink,writeFile,rm,readdir,mkdir}from'fs/promises';import {spawn}from'child_process';import {watch,existsSync,readFileSync}from'fs';async function I(e){let n=[];async function t(r,o=[]){let i;try{i=await readdir(r,{withFileTypes:!0});}catch{return}let l=i.find(s=>!s.isDirectory()&&(s.name==="index.ts"||s.name==="index.js"));if(l){let s="./"+relative(e,join(r,l.name)).replace(/\\/g,"/").replace(/\.ts$/,".js"),u="/"+o.map(c=>/^\[\.\.\.\w+\]$/.test(c)?"*":c.replace(/^\[(\w+)\??\]$/,":$1")).join("/");n.push({importPath:s,routePath:u==="//"?"/":u});}for(let s of i)s.isDirectory()&&!s.name.startsWith("+")&&await t(join(r,s.name),[...o,s.name]);}return await t(e),n}async function M(e){let n=[];async function t(r,o){let i;try{i=await readdir(r,{withFileTypes:!0});}catch{return}let l=i.find(u=>u.name==="+middleware.global.ts")??i.find(u=>u.name==="+middleware.global.js");l&&n.push({importPath:"./"+relative(e,join(r,l.name)).replace(/\\/g,"/").replace(/\.ts$/,".js"),routePath:o,type:"cascade"});let s=i.find(u=>u.name==="+middleware.ts")??i.find(u=>u.name==="+middleware.js");s&&n.push({importPath:"./"+relative(e,join(r,s.name)).replace(/\\/g,"/").replace(/\.ts$/,".js"),routePath:o,type:"exact"});for(let u of i)if(u.isDirectory()&&!u.name.startsWith("+")){let c=o==="/"?`/${u.name}`:`${o}/${u.name}`;await t(join(r,u.name),c);}}return await t(e,"/"),n}async function m(e){let[n,t]=await Promise.all([I(e),M(e)]),r=n.map((c,d)=>`import * as _route_${d} from '${c.importPath}'`).join(`
`),o=t.map((c,d)=>`import * as _mw_${d} from '${c.importPath}'`).join(`
`),i=[r,o].filter(Boolean).join(`
`),l=n.map((c,d)=>` { path: '${c.routePath}', handlers: _route_${d} }`).join(`,
`),s=t.map((c,d)=>` { path: '${c.routePath}', type: '${c.type}', module: _mw_${d} }`).join(`,
`),u=["// AUTO-GENERATED by lacis build \u2014 do not edit",i,"","export const routes = [",l,"]","","export const middlewares = [",s,"]",""].join(`
`);await writeFile(join(e,"_manifest.ts"),u,"utf-8"),console.log(`[lacis] Generated manifest with ${n.length} route(s) and ${t.length} middleware(s)`);}function A(e){let n=e.map(o=>o.match(/^\[(\w+)(\?)?\]$/)).filter(o=>o!==null).map(o=>({name:o[1],optional:o[2]==="?"})),t=e.some(o=>/^\[\.\.\.\w+\]$/.test(o)),r=n.map(o=>`${o.name}${o.optional?"?":""}: string`);return t&&r.push('"*": string'),r.length===0?"Record<string, never>":"{ "+r.join("; ")+" }"}function B(e){return ["// AUTO-GENERATED by lacis \u2014 do not edit.","import type { Request, Response } from 'lacis'","",`export type RouteParams = ${e}`,"export type RouteRequest = Omit<Request, 'params'> & { params: RouteParams }","export type RouteHandler = (req: RouteRequest, res: Response) => void | Promise<void>",""].join(`
`)}async function p(e){let n=process.cwd(),t=join(n,".lacis","types");await rm(t,{recursive:true,force:true});let r=0;async function o(i,l){let s;try{s=await readdir(i,{withFileTypes:!0});}catch{return}if(s.some(c=>!c.isDirectory()&&(c.name==="index.ts"||c.name==="index.js"))){let c=relative(n,resolve(i)).replace(/\\/g,"/"),d=join(t,c,"$types.d.ts");await mkdir(dirname(d),{recursive:true}),await writeFile(d,B(A(l)),"utf-8"),r++;}for(let c of s)c.isDirectory()&&!c.name.startsWith("+")&&await o(join(i,c.name),[...l,c.name]);}await o(e,[]),console.log(`[lacis] Generated $types for ${r} route(s)`);}function C(e){if(existsSync(join(e,"wrangler.toml")))return "cloudflare";if(existsSync(join(e,"netlify.toml")))return "netlify";if(existsSync(join(e,"vercel.json")))return "vercel";try{let n=JSON.parse(readFileSync(join(e,"package.json"),"utf-8")),t={...n.dependencies,...n.devDependencies};if(t.wrangler)return "cloudflare";if(t["@netlify/functions"]||t["netlify-cli"])return "netlify";if(t.vercel||t["@vercel/node"])return "vercel"}catch{}return process.versions.bun!==void 0||existsSync(join(e,"bun.lockb"))||existsSync(join(e,"bun.lock"))?"bun":"node"}function J(e){try{let n=JSON.parse(readFileSync(join(e,"package.json"),"utf-8")),t=n.module??n.main;if(typeof t=="string"&&existsSync(join(e,t)))return t}catch{}for(let n of ["server.ts","index.ts","app.ts","main.ts"])if(existsSync(join(e,n)))return n;return null}function G(e){try{return JSON.parse(readFileSync(join(e,"tsconfig.json"),"utf-8")).compilerOptions?.outDir??null}catch{return null}}async function R(e){let n=[],t;try{t=await readdir(e,{withFileTypes:!0});}catch{return n}for(let r of t)r.isDirectory()?n.push(...await R(join(e,r.name))):r.name.endsWith(".ts")&&r.name!=="_manifest.ts"&&n.push(join(e,r.name));return n}async function E(e,n){let t;try{t=await readdir(e,{withFileTypes:!0});}catch{return null}for(let r of t){let o=join(e,r.name);if(r.isDirectory()){let i=await E(o,n);if(i)return i}else if(r.name===n)return o}return null}function W(e,n,t){return new Promise((r,o)=>{let i=spawn(e,n,{stdio:"inherit",cwd:t});i.on("close",l=>{l===0?r():o(new Error(`${e} exited with code ${l}`));}),i.on("error",l=>{l.code==="ENOENT"?o(new Error(`Command not found: ${e}. Make sure it is installed.`)):o(l);});})}async function b(e,n){let t=process.cwd(),r=C(t),o=join(e,"_manifest.ts"),i="dist";if(await m(e),await p(e),r==="vercel"||r==="netlify"||r==="cloudflare"){console.log(`[lacis] ${r} detected \u2014 manifest generated, platform handles compilation`);return}try{if(r==="bun"){let s=n??J(t);if(!s)throw new Error("Entry point not found. Specify one with --entry <file>.");let u=await R(e),c=[join(t,s),o,...u],d=await globalThis.Bun.build({entrypoints:c,outdir:join(t,"dist"),root:t,external:["*"],target:"bun"});if(!d.success){let k=d.logs.map(x=>x.message??String(x)).join(`
`);throw new Error(`Bun build failed:
${k}`)}}else {let s=join(t,"node_modules",".bin","tsc");if(!existsSync(s))throw new Error("TypeScript not found. Add it to your project: npm install --save-dev typescript");i=G(t)??"dist",await W(s,["--outDir",i],t);}}finally{await unlink(o).catch(()=>{});}if(!await E(join(t,i),"_manifest.js"))throw new Error(`Build produced no _manifest.js under "${i}/". The compiler ran but emitted nothing for the manifest \u2014 check that your tsconfig does not set "noEmit": true and that "include" covers your routes directory.`);console.log(`[lacis] Build complete \u2192 ${i}/`);}async function y(e){await m(e),await p(e);let n=null;watch(e,{recursive:true},(t,r)=>{!r||r==="_manifest.ts"||(n&&clearTimeout(n),n=setTimeout(async()=>{console.log(`[lacis] Route changed: ${r}, regenerating...`);try{await m(e),await p(e);}catch(o){console.error("[lacis] Failed to regenerate:",o);}},100));});}function H(e){if(existsSync(join(e,"wrangler.toml")))return "cloudflare";if(existsSync(join(e,"netlify.toml")))return "netlify";if(existsSync(join(e,"vercel.json")))return "vercel";try{let n=JSON.parse(readFileSync(join(e,"package.json"),"utf-8")),t={...n.dependencies,...n.devDependencies};if(t.wrangler)return "cloudflare";if(t["@netlify/functions"]||t["netlify-cli"])return "netlify";if(t.vercel||t["@vercel/node"])return "vercel"}catch{}return "node"}async function T(e){let n=process.cwd();if(process.env.VERCEL==="1"){await m(e),await p(e);return}if(process.env.NETLIFY==="true"){await y(e);return}let t=H(n);if(console.log(`[lacis] Detected platform: ${t}`),await y(e),t==="node"){console.log("[lacis] Node mode: watching routes for changes...");return}let[r,o]=t==="netlify"?["netlify",["dev"]]:t==="cloudflare"?["wrangler",["dev"]]:["vercel",["dev"]],i=spawn(r,o,{stdio:"inherit",shell:true,cwd:n});i.on("error",s=>{s.code==="ENOENT"?console.error(`[lacis] ${r} CLI not found. Install it with: ${t==="netlify"?"npm i -g netlify-cli":"npm i -g vercel"}`):console.error(`[lacis] Failed to start ${r} dev:`,s.message);});let l=s=>{process.on(s,()=>i.kill(s));};l("SIGINT"),l("SIGTERM");}function V(e){let n=e.slice(2),t=n[0]??"",r=n.indexOf("--routes"),o=r!==-1?n[r+1]:void 0,i=o?resolve(process.cwd(),o):resolve(process.cwd(),"routes"),l=n.indexOf("--entry"),s=l!==-1?n[l+1]:void 0;return {command:t,routesDir:i,entry:s}}function Y(){console.log(`
Usage: lacis <command> [options]
Commands:
build Compile project to dist/ (Node: tsc/tsup, Bun: bun build)
watch Watch routes and regenerate manifest on changes
dev Auto-detect platform and start dev server
To scaffold a new project: npm create lacis@latest
Options:
--routes <dir> Path to routes directory (default: ./routes)
--entry <file> Entry point for bundlers (default: auto-detected from package.json)
`);}async function z(){let{command:e,routesDir:n,entry:t}=V(process.argv);switch(e){case "build":await b(n,t);break;case "watch":await y(n);break;case "dev":await T(n);break;default:Y(),e&&(console.error(`Unknown command: ${e}`),process.exit(1));}}z().catch(e=>{console.error("[lacis]",e),process.exit(1);});