uniweb 0.27.0 → 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 +7 -7
- package/partials/agents.md +24 -17
- package/src/backend/client.js +25 -37
- package/src/commands/inspect.js +11 -1
- package/src/commands/publish.js +26 -73
- package/src/framework-index.json +8 -8
- package/src/index.js +0 -10
- package/src/commands/runtime.js +0 -249
- package/src/utils/runtime-upload.js +0 -201
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
45
|
-
"@uniweb/
|
|
46
|
-
"@uniweb/kit": "^0.13.
|
|
47
|
-
"@uniweb/
|
|
44
|
+
"@uniweb/runtime": "^0.12.9",
|
|
45
|
+
"@uniweb/semantic-parser": "^1.3.0",
|
|
46
|
+
"@uniweb/kit": "^0.13.2",
|
|
47
|
+
"@uniweb/core": "^0.11.1"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"@uniweb/build": "^0.25.0",
|
|
51
|
-
"@uniweb/semantic-parser": "^1.
|
|
52
|
-
"@uniweb/content-reader": "^1.2.
|
|
51
|
+
"@uniweb/semantic-parser": "^1.3.0",
|
|
52
|
+
"@uniweb/content-reader": "^1.2.4"
|
|
53
53
|
},
|
|
54
54
|
"peerDependenciesMeta": {
|
|
55
55
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -305,11 +305,11 @@ type: Hero
|
|
|
305
305
|
theme: dark
|
|
306
306
|
---
|
|
307
307
|
|
|
308
|
-
|
|
308
|
+
#> New in v1 ← pretitle (the label line above the title)
|
|
309
309
|
|
|
310
310
|
# Build the system. ← title (the big headline)
|
|
311
311
|
|
|
312
|
-
## Not every page. ← subtitle
|
|
312
|
+
## Not every page. ← subtitle (one step smaller, directly below)
|
|
313
313
|
|
|
314
314
|
Description paragraph.
|
|
315
315
|
|
|
@@ -318,7 +318,7 @@ Description paragraph.
|
|
|
318
318
|

|
|
319
319
|
```
|
|
320
320
|
|
|
321
|
-
Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing.
|
|
321
|
+
Heading levels set *structure* (pretitle, title, subtitle), not font size — the component controls visual sizing. The `#>` label line marks a pretitle explicitly (any number of leading `#`s spells the same label); a smaller ordinary heading directly above the title also becomes the pretitle.
|
|
322
322
|
|
|
323
323
|
**A section with no `type:` renders through the foundation's default section type — a component named `Section`, unless the foundation's `main.js` sets `defaultSection` to something else.** This is what lets a folder of plain markdown with no frontmatter at all become pages: mounted documentation, an imported wiki, anything written before it met this framework. If such content renders blank, the foundation has no `Section` — that, not the markdown, is what to fix.
|
|
324
324
|
|
|
@@ -333,8 +333,10 @@ The semantic parser produces a flat, guaranteed structure. No null checks needed
|
|
|
333
333
|
```js
|
|
334
334
|
content = {
|
|
335
335
|
title: '', // Main heading (string or string[] for multi-line)
|
|
336
|
-
pretitle: '', //
|
|
337
|
-
|
|
336
|
+
pretitle: '', // `#>` label line(s), or smaller headings stacked above
|
|
337
|
+
// the title (string or string[])
|
|
338
|
+
subtitle: '', // Line(s) one step below the title — each further
|
|
339
|
+
// one-step descent is another line (string or string[])
|
|
338
340
|
paragraphs: [], // Text blocks
|
|
339
341
|
links: [], // { href, label, role } — standalone links (not inside lists)
|
|
340
342
|
images: [], // { src, alt, role, href }
|
|
@@ -347,7 +349,8 @@ content = {
|
|
|
347
349
|
data: {}, // Tagged blocks — ```yaml:tag / ```json:tag give the parsed value,
|
|
348
350
|
// ```md:tag gives { items, sequence } (see Concept blocks)
|
|
349
351
|
tables: [], // Only when present — [{ rows: [{ cells: [{ children, header, align }] }] }]
|
|
350
|
-
headings: [], //
|
|
352
|
+
headings: [], // Only from nested content (quote/list bodies) — a
|
|
353
|
+
// section's headline never spills here
|
|
351
354
|
items: [], // Each has the same flat structure — from headings after body content
|
|
352
355
|
sequence: [], // All elements in document order
|
|
353
356
|
}
|
|
@@ -358,7 +361,7 @@ content = {
|
|
|
358
361
|
### Markdown → content, side by side
|
|
359
362
|
|
|
360
363
|
```markdown
|
|
361
|
-
|
|
364
|
+
#> Eyebrow │ content.pretitle = "Eyebrow"
|
|
362
365
|
# Our Features │ content.title = "Our Features"
|
|
363
366
|
## Build better products │ content.subtitle = "Build better products"
|
|
364
367
|
│
|
|
@@ -375,7 +378,7 @@ Lightning quick. │ content.items[0].paragraphs[0] = "Lightning
|
|
|
375
378
|
Enterprise-grade security. │ content.items[1].paragraphs[0] = "Enterprise-grade…"
|
|
376
379
|
```
|
|
377
380
|
|
|
378
|
-
The
|
|
381
|
+
The staircase rule produces this — each heading relates to the one before it: the same size adds another line to the same part; **one step smaller** joins the headline as the next part down (the subtitle, then further subtitle lines); **two steps smaller** starts an item; and once body content has begun, *any* heading starts an item. `#>` label lines, and smaller headings stacked above the title, become `pretitle`.
|
|
379
382
|
|
|
380
383
|
### Items have the full content shape
|
|
381
384
|
|
|
@@ -391,21 +394,20 @@ seats: 1
|
|
|
391
394
|
``` ← items[0].data.details = { trial: "14 days", seats: 1 }
|
|
392
395
|
````
|
|
393
396
|
|
|
394
|
-
### Subtitle vs items — the
|
|
397
|
+
### Subtitle vs items — the step rule
|
|
395
398
|
|
|
396
|
-
A heading
|
|
399
|
+
A heading directly after the title becomes `subtitle` **only when it is exactly one step smaller** (H1→H2, H2→H3) — and each *further* one-step line continues the subtitle (a three-line header is `subtitle: [line2, line3]`). **Two steps smaller starts items** — the idiom for entries with no lead paragraph:
|
|
397
400
|
|
|
398
401
|
```markdown
|
|
399
402
|
# Our Stats │ content.title = "Our Stats"
|
|
400
|
-
|
|
401
|
-
## 15,000+ │ content.items[0].title = "15,000+"
|
|
403
|
+
### 15,000+ │ content.items[0].title = "15,000+"
|
|
402
404
|
Students from 90 countries │ content.items[0].paragraphs[0]
|
|
403
405
|
│
|
|
404
|
-
|
|
406
|
+
### 200+ │ content.items[1].title = "200+"
|
|
405
407
|
Programs offered │ content.items[1].paragraphs[0]
|
|
406
408
|
```
|
|
407
409
|
|
|
408
|
-
|
|
410
|
+
With `## 15,000+` instead, the first stat would join the headline as `content.subtitle`. A `---` divider, or any body content after the headline, also closes it — after body, headings of *any* size start items.
|
|
409
411
|
|
|
410
412
|
### Multi-line headings
|
|
411
413
|
|
|
@@ -421,7 +423,7 @@ Consecutive headings at the same level merge into a title array — one heading
|
|
|
421
423
|
│ ]
|
|
422
424
|
```
|
|
423
425
|
|
|
424
|
-
**Rule:**
|
|
426
|
+
**Rule:** a repeated size always continues the part it repeats — title lines, or subtitle lines once the headline has stepped down. Use `---` to force separate items where adjacent headings would otherwise continue the headline.
|
|
425
427
|
|
|
426
428
|
### Sequential content
|
|
427
429
|
|
|
@@ -2047,6 +2049,11 @@ yes, so an event added in a later framework release is included without you
|
|
|
2047
2049
|
changing anything — which is exactly why `standard` exists as well: it is a
|
|
2048
2050
|
curated set that a release cannot grow behind your back.
|
|
2049
2051
|
|
|
2052
|
+
A site can also set **`flushIntervalMs`** to widen the batching window (default
|
|
2053
|
+
5000, milliseconds — `30` is thirty *milliseconds*). A host that supplies your
|
|
2054
|
+
collector may set it for you and your own value wins; the trade is fewer
|
|
2055
|
+
requests against more tail loss at the end of a visit.
|
|
2056
|
+
|
|
2050
2057
|
⚠️ **`emit` never limits what YOU send.** `block.track()` and `useTracker()` are
|
|
2051
2058
|
not filtered by it — the registry is open, and your events are yours. It governs
|
|
2052
2059
|
only the ones the framework emits on its own. A host may narrow the list further
|
|
@@ -2211,7 +2218,7 @@ uniweb inspect <path> # Show parsed content for a section or page (-
|
|
|
2211
2218
|
uniweb <command> --help # Per-command flags — no side effects. Prefer this over guessing.
|
|
2212
2219
|
```
|
|
2213
2220
|
|
|
2214
|
-
**
|
|
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.
|
|
2215
2222
|
|
|
2216
2223
|
### Where a site can live
|
|
2217
2224
|
|
|
@@ -2414,7 +2421,7 @@ Most Uniweb failures are **silent** — the build succeeds and the page is wrong
|
|
|
2414
2421
|
|
|
2415
2422
|
**A section doesn't appear at all** — the file is `@`-prefixed (a child section, only rendered via `nest:`), or `_`-prefixed (treated as a draft and skipped), or it's a section type nested below the root of `sections/` without a `meta.js`, which means it was never discovered.
|
|
2416
2423
|
|
|
2417
|
-
**Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the
|
|
2424
|
+
**Content lands in the wrong field** — a heading became a `subtitle` when you wanted an item, or the reverse. That's the step rule (one step smaller and adjacent = the next headline line; two steps, a divider, or any body content first = items). Run `uniweb inspect <path>` rather than re-deriving it.
|
|
2418
2425
|
|
|
2419
2426
|
**A var in frontmatter does nothing** — component vars only apply when declared in that section type's `meta.js` `vars:`. Unknown names are ignored silently.
|
|
2420
2427
|
|
package/src/backend/client.js
CHANGED
|
@@ -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
|
|
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,
|
|
432
|
-
*
|
|
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
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
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, {
|
|
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}. */
|
package/src/commands/inspect.js
CHANGED
|
@@ -56,7 +56,10 @@ async function loadDependencies() {
|
|
|
56
56
|
])
|
|
57
57
|
return {
|
|
58
58
|
markdownToProseMirror: contentReader.markdownToProseMirror,
|
|
59
|
-
parseContent: semanticParser.parseContent
|
|
59
|
+
parseContent: semanticParser.parseContent,
|
|
60
|
+
// Optional: present from @uniweb/semantic-parser >= the staircase
|
|
61
|
+
// release; older installs simply show no findings.
|
|
62
|
+
lintContent: semanticParser.lintContent
|
|
60
63
|
}
|
|
61
64
|
} catch {
|
|
62
65
|
console.error(
|
|
@@ -303,6 +306,13 @@ function processFile(fileContent, fileName, deps, options) {
|
|
|
303
306
|
|
|
304
307
|
if (insets.length > 0) result.insets = insets
|
|
305
308
|
|
|
309
|
+
// Grouping near-miss findings — shapes that usually mean the author got a
|
|
310
|
+
// different structure than they wanted (an entry absorbed into the
|
|
311
|
+
// headline, a hand-written eyebrow demoting the title). Observation only:
|
|
312
|
+
// the parse above is untouched.
|
|
313
|
+
const findings = deps.lintContent?.(doc) || []
|
|
314
|
+
if (findings.length > 0) result.findings = findings
|
|
315
|
+
|
|
306
316
|
return result
|
|
307
317
|
}
|
|
308
318
|
|
package/src/commands/publish.js
CHANGED
|
@@ -220,76 +220,33 @@ export async function publish(args = []) {
|
|
|
220
220
|
return { exitCode: 1 }
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
// a
|
|
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
|
|
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 (`
|
|
267
|
-
// `register
|
|
268
|
-
//
|
|
269
|
-
//
|
|
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
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
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
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
//
|
|
788
|
-
|
|
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
|
})
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-08-
|
|
3
|
+
"generatedAt": "2026-08-22T18:52:00.408Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/build": {
|
|
6
6
|
"version": "0.25.0",
|
|
@@ -19,17 +19,17 @@
|
|
|
19
19
|
]
|
|
20
20
|
},
|
|
21
21
|
"@uniweb/content-reader": {
|
|
22
|
-
"version": "1.2.
|
|
22
|
+
"version": "1.2.4",
|
|
23
23
|
"path": "framework/content-reader",
|
|
24
24
|
"deps": []
|
|
25
25
|
},
|
|
26
26
|
"@uniweb/content-writer": {
|
|
27
|
-
"version": "0.3.
|
|
27
|
+
"version": "0.3.4",
|
|
28
28
|
"path": "framework/content-writer",
|
|
29
29
|
"deps": []
|
|
30
30
|
},
|
|
31
31
|
"@uniweb/core": {
|
|
32
|
-
"version": "0.11.
|
|
32
|
+
"version": "0.11.1",
|
|
33
33
|
"path": "framework/core",
|
|
34
34
|
"deps": [
|
|
35
35
|
"@uniweb/semantic-parser",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
]
|
|
50
50
|
},
|
|
51
51
|
"@uniweb/kit": {
|
|
52
|
-
"version": "0.13.
|
|
52
|
+
"version": "0.13.2",
|
|
53
53
|
"path": "framework/kit",
|
|
54
54
|
"deps": [
|
|
55
55
|
"@uniweb/core",
|
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
]
|
|
77
77
|
},
|
|
78
78
|
"@uniweb/runtime": {
|
|
79
|
-
"version": "0.12.
|
|
79
|
+
"version": "0.12.9",
|
|
80
80
|
"path": "framework/runtime",
|
|
81
81
|
"deps": [
|
|
82
82
|
"@uniweb/core",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"deps": []
|
|
100
100
|
},
|
|
101
101
|
"@uniweb/semantic-parser": {
|
|
102
|
-
"version": "1.
|
|
102
|
+
"version": "1.3.0",
|
|
103
103
|
"path": "framework/semantic-parser",
|
|
104
104
|
"deps": []
|
|
105
105
|
},
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"deps": []
|
|
115
115
|
},
|
|
116
116
|
"@uniweb/unipress": {
|
|
117
|
-
"version": "0.8.
|
|
117
|
+
"version": "0.8.11",
|
|
118
118
|
"path": "framework/unipress",
|
|
119
119
|
"deps": [
|
|
120
120
|
"@uniweb/build",
|
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)
|
package/src/commands/runtime.js
DELETED
|
@@ -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
|
-
}
|