@astrale-os/kernel-server 1.0.0-beta.4 → 1.0.0-beta.5

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.
@@ -3,7 +3,9 @@ import * as errors from '@astrale-os/kernel-protocol/errors';
3
3
  export declare function classifyError(input: unknown): errors.ErrorPayload;
4
4
  /**
5
5
  * Classify the failure of one request. Its own deadline or cancellation names the failure, except an
6
- * outcome already unknown: ending the request proves nothing about an effect.
6
+ * outcome already unknown: ending the request proves nothing about an effect. A request the Server's
7
+ * close ended keeps the draining refusal only when that refusal is the failure itself: the Server
8
+ * refused before dispatch, or the Handler proved it ran nothing.
7
9
  */
8
10
  export declare function classifyRequestError(input: unknown, request: {
9
11
  readonly timedOut: boolean;
@@ -19,7 +19,9 @@ export function classifyError(input) {
19
19
  }
20
20
  /**
21
21
  * Classify the failure of one request. Its own deadline or cancellation names the failure, except an
22
- * outcome already unknown: ending the request proves nothing about an effect.
22
+ * outcome already unknown: ending the request proves nothing about an effect. A request the Server's
23
+ * close ended keeps the draining refusal only when that refusal is the failure itself: the Server
24
+ * refused before dispatch, or the Handler proved it ran nothing.
23
25
  */
24
26
  export function classifyRequestError(input, request) {
25
27
  if (input instanceof errors.ProtocolError &&
@@ -28,10 +30,18 @@ export function classifyRequestError(input, request) {
28
30
  }
29
31
  if (request.timedOut)
30
32
  return publicError('DEADLINE_EXCEEDED', 'Invocation deadline exceeded.');
31
- if (request.signal.aborted)
33
+ if (request.signal.aborted) {
34
+ if (draining(request.signal.reason) && draining(input))
35
+ return input.payload;
32
36
  return publicError('CANCELLED', 'Invocation was cancelled.');
37
+ }
33
38
  return classifyError(input);
34
39
  }
40
+ function draining(input) {
41
+ return (input instanceof errors.ProtocolError &&
42
+ errors.retryAfterOf(input.payload) !== undefined &&
43
+ errors.reasonOf(input.payload.data)?.code === errors.RETRY_REASONS.SERVER_DRAINING);
44
+ }
35
45
  function publicError(name, message) {
36
46
  return { code: errors.CODES[name], message };
37
47
  }
@@ -55,6 +55,8 @@ export interface HandlerRequest {
55
55
  readonly via?: protocol.Via;
56
56
  /** Source-only local-target or remote-revision Schema Registration precondition. */
57
57
  readonly schema?: protocol.SchemaTarget;
58
+ /** Source-only maintenance session guard forwarded unchanged to Runtime. */
59
+ readonly maintenance?: protocol.Maintenance;
58
60
  /** Exact Server-pinned release for a request at a release-backed destination. */
59
61
  readonly release?: ReleaseSnapshot;
60
62
  readonly context: InvocationContext;
@@ -11,7 +11,8 @@ export interface ActiveOperation {
11
11
  readonly timedOut: boolean;
12
12
  /** Register cooperative cleanup and return an idempotent unregistration function. */
13
13
  onCancel(cancel: CancelWork): () => void;
14
- cancel(reason: string): void;
14
+ /** Abort with `cause` as the signal reason, or a plain Error naming `reason`. */
15
+ cancel(reason: string, cause?: Error): void;
15
16
  /** Replace the remaining budget after a trusted transport binding is admitted. */
16
17
  extend(timeoutMs: number): void;
17
18
  settle(): void;
@@ -1,3 +1,5 @@
1
+ import * as errors from '@astrale-os/kernel-protocol/errors';
2
+ import { admissionRefusal } from '../errors/index.js';
1
3
  // Streaming iterators can need several microtasks to return after cancellation. Keep the
2
4
  // cleanup join bounded even when a producer ignores its cancellation signal.
3
5
  const CANCELLATION_CLEANUP_GRACE_MS = 250;
@@ -85,8 +87,10 @@ export class ActiveOperations {
85
87
  await this.waitUntilIdleOr(deadline);
86
88
  const hasCancellationWork = [...this.operations].some((operation) => operation.hasCancellationWork);
87
89
  if (this.operations.size > 0) {
90
+ // The typed draining refusal lets a Handler that ran nothing answer it, to be retried elsewhere.
91
+ const closing = new errors.ProtocolError(admissionRefusal(false));
88
92
  for (const operation of this.operations)
89
- operation.cancel('server closing');
93
+ operation.cancel('server closing', closing);
90
94
  }
91
95
  this.closeSockets();
92
96
  if (hasCancellationWork) {
@@ -196,13 +200,13 @@ class Operation {
196
200
  this.cancelWork.delete(cancel);
197
201
  };
198
202
  }
199
- cancel(reason) {
203
+ cancel(reason, cause) {
200
204
  if (this.cancelled || this.settled)
201
205
  return;
202
206
  this.cancelled = true;
203
207
  clearTimeout(this.timer);
204
208
  this.detachExternal();
205
- this.controller.abort(new Error(reason));
209
+ this.controller.abort(cause ?? new Error(reason));
206
210
  for (const cancel of this.cancelWork)
207
211
  void settleCancellation(cancel, reason);
208
212
  this.cancelWork.clear();
package/dist/server.js CHANGED
@@ -71,7 +71,13 @@ function createPreparedServer(input, config) {
71
71
  if (config.health !== undefined) {
72
72
  app.get(config.health.path, (context) => {
73
73
  const ready = active.isAvailable && config.health.probe.check();
74
- return context.json({ status: ready ? 'ready' : 'not-ready' }, ready ? 200 : 503, {
74
+ const maintenance = config.health.probe.maintenance?.();
75
+ const uncertified = config.health.probe.uncertified?.() === true;
76
+ return context.json({
77
+ status: ready ? 'ready' : 'not-ready',
78
+ ...(maintenance === undefined ? {} : { maintenance }),
79
+ ...(uncertified ? { certification: 'uncertified' } : {}),
80
+ }, ready ? 200 : 503, {
75
81
  ...config.cors.response(context.req.raw),
76
82
  'cache-control': 'no-store',
77
83
  });
@@ -224,7 +230,25 @@ function servedDocumentPaths(_kind, issuer) {
224
230
  }
225
231
  function captureHealthProbe(probe) {
226
232
  const check = captureMethod(probe, 'check', 'Health probe is invalid.');
227
- return Object.freeze({ check });
233
+ const maintenance = probe.maintenance === undefined
234
+ ? undefined
235
+ : captureMethod(probe, 'maintenance', 'Health probe is invalid.');
236
+ const uncertified = probe.uncertified === undefined
237
+ ? undefined
238
+ : captureMethod(probe, 'uncertified', 'Health probe is invalid.');
239
+ return Object.freeze({
240
+ check,
241
+ ...(maintenance === undefined
242
+ ? {}
243
+ : {
244
+ // A mode that is not a short token is not reported rather than echoed.
245
+ maintenance: () => {
246
+ const mode = maintenance();
247
+ return typeof mode === 'string' && /^[a-z]{1,32}$/u.test(mode) ? mode : undefined;
248
+ },
249
+ }),
250
+ ...(uncertified === undefined ? {} : { uncertified: () => uncertified() === true }),
251
+ });
228
252
  }
229
253
  function captureWebSocket(adapter) {
230
254
  const upgrade = captureMethod(adapter, 'upgrade', 'WebSocket adapter is invalid.');
@@ -176,6 +176,7 @@ export function createHttpHandler(config) {
176
176
  ...(envelope.delegate === undefined ? {} : { delegate: envelope.delegate }),
177
177
  ...(envelope.routing?.kind === 'via' ? { via: envelope.routing } : {}),
178
178
  ...(envelope.schema === undefined ? {} : { schema: envelope.schema }),
179
+ ...(envelope.maintenance === undefined ? {} : { maintenance: envelope.maintenance }),
179
180
  ...(served === undefined ? {} : pinned(served)),
180
181
  context: invocationContext(assigned, active, config.active, host, undefined, envelope.idempotencyKey, binary),
181
182
  }));
@@ -142,6 +142,7 @@ export function createWebSocketSession(config) {
142
142
  ...(envelope.delegate === undefined ? {} : { delegate: envelope.delegate }),
143
143
  ...(envelope.routing?.kind === 'via' ? { via: envelope.routing } : {}),
144
144
  ...(envelope.schema === undefined ? {} : { schema: envelope.schema }),
145
+ ...(envelope.maintenance === undefined ? {} : { maintenance: envelope.maintenance }),
145
146
  ...(served === undefined ? {} : pinned(served)),
146
147
  context: invocationContext(assigned, operation, config.active, config.host, undefined, envelope.idempotencyKey),
147
148
  }));
package/dist/types.d.ts CHANGED
@@ -35,6 +35,16 @@ export interface ServerCloseOptions {
35
35
  export interface HealthProbe {
36
36
  /** Non-blocking snapshot; health never performs provider I/O. */
37
37
  check(): boolean;
38
+ /**
39
+ * The Instance admission mode the Host reports beside readiness: maintenance never lowers
40
+ * readiness, so health names it instead. A non-blocking snapshot, absent to report nothing.
41
+ */
42
+ maintenance?(): string | undefined;
43
+ /**
44
+ * True while the Instance carries the break-glass `uncertified` mark: no certificate covers its
45
+ * data until an install validates it in full. Health names it; it never lowers readiness.
46
+ */
47
+ uncertified?(): boolean;
38
48
  }
39
49
  /** Static transport configuration admitted before Runtime Boot performs I/O. */
40
50
  export interface ServerOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrale-os/kernel-server",
3
- "version": "1.0.0-beta.4",
3
+ "version": "1.0.0-beta.5",
4
4
  "description": "Portable HTTP and WebSocket boundary for Astrale invocation, Release, installation, and issuer protocols",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -25,9 +25,9 @@
25
25
  "access": "public"
26
26
  },
27
27
  "dependencies": {
28
- "@astrale-os/kernel-core": "1.0.0-beta.4",
28
+ "@astrale-os/kernel-core": "1.0.0-beta.5",
29
29
  "@astrale-os/kernel-dsl": "1.0.0-beta.3",
30
- "@astrale-os/kernel-protocol": "1.0.0-beta.4",
30
+ "@astrale-os/kernel-protocol": "1.0.0-beta.5",
31
31
  "hono": "^4.6.20"
32
32
  },
33
33
  "devDependencies": {