@proteinjs/server 3.5.2 → 3.6.0

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/CHANGELOG.md CHANGED
@@ -3,6 +3,28 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [3.6.0](https://github.com/proteinjs/server/compare/@proteinjs/server@3.5.3...@proteinjs/server@3.6.0) (2026-09-10)
7
+
8
+
9
+ ### Features
10
+
11
+ * **server:** GracefulShutdown holds — the SIGTERM drain waits for work no connection represents (a detached chat turn), bounded by shutdown.turnDrainMs ([b534d68](https://github.com/proteinjs/server/commit/b534d68ab14f925a686cb338466ebabd4b40ef75))
12
+
13
+
14
+
15
+
16
+
17
+ ## [3.5.3](https://github.com/proteinjs/server/compare/@proteinjs/server@3.5.2...@proteinjs/server@3.5.3) (2026-09-01)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * **server:** the react-app HTML response says Cache-Control: no-cache — the page always revalidates (found live 2026-09-01, the mobile-app stale-page investigation). The '*' route sent HTML with an ETag but NO Cache-Control: with no explicit policy, HTTP heuristic caching applies (RFC 9111 §4.2.2) and WKWebView in particular can serve the cached page without revalidating — across app force-quits — pinning stale bundle POINTERS long after a deploy. The hashed bundles under /static keep their long cache (a fresh page always points at fresh hashes); the PAGE must always revalidate. no-cache + the ETag express already stamps = a cheap 304 on every load, never a stale page. Red-before-green: htmlCacheControl.test.ts (front door — a real spawned server, the LB's X-Forwarded-Proto topology) read cache-control undefined at the pre-fix dist (2 red), green after; / and /chat both pinned (the catch-all is the one HTML owner). Fixture gains a bundlePaths pointer so the react-app route serves its page; full estate 5 suites / 11 green (9 pre-existing). ([076536f](https://github.com/proteinjs/server/commit/076536f158ce1c91b073061345fe7aae802b4f38))
23
+
24
+
25
+
26
+
27
+
6
28
  ## [3.5.2](https://github.com/proteinjs/server/compare/@proteinjs/server@3.5.1...@proteinjs/server@3.5.2) (2026-08-27)
7
29
 
8
30
 
@@ -33,7 +33,7 @@ require("react-helmet");
33
33
  require("socket.io");
34
34
  require("typescript");
35
35
  /** Generate Source Graph */
36
- var sourceGraph = "{\"options\":{\"directed\":true,\"multigraph\":false,\"compound\":false},\"nodes\":[{\"v\":\"@proteinjs/server/NodeGlobalDataStorage\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"NodeGlobalDataStorage\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/NodeGlobalDataStorage.ts\",\"qualifiedName\":\"@proteinjs/server/NodeGlobalDataStorage\",\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"properties\":[{\"name\":\"HOOK_INITIALIZED\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"GLOBAL_DATA\",\"type\":{\"packageName\":\"@proteinjs/server\",\"name\":\"{ [id: string]: GlobalData }\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server/{ [id: string]: GlobalData }\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"environment\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[{\"name\":\"setData\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[{\"name\":\"data\",\"type\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalData\",\"typeParameters\":null,\"directParents\":null}}]},{\"name\":\"getData\",\"returnType\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalData\",\"typeParameters\":null,\"directParents\":null},\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[]},{\"name\":\"initHook\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"private\",\"parameters\":[]}],\"typeParameters\":[],\"directParentInterfaces\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalDataStorage\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalDataStorage\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"directParentClasses\":[],\"sourceType\":2}},{\"v\":\"@proteinjs/server-api/GlobalDataStorage\"},{\"v\":\"@proteinjs/server/NodeSessionDataStorage\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"NodeSessionDataStorage\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/NodeSessionDataStorage.ts\",\"qualifiedName\":\"@proteinjs/server/NodeSessionDataStorage\",\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"properties\":[{\"name\":\"HOOK_INITIALIZED\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"SESSION_DATA\",\"type\":{\"packageName\":\"@proteinjs/server\",\"name\":\"{ [id: string]: SessionData }\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server/{ [id: string]: SessionData }\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"environment\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[{\"name\":\"setData\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[{\"name\":\"data\",\"type\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"SessionData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/SessionData\",\"typeParameters\":null,\"directParents\":null}}]},{\"name\":\"getData\",\"returnType\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"SessionData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/SessionData\",\"typeParameters\":null,\"directParents\":null},\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[]},{\"name\":\"clearData\",\"returnType\":{\"packageName\":\"\",\"name\":\"void\",\"filePath\":null,\"qualifiedName\":\"/void\",\"typeParameters\":null,\"directParents\":null},\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[]},{\"name\":\"initHook\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"private\",\"parameters\":[]}],\"typeParameters\":[],\"directParentInterfaces\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"SessionDataStorage\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/SessionDataStorage\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"directParentClasses\":[],\"sourceType\":2}},{\"v\":\"@proteinjs/server-api/SessionDataStorage\"},{\"v\":\"@proteinjs/server/ExtendedIncomingMessage\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"ExtendedIncomingMessage\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts\",\"qualifiedName\":\"@proteinjs/server/ExtendedIncomingMessage\",\"properties\":[{\"name\":\"sessionID\",\"type\":{\"packageName\":\"\",\"name\":\"string\",\"filePath\":null,\"qualifiedName\":\"/string\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":true,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"},{\"name\":\"user\",\"type\":{\"packageName\":\"\",\"name\":\"string\",\"filePath\":null,\"qualifiedName\":\"/string\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":true,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[],\"typeParameters\":[],\"directParents\":[{\"packageName\":\"http\",\"name\":\"IncomingMessage\",\"filePath\":null,\"qualifiedName\":\"http/IncomingMessage\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"sourceType\":3}},{\"v\":\"http/IncomingMessage\"},{\"v\":\"@proteinjs/server/ExtendedSocket\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"ExtendedSocket\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts\",\"qualifiedName\":\"@proteinjs/server/ExtendedSocket\",\"properties\":[{\"name\":\"request\",\"type\":{\"packageName\":\"@proteinjs/server\",\"name\":\"ExtendedIncomingMessage\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server/ExtendedIncomingMessage\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[],\"typeParameters\":[],\"directParents\":[{\"packageName\":\"socket.io\",\"name\":\"Socket\",\"filePath\":null,\"qualifiedName\":\"socket.io/Socket\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"sourceType\":3}},{\"v\":\"socket.io/Socket\"},{\"v\":\"@proteinjs/server/environmentCache\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"environmentCache\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/envCache.ts\",\"qualifiedName\":\"@proteinjs/server/environmentCache\",\"type\":{\"packageName\":\"\",\"name\":\"GlobalDataCache<Env>\",\"filePath\":null,\"qualifiedName\":\"/GlobalDataCache<Env>\",\"typeParameters\":[],\"directParents\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalDataCache\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalDataCache\",\"typeParameters\":[\"@proteinjs/server-api/Env\"],\"directParents\":null}]},\"isExported\":true,\"isConst\":true,\"sourceType\":0}},{\"v\":\"@proteinjs/server-api/GlobalDataCache\"},{\"v\":\"@proteinjs/server/healthCheck\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"healthCheck\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/routes/healthCheck.ts\",\"qualifiedName\":\"@proteinjs/server/healthCheck\",\"type\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"Route\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/Route\",\"typeParameters\":[],\"directParents\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"Route\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/Route\",\"typeParameters\":[],\"directParents\":null}]},\"isExported\":true,\"isConst\":true,\"sourceType\":0}},{\"v\":\"@proteinjs/server-api/Route\"}],\"edges\":[{\"v\":\"@proteinjs/server/NodeGlobalDataStorage\",\"w\":\"@proteinjs/server-api/GlobalDataStorage\",\"value\":\"implements interface\"},{\"v\":\"@proteinjs/server/NodeSessionDataStorage\",\"w\":\"@proteinjs/server-api/SessionDataStorage\",\"value\":\"implements interface\"},{\"v\":\"@proteinjs/server/ExtendedIncomingMessage\",\"w\":\"http/IncomingMessage\",\"value\":\"extends interface\"},{\"v\":\"@proteinjs/server/ExtendedSocket\",\"w\":\"socket.io/Socket\",\"value\":\"extends interface\"},{\"v\":\"@proteinjs/server/environmentCache\",\"w\":\"@proteinjs/server-api/GlobalDataCache\",\"value\":\"has type\"},{\"v\":\"@proteinjs/server/healthCheck\",\"w\":\"@proteinjs/server-api/Route\",\"value\":\"has type\"}]}";
36
+ var sourceGraph = "{\"options\":{\"directed\":true,\"multigraph\":false,\"compound\":false},\"nodes\":[{\"v\":\"/() => void\"},{\"v\":\"@proteinjs/server/NodeGlobalDataStorage\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"NodeGlobalDataStorage\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/NodeGlobalDataStorage.ts\",\"qualifiedName\":\"@proteinjs/server/NodeGlobalDataStorage\",\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"properties\":[{\"name\":\"HOOK_INITIALIZED\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"GLOBAL_DATA\",\"type\":{\"packageName\":\"@proteinjs/server\",\"name\":\"{ [id: string]: GlobalData }\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server/{ [id: string]: GlobalData }\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"environment\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[{\"name\":\"setData\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[{\"name\":\"data\",\"type\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalData\",\"typeParameters\":null,\"directParents\":null}}]},{\"name\":\"getData\",\"returnType\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalData\",\"typeParameters\":null,\"directParents\":null},\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[]},{\"name\":\"initHook\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"private\",\"parameters\":[]}],\"typeParameters\":[],\"directParentInterfaces\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalDataStorage\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalDataStorage\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"directParentClasses\":[],\"sourceType\":2}},{\"v\":\"@proteinjs/server-api/GlobalDataStorage\"},{\"v\":\"@proteinjs/server/NodeSessionDataStorage\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"NodeSessionDataStorage\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/NodeSessionDataStorage.ts\",\"qualifiedName\":\"@proteinjs/server/NodeSessionDataStorage\",\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"properties\":[{\"name\":\"HOOK_INITIALIZED\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"SESSION_DATA\",\"type\":{\"packageName\":\"@proteinjs/server\",\"name\":\"{ [id: string]: SessionData }\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server/{ [id: string]: SessionData }\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":false,\"isAbstract\":false,\"isStatic\":true,\"visibility\":\"private\"},{\"name\":\"environment\",\"type\":null,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[{\"name\":\"setData\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[{\"name\":\"data\",\"type\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"SessionData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/SessionData\",\"typeParameters\":null,\"directParents\":null}}]},{\"name\":\"getData\",\"returnType\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"SessionData\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/SessionData\",\"typeParameters\":null,\"directParents\":null},\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[]},{\"name\":\"clearData\",\"returnType\":{\"packageName\":\"\",\"name\":\"void\",\"filePath\":null,\"qualifiedName\":\"/void\",\"typeParameters\":null,\"directParents\":null},\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\",\"parameters\":[]},{\"name\":\"initHook\",\"returnType\":null,\"isAsync\":false,\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"private\",\"parameters\":[]}],\"typeParameters\":[],\"directParentInterfaces\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"SessionDataStorage\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/SessionDataStorage\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"directParentClasses\":[],\"sourceType\":2}},{\"v\":\"@proteinjs/server-api/SessionDataStorage\"},{\"v\":\"@proteinjs/server/ExtendedIncomingMessage\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"ExtendedIncomingMessage\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts\",\"qualifiedName\":\"@proteinjs/server/ExtendedIncomingMessage\",\"properties\":[{\"name\":\"sessionID\",\"type\":{\"packageName\":\"\",\"name\":\"string\",\"filePath\":null,\"qualifiedName\":\"/string\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":true,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"},{\"name\":\"user\",\"type\":{\"packageName\":\"\",\"name\":\"string\",\"filePath\":null,\"qualifiedName\":\"/string\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":true,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[],\"typeParameters\":[],\"directParents\":[{\"packageName\":\"http\",\"name\":\"IncomingMessage\",\"filePath\":null,\"qualifiedName\":\"http/IncomingMessage\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"sourceType\":3}},{\"v\":\"http/IncomingMessage\"},{\"v\":\"@proteinjs/server/ExtendedSocket\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"ExtendedSocket\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts\",\"qualifiedName\":\"@proteinjs/server/ExtendedSocket\",\"properties\":[{\"name\":\"request\",\"type\":{\"packageName\":\"@proteinjs/server\",\"name\":\"ExtendedIncomingMessage\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server/ExtendedIncomingMessage\",\"typeParameters\":null,\"directParents\":null},\"isOptional\":false,\"isAbstract\":false,\"isStatic\":false,\"visibility\":\"public\"}],\"methods\":[],\"typeParameters\":[],\"directParents\":[{\"packageName\":\"socket.io\",\"name\":\"Socket\",\"filePath\":null,\"qualifiedName\":\"socket.io/Socket\",\"properties\":[],\"methods\":[],\"typeParameters\":[],\"directParents\":[]}],\"sourceType\":3}},{\"v\":\"socket.io/Socket\"},{\"v\":\"@proteinjs/server/environmentCache\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"environmentCache\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/envCache.ts\",\"qualifiedName\":\"@proteinjs/server/environmentCache\",\"type\":{\"packageName\":\"\",\"name\":\"GlobalDataCache<Env>\",\"filePath\":null,\"qualifiedName\":\"/GlobalDataCache<Env>\",\"typeParameters\":[],\"directParents\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"GlobalDataCache\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/GlobalDataCache\",\"typeParameters\":[\"@proteinjs/server-api/Env\"],\"directParents\":null}]},\"isExported\":true,\"isConst\":true,\"sourceType\":0}},{\"v\":\"@proteinjs/server-api/GlobalDataCache\"},{\"v\":\"@proteinjs/server/healthCheck\",\"value\":{\"packageName\":\"@proteinjs/server\",\"name\":\"healthCheck\",\"filePath\":\"/home/runner/work/server/server/packages/server/src/routes/healthCheck.ts\",\"qualifiedName\":\"@proteinjs/server/healthCheck\",\"type\":{\"packageName\":\"@proteinjs/server-api\",\"name\":\"Route\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/Route\",\"typeParameters\":[],\"directParents\":[{\"packageName\":\"@proteinjs/server-api\",\"name\":\"Route\",\"filePath\":null,\"qualifiedName\":\"@proteinjs/server-api/Route\",\"typeParameters\":[],\"directParents\":null}]},\"isExported\":true,\"isConst\":true,\"sourceType\":0}},{\"v\":\"@proteinjs/server-api/Route\"}],\"edges\":[{\"v\":\"@proteinjs/server/NodeGlobalDataStorage\",\"w\":\"@proteinjs/server-api/GlobalDataStorage\",\"value\":\"implements interface\"},{\"v\":\"@proteinjs/server/NodeSessionDataStorage\",\"w\":\"@proteinjs/server-api/SessionDataStorage\",\"value\":\"implements interface\"},{\"v\":\"@proteinjs/server/ExtendedIncomingMessage\",\"w\":\"http/IncomingMessage\",\"value\":\"extends interface\"},{\"v\":\"@proteinjs/server/ExtendedSocket\",\"w\":\"socket.io/Socket\",\"value\":\"extends interface\"},{\"v\":\"@proteinjs/server/environmentCache\",\"w\":\"@proteinjs/server-api/GlobalDataCache\",\"value\":\"has type\"},{\"v\":\"@proteinjs/server/healthCheck\",\"w\":\"@proteinjs/server-api/Route\",\"value\":\"has type\"}]}";
37
37
  /** Generate Source Links */
38
38
  var NodeGlobalDataStorage_1 = require("../src/NodeGlobalDataStorage");
39
39
  var NodeSessionDataStorage_1 = require("../src/NodeSessionDataStorage");
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../generated/index.ts"],"names":[],"mappings":";AAAA,oCAAoC;;;;;;;;;;;;;;;;AAEpC,4BAA0B;AAC1B,6BAA2B;AAC3B,iCAA+B;AAC/B,iCAA+B;AAC/B,gCAA8B;AAC9B,uBAAqB;AACrB,yBAAuB;AACvB,kBAAgB;AAChB,mBAAiB;AACjB,2BAAyB;AACzB,oBAAkB;AAClB,0BAAwB;AACxB,mBAAiB;AACjB,iBAAe;AACf,wBAAsB;AACtB,qBAAmB;AACnB,sBAAoB;AAGpB,4BAA4B;AAE5B,IAAM,WAAW,GAAG,y8RAAy8R,CAAC;AAG99R,4BAA4B;AAE5B,sEAAqE;AACrE,wEAAuE;AACvE,4CAAmD;AACnD,yDAAwD;AAExD,IAAM,WAAW,GAAG;IACnB,yCAAyC,EAAE,6CAAqB;IAChE,0CAA0C,EAAE,+CAAsB;IAClE,oCAAoC,EAAE,2BAAgB;IACtD,+BAA+B,EAAE,yBAAW;CAC5C,CAAC;AAGF,kCAAkC;AAElC,oDAAyD;AACzD,6BAAgB,CAAC,KAAK,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;AAGjD,2CAAyB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../generated/index.ts"],"names":[],"mappings":";AAAA,oCAAoC;;;;;;;;;;;;;;;;AAEpC,4BAA0B;AAC1B,6BAA2B;AAC3B,iCAA+B;AAC/B,iCAA+B;AAC/B,gCAA8B;AAC9B,uBAAqB;AACrB,yBAAuB;AACvB,kBAAgB;AAChB,mBAAiB;AACjB,2BAAyB;AACzB,oBAAkB;AAClB,0BAAwB;AACxB,mBAAiB;AACjB,iBAAe;AACf,wBAAsB;AACtB,qBAAmB;AACnB,sBAAoB;AAGpB,4BAA4B;AAE5B,IAAM,WAAW,GAAG,i+RAAi+R,CAAC;AAGt/R,4BAA4B;AAE5B,sEAAqE;AACrE,wEAAuE;AACvE,4CAAmD;AACnD,yDAAwD;AAExD,IAAM,WAAW,GAAG;IACnB,yCAAyC,EAAE,6CAAqB;IAChE,0CAA0C,EAAE,+CAAsB;IAClE,oCAAoC,EAAE,2BAAgB;IACtD,+BAA+B,EAAE,yBAAW;CAC5C,CAAC;AAGF,kCAAkC;AAElC,oDAAyD;AACzD,6BAAgB,CAAC,KAAK,CAAC,WAAW,EAAE,WAAW,CAAC,CAAC;AAGjD,2CAAyB"}
@@ -1,6 +1,21 @@
1
1
  /// <reference types="node" />
2
2
  import { Server as HttpServer } from 'http';
3
3
  import { ServerConfig } from '@proteinjs/server-api';
4
+ /** Releases a hold taken with {@link GracefulShutdown.hold}. Idempotent. */
5
+ export type HoldRelease = () => void;
6
+ /** A hold outstanding on the process: its label plus whatever the holder attached for the logs. */
7
+ export type Hold = {
8
+ label: string;
9
+ context?: Record<string, unknown>;
10
+ };
11
+ /**
12
+ * Follows the process's holds as they come and go — the dev supervisor's lease projection
13
+ * (@n3xah/util-server's ServePackageHold) is the one consumer; it never decides anything here.
14
+ */
15
+ export type HoldObserver = {
16
+ acquired?: (label: string) => void;
17
+ released?: (label: string) => void;
18
+ };
4
19
  /**
5
20
  * The one owner of the server's shutdown state. Installed by `startServer` before the listener
6
21
  * opens; the `/health-check` route reads `isShuttingDown()` — no other flag system exists.
@@ -15,25 +30,48 @@ import { ServerConfig } from '@proteinjs/server-api';
15
30
  * 2. The listener closes (socket.io clients are disconnected, and `io.close()` closes the
16
31
  * http server it is attached to), idle keep-alive connections are closed, and in-flight
17
32
  * requests run to completion.
18
- * 3. The drain is BOUNDED by `shutdown.drainTimeoutMs`: past it, remaining connections are
19
- * force-closed. Either way the exit code is 0 a drained shutdown is not a crash.
33
+ * 3. The process WAITS for its HOLDS (`hold(label)` below) to release, bounded by
34
+ * `shutdown.turnDrainMs`: work that must not die with the process and that no HTTP
35
+ * connection represents — the canonical holder is a chat turn whose client has gone
36
+ * (the response detached, the model still writing, nothing persisted yet). Past the
37
+ * bound every hold still outstanding is logged by label and abandoned.
38
+ * 4. The connection drain is BOUNDED by `shutdown.drainTimeoutMs`: past it, remaining
39
+ * connections are force-closed. Either way the exit code is 0 — a drained shutdown is
40
+ * not a crash.
20
41
  * SIGINT → exit 0 immediately (dev ctrl-C: fast and quiet; nothing needs flushing).
21
42
  * exit 86 → NOT this class's concern: `process.exit(86)` is the ServePackageSupervisor
22
43
  * restart-request contract (a liveness monitor giving up on a dependency); it is not
23
44
  * signal-driven and is untouched by these handlers.
24
45
  *
46
+ * Holds are a process-wide static seam so a holder never needs the server instance (a turn
47
+ * registry, a suite with no listener): `hold(label, context)` returns the release; the same
48
+ * label held twice is one hold (the release is shared — the turn registry's idempotence);
49
+ * `holds()` lists what is outstanding, in acquisition order; `observeHolds` lets the dev
50
+ * supervisor's lease follow the same set, so a holder declares itself ONCE and both the
51
+ * pre-signal restart deferral (dev) and the post-SIGTERM drain (prod) honor it.
52
+ *
53
+ * Why holds and not connections: the 2026-09-05 prod kill — a phone locked mid-turn, the route
54
+ * detached the dead response and the turn ran on to persist, GKE's autoscaler evicted the pod,
55
+ * and this drain saw NO connection in flight, so `process.exit(0)` ran 5 s after SIGTERM with the
56
+ * model call 32 s in. Nothing persisted, nothing logged (plans/FREE_AGENT.md §M.14).
57
+ *
25
58
  * A SIGTERM before the listener is open (mid-boot kill) exits 0 immediately — nothing is
26
59
  * registered anywhere and nothing is in flight.
27
60
  *
28
61
  * Supervisor compatibility (dev): ServePackageSupervisor SIGTERMs the child's process group and
29
62
  * escalates to SIGKILL after its grace period (default 10s); its exit handler ignores exit codes
30
63
  * for kills it initiated, so the drain-then-exit-0 shape cannot be misread. DEVELOPMENT defaults
31
- * `drainDelayMs` to 0 so supervised restarts stay fast.
64
+ * `drainDelayMs` to 0 so supervised restarts stay fast; a turn in flight under the supervisor is
65
+ * covered BEFORE the signal by its lease (the hold projection), not by this drain.
32
66
  */
33
67
  export declare class GracefulShutdown {
34
68
  private server;
35
69
  private config;
36
70
  private static instance?;
71
+ private static holds;
72
+ private static holdObservers;
73
+ private static holdWaiters;
74
+ private static staticLogger;
37
75
  private shuttingDown;
38
76
  private logger;
39
77
  private constructor();
@@ -41,10 +79,25 @@ export declare class GracefulShutdown {
41
79
  static install(server: HttpServer, config: ServerConfig): void;
42
80
  /** The readiness seam: `/health-check` reports 503 whenever this is true. */
43
81
  static isShuttingDown(): boolean;
82
+ /**
83
+ * Keep the process alive through a SIGTERM drain until the returned release is called (bounded
84
+ * by `shutdown.turnDrainMs`). `label` names the work in the drain's logs (`chat-turn:<id>`);
85
+ * `context` rides beside it (chat id, user id). Holding a label already held returns the
86
+ * existing release — one hold per label.
87
+ */
88
+ static hold(label: string, context?: Record<string, unknown>): HoldRelease;
89
+ /** Every hold outstanding right now, in acquisition order. */
90
+ static outstandingHolds(): Hold[];
91
+ /** Follow holds as they are acquired and released. Returns the unsubscribe. */
92
+ static observeHolds(observer: HoldObserver): () => void;
44
93
  private drainAndExit;
94
+ private drainHolds;
45
95
  private exitNow;
46
96
  private drainDelayMs;
47
97
  private drainTimeoutMs;
98
+ private turnDrainMs;
99
+ private static whenNoHolds;
100
+ private static notify;
48
101
  /** Await a promise with a deadline; true if it settled in time. */
49
102
  private settled;
50
103
  private sleep;
@@ -1 +1 @@
1
- {"version":3,"file":"GracefulShutdown.d.ts","sourceRoot":"","sources":["../../src/GracefulShutdown.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,MAAM,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAIrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,qBAAa,gBAAgB;IAMzB,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,MAAM;IANhB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAmB;IAC3C,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAA4C;IAE1D,OAAO;IAKP,iFAAiF;IACjF,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI;IAU9D,6EAA6E;IAC7E,MAAM,CAAC,cAAc,IAAI,OAAO;YAIlB,YAAY;IAwC1B,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,cAAc;IAItB,mEAAmE;YACrD,OAAO;IAUrB,OAAO,CAAC,KAAK;CAGd"}
1
+ {"version":3,"file":"GracefulShutdown.d.ts","sourceRoot":"","sources":["../../src/GracefulShutdown.ts"],"names":[],"mappings":";AAAA,OAAO,EAAE,MAAM,IAAI,UAAU,EAAE,MAAM,MAAM,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAIrD,4EAA4E;AAC5E,MAAM,MAAM,WAAW,GAAG,MAAM,IAAI,CAAC;AAErC,mGAAmG;AACnG,MAAM,MAAM,IAAI,GAAG;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAExE;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG;IAAE,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE,CAAC;AAEtG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+CG;AACH,qBAAa,gBAAgB;IAUzB,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,MAAM;IAVhB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAmB;IAC3C,OAAO,CAAC,MAAM,CAAC,KAAK,CAA2D;IAC/E,OAAO,CAAC,MAAM,CAAC,aAAa,CAA2B;IACvD,OAAO,CAAC,MAAM,CAAC,WAAW,CAAyB;IACnD,OAAO,CAAC,MAAM,CAAC,YAAY,CAA4C;IACvE,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,MAAM,CAA4C;IAE1D,OAAO;IAKP,iFAAiF;IACjF,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI;IAU9D,6EAA6E;IAC7E,MAAM,CAAC,cAAc,IAAI,OAAO;IAIhC;;;;;OAKG;IACH,MAAM,CAAC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW;IAuB1E,8DAA8D;IAC9D,MAAM,CAAC,gBAAgB,IAAI,IAAI,EAAE;IAIjC,+EAA+E;IAC/E,MAAM,CAAC,YAAY,CAAC,QAAQ,EAAE,YAAY,GAAG,MAAM,IAAI;YAOzC,YAAY;YA6CZ,UAAU;IAqBxB,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,YAAY;IAOpB,OAAO,CAAC,cAAc;IAItB,OAAO,CAAC,WAAW;IAInB,OAAO,CAAC,MAAM,CAAC,WAAW;IAO1B,OAAO,CAAC,MAAM,CAAC,MAAM;IAcrB,mEAAmE;YACrD,OAAO;IAUrB,OAAO,CAAC,KAAK;CAGd"}
@@ -1,4 +1,15 @@
1
1
  "use strict";
2
+ var __assign = (this && this.__assign) || function () {
3
+ __assign = Object.assign || function(t) {
4
+ for (var s, i = 1, n = arguments.length; i < n; i++) {
5
+ s = arguments[i];
6
+ for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p))
7
+ t[p] = s[p];
8
+ }
9
+ return t;
10
+ };
11
+ return __assign.apply(this, arguments);
12
+ };
2
13
  var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
14
  function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
15
  return new (P || (P = Promise))(function (resolve, reject) {
@@ -53,20 +64,39 @@ var SocketIOServerRepo_1 = require("./SocketIOServerRepo");
53
64
  * 2. The listener closes (socket.io clients are disconnected, and `io.close()` closes the
54
65
  * http server it is attached to), idle keep-alive connections are closed, and in-flight
55
66
  * requests run to completion.
56
- * 3. The drain is BOUNDED by `shutdown.drainTimeoutMs`: past it, remaining connections are
57
- * force-closed. Either way the exit code is 0 a drained shutdown is not a crash.
67
+ * 3. The process WAITS for its HOLDS (`hold(label)` below) to release, bounded by
68
+ * `shutdown.turnDrainMs`: work that must not die with the process and that no HTTP
69
+ * connection represents — the canonical holder is a chat turn whose client has gone
70
+ * (the response detached, the model still writing, nothing persisted yet). Past the
71
+ * bound every hold still outstanding is logged by label and abandoned.
72
+ * 4. The connection drain is BOUNDED by `shutdown.drainTimeoutMs`: past it, remaining
73
+ * connections are force-closed. Either way the exit code is 0 — a drained shutdown is
74
+ * not a crash.
58
75
  * SIGINT → exit 0 immediately (dev ctrl-C: fast and quiet; nothing needs flushing).
59
76
  * exit 86 → NOT this class's concern: `process.exit(86)` is the ServePackageSupervisor
60
77
  * restart-request contract (a liveness monitor giving up on a dependency); it is not
61
78
  * signal-driven and is untouched by these handlers.
62
79
  *
80
+ * Holds are a process-wide static seam so a holder never needs the server instance (a turn
81
+ * registry, a suite with no listener): `hold(label, context)` returns the release; the same
82
+ * label held twice is one hold (the release is shared — the turn registry's idempotence);
83
+ * `holds()` lists what is outstanding, in acquisition order; `observeHolds` lets the dev
84
+ * supervisor's lease follow the same set, so a holder declares itself ONCE and both the
85
+ * pre-signal restart deferral (dev) and the post-SIGTERM drain (prod) honor it.
86
+ *
87
+ * Why holds and not connections: the 2026-09-05 prod kill — a phone locked mid-turn, the route
88
+ * detached the dead response and the turn ran on to persist, GKE's autoscaler evicted the pod,
89
+ * and this drain saw NO connection in flight, so `process.exit(0)` ran 5 s after SIGTERM with the
90
+ * model call 32 s in. Nothing persisted, nothing logged (plans/FREE_AGENT.md §M.14).
91
+ *
63
92
  * A SIGTERM before the listener is open (mid-boot kill) exits 0 immediately — nothing is
64
93
  * registered anywhere and nothing is in flight.
65
94
  *
66
95
  * Supervisor compatibility (dev): ServePackageSupervisor SIGTERMs the child's process group and
67
96
  * escalates to SIGKILL after its grace period (default 10s); its exit handler ignores exit codes
68
97
  * for kills it initiated, so the drain-then-exit-0 shape cannot be misread. DEVELOPMENT defaults
69
- * `drainDelayMs` to 0 so supervised restarts stay fast.
98
+ * `drainDelayMs` to 0 so supervised restarts stay fast; a turn in flight under the supervisor is
99
+ * covered BEFORE the signal by its lease (the hold projection), not by this drain.
70
100
  */
71
101
  var GracefulShutdown = /** @class */ (function () {
72
102
  function GracefulShutdown(server, config) {
@@ -90,9 +120,50 @@ var GracefulShutdown = /** @class */ (function () {
90
120
  var _a, _b;
91
121
  return (_b = (_a = GracefulShutdown.instance) === null || _a === void 0 ? void 0 : _a.shuttingDown) !== null && _b !== void 0 ? _b : false;
92
122
  };
123
+ /**
124
+ * Keep the process alive through a SIGTERM drain until the returned release is called (bounded
125
+ * by `shutdown.turnDrainMs`). `label` names the work in the drain's logs (`chat-turn:<id>`);
126
+ * `context` rides beside it (chat id, user id). Holding a label already held returns the
127
+ * existing release — one hold per label.
128
+ */
129
+ GracefulShutdown.hold = function (label, context) {
130
+ var existing = GracefulShutdown.holds.get(label);
131
+ if (existing) {
132
+ return existing.release;
133
+ }
134
+ var release = function () {
135
+ var _a;
136
+ if (((_a = GracefulShutdown.holds.get(label)) === null || _a === void 0 ? void 0 : _a.release) !== release) {
137
+ return; // already released (or the label re-held since — that hold has its own release)
138
+ }
139
+ GracefulShutdown.holds.delete(label);
140
+ GracefulShutdown.notify('released', label);
141
+ if (GracefulShutdown.holds.size === 0) {
142
+ var waiters = GracefulShutdown.holdWaiters.splice(0, GracefulShutdown.holdWaiters.length);
143
+ for (var _i = 0, waiters_1 = waiters; _i < waiters_1.length; _i++) {
144
+ var resolve = waiters_1[_i];
145
+ resolve();
146
+ }
147
+ }
148
+ };
149
+ GracefulShutdown.holds.set(label, { hold: context ? { label: label, context: context } : { label: label }, release: release });
150
+ GracefulShutdown.notify('acquired', label);
151
+ return release;
152
+ };
153
+ /** Every hold outstanding right now, in acquisition order. */
154
+ GracefulShutdown.outstandingHolds = function () {
155
+ return Array.from(GracefulShutdown.holds.values()).map(function (entry) { return (__assign({}, entry.hold)); });
156
+ };
157
+ /** Follow holds as they are acquired and released. Returns the unsubscribe. */
158
+ GracefulShutdown.observeHolds = function (observer) {
159
+ GracefulShutdown.holdObservers.add(observer);
160
+ return function () {
161
+ GracefulShutdown.holdObservers.delete(observer);
162
+ };
163
+ };
93
164
  GracefulShutdown.prototype.drainAndExit = function () {
94
165
  return __awaiter(this, void 0, void 0, function () {
95
- var drainDelayMs, drainTimeoutMs, drained, io;
166
+ var drainDelayMs, drainTimeoutMs, turnDrainMs, drained, io;
96
167
  var _this = this;
97
168
  return __generator(this, function (_a) {
98
169
  switch (_a.label) {
@@ -107,8 +178,10 @@ var GracefulShutdown = /** @class */ (function () {
107
178
  }
108
179
  drainDelayMs = this.drainDelayMs();
109
180
  drainTimeoutMs = this.drainTimeoutMs();
181
+ turnDrainMs = this.turnDrainMs();
110
182
  this.logger.info({
111
- message: "Received SIGTERM \u2014 /health-check now reports 503; accepting connections for another ".concat(drainDelayMs, "ms, then draining in-flight requests (bound: ").concat(drainTimeoutMs, "ms)"),
183
+ message: "Received SIGTERM \u2014 /health-check now reports 503; accepting connections for another ".concat(drainDelayMs, "ms, then draining in-flight requests (bound: ").concat(drainTimeoutMs, "ms) and ").concat(GracefulShutdown.holds.size, " hold(s) (bound: ").concat(turnDrainMs, "ms)"),
184
+ obj: { holds: GracefulShutdown.outstandingHolds() },
112
185
  });
113
186
  // 1. Readiness-propagation window: the LB observes the 503 while we still serve normally.
114
187
  return [4 /*yield*/, this.sleep(drainDelayMs)];
@@ -124,9 +197,16 @@ var GracefulShutdown = /** @class */ (function () {
124
197
  this.server.close();
125
198
  }
126
199
  this.server.closeIdleConnections();
127
- return [4 /*yield*/, this.settled(drained, drainTimeoutMs)];
200
+ // 3. Holds: work no connection represents (a detached chat turn). In-flight requests keep
201
+ // running meanwhile — the listener is closed, existing connections are not. Bounded.
202
+ return [4 /*yield*/, this.drainHolds(turnDrainMs)];
128
203
  case 2:
129
- // 3. Bounded: past the drain timeout, force-close what remains. Exit 0 either way.
204
+ // 3. Holds: work no connection represents (a detached chat turn). In-flight requests keep
205
+ // running meanwhile — the listener is closed, existing connections are not. Bounded.
206
+ _a.sent();
207
+ return [4 /*yield*/, this.settled(drained, drainTimeoutMs)];
208
+ case 3:
209
+ // 4. Bounded: past the drain timeout, force-close what remains. Exit 0 either way.
130
210
  if (!(_a.sent())) {
131
211
  this.logger.warn({
132
212
  message: "Drain exceeded its ".concat(drainTimeoutMs, "ms bound \u2014 force-closing the remaining connections"),
@@ -140,6 +220,37 @@ var GracefulShutdown = /** @class */ (function () {
140
220
  });
141
221
  });
142
222
  };
223
+ GracefulShutdown.prototype.drainHolds = function (turnDrainMs) {
224
+ return __awaiter(this, void 0, void 0, function () {
225
+ var abandoned;
226
+ return __generator(this, function (_a) {
227
+ switch (_a.label) {
228
+ case 0:
229
+ if (GracefulShutdown.holds.size === 0) {
230
+ return [2 /*return*/];
231
+ }
232
+ this.logger.info({
233
+ message: "Listener closed \u2014 waiting for ".concat(GracefulShutdown.holds.size, " hold(s) to release (bound: ").concat(turnDrainMs, "ms)"),
234
+ obj: { holds: GracefulShutdown.outstandingHolds() },
235
+ });
236
+ return [4 /*yield*/, this.settled(GracefulShutdown.whenNoHolds(), turnDrainMs)];
237
+ case 1:
238
+ if (_a.sent()) {
239
+ this.logger.info({ message: "Every hold released \u2014 continuing the drain" });
240
+ return [2 /*return*/];
241
+ }
242
+ abandoned = GracefulShutdown.outstandingHolds();
243
+ this.logger.warn({
244
+ message: "Hold drain exceeded its ".concat(turnDrainMs, "ms bound \u2014 abandoning ").concat(abandoned.length, " hold(s): ").concat(abandoned
245
+ .map(function (hold) { return hold.label; })
246
+ .join(', ')),
247
+ obj: { holds: abandoned },
248
+ });
249
+ return [2 /*return*/];
250
+ }
251
+ });
252
+ });
253
+ };
143
254
  GracefulShutdown.prototype.exitNow = function () {
144
255
  this.shuttingDown = true;
145
256
  this.logger.info({ message: "Received SIGINT \u2014 exiting immediately" });
@@ -156,6 +267,32 @@ var GracefulShutdown = /** @class */ (function () {
156
267
  var _a, _b;
157
268
  return (_b = (_a = this.config.shutdown) === null || _a === void 0 ? void 0 : _a.drainTimeoutMs) !== null && _b !== void 0 ? _b : 30000;
158
269
  };
270
+ GracefulShutdown.prototype.turnDrainMs = function () {
271
+ var _a, _b;
272
+ return (_b = (_a = this.config.shutdown) === null || _a === void 0 ? void 0 : _a.turnDrainMs) !== null && _b !== void 0 ? _b : 240000;
273
+ };
274
+ GracefulShutdown.whenNoHolds = function () {
275
+ if (GracefulShutdown.holds.size === 0) {
276
+ return Promise.resolve();
277
+ }
278
+ return new Promise(function (resolve) { return GracefulShutdown.holdWaiters.push(resolve); });
279
+ };
280
+ GracefulShutdown.notify = function (event, label) {
281
+ var _a;
282
+ for (var _i = 0, _b = Array.from(GracefulShutdown.holdObservers); _i < _b.length; _i++) {
283
+ var observer = _b[_i];
284
+ try {
285
+ (_a = observer[event]) === null || _a === void 0 ? void 0 : _a.call(observer, label);
286
+ }
287
+ catch (error) {
288
+ // An observer follows the holds; its failure never touches the hold itself.
289
+ GracefulShutdown.staticLogger.warn({
290
+ message: "A hold observer threw on ".concat(event),
291
+ obj: { label: label, error: error === null || error === void 0 ? void 0 : error.message },
292
+ });
293
+ }
294
+ }
295
+ };
159
296
  /** Await a promise with a deadline; true if it settled in time. */
160
297
  GracefulShutdown.prototype.settled = function (promise, ms) {
161
298
  return __awaiter(this, void 0, void 0, function () {
@@ -178,6 +315,10 @@ var GracefulShutdown = /** @class */ (function () {
178
315
  GracefulShutdown.prototype.sleep = function (ms) {
179
316
  return new Promise(function (resolve) { return setTimeout(resolve, ms); });
180
317
  };
318
+ GracefulShutdown.holds = new Map();
319
+ GracefulShutdown.holdObservers = new Set();
320
+ GracefulShutdown.holdWaiters = [];
321
+ GracefulShutdown.staticLogger = new logger_1.Logger({ name: 'GracefulShutdown' });
181
322
  return GracefulShutdown;
182
323
  }());
183
324
  exports.GracefulShutdown = GracefulShutdown;
@@ -1 +1 @@
1
- {"version":3,"file":"GracefulShutdown.js","sourceRoot":"","sources":["../../src/GracefulShutdown.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"GracefulShutdown.js","sourceRoot":"","sources":["../../src/GracefulShutdown.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"reactApp.d.ts","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAA4B,MAAM,uBAAuB,CAAC;AAU/E,eAAO,MAAM,cAAc,iBAAkB,YAAY;;;yBAI1B,GAAG,YAAY,GAAG,KAAG,QAAQ,IAAI,CAAC;CA+BhE,CAAC"}
1
+ {"version":3,"file":"reactApp.d.ts","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,YAAY,EAA4B,MAAM,uBAAuB,CAAC;AAU/E,eAAO,MAAM,cAAc,iBAAkB,YAAY;;;yBAI1B,GAAG,YAAY,GAAG,KAAG,QAAQ,IAAI,CAAC;CAsChE,CAAC"}
@@ -67,6 +67,13 @@ var createReactApp = function (serverConfig) {
67
67
  throw new Error("ServerConfig.bundlePath or ServerConfig.bundlesDir must be provided to serve a react app");
68
68
  }
69
69
  helmet = react_helmet_1.default.renderStatic();
70
+ // The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
71
+ // investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
72
+ // §4.2.2) — WKWebView in particular can serve the cached page without revalidating,
73
+ // across app force-quits, pinning stale bundle POINTERS long after a deploy. The hashed
74
+ // bundles under /static keep their long cache (a fresh page always points at fresh
75
+ // hashes); `no-cache` + the ETag express already stamps = a cheap 304 on every load.
76
+ response.set('Cache-Control', 'no-cache');
70
77
  _b = (_a = response).send;
71
78
  _e = (_d = "<!DOCTYPE html>\n <html ".concat(helmet.htmlAttributes, ">\n <head>\n <meta charset='utf-8' />\n <meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1, viewport-fit=cover'>\n <meta name='theme-color' content='").concat(THEME_COLOR_LIGHT, "' media='(prefers-color-scheme: light)'>\n <meta name='theme-color' content='").concat(THEME_COLOR_DARK, "' media='(prefers-color-scheme: dark)'>\n <link href='").concat(((_j = serverConfig.staticContent) === null || _j === void 0 ? void 0 : _j.faviconPath) ? path_1.default.join('/static/', serverConfig.staticContent.faviconPath) : '', "' rel='icon' type='image/png' />\n ").concat(helmet.title.toString(), "\n ").concat(helmet.meta.toString(), "\n ").concat(helmet.link.toString(), "\n </head>\n <body ").concat(helmet.bodyAttributes.toString(), ">\n <div id='app'></div>\n <script>proteinjs = {};</script>\n ")).concat;
72
79
  return [4 /*yield*/, serverRenderedScriptTags()];
@@ -1 +1 @@
1
- {"version":3,"file":"reactApp.js","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"reactApp.js","sourceRoot":"","sources":["../../../src/routes/reactApp.ts"],"names":[],"mappings":"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"}
@@ -21,7 +21,7 @@ import 'typescript';
21
21
  /** Generate Source Graph */
22
22
 
23
23
  const sourceGraph =
24
- '{"options":{"directed":true,"multigraph":false,"compound":false},"nodes":[{"v":"@proteinjs/server/NodeGlobalDataStorage","value":{"packageName":"@proteinjs/server","name":"NodeGlobalDataStorage","filePath":"/home/runner/work/server/server/packages/server/src/NodeGlobalDataStorage.ts","qualifiedName":"@proteinjs/server/NodeGlobalDataStorage","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"HOOK_INITIALIZED","type":null,"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"GLOBAL_DATA","type":{"packageName":"@proteinjs/server","name":"{ [id: string]: GlobalData }","filePath":null,"qualifiedName":"@proteinjs/server/{ [id: string]: GlobalData }","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"environment","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"setData","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"data","type":{"packageName":"@proteinjs/server-api","name":"GlobalData","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalData","typeParameters":null,"directParents":null}}]},{"name":"getData","returnType":{"packageName":"@proteinjs/server-api","name":"GlobalData","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalData","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[]},{"name":"initHook","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"private","parameters":[]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/server-api","name":"GlobalDataStorage","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalDataStorage","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/server-api/GlobalDataStorage"},{"v":"@proteinjs/server/NodeSessionDataStorage","value":{"packageName":"@proteinjs/server","name":"NodeSessionDataStorage","filePath":"/home/runner/work/server/server/packages/server/src/NodeSessionDataStorage.ts","qualifiedName":"@proteinjs/server/NodeSessionDataStorage","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"HOOK_INITIALIZED","type":null,"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"SESSION_DATA","type":{"packageName":"@proteinjs/server","name":"{ [id: string]: SessionData }","filePath":null,"qualifiedName":"@proteinjs/server/{ [id: string]: SessionData }","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"environment","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"setData","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"data","type":{"packageName":"@proteinjs/server-api","name":"SessionData","filePath":null,"qualifiedName":"@proteinjs/server-api/SessionData","typeParameters":null,"directParents":null}}]},{"name":"getData","returnType":{"packageName":"@proteinjs/server-api","name":"SessionData","filePath":null,"qualifiedName":"@proteinjs/server-api/SessionData","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[]},{"name":"clearData","returnType":{"packageName":"","name":"void","filePath":null,"qualifiedName":"/void","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[]},{"name":"initHook","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"private","parameters":[]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/server-api","name":"SessionDataStorage","filePath":null,"qualifiedName":"@proteinjs/server-api/SessionDataStorage","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/server-api/SessionDataStorage"},{"v":"@proteinjs/server/ExtendedIncomingMessage","value":{"packageName":"@proteinjs/server","name":"ExtendedIncomingMessage","filePath":"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts","qualifiedName":"@proteinjs/server/ExtendedIncomingMessage","properties":[{"name":"sessionID","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null},"isOptional":true,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"user","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null},"isOptional":true,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParents":[{"packageName":"http","name":"IncomingMessage","filePath":null,"qualifiedName":"http/IncomingMessage","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"http/IncomingMessage"},{"v":"@proteinjs/server/ExtendedSocket","value":{"packageName":"@proteinjs/server","name":"ExtendedSocket","filePath":"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts","qualifiedName":"@proteinjs/server/ExtendedSocket","properties":[{"name":"request","type":{"packageName":"@proteinjs/server","name":"ExtendedIncomingMessage","filePath":null,"qualifiedName":"@proteinjs/server/ExtendedIncomingMessage","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParents":[{"packageName":"socket.io","name":"Socket","filePath":null,"qualifiedName":"socket.io/Socket","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"socket.io/Socket"},{"v":"@proteinjs/server/environmentCache","value":{"packageName":"@proteinjs/server","name":"environmentCache","filePath":"/home/runner/work/server/server/packages/server/src/envCache.ts","qualifiedName":"@proteinjs/server/environmentCache","type":{"packageName":"","name":"GlobalDataCache<Env>","filePath":null,"qualifiedName":"/GlobalDataCache<Env>","typeParameters":[],"directParents":[{"packageName":"@proteinjs/server-api","name":"GlobalDataCache","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalDataCache","typeParameters":["@proteinjs/server-api/Env"],"directParents":null}]},"isExported":true,"isConst":true,"sourceType":0}},{"v":"@proteinjs/server-api/GlobalDataCache"},{"v":"@proteinjs/server/healthCheck","value":{"packageName":"@proteinjs/server","name":"healthCheck","filePath":"/home/runner/work/server/server/packages/server/src/routes/healthCheck.ts","qualifiedName":"@proteinjs/server/healthCheck","type":{"packageName":"@proteinjs/server-api","name":"Route","filePath":null,"qualifiedName":"@proteinjs/server-api/Route","typeParameters":[],"directParents":[{"packageName":"@proteinjs/server-api","name":"Route","filePath":null,"qualifiedName":"@proteinjs/server-api/Route","typeParameters":[],"directParents":null}]},"isExported":true,"isConst":true,"sourceType":0}},{"v":"@proteinjs/server-api/Route"}],"edges":[{"v":"@proteinjs/server/NodeGlobalDataStorage","w":"@proteinjs/server-api/GlobalDataStorage","value":"implements interface"},{"v":"@proteinjs/server/NodeSessionDataStorage","w":"@proteinjs/server-api/SessionDataStorage","value":"implements interface"},{"v":"@proteinjs/server/ExtendedIncomingMessage","w":"http/IncomingMessage","value":"extends interface"},{"v":"@proteinjs/server/ExtendedSocket","w":"socket.io/Socket","value":"extends interface"},{"v":"@proteinjs/server/environmentCache","w":"@proteinjs/server-api/GlobalDataCache","value":"has type"},{"v":"@proteinjs/server/healthCheck","w":"@proteinjs/server-api/Route","value":"has type"}]}';
24
+ '{"options":{"directed":true,"multigraph":false,"compound":false},"nodes":[{"v":"/() => void"},{"v":"@proteinjs/server/NodeGlobalDataStorage","value":{"packageName":"@proteinjs/server","name":"NodeGlobalDataStorage","filePath":"/home/runner/work/server/server/packages/server/src/NodeGlobalDataStorage.ts","qualifiedName":"@proteinjs/server/NodeGlobalDataStorage","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"HOOK_INITIALIZED","type":null,"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"GLOBAL_DATA","type":{"packageName":"@proteinjs/server","name":"{ [id: string]: GlobalData }","filePath":null,"qualifiedName":"@proteinjs/server/{ [id: string]: GlobalData }","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"environment","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"setData","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"data","type":{"packageName":"@proteinjs/server-api","name":"GlobalData","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalData","typeParameters":null,"directParents":null}}]},{"name":"getData","returnType":{"packageName":"@proteinjs/server-api","name":"GlobalData","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalData","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[]},{"name":"initHook","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"private","parameters":[]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/server-api","name":"GlobalDataStorage","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalDataStorage","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/server-api/GlobalDataStorage"},{"v":"@proteinjs/server/NodeSessionDataStorage","value":{"packageName":"@proteinjs/server","name":"NodeSessionDataStorage","filePath":"/home/runner/work/server/server/packages/server/src/NodeSessionDataStorage.ts","qualifiedName":"@proteinjs/server/NodeSessionDataStorage","isAbstract":false,"isStatic":false,"visibility":"public","properties":[{"name":"HOOK_INITIALIZED","type":null,"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"SESSION_DATA","type":{"packageName":"@proteinjs/server","name":"{ [id: string]: SessionData }","filePath":null,"qualifiedName":"@proteinjs/server/{ [id: string]: SessionData }","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":true,"visibility":"private"},{"name":"environment","type":null,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[{"name":"setData","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[{"name":"data","type":{"packageName":"@proteinjs/server-api","name":"SessionData","filePath":null,"qualifiedName":"@proteinjs/server-api/SessionData","typeParameters":null,"directParents":null}}]},{"name":"getData","returnType":{"packageName":"@proteinjs/server-api","name":"SessionData","filePath":null,"qualifiedName":"@proteinjs/server-api/SessionData","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[]},{"name":"clearData","returnType":{"packageName":"","name":"void","filePath":null,"qualifiedName":"/void","typeParameters":null,"directParents":null},"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public","parameters":[]},{"name":"initHook","returnType":null,"isAsync":false,"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"private","parameters":[]}],"typeParameters":[],"directParentInterfaces":[{"packageName":"@proteinjs/server-api","name":"SessionDataStorage","filePath":null,"qualifiedName":"@proteinjs/server-api/SessionDataStorage","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"directParentClasses":[],"sourceType":2}},{"v":"@proteinjs/server-api/SessionDataStorage"},{"v":"@proteinjs/server/ExtendedIncomingMessage","value":{"packageName":"@proteinjs/server","name":"ExtendedIncomingMessage","filePath":"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts","qualifiedName":"@proteinjs/server/ExtendedIncomingMessage","properties":[{"name":"sessionID","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null},"isOptional":true,"isAbstract":false,"isStatic":false,"visibility":"public"},{"name":"user","type":{"packageName":"","name":"string","filePath":null,"qualifiedName":"/string","typeParameters":null,"directParents":null},"isOptional":true,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParents":[{"packageName":"http","name":"IncomingMessage","filePath":null,"qualifiedName":"http/IncomingMessage","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"http/IncomingMessage"},{"v":"@proteinjs/server/ExtendedSocket","value":{"packageName":"@proteinjs/server","name":"ExtendedSocket","filePath":"/home/runner/work/server/server/packages/server/src/SocketIOServerRepo.ts","qualifiedName":"@proteinjs/server/ExtendedSocket","properties":[{"name":"request","type":{"packageName":"@proteinjs/server","name":"ExtendedIncomingMessage","filePath":null,"qualifiedName":"@proteinjs/server/ExtendedIncomingMessage","typeParameters":null,"directParents":null},"isOptional":false,"isAbstract":false,"isStatic":false,"visibility":"public"}],"methods":[],"typeParameters":[],"directParents":[{"packageName":"socket.io","name":"Socket","filePath":null,"qualifiedName":"socket.io/Socket","properties":[],"methods":[],"typeParameters":[],"directParents":[]}],"sourceType":3}},{"v":"socket.io/Socket"},{"v":"@proteinjs/server/environmentCache","value":{"packageName":"@proteinjs/server","name":"environmentCache","filePath":"/home/runner/work/server/server/packages/server/src/envCache.ts","qualifiedName":"@proteinjs/server/environmentCache","type":{"packageName":"","name":"GlobalDataCache<Env>","filePath":null,"qualifiedName":"/GlobalDataCache<Env>","typeParameters":[],"directParents":[{"packageName":"@proteinjs/server-api","name":"GlobalDataCache","filePath":null,"qualifiedName":"@proteinjs/server-api/GlobalDataCache","typeParameters":["@proteinjs/server-api/Env"],"directParents":null}]},"isExported":true,"isConst":true,"sourceType":0}},{"v":"@proteinjs/server-api/GlobalDataCache"},{"v":"@proteinjs/server/healthCheck","value":{"packageName":"@proteinjs/server","name":"healthCheck","filePath":"/home/runner/work/server/server/packages/server/src/routes/healthCheck.ts","qualifiedName":"@proteinjs/server/healthCheck","type":{"packageName":"@proteinjs/server-api","name":"Route","filePath":null,"qualifiedName":"@proteinjs/server-api/Route","typeParameters":[],"directParents":[{"packageName":"@proteinjs/server-api","name":"Route","filePath":null,"qualifiedName":"@proteinjs/server-api/Route","typeParameters":[],"directParents":null}]},"isExported":true,"isConst":true,"sourceType":0}},{"v":"@proteinjs/server-api/Route"}],"edges":[{"v":"@proteinjs/server/NodeGlobalDataStorage","w":"@proteinjs/server-api/GlobalDataStorage","value":"implements interface"},{"v":"@proteinjs/server/NodeSessionDataStorage","w":"@proteinjs/server-api/SessionDataStorage","value":"implements interface"},{"v":"@proteinjs/server/ExtendedIncomingMessage","w":"http/IncomingMessage","value":"extends interface"},{"v":"@proteinjs/server/ExtendedSocket","w":"socket.io/Socket","value":"extends interface"},{"v":"@proteinjs/server/environmentCache","w":"@proteinjs/server-api/GlobalDataCache","value":"has type"},{"v":"@proteinjs/server/healthCheck","w":"@proteinjs/server-api/Route","value":"has type"}]}';
25
25
 
26
26
  /** Generate Source Links */
27
27
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteinjs/server",
3
- "version": "3.5.2",
3
+ "version": "3.6.0",
4
4
  "description": "A server impl",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -25,7 +25,7 @@
25
25
  "@proteinjs/event": "^1.0.18",
26
26
  "@proteinjs/logger": "^1.0.21",
27
27
  "@proteinjs/reflection": "^1.1.12",
28
- "@proteinjs/server-api": "^3.1.2",
28
+ "@proteinjs/server-api": "^3.2.0",
29
29
  "@proteinjs/util-node": "^1.10.1",
30
30
  "body-parser": "1.19.0",
31
31
  "cookie-parser": "1.4.5",
@@ -64,5 +64,5 @@
64
64
  },
65
65
  "main": "./dist/generated/index.js",
66
66
  "types": "./dist/generated/index.d.ts",
67
- "gitHead": "ea237ffcd7725e009d841d339ed098e324d3afef"
67
+ "gitHead": "c1a14f62c4e023db66161869955baf616aab31bb"
68
68
  }
@@ -3,6 +3,18 @@ import { ServerConfig } from '@proteinjs/server-api';
3
3
  import { Logger } from '@proteinjs/logger';
4
4
  import { SocketIOServerRepo } from './SocketIOServerRepo';
5
5
 
6
+ /** Releases a hold taken with {@link GracefulShutdown.hold}. Idempotent. */
7
+ export type HoldRelease = () => void;
8
+
9
+ /** A hold outstanding on the process: its label plus whatever the holder attached for the logs. */
10
+ export type Hold = { label: string; context?: Record<string, unknown> };
11
+
12
+ /**
13
+ * Follows the process's holds as they come and go — the dev supervisor's lease projection
14
+ * (@n3xah/util-server's ServePackageHold) is the one consumer; it never decides anything here.
15
+ */
16
+ export type HoldObserver = { acquired?: (label: string) => void; released?: (label: string) => void };
17
+
6
18
  /**
7
19
  * The one owner of the server's shutdown state. Installed by `startServer` before the listener
8
20
  * opens; the `/health-check` route reads `isShuttingDown()` — no other flag system exists.
@@ -17,23 +29,46 @@ import { SocketIOServerRepo } from './SocketIOServerRepo';
17
29
  * 2. The listener closes (socket.io clients are disconnected, and `io.close()` closes the
18
30
  * http server it is attached to), idle keep-alive connections are closed, and in-flight
19
31
  * requests run to completion.
20
- * 3. The drain is BOUNDED by `shutdown.drainTimeoutMs`: past it, remaining connections are
21
- * force-closed. Either way the exit code is 0 a drained shutdown is not a crash.
32
+ * 3. The process WAITS for its HOLDS (`hold(label)` below) to release, bounded by
33
+ * `shutdown.turnDrainMs`: work that must not die with the process and that no HTTP
34
+ * connection represents — the canonical holder is a chat turn whose client has gone
35
+ * (the response detached, the model still writing, nothing persisted yet). Past the
36
+ * bound every hold still outstanding is logged by label and abandoned.
37
+ * 4. The connection drain is BOUNDED by `shutdown.drainTimeoutMs`: past it, remaining
38
+ * connections are force-closed. Either way the exit code is 0 — a drained shutdown is
39
+ * not a crash.
22
40
  * SIGINT → exit 0 immediately (dev ctrl-C: fast and quiet; nothing needs flushing).
23
41
  * exit 86 → NOT this class's concern: `process.exit(86)` is the ServePackageSupervisor
24
42
  * restart-request contract (a liveness monitor giving up on a dependency); it is not
25
43
  * signal-driven and is untouched by these handlers.
26
44
  *
45
+ * Holds are a process-wide static seam so a holder never needs the server instance (a turn
46
+ * registry, a suite with no listener): `hold(label, context)` returns the release; the same
47
+ * label held twice is one hold (the release is shared — the turn registry's idempotence);
48
+ * `holds()` lists what is outstanding, in acquisition order; `observeHolds` lets the dev
49
+ * supervisor's lease follow the same set, so a holder declares itself ONCE and both the
50
+ * pre-signal restart deferral (dev) and the post-SIGTERM drain (prod) honor it.
51
+ *
52
+ * Why holds and not connections: the 2026-09-05 prod kill — a phone locked mid-turn, the route
53
+ * detached the dead response and the turn ran on to persist, GKE's autoscaler evicted the pod,
54
+ * and this drain saw NO connection in flight, so `process.exit(0)` ran 5 s after SIGTERM with the
55
+ * model call 32 s in. Nothing persisted, nothing logged (plans/FREE_AGENT.md §M.14).
56
+ *
27
57
  * A SIGTERM before the listener is open (mid-boot kill) exits 0 immediately — nothing is
28
58
  * registered anywhere and nothing is in flight.
29
59
  *
30
60
  * Supervisor compatibility (dev): ServePackageSupervisor SIGTERMs the child's process group and
31
61
  * escalates to SIGKILL after its grace period (default 10s); its exit handler ignores exit codes
32
62
  * for kills it initiated, so the drain-then-exit-0 shape cannot be misread. DEVELOPMENT defaults
33
- * `drainDelayMs` to 0 so supervised restarts stay fast.
63
+ * `drainDelayMs` to 0 so supervised restarts stay fast; a turn in flight under the supervisor is
64
+ * covered BEFORE the signal by its lease (the hold projection), not by this drain.
34
65
  */
35
66
  export class GracefulShutdown {
36
67
  private static instance?: GracefulShutdown;
68
+ private static holds = new Map<string, { hold: Hold; release: HoldRelease }>();
69
+ private static holdObservers = new Set<HoldObserver>();
70
+ private static holdWaiters: Array<() => void> = [];
71
+ private static staticLogger = new Logger({ name: 'GracefulShutdown' });
37
72
  private shuttingDown = false;
38
73
  private logger = new Logger({ name: 'GracefulShutdown' });
39
74
 
@@ -58,6 +93,48 @@ export class GracefulShutdown {
58
93
  return GracefulShutdown.instance?.shuttingDown ?? false;
59
94
  }
60
95
 
96
+ /**
97
+ * Keep the process alive through a SIGTERM drain until the returned release is called (bounded
98
+ * by `shutdown.turnDrainMs`). `label` names the work in the drain's logs (`chat-turn:<id>`);
99
+ * `context` rides beside it (chat id, user id). Holding a label already held returns the
100
+ * existing release — one hold per label.
101
+ */
102
+ static hold(label: string, context?: Record<string, unknown>): HoldRelease {
103
+ const existing = GracefulShutdown.holds.get(label);
104
+ if (existing) {
105
+ return existing.release;
106
+ }
107
+ const release: HoldRelease = () => {
108
+ if (GracefulShutdown.holds.get(label)?.release !== release) {
109
+ return; // already released (or the label re-held since — that hold has its own release)
110
+ }
111
+ GracefulShutdown.holds.delete(label);
112
+ GracefulShutdown.notify('released', label);
113
+ if (GracefulShutdown.holds.size === 0) {
114
+ const waiters = GracefulShutdown.holdWaiters.splice(0, GracefulShutdown.holdWaiters.length);
115
+ for (const resolve of waiters) {
116
+ resolve();
117
+ }
118
+ }
119
+ };
120
+ GracefulShutdown.holds.set(label, { hold: context ? { label, context } : { label }, release });
121
+ GracefulShutdown.notify('acquired', label);
122
+ return release;
123
+ }
124
+
125
+ /** Every hold outstanding right now, in acquisition order. */
126
+ static outstandingHolds(): Hold[] {
127
+ return Array.from(GracefulShutdown.holds.values()).map((entry) => ({ ...entry.hold }));
128
+ }
129
+
130
+ /** Follow holds as they are acquired and released. Returns the unsubscribe. */
131
+ static observeHolds(observer: HoldObserver): () => void {
132
+ GracefulShutdown.holdObservers.add(observer);
133
+ return () => {
134
+ GracefulShutdown.holdObservers.delete(observer);
135
+ };
136
+ }
137
+
61
138
  private async drainAndExit(): Promise<void> {
62
139
  if (this.shuttingDown) {
63
140
  return; // already draining; the sender's escalation is SIGKILL, not a second drain
@@ -69,8 +146,10 @@ export class GracefulShutdown {
69
146
  }
70
147
  const drainDelayMs = this.drainDelayMs();
71
148
  const drainTimeoutMs = this.drainTimeoutMs();
149
+ const turnDrainMs = this.turnDrainMs();
72
150
  this.logger.info({
73
- message: `Received SIGTERM — /health-check now reports 503; accepting connections for another ${drainDelayMs}ms, then draining in-flight requests (bound: ${drainTimeoutMs}ms)`,
151
+ message: `Received SIGTERM — /health-check now reports 503; accepting connections for another ${drainDelayMs}ms, then draining in-flight requests (bound: ${drainTimeoutMs}ms) and ${GracefulShutdown.holds.size} hold(s) (bound: ${turnDrainMs}ms)`,
152
+ obj: { holds: GracefulShutdown.outstandingHolds() },
74
153
  });
75
154
  // 1. Readiness-propagation window: the LB observes the 503 while we still serve normally.
76
155
  await this.sleep(drainDelayMs);
@@ -87,7 +166,10 @@ export class GracefulShutdown {
87
166
  this.server.close();
88
167
  }
89
168
  this.server.closeIdleConnections();
90
- // 3. Bounded: past the drain timeout, force-close what remains. Exit 0 either way.
169
+ // 3. Holds: work no connection represents (a detached chat turn). In-flight requests keep
170
+ // running meanwhile — the listener is closed, existing connections are not. Bounded.
171
+ await this.drainHolds(turnDrainMs);
172
+ // 4. Bounded: past the drain timeout, force-close what remains. Exit 0 either way.
91
173
  if (!(await this.settled(drained, drainTimeoutMs))) {
92
174
  this.logger.warn({
93
175
  message: `Drain exceeded its ${drainTimeoutMs}ms bound — force-closing the remaining connections`,
@@ -98,6 +180,27 @@ export class GracefulShutdown {
98
180
  process.exit(0);
99
181
  }
100
182
 
183
+ private async drainHolds(turnDrainMs: number): Promise<void> {
184
+ if (GracefulShutdown.holds.size === 0) {
185
+ return;
186
+ }
187
+ this.logger.info({
188
+ message: `Listener closed — waiting for ${GracefulShutdown.holds.size} hold(s) to release (bound: ${turnDrainMs}ms)`,
189
+ obj: { holds: GracefulShutdown.outstandingHolds() },
190
+ });
191
+ if (await this.settled(GracefulShutdown.whenNoHolds(), turnDrainMs)) {
192
+ this.logger.info({ message: `Every hold released — continuing the drain` });
193
+ return;
194
+ }
195
+ const abandoned = GracefulShutdown.outstandingHolds();
196
+ this.logger.warn({
197
+ message: `Hold drain exceeded its ${turnDrainMs}ms bound — abandoning ${abandoned.length} hold(s): ${abandoned
198
+ .map((hold) => hold.label)
199
+ .join(', ')}`,
200
+ obj: { holds: abandoned },
201
+ });
202
+ }
203
+
101
204
  private exitNow(): void {
102
205
  this.shuttingDown = true;
103
206
  this.logger.info({ message: `Received SIGINT — exiting immediately` });
@@ -115,6 +218,31 @@ export class GracefulShutdown {
115
218
  return this.config.shutdown?.drainTimeoutMs ?? 30_000;
116
219
  }
117
220
 
221
+ private turnDrainMs(): number {
222
+ return this.config.shutdown?.turnDrainMs ?? 240_000;
223
+ }
224
+
225
+ private static whenNoHolds(): Promise<void> {
226
+ if (GracefulShutdown.holds.size === 0) {
227
+ return Promise.resolve();
228
+ }
229
+ return new Promise<void>((resolve) => GracefulShutdown.holdWaiters.push(resolve));
230
+ }
231
+
232
+ private static notify(event: 'acquired' | 'released', label: string): void {
233
+ for (const observer of Array.from(GracefulShutdown.holdObservers)) {
234
+ try {
235
+ observer[event]?.(label);
236
+ } catch (error: any) {
237
+ // An observer follows the holds; its failure never touches the hold itself.
238
+ GracefulShutdown.staticLogger.warn({
239
+ message: `A hold observer threw on ${event}`,
240
+ obj: { label, error: error?.message },
241
+ });
242
+ }
243
+ }
244
+ }
245
+
118
246
  /** Await a promise with a deadline; true if it settled in time. */
119
247
  private async settled(promise: Promise<void>, ms: number): Promise<boolean> {
120
248
  let timer: NodeJS.Timeout | undefined;
@@ -24,6 +24,13 @@ export const createReactApp = (serverConfig: ServerConfig) => {
24
24
  }
25
25
 
26
26
  const helmet = ReactHelmet.renderStatic();
27
+ // The page must ALWAYS revalidate (found live 2026-09-01, the mobile-app stale-page
28
+ // investigation): without an explicit policy, HTTP heuristic caching applies (RFC 9111
29
+ // §4.2.2) — WKWebView in particular can serve the cached page without revalidating,
30
+ // across app force-quits, pinning stale bundle POINTERS long after a deploy. The hashed
31
+ // bundles under /static keep their long cache (a fresh page always points at fresh
32
+ // hashes); `no-cache` + the ETag express already stamps = a cheap 304 on every load.
33
+ response.set('Cache-Control', 'no-cache');
27
34
  response.send(`<!DOCTYPE html>
28
35
  <html ${helmet.htmlAttributes}>
29
36
  <head>
@@ -2,18 +2,22 @@
2
2
  * Test fixture: a REAL @proteinjs/server (the built dist — the same code prod runs) with a
3
3
  * deliberately slow endpoint, so the graceful-shutdown suite can hold a request in flight
4
4
  * while it signals the process. Run with:
5
- * FIXTURE_PORT=<port> [FIXTURE_DRAIN_DELAY_MS=..] [FIXTURE_DRAIN_TIMEOUT_MS=..] node gracefulShutdownServer.js
5
+ * FIXTURE_PORT=<port> [FIXTURE_DRAIN_DELAY_MS=..] [FIXTURE_DRAIN_TIMEOUT_MS=..] [FIXTURE_TURN_DRAIN_MS=..] node gracefulShutdownServer.js
6
6
  *
7
7
  * Markers on stdout (the suite's synchronization points):
8
8
  * FIXTURE_READY — startServer resolved (the listener is up)
9
9
  * SLOW_REQUEST_STARTED — the /slow handler is executing (a request is now in flight)
10
+ * HOLD_ACQUIRED <label> — /hold took a process hold (GracefulShutdown.hold) that outlives
11
+ * its request: the response ends at once, the hold releases itself
12
+ * ?ms= later — a detached chat turn's shape (no connection, work live)
13
+ * HOLD_RELEASED <label> — that hold released
10
14
  *
11
15
  * Also serves /server-timeouts: the LIVE http.Server's keepAliveTimeout/headersTimeout (read off
12
16
  * the request's own socket), so the keep-alive suite asserts the running instance through the
13
17
  * front door instead of re-deriving values from source.
14
18
  */
15
19
  const expressSession = require('express-session');
16
- const { startServer } = require('../../dist/generated/index.js');
20
+ const { startServer, GracefulShutdown } = require('../../dist/generated/index.js');
17
21
 
18
22
  const port = Number(process.env.FIXTURE_PORT);
19
23
  if (!port) {
@@ -44,6 +48,20 @@ startServer({
44
48
  response.status(200).send('session-cookie-set');
45
49
  return;
46
50
  }
51
+ if (request.path === '/hold') {
52
+ // A hold that OUTLIVES its request — the detached-turn shape the drain must wait for:
53
+ // the response ends now (no connection remains), the work stays live for ?ms=.
54
+ const label = String(request.query.label ?? 'fixture-hold');
55
+ const ms = Number(request.query.ms ?? 5000);
56
+ const release = GracefulShutdown.hold(label, { source: 'fixture', ms });
57
+ console.log(`HOLD_ACQUIRED ${label}`);
58
+ setTimeout(() => {
59
+ release();
60
+ console.log(`HOLD_RELEASED ${label}`);
61
+ }, ms);
62
+ response.status(200).send('held');
63
+ return;
64
+ }
47
65
  if (request.path !== '/slow') {
48
66
  next();
49
67
  return;
@@ -54,8 +72,13 @@ startServer({
54
72
  response.status(200).send('slow-done');
55
73
  },
56
74
  },
75
+ // A bundle pointer so the '*' react-app route serves its HTML — the html-cache-control
76
+ // suite asserts response headers on the page the app actually ships. No staticContentDir:
77
+ // nothing needs the bundle to resolve, only the page response to render.
78
+ staticContent: { bundlePaths: ['bundles/app.test.js'] },
57
79
  shutdown: {
58
80
  drainDelayMs: process.env.FIXTURE_DRAIN_DELAY_MS ? Number(process.env.FIXTURE_DRAIN_DELAY_MS) : undefined,
59
81
  drainTimeoutMs: process.env.FIXTURE_DRAIN_TIMEOUT_MS ? Number(process.env.FIXTURE_DRAIN_TIMEOUT_MS) : undefined,
82
+ turnDrainMs: process.env.FIXTURE_TURN_DRAIN_MS ? Number(process.env.FIXTURE_TURN_DRAIN_MS) : undefined,
60
83
  },
61
84
  }).then(() => console.log('FIXTURE_READY'));
@@ -13,6 +13,11 @@ import { ChildProcess, spawn } from 'child_process';
13
13
  * idle keep-alives are closed, and the process exits 0 — bounded by
14
14
  * shutdown.drainTimeoutMs (past it, remaining connections are force-closed and
15
15
  * the exit is still 0).
16
+ * HOLDS (GracefulShutdown.hold): work no connection represents — a chat turn whose
17
+ * client disconnected (the 2026-09-05 prod kill, plans/FREE_AGENT.md §M.14) — keeps
18
+ * the process alive past the connection drain until it releases, bounded by
19
+ * shutdown.turnDrainMs; past THAT bound the holds still outstanding are logged by
20
+ * label and abandoned, and the exit is still 0.
16
21
  * SIGINT → immediate exit 0 (dev ctrl-C: fast, quiet).
17
22
  * exit 86 → RESTART_REQUEST_EXIT_CODE, untouched by this feature: process.exit(86) is not
18
23
  * signal-driven (ServePackageSupervisor's respawn contract rides it).
@@ -101,9 +106,70 @@ describe('graceful shutdown', () => {
101
106
  expect(exit).toEqual({ code: 0, signal: null });
102
107
  expect(Date.now() - sigintAt).toBeLessThan(2000); // no drain delay on the fast path
103
108
  }, 30000);
109
+
110
+ describe('holds — work no connection represents (a detached chat turn)', () => {
111
+ it('SIGTERM: a hold whose request already ended keeps the process alive past the connection drain; its release lets the exit proceed', async () => {
112
+ // No delay, a short connection bound, a long hold bound: the ONLY thing that can keep this
113
+ // process alive after SIGTERM is the hold.
114
+ fixture = await startFixture({ drainDelayMs: 0, drainTimeoutMs: 1000, turnDrainMs: 20000 });
115
+
116
+ // The detached-turn shape: the request ends immediately (no connection remains), the work
117
+ // it started stays live for 5s and releases its hold when done.
118
+ const held = await request(fixture.port, '/hold?label=chat-turn:detached-turn&ms=5000');
119
+ expect(held).toEqual({ status: 200, body: 'held' });
120
+ await fixture.waitForMarker('HOLD_ACQUIRED chat-turn:detached-turn');
121
+
122
+ const sigtermAt = Date.now();
123
+ fixture.child.kill('SIGTERM');
124
+
125
+ // 1. Past the connection drain (nothing was in flight — it completes at once; the listener
126
+ // is closed) the process is STILL RUNNING: the hold is what keeps it.
127
+ await sleep(2500);
128
+ expect(fixture.child.exitCode).toBeNull();
129
+ expect(fixture.child.signalCode).toBeNull();
130
+ await expect(request(fixture.port, '/health-check')).rejects.toMatchObject({
131
+ code: expect.stringMatching(/ECONNREFUSED|ECONNRESET/),
132
+ });
133
+ expect(fixture.stdout()).toContain('waiting for 1 hold(s) to release (bound: 20000ms)');
134
+
135
+ // 2. The work finishes and releases; the exit follows the RELEASE (~5s), not the 20s bound.
136
+ const exit = await fixture.exited;
137
+ expect(exit).toEqual({ code: 0, signal: null });
138
+ const elapsed = Date.now() - sigtermAt;
139
+ expect(elapsed).toBeGreaterThanOrEqual(4500);
140
+ expect(elapsed).toBeLessThan(12000);
141
+ expect(fixture.stdout()).toContain('HOLD_RELEASED chat-turn:detached-turn');
142
+ expect(fixture.stdout()).toContain('Every hold released');
143
+ }, 40000);
144
+
145
+ it('SIGTERM: the hold drain is BOUNDED — past turnDrainMs the outstanding holds are logged by label, abandoned, and the exit is still 0', async () => {
146
+ fixture = await startFixture({ drainDelayMs: 0, drainTimeoutMs: 1000, turnDrainMs: 3000 });
147
+
148
+ // A hold that would outlive the bound by far.
149
+ const held = await request(fixture.port, '/hold?label=chat-turn:abandoned-turn&ms=60000');
150
+ expect(held.status).toBe(200);
151
+ await fixture.waitForMarker('HOLD_ACQUIRED chat-turn:abandoned-turn');
152
+
153
+ const sigtermAt = Date.now();
154
+ fixture.child.kill('SIGTERM');
155
+
156
+ const exit = await fixture.exited;
157
+ expect(exit).toEqual({ code: 0, signal: null });
158
+ const elapsed = Date.now() - sigtermAt;
159
+ expect(elapsed).toBeGreaterThanOrEqual(3000); // the bound was actually served
160
+ expect(elapsed).toBeLessThan(9000); // ...and it ended the wait, not the 60s hold
161
+ // The abandoned work is NAMED — the line the 2026-09-05 kill never left behind.
162
+ expect(fixture.stdout()).toContain('abandoning 1 hold(s): chat-turn:abandoned-turn');
163
+ expect(fixture.stdout()).not.toContain('HOLD_RELEASED');
164
+ }, 30000);
165
+ });
104
166
  });
105
167
 
106
- async function startFixture(shutdown: { drainDelayMs: number; drainTimeoutMs: number }): Promise<Fixture> {
168
+ async function startFixture(shutdown: {
169
+ drainDelayMs: number;
170
+ drainTimeoutMs: number;
171
+ turnDrainMs?: number;
172
+ }): Promise<Fixture> {
107
173
  const port = await ephemeralPort();
108
174
  // Scrub the env vars startServer reads (dev machines export some of these): the fixture's
109
175
  // behavior must come from its own config only.
@@ -119,6 +185,7 @@ async function startFixture(shutdown: { drainDelayMs: number; drainTimeoutMs: nu
119
185
  FIXTURE_PORT: String(port),
120
186
  FIXTURE_DRAIN_DELAY_MS: String(shutdown.drainDelayMs),
121
187
  FIXTURE_DRAIN_TIMEOUT_MS: String(shutdown.drainTimeoutMs),
188
+ ...(shutdown.turnDrainMs !== undefined ? { FIXTURE_TURN_DRAIN_MS: String(shutdown.turnDrainMs) } : {}),
122
189
  },
123
190
  stdio: ['ignore', 'pipe', 'pipe'],
124
191
  });
@@ -0,0 +1,82 @@
1
+ import { GracefulShutdown } from '../src/GracefulShutdown';
2
+
3
+ /**
4
+ * The holds seam as a process-wide static contract — what a holder (a chat turn registry) and a
5
+ * follower (the dev supervisor's lease projection) can rely on without a server instance:
6
+ * one hold per label, idempotent release, acquisition-ordered listing with the holder's context,
7
+ * and observers told of every transition exactly once.
8
+ */
9
+ describe('GracefulShutdown holds', () => {
10
+ afterEach(() => {
11
+ for (const hold of GracefulShutdown.outstandingHolds()) {
12
+ GracefulShutdown.hold(hold.label)(); // hold() on a held label returns its release
13
+ }
14
+ });
15
+
16
+ it('a hold is listed with its context until its release; releasing twice is a no-op', () => {
17
+ const release = GracefulShutdown.hold('chat-turn:t1', { chatId: 'c1', userId: 'u1' });
18
+ expect(GracefulShutdown.outstandingHolds()).toEqual([
19
+ { label: 'chat-turn:t1', context: { chatId: 'c1', userId: 'u1' } },
20
+ ]);
21
+ release();
22
+ expect(GracefulShutdown.outstandingHolds()).toEqual([]);
23
+ release();
24
+ expect(GracefulShutdown.outstandingHolds()).toEqual([]);
25
+ });
26
+
27
+ it('holding a label already held is the SAME hold — one entry, one release', () => {
28
+ const first = GracefulShutdown.hold('chat-turn:t2', { chatId: 'c2' });
29
+ const second = GracefulShutdown.hold('chat-turn:t2', { chatId: 'other' });
30
+ expect(second).toBe(first);
31
+ expect(GracefulShutdown.outstandingHolds()).toEqual([{ label: 'chat-turn:t2', context: { chatId: 'c2' } }]);
32
+ second();
33
+ expect(GracefulShutdown.outstandingHolds()).toEqual([]);
34
+ });
35
+
36
+ it('a stale release never drops a label re-held since', () => {
37
+ const stale = GracefulShutdown.hold('chat-turn:t3');
38
+ stale();
39
+ const fresh = GracefulShutdown.hold('chat-turn:t3');
40
+ stale(); // the first hold's release, called again after the label was re-held
41
+ expect(GracefulShutdown.outstandingHolds()).toEqual([{ label: 'chat-turn:t3' }]);
42
+ fresh();
43
+ expect(GracefulShutdown.outstandingHolds()).toEqual([]);
44
+ });
45
+
46
+ it('lists holds in acquisition order', () => {
47
+ GracefulShutdown.hold('b');
48
+ GracefulShutdown.hold('a');
49
+ expect(GracefulShutdown.outstandingHolds().map((hold) => hold.label)).toEqual(['b', 'a']);
50
+ });
51
+
52
+ it('observers see each acquisition and release once; a re-hold of a held label is silent; unsubscribing stops the stream', () => {
53
+ const events: string[] = [];
54
+ const unsubscribe = GracefulShutdown.observeHolds({
55
+ acquired: (label) => events.push(`+${label}`),
56
+ released: (label) => events.push(`-${label}`),
57
+ });
58
+ const release = GracefulShutdown.hold('chat-turn:t4');
59
+ GracefulShutdown.hold('chat-turn:t4');
60
+ release();
61
+ release();
62
+ unsubscribe();
63
+ GracefulShutdown.hold('chat-turn:t5')();
64
+ expect(events).toEqual(['+chat-turn:t4', '-chat-turn:t4']);
65
+ });
66
+
67
+ it('a throwing observer never touches the hold', () => {
68
+ const unsubscribe = GracefulShutdown.observeHolds({
69
+ acquired: () => {
70
+ throw new Error('follower down');
71
+ },
72
+ released: () => {
73
+ throw new Error('follower down');
74
+ },
75
+ });
76
+ const release = GracefulShutdown.hold('chat-turn:t6');
77
+ expect(GracefulShutdown.outstandingHolds()).toEqual([{ label: 'chat-turn:t6' }]);
78
+ release();
79
+ expect(GracefulShutdown.outstandingHolds()).toEqual([]);
80
+ unsubscribe();
81
+ });
82
+ });
@@ -0,0 +1,129 @@
1
+ import * as http from 'http';
2
+ import * as net from 'net';
3
+ import * as path from 'path';
4
+ import { ChildProcess, spawn } from 'child_process';
5
+
6
+ /**
7
+ * HTML cache policy of a real server process (spawns the built dist — run `npm run build`
8
+ * first), asserted through the front door on the page the react-app route actually sends.
9
+ *
10
+ * THE CLASS THIS PINS (found live 2026-09-01, the mobile-app stale-page investigation): the
11
+ * HTML response carried NO Cache-Control header — an ETag only. With no explicit policy,
12
+ * HTTP heuristic caching applies (RFC 9111 §4.2.2), and WKWebView in particular can serve
13
+ * the cached page without revalidating — across app force-quits — pinning stale bundle
14
+ * POINTERS long after a deploy. The hashed bundles under /static keep their long cache (a
15
+ * new page always points at new hashes); the PAGE must always revalidate. `no-cache` states
16
+ * exactly that, and the ETag the response already carries keeps revalidation a cheap 304.
17
+ */
18
+ const packageRoot = path.join(__dirname, '..');
19
+ const fixturePath = path.join(__dirname, 'fixture', 'gracefulShutdownServer.js');
20
+
21
+ describe('html cache policy', () => {
22
+ let fixture: Fixture | undefined;
23
+
24
+ afterEach(async () => {
25
+ if (fixture && fixture.child.exitCode === null && fixture.child.signalCode === null) {
26
+ fixture.child.kill('SIGKILL');
27
+ await fixture.exited.catch(() => undefined);
28
+ }
29
+ fixture = undefined;
30
+ });
31
+
32
+ it('the react-app HTML response says Cache-Control: no-cache — the page always revalidates', async () => {
33
+ fixture = await startFixture();
34
+
35
+ const result = await request(fixture.port, '/');
36
+ expect(result.status).toBe(200);
37
+ // Sanity: this IS the react-app page (the configured bundle pointer is in the body).
38
+ expect(result.body).toContain('bundles/app.test.js');
39
+ expect(result.headers['content-type']).toContain('text/html');
40
+ expect(result.headers['cache-control']).toBe('no-cache');
41
+ }, 30000);
42
+
43
+ it('every page path rides the same policy (the catch-all route is the one HTML owner)', async () => {
44
+ fixture = await startFixture();
45
+
46
+ const result = await request(fixture.port, '/chat');
47
+ expect(result.status).toBe(200);
48
+ expect(result.headers['content-type']).toContain('text/html');
49
+ expect(result.headers['cache-control']).toBe('no-cache');
50
+ }, 30000);
51
+ });
52
+
53
+ type Fixture = {
54
+ child: ChildProcess;
55
+ port: number;
56
+ exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
57
+ };
58
+
59
+ async function startFixture(): Promise<Fixture> {
60
+ const port = await ephemeralPort();
61
+ // Scrub the env vars startServer reads (dev machines export some of these): the fixture's
62
+ // behavior must come from its own config only.
63
+ const env = { ...process.env };
64
+ delete env.SERVER_PORT;
65
+ delete env.DEVELOPMENT;
66
+ delete env.DISABLE_HOT_CLIENT_BUILDS;
67
+ delete env.HMR_PORT;
68
+ const child = spawn(process.execPath, [fixturePath], {
69
+ cwd: packageRoot,
70
+ env: { ...env, FIXTURE_PORT: String(port) },
71
+ stdio: ['ignore', 'pipe', 'pipe'],
72
+ });
73
+ let output = '';
74
+ child.stdout!.on('data', (chunk) => (output += String(chunk)));
75
+ child.stderr!.on('data', (chunk) => (output += String(chunk)));
76
+ const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) =>
77
+ child.once('exit', (code, signal) => resolve({ code, signal }))
78
+ );
79
+ // Ready = the health check answers 200 (readiness through the front door, not log lines).
80
+ const deadline = Date.now() + 20000;
81
+ for (;;) {
82
+ if (child.exitCode !== null || child.signalCode !== null) {
83
+ throw new Error(`Fixture exited during boot; output:\n${output}`);
84
+ }
85
+ const result = await request(port, '/health-check').catch(() => undefined);
86
+ if (result?.status === 200) {
87
+ return { child, port, exited };
88
+ }
89
+ if (Date.now() > deadline) {
90
+ throw new Error(`Fixture never became ready; output:\n${output}`);
91
+ }
92
+ await sleep(100);
93
+ }
94
+ }
95
+
96
+ /** One request on its OWN connection (agent: false — no keep-alive pooling). The forwarded
97
+ * proto is the LB topology in miniature: the prod shape 302s plain http to https. */
98
+ function request(
99
+ port: number,
100
+ requestPath: string
101
+ ): Promise<{ status: number; body: string; headers: http.IncomingHttpHeaders }> {
102
+ return new Promise((resolve, reject) => {
103
+ const req = http.get(
104
+ { host: '127.0.0.1', port, path: requestPath, agent: false, headers: { 'X-Forwarded-Proto': 'https' } },
105
+ (res) => {
106
+ let body = '';
107
+ res.on('data', (chunk) => (body += String(chunk)));
108
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body, headers: res.headers }));
109
+ res.on('error', reject);
110
+ }
111
+ );
112
+ req.on('error', reject);
113
+ });
114
+ }
115
+
116
+ async function ephemeralPort(): Promise<number> {
117
+ return new Promise((resolve, reject) => {
118
+ const probe = net.createServer();
119
+ probe.once('error', reject);
120
+ probe.listen(0, '127.0.0.1', () => {
121
+ const address = probe.address() as net.AddressInfo;
122
+ probe.close(() => resolve(address.port));
123
+ });
124
+ });
125
+ }
126
+
127
+ function sleep(ms: number): Promise<void> {
128
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, ms)));
129
+ }