@deeep-network/riptide 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
${c}`:c,m=[];s.stdout?.trim()&&m.push(`STDOUT: ${s.stdout.trim()}`),p&&m.push(`STDERR: ${p}`);let M=[`Command execution timed out: ${i}`,...m].join(`
|
|
5
5
|
|
|
6
|
-
`),E=new Error(M);throw E.name="CommandTimeoutError",E}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:n(async(i,e,t={})=>{let{mode:r="0644",encoding:o="utf8"}=t;await f.promises.mkdir(b.dirname(i),{recursive:!0}),await f.promises.writeFile(i,e,{encoding:o}),await f.promises.chmod(i,r)},"writeFile"),readFile:n(async i=>await f.promises.readFile(i,"utf8"),"readFile"),fileExists:n(async i=>{try{return await f.promises.access(i),!0}catch{return!1}},"fileExists"),downloadFile:n(async function i(e,t){return new Promise((r,o)=>{let s=b.dirname(t);f.promises.mkdir(s,{recursive:!0}).then(()=>{let a=(0,f.createWriteStream)(t);(e.startsWith("https:")?Y:W).get(e,p=>{if(p.statusCode===200)p.pipe(a),a.on("finish",()=>{a.close(),r(t)});else if(p.statusCode===301||p.statusCode===302){let m=p.headers.location;m?r(i(m,t)):o(new Error(`Redirect without location header: ${p.statusCode}`))}else o(new Error(`Failed to download: ${p.statusCode} ${p.statusMessage}`))}).on("error",p=>{f.promises.unlink(t).catch(()=>{}),o(p)}),a.on("error",p=>{f.promises.unlink(t).catch(()=>{}),o(p)})}).catch(o)})},"downloadFile")}}n(I,"createUtilityContext");var H=require("http");var k=class{constructor(e,t,r,o,s){this.getStatus=r;this.executeHealthCheck=o;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{n(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,H.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let r=await this.executeHealthCheck(),o=this.getStatus(),s={healthy:r,status:o.status,uptime:o.uptime,message:o.message};r?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(r){this.logger.error({error:r},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:r instanceof Error?r.message:"Internal server error"}))}else if(e.url==="/metrics")try{let r=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(r)}catch(r){this.logger.error({error:r},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:r instanceof Error?r.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",r=>{this.logger.error({error:r,port:this.port},"Failed to start web server"),t(r)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var S=class{static{n(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;updateInterval;webServer;constructor(e,t,r={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=P({serviceName:t.service.name,level:r.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.startUpdate(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook defined, continuing...");return}try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);this.logger.info(`Executing ${e} hook`);let r=this.createHookContext(),o=Date.now();try{let s=await t(r),a=Date.now()-o;return this.logger.info(`${e} hook completed (${a}ms)`),s}catch(s){let a=Date.now()-o;throw this.logger.error({hookName:e,duration:a,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},`${e} hook threw an exception, will not continue`),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(r){y(r)&&(this.logger.error({hookName:e,error:r.message,exitCode:r.exitCode},`${e} hook threw ${r.name}, exiting with code ${r.exitCode}`),process.exit(r.exitCode)),this.logger.error({hookName:e,error:r instanceof Error?r.message:String(r),stack:r instanceof Error?r.stack:void 0},`${e} hook threw an unhandled exception, exiting with code 1`),process.exit(1)}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:I()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=n(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.updateInterval&&clearInterval(this.updateInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(r){this.logger.error({error:r instanceof Error?r.message:String(r)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;let r=await this.executeHookSafely("heartbeat");if(r===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat"),t=!1;return}try{await this.pingSonar(r)}catch(o){this.logger.warn({error:o instanceof Error?o.message:String(o)},"Failed to send heartbeat to Sonar")}finally{t=!1}},e)}startUpdate(){if(!this.hooks.update){this.logger.info("No update hook defined, skipping update");return}if(!this.config.update?.enabled){this.logger.info("Update disabled in config, skipping...");return}let e=this.config.update?.interval||6e4;this.logger.info({interval:e},"Starting update");let t=!1;this.updateInterval=setInterval(async()=>{if(t){this.logger.info("Update still executing, skipping this interval");return}t=!0,await this.executeHookSafely("update"),this.logger.debug("Update hook completed successfully"),t=!1},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,r=process.env.SONAR_API_KEY,o=process.env.NOMAD_JOB_NAME;if(!t||!r||!o){this.logger.info({hasUrl:!!t,hasKey:!!r,hasJobId:!!o},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),a={entity_id:o,client_timestamp:s,metadata:e};try{this.logger.info({payload:a,sonarUrl:t,sonarApiKey:r,nomadJobId:o},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify(a)});if(!c.ok){let d=await c.text();this.logger.warn({status:c.status,error:d,entity_id:o},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:o},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new k(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=n(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var h=g(require("fs/promises")),l=g(require("path"));var C=class{constructor(e){this.logger=e}static{n(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:r=".",template:o="basic",description:s}=e,a=l.join(r,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${o}`),this.logger.info(`Location: ${a}`),await this.createDirectoryStructure(a),await this.createPackageJson(a,t,s),await this.createRiptideConfig(a,t,s),await this.createTsConfig(a),await this.createTsupConfig(a),await this.createHooks(a,o),await this.createDockerfile(a,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,a)}async createDirectoryStructure(e){await h.mkdir(l.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,r){let o=await this.checkIfInWorkspace(e),s=o?"workspace:*":await this.getRiptideVersion(),a=o?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f services/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:r||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":a,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await h.writeFile(l.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,r){let o={service:{name:t,version:"1.0.0",description:r||`${t} service`},logging:{level:"info"}};await h.writeFile(l.join(e,"riptide.config.json"),JSON.stringify(o,null,2))}async createTsConfig(e){let r=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await h.writeFile(l.join(e,"tsconfig.json"),JSON.stringify(r,null,2))}async createTsupConfig(e){await h.writeFile(l.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
6
|
+
`),E=new Error(M);throw E.name="CommandTimeoutError",E}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:n(async(i,e,t={})=>{let{mode:r="0644",encoding:o="utf8"}=t;await f.promises.mkdir(b.dirname(i),{recursive:!0}),await f.promises.writeFile(i,e,{encoding:o}),await f.promises.chmod(i,r)},"writeFile"),readFile:n(async i=>await f.promises.readFile(i,"utf8"),"readFile"),fileExists:n(async i=>{try{return await f.promises.access(i),!0}catch{return!1}},"fileExists"),downloadFile:n(async function i(e,t){return new Promise((r,o)=>{let s=b.dirname(t);f.promises.mkdir(s,{recursive:!0}).then(()=>{let a=(0,f.createWriteStream)(t);(e.startsWith("https:")?Y:W).get(e,p=>{if(p.statusCode===200)p.pipe(a),a.on("finish",()=>{a.close(),r(t)});else if(p.statusCode===301||p.statusCode===302){let m=p.headers.location;m?r(i(m,t)):o(new Error(`Redirect without location header: ${p.statusCode}`))}else o(new Error(`Failed to download: ${p.statusCode} ${p.statusMessage}`))}).on("error",p=>{f.promises.unlink(t).catch(()=>{}),o(p)}),a.on("error",p=>{f.promises.unlink(t).catch(()=>{}),o(p)})}).catch(o)})},"downloadFile")}}n(I,"createUtilityContext");var H=require("http");var k=class{constructor(e,t,r,o,s){this.getStatus=r;this.executeHealthCheck=o;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{n(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,H.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let r=await this.executeHealthCheck(),o=this.getStatus(),s={healthy:r,status:o.status,uptime:o.uptime,message:o.message};r?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(r){this.logger.error({error:r},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:r instanceof Error?r.message:"Internal server error"}))}else if(e.url==="/metrics")try{let r=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(r)}catch(r){this.logger.error({error:r},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:r instanceof Error?r.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",r=>{this.logger.error({error:r,port:this.port},"Failed to start web server"),t(r)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var S=class{static{n(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;updateInterval;webServer;constructor(e,t,r={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=P({serviceName:t.service.name,level:r.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.startUpdate(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook defined, continuing...");return}try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);this.logger.info(`Executing ${e} hook`);let r=this.createHookContext(),o=Date.now();try{let s=await t(r),a=Date.now()-o;return this.logger.info(`${e} hook completed (${a}ms)`),s}catch(s){let a=Date.now()-o;throw this.logger.error({hookName:e,duration:a,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},`${e} hook threw an exception, will not continue`),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(r){y(r)&&(this.logger.error({hookName:e,error:r.message,exitCode:r.exitCode},`${e} hook threw ${r.name}, exiting with code ${r.exitCode}`),process.exit(r.exitCode)),this.logger.error({hookName:e,error:r instanceof Error?r.message:String(r),stack:r instanceof Error?r.stack:void 0},`${e} hook threw an unhandled exception, exiting with code 1`),process.exit(1)}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:I()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=n(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.updateInterval&&clearInterval(this.updateInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(r){this.logger.error({error:r instanceof Error?r.message:String(r)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;let r=await this.executeHookSafely("heartbeat");if(r===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat"),t=!1;return}try{await this.pingSonar(r)}catch(o){this.logger.warn({error:o instanceof Error?o.message:String(o)},"Failed to send heartbeat to Sonar")}finally{t=!1}},e)}startUpdate(){if(!this.hooks.update){this.logger.info("No update hook defined, skipping update");return}if(!this.config.update?.enabled){this.logger.info("Update disabled in config, skipping...");return}let e=this.config.update?.interval||6e4;this.logger.info({interval:e},"Starting update");let t=!1;this.updateInterval=setInterval(async()=>{if(t){this.logger.info("Update still executing, skipping this interval");return}t=!0,await this.executeHookSafely("update"),this.logger.debug("Update hook completed successfully"),t=!1},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,r=process.env.SONAR_API_KEY,o=process.env.NOMAD_JOB_NAME;if(!t||!r||!o){this.logger.info({hasUrl:!!t,hasKey:!!r,hasJobId:!!o},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),a={entity_id:o,client_timestamp:s,metadata:e};try{this.logger.info({payload:a,sonarUrl:t,nomadJobId:o},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify(a)});if(!c.ok){let d=await c.text();this.logger.warn({status:c.status,error:d,entity_id:o},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:o},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new k(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=n(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var h=g(require("fs/promises")),l=g(require("path"));var C=class{constructor(e){this.logger=e}static{n(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:r=".",template:o="basic",description:s}=e,a=l.join(r,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${o}`),this.logger.info(`Location: ${a}`),await this.createDirectoryStructure(a),await this.createPackageJson(a,t,s),await this.createRiptideConfig(a,t,s),await this.createTsConfig(a),await this.createTsupConfig(a),await this.createHooks(a,o),await this.createDockerfile(a,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,a)}async createDirectoryStructure(e){await h.mkdir(l.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,r){let o=await this.checkIfInWorkspace(e),s=o?"workspace:*":await this.getRiptideVersion(),a=o?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f services/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:r||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":a,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await h.writeFile(l.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,r){let o={service:{name:t,version:"1.0.0",description:r||`${t} service`},logging:{level:"info"}};await h.writeFile(l.join(e,"riptide.config.json"),JSON.stringify(o,null,2))}async createTsConfig(e){let r=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await h.writeFile(l.join(e,"tsconfig.json"),JSON.stringify(r,null,2))}async createTsupConfig(e){await h.writeFile(l.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
7
|
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
entry: ['src/hooks.ts'],
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
${c}`:c,f=[];s.stdout?.trim()&&f.push(`STDOUT: ${s.stdout.trim()}`),d&&f.push(`STDERR: ${d}`);let X=[`Command execution timed out: ${r}`,...f].join(`
|
|
4
4
|
|
|
5
5
|
`),D=new Error(X);throw D.name="CommandTimeoutError",D}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:a(async(r,e,t={})=>{let{mode:i="0644",encoding:o="utf8"}=t;await g.promises.mkdir($.dirname(r),{recursive:!0}),await g.promises.writeFile(r,e,{encoding:o}),await g.promises.chmod(r,i)},"writeFile"),readFile:a(async r=>await g.promises.readFile(r,"utf8"),"readFile"),fileExists:a(async r=>{try{return await g.promises.access(r),!0}catch{return!1}},"fileExists"),downloadFile:a(async function r(e,t){return new Promise((i,o)=>{let s=$.dirname(t);g.promises.mkdir(s,{recursive:!0}).then(()=>{let n=(0,g.createWriteStream)(t);(e.startsWith("https:")?ne:se).get(e,d=>{if(d.statusCode===200)d.pipe(n),n.on("finish",()=>{n.close(),i(t)});else if(d.statusCode===301||d.statusCode===302){let f=d.headers.location;f?i(r(f,t)):o(new Error(`Redirect without location header: ${d.statusCode}`))}else o(new Error(`Failed to download: ${d.statusCode} ${d.statusMessage}`))}).on("error",d=>{g.promises.unlink(t).catch(()=>{}),o(d)}),n.on("error",d=>{g.promises.unlink(t).catch(()=>{}),o(d)})}).catch(o)})},"downloadFile")}}a(P,"createUtilityContext");function W(r){return!r||r.length<=10?"[REDACTED]":`${r.slice(0,4)}...${r.slice(-4)}`}a(W,"redactSecret");function L(r){let e={},t=r.split(`
|
|
6
|
-
`);for(let i of t){let o=i.trim();if(o&&!o.startsWith("#")){let[s,...n]=o.split("=");s&&n.length>0&&(e[s.trim()]=n.join("=").trim())}}return e}a(L,"parseEnvironmentVariables");function R(r,e=process.env){return r.replace(/\$\{([^}]+)\}/g,(t,i)=>e[i]||t)}a(R,"expandEnvironmentVariables");var q=require("http");var O=class{constructor(e,t,i,o,s){this.getStatus=i;this.executeHealthCheck=o;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{a(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,q.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let i=await this.executeHealthCheck(),o=this.getStatus(),s={healthy:i,status:o.status,uptime:o.uptime,message:o.message};i?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(i){this.logger.error({error:i},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:i instanceof Error?i.message:"Internal server error"}))}else if(e.url==="/metrics")try{let i=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(i)}catch(i){this.logger.error({error:i},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:i instanceof Error?i.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",i=>{this.logger.error({error:i,port:this.port},"Failed to start web server"),t(i)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var I=class{static{a(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;updateInterval;webServer;constructor(e,t,i={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=b({serviceName:t.service.name,level:i.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.startUpdate(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook defined, continuing...");return}try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);this.logger.info(`Executing ${e} hook`);let i=this.createHookContext(),o=Date.now();try{let s=await t(i),n=Date.now()-o;return this.logger.info(`${e} hook completed (${n}ms)`),s}catch(s){let n=Date.now()-o;throw this.logger.error({hookName:e,duration:n,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},`${e} hook threw an exception, will not continue`),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(i){y(i)&&(this.logger.error({hookName:e,error:i.message,exitCode:i.exitCode},`${e} hook threw ${i.name}, exiting with code ${i.exitCode}`),process.exit(i.exitCode)),this.logger.error({hookName:e,error:i instanceof Error?i.message:String(i),stack:i instanceof Error?i.stack:void 0},`${e} hook threw an unhandled exception, exiting with code 1`),process.exit(1)}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:P()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=a(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.updateInterval&&clearInterval(this.updateInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(i){this.logger.error({error:i instanceof Error?i.message:String(i)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;let i=await this.executeHookSafely("heartbeat");if(i===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat"),t=!1;return}try{await this.pingSonar(i)}catch(o){this.logger.warn({error:o instanceof Error?o.message:String(o)},"Failed to send heartbeat to Sonar")}finally{t=!1}},e)}startUpdate(){if(!this.hooks.update){this.logger.info("No update hook defined, skipping update");return}if(!this.config.update?.enabled){this.logger.info("Update disabled in config, skipping...");return}let e=this.config.update?.interval||6e4;this.logger.info({interval:e},"Starting update");let t=!1;this.updateInterval=setInterval(async()=>{if(t){this.logger.info("Update still executing, skipping this interval");return}t=!0,await this.executeHookSafely("update"),this.logger.debug("Update hook completed successfully"),t=!1},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,i=process.env.SONAR_API_KEY,o=process.env.NOMAD_JOB_NAME;if(!t||!i||!o){this.logger.info({hasUrl:!!t,hasKey:!!i,hasJobId:!!o},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),n={entity_id:o,client_timestamp:s,metadata:e};try{this.logger.info({payload:n,sonarUrl:t,sonarApiKey:i,nomadJobId:o},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${i}`},body:JSON.stringify(n)});if(!c.ok){let u=await c.text();this.logger.warn({status:c.status,error:u,entity_id:o},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:o},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new O(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=a(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var Y=require("fs"),J=m(require("path"));async function V(r){try{let e=await Y.promises.readFile(r,"utf8"),t=JSON.parse(e),i=ce(t);return H(i),i}catch(e){throw new Error(`Failed to load config from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(V,"loadConfig");async function K(r){try{let e=J.resolve(r);delete require.cache[e];let t=require(e);if(typeof t.start!="function")throw new Error('Hooks module must export a "start" function');return t}catch(e){throw new Error(`Failed to load hooks from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(K,"loadHooks");function H(r){if(!r.service?.name)throw new Error("Config must have service.name");if(r.health,r.heartbeat&&r.heartbeat.interval&&r.heartbeat.interval<1e3)throw new Error("heartbeat.interval must be at least 1000ms")}a(H,"validateConfig");function ce(r,e=process.env){let t=JSON.parse(JSON.stringify(r));function i(o){if(typeof o=="string")return R(o,e);if(Array.isArray(o))return o.map(i);if(o&&typeof o=="object"){let s={};for(let[n,c]of Object.entries(o))s[n]=i(c);return s}return o}return a(i,"expandObject"),i(t)}a(ce,"expandConfigVariables");function G(){return{health:{port:3e3},heartbeat:{interval:6e4,enabled:!1},logging:{level:"info",format:"pretty"}}}a(G,"getDefaultConfig");function B(r,e){function t(o,s){if(s&&typeof s=="object"&&!Array.isArray(s))for(let n in s)s.hasOwnProperty(n)&&(o[n]&&typeof o[n]=="object"&&!Array.isArray(o[n])?o[n]=t(o[n],s[n]):o[n]=s[n]);return o}a(t,"deepMerge");let i=t({...r},e);return H(i),i}a(B,"mergeConfigs");var l=m(require("fs/promises")),p=m(require("path"));var k=class{constructor(e){this.logger=e}static{a(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:i=".",template:o="basic",description:s}=e,n=p.join(i,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${o}`),this.logger.info(`Location: ${n}`),await this.createDirectoryStructure(n),await this.createPackageJson(n,t,s),await this.createRiptideConfig(n,t,s),await this.createTsConfig(n),await this.createTsupConfig(n),await this.createHooks(n,o),await this.createDockerfile(n,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,n)}async createDirectoryStructure(e){await l.mkdir(p.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,i){let o=await this.checkIfInWorkspace(e),s=o?"workspace:*":await this.getRiptideVersion(),n=o?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f services/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:i||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":n,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await l.writeFile(p.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,i){let o={service:{name:t,version:"1.0.0",description:i||`${t} service`},logging:{level:"info"}};await l.writeFile(p.join(e,"riptide.config.json"),JSON.stringify(o,null,2))}async createTsConfig(e){let i=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await l.writeFile(p.join(e,"tsconfig.json"),JSON.stringify(i,null,2))}async createTsupConfig(e){await l.writeFile(p.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
6
|
+
`);for(let i of t){let o=i.trim();if(o&&!o.startsWith("#")){let[s,...n]=o.split("=");s&&n.length>0&&(e[s.trim()]=n.join("=").trim())}}return e}a(L,"parseEnvironmentVariables");function R(r,e=process.env){return r.replace(/\$\{([^}]+)\}/g,(t,i)=>e[i]||t)}a(R,"expandEnvironmentVariables");var q=require("http");var O=class{constructor(e,t,i,o,s){this.getStatus=i;this.executeHealthCheck=o;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{a(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,q.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let i=await this.executeHealthCheck(),o=this.getStatus(),s={healthy:i,status:o.status,uptime:o.uptime,message:o.message};i?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(i){this.logger.error({error:i},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:i instanceof Error?i.message:"Internal server error"}))}else if(e.url==="/metrics")try{let i=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(i)}catch(i){this.logger.error({error:i},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:i instanceof Error?i.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",i=>{this.logger.error({error:i,port:this.port},"Failed to start web server"),t(i)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var I=class{static{a(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;updateInterval;webServer;constructor(e,t,i={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=b({serviceName:t.service.name,level:i.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.startUpdate(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook defined, continuing...");return}try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);this.logger.info(`Executing ${e} hook`);let i=this.createHookContext(),o=Date.now();try{let s=await t(i),n=Date.now()-o;return this.logger.info(`${e} hook completed (${n}ms)`),s}catch(s){let n=Date.now()-o;throw this.logger.error({hookName:e,duration:n,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},`${e} hook threw an exception, will not continue`),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(i){y(i)&&(this.logger.error({hookName:e,error:i.message,exitCode:i.exitCode},`${e} hook threw ${i.name}, exiting with code ${i.exitCode}`),process.exit(i.exitCode)),this.logger.error({hookName:e,error:i instanceof Error?i.message:String(i),stack:i instanceof Error?i.stack:void 0},`${e} hook threw an unhandled exception, exiting with code 1`),process.exit(1)}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:P()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=a(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.updateInterval&&clearInterval(this.updateInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(i){this.logger.error({error:i instanceof Error?i.message:String(i)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;let i=await this.executeHookSafely("heartbeat");if(i===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat"),t=!1;return}try{await this.pingSonar(i)}catch(o){this.logger.warn({error:o instanceof Error?o.message:String(o)},"Failed to send heartbeat to Sonar")}finally{t=!1}},e)}startUpdate(){if(!this.hooks.update){this.logger.info("No update hook defined, skipping update");return}if(!this.config.update?.enabled){this.logger.info("Update disabled in config, skipping...");return}let e=this.config.update?.interval||6e4;this.logger.info({interval:e},"Starting update");let t=!1;this.updateInterval=setInterval(async()=>{if(t){this.logger.info("Update still executing, skipping this interval");return}t=!0,await this.executeHookSafely("update"),this.logger.debug("Update hook completed successfully"),t=!1},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,i=process.env.SONAR_API_KEY,o=process.env.NOMAD_JOB_NAME;if(!t||!i||!o){this.logger.info({hasUrl:!!t,hasKey:!!i,hasJobId:!!o},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),n={entity_id:o,client_timestamp:s,metadata:e};try{this.logger.info({payload:n,sonarUrl:t,nomadJobId:o},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${i}`},body:JSON.stringify(n)});if(!c.ok){let u=await c.text();this.logger.warn({status:c.status,error:u,entity_id:o},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:o},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new O(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=a(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var Y=require("fs"),J=m(require("path"));async function V(r){try{let e=await Y.promises.readFile(r,"utf8"),t=JSON.parse(e),i=ce(t);return H(i),i}catch(e){throw new Error(`Failed to load config from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(V,"loadConfig");async function K(r){try{let e=J.resolve(r);delete require.cache[e];let t=require(e);if(typeof t.start!="function")throw new Error('Hooks module must export a "start" function');return t}catch(e){throw new Error(`Failed to load hooks from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(K,"loadHooks");function H(r){if(!r.service?.name)throw new Error("Config must have service.name");if(r.health,r.heartbeat&&r.heartbeat.interval&&r.heartbeat.interval<1e3)throw new Error("heartbeat.interval must be at least 1000ms")}a(H,"validateConfig");function ce(r,e=process.env){let t=JSON.parse(JSON.stringify(r));function i(o){if(typeof o=="string")return R(o,e);if(Array.isArray(o))return o.map(i);if(o&&typeof o=="object"){let s={};for(let[n,c]of Object.entries(o))s[n]=i(c);return s}return o}return a(i,"expandObject"),i(t)}a(ce,"expandConfigVariables");function G(){return{health:{port:3e3},heartbeat:{interval:6e4,enabled:!1},logging:{level:"info",format:"pretty"}}}a(G,"getDefaultConfig");function B(r,e){function t(o,s){if(s&&typeof s=="object"&&!Array.isArray(s))for(let n in s)s.hasOwnProperty(n)&&(o[n]&&typeof o[n]=="object"&&!Array.isArray(o[n])?o[n]=t(o[n],s[n]):o[n]=s[n]);return o}a(t,"deepMerge");let i=t({...r},e);return H(i),i}a(B,"mergeConfigs");var l=m(require("fs/promises")),p=m(require("path"));var k=class{constructor(e){this.logger=e}static{a(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:i=".",template:o="basic",description:s}=e,n=p.join(i,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${o}`),this.logger.info(`Location: ${n}`),await this.createDirectoryStructure(n),await this.createPackageJson(n,t,s),await this.createRiptideConfig(n,t,s),await this.createTsConfig(n),await this.createTsupConfig(n),await this.createHooks(n,o),await this.createDockerfile(n,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,n)}async createDirectoryStructure(e){await l.mkdir(p.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,i){let o=await this.checkIfInWorkspace(e),s=o?"workspace:*":await this.getRiptideVersion(),n=o?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f services/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:i||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":n,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await l.writeFile(p.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,i){let o={service:{name:t,version:"1.0.0",description:i||`${t} service`},logging:{level:"info"}};await l.writeFile(p.join(e,"riptide.config.json"),JSON.stringify(o,null,2))}async createTsConfig(e){let i=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await l.writeFile(p.join(e,"tsconfig.json"),JSON.stringify(i,null,2))}async createTsupConfig(e){await l.writeFile(p.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
7
|
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
entry: ['src/hooks.ts'],
|