anbaric-cloud-hosting 1.68.1 → 1.68.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.68.1",
3
+ "version": "1.68.2",
4
4
  "description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
5
5
  "license": "MIT",
6
6
  "author": "chris@anbaric.ai",
@@ -18,18 +18,18 @@
18
18
  "@aws-sdk/client-s3": "^3.1110.0",
19
19
  "@aws-sdk/client-secrets-manager": "^3.700.0",
20
20
  "@aws-sdk/client-servicediscovery": "^3.1110.0",
21
- "anbaric-data-store": "^1.68.1",
22
- "anbaric-plugins": "^1.68.1",
23
- "anbaric-tsapi": "^1.68.1",
24
- "anbaric-web": "^1.68.1",
21
+ "anbaric-data-store": "^1.68.2",
22
+ "anbaric-plugins": "^1.68.2",
23
+ "anbaric-tsapi": "^1.68.2",
24
+ "anbaric-web": "^1.68.2",
25
25
  "esbuild": "^0.28.2",
26
26
  "pg": "^8.16.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^26.2.0",
30
30
  "@types/pg": "^8.15.0",
31
- "anbaric-impl-cloud": "^1.68.1",
32
- "anbaric-state-machine": "^1.68.1",
31
+ "anbaric-impl-cloud": "^1.68.2",
32
+ "anbaric-state-machine": "^1.68.2",
33
33
  "tsx": "^4.20.0",
34
34
  "typescript": "^7.0.2"
35
35
  },
@@ -0,0 +1,33 @@
1
+ type Exit = (code : number) => void;
2
+ type Log = (line : string) => void;
3
+
4
+ /* The platform's last words. An error nobody caught ends the process - Node
5
+ gives no choice there - but it should end it saying what happened, in one
6
+ line a log search can find, rather than a stack trace that only a person
7
+ reading the stream would notice. ECS restarts the task either way; this is
8
+ what makes the restart explicable afterwards. */
9
+ class FatalErrors {
10
+
11
+ constructor(private log : Log = line => console.error(line), private exit : Exit = code => process.exit(code)) {}
12
+
13
+ install(target : NodeJS.Process = process) : void {
14
+ target.on("uncaughtException", error => this.die("uncaughtException", error));
15
+ target.on("unhandledRejection", reason => this.die("unhandledRejection", reason));
16
+ target.on("SIGTERM", () => this.log(this.line("platform.stopping", { signal: "SIGTERM" })));
17
+ }
18
+
19
+ private die(kind : string, error : unknown) : void {
20
+ const detail = error instanceof Error
21
+ ? { message: error.message, name: error.name, code: (error as { code? : string }).code, stack: error.stack }
22
+ : { message: String(error) };
23
+ this.log(this.line("platform.crash", { kind, ...detail }));
24
+ this.exit(1);
25
+ }
26
+
27
+ private line(event : string, fields : Record<string, unknown>) : string {
28
+ return JSON.stringify({ level: "fatal", event, at: new Date().toISOString(), ...fields });
29
+ }
30
+
31
+ }
32
+
33
+ export { FatalErrors };
@@ -17,14 +17,32 @@ class Server {
17
17
  const message = error instanceof Error ? error.message : "Internal error";
18
18
  const notFound = /^No .+ found/.test(message);
19
19
  if (! notFound) console.error(`[server] ${incoming.method} ${incoming.url} failed (ray ${response.getHeader("x-anbaric-ray") ?? "-"}):`, error);
20
- if (!response.writableEnded) {
21
- response.writeHead(notFound ? 404 : 500, { "content-type": "application/json" });
22
- response.end(JSON.stringify({ error: notFound ? message : Server.internalErrorMessage(response) }));
23
- }
20
+ Server.failResponse(response, notFound ? 404 : 500, notFound ? message : Server.internalErrorMessage(response));
24
21
  });
25
22
  });
26
23
  }
27
24
 
25
+ /* The last word on a request that failed. A response that has not started
26
+ gets a status and a body; one that was already streaming when it failed
27
+ cannot be given either, so its connection is cut instead - the client
28
+ sees a truncated body, which is the truth. Writing headers twice would
29
+ throw inside the error handler itself, with nothing left to catch it,
30
+ and take the whole platform down with one dropped connection. */
31
+ private static failResponse(response : ServerResponse, status : number, error : string) : void {
32
+ if (response.writableEnded) return;
33
+
34
+ if (response.headersSent) {
35
+ response.destroy();
36
+ return;
37
+ }
38
+ try {
39
+ response.writeHead(status, { "content-type": "application/json" });
40
+ response.end(JSON.stringify({ error }));
41
+ } catch {
42
+ response.destroy();
43
+ }
44
+ }
45
+
28
46
  /* What a failure inside the platform looks like from outside: never the
29
47
  underlying error, which names infrastructure and credentials, only a
30
48
  reference the logs can be searched for. */
@@ -54,7 +54,20 @@ class AppProxyHandler implements RequestHandler {
54
54
 
55
55
  const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.rawBody();
56
56
 
57
+ /* Once the app's response has started, whatever ends it ends the
58
+ request: a browser that closed the tab mid-stream, or an app that
59
+ dropped the connection. Neither is a failure of the platform's,
60
+ and there is nothing left to say to the client, so the upstream is
61
+ let go and the request is over. Only a failure before the first
62
+ byte is an error worth answering. */
57
63
  await new Promise<void>((resolve, reject) => {
64
+ const client = request.rawResponse;
65
+ const settle = (error : unknown) => {
66
+ if (! client.headersSent) return reject(error);
67
+ client.destroy();
68
+ resolve();
69
+ };
70
+
58
71
  const upstream = httpRequest({
59
72
  host: app.appHost,
60
73
  port: app.appPort,
@@ -62,12 +75,18 @@ class AppProxyHandler implements RequestHandler {
62
75
  path: `${appPath}${request.url.search}`,
63
76
  headers: this.forwardHeaders(request, prefix),
64
77
  }, response => {
65
- request.rawResponse.writeHead(response.statusCode ?? 502, this.passThrough(response.headers));
66
- response.pipe(request.rawResponse);
78
+ client.writeHead(response.statusCode ?? 502, this.passThrough(response.headers));
79
+ response.pipe(client);
67
80
  response.on("end", resolve);
68
- response.on("error", reject);
81
+ response.on("error", settle);
82
+ response.on("close", () => { if (! response.complete) settle(new Error("The app closed the connection")); });
83
+ });
84
+ client.on("close", () => {
85
+ if (client.writableFinished) return;
86
+ upstream.destroy();
87
+ resolve();
69
88
  });
70
- upstream.on("error", reject);
89
+ upstream.on("error", settle);
71
90
  if (body && body.length > 0) upstream.write(body);
72
91
  upstream.end();
73
92
  });
package/src/main.ts CHANGED
@@ -33,11 +33,14 @@ import {PostgresPromptManager} from "./data-store/PostgresPromptManager";
33
33
  import {ConsumerRegistry} from "./queuing/ConsumerRegistry";
34
34
  import {Dispatcher} from "./queuing/Dispatcher";
35
35
  import {HostingServer} from "./hosting/HostingServer";
36
+ import {FatalErrors} from "./hosting/FatalErrors";
36
37
  import {PluginLoader} from "./plugins/PluginLoader";
37
38
  import {UsageReporter} from "./usage/UsageReporter";
38
39
  import {HttpSubdomains} from "./subdomains/HttpSubdomains";
39
40
  import {CentralBilling} from "./billing/CentralBilling";
40
41
 
42
+ new FatalErrors().install();
43
+
41
44
  const pool = new Pool({ connectionString: process.env.ANBARIC_DATABASE_URL });
42
45
  await ensureSchema(pool);
43
46