@ultimat3/cli 19.3.3 → 19.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "19.3.3",
3
+ "version": "19.4.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,34 +37,34 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "19.3.3",
41
- "@ultimat3/admin": "19.3.3",
42
- "@ultimat3/ai": "19.3.3",
43
- "@ultimat3/auth": "19.3.3",
44
- "@ultimat3/cache": "19.3.3",
45
- "@ultimat3/core": "19.3.3",
46
- "@ultimat3/db": "19.3.3",
47
- "@ultimat3/entity": "19.3.3",
48
- "@ultimat3/flags": "19.3.3",
49
- "@ultimat3/http": "19.3.3",
50
- "@ultimat3/i18n": "19.3.3",
51
- "@ultimat3/jobs": "19.3.3",
52
- "@ultimat3/mail": "19.3.3",
53
- "@ultimat3/manifest": "19.3.3",
54
- "@ultimat3/mcp": "19.3.3",
55
- "@ultimat3/money": "19.3.3",
56
- "@ultimat3/notify": "19.3.3",
57
- "@ultimat3/policy": "19.3.3",
58
- "@ultimat3/pwa": "19.3.3",
59
- "@ultimat3/query": "19.3.3",
60
- "@ultimat3/realtime": "19.3.3",
61
- "@ultimat3/render": "19.3.3",
62
- "@ultimat3/schema": "19.3.3",
63
- "@ultimat3/scraping": "19.3.3",
64
- "@ultimat3/seo": "19.3.3",
65
- "@ultimat3/storage": "19.3.3",
66
- "@ultimat3/testing": "19.3.3",
67
- "@ultimat3/time": "19.3.3",
40
+ "@ultimat3/action": "19.4.0",
41
+ "@ultimat3/admin": "19.4.0",
42
+ "@ultimat3/ai": "19.4.0",
43
+ "@ultimat3/auth": "19.4.0",
44
+ "@ultimat3/cache": "19.4.0",
45
+ "@ultimat3/core": "19.4.0",
46
+ "@ultimat3/db": "19.4.0",
47
+ "@ultimat3/entity": "19.4.0",
48
+ "@ultimat3/flags": "19.4.0",
49
+ "@ultimat3/http": "19.4.0",
50
+ "@ultimat3/i18n": "19.4.0",
51
+ "@ultimat3/jobs": "19.4.0",
52
+ "@ultimat3/mail": "19.4.0",
53
+ "@ultimat3/manifest": "19.4.0",
54
+ "@ultimat3/mcp": "19.4.0",
55
+ "@ultimat3/money": "19.4.0",
56
+ "@ultimat3/notify": "19.4.0",
57
+ "@ultimat3/policy": "19.4.0",
58
+ "@ultimat3/pwa": "19.4.0",
59
+ "@ultimat3/query": "19.4.0",
60
+ "@ultimat3/realtime": "19.4.0",
61
+ "@ultimat3/render": "19.4.0",
62
+ "@ultimat3/schema": "19.4.0",
63
+ "@ultimat3/scraping": "19.4.0",
64
+ "@ultimat3/seo": "19.4.0",
65
+ "@ultimat3/storage": "19.4.0",
66
+ "@ultimat3/testing": "19.4.0",
67
+ "@ultimat3/time": "19.4.0",
68
68
  "babel-preset-solid": "^1.9.15"
69
69
  }
70
70
  }
package/src/dev-roles.ts CHANGED
@@ -8,7 +8,13 @@
8
8
 
9
9
  import type { Role } from '@ultimat3/core';
10
10
  import { createContext, isRole, logger, ROLES } from '@ultimat3/core';
11
- import type { RateLimitStore, Route, ServerHandle, ServerHooks } from '@ultimat3/http';
11
+ import type {
12
+ RateLimitStore,
13
+ Route,
14
+ ServerHandle,
15
+ ServerHooks,
16
+ WebSocketMount,
17
+ } from '@ultimat3/http';
12
18
  import {
13
19
  configuredAuthenticator,
14
20
  configuredHttp,
@@ -26,7 +32,7 @@ import {
26
32
  jobDriver,
27
33
  pgSchedulerState,
28
34
  } from '@ultimat3/jobs';
29
- import type { LiveQueryRegistry } from '@ultimat3/realtime/server';
35
+ import type { LiveQueryRegistry, SyncWs } from '@ultimat3/realtime/server';
30
36
  import type { LiveReplicator } from '@ultimat3/testing';
31
37
  import { devHooks } from './dev-hooks';
32
38
  import type { LiveFeed } from './dev-live-feed';
@@ -36,7 +42,7 @@ import type { RunningReplicator } from './dev-replicator';
36
42
  import { startReplicator } from './dev-replicator';
37
43
  import type { RunningServices } from './dev-runtime';
38
44
  import type { Env } from './dev-services';
39
- import { startSync } from './dev-sync';
45
+ import { prepareSync, type RunningSync } from './dev-sync';
40
46
  import { errorPageHook } from './error-pages';
41
47
  import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
42
48
  import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
@@ -270,7 +276,9 @@ function rateLimitStoreFor(options: StartRolesOptions): RateLimitStore | undefin
270
276
  return supplied;
271
277
  }
272
278
 
273
- function startWeb(options: StartRolesOptions): ServerHandle {
279
+ /** `mount` is the sync node's socket, served on THIS port as well as its own — why, in `dev-sync`.
280
+ * Undefined without the `sync` role, and then this server opens no websocket, as it always did. */
281
+ function startWeb(options: StartRolesOptions, mount?: WebSocketMount<SyncWs>): ServerHandle {
274
282
  warnIfUnauthenticatable(options.routes);
275
283
  const binding = options.http ?? DEV_BINDING;
276
284
  const hops = trustedHopsFromEnv(options.env);
@@ -278,6 +286,7 @@ function startWeb(options: StartRolesOptions): ServerHandle {
278
286
  return createServer({
279
287
  routes: options.routes,
280
288
  role: 'web',
289
+ ...(mount === undefined ? {} : { websocket: mount }),
281
290
  hooks: devHooks({
282
291
  ...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
283
292
  ...(options.root === undefined ? {} : { errorPage: errorPageHook(options.root) }),
@@ -373,11 +382,20 @@ export async function startRoles(options: StartRolesOptions): Promise<RunningRol
373
382
  });
374
383
  started.push(async () => metrics.stop());
375
384
 
376
- const server = selected.includes('web') ? startWeb(options) : null;
385
+ // BUILT here, BOUND below, the web role between them: `web` serves the node's socket on its
386
+ // own port and a listening server cannot be handed one, while the neighbouring-port refusals
387
+ // are only the right answer once the web port's own has been given.
388
+ const prepared = selected.includes('sync') ? await prepareSync(options) : null;
389
+ // One rollback entry, kept current — two would stop the node twice out of a failed boot.
390
+ let releaseSync = prepared?.stop ?? null;
391
+ if (prepared !== null) started.push(async () => await releaseSync?.());
392
+
393
+ const server = selected.includes('web') ? startWeb(options, prepared?.mount) : null;
377
394
  if (server !== null) started.push(() => server.stop());
378
395
 
379
- const sync = selected.includes('sync') ? await startSync(options) : null;
380
- if (sync !== null) started.push(sync.stop);
396
+ const sync: RunningSync | null =
397
+ prepared === null ? null : await prepared.listen(server === null ? null : server.url());
398
+ if (sync !== null) releaseSync = sync.stop;
381
399
 
382
400
  const worker = selected.includes('worker')
383
401
  ? createWorker({
package/src/dev-sync.ts CHANGED
@@ -3,7 +3,9 @@
3
3
  // and a listener of its own — and because that file is the boot's index, not its detail.
4
4
 
5
5
  import { createContext, logger, UltimateError } from '@ultimat3/core';
6
+ import type { WebSocketMount } from '@ultimat3/http';
6
7
  import { listQueries } from '@ultimat3/query';
8
+ import type { SyncNode, SyncWs } from '@ultimat3/realtime/server';
7
9
  import {
8
10
  ChannelHub,
9
11
  createSyncNode,
@@ -98,6 +100,32 @@ export function syncPortFor(port: number): number {
98
100
  return port + 1;
99
101
  }
100
102
 
103
+ /**
104
+ * A node that is built and subscribed but bound to nothing yet — the one moment the `web` role can
105
+ * still mount its socket, since `web` binds its port first and a mount handed over after that is a
106
+ * server already listening without it.
107
+ */
108
+ export interface PreparedSync {
109
+ readonly node: SyncNode;
110
+ readonly registry: LiveQueryRegistry;
111
+ /**
112
+ * The socket as the WEB role can serve it, on the port that role already publishes.
113
+ *
114
+ * `x dev` does BOTH: this mount on `PORT`, and the node's own listener on `PORT + 1` below. They
115
+ * are one node behind two doors, not two nodes — the sockets share the registry, the grants and
116
+ * the change subscription — and which door a browser uses is whichever one it can reach.
117
+ * `docker/` publishes the second; a laptop reached through one forwarded port uses the first.
118
+ */
119
+ readonly mount: WebSocketMount<SyncWs>;
120
+ /**
121
+ * Bind `PORT + 1`, and answer with the same object `startSync` always did. `appUrl` is the web
122
+ * role's own origin when one runs here, for the line below that names both doors.
123
+ */
124
+ listen(appUrl: string | null): Promise<RunningSync>;
125
+ /** Release the node when nothing ever bound it — a `web` role that threw after it was built. */
126
+ stop(): Promise<void>;
127
+ }
128
+
101
129
  /** What `startRoles` holds on to: where the node listens, and how to take it down. */
102
130
  export interface RunningSync {
103
131
  readonly url: string;
@@ -141,13 +169,10 @@ export function registerLiveQueries(options: StartRolesOptions): LiveQueryRegist
141
169
  }
142
170
 
143
171
  /**
144
- * The sync role owns its own socket: websockets and the request pipeline drain differently.
145
- *
146
- * Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
147
- * port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
148
- * never a string built from the port that was requested.
172
+ * The node itself: its hub, its registry, its authenticator and its change subscription. Nothing
173
+ * bound — `listen()` and the `web` role's mount are the two doors, and this is what is behind both.
149
174
  */
150
- export async function startSync(options: StartRolesOptions): Promise<RunningSync> {
175
+ export async function prepareSync(options: StartRolesOptions): Promise<PreparedSync> {
151
176
  const sockets = new SocketRegistry();
152
177
  const hub = new ChannelHub({ transport: options.runtime.transport, sockets });
153
178
  // The node evaluated no credential of its own and no host ever handed it one, so every socket
@@ -180,6 +205,35 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
180
205
  }),
181
206
  });
182
207
  await node.start();
208
+ return {
209
+ node,
210
+ registry,
211
+ // The node's OWN path, asked rather than restated: `SyncNodeOptions.path` is settable and a
212
+ // second copy of `/_x/sync` here is the copy that stays behind when it moves.
213
+ mount: { path: node.path, fetch: node.fetch, websocket: node.websocket },
214
+ stop: () => node.stop(),
215
+ listen: async (appUrl) => await listen(options, node, registry, appUrl),
216
+ };
217
+ }
218
+
219
+ /**
220
+ * The node's own socket, on `PORT + 1`. The sync role owns it because websockets and the request
221
+ * pipeline drain differently, and `docker/` publishes it as a service of its own.
222
+ *
223
+ * Kept a step of its own so `web` binds BEFORE it: the two refusals below are about a taken
224
+ * neighbouring port, and reversing the order would answer a second `x dev` on this checkout with
225
+ * "port 3001 is in use" when the fact worth printing is that 3000 is.
226
+ *
227
+ * Port 0 is passed straight through rather than incremented — `+ 1` would ask the kernel for
228
+ * port 1 instead of an ephemeral one — and the reported url is the listener's own bound address,
229
+ * never a string built from the port that was requested.
230
+ */
231
+ async function listen(
232
+ options: StartRolesOptions,
233
+ node: SyncNode,
234
+ registry: LiveQueryRegistry,
235
+ appUrl: string | null,
236
+ ): Promise<RunningSync> {
183
237
  const port = syncPortFor(options.port);
184
238
  try {
185
239
  // The SAME interface the web role binds, resolved from the same option and the same default.
@@ -192,6 +246,14 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
192
246
  // seconds of every Ctrl-C (measured 2026-09-06, 5.0s of 5.1s) spent on a reconnect frame
193
247
  // whose target does not exist yet.
194
248
  const listener = listenSyncNode(node, { port, hostname: binding.hostname, drainGraceMs: 0 });
249
+ // BOTH doors, named, once. `sync node ready` said only that a node existed: the first question
250
+ // a failing browser socket raises — "is the ws server up, and where?" — had no answer anywhere
251
+ // in the boot output, and the port was never printed at all. It is also what an editor's port
252
+ // forwarding reads: a url in the terminal is how VS Code and a Codespace learn a port exists.
253
+ logger.info('sync reachable', {
254
+ node: `${listener.url}${node.path}`,
255
+ app: appUrl === null ? null : `${appUrl}${node.path}`,
256
+ });
195
257
  return {
196
258
  url: listener.url,
197
259
  registry,
@@ -207,3 +269,9 @@ export async function startSync(options: StartRolesOptions): Promise<RunningSync
207
269
  throw error;
208
270
  }
209
271
  }
272
+
273
+ /** Both steps, for a caller with no web role to mount anything on. */
274
+ export async function startSync(options: StartRolesOptions): Promise<RunningSync> {
275
+ const prepared = await prepareSync(options);
276
+ return await prepared.listen(null);
277
+ }