uniweb 0.27.1 → 0.28.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": "uniweb",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,15 +41,15 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "^0.12.7",
44
+ "@uniweb/runtime": "^0.12.9",
45
45
  "@uniweb/semantic-parser": "^1.3.0",
46
- "@uniweb/core": "^0.11.1",
47
- "@uniweb/kit": "^0.13.2"
46
+ "@uniweb/kit": "^0.13.2",
47
+ "@uniweb/core": "^0.11.1"
48
48
  },
49
49
  "peerDependencies": {
50
- "@uniweb/content-reader": "^1.2.4",
50
+ "@uniweb/build": "^0.25.0",
51
51
  "@uniweb/semantic-parser": "^1.3.0",
52
- "@uniweb/build": "^0.25.0"
52
+ "@uniweb/content-reader": "^1.2.4"
53
53
  },
54
54
  "peerDependenciesMeta": {
55
55
  "@uniweb/build": {
@@ -2218,7 +2218,7 @@ uniweb inspect <path> # Show parsed content for a section or page (-
2218
2218
  uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
2219
2219
  ```
2220
2220
 
2221
- **Four verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. `runtime register` uploads a built runtime and is internal — an **escape hatch** for a build npm does not have (an unreleased version, a local workspace tree, or a deployment that cannot reach the distribution channel), never the normal way a runtime reaches a backend. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
2221
+ **Three verbs dispatch but are deliberately not listed above.** `invite`, `handoff` and `template` are **reserved names with no implementation** — the flows they named ran against a backend the CLI no longer talks to, and the names are held so a future rebuild doesn't need a breaking change. Running any of them is not useful; their absence from this list is the answer, not an omission to fix.
2222
2222
 
2223
2223
  ### Where a site can live
2224
2224
 
@@ -40,7 +40,6 @@ import {
40
40
  } from '../utils/registry-orgs.js'
41
41
  import { uploadFoundationCode } from '../utils/code-upload.js'
42
42
  import { uploadSiteAssets } from '../utils/asset-upload.js'
43
- import { uploadRuntime } from '../utils/runtime-upload.js'
44
43
 
45
44
  /**
46
45
  * Resolve the backend origin via the resolution ladder (highest precedence
@@ -85,8 +84,7 @@ export function resolveBackendOrigin(flag, { siteBackend } = {}) {
85
84
  /**
86
85
  * The fallback capability doc when `GET /dev/config` is absent or unreachable
87
86
  * (an older backend, or no backend at all). Keeps the client non-breaking: the
88
- * bases mirror a self-serve dev backend, and `runtime.installed` is empty so
89
- * runtime resolution requires an explicit pin.
87
+ * bases mirror a self-serve dev backend.
90
88
  */
91
89
  export const DISCOVERY_DEFAULTS = {
92
90
  // ⛔ No serve-root default, and no `assetBase`. Serve locations are read from
@@ -99,10 +97,18 @@ export const DISCOVERY_DEFAULTS = {
99
97
  // production host, hardcoded, applied to EVERY deployment the CLI can be
100
98
  // pointed at. It was only ever read to compose an asset URL, which the plan
101
99
  // already returns as `serve_url`; both the reader and the composer are gone.
100
+ //
101
+ // No `runtime` entry, and there must not be one. A backend does not hold
102
+ // runtimes: a version is acquired from a CDN — the official mirror, the
103
+ // distribution channel, or a local server — so there is no installed set for
104
+ // it to report and nothing here to default. `runtime.installed` lived here
105
+ // until 2026-08-22, alongside a `uniweb runtime register` verb that pushed
106
+ // builds to a backend; both are gone. The runtime a site gets follows from
107
+ // its foundation's floor (`info.runtime`, stated at register), not from
108
+ // anything the CLI asks a backend about.
102
109
  auth: { loginPath: '/dev/auth/login', required: true },
103
110
  delivery: { deploy: true, publish: true, broker: 'self-serve' },
104
- assets: { supported: false },
105
- runtime: { installed: [] }
111
+ assets: { supported: false }
106
112
  }
107
113
 
108
114
  export class BackendClient {
@@ -224,8 +230,7 @@ export class BackendClient {
224
230
  * lifetime; a missing route or any transport/parse error falls back to
225
231
  * DISCOVERY_DEFAULTS (non-breaking — an older backend still works). Lets the
226
232
  * CLI hardcode nothing about a backend but its origin and discover the rest:
227
- * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?),
228
- * `runtime.installed` (the default-runtime source replacing the old /runtime/latest).
233
+ * `auth`, `delivery` (deploy/publish? broker), `assets` (lane built yet?).
229
234
  * @returns {Promise<object>}
230
235
  */
231
236
  async discover() {
@@ -428,8 +433,8 @@ export class BackendClient {
428
433
 
429
434
  /**
430
435
  * POST /dev/deploy — dumb, file-built delivery. Body is the deploy payload (the
431
- * runtime-init JSON `build-site-data.js` produces — foundation, runtimeVersion,
432
- * theme, languages, locales, optional dataFiles/searchFiles) plus an optional
436
+ * runtime-init JSON `build-site-data.js` produces — foundation, theme,
437
+ * languages, locales, optional dataFiles/searchFiles) plus an optional
433
438
  * `site_uuid`. First deploy of a never-synced site omits it → the backend mints
434
439
  * a uuid and returns it for write-back to deploy.yml; later deploys resend it so
435
440
  * the site's published URL stays stable. Returns the raw Response so the caller
@@ -451,27 +456,23 @@ export class BackendClient {
451
456
  * POST /dev/site/publish/{uuid} — CMS-publish a synced site (make its CURRENT
452
457
  * backend state live; it does NOT push local files). `{uuid}` is the site-content
453
458
  * uuid (`site.yml::$uuid`); a never-synced site 404s (sync first, or use deploy).
454
- * The CLI knows the runtime from its build; the body carries it snake-cased per
455
- * the route contract. Returns the raw Response ({ deploy_uuid, url,
456
- * published_folder_uuid, status } on 200).
459
+ * Languages, when present, go in the body; absent no body. Returns the raw
460
+ * Response ({ deploy_uuid, url, published_folder_uuid, status } on 200).
461
+ *
462
+ * ⛔ Sends NOTHING about a runtime. A `?runtime=<version>` param rode here until
463
+ * 2026-08-22, carrying a `site.yml::runtime` pin — a vestigial prop [Diego]: no
464
+ * template ever set it, no public doc described it, and the backend may already
465
+ * have been ignoring it. A site is codeless and has no basis for naming a
466
+ * runtime; the binding party is the FOUNDATION, whose floor travels as
467
+ * `info.runtime` at register. Do not reintroduce the param.
457
468
  * @param {string} uuid - the site-content uuid
458
- * @param {object} opts
459
- * @param {string} opts.runtimeVersion
469
+ * @param {object} [opts]
460
470
  * @param {string[]} [opts.languages]
461
471
  * @returns {Promise<Response>}
462
472
  */
463
- async publishSite(uuid, { runtimeVersion, languages } = {}) {
464
- // Runtime rides as a query param (?runtime=<version>) per the shipped /dev
465
- // route (D3, "request-carried"), NOT the body. Languages, when present, go in
466
- // the body; absent → no body (the route only requires the runtime).
467
- // Omitted when the site pins no runtime — silence is NOT a request to change
468
- // it, so the backend keeps the site on its current resolved version (its
469
- // order: body → current → UNIWEBD_DEFAULT_RUNTIME → highest installed → 400).
470
- // Sending a locally-computed guess instead would undo a propagation walk on
471
- // the next publish.
473
+ async publishSite(uuid, { languages } = {}) {
472
474
  return this.request(`/dev/site/publish/${encodeURIComponent(uuid)}`, {
473
475
  method: 'POST',
474
- ...(runtimeVersion ? { query: { runtime: runtimeVersion } } : {}),
475
476
  ...(languages ? { body: JSON.stringify({ languages }) } : {})
476
477
  })
477
478
  }
@@ -548,19 +549,6 @@ export class BackendClient {
548
549
  })
549
550
  }
550
551
 
551
- /**
552
- * Upload a built `@uniweb/runtime` to the runtime registry (plan → PUT-per-file),
553
- * served by the backend at a location it reports. @std-gated on the backend. Thin
554
- * pass-through to utils/runtime-upload.js with this client's origin + token.
555
- * @param {object} opts - { version, distDir, files?, onProgress? }
556
- */
557
- async uploadRuntime(opts) {
558
- return uploadRuntime({
559
- apiBase: this.origin,
560
- token: await this.token(),
561
- ...opts
562
- })
563
- }
564
552
  }
565
553
 
566
554
  /** `@scope/name` → /dev/registry/data-schemas/{scope}/{name}; a bare name → …/{name}. */
@@ -220,76 +220,33 @@ export async function publish(args = []) {
220
220
  return { exitCode: 1 }
221
221
  }
222
222
 
223
- // Runtime: an explicit site.yml::runtime pin wins; else the highest installed;
224
- // else fail closed (better than serving a site with no runtime). A dry-run is
225
- // a pure preview, so it only WARNS it stays useful with no backend reachable.
226
- const installed = Array.isArray(config?.runtime?.installed)
227
- ? config.runtime.installed
228
- : []
229
- if (
230
- siteYml.runtime &&
231
- installed.length &&
232
- !installed.includes(siteYml.runtime)
233
- ) {
234
- // ⚠️ Leads with REMOVING the pin, deliberately. A site ships no JS, so it
235
- // has no basis for holding a runtime version, and `runtime:` is an
236
- // operator-level override that is no longer part of the documented
237
- // authoring surface. This message used to say "pin one of these in
238
- // site.yml" — which pushed a reader deeper into a mechanism they should
239
- // not be using, and named a key the docs no longer describe. Keep the
240
- // installed list (it is the actionable part when a pin IS intended), but
241
- // do not restore pin-first phrasing.
242
- say.err(
243
- `Runtime ${siteYml.runtime} (pinned in site.yml) is not installed on the backend.`
244
- )
245
- say.dim(
246
- `Remove the \`runtime:\` pin and the backend chooses — a site ships no code, so it has no reason to hold one.`
247
- )
248
- say.dim(
249
- `Installed: ${installed.join(', ') || '(none)'} — or pin one of those, or have ${siteYml.runtime} installed.`
250
- )
251
- if (!dryRun) return { exitCode: 1 }
252
- }
253
- // An explicit pin is sent; NOTHING is synthesized when site.yml is silent.
254
- //
255
- // This used to fall back to the highest version the backend reported
256
- // installed. That is the producer guessing at a fact the control plane owns —
257
- // and once propagation moves sites, actively wrong: a walk advances a site to
258
- // X, and the next publish would restate a *different* version the producer
259
- // computed locally, silently undoing it.
223
+ // NOTHING about a runtime is sent from here. `site.yml::runtime` was a
224
+ // vestigial prop and is no longer read [Diego, 2026-08-22]; `?runtime=` is no
225
+ // longer a query param on publish. Do not reintroduce either.
260
226
  //
261
- // The deeper reason, upstream of ownership: a link-mode site is CODELESS. It
227
+ // The chain is SITE FOUNDATION RUNTIME. A link-mode site is CODELESS: it
262
228
  // ships no JS, so it has nothing that binds to a runtime version and cannot
263
229
  // break when the runtime moves. Asking it to name one is not a hard question,
264
230
  // it is a malformed one. The party that binds is the FOUNDATION, whose build
265
231
  // externalizes react / react-dom / jsx-runtime / @uniweb/core — which is why
266
- // the compatibility floor rides on the foundation (`info.runtime`, set by
267
- // `register`) and not here. See
268
- // the site/foundation/runtime model, § "who gets to say
269
- // whether a site accepts a newer runtime".
270
- //
271
- // Silence is therefore not a request to change the runtime, and the backend
272
- // resolves it: an explicit pin → the site's CURRENT resolved runtime →
273
- // UNIWEBD_DEFAULT_RUNTIME → (self-serve) highest installed.
232
+ // the compatibility floor rides on the foundation (`dist/runtime-pin.json`
233
+ // `info.runtime`, stated on every register) and never here. Whoever resolves a
234
+ // whole site picks a runtime satisfying max() of the floors its foundation and
235
+ // extensions declare, and fetches it from a CDN — the official mirror, the
236
+ // distribution channel, or a local server. A backend never holds one.
274
237
  //
275
- // ⚠️ THAT LAST SENTENCE IS A CLAIM ABOUT ANOTHER LANE, and it was FALSE when it
276
- // was first written here `/dev/site/publish` required `?runtime=` with no
277
- // fallback at all, so an unpinned publish 400'd and no scaffolded project could
278
- // publish. It read as true because the `/api` lane did have the fallback, and
279
- // the backend's own doc justified the omission with "the CLI always pins from
280
- // site.yml" which was equally false, since no template ships a `runtime:` key.
281
- // Two complementary assumptions, each load-bearing for the other lane, neither
282
- // ever checked. Found by driving the CLI against a live backend, which is the
283
- // only vantage point from which either assumption is visible.
284
- // ✅ Now the shipped contract, agreed with the backend and verified end to end
285
- // with the workaround removed (2026-08-16). Re-verify against the backend
286
- // rather than trusting this comment if it starts mattering again.
287
- //
288
- // ⚠️ Do NOT reintroduce a local fallback. Sending our own guess when the site
289
- // did not ask is what makes an unpinned republish regress. (The pinned path is
290
- // unaffected — an explicit pin is still validated fail-closed above, and the
291
- // backend refuses a backward move unless forced.)
292
- const runtimeVersion = siteYml.runtime || null
238
+ // History, because each removal undid a different bad idea:
239
+ // a local fallback to the highest version the backend reported installed —
240
+ // the producer guessing at a fact the control plane owns, and actively
241
+ // wrong once propagation moves sites (a walk advances a site to X; the
242
+ // next publish would restate a locally-computed value, silently undoing
243
+ // it). Removed in `6d32d94`.
244
+ // fail-closed validation of a pin against `/dev/config`'s
245
+ // `runtime.installed`, and the `uniweb runtime register` verb that
246
+ // populated that field. Removed 2026-08-22 with the concept.
247
+ // the pin itself, and the `?runtime=` it rode on. Removed 2026-08-22 —
248
+ // nothing scaffolded ever set it, it is in no public doc, and the backend
249
+ // may already have been ignoring it.
293
250
 
294
251
  // deploy.yml target (the Uniweb hosting memory). No --target on publish — it
295
252
  // always targets Uniweb hosting; resolveTarget gives us the target name +
@@ -351,9 +308,6 @@ export async function publish(args = []) {
351
308
  if (dryRun) {
352
309
  say.info('Dry run — would bring the foundation along, sync, and go live:')
353
310
  say.dim(`Backend : ${client.origin}`)
354
- say.dim(
355
- `Runtime : ${runtimeVersion || '(not pinned — the backend keeps this site on its current runtime)'}`
356
- )
357
311
  say.dim(
358
312
  `site_uuid : ${siteYml.$uuid || '(none — the site is created before anything uploads)'}`
359
313
  )
@@ -718,7 +672,6 @@ export async function publish(args = []) {
718
672
  let pubRes
719
673
  try {
720
674
  pubRes = await client.publishSite(siteUuid, {
721
- runtimeVersion,
722
675
  ...(languages ? { languages } : {})
723
676
  })
724
677
  } catch (err) {
@@ -781,11 +734,11 @@ export async function publish(args = []) {
781
734
  ...(recordedRef ? { ref: recordedRef } : {}),
782
735
  released: fnd.released
783
736
  },
784
- // Only when the site pinned one. An unpinned site's runtime is resolved by
785
- // the backend and can move under propagation, so recording a value here
786
- // would be a snapshot that silently goes stale and `deploy.yml` is a
787
- // record of what this publish did, not a cache of backend state.
788
- ...(runtimeVersion ? { runtime: runtimeVersion } : {}),
737
+ // No `runtime` here. `deploy.yml` records what this publish DID, and a
738
+ // publish sends nothing about a runtime the site's runtime follows from
739
+ // its foundation's floor and is resolved by whoever serves it. Recording a
740
+ // value would be a snapshot that silently goes stale, of a decision this
741
+ // command does not make.
789
742
  locales: Array.isArray(result.locales) ? result.locales : languages
790
743
  }
791
744
  })
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-08-20T22:44:56.592Z",
3
+ "generatedAt": "2026-08-22T18:52:00.408Z",
4
4
  "packages": {
5
5
  "@uniweb/build": {
6
6
  "version": "0.25.0",
@@ -76,7 +76,7 @@
76
76
  ]
77
77
  },
78
78
  "@uniweb/runtime": {
79
- "version": "0.12.7",
79
+ "version": "0.12.9",
80
80
  "path": "framework/runtime",
81
81
  "deps": [
82
82
  "@uniweb/core",
package/src/index.js CHANGED
@@ -843,15 +843,6 @@ async function main() {
843
843
  return
844
844
  }
845
845
 
846
- // Handle runtime command — `runtime register` uploads a built @uniweb/runtime to
847
- // the backend's runtime registry; @std-gated. Where it is served from is the
848
- // backend's to report, not ours to know.
849
- if (command === 'runtime') {
850
- const { runtime } = await import('./commands/runtime.js')
851
- const result = await runtime(args.slice(1))
852
- process.exit(result?.exitCode ?? 0)
853
- }
854
-
855
846
  // Handle invite command
856
847
  if (command === 'invite') {
857
848
  await invite(args.slice(1))
@@ -1721,7 +1712,6 @@ ${colors.bright}Commands:${colors.reset}
1721
1712
  export Export a self-contained site for third-party hosting
1722
1713
  register Register a foundation + its data schemas with the backend registry
1723
1714
  release Release a foundation version (synonym of register)
1724
- runtime register Escape hatch: push a LOCAL/unreleased runtime build (@std only)
1725
1715
  push Push a site's content to the backend
1726
1716
  pull Pull a site's content from the backend
1727
1717
  refresh Catch up with the git remote AND the backend (never pushes)
@@ -1,249 +0,0 @@
1
- /**
2
- * uniweb runtime register — upload a built `@uniweb/runtime` to the backend so it
3
- * can serve the runtime version. The runtime is a SYSTEM artifact: registering it
4
- * requires **@std membership** (a non-@std bearer 403s).
5
- *
6
- * ─────────────────────────────────────────────────────────────────────────────
7
- * ⛔ THIS IS AN ESCAPE HATCH. IT IS NOT HOW A RUNTIME NORMALLY REACHES A BACKEND.
8
- * ─────────────────────────────────────────────────────────────────────────────
9
- *
10
- * The normal path is **publish to npm**, which CI mirrors into the public
11
- * distribution channel, from which a backend acquires the version itself — no
12
- * login, no per-backend push, no credentials leaving the backend.
13
- *
14
- * This verb pushes ONE build to ONE backend, by hand. Its cost is not the
15
- * upload, it is that the result is **invisible to every other backend and to
16
- * the channel**: a version installed this way exists nowhere else, cannot be
17
- * verified against a published digest, and will not be what a second
18
- * environment resolves. Two backends can end up serving different bytes under
19
- * one version number, which is the exact defect the channel's immutability
20
- * exists to make impossible.
21
- *
22
- * It stays because three cases have no other path, and they are all LOCAL or
23
- * pre-release:
24
- *
25
- * • **an unpublished build** — npm versions are immutable, so you cannot
26
- * iterate on a published one. Testing a runtime before releasing it goes
27
- * through here.
28
- * • **local workspace development** — `@uniweb/runtime` is a pnpm symlink to
29
- * the working tree; there is no published artifact for the code you are
30
- * editing.
31
- * • **a deployment that cannot pull** — air-gapped or policy-restricted, or
32
- * any environment without the egress the channel needs.
33
- *
34
- * ⇒ **If the version you are pushing is on npm, you almost certainly want the
35
- * channel instead.** The command warns at runtime for that reason.
36
- *
37
- * A foundation emits `dist/runtime-pin.json`. `uniweb register` now reads it and
38
- * ships it as the foundation's `info.runtime` — but **NOTHING ENFORCES IT** in
39
- * any lane. An earlier version of this comment claimed `uniweb register` of a
40
- * foundation fails when its pinned version isn't registered. That was never
41
- * true, and it propagated into internal notes AND into four public
42
- * documentation surfaces before anyone checked it against `commands/register.js`
43
- * three files away.
44
- *
45
- * The pin is a **compatibility floor**, not a selector: a site loads a primary
46
- * foundation plus N extensions, each emitting its own pin, and a site has exactly
47
- * one runtime — so pins are plural and the selector must be singular. The selector
48
- * is `site.yml::runtime` (see commands/publish.js), which the backend stamps into
49
- * the site's meta at publish. The pin's use is VALIDATION — is the selected
50
- * runtime at or above max() of every loaded foundation's floor? — and it belongs
51
- * wherever all of a site's foundations are held. The producer publishes one
52
- * foundation at a time and cannot see the others, so it STATES its floor and
53
- * something holding the whole set does the arithmetic.
54
- *
55
- * Contract AGREED with the backend (2026-06-14): `POST /dev/runtime`, @std-gated,
56
- * manifest-last. Wire + the two-half artifact set (SPA + ssr-edge isolate, the
57
- * orchestrator stays platform-owned): utils/runtime-upload.js.
58
- *
59
- * Usage (escape hatch — prefer publishing to npm; see above):
60
- * uniweb runtime register From framework/runtime (or --path <dir>)
61
- * uniweb runtime register --path <dir> The @uniweb/runtime package dir
62
- * uniweb runtime register --version <v> Override dist/app/manifest.json's version
63
- * uniweb runtime register --backend <url> Override the backend origin
64
- * uniweb runtime register --token <bearer> Auth bearer (skips `uniweb login`)
65
- * uniweb runtime register --dry-run Print the version + file plan; upload nothing
66
- */
67
-
68
- import { readFileSync } from 'node:fs'
69
- import { resolve, join } from 'node:path'
70
-
71
- import { BackendClient } from '../backend/client.js'
72
- import {
73
- collectRuntimeFiles,
74
- hasWorkerRuntime,
75
- hasShims
76
- } from '../utils/runtime-upload.js'
77
- import { readFlagValue } from '../utils/args.js'
78
-
79
- const c = {
80
- reset: '\x1b[0m',
81
- bold: '\x1b[1m',
82
- dim: '\x1b[2m',
83
- cyan: '\x1b[36m',
84
- green: '\x1b[32m',
85
- yellow: '\x1b[33m',
86
- red: '\x1b[31m'
87
- }
88
- const say = {
89
- ok: (m) => console.log(`${c.green}✓${c.reset} ${m}`),
90
- info: (m) => console.log(`${c.cyan}→${c.reset} ${m}`),
91
- warn: (m) => console.log(`${c.yellow}⚠${c.reset} ${m}`),
92
- err: (m) => console.error(`${c.red}✗${c.reset} ${m}`),
93
- dim: (m) => console.log(` ${c.dim}${m}${c.reset}`)
94
- }
95
-
96
- // The runtime package dir: --path, else the cwd when it IS @uniweb/runtime.
97
- function resolveRuntimeDir(args) {
98
- const pathFlag = readFlagValue(args, '--path')
99
- if (pathFlag) return resolve(pathFlag)
100
- try {
101
- if (
102
- JSON.parse(readFileSync(join(process.cwd(), 'package.json'), 'utf8'))
103
- .name === '@uniweb/runtime'
104
- ) {
105
- return process.cwd()
106
- }
107
- } catch {
108
- // no readable package.json — fall through
109
- }
110
- return null
111
- }
112
-
113
- export async function runtime(args = []) {
114
- const sub = args[0]
115
- if (sub !== 'register') {
116
- say.err(
117
- sub
118
- ? `Unknown subcommand: runtime ${sub}`
119
- : 'Usage: uniweb runtime register'
120
- )
121
- say.dim(
122
- 'uniweb runtime register — upload the built @uniweb/runtime to the backend (@std only).'
123
- )
124
- return { exitCode: sub ? 1 : 0 }
125
- }
126
- const rest = args.slice(1)
127
- const dryRun = rest.includes('--dry-run')
128
-
129
- // Say it at the moment of use, not only in the header. A verb that works
130
- // fine is a verb people keep reaching for: this one succeeds quietly and
131
- // leaves a version that exists on exactly one backend, unverifiable against
132
- // any published digest.
133
- say.warn('`runtime register` is an escape hatch, not the normal path.')
134
- say.dim('A published runtime reaches a backend through the distribution channel — no push, no login.')
135
- say.dim('Use this for a build npm does not have: an unreleased version, a local workspace tree,')
136
- say.dim('or a deployment that cannot reach the channel. Pushing a version that IS on npm installs')
137
- say.dim('bytes nothing else can verify, and a second environment will resolve something different.')
138
- console.log('')
139
-
140
- const runtimeDir = resolveRuntimeDir(rest)
141
- if (!runtimeDir) {
142
- say.err('Not an @uniweb/runtime package.')
143
- say.dim('Run from framework/runtime, or pass --path <dir>.')
144
- return { exitCode: 2 }
145
- }
146
- const distDir = join(runtimeDir, 'dist')
147
- const files = collectRuntimeFiles(distDir)
148
- if (!files.length) {
149
- say.err('No built runtime found (dist/app/).')
150
- say.dim('Build it first: `pnpm build` in framework/runtime.')
151
- return { exitCode: 2 }
152
- }
153
-
154
- // Version from the SPA build's manifest (the backend keys the version on it);
155
- // --version overrides, parity with the backend's runtime install --version.
156
- let version = readFlagValue(rest, '--version')
157
- if (!version) {
158
- try {
159
- version = JSON.parse(
160
- readFileSync(join(distDir, 'app', 'manifest.json'), 'utf8')
161
- ).version
162
- } catch (err) {
163
- say.err(`Could not read dist/app/manifest.json: ${err.message}`)
164
- return { exitCode: 2 }
165
- }
166
- if (!version) {
167
- say.err(
168
- 'dist/app/manifest.json has no "version" field — rebuild the runtime.'
169
- )
170
- return { exitCode: 2 }
171
- }
172
- }
173
- // The ssr-edge artifact is a SET: worker-runtime.js + its shims/*.js. Warn when
174
- // the set is absent or incomplete (a worker without shims can't resolve react).
175
- if (!hasWorkerRuntime(files)) {
176
- say.warn(
177
- "dist/worker-runtime.js is missing — the SSR isolate bundle won't be uploaded."
178
- )
179
- say.dim(
180
- 'Build it first: `pnpm build:worker` in @uniweb/runtime (after `pnpm build`).'
181
- )
182
- } else if (!hasShims(files)) {
183
- say.warn(
184
- 'dist/worker-runtime.js is present but dist/shims/ is missing — the SSR isolate set is incomplete.'
185
- )
186
- say.dim(
187
- 'The isolate resolves react/jsx-runtime/@uniweb/core through those shims; re-run `pnpm build:worker`.'
188
- )
189
- }
190
-
191
- if (dryRun) {
192
- say.info(
193
- `Would register ${c.bold}@uniweb/runtime@${version}${c.reset} (${files.length} files):`
194
- )
195
- for (const f of files)
196
- say.dim(`${f.path} ${f.size} bytes ${f.content_type}`)
197
- return { exitCode: 0 }
198
- }
199
-
200
- const client = new BackendClient({
201
- originFlag:
202
- readFlagValue(rest, '--backend') || readFlagValue(rest, '--registry'),
203
- token: readFlagValue(rest, '--token') || undefined,
204
- args: rest,
205
- command: 'Registering the runtime'
206
- })
207
-
208
- say.info(
209
- `Registering ${c.bold}@uniweb/runtime@${version}${c.reset} → ${c.dim}${client.origin}${c.reset} (${files.length} files)…`
210
- )
211
- let result
212
- try {
213
- result = await client.uploadRuntime({
214
- version,
215
- distDir,
216
- files,
217
- onProgress: (m) => say.dim(m)
218
- })
219
- } catch (err) {
220
- if (err.status === 403) {
221
- say.err(
222
- 'Not authorized — registering a runtime version requires @std membership.'
223
- )
224
- return { exitCode: 1 }
225
- }
226
- say.err(`Runtime registration failed: ${err.message}`)
227
- say.dim(
228
- 'Set the origin with --backend <url>; auth with `uniweb login` or --token <bearer>.'
229
- )
230
- return { exitCode: 1 }
231
- }
232
- if (result.failed.length) {
233
- say.err(`${result.failed.length} file(s) failed to upload:`)
234
- for (const f of result.failed)
235
- say.dim(`${f.path} — HTTP ${f.status} ${f.detail}`)
236
- say.dim(
237
- 'Re-run `uniweb runtime register` to resume — completed files dedupe.'
238
- )
239
- return { exitCode: 1 }
240
- }
241
- console.log('')
242
- say.ok(
243
- `Registered ${c.bold}@uniweb/runtime@${version}${c.reset} (${result.uploaded.length} files, ${result.mode} mode)`
244
- )
245
- if (result.serveBase) say.dim(`served at ${result.serveBase}`)
246
- return { exitCode: 0 }
247
- }
248
-
249
- export default runtime
@@ -1,201 +0,0 @@
1
- /**
2
- * Runtime registration — the framework half of `uniweb runtime register`.
3
- *
4
- * Uploads a built `@uniweb/runtime` to the backend so it can serve the runtime
5
- * version. Mirrors the foundation code lane (utils/code-upload.js): plan →
6
- * PUT-per-file, one mode-aware auth rule.
7
- *
8
- * The runtime is a SYSTEM artifact — registering it requires **@std membership**
9
- * (a non-@std bearer 403s). Versioned by version alone (no scope/name).
10
- *
11
- * A runtime version is ONE unit with two halves, BOTH uploaded here (the backend
12
- * stages the bytes; where/how it serves them is its decision — we don't assume a
13
- * serve path, we read `serve_base` back from the plan):
14
- * - dist/app/** the browser SPA (_importmap/, assets/, index.html,
15
- * manifest.json) — boots + client-renders a site.
16
- * - dist/worker-runtime.js + dist/shims/*.js the ssr-edge isolate set (4
17
- * files): the inlined SSR bundle + its 3 globalThis-bridge
18
- * shims (react, react/jsx-runtime, @uniweb/core). The
19
- * isolate can't resolve react without the shims.
20
- *
21
- * NOT uploaded: the SSR *orchestrator* (the isolate's `entry.js` boot module). It's
22
- * a serverless-isolate fetch handler encoding the platform's isolate dispatch
23
- * protocol — owned by the platform's SSR layer, not a framework artifact. The
24
- * framework ships the render API the orchestrator imports (worker-runtime.js
25
- * exports initPrerenderForLocale / renderPage / injectPageContent / hydrateDataStore).
26
- *
27
- * Contract — AGREED with the backend (2026-06-14):
28
- * PLAN POST {apiBase}/dev/runtime
29
- * { version, files: [{ path, content_type, size, sha256 }] }
30
- * → { mode, expires_in, serve_base, uploads: [{ path, method, url, headers }] }
31
- * bearer; @std required (else 403, RFC7807 problem+json, op "runtime-register").
32
- * UPLOAD PUT each file (direct → bearer; presigned → none; x-uniweb-sha256).
33
- * MANIFEST LAST — discovery keys a version's existence on manifest.json,
34
- * so a partial upload never advertises a half-delivered version.
35
- * SERVE the backend's call (read `serve_base` from the plan; we never construct it).
36
- */
37
-
38
- import { createHash } from 'node:crypto'
39
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
40
- import { join } from 'node:path'
41
- import { contentTypeFor } from './code-upload.js'
42
-
43
- const WORKER_RUNTIME = 'worker-runtime.js'
44
- const SHIMS_DIR = 'shims'
45
- const MANIFEST = 'manifest.json'
46
-
47
- function fileEntry(diskPath, path) {
48
- const bytes = readFileSync(diskPath)
49
- return {
50
- path,
51
- content_type: contentTypeFor(path),
52
- size: bytes.length,
53
- sha256: createHash('sha256').update(bytes).digest('hex'),
54
- diskPath
55
- }
56
- }
57
-
58
- /**
59
- * Collect a built runtime's upload set from `distDir` (framework/runtime/dist):
60
- * everything under `dist/app/**` at the root, plus `dist/worker-runtime.js` and
61
- * the `dist/shims/*.js` it depends on. Sourcemaps (`*.map`) are excluded (dev-only,
62
- * not CDN-served). Returns `[]` when `dist/app/` is missing (the runtime isn't
63
- * built). The worker bundle + shims are collected when present (graceful when not).
64
- *
65
- * @param {string} distDir - framework/runtime/dist
66
- * @returns {Array<{ path, content_type, size, sha256, diskPath }>}
67
- */
68
- export function collectRuntimeFiles(distDir) {
69
- const appDir = join(distDir, 'app')
70
- if (!existsSync(appDir)) return []
71
- const files = []
72
- const walk = (dir, prefix) => {
73
- for (const name of readdirSync(dir).sort()) {
74
- const full = join(dir, name)
75
- const rel = prefix ? `${prefix}/${name}` : name
76
- const st = statSync(full)
77
- if (st.isDirectory()) walk(full, rel)
78
- else if (st.isFile() && !rel.endsWith('.map'))
79
- files.push(fileEntry(full, rel))
80
- }
81
- }
82
- walk(appDir, '')
83
- const worker = join(distDir, WORKER_RUNTIME)
84
- if (existsSync(worker)) files.push(fileEntry(worker, WORKER_RUNTIME))
85
- // The SSR isolate's globalThis-bridge shims ride alongside worker-runtime.js,
86
- // served at shims/*.js. They're part of the ssr-edge artifact SET — the isolate
87
- // can't resolve `react` without them — so collect the whole dir when present.
88
- const shimsDir = join(distDir, SHIMS_DIR)
89
- if (existsSync(shimsDir)) walk(shimsDir, SHIMS_DIR)
90
- // MANIFEST LAST: the backend keys a version's existence on manifest.json (no
91
- // server confirm step), so uploading it last means a partial delivery never
92
- // advertises a half-built version. Reorder regardless of walk order.
93
- const manifest = files.filter((f) => f.path === MANIFEST)
94
- if (!manifest.length) return files
95
- return [...files.filter((f) => f.path !== MANIFEST), ...manifest]
96
- }
97
-
98
- /** True when the worker SSR bundle is in the collected set. */
99
- export function hasWorkerRuntime(files) {
100
- return files.some((f) => f.path === WORKER_RUNTIME)
101
- }
102
-
103
- /** True when any SSR-isolate shim (shims/*.js) is in the collected set. */
104
- export function hasShims(files) {
105
- return files.some((f) => f.path.startsWith(`${SHIMS_DIR}/`))
106
- }
107
-
108
- /**
109
- * Plan + upload a built runtime. Throws on a plan-level failure (the caller maps
110
- * 403 → "@std only"); per-file PUT failures surface in `failed`.
111
- *
112
- * @param {object} opts - { apiBase, token, version, distDir, files?, onProgress? }
113
- * @returns {Promise<{ mode, uploaded: string[], failed: Array, serveBase: string|null }>}
114
- */
115
- export async function uploadRuntime({
116
- apiBase,
117
- token,
118
- version,
119
- distDir,
120
- files,
121
- onProgress = () => {}
122
- }) {
123
- const list = files || collectRuntimeFiles(distDir)
124
- if (!list.length)
125
- return { mode: 'none', uploaded: [], failed: [], serveBase: null }
126
-
127
- const origin = apiBase.replace(/\/$/, '')
128
- const planRes = await fetch(`${origin}/dev/runtime`, {
129
- method: 'POST',
130
- headers: {
131
- 'Content-Type': 'application/json',
132
- Authorization: `Bearer ${token}`
133
- },
134
- body: JSON.stringify({
135
- version,
136
- files: list.map(({ path, content_type, size, sha256 }) => ({
137
- path,
138
- content_type,
139
- size,
140
- sha256
141
- }))
142
- })
143
- })
144
- if (!planRes.ok) {
145
- const detail = await planRes.text().catch(() => '')
146
- const err = new Error(
147
- `runtime plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail.slice(0, 300)}` : ''}`
148
- )
149
- err.status = planRes.status
150
- throw err
151
- }
152
- const plan = await planRes.json()
153
- const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
154
- // The one mode-aware bit: direct PUTs are bearer-authed backend routes;
155
- // presigned URLs are self-authorizing and must NOT carry a foreign bearer.
156
- const authHeaders =
157
- plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
158
-
159
- const uploaded = []
160
- const failed = []
161
- for (const f of list) {
162
- const target = targets.get(f.path)
163
- if (!target) {
164
- failed.push({
165
- path: f.path,
166
- status: 0,
167
- detail: 'no upload target in plan'
168
- })
169
- continue
170
- }
171
- onProgress(`↑ ${f.path}`)
172
- let res
173
- try {
174
- res = await fetch(new URL(target.url, origin), {
175
- method: target.method || 'PUT',
176
- headers: {
177
- ...(target.headers || {}),
178
- ...authHeaders,
179
- 'x-uniweb-sha256': f.sha256
180
- },
181
- body: readFileSync(f.diskPath)
182
- })
183
- } catch (err) {
184
- failed.push({ path: f.path, status: 0, detail: err.message })
185
- continue
186
- }
187
- if (res.ok) uploaded.push(f.path)
188
- else
189
- failed.push({
190
- path: f.path,
191
- status: res.status,
192
- detail: (await res.text().catch(() => '')).slice(0, 200)
193
- })
194
- }
195
- return {
196
- mode: plan.mode || 'direct',
197
- uploaded,
198
- failed,
199
- serveBase: plan.serve_base || null
200
- }
201
- }