Repository navigation
Expand file tree
/
Copy pathserve.ts
More file actions
7112 lines (6881 loc) · 388 KB
/
Copy pathserve.ts
File metadata and controls
7112 lines (6881 loc) · 388 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { Args, Command, Flags } from '@oclif/core';
import path from 'path';
import fs from 'fs';
import net from 'net';
import chalk, { chalkStderr } from 'chalk';
import { bundleRequire } from 'bundle-require';
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
import { mergeBootConfig } from '../utils/merge-boot-config.js';
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
import { makeDirectorySync } from '../utils/make-directory.js';
// The precedence's last rung — whether the cwd config takes part — decided by
// the SAME predicate the supervisors print their `Config:` row by (#21501).
import { cwdConfigJoinsBoot } from '../utils/artifact-precedence.js';
import {
resolveDriverType,
resolveStorageDefinition,
loadTursoDriverFactory,
isTursoDriverId,
MissingDriverPackageError,
UnsupportedDriverError,
} from '../utils/storage-driver.js';
// [ADR-0105 D1] `resolveMultiOrgEnabled` is deliberately NOT imported here: the
// posture is the authoritative knob and `resolveTenancyPosture()` already folds
// the legacy boolean in as its unset-fallback. serve's last direct reader of the
// boolean was the banner, and that was exactly the drift #4801 fixed.
import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel';
// [#22301] The `requires` token → provider table and its exact identity match,
// shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS`
// — and, beside it, which tokens a served boot mounts (`requires` plus the
// always-on slate) and what it constructs each provider with.
import {
CAPABILITY_PROVIDERS,
providesCapability,
resolveServedCapabilities,
resolveCapabilityArgument,
materializeStackPlugin,
STACK_TIER_PRESETS,
CAPABILITY_TO_TIER as SHARED_CAPABILITY_TO_TIER,
resolveStackTiers,
resolveAuthSecret,
resolvePlatformAuthComposition,
stackSuppliesAuthPlugin,
stackDeclaresOrganizationRecordPage,
isDevelopmentBoot,
type CapabilitySpec,
} from '@objectstack/core';
// The posture vocabulary, read from the package that DEFINES it (#5359) — the
// boot gate's fix list enumerates the accepted values, and a second literal
// list would be free to drift the day a posture is added.
import { TENANCY_POSTURES, type TenancyPosture } from '@objectstack/spec/security';
// #17329 — the PUBLISHED settlement contract, READ rather than re-derived, so
// the `objectstack:seed-settled` announcement below asks the one component that
// knows whether this boot's seeding has come to rest. The service is registered
// by the runtime this command itself boots (`@objectstack/runtime`'s
// `declareSeedSource`); `packages/cli` never registers or mutates it — the
// contract is deliberately read-only, because a consumer that could edit the
// tally could certify itself.
import {
SEED_SETTLEMENT_SERVICE,
type ISeedSettlementService,
type SeedSettlementSnapshot,
type SeedSuppressionReason,
} from '@objectstack/spec/contracts';
import { missingProviderMessage } from '../utils/capability-preflight.js';
// The deployment's product name, for `AuthPlugin` — the same resolver the email
// capability's template context reads (`resolveEmailCapabilityArg`), so the two
// cannot disagree. Both moved to `@objectstack/plugin-email` beside the mail
// provider vocabulary they read (#22301; see the re-exports at the foot of this
// file). The capability loop still loads `EmailServicePlugin` itself with a bare
// `import()`, resolved against THIS CLI's own realpath — its bundled copy always
// wins, never the host app's — and reads the email and SMS readers off that
// module. Contrast `importConfigPlugin` below, which IS host-anchored: an
// app-declared package wins there (per commit 5a90c56d1).
import { resolveDeploymentAppName } from '@objectstack/plugin-email';
import { createHash } from 'node:crypto';
import { resolveObjectStackHome } from '@objectstack/runtime';
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
// The ONE port contract — range, reader and refusal prose — shared with the two
// commands that spawn this one (#12673). `serve`'s own door below calls it
// exactly as `dev` and `start` call it, and it is the only place the range is
// declared; see the module docblock for why it is not an oclif flag `parse`.
import {
describePortSource,
parseRequestedPort,
formatInvalidPortNotice,
portTextReadNotice,
MAX_PORT,
type PortInputSource,
} from '../utils/port-contract.js';
// The ONE dev-TLS contract, shared with `dev` (which spawns this command) so the
// protocol the parent derives and the protocol this process actually binds come
// from a single reader (#16804). ⛔ Nothing about certificates is declared in
// this file, and ⛔ nothing anywhere generates one — see that module's header.
import {
devTlsCertFlag,
devTlsKeyFlag,
resolveDevTlsIntent,
readDevTlsMaterial,
listenerProtocol,
colorizeDevTlsNotice,
type DevTlsMaterial,
type ListenerProtocol,
} from '../utils/dev-tls-contract.js';
import { BootLogCapture, isVerboseBootLevel } from '../utils/boot-log-capture.js';
import { graftAuthoredRuntimeMembers, isAppPluginLike } from '../utils/graft-runtime-hooks.js';
// [ADR-0130 D4 / option B, #15006] Every read below that keys off a
// PACKAGE-OWNED collection goes through this seam, so an option-B artifact
// (flattened top level gone, `packages[]` carrying everything once) reaches
// the same decision — and so the acceptance probe can CALL the decision
// instead of re-implementing it.
import {
shouldAutoRegisterObjectQL,
shouldAutoRegisterStorageDriver,
stackDeclaresMetadata,
bundleDeclaresTranslations,
resolveStackCollection,
stackDeclaredCapabilities,
stackBootPlugins,
} from '../utils/stack-collections.js';
import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from '../utils/connection-display.js';
import { captureServedDatabaseFile, watchServedDatabaseFile } from '../utils/served-database-file.js';
// The posture prose `os serve` and `os doctor` BOTH print, declared once
// (#12492) — and, since #12579, the multi-org runtime SPELLING those two
// commands put in front of the same operator, declared once with it.
//
// Renamed on import so `Serve.ORGANIZATIONS_RUNTIME_PKG` below keeps its own
// NAME: separate pins address that static by name, and only the spelling moved.
import {
ORGANIZATIONS_RUNTIME_PKG as SHARED_ORGANIZATIONS_RUNTIME_PKG,
TENANCY_POSTURE_FIX_HINTS,
} from '../utils/tenancy-posture-hints.js';
// Shared with @objectstack/verify and the dogfood multi-org probes (#4700) —
// node-only, hence the `/node` subpath rather than the edge-safe root export.
import {
createHostImporter,
hostImportFailureKind,
isDeclaredByHost,
packageNameFromSpecifier,
readHostDeclaration,
type HostDeclaration,
type HostImporter,
type HostImportFailureKind,
} from '@objectstack/types/node';
import {
printHeader,
printKV,
printSuccess,
printError,
printStep,
printInfo,
printServerReady,
printBootDiagnostics,
type AutomationReadySummary,
type HttpServerComposition,
type SeedSourceSummary,
} from '../utils/format.js';
// [#16630] The reader half of the degraded-boot data path — the kernel's own
// missing-core-service list, fetched, never re-derived. See its header.
import { readMissingCoreServices } from '../utils/degraded-capabilities.js';
import { redirectStdoutToStderr } from '../utils/json-stdout.js';
import {
CONSOLE_PATH,
resolveConsolePath,
hasConsoleDist,
decideConsoleMount,
formatConsoleShaDriftWarning,
formatConsoleShaDriftRefusal,
formatConsoleDistMissingWarning,
createConsoleStaticPlugin,
createConsoleNotBuiltPlugin,
createConsoleShaDriftRefusalPlugin,
createRuntimeAssetsPlugin,
type ConsoleShaDrift,
} from '../utils/console.js';
// [#22579] The project's `.env*` files, loaded by the one function `os migrate`'s
// boot (`bootSchemaStack`) loads them through too.
import { loadProjectEnvFiles } from '../utils/schema-migrate.js';
// Metric NAMES and the metrics-service name, from the package that owns both.
// `buildServeObservability()` below reaches the same package through a dynamic
// `import()` with a "not installed — silently skip" catch; this STATIC import
// adds no new failure mode on top of it, and the measurement is written down
// so the next reader does not have to re-derive it: `@objectstack/runtime` is
// imported statically a few lines above, its index re-exports
// `./observability/index.js`, and that module statically imports
// `@objectstack/observability` for `OBSERVABILITY_METRICS_SERVICE`. So loading
// this command already requires the package to resolve; the dynamic form there
// is about the exporter CLASSES, not about reachability.
import { SEMCONV, OBSERVABILITY_METRICS_SERVICE, type MetricsRegistry } from '@objectstack/observability';
// ---------------------------------------------------------------------------
// Observability bootstrap for `objectstack serve`
//
// Reads OS_OBS_* env vars and returns a `{ metrics, errorReporter }` block
// to hand off to `createDispatcherPlugin`. Default is fully noop so the
// CLI imposes no runtime cost when observability isn't configured.
//
// Env knobs — the SAME names are also read by the cloud host
// (`apps/cloud/server/observability.ts`, cloud repo), so keep the two in sync
// if you tweak NAMES. That duty is live, not stale prose: the cloud file reads
// these as `process.env` lookups — measured on #15295 for every knob listed
// below EXCEPT `OS_OTLP_FLUSH_MS`, which was added on this side afterwards and
// has never been measured against that repository: treat that one as
// unverified rather than mirrored.
//
// ⛔ NAMES only — the DEFAULTS differ on purpose, and unifying them is a
// telemetry regression rather than a tidy-up: `OS_OBS_SERVICE_NAME` defaults
// to `objectstack` here and to `objectstack-cloud` there because two
// deployments are two services; collapsing them merges both into one series.
//
// The canonical home for this list is `@objectstack/observability` — the
// package BOTH sides already import (statically, a few lines above; the cloud
// file imports it too) — not these comments pointing at each other, which
// is how the duty decayed to begin with: it is one-sided today, the cloud file
// carries no reciprocal sentence, so nobody renaming a name over there is
// prompted to come back here. Until the list lives in that package, this block
// is it, and moving the cloud half is a change in the cloud repo (#17693).
//
// ⚠️ And the carriers are not two but three:
// `content/docs/deployment/environment-variables.mdx` (`## Observability`)
// publishes this same list with types and defaults, and it is the one most
// readers reach first. Its `OS_OBS_SERVICE_NAME` row states the framework
// default with no note that the cloud host differs on purpose, so the
// NAMES-only rule above has to be honoured there too. Fixing that page is out
// of this block's scope; the caveat rides with the canonical-home decision
// (#17693).
// OS_OBS_EXPORTER noop (default) | console | json | otlp
// OS_OTLP_ENDPOINT OTLP/HTTP root, e.g. https://otlp.grafana.net/otlp
// OS_OTLP_HEADERS comma-separated Key=Value; values may be URL-encoded
// (Grafana ships `Authorization=Basic%20<base64>`)
// OS_OBS_SERVICE_NAME resource attr, default `objectstack`
// OS_OBS_DEPLOYMENT_ENV resource attr, default `production`
// OS_OTLP_FLUSH_MS buffer flush interval (default 10000)
// ---------------------------------------------------------------------------
function parseObsHeaders(spec: string | undefined): Record<string, string> {
if (!spec) return {};
const out: Record<string, string> = {};
for (const pair of spec.split(',')) {
const i = pair.indexOf('=');
if (i < 0) continue;
const k = pair.slice(0, i).trim();
const raw = pair.slice(i + 1).trim();
if (!k) continue;
let v = raw;
try { v = decodeURIComponent(raw); } catch { /* keep raw */ }
out[k] = v;
}
return out;
}
async function buildServeObservability(): Promise<{ metrics: any; errorReporter: any } | undefined> {
const exporter = (process.env.OS_OBS_EXPORTER ?? 'noop').toLowerCase();
if (exporter === 'noop') return undefined; // dispatcher falls back to its own NoopMetricsRegistry
let mod: any;
try {
mod = await import('@objectstack/observability');
} catch {
return undefined; // observability pkg not installed — silently skip
}
try {
let metrics: any;
if (exporter === 'console' || exporter === 'json') {
metrics = new mod.ConsoleMetricsRegistry();
} else if (exporter === 'otlp') {
const endpoint = process.env.OS_OTLP_ENDPOINT;
if (!endpoint) {
console.warn('[observability] OS_OBS_EXPORTER=otlp but OS_OTLP_ENDPOINT is empty — falling back to noop');
return undefined;
}
const resource = {
'service.name': process.env.OS_OBS_SERVICE_NAME ?? 'objectstack',
'deployment.environment': process.env.OS_OBS_DEPLOYMENT_ENV ?? 'production',
};
metrics = new mod.OtlpHttpMetricsRegistry({
endpoint,
headers: parseObsHeaders(process.env.OS_OTLP_HEADERS),
resource,
onError: (err: unknown) => {
console.warn('[observability] OTLP export failed:', (err as any)?.message ?? err);
},
});
const flushMs = Number(process.env.OS_OTLP_FLUSH_MS ?? '10000');
if (flushMs > 0) {
const timer = setInterval(() => {
(metrics as any).flush?.().catch(() => { /* swallowed via onError */ });
}, flushMs);
if (typeof (timer as any).unref === 'function') (timer as any).unref();
}
} else {
return undefined;
}
const errorReporter = new mod.ConsoleErrorReporter();
return { metrics, errorReporter };
} catch (err) {
console.warn('[observability] init failed; falling back to noop:', (err as any)?.message ?? err);
return undefined;
}
}
// Probe whether a TCP port can be bound right now.
const isPortAvailable = (port: number): Promise<boolean> => {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', () => {
resolve(false);
});
server.once('listening', () => {
server.close(() => resolve(true));
});
server.listen(port);
});
};
/**
* How far past `startPort` the dev auto-shift search walks before giving up.
*
* ⚠️ This is a SPAN, not a count: the search probes `startPort` itself and then
* every port up to and including `startPort + PORT_SEARCH_SPAN`, so the number
* of ports actually probed is `PORT_SEARCH_SPAN + 1`. Both numbers are read
* from this one constant — by the loop below and by
* {@link formatExhaustedPortSearchNotice}, which states the range to the
* operator. ⛔ Never hand-write either number anywhere else: an off-by-one
* inside a diagnostic that exists to be accurate is the defect it was added to
* fix (#12620).
*/
const PORT_SEARCH_SPAN = 100;
/**
* The one thing {@link getAvailablePort} does to the outside world, injectable
* so its exhausted path can be driven without binding a socket.
*
* ⛔ The alternative is a test that holds 101 real ports: slow, flaky, and
* hostile to a shared many-agent container whose ephemeral range is already
* crowded (#12441 measured that contention costing a suite a red run). The
* production call site passes nothing and gets {@link isPortAvailable}.
*/
type PortProbe = (port: number) => Promise<boolean>;
/**
* Helper to find available port (dev convenience — see the gated caller).
*
* A plain contiguous `port++` walk: `startPort`, `startPort + 1`, … with **no
* skip**. ⚠️ Nearby cards have described this search as hopping over ports; it
* does not, and a diagnostic written from that reading would be wrong.
*/
export class PortSearchExhaustedError extends Error {
/** The first port probed — the walk's own record of it, not the caller's. */
readonly startPort: number;
/** The last port probed. Inclusive, and derived from {@link PORT_SEARCH_SPAN}. */
readonly lastPort: number;
/** How many ports were probed: every one of them, and every one busy. */
readonly probedCount: number;
constructor(startPort: number) {
// ⭐ The message is UNCHANGED from the plain `Error` this replaces. It is
// the sentence the notice carries verbatim, so it is not the class's to
// reword — the type is added to say WHICH failure this is, not to say it
// differently.
super(`Could not find an available port starting from ${startPort}`);
this.name = 'PortSearchExhaustedError';
this.startPort = startPort;
this.lastPort = startPort + PORT_SEARCH_SPAN;
this.probedCount = PORT_SEARCH_SPAN + 1;
}
}
export const getAvailablePort = async (
startPort: number,
probe: PortProbe = isPortAvailable,
): Promise<number> => {
let port = startPort;
while (!(await probe(port))) {
port++;
if (port > startPort + PORT_SEARCH_SPAN) {
throw new PortSearchExhaustedError(startPort);
}
}
return port;
};
/**
* The notice for a search that ran out of ports (#12620).
*
* ## The fallthrough is deliberate; the SILENCE was the defect
*
* When {@link getAvailablePort} exhausts its span the caller swallows the throw
* and binds `requestedPort` anyway — which is defensible on its own terms: a
* dev whose whole next span is busy arguably wants the requested port attempted
* rather than a hard refusal. ⛔ Whether it should refuse instead is #11113's
* production/development policy split and is deliberately NOT decided here;
* nothing in this function changes what `os serve` does.
*
* What is not defensible is doing it in silence. This is the one shape in the
* whole port policy where the operator gets **neither** half of the legibility
* work this family has landed: the production `Port … is already in use` line
* lives in the `else if` this boot never enters, and #12543's drift notice is
* gated on `port !== requestedPort`, which on this path is false because the
* assignment never happened. The boot then dies on the kernel's raw error with
* the accurate explanation discarded one line earlier.
*
* ## ⭐ It CARRIES the thrown message; it does not paraphrase it
*
* `Could not find an available port starting from ${startPort}` already names
* the problem exactly. Rewriting it into a fresh sentence would create a second
* spelling of one fact, free to drift from the first. So the caught error's own
* `message` is the headline, verbatim, and every other line here adds a
* DIFFERENT fact: how wide the search was, what happens next, and what to do.
*
* `cause` is typed `unknown` and rendered defensively rather than narrowed to
* `Error`: the point is to carry whatever text the seam actually produced, and
* a probe that rejects with a non-`Error` must not turn this notice into
* `[object Object]`.
*
* ## CHANNEL — measured, not chosen (#7915)
*
* The caller writes this through `printDiagnostic`, straight to **stderr**.
* `stdout` is the JSON-RPC channel whenever the stdio MCP transport is mounted,
* where one non-frame line reaches a conforming client as a transport error;
* `serve-stdio-stdout-purity.e2e.test.ts` exists to pin exactly that.
*/
export function formatExhaustedPortSearchNotice(requestedPort: number, cause: unknown): string {
// ⭐ The thrown text, carried — not restated. See the docblock.
const thrown = cause instanceof Error ? cause.message : String(cause);
// ⚠️ The `catch` this feeds catches EVERY rejection, not only an exhausted
// walk, and the two cannot share a body. `isPortAvailable` rejects
// synchronously with `ERR_SOCKET_BAD_PORT` whenever it is handed a port
// outside 0–65535 — reachable by a walk that starts high enough to cross the
// ceiling. (⚠️ It used to be reachable a second way, from `--port` text that
// `parseInt` turned into `NaN`; since #12662 that value is refused before
// this policy runs, so a crossing walk is the only route left. The branch
// below is unchanged: the walk route still reaches it.) On that path
// nothing was exhausted, so a body claiming a probed range would
// print `NaN–NaN` and assert a search that never ran. A diagnostic that
// exists to be accurate does not get to guess, so the span sentence is
// reached only through the error that actually carries the span.
if (!(cause instanceof PortSearchExhaustedError)) {
return (
'\n'
+ chalk.yellow(` ⚠ ${thrown}\n`)
+ chalk.dim(' The development port search could not run to completion, so this server\n')
+ chalk.dim(` is falling back to the port it was asked for (${requestedPort}). If that port is\n`)
+ chalk.dim(' taken the bind that follows will fail with a raw EADDRINUSE from the\n')
+ chalk.dim(' kernel, and this notice is the only place that says why.')
);
}
// Every number below is the ERROR's own, recorded by the walk that failed —
// never re-derived here from `requestedPort`, which is a second source that
// could disagree with the first.
const { startPort, lastPort, probedCount } = cause;
return (
'\n'
+ chalk.yellow(` ⚠ ${thrown}\n`)
+ chalk.dim(` Development auto-shift probed ${probedCount} ports (${startPort}–${lastPort}) and every\n`)
+ chalk.dim(` one was busy, so this server is falling back to ${startPort} — the port the\n`)
+ chalk.dim(' search has just proven is taken. The bind that follows will almost\n')
+ chalk.dim(' certainly fail with a raw EADDRINUSE from the kernel, and this notice\n')
+ chalk.dim(' is the only place that says why.\n')
+ chalk.dim(` Free a port in ${startPort}–${lastPort}, or pick another via PORT=<port> (or --port <port>).`)
);
}
/**
* The port the HTTP server ACTUALLY bound, read off the transport that bound it
* (#13062).
*
* ## The defect this closes
*
* `serve` used to publish the port it was ASKED for on all three of the
* channels that ANNOUNCE an address — the `objectstack:listening` IPC message,
* the ready banner's `API:` row (through {@link resolveAuthBaseUrl}), and
* `runtime.<environment>.<project>.json`. For every port but one the requested
* and the bound value coincide, which is why it stayed invisible; for `0` they
* CANNOT coincide. `MIN_PORT = 0` is legal on purpose — `utils/port-contract.ts` says
* so in its own words, from its own measurement, that 0 is "a REQUEST, not an
* error", and `listen(0)` binds a kernel-assigned port. So `os serve --port 0`
* announced `{ port: 0 }`, printed `API: http://localhost:0/` and wrote
* `"port": 0`: three channels naming an address nothing is listening on, and
* nothing anywhere erroring.
*
* ## Where the answer comes from, and why it is not a second measurement
*
* `IHttpServer.getPort?()` is a DECLARED contract member
* (`@objectstack/spec/contracts`): *"after `listen()` resolves, implementations
* that provide this member MUST return the real bound port — in particular when
* `listen(0)` requested an ephemeral port"*. The Hono transport fills it from
* `@hono/node-server`'s own listening callback (`info.port`, the socket's
* `address().port`). So this asks the one component that KNOWS instead of
* re-deriving the number from the request — the same rule
* {@link formatExhaustedPortSearchNotice} follows just above, where every number
* printed is the failing walk's own record rather than a recomputation.
*
* ## The fallback IS today's behaviour, deliberately
*
* A boot with no transport (`--server=false`), a host that mounts a transport
* not implementing the optional member, or an answer that cannot be a bound
* port, all return `requestedPort` — exactly what every channel published
* before this function existed. This repair may not narrow what boots.
*
* ⚠️ On the first of those — no transport at all — the number names nothing
* that listens, so `serve` publishes it on no channel (#23743): the publish
* site is gated on {@link resolveHttpServerComposition}, not on this value.
*
* ⛔ `0` is NOT accepted as an ANSWER here, though it is a legal REQUEST: no
* socket is ever bound to port 0, so a transport reporting it has not listened
* yet (`HonoHttpServer.getPort()` falls back to its constructor argument until
* `listen()` resolves), and republishing it is the defect itself.
*/
export function resolveBoundPort(
kernel: { getService?: (name: string) => unknown } | undefined,
requestedPort: number,
): number {
const server = readComposedHttpServer(kernel) as { getPort?: () => unknown } | undefined;
if (typeof server?.getPort !== 'function') return requestedPort;
let reported: unknown;
try {
reported = server.getPort();
} catch {
return requestedPort;
}
if (typeof reported !== 'number' || !Number.isInteger(reported)) return requestedPort;
if (reported <= 0 || reported > MAX_PORT) return requestedPort;
return reported;
}
/**
* The HTTP server this boot composed: the kernel's `http.server` service, or
* `undefined` when nothing registered one.
*
* The ONE read behind both {@link resolveBoundPort} (which port the transport
* bound) and {@link resolveHttpServerComposition} (whether there is a
* transport at all), so the banner's address and the banner's decision to
* print one cannot come from two different answers.
*
* `http.server` is the CANONICAL service name; `http-server` is a deprecated
* alias for the same instance (#4251) that new code must not read. ⚠️
* `getService` THROWS when nothing registered the name — it does not return
* undefined — hence the `try`, not a `?.` chain alone.
*/
export function readComposedHttpServer(
kernel: { getService?: (name: string) => unknown } | undefined,
): unknown {
try {
return kernel?.getService?.('http.server') ?? undefined;
} catch {
return undefined;
}
}
/**
* Whether this boot serves HTTP, for the ready banner's address rows (#23623).
*
* Read off the booted kernel, not off `--server`: under `--no-server` a config
* can still mount its own `HonoServerPlugin` (serve skips only its own
* auto-wired one), and then an `http.server` is registered and its address is
* real. `serverFlag` only says WHY there is none, so the banner names
* `--no-server` when that flag is what left the boot without one, and not
* otherwise.
*/
export function resolveHttpServerComposition(
kernel: { getService?: (name: string) => unknown } | undefined,
serverFlag: boolean,
): HttpServerComposition {
if (readComposedHttpServer(kernel) !== undefined) return 'composed';
return serverFlag ? 'not-composed' : 'skipped-by-no-server';
}
/**
* The plugin that mounts `/api/v1/mcp` (and `/api/v1/mcp/skill`): the runtime
* dispatcher, `createDispatcherPlugin` in `@objectstack/runtime`. The same
* name `HonoServerPlugin` asks the kernel about before it warns that no data
* or discovery API is mounted.
*/
export const MCP_ROUTE_OWNER_PLUGIN = 'com.objectstack.runtime.dispatcher';
/**
* Whether the plugin that mounts the MCP route is composed on this boot
* (#23743), for the ready banner's `MCP:` row.
*
* ## The defect this closes
*
* `os serve objectstack.config.ts --no-server` with a config that mounts its
* own `HonoServerPlugin` printed `MCP: http://localhost:PORT/api/v1/mcp` and
* its connect hint, and `GET /api/v1/mcp` answered 404 while
* `/api/v1/data/<object>` answered 401: the transport listened, the route did
* not exist. The dispatcher that mounts it is composed only inside `serve`'s
* `if (flags.server)` block, and the row was gated on `http.server` (#23623)
* and on the MCP switch, neither of which says whether the route is mounted.
*
* ## Why this read
*
* Read off the booted kernel, like {@link readComposedHttpServer}, so a config
* that mounts its own dispatcher is answered too. The dispatcher registers no
* service (it only consumes `http.server`), so the kernel's plugin registry is
* the read: `hasPlugin` answers for every `kernel.use()` already made, and by
* the time the banner prints every one has been. The dispatcher mounts the
* route in its own `start()` only when an `http.server` is registered, so the
* caller pairs this with a composed transport.
*
* `false` when the kernel cannot answer (no `hasPlugin`, or it throws): an
* unanswerable question must not print a URL an AI client will be pointed at.
*/
export function readComposedMcpRouteOwner(
kernel: { hasPlugin?: (name: string) => boolean } | undefined,
): boolean {
try {
return typeof kernel?.hasPlugin === 'function' && kernel.hasPlugin(MCP_ROUTE_OWNER_PLUGIN) === true;
} catch {
return false;
}
}
/**
* The period of {@link holdKernelOnlyProcess}'s handle: the largest delay a
* Node timer accepts (2^31 - 1 ms, about 24.8 days). Its callback does nothing,
* so the period decides only how rarely the event loop wakes for it.
*/
const KERNEL_ONLY_HOLD_PERIOD_MS = 0x7fffffff;
/**
* Keep a boot that composes no HTTP transport alive until its kernel stops
* (#23744).
*
* ## The defect this closes
*
* `os serve objectstack.config.ts --no-server --preset minimal`, in production
* with no `OS_DATABASE_URL`, printed its ready banner ending `Press Ctrl+C to
* stop` and then exited 0 by itself, milliseconds later, with no signal and
* without its kernel ever shutting down. On a composed boot the HTTP listener
* holds the event loop. With none composed, nothing this command owns did. The
* same command stayed up whenever the storage driver happened to keep a
* connection pool (`--dev --no-server`, or `--no-server` with
* `OS_DATABASE_URL` set): measured, the only ref'd handle on those boots was
* the SQL driver's pool-reaping interval. So whether a kernel-only boot stayed
* up depended on the driver, and nothing decided it.
*
* ## Why "stay up" and not "run and exit"
*
* - The banner says `Press Ctrl+C to stop`.
* - The kernel is built to own the process: under `gracefulShutdown` a signal
* drains it and then exits.
* - The platform's background loops are `unref()`ed on purpose, so they never
* keep a process alive by themselves (`DispatchLoop` in `@objectstack/core`).
* The host decides the lifetime.
* - The run-and-exit kernel boot already exists, and is explicit:
* `OS_MIGRATE_AND_EXIT=1` shuts the kernel down and exits before any banner.
*
* ## The mechanism
*
* One ref'd interval whose callback does nothing. It is a handle, not a
* timeout: it ends nothing and decides nothing. The kernel's own shutdown
* releases it (`onShutdown`), so the kernel's signal handling stays the only
* thing that ends the process, with the kernel's exit code. ⛔ No signal
* listener here: the kernel installs its own, and a second one is how one
* SIGTERM used to exit a process more than once (#22286).
*
* Returns the handle, for the pin that reads it.
*/
export function holdKernelOnlyProcess(
kernel: { onShutdown(handler: () => Promise<void>): void },
): ReturnType<typeof setInterval> {
const hold = setInterval(() => {}, KERNEL_ONLY_HOLD_PERIOD_MS);
kernel.onShutdown(async () => {
clearInterval(hold);
});
return hold;
}
/**
* The `objectstack:listening` IPC message the `os dev` parent waits for.
*
* Sent only when an HTTP transport is composed (#23743): its documented
* meaning is "The HTTP server is bound", so a boot that binds none sends no
* such message rather than one naming a port nothing listens on.
*/
export interface ListeningMessage {
type: 'objectstack:listening';
port: number;
url: string;
}
/**
* The `objectstack:seed-settled` IPC message — "this boot's seeding has come to
* rest" (#17329).
*
* ## The defect this closes
*
* `✓ Server is ready` is true about the HTTP SERVER and says nothing about the
* APP. `AppPlugin` races its inline seed against a soft budget
* (`OS_INLINE_SEED_BUDGET_MS`, default 8s); over budget it hands the rest to a
* detached promise and the kernel starts anyway. So the banner can print, the
* parent can proceed, and eighty seconds later the continuation emits its error
* wall — and a parent that merely spawned the child has no readable signal that
* distinguishes that boot from one that seeded cleanly before the banner. Which
* side wins is decided by whether the seed fits its budget on a contended box,
* so the same command on the same corpus disagrees between two containers.
*
* Every signal that WOULD distinguish them — the over-budget warning, the error
* wall, the completion summary — arrives on the child's inherited stdio. Reading
* it costs the boot its TTY. This message is the same fact on the channel the
* parent already holds.
*
* ## When it is sent, and the one guarantee that makes it waitable
*
* Exactly once per boot, and never before {@link ListeningMessage}: seeding that
* settles DURING `runtime.start()` (the ordinary in-budget boot) is latched and
* released after {@link publishBoundPort} has driven its three channels, so a
* parent that waits for `objectstack:listening` first can never miss this one by
* being late. See {@link createSeedSettlementAnnouncer}.
*
* On a boot that binds no HTTP server no {@link ListeningMessage} is sent at
* all (#23743), and this one still is — it is then the boot's only message. A
* kernel-only boot still seeds, and a parent waiting for the settle must not
* hang for want of a listening message that is never coming.
*
* ## ⭐ `suppressed` is what keeps a waiting parent from hanging forever
*
* Two deployment shapes register a seed source and then deliberately never run
* it, so `app:seeded` never fires and the contract's `pending` stays above zero
* for the life of the process: **multi-tenant** (seeds replay per organization
* on `sys_organization` insert) and **`skipSeedData`** (an `os migrate` planning
* boot that must not write). A message keyed on `pending === 0` would never be
* sent in either, and its absence would be indistinguishable from a boot still
* writing — the exact ambiguity this card exists to end, one level up.
*
* So the predicate is {@link SeedSettlementSnapshot.inFlight}, not `pending`:
* *nothing is still writing*. Both modes reach `inFlight === 0` inside Phase 2
* `start()` (`suppress()` decrements the in-flight tally and records the reason),
* so the message is sent on those boots too — carrying the reasons, so a
* consumer can say **why** no rows landed instead of waiting for rows that were
* never coming. An empty `suppressed` with an empty `sources` is the third
* honest answer: this kernel has no seed pipeline at all.
*/
export interface SeedSettledMessage {
type: 'objectstack:seed-settled';
/**
* Whether every source that REPORTED an outcome reported a clean one — no
* rejected records, no dropped references, no install that came up empty.
*
* ⛔ Read it against {@link sources}, never alone: it is a verdict on what the
* seed summary contains, and a source that finished by throwing may record no
* outcome at all. `true` over an empty {@link sources} means "nothing reported
* a problem", which on a suppressed boot is simply "nothing ran".
*/
ok: boolean;
/**
* One entry per source this boot deliberately did not run. Non-empty means
* rows these datasets describe were never written by this process, and no
* later signal is coming for them.
*/
suppressed: readonly SeedSuppressionReason[];
/** Per-source counts — the same array the banner's `Seeds:` row renders. */
sources: SeedSourceSummary[];
}
/**
* Read the published settlement tally off the kernel this command booted.
*
* `undefined` means no seed pipeline registered on this kernel — a FACT by
* `kernel:ready`, since every source is declared in Phase 2 `start()`, and
* therefore an answer ("nothing to wait for") rather than a not-yet. ⚠️
* `getService` THROWS on an unregistered name rather than returning undefined,
* hence the `try`.
*/
export function readSeedSettlement(
kernel: { getService?: (name: string) => unknown } | undefined,
): SeedSettlementSnapshot | undefined {
try {
const svc = kernel?.getService?.(SEED_SETTLEMENT_SERVICE) as ISeedSettlementService | undefined;
if (!svc || typeof svc.snapshot !== 'function') return undefined;
const snapshot = svc.snapshot();
// A tally that does not answer the one question is the same as no tally.
if (typeof snapshot?.inFlight !== 'number') return undefined;
return snapshot;
} catch {
return undefined;
}
}
/**
* Has this boot's seeding come to rest?
*
* ⛔ NOT `pending === 0`. `pending` counts suppressed sources forever by design
* (see {@link SeedSettledMessage}), so keying on it would hang every
* multi-tenant and `skipSeedData` boot. The question a parent is actually asking
* is "is anything still writing", and that is `inFlight`.
*
* An absent snapshot is settled: no seed pipeline registered, so nothing is
* outstanding and nothing more is coming.
*/
export function seedingHasSettled(snapshot: SeedSettlementSnapshot | undefined): boolean {
return !snapshot || snapshot.inFlight === 0;
}
/** Build the message from the two things the kernel already knows. */
export function composeSeedSettledMessage(
snapshot: SeedSettlementSnapshot | undefined,
sources: SeedSourceSummary[] | undefined,
): SeedSettledMessage {
const list = sources ?? [];
return {
type: 'objectstack:seed-settled',
ok: list.every((s) => s.rejected === 0 && (s.droppedRefs ?? 0) === 0 && !s.emptyInstall),
suppressed: snapshot ? [...snapshot.suppressed] : [],
sources: list,
};
}
/** What {@link createSeedSettlementAnnouncer} needs from its host. */
export interface SeedSettlementChannels {
/** The live tally — re-read per use, never cached (the contract says so). */
readSettlement: () => SeedSettlementSnapshot | undefined;
/** The per-source counts the banner renders, or `undefined` if none ran. */
readSummary: () => SeedSourceSummary[] | undefined;
/** Sends {@link SeedSettledMessage}, when an IPC channel is open. */
announceSettled: (message: SeedSettledMessage) => void;
}
/**
* The latch that turns "seeding settled" into ONE message, in the one order it
* is safe to send it in.
*
* ## Why a latch rather than a call at the settle point
*
* The two clocks this card is about run in EITHER order, and both have to work:
*
* ```text
* in-budget seed settles ─▶ kernel:ready ─▶ publishBoundPort ─▶ (release) ─▶ send
* over-budget kernel:ready ─▶ publishBoundPort ─▶ (release) … ─▶ app:seeded ─▶ send
* ```
*
* {@link check} is called from both the `app:seeded` and `kernel:ready` hooks;
* {@link release} is called once, after {@link publishBoundPort} has driven its
* three channels. Before the release nothing is sent, so a settle that happened
* during `runtime.start()` cannot overtake `objectstack:listening`; after it,
* the first `check` that finds nothing in flight sends and closes the latch.
*
* ⛔ The `kernel:ready` leg is not redundant with `app:seeded`. A suppressed
* boot (multi-tenant, `skipSeedData`) NEVER fires `app:seeded` — `emitSeedSettled`
* lives only on the branch that actually seeds — so `kernel:ready` is the only
* hook that runs on those boots, and dropping it is exactly how a waiting parent
* would hang forever there.
*
* ⛔ And it is not a poll. Every leg is an event the kernel already emits; the
* latch only decides which of them is allowed to be the one that speaks.
*/
export function createSeedSettlementAnnouncer(channels: SeedSettlementChannels): {
/** Re-evaluate: send iff released, not yet sent, and nothing in flight. */
check: () => void;
/** Open the gate — call AFTER the bound-port channels have been driven. */
release: () => void;
} {
let released = false;
let sent = false;
const check = () => {
if (!released || sent) return;
const snapshot = channels.readSettlement();
if (!seedingHasSettled(snapshot)) return;
sent = true;
channels.announceSettled(composeSeedSettledMessage(snapshot, channels.readSummary()));
};
return {
check,
release: () => {
released = true;
check();
},
};
}
/**
* The three channels {@link publishBoundPort} drives — declared in the ONE
* order it is safe to drive them in, which is also the order the fields are
* listed here.
*/
export interface BoundPortChannels {
/**
* Writes `runtime.<environment>.<project>.json`. ⛔ Must COMPLETE before
* either announcement below: it is the file both of them send a consumer to.
* Best-effort: when it cannot be written the boot goes on, and the real
* channel says so once, naming the file (#24091).
*/
writeRuntimeState: (published: { port: number; url: string }) => void;
/**
* Prints the ready banner, whose `API:` row names the same address when an
* HTTP server is composed (#23623), and the boot diagnostics under it. ⛔ Must COMPLETE before the IPC message below:
* the `os dev` parent prints its MCP connect block on that message, into the
* same terminal (#22410).
*/
printBanner: () => void;
/** Sends {@link ListeningMessage}, when an IPC channel is open. LAST — see above. */
announceListening: (message: ListeningMessage) => void;
}
/**
* Publish the bound port on all three channels, in the one order that is safe.
*
* Driven only when an HTTP transport is composed (#23743): a boot with none has
* no bound port to publish, and prints the banner alone.
*
* ## The bug this shape exists to make impossible
*
* `os serve` announces its address on three channels: the runtime state file
* `runtime.<environment>.<project>.json`, the `objectstack:listening` IPC
* message, and the ready banner. Two of those are ANNOUNCEMENTS a consumer
* reacts to; the third is the FILE those consumers then open. Published in the order they
* happened to be written — banner, IPC, file — every consumer that believes an
* announcement races a file that is not there yet:
*
* ```text
* banner ─▶ a supervisor sees "ready" and opens runtime.env_local.<project>.json
* IPC ─▶ the `os dev` parent sees the port
* file ─────────────────────────▶ ...written here. The ENOENT already happened.
* ```
*
* ⛔ Not hypothetical, and ⛔ not a test artefact. The e2e that pins #13062's
* claim (`serve-publishes-bound-port.e2e.test.ts`) is an ORDINARY consumer — it
* waits for the banner AND the IPC message, then reads the file — and it
* ejected 14 PRs from the shared merge queue in a rolling 24 hours (10
* independent hits, #13158) with `ENOENT: ... runtime.env_local.json` — the
* name that file carried then, before {@link runtimeStateFileName} keyed it by
* project as well. A real supervisor written the same way loses the same race;
* all a loaded machine does is deschedule the child between the announcement
* and the write, which is why it read as a flake for a day.
*
* ⛔ The repair is NOT to make the reader poll. A consumer that must poll after
* being told "ready" was told "ready" too early — polling spreads the defect
* into every consumer forever and hides it from the one place that can fix it.
* The producer owns the ordering: **write the file, THEN announce it.**
*
* ## The second race: two processes, one terminal (#22410)
*
* The IPC message is not only a fact for a program. The `os dev` parent prints
* its MCP connect block (`🤖 MCP server — connect a coding agent:` and its
* Endpoint / Skill / Connect / Disable rows) the moment the message arrives,
* into the terminal this process writes its banner to. Sent BEFORE the banner,
* the two processes wrote that terminal at once: measured under a pty on the
* Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block
* above or inside the banner, 2 of them with banner lines inside the block, and
* one probe boot printed the whole credential and plugin section between
* `Skill` and `Connect`.
*
* So the banner comes SECOND and the message LAST. That makes the order
* causal, not timed: the banner is written synchronously (a terminal is
* synchronous stdio on POSIX, and `keepStderrNonBlocking` leaves a TTY
* alone), so every banner byte is in the terminal before the message is sent,
* and the parent prints only on receipt. ⛔ No sleep or timer orders anything
* here. The message's shape is unchanged; it arrives a banner's print later.
*
* ## Why the channels are injected rather than called inline
*
* The ORDER is the contract here, and an order is only pinned by a test that
* can observe it. Passing the channels in lets
* `serve-bound-port-publish-order.test.ts` record the sequence and go red
* DETERMINISTICALLY the day it is reversed. An end-to-end test cannot do that:
* it can only lose the race often enough for someone to notice — which is
* precisely the year-of-flakes this replaces.
*/
export function publishBoundPort(
boundPort: number,
channels: BoundPortChannels,
boundProtocol: ListenerProtocol = 'http',
): void {
// ⭐ The SOCKET's own address, so the scheme has to be the one the socket
// actually speaks (#16804). Both consumers of this `url` open it: the runtime
// state file is what an external supervisor or health check dials, and the
// IPC message is what the `os dev` parent learns the server from. Under
// `--cert`/`--key` a hardcoded `http://` here would hand both of them an
// address that answers a TLS handshake error — a machine-readable surface
// saying something untrue about the process that wrote it. ⛔ Unlike the
// canonical origin, this is NOT overridable by `OS_AUTH_URL`: that variable
// names where a deployment is REACHED, and this line names what was BOUND.
const url = `${boundProtocol}://localhost:${boundPort}`;
// 1 ─ THE FILE FIRST. Both announcements below send a consumer to it.
channels.writeRuntimeState({ port: boundPort, url });
// 2 ─ The banner: a human, or a supervisor tailing stdout, reads the same
// address — and by now the state file it names is on disk.
channels.printBanner();
// 3 ─ IPC LAST: the `os dev` parent learns the real port without polling,
// and prints its MCP connect block under a banner that has finished (#22410).
channels.announceListening({ type: 'objectstack:listening', port: boundPort, url });
}
/**
* The identity a runtime state file is keyed by: the ROOT OF THE PROJECT this
* process is serving, folded into one filename-safe component.
*
* ## The collision this ends, DRIVEN rather than reasoned (#15733)
*
* The state file used to be `runtime.<environment>.json`, and both halves of
* that name are machine-global. {@link resolveObjectStackHome} takes NO
* arguments — it reads `OS_HOME`, else `~/.objectstack` — and an environment