@saws/hono-service 2.0.0-beta.3
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/package.json +19 -0
- package/src/HonoService.ts +357 -0
- package/src/index.ts +1 -0
- package/tsconfig.json +8 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@saws/hono-service",
|
|
3
|
+
"version": "2.0.0-beta.3",
|
|
4
|
+
"description": "",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"author": "",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@saws/core": "2.0.0-beta.3",
|
|
17
|
+
"@saws/docker-service": "2.0.0-beta.3"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { ServiceDefinition, type Host } from "@saws/core";
|
|
5
|
+
import { installDependencies } from "@saws/core/utils/dependency-management";
|
|
6
|
+
import { fileExists } from "@saws/core/utils/file-exists";
|
|
7
|
+
import { shellQuote } from "@saws/core/utils/shell-quote";
|
|
8
|
+
import {
|
|
9
|
+
DockerService,
|
|
10
|
+
type DockerRunConfig,
|
|
11
|
+
type DockerServiceConfig,
|
|
12
|
+
} from "@saws/docker-service";
|
|
13
|
+
|
|
14
|
+
export interface HonoServiceConfig extends Omit<
|
|
15
|
+
DockerServiceConfig,
|
|
16
|
+
"image" | "dockerfile" | "buildContext" | "ports" | "command" | "healthCheck"
|
|
17
|
+
> {
|
|
18
|
+
/** Port the Hono app listens on. Defaults to PORT or 3000. */
|
|
19
|
+
port?: number;
|
|
20
|
+
/** Public host name routed by Traefik during remote deploys. */
|
|
21
|
+
domain?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class HonoService extends DockerService {
|
|
25
|
+
readonly port?: number;
|
|
26
|
+
readonly domain?: string;
|
|
27
|
+
protected override readonly serviceType = "hono";
|
|
28
|
+
private honoDevProcess?: ChildProcess;
|
|
29
|
+
|
|
30
|
+
constructor(config: HonoServiceConfig) {
|
|
31
|
+
super({
|
|
32
|
+
...config,
|
|
33
|
+
dockerfile: path.join(config.name, "Dockerfile"),
|
|
34
|
+
buildContext: config.name,
|
|
35
|
+
healthCheck: {
|
|
36
|
+
command:
|
|
37
|
+
"node -e \"fetch('http://localhost:${PORT:-3000}/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))\"",
|
|
38
|
+
interval: "5s",
|
|
39
|
+
timeout: "2s",
|
|
40
|
+
retries: 12,
|
|
41
|
+
startPeriod: "5s",
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
this.port = config.port;
|
|
45
|
+
this.domain = config.domain;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
override async init() {
|
|
49
|
+
await super.init();
|
|
50
|
+
await mkdir(path.resolve(this.name, "src"), { recursive: true });
|
|
51
|
+
await writeFileIfMissing(
|
|
52
|
+
path.resolve(this.name, "package.json"),
|
|
53
|
+
JSON.stringify({ type: "module" }, null, 2) + "\n",
|
|
54
|
+
);
|
|
55
|
+
await writeFileIfMissing(
|
|
56
|
+
path.resolve(this.name, "tsconfig.json"),
|
|
57
|
+
JSON.stringify(
|
|
58
|
+
{
|
|
59
|
+
extends: "@tsconfig/node26/tsconfig.json",
|
|
60
|
+
compilerOptions: {
|
|
61
|
+
composite: true,
|
|
62
|
+
outDir: "./dist",
|
|
63
|
+
rootDir: "./src",
|
|
64
|
+
types: ["node"],
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
null,
|
|
68
|
+
2,
|
|
69
|
+
) + "\n",
|
|
70
|
+
);
|
|
71
|
+
await writeFileIfMissing(path.resolve(this.name, "src", "index.ts"), honoIndexTemplate());
|
|
72
|
+
await writeFileIfMissing(path.resolve(this.name, "Dockerfile"), dockerfileTemplate());
|
|
73
|
+
await addWorkspace(this.name);
|
|
74
|
+
await addTsconfigReference(`./${this.name}/tsconfig.json`);
|
|
75
|
+
await installDependencies(["hono", "@hono/node-server"], {
|
|
76
|
+
workspace: this.name,
|
|
77
|
+
logSink: this.getRuntimeLogSink(),
|
|
78
|
+
serviceName: this.name,
|
|
79
|
+
});
|
|
80
|
+
await installDependencies(["typescript", "tsx", "@tsconfig/node26", "@types/node"], {
|
|
81
|
+
workspace: this.name,
|
|
82
|
+
development: true,
|
|
83
|
+
logSink: this.getRuntimeLogSink(),
|
|
84
|
+
serviceName: this.name,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
override async dev() {
|
|
89
|
+
await ServiceDefinition.prototype.dev.call(this);
|
|
90
|
+
|
|
91
|
+
const port = String(this.getPort());
|
|
92
|
+
const environment = {
|
|
93
|
+
...(await this.getDependenciesEnvironmentVariables("local", "host")),
|
|
94
|
+
...(await this.getStageEnvironmentVariables("local")),
|
|
95
|
+
PORT: port,
|
|
96
|
+
};
|
|
97
|
+
this.writeRuntimeLog(`Start Hono dev server ${this.name} on port ${port}\n`);
|
|
98
|
+
this.honoDevProcess = spawn("npx", ["tsx", "watch", "src/index.ts"], {
|
|
99
|
+
cwd: path.resolve(this.name),
|
|
100
|
+
env: {
|
|
101
|
+
...process.env,
|
|
102
|
+
...environment,
|
|
103
|
+
NODE_ENV: "development",
|
|
104
|
+
},
|
|
105
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
106
|
+
});
|
|
107
|
+
this.observeHonoDevProcess(this.honoDevProcess);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override async deploy(stage: string) {
|
|
111
|
+
await DockerService.prototype.deploy.call(this, stage);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
override exit() {
|
|
115
|
+
super.exit();
|
|
116
|
+
this.honoDevProcess?.kill();
|
|
117
|
+
this.honoDevProcess = undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
protected override async getContainerEnvironment(stage: string): Promise<Record<string, string>> {
|
|
121
|
+
return {
|
|
122
|
+
...(await super.getContainerEnvironment(stage)),
|
|
123
|
+
PORT: String(this.getPort()),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
protected override async getDockerRunConfig(stage: string, deploy: boolean) {
|
|
128
|
+
const config = await super.getDockerRunConfig(stage, deploy);
|
|
129
|
+
const port = this.getPort();
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
...config,
|
|
133
|
+
ports: [`${port}:${port}`],
|
|
134
|
+
} satisfies DockerRunConfig;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private getPort() {
|
|
138
|
+
return this.port ?? Number(process.env["PORT"] ?? 3000);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private traefikRouterName(stage: string) {
|
|
142
|
+
return `${stage}-${this.name}`.replaceAll(/[^a-zA-Z0-9-]/g, "-").toLowerCase();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private traefikServiceName(stage: string) {
|
|
146
|
+
return this.traefikRouterName(stage);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private getTraefikRule() {
|
|
150
|
+
if (this.domain != null) return `Host(\`${this.domain}\`)`;
|
|
151
|
+
return `PathPrefix(\`/\`)`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async installTraefik(stage: string, host: Host) {
|
|
155
|
+
await this.assertRemoteHostReady();
|
|
156
|
+
await this.prepareRemote(stage, this.getNetwork(stage));
|
|
157
|
+
const runTraefik = [
|
|
158
|
+
"docker run -d",
|
|
159
|
+
"--name saws-traefik",
|
|
160
|
+
"--restart unless-stopped",
|
|
161
|
+
`--network ${shellQuote(this.getNetwork(stage))}`,
|
|
162
|
+
"-p 80:80",
|
|
163
|
+
"-v /var/run/docker.sock:/var/run/docker.sock:ro",
|
|
164
|
+
"traefik:v3.6",
|
|
165
|
+
"--providers.docker=true",
|
|
166
|
+
"--providers.docker.exposedbydefault=false",
|
|
167
|
+
"--entrypoints.web.address=:80",
|
|
168
|
+
].join(" ");
|
|
169
|
+
|
|
170
|
+
await host.exec(
|
|
171
|
+
[
|
|
172
|
+
"if ! docker inspect saws-traefik >/dev/null 2>&1; then",
|
|
173
|
+
runTraefik,
|
|
174
|
+
"fi",
|
|
175
|
+
`docker network connect ${shellQuote(this.getNetwork(stage))} saws-traefik >/dev/null 2>&1 || true`,
|
|
176
|
+
].join("\n"),
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async deployBlueGreen(stage: string, config: DockerRunConfig) {
|
|
181
|
+
await this.prepareRemote(stage, config.network);
|
|
182
|
+
await this.host!.exec(`docker pull ${shellQuote(config.image)}`);
|
|
183
|
+
|
|
184
|
+
let environmentFile;
|
|
185
|
+
try {
|
|
186
|
+
environmentFile = await this.writeRemoteEnvironmentFile(stage, config);
|
|
187
|
+
const blueConfig = this.withColor(config, "blue", true);
|
|
188
|
+
const greenConfig = this.withColor(config, "green", true);
|
|
189
|
+
const blueRun = this.getDockerRunCommand(this.withConfigHash(blueConfig, config), true);
|
|
190
|
+
const greenRun = this.getDockerRunCommand(this.withConfigHash(greenConfig, config), true);
|
|
191
|
+
await this.host!.exec(
|
|
192
|
+
[
|
|
193
|
+
`ACTIVE_COLOR=$(docker ps --filter label=saws.service=${shellQuote(this.name)} --filter label=saws.stage=${shellQuote(stage)} --filter label=traefik.enable=true --format '{{.Label "saws.deploymentColor"}}' | head -n 1)`,
|
|
194
|
+
'if [ "$ACTIVE_COLOR" = "blue" ]; then',
|
|
195
|
+
`NEXT_CONTAINER=${shellQuote(greenConfig.name)}`,
|
|
196
|
+
`OLD_CONTAINER=${shellQuote(blueConfig.name)}`,
|
|
197
|
+
`docker rm -f ${shellQuote(greenConfig.name)} >/dev/null 2>&1 || true`,
|
|
198
|
+
greenRun,
|
|
199
|
+
"else",
|
|
200
|
+
`NEXT_CONTAINER=${shellQuote(blueConfig.name)}`,
|
|
201
|
+
`OLD_CONTAINER=${shellQuote(greenConfig.name)}`,
|
|
202
|
+
`docker rm -f ${shellQuote(blueConfig.name)} >/dev/null 2>&1 || true`,
|
|
203
|
+
blueRun,
|
|
204
|
+
"fi",
|
|
205
|
+
this.waitForHealthyScript("$NEXT_CONTAINER"),
|
|
206
|
+
'docker rm -f "$OLD_CONTAINER" >/dev/null 2>&1 || true',
|
|
207
|
+
].join("\n"),
|
|
208
|
+
);
|
|
209
|
+
} finally {
|
|
210
|
+
if (environmentFile != null) {
|
|
211
|
+
await this.removeRemoteRuntimeFile(environmentFile);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private withConfigHash(config: DockerRunConfig, baseConfig: DockerRunConfig) {
|
|
217
|
+
return {
|
|
218
|
+
...config,
|
|
219
|
+
labels: {
|
|
220
|
+
...config.labels,
|
|
221
|
+
"saws.configHash": baseConfig.configHash ?? this.getContainerConfigHash(baseConfig),
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private withColor(config: DockerRunConfig, color: "blue" | "green", enabled: boolean) {
|
|
227
|
+
return {
|
|
228
|
+
...config,
|
|
229
|
+
name: `${config.name}-${color}`,
|
|
230
|
+
labels: {
|
|
231
|
+
...config.labels,
|
|
232
|
+
"saws.deploymentColor": color,
|
|
233
|
+
"traefik.enable": enabled ? "true" : "false",
|
|
234
|
+
},
|
|
235
|
+
} satisfies DockerRunConfig;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private waitForHealthyScript(containerNameExpression: string) {
|
|
239
|
+
return [
|
|
240
|
+
"for i in $(seq 1 60); do",
|
|
241
|
+
`status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}' ${containerNameExpression} 2>/dev/null || true)`,
|
|
242
|
+
'if [ "$status" = "healthy" ] || [ "$status" = "running" ]; then break; fi',
|
|
243
|
+
'if [ "$i" = "60" ]; then',
|
|
244
|
+
`docker logs ${containerNameExpression} --tail 100 || true`,
|
|
245
|
+
'echo "Container did not become healthy"',
|
|
246
|
+
"exit 1",
|
|
247
|
+
"fi",
|
|
248
|
+
"sleep 1",
|
|
249
|
+
"done",
|
|
250
|
+
].join("\n");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private observeHonoDevProcess(process: ChildProcess) {
|
|
254
|
+
process.stdout?.on("data", (chunk: Buffer) => this.writeRuntimeLog(chunk.toString("utf8")));
|
|
255
|
+
process.stderr?.on("data", (chunk: Buffer) =>
|
|
256
|
+
this.writeRuntimeLog(chunk.toString("utf8"), "stderr"),
|
|
257
|
+
);
|
|
258
|
+
process.once("error", (error) => {
|
|
259
|
+
this.writeRuntimeLog(`${error.stack ?? error.message}\n`, "stderr");
|
|
260
|
+
});
|
|
261
|
+
process.once("exit", (code, signal) => {
|
|
262
|
+
if (this.honoDevProcess === process) this.honoDevProcess = undefined;
|
|
263
|
+
if (code !== 0 && signal !== "SIGTERM" && signal !== "SIGINT") {
|
|
264
|
+
this.writeRuntimeLog(
|
|
265
|
+
`Hono dev server exited with code ${code ?? "unknown"}${signal == null ? "" : ` (${signal})`}\n`,
|
|
266
|
+
"stderr",
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function writeFileIfMissing(filePath: string, contents: string) {
|
|
274
|
+
if (await fileExists(filePath)) return;
|
|
275
|
+
await writeFile(filePath, contents);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function addWorkspace(workspace: string) {
|
|
279
|
+
const packageJsonPath = path.resolve("package.json");
|
|
280
|
+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8")) as {
|
|
281
|
+
workspaces?: string[] | { packages?: string[] };
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
if (Array.isArray(packageJson.workspaces)) {
|
|
285
|
+
if (!packageJson.workspaces.includes(workspace)) packageJson.workspaces.push(workspace);
|
|
286
|
+
} else {
|
|
287
|
+
packageJson.workspaces = {
|
|
288
|
+
...packageJson.workspaces,
|
|
289
|
+
packages: [...new Set([...(packageJson.workspaces?.packages ?? []), workspace])],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function addTsconfigReference(reference: string) {
|
|
297
|
+
const tsconfigPath = path.resolve("tsconfig.json");
|
|
298
|
+
const tsconfig = JSON.parse(await readFile(tsconfigPath, "utf8")) as {
|
|
299
|
+
references?: Array<{ path: string }>;
|
|
300
|
+
};
|
|
301
|
+
tsconfig.references = tsconfig.references ?? [];
|
|
302
|
+
if (
|
|
303
|
+
!tsconfig.references.some(
|
|
304
|
+
(entry) => entry.path === reference || entry.path === `./${reference}`,
|
|
305
|
+
)
|
|
306
|
+
) {
|
|
307
|
+
tsconfig.references.push({ path: reference });
|
|
308
|
+
}
|
|
309
|
+
await writeFile(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function honoIndexTemplate() {
|
|
313
|
+
return `import { serve } from "@hono/node-server";
|
|
314
|
+
import { Hono } from "hono";
|
|
315
|
+
|
|
316
|
+
const app = new Hono()
|
|
317
|
+
.get("/health", (c) => c.json({ ok: true }))
|
|
318
|
+
.get("/", (c) => c.text("Hello from Hono"));
|
|
319
|
+
|
|
320
|
+
export type AppType = typeof app;
|
|
321
|
+
|
|
322
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
323
|
+
|
|
324
|
+
serve(
|
|
325
|
+
{
|
|
326
|
+
fetch: app.fetch,
|
|
327
|
+
port,
|
|
328
|
+
},
|
|
329
|
+
(info) => {
|
|
330
|
+
console.log(\`Hono server listening on http://localhost:\${info.port}\`);
|
|
331
|
+
},
|
|
332
|
+
);
|
|
333
|
+
`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function dockerfileTemplate() {
|
|
337
|
+
return `FROM node:26-slim AS build
|
|
338
|
+
WORKDIR /app
|
|
339
|
+
|
|
340
|
+
COPY package.json ./
|
|
341
|
+
RUN npm install
|
|
342
|
+
|
|
343
|
+
COPY . .
|
|
344
|
+
RUN npx --no-install tsc -b
|
|
345
|
+
|
|
346
|
+
FROM node:26-slim AS runtime
|
|
347
|
+
WORKDIR /app
|
|
348
|
+
ENV NODE_ENV=production
|
|
349
|
+
|
|
350
|
+
COPY package.json ./
|
|
351
|
+
RUN npm install --omit=dev
|
|
352
|
+
|
|
353
|
+
COPY --from=build /app/dist ./dist
|
|
354
|
+
|
|
355
|
+
CMD ["node", "dist/index.js"]
|
|
356
|
+
`;
|
|
357
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./HonoService.js";
|