@deeep-network/riptide 1.0.2 → 2.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 +8 -8
- package/dist/index.d.ts +56 -2
- package/dist/index.js +8 -8
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";var _=Object.create;var
|
|
2
|
+
"use strict";var _=Object.create;var S=Object.defineProperty;var A=Object.getOwnPropertyDescriptor;var F=Object.getOwnPropertyNames;var U=Object.getPrototypeOf,L=Object.prototype.hasOwnProperty;var a=(o,e)=>S(o,"name",{value:e,configurable:!0});var J=(o,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of F(e))!L.call(o,i)&&i!==t&&S(o,i,{get:()=>e[i],enumerable:!(r=A(e,i))||r.enumerable});return o};var u=(o,e,t)=>(t=o!=null?_(U(o)):{},J(e||!o||!o.__esModule?S(t,"default",{value:o,enumerable:!0}):t,o));var N=u(require("pino"));var T=require("crypto");function y(o){return o instanceof Error&&"exitCode"in o&&typeof o.exitCode=="number"}a(y,"isRiptideError");var k=u(require("pino"));function P(o){let{level:e="info",format:t="pretty",serviceName:r}=o,i={level:e,timestamp:k.default.stdTimeFunctions.isoTime,formatters:{log:a(s=>({service:r,...s}),"log"),bindings:a(s=>{let{pid:n,hostname:p,...c}=s;return c},"bindings")}};return t==="pretty"&&!process.env.NODE_ENV?.includes("prod")?(0,k.default)({...i,transport:{target:"pino-pretty",options:{colorize:!0,translateTime:"SYS:standard",ignore:"pid,hostname"}}}):(0,k.default)(i)}a(P,"createLogger");var R=require("child_process"),f=require("fs"),W=u(require("http")),Y=u(require("https")),b=u(require("path")),O=require("util");var V=(0,O.promisify)(R.exec);function H(){return{sleep:a(o=>new Promise(e=>setTimeout(e,o)),"sleep"),retry:a(async(o,e={})=>{let{maxAttempts:t=3,delay:r=1e3,backoffMultiplier:i=2,maxDelay:s=3e4}=e,n,p=r;for(let c=1;c<=t;c++)try{return await o()}catch(l){if(n=l instanceof Error?l:new Error(String(l)),c===t)throw n;await new Promise(m=>setTimeout(m,Math.min(p,s))),p*=i}throw n},"retry"),execCommand:a(async(o,e={})=>{let{timeout:t=3e4,cwd:r=process.cwd(),env:i=process.env}=e;try{let{stdout:s,stderr:n}=await V(o,{timeout:t,cwd:r,env:{...process.env,...i}});return{stdout:s.trim(),stderr:n.trim(),exitCode:0}}catch(s){if(s.killed&&s.signal==="SIGTERM"){let p=`Command timed out after ${t/1e3}s. Consider increasing timeout if command needs more time to complete: utils.execCommand('${o}', { timeout: ${t*2} })`,c=s.stderr?.trim()||"",l=c?`${c}
|
|
3
3
|
|
|
4
|
-
${
|
|
4
|
+
${p}`:p,m=[];s.stdout?.trim()&&m.push(`STDOUT: ${s.stdout.trim()}`),l&&m.push(`STDERR: ${l}`);let M=[`Command execution timed out: ${o}`,...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,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
|
+
`),C=new Error(M);throw C.name="CommandTimeoutError",C}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:a(async(o,e,t={})=>{let{mode:r="0644",encoding:i="utf8"}=t;await f.promises.mkdir(b.dirname(o),{recursive:!0}),await f.promises.writeFile(o,e,{encoding:i}),await f.promises.chmod(o,r)},"writeFile"),readFile:a(async o=>await f.promises.readFile(o,"utf8"),"readFile"),fileExists:a(async o=>{try{return await f.promises.access(o),!0}catch{return!1}},"fileExists"),downloadFile:a(async function o(e,t){return new Promise((r,i)=>{let s=b.dirname(t);f.promises.mkdir(s,{recursive:!0}).then(()=>{let n=(0,f.createWriteStream)(t);(e.startsWith("https:")?Y:W).get(e,l=>{if(l.statusCode===200)l.pipe(n),n.on("finish",()=>{n.close(),r(t)});else if(l.statusCode===301||l.statusCode===302){let m=l.headers.location;m?r(o(m,t)):i(new Error(`Redirect without location header: ${l.statusCode}`))}else i(new Error(`Failed to download: ${l.statusCode} ${l.statusMessage}`))}).on("error",l=>{f.promises.unlink(t).catch(()=>{}),i(l)}),n.on("error",l=>{f.promises.unlink(t).catch(()=>{}),i(l)})}).catch(i)})},"downloadFile")}}a(H,"createUtilityContext");var I=require("http");var w=class{constructor(e,t,r,i,s){this.getStatus=r;this.executeHealthCheck=i;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,I.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(),i=this.getStatus(),s={healthy:r,status:i.status,uptime:i.uptime,message:i.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 x=class{static{a(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;riptideStartTime;heartbeatInterval;updateInterval;echoInterval;lastEchoHash=null;isEchoExecuting=!1;webServer;constructor(e,t,r={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.riptideStartTime=Date.now(),this.logger=P({serviceName:t.service.name,level:process.env.RIPTIDE_LOG_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.startEcho(),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.debug(`Executing ${e} hook`);let r=this.createHookContext(),i=Date.now();try{let s=await t(r),n=Date.now()-i;return this.logger.debug(`${e} hook completed (${n}ms)`),s}catch(s){let n=Date.now()-i;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(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:H()}}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.echoInterval&&clearInterval(this.echoInterval),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(i){this.logger.warn({error:i instanceof Error?i.message:String(i)},"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)}startEcho(){if(!this.hooks.echo){this.logger.info("No echo hook defined, skipping echo");return}if(this.config.echo?.enabled===!1){this.logger.info("Echo disabled in config, skipping...");return}let e=this.config.echo?.interval||3e3;this.logger.info({interval:e},"Starting echo"),this.echoInterval=setInterval(async()=>{if(this.isEchoExecuting){this.logger.debug("Echo still executing, skipping this interval");return}this.isEchoExecuting=!0;try{let t=await this.executeHookSafely("echo");if(!t)return;let r=JSON.stringify(t),i=(0,T.createHash)("sha256").update(r).digest("hex");i!==this.lastEchoHash&&(this.lastEchoHash=i,await this.sendEcho(t))}catch(t){this.logger.warn({error:t instanceof Error?t.message:String(t)},"Echo failed")}finally{this.isEchoExecuting=!1}},e)}async sendEcho(e){let t=process.env.SONAR_API_URL,r=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!r||!i){this.logger.error({hasUrl:!!t,hasKey:!!r,hasJobId:!!i},"Sonar API configuration incomplete, skipping echo send");return}let s={...e,condition:e.condition??"normal"},n={entity_id:i,client_timestamp:Math.floor(Date.now()/1e3),data:s};try{this.logger.info({payload:n,sonarUrl:t},"Sending echo to Sonar API");let p=await fetch(`${t}/api/v1/echo`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify(n)});if(!p.ok){let c=await p.text();this.logger.warn({status:p.status,error:c,entity_id:i},"Sonar API echo failed")}}catch(p){this.logger.warn({error:p instanceof Error?p.message:String(p)},"Failed to send echo to Sonar API, dropping")}}async pingSonar(e){let t=process.env.SONAR_API_URL,r=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!r||!i){this.logger.error({hasUrl:!!t,hasKey:!!r,hasJobId:!!i},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor((Date.now()-this.riptideStartTime)/1e3),n={...e},p={entity_id:i,client_timestamp:Math.floor(Date.now()/1e3),riptide_uptime:s,data:n};try{this.logger.info({payload:p,sonarUrl:t,nomadJobId:i},"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(p)});if(!c.ok){let l=await c.text();this.logger.warn({status:c.status,error:l,entity_id:i},"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:i},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new w(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 h=u(require("fs/promises")),d=u(require("path"));var E=class{constructor(e){this.logger=e}static{a(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:r=".",template:i="basic",description:s}=e,n=d.join(r,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${i}`),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,i),await this.createDockerfile(n,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,n)}async createDirectoryStructure(e){await h.mkdir(d.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,r){let i=await this.checkIfInWorkspace(e),s=i?"workspace:*":await this.getRiptideVersion(),n=i?`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} .`,p={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":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 h.writeFile(d.join(e,"package.json"),JSON.stringify(p,null,2))}async createRiptideConfig(e,t,r){let i={service:{name:t,version:"1.0.0",description:r||`${t} service`},logging:{level:"info"}};await h.writeFile(d.join(e,"riptide.config.json"),JSON.stringify(i,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(d.join(e,"tsconfig.json"),JSON.stringify(r,null,2))}async createTsupConfig(e){await h.writeFile(d.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
7
|
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
entry: ['src/hooks.ts'],
|
|
@@ -14,7 +14,7 @@ export default defineConfig({
|
|
|
14
14
|
minify: false,
|
|
15
15
|
sourcemap: true
|
|
16
16
|
})
|
|
17
|
-
`)}async createHooks(e,t){let r="";switch(t){case"with-secrets":r=this.getHooksWithSecrets();break;case"with-process":r=this.getHooksWithProcess();break;case"with-metrics":r=this.getHooksWithMetrics();break;default:r=this.getBasicHooks()}await h.writeFile(
|
|
17
|
+
`)}async createHooks(e,t){let r="";switch(t){case"with-secrets":r=this.getHooksWithSecrets();break;case"with-process":r=this.getHooksWithProcess();break;case"with-metrics":r=this.getHooksWithMetrics();break;default:r=this.getBasicHooks()}await h.writeFile(d.join(e,"src","hooks.ts"),r)}getBasicHooks(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
18
18
|
|
|
19
19
|
module.exports = {
|
|
20
20
|
installSecrets: async ({ logger }: HookContext) => {
|
|
@@ -241,7 +241,7 @@ module.exports = {
|
|
|
241
241
|
logger.info('Service stopping')
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
-
`}async createDockerfile(e,t){let
|
|
244
|
+
`}async createDockerfile(e,t){let i=await this.checkIfInWorkspace(e)?`# DeEEP Network Service for ${t}
|
|
245
245
|
|
|
246
246
|
# ----------------------------------------
|
|
247
247
|
# Base
|
|
@@ -366,7 +366,7 @@ USER riptide
|
|
|
366
366
|
ENV NODE_ENV=production
|
|
367
367
|
ENTRYPOINT ["/usr/local/bin/riptide"]
|
|
368
368
|
CMD ["start", "--config", "/riptide/riptide.config.json", "--hooks", "/riptide/dist/hooks.js"]
|
|
369
|
-
`;await h.writeFile(
|
|
369
|
+
`;await h.writeFile(d.join(e,"Dockerfile"),i)}showNextSteps(e,t){console.log(`
|
|
370
370
|
Next steps:
|
|
371
371
|
-----------
|
|
372
372
|
1. Navigate to your service:
|
|
@@ -396,7 +396,7 @@ Next steps:
|
|
|
396
396
|
|
|
397
397
|
NOTE: Riptide is a node application. If your base image does
|
|
398
398
|
not have node installed (v22+), install it in the Dockerfile.
|
|
399
|
-
`)}async checkIfInWorkspace(e){let t=
|
|
399
|
+
`)}async checkIfInWorkspace(e){let t=d.resolve(e),r=d.parse(t).root;for(;t!==r;){try{return await h.access(d.join(t,"pnpm-workspace.yaml")),!0}catch{}t=d.dirname(t)}return!1}async getRiptideVersion(){try{let e=[d.join(__dirname,"..","package.json"),d.join(__dirname,"..","..","package.json"),d.join(__dirname,"..","..","..","@deeep-network","riptide","package.json"),d.join(__dirname,"..","..","node_modules","@deeep-network","riptide","package.json")];for(let t of e)try{let r=await h.readFile(t,"utf-8"),i=JSON.parse(r);if(i.name==="@deeep-network/riptide")return`^${i.version}`}catch{continue}try{return`^${require("@deeep-network/riptide/package.json").version}`}catch{}return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}catch{return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}}};async function D(o,e,t={}){await new E(o).scaffold({serviceName:e,...t})}a(D,"initService");var g=(0,N.default)({level:process.env.LOG_LEVEL||"info",transport:{target:"pino-pretty"}});async function q(){try{let o=process.argv[2];if(o==="--help"||o==="-h"||o==="help"){$();return}if(o==="--version"||o==="-v"||o==="version"){await K();return}let e=v("--config")||v("-c")||"./riptide.config.json",t=v("--hooks")||v("-h")||"./hooks.js";switch(o){case"init":await X();break;case"start":await j(e,t);break;case"validate":await G(e,t);break;case"health":await B();break;case"status":await z();break;case"verify":break;default:o?(g.error(`Unknown command: ${o}`),$(),process.exit(1)):await j(e,t)}}catch(o){g.error({error:o},"CLI command failed"),process.exit(1)}}a(q,"main");function v(o){let e=process.argv.indexOf(o);if(e>=0&&e+1<process.argv.length)return process.argv[e+1]}a(v,"getArgValue");function $(){console.log(`
|
|
400
400
|
Riptide - Self-contained service lifecycle management
|
|
401
401
|
|
|
402
402
|
USAGE:
|
|
@@ -427,4 +427,4 @@ EXAMPLES:
|
|
|
427
427
|
npx riptide start # Start service
|
|
428
428
|
npx riptide validate # Validate config and hooks
|
|
429
429
|
npx riptide health # Check service health
|
|
430
|
-
`)}
|
|
430
|
+
`)}a($,"showHelp");async function K(){try{let o=await import("fs/promises"),e=await import("path"),t=[e.resolve(__dirname,"../package.json"),e.resolve(__dirname,"./package.json"),"/riptide-runtime/package.json",e.resolve(process.cwd(),"package.json")];for(let r of t)try{let i=await o.readFile(r,"utf-8"),s=JSON.parse(i);if(s.name==="@deeep-network/riptide"){console.log(`@deeep-network/riptide v${s.version}`);return}}catch{continue}console.log("@deeep-network/riptide (version unknown)")}catch{console.log("@deeep-network/riptide (version unknown)")}}a(K,"showVersion");async function j(o,e){try{let t=await import("fs/promises"),r=await import("path"),i=await t.readFile(o,"utf-8"),s=JSON.parse(i),p=await import(r.resolve(process.cwd(),e)),c=p.default||p;await new x(c,s,{}).start()}catch(t){g.error({error:t},"Failed to start service"),process.exit(1)}}a(j,"startService");async function G(o,e){g.info("Validating riptide configuration and hooks...");try{let t=await import("fs/promises"),r=await import("path"),i=await t.readFile(o,"utf-8"),s=JSON.parse(i);g.info({config:s},"Configuration loaded successfully");let p=await import(r.resolve(process.cwd(),e)),c=p.default||p;g.info({availableHooks:Object.keys(c)},"Hooks loaded successfully"),g.info("\u2705 Validation completed successfully"),process.exit(0)}catch(t){g.error({error:t},"\u274C Validation failed"),process.exit(1)}}a(G,"validateService");async function B(){try{let e=await(await fetch("http://localhost:3000/health")).json();console.log("Health Status:",JSON.stringify(e,null,2)),e.status==="healthy"?process.exit(0):process.exit(1)}catch(o){g.error({error:o},"Failed to check health"),process.exit(1)}}a(B,"checkHealth");async function z(){try{let e=await(await fetch("http://localhost:3000/status")).json();console.log("Service Status:",JSON.stringify(e,null,2))}catch(o){g.error({error:o},"Failed to get status"),process.exit(1)}}a(z,"showStatus");async function X(){let o=process.argv[3];o||(g.error("Service name is required"),console.log("Usage: npx riptide init <service-name>"),process.exit(1)),/^[a-z0-9-]+$/.test(o)||(g.error("Service name must contain only lowercase letters, numbers, and hyphens"),process.exit(1));let e=v("--template")||"basic",t=v("--path")||".",r=v("--description"),i=["basic","with-secrets","with-process","with-metrics"];i.includes(e)||(g.error(`Invalid template: ${e}`),console.log(`Valid templates: ${i.join(", ")}`),process.exit(1));try{await D(g,o,{targetPath:t,template:e,description:r}),process.exit(0)}catch(s){g.error({error:s},"Failed to initialize service"),process.exit(1)}}a(X,"initServiceCommand");q();
|
package/dist/index.d.ts
CHANGED
|
@@ -37,6 +37,38 @@ interface WriteFileOptions {
|
|
|
37
37
|
mode?: string;
|
|
38
38
|
encoding?: BufferEncoding;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Workload condition enum - indicates sub-issues that wouldn't fail health check
|
|
42
|
+
* This is separate from the health check which determines container liveness.
|
|
43
|
+
* Use this to surface degradation or unknown states to the user.
|
|
44
|
+
*/
|
|
45
|
+
declare enum WorkloadCondition {
|
|
46
|
+
Normal = "normal",
|
|
47
|
+
Degraded = "degraded",
|
|
48
|
+
Unknown = "unknown"
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Echo hook return type
|
|
52
|
+
* - condition: Optional workload condition (defaults to Normal if not provided)
|
|
53
|
+
* - message: Optional user-facing message (e.g., "Minimum stake not met")
|
|
54
|
+
* - Additional workload-specific fields are allowed via index signature
|
|
55
|
+
*/
|
|
56
|
+
interface EchoResult {
|
|
57
|
+
condition?: WorkloadCondition;
|
|
58
|
+
message?: string;
|
|
59
|
+
[key: string]: unknown;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Heartbeat hook return type
|
|
63
|
+
* - uptime: Optional workload-specific uptime in seconds
|
|
64
|
+
* - Additional workload-specific fields are allowed via index signature
|
|
65
|
+
*
|
|
66
|
+
* Riptide wraps the entire HeartbeatResult in 'metadata' when sending to Sonar.
|
|
67
|
+
*/
|
|
68
|
+
interface HeartbeatResult {
|
|
69
|
+
uptime?: number;
|
|
70
|
+
[key: string]: unknown;
|
|
71
|
+
}
|
|
40
72
|
interface HookModule {
|
|
41
73
|
installSecrets?: (context: HookContext) => Promise<{
|
|
42
74
|
success: boolean;
|
|
@@ -45,8 +77,9 @@ interface HookModule {
|
|
|
45
77
|
start: (context: HookContext) => Promise<void>;
|
|
46
78
|
stop?: (context: HookContext) => Promise<void>;
|
|
47
79
|
health: (context: HookContext) => Promise<boolean>;
|
|
48
|
-
heartbeat?: (context: HookContext) => Promise<
|
|
80
|
+
heartbeat?: (context: HookContext) => Promise<HeartbeatResult | null>;
|
|
49
81
|
update?: (context: HookContext) => Promise<void>;
|
|
82
|
+
echo?: (context: HookContext) => Promise<EchoResult>;
|
|
50
83
|
status?: (context: HookContext) => Promise<ServiceStatus>;
|
|
51
84
|
metrics?: (context: HookContext) => Promise<ServiceMetrics>;
|
|
52
85
|
}
|
|
@@ -63,6 +96,10 @@ interface ServiceConfig {
|
|
|
63
96
|
interval?: number;
|
|
64
97
|
enabled?: boolean;
|
|
65
98
|
};
|
|
99
|
+
echo?: {
|
|
100
|
+
interval?: number;
|
|
101
|
+
enabled?: boolean;
|
|
102
|
+
};
|
|
66
103
|
update?: {
|
|
67
104
|
interval?: number;
|
|
68
105
|
enabled?: boolean;
|
|
@@ -95,8 +132,12 @@ declare class RiptideEntrypoint {
|
|
|
95
132
|
private isShuttingDown;
|
|
96
133
|
private status;
|
|
97
134
|
private startTime;
|
|
135
|
+
private riptideStartTime;
|
|
98
136
|
private heartbeatInterval?;
|
|
99
137
|
private updateInterval?;
|
|
138
|
+
private echoInterval?;
|
|
139
|
+
private lastEchoHash;
|
|
140
|
+
private isEchoExecuting;
|
|
100
141
|
private webServer?;
|
|
101
142
|
constructor(hooks: HookModule, config: ServiceConfig, options?: RiptideEntrypointOptions);
|
|
102
143
|
start(): Promise<void>;
|
|
@@ -108,6 +149,8 @@ declare class RiptideEntrypoint {
|
|
|
108
149
|
private setupSignalHandlers;
|
|
109
150
|
private startHeartbeat;
|
|
110
151
|
private startUpdate;
|
|
152
|
+
private startEcho;
|
|
153
|
+
private sendEcho;
|
|
111
154
|
private pingSonar;
|
|
112
155
|
private startWebServer;
|
|
113
156
|
private waitForShutdown;
|
|
@@ -204,6 +247,16 @@ declare class AlreadyRunningError extends RiptideError {
|
|
|
204
247
|
readonly exitCode = 6;
|
|
205
248
|
constructor(message: string);
|
|
206
249
|
}
|
|
250
|
+
/**
|
|
251
|
+
* Error indicating that user configuration/action is needed before the service can run
|
|
252
|
+
* This is a terminal state - the container should exit and not retry.
|
|
253
|
+
* Examples: insufficient stake, missing delegation, account not registered
|
|
254
|
+
* Exit code: 7
|
|
255
|
+
*/
|
|
256
|
+
declare class UserConfigNeededError extends RiptideError {
|
|
257
|
+
readonly exitCode = 7;
|
|
258
|
+
constructor(message: string);
|
|
259
|
+
}
|
|
207
260
|
/**
|
|
208
261
|
* Type guards for Riptide errors that work across module boundaries
|
|
209
262
|
*/
|
|
@@ -212,5 +265,6 @@ declare function isDiagnoseRequiredError(error: unknown): error is DiagnoseRequi
|
|
|
212
265
|
declare function isMissingSecretError(error: unknown): error is MissingSecretError;
|
|
213
266
|
declare function isInvalidSecretError(error: unknown): error is InvalidSecretError;
|
|
214
267
|
declare function isAlreadyRunningError(error: unknown): error is AlreadyRunningError;
|
|
268
|
+
declare function isUserConfigNeededError(error: unknown): error is UserConfigNeededError;
|
|
215
269
|
|
|
216
|
-
export { AlreadyRunningError, DiagnoseRequiredError, type ExecOptions, type ExecResult, type HookContext, type HookModule, InvalidSecretError, MissingSecretError, type RetryOptions, RiptideEntrypoint, type RiptideEntrypointOptions, RiptideError, type ScaffoldOptions, type ServiceConfig, type ServiceMetrics, ServiceScaffolder, type ServiceStatus, type UtilityContext, type WriteFileOptions, createChildLogger, createLogger, createUtilityContext, expandEnvironmentVariables, getDefaultConfig, initService, isAlreadyRunningError, isDiagnoseRequiredError, isInvalidSecretError, isMissingSecretError, isRiptideError, loadConfig, loadHooks, mergeConfigs, parseEnvironmentVariables, redactSecret, validateConfig };
|
|
270
|
+
export { AlreadyRunningError, DiagnoseRequiredError, type EchoResult, type ExecOptions, type ExecResult, type HeartbeatResult, type HookContext, type HookModule, InvalidSecretError, MissingSecretError, type RetryOptions, RiptideEntrypoint, type RiptideEntrypointOptions, RiptideError, type ScaffoldOptions, type ServiceConfig, type ServiceMetrics, ServiceScaffolder, type ServiceStatus, UserConfigNeededError, type UtilityContext, WorkloadCondition, type WriteFileOptions, createChildLogger, createLogger, createUtilityContext, expandEnvironmentVariables, getDefaultConfig, initService, isAlreadyRunningError, isDiagnoseRequiredError, isInvalidSecretError, isMissingSecretError, isRiptideError, isUserConfigNeededError, loadConfig, loadHooks, mergeConfigs, parseEnvironmentVariables, redactSecret, validateConfig };
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"use strict";var
|
|
1
|
+
"use strict";var re=Object.create;var v=Object.defineProperty;var oe=Object.getOwnPropertyDescriptor;var ie=Object.getOwnPropertyNames;var se=Object.getPrototypeOf,ne=Object.prototype.hasOwnProperty;var a=(r,e)=>v(r,"name",{value:e,configurable:!0});var ae=(r,e)=>{for(var t in e)v(r,t,{get:e[t],enumerable:!0})},N=(r,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of ie(e))!ne.call(r,i)&&i!==t&&v(r,i,{get:()=>e[i],enumerable:!(o=oe(e,i))||o.enumerable});return r};var m=(r,e,t)=>(t=r!=null?re(se(r)):{},N(e||!r||!r.__esModule?v(t,"default",{value:r,enumerable:!0}):t,r)),ce=r=>N(v({},"__esModule",{value:!0}),r);var ue={};ae(ue,{AlreadyRunningError:()=>E,DiagnoseRequiredError:()=>x,InvalidSecretError:()=>S,MissingSecretError:()=>w,RiptideEntrypoint:()=>$,RiptideError:()=>h,ServiceScaffolder:()=>k,UserConfigNeededError:()=>b,WorkloadCondition:()=>R,createChildLogger:()=>L,createLogger:()=>P,createUtilityContext:()=>O,expandEnvironmentVariables:()=>H,getDefaultConfig:()=>Q,initService:()=>ee,isAlreadyRunningError:()=>U,isDiagnoseRequiredError:()=>_,isInvalidSecretError:()=>A,isMissingSecretError:()=>j,isRiptideError:()=>y,isUserConfigNeededError:()=>F,loadConfig:()=>z,loadHooks:()=>X,mergeConfigs:()=>Z,parseEnvironmentVariables:()=>q,redactSecret:()=>Y,validateConfig:()=>D});module.exports=ce(ue);var K=require("crypto");var h=class r extends Error{static{a(this,"RiptideError")}constructor(e){super(e),Object.setPrototypeOf(this,r.prototype)}},x=class r extends h{static{a(this,"DiagnoseRequiredError")}exitCode=3;constructor(e){super(e),this.name="DiagnoseRequiredError",Object.setPrototypeOf(this,r.prototype)}},w=class r extends h{static{a(this,"MissingSecretError")}exitCode=4;constructor(e){super(e),this.name="MissingSecretError",Object.setPrototypeOf(this,r.prototype)}},S=class r extends h{static{a(this,"InvalidSecretError")}exitCode=5;constructor(e){super(e),this.name="InvalidSecretError",Object.setPrototypeOf(this,r.prototype)}},E=class r extends h{static{a(this,"AlreadyRunningError")}exitCode=6;constructor(e){super(e),this.name="AlreadyRunningError",Object.setPrototypeOf(this,r.prototype)}},b=class r extends h{static{a(this,"UserConfigNeededError")}exitCode=7;constructor(e){super(e),this.name="UserConfigNeededError",Object.setPrototypeOf(this,r.prototype)}};function y(r){return r instanceof Error&&"exitCode"in r&&typeof r.exitCode=="number"}a(y,"isRiptideError");function _(r){return r instanceof Error&&r.name==="DiagnoseRequiredError"&&r.exitCode===3}a(_,"isDiagnoseRequiredError");function j(r){return r instanceof Error&&r.name==="MissingSecretError"&&r.exitCode===4}a(j,"isMissingSecretError");function A(r){return r instanceof Error&&r.name==="InvalidSecretError"&&r.exitCode===5}a(A,"isInvalidSecretError");function U(r){return r instanceof Error&&r.name==="AlreadyRunningError"&&r.exitCode===6}a(U,"isAlreadyRunningError");function F(r){return r instanceof Error&&r.name==="UserConfigNeededError"&&r.exitCode===7}a(F,"isUserConfigNeededError");var C=m(require("pino"));function P(r){let{level:e="info",format:t="pretty",serviceName:o}=r,i={level:e,timestamp:C.default.stdTimeFunctions.isoTime,formatters:{log:a(s=>({service:o,...s}),"log"),bindings:a(s=>{let{pid:n,hostname:p,...c}=s;return c},"bindings")}};return t==="pretty"&&!process.env.NODE_ENV?.includes("prod")?(0,C.default)({...i,transport:{target:"pino-pretty",options:{colorize:!0,translateTime:"SYS:standard",ignore:"pid,hostname"}}}):(0,C.default)(i)}a(P,"createLogger");function L(r,e){return r.child(e)}a(L,"createChildLogger");var R=(o=>(o.Normal="normal",o.Degraded="degraded",o.Unknown="unknown",o))(R||{});var J=require("child_process"),u=require("fs"),pe=m(require("http")),le=m(require("https")),T=m(require("path")),W=require("util");var de=(0,W.promisify)(J.exec);function O(){return{sleep:a(r=>new Promise(e=>setTimeout(e,r)),"sleep"),retry:a(async(r,e={})=>{let{maxAttempts:t=3,delay:o=1e3,backoffMultiplier:i=2,maxDelay:s=3e4}=e,n,p=o;for(let c=1;c<=t;c++)try{return await r()}catch(l){if(n=l instanceof Error?l:new Error(String(l)),c===t)throw n;await new Promise(f=>setTimeout(f,Math.min(p,s))),p*=i}throw n},"retry"),execCommand:a(async(r,e={})=>{let{timeout:t=3e4,cwd:o=process.cwd(),env:i=process.env}=e;try{let{stdout:s,stderr:n}=await de(r,{timeout:t,cwd:o,env:{...process.env,...i}});return{stdout:s.trim(),stderr:n.trim(),exitCode:0}}catch(s){if(s.killed&&s.signal==="SIGTERM"){let p=`Command timed out after ${t/1e3}s. Consider increasing timeout if command needs more time to complete: utils.execCommand('${r}', { timeout: ${t*2} })`,c=s.stderr?.trim()||"",l=c?`${c}
|
|
2
2
|
|
|
3
|
-
${
|
|
3
|
+
${p}`:p,f=[];s.stdout?.trim()&&f.push(`STDOUT: ${s.stdout.trim()}`),l&&f.push(`STDERR: ${l}`);let te=[`Command execution timed out: ${r}`,...f].join(`
|
|
4
4
|
|
|
5
|
-
`),
|
|
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'
|
|
5
|
+
`),M=new Error(te);throw M.name="CommandTimeoutError",M}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:a(async(r,e,t={})=>{let{mode:o="0644",encoding:i="utf8"}=t;await u.promises.mkdir(T.dirname(r),{recursive:!0}),await u.promises.writeFile(r,e,{encoding:i}),await u.promises.chmod(r,o)},"writeFile"),readFile:a(async r=>await u.promises.readFile(r,"utf8"),"readFile"),fileExists:a(async r=>{try{return await u.promises.access(r),!0}catch{return!1}},"fileExists"),downloadFile:a(async function r(e,t){return new Promise((o,i)=>{let s=T.dirname(t);u.promises.mkdir(s,{recursive:!0}).then(()=>{let n=(0,u.createWriteStream)(t);(e.startsWith("https:")?le:pe).get(e,l=>{if(l.statusCode===200)l.pipe(n),n.on("finish",()=>{n.close(),o(t)});else if(l.statusCode===301||l.statusCode===302){let f=l.headers.location;f?o(r(f,t)):i(new Error(`Redirect without location header: ${l.statusCode}`))}else i(new Error(`Failed to download: ${l.statusCode} ${l.statusMessage}`))}).on("error",l=>{u.promises.unlink(t).catch(()=>{}),i(l)}),n.on("error",l=>{u.promises.unlink(t).catch(()=>{}),i(l)})}).catch(i)})},"downloadFile")}}a(O,"createUtilityContext");function Y(r){return!r||r.length<=10?"[REDACTED]":`${r.slice(0,4)}...${r.slice(-4)}`}a(Y,"redactSecret");function q(r){let e={},t=r.split(`
|
|
6
|
+
`);for(let o of t){let i=o.trim();if(i&&!i.startsWith("#")){let[s,...n]=i.split("=");s&&n.length>0&&(e[s.trim()]=n.join("=").trim())}}return e}a(q,"parseEnvironmentVariables");function H(r,e=process.env){return r.replace(/\$\{([^}]+)\}/g,(t,o)=>e[o]||t)}a(H,"expandEnvironmentVariables");var V=require("http");var I=class{constructor(e,t,o,i,s){this.getStatus=o;this.executeHealthCheck=i;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,V.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 o=await this.executeHealthCheck(),i=this.getStatus(),s={healthy:o,status:i.status,uptime:i.uptime,message:i.message};o?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(o){this.logger.error({error:o},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:o instanceof Error?o.message:"Internal server error"}))}else if(e.url==="/metrics")try{let o=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(o)}catch(o){this.logger.error({error:o},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:o instanceof Error?o.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",o=>{this.logger.error({error:o,port:this.port},"Failed to start web server"),t(o)}),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 $=class{static{a(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;riptideStartTime;heartbeatInterval;updateInterval;echoInterval;lastEchoHash=null;isEchoExecuting=!1;webServer;constructor(e,t,o={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.riptideStartTime=Date.now(),this.logger=P({serviceName:t.service.name,level:process.env.RIPTIDE_LOG_LEVEL||o.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.startEcho(),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.debug(`Executing ${e} hook`);let o=this.createHookContext(),i=Date.now();try{let s=await t(o),n=Date.now()-i;return this.logger.debug(`${e} hook completed (${n}ms)`),s}catch(s){let n=Date.now()-i;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(o){y(o)&&(this.logger.error({hookName:e,error:o.message,exitCode:o.exitCode},`${e} hook threw ${o.name}, exiting with code ${o.exitCode}`),process.exit(o.exitCode)),this.logger.error({hookName:e,error:o instanceof Error?o.message:String(o),stack:o instanceof Error?o.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:O()}}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.echoInterval&&clearInterval(this.echoInterval),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(o){this.logger.error({error:o instanceof Error?o.message:String(o)},"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 o=await this.executeHookSafely("heartbeat");if(o===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat"),t=!1;return}try{await this.pingSonar(o)}catch(i){this.logger.warn({error:i instanceof Error?i.message:String(i)},"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)}startEcho(){if(!this.hooks.echo){this.logger.info("No echo hook defined, skipping echo");return}if(this.config.echo?.enabled===!1){this.logger.info("Echo disabled in config, skipping...");return}let e=this.config.echo?.interval||3e3;this.logger.info({interval:e},"Starting echo"),this.echoInterval=setInterval(async()=>{if(this.isEchoExecuting){this.logger.debug("Echo still executing, skipping this interval");return}this.isEchoExecuting=!0;try{let t=await this.executeHookSafely("echo");if(!t)return;let o=JSON.stringify(t),i=(0,K.createHash)("sha256").update(o).digest("hex");i!==this.lastEchoHash&&(this.lastEchoHash=i,await this.sendEcho(t))}catch(t){this.logger.warn({error:t instanceof Error?t.message:String(t)},"Echo failed")}finally{this.isEchoExecuting=!1}},e)}async sendEcho(e){let t=process.env.SONAR_API_URL,o=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!o||!i){this.logger.error({hasUrl:!!t,hasKey:!!o,hasJobId:!!i},"Sonar API configuration incomplete, skipping echo send");return}let s={...e,condition:e.condition??"normal"},n={entity_id:i,client_timestamp:Math.floor(Date.now()/1e3),data:s};try{this.logger.info({payload:n,sonarUrl:t},"Sending echo to Sonar API");let p=await fetch(`${t}/api/v1/echo`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify(n)});if(!p.ok){let c=await p.text();this.logger.warn({status:p.status,error:c,entity_id:i},"Sonar API echo failed")}}catch(p){this.logger.warn({error:p instanceof Error?p.message:String(p)},"Failed to send echo to Sonar API, dropping")}}async pingSonar(e){let t=process.env.SONAR_API_URL,o=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!o||!i){this.logger.error({hasUrl:!!t,hasKey:!!o,hasJobId:!!i},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor((Date.now()-this.riptideStartTime)/1e3),n={...e},p={entity_id:i,client_timestamp:Math.floor(Date.now()/1e3),riptide_uptime:s,data:n};try{this.logger.info({payload:p,sonarUrl:t,nomadJobId:i},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify(p)});if(!c.ok){let l=await c.text();this.logger.warn({status:c.status,error:l,entity_id:i},"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:i},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new I(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 G=require("fs"),B=m(require("path"));async function z(r){try{let e=await G.promises.readFile(r,"utf8"),t=JSON.parse(e),o=ge(t);return D(o),o}catch(e){throw new Error(`Failed to load config from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(z,"loadConfig");async function X(r){try{let e=B.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(X,"loadHooks");function D(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");if(r.echo&&r.echo.interval&&r.echo.interval<1e3)throw new Error("echo.interval must be at least 1000ms")}a(D,"validateConfig");function ge(r,e=process.env){let t=JSON.parse(JSON.stringify(r));function o(i){if(typeof i=="string")return H(i,e);if(Array.isArray(i))return i.map(o);if(i&&typeof i=="object"){let s={};for(let[n,p]of Object.entries(i))s[n]=o(p);return s}return i}return a(o,"expandObject"),o(t)}a(ge,"expandConfigVariables");function Q(){return{health:{port:3e3},heartbeat:{interval:6e4,enabled:!1},echo:{interval:3e3,enabled:!0},logging:{level:"info",format:"pretty"}}}a(Q,"getDefaultConfig");function Z(r,e){function t(i,s){if(s&&typeof s=="object"&&!Array.isArray(s))for(let n in s)s.hasOwnProperty(n)&&(i[n]&&typeof i[n]=="object"&&!Array.isArray(i[n])?i[n]=t(i[n],s[n]):i[n]=s[n]);return i}a(t,"deepMerge");let o=t({...r},e);return D(o),o}a(Z,"mergeConfigs");var g=m(require("fs/promises")),d=m(require("path"));var k=class{constructor(e){this.logger=e}static{a(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:o=".",template:i="basic",description:s}=e,n=d.join(o,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${i}`),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,i),await this.createDockerfile(n,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,n)}async createDirectoryStructure(e){await g.mkdir(d.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,o){let i=await this.checkIfInWorkspace(e),s=i?"workspace:*":await this.getRiptideVersion(),n=i?`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} .`,p={name:`reef-${t}`,version:"1.0.0",description:o||`${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 g.writeFile(d.join(e,"package.json"),JSON.stringify(p,null,2))}async createRiptideConfig(e,t,o){let i={service:{name:t,version:"1.0.0",description:o||`${t} service`},logging:{level:"info"}};await g.writeFile(d.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let o=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 g.writeFile(d.join(e,"tsconfig.json"),JSON.stringify(o,null,2))}async createTsupConfig(e){await g.writeFile(d.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
7
|
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
entry: ['src/hooks.ts'],
|
|
@@ -14,7 +14,7 @@ export default defineConfig({
|
|
|
14
14
|
minify: false,
|
|
15
15
|
sourcemap: true
|
|
16
16
|
})
|
|
17
|
-
`)}async createHooks(e,t){let
|
|
17
|
+
`)}async createHooks(e,t){let o="";switch(t){case"with-secrets":o=this.getHooksWithSecrets();break;case"with-process":o=this.getHooksWithProcess();break;case"with-metrics":o=this.getHooksWithMetrics();break;default:o=this.getBasicHooks()}await g.writeFile(d.join(e,"src","hooks.ts"),o)}getBasicHooks(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
18
18
|
|
|
19
19
|
module.exports = {
|
|
20
20
|
installSecrets: async ({ logger }: HookContext) => {
|
|
@@ -241,7 +241,7 @@ module.exports = {
|
|
|
241
241
|
logger.info('Service stopping')
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
|
-
`}async createDockerfile(e,t){let
|
|
244
|
+
`}async createDockerfile(e,t){let i=await this.checkIfInWorkspace(e)?`# DeEEP Network Service for ${t}
|
|
245
245
|
|
|
246
246
|
# ----------------------------------------
|
|
247
247
|
# Base
|
|
@@ -366,7 +366,7 @@ USER riptide
|
|
|
366
366
|
ENV NODE_ENV=production
|
|
367
367
|
ENTRYPOINT ["/usr/local/bin/riptide"]
|
|
368
368
|
CMD ["start", "--config", "/riptide/riptide.config.json", "--hooks", "/riptide/dist/hooks.js"]
|
|
369
|
-
`;await
|
|
369
|
+
`;await g.writeFile(d.join(e,"Dockerfile"),i)}showNextSteps(e,t){console.log(`
|
|
370
370
|
Next steps:
|
|
371
371
|
-----------
|
|
372
372
|
1. Navigate to your service:
|
|
@@ -396,4 +396,4 @@ Next steps:
|
|
|
396
396
|
|
|
397
397
|
NOTE: Riptide is a node application. If your base image does
|
|
398
398
|
not have node installed (v22+), install it in the Dockerfile.
|
|
399
|
-
`)}async checkIfInWorkspace(e){let t=
|
|
399
|
+
`)}async checkIfInWorkspace(e){let t=d.resolve(e),o=d.parse(t).root;for(;t!==o;){try{return await g.access(d.join(t,"pnpm-workspace.yaml")),!0}catch{}t=d.dirname(t)}return!1}async getRiptideVersion(){try{let e=[d.join(__dirname,"..","package.json"),d.join(__dirname,"..","..","package.json"),d.join(__dirname,"..","..","..","@deeep-network","riptide","package.json"),d.join(__dirname,"..","..","node_modules","@deeep-network","riptide","package.json")];for(let t of e)try{let o=await g.readFile(t,"utf-8"),i=JSON.parse(o);if(i.name==="@deeep-network/riptide")return`^${i.version}`}catch{continue}try{return`^${require("@deeep-network/riptide/package.json").version}`}catch{}return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}catch{return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}}};async function ee(r,e,t={}){await new k(r).scaffold({serviceName:e,...t})}a(ee,"initService");0&&(module.exports={AlreadyRunningError,DiagnoseRequiredError,InvalidSecretError,MissingSecretError,RiptideEntrypoint,RiptideError,ServiceScaffolder,UserConfigNeededError,WorkloadCondition,createChildLogger,createLogger,createUtilityContext,expandEnvironmentVariables,getDefaultConfig,initService,isAlreadyRunningError,isDiagnoseRequiredError,isInvalidSecretError,isMissingSecretError,isRiptideError,isUserConfigNeededError,loadConfig,loadHooks,mergeConfigs,parseEnvironmentVariables,redactSecret,validateConfig});
|