uniweb 0.12.26 → 0.12.28
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 +6 -6
- package/partials/agents.md +3 -0
- package/src/backend/client.js +339 -0
- package/src/commands/clone.js +18 -32
- package/src/commands/deploy.js +218 -1783
- package/src/commands/handoff.js +9 -246
- package/src/commands/invite.js +10 -318
- package/src/commands/org.js +6 -8
- package/src/commands/publish.js +128 -1153
- package/src/commands/pull.js +22 -36
- package/src/commands/push.js +43 -101
- package/src/commands/register.js +184 -39
- package/src/commands/runtime.js +141 -0
- package/src/commands/template.js +13 -221
- package/src/framework-index.json +18 -7
- package/src/index.js +74 -100
- package/src/utils/asset-upload.js +162 -0
- package/src/utils/code-upload.js +245 -0
- package/src/utils/config.js +11 -44
- package/src/utils/registry-auth.js +35 -1
- package/src/utils/registry-orgs.js +141 -73
- package/src/utils/runtime-upload.js +163 -0
- package/src/commands/login.js +0 -230
- package/src/utils/auth.js +0 -212
- package/src/utils/registry.js +0 -466
|
@@ -2,27 +2,35 @@
|
|
|
2
2
|
* New-backend org operations for the publish-scope bootstrap — used by
|
|
3
3
|
* `uniweb register`'s scope resolution and the `uniweb org` command.
|
|
4
4
|
*
|
|
5
|
-
* One resource (Bearer auth,
|
|
6
|
-
* GET /dev/orgs → [{ handle, is_primary }] (your orgs, primary first)
|
|
7
|
-
* POST /dev/orgs { handle } → { handle, uuid, is_primary } (creator becomes a
|
|
8
|
-
* member, primary iff their first)
|
|
5
|
+
* One resource (Bearer auth, the CLI's /dev lane); the verb selects the op:
|
|
9
6
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* GET /dev/orgs → {
|
|
8
|
+
* account_handle: string|null, // the caller's account handle
|
|
9
|
+
* personal_org_exists: bool, // does @<account_handle> exist as an org
|
|
10
|
+
* orgs: [{ handle, is_primary }] // memberships, primary first;
|
|
11
|
+
* } // handle-less units filtered server-side
|
|
12
|
+
*
|
|
13
|
+
* POST /dev/orgs { handle } → { handle, uuid, is_primary }
|
|
14
|
+
*
|
|
15
|
+
* The PERSONAL org needs no flag: handles live in ONE global namespace across
|
|
16
|
+
* accounts + orgs and only the owner can create the org matching their account
|
|
17
|
+
* handle — so `org.handle === account_handle` is a sound derivation, and the
|
|
18
|
+
* lazy personal claim is just a create. Org creation requires NO second factor
|
|
19
|
+
* on any lane (the 2FA gate lives at escalation points, not here).
|
|
20
|
+
*
|
|
21
|
+
* Failure shapes (branch on STATUS; details are human display, not contract):
|
|
22
|
+
* 422 = handle grammar; 409 = taken / reserved / belongs to another account
|
|
23
|
+
* (one status, server detail says which). Reserved names are SERVER-curated —
|
|
24
|
+
* deliberately not replicated here; the 409 detail carries the answer.
|
|
25
|
+
*
|
|
26
|
+
* Handle grammar (pre-validated for a fast prompt): lowercase alphanumerics +
|
|
27
|
+
* hyphens, 3–39 chars, no leading/trailing hyphen (consecutive hyphens are
|
|
28
|
+
* allowed).
|
|
12
29
|
*/
|
|
13
30
|
|
|
14
31
|
const ORGS_PATH = '/dev/orgs'
|
|
15
32
|
|
|
16
|
-
|
|
17
|
-
// (uwx-format scope model). Grammatically valid, so the server returns 409
|
|
18
|
-
// "taken" for these; we block them earlier for a clearer message.
|
|
19
|
-
const RESERVED_HANDLES = new Set(['uniweb', 'std'])
|
|
20
|
-
|
|
21
|
-
// Handle grammar (backend): lowercase alphanumerics + internal hyphens, 3–39 chars,
|
|
22
|
-
// no leading/trailing hyphen. The server is authoritative on reserved names (409).
|
|
23
|
-
const HANDLE_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/
|
|
24
|
-
const HANDLE_MIN = 3
|
|
25
|
-
const HANDLE_MAX = 39
|
|
33
|
+
const HANDLE_RE = /^[a-z0-9][a-z0-9-]{1,37}[a-z0-9]$/
|
|
26
34
|
|
|
27
35
|
/** Strip a leading `@` and any `/suffix`, returning the bare handle segment. */
|
|
28
36
|
export function bareHandle(scope) {
|
|
@@ -30,34 +38,44 @@ export function bareHandle(scope) {
|
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
|
-
* Validate a handle client-side
|
|
34
|
-
*
|
|
41
|
+
* Validate a handle's GRAMMAR client-side (reserved names are the server's
|
|
42
|
+
* call — a 409 carries the verdict). Returns an error string, or null.
|
|
35
43
|
*/
|
|
36
44
|
export function validateHandle(handle) {
|
|
37
45
|
const h = bareHandle(handle)
|
|
38
46
|
if (!h) return 'A handle is required.'
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
if (!HANDLE_RE.test(h)) {
|
|
48
|
+
return 'Use 3–39 lowercase letters, digits, and hyphens (no leading/trailing hyphen).'
|
|
49
|
+
}
|
|
42
50
|
return null
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
54
|
+
* The picker read: memberships + the caller's account handle + whether the
|
|
55
|
+
* personal org already exists.
|
|
56
|
+
* @returns {Promise<{account_handle: string|null, personal_org_exists: boolean, orgs: Array<{handle: string, is_primary: boolean}>}>}
|
|
48
57
|
*/
|
|
49
|
-
export async function
|
|
58
|
+
export async function fetchOrgs({ apiBase, token }) {
|
|
50
59
|
const res = await fetch(`${apiBase.replace(/\/$/, '')}${ORGS_PATH}`, {
|
|
51
60
|
headers: { Authorization: `Bearer ${token}` },
|
|
52
61
|
})
|
|
53
62
|
if (!res.ok) throw new Error(`Could not list your orgs: HTTP ${res.status} ${res.statusText}`)
|
|
54
63
|
const data = await res.json().catch(() => null)
|
|
55
|
-
return
|
|
64
|
+
return {
|
|
65
|
+
account_handle: data?.account_handle ?? null,
|
|
66
|
+
personal_org_exists: data?.personal_org_exists === true,
|
|
67
|
+
orgs: Array.isArray(data?.orgs) ? data.orgs : [],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Back-compat alias: the membership rows only. */
|
|
72
|
+
export async function listOrgs(opts) {
|
|
73
|
+
return (await fetchOrgs(opts)).orgs
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
/**
|
|
59
|
-
* Create an org
|
|
60
|
-
*
|
|
77
|
+
* Create an org (the lazy personal claim is exactly this create — the
|
|
78
|
+
* owner-exception admits `handle === account_handle` for the owner).
|
|
61
79
|
* @returns {Promise<{handle: string, uuid?: string, is_primary?: boolean}>}
|
|
62
80
|
*/
|
|
63
81
|
export async function createOrg({ apiBase, token, handle }) {
|
|
@@ -67,56 +85,58 @@ export async function createOrg({ apiBase, token, handle }) {
|
|
|
67
85
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
68
86
|
body: JSON.stringify({ handle: h }),
|
|
69
87
|
})
|
|
70
|
-
if (res.
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
return res.json()
|
|
88
|
+
if (res.ok) return res.json()
|
|
89
|
+
|
|
90
|
+
// Surface the server's human detail — a 409 distinguishes taken vs
|
|
91
|
+
// reserved vs belongs-to-another-account; a 422 names the grammar rule.
|
|
92
|
+
let detail = ''
|
|
93
|
+
try {
|
|
94
|
+
const body = await res.json()
|
|
95
|
+
detail = body?.detail || ''
|
|
96
|
+
} catch { /* non-JSON body — keep the generic line */ }
|
|
97
|
+
const fallback =
|
|
98
|
+
res.status === 409 ? `@${h} is not available.`
|
|
99
|
+
: res.status === 422 ? `@${h} is not a valid handle.`
|
|
100
|
+
: `Could not create @${h}: HTTP ${res.status}`
|
|
101
|
+
const e = new Error(detail || fallback)
|
|
102
|
+
e.status = res.status
|
|
103
|
+
throw e
|
|
88
104
|
}
|
|
89
105
|
|
|
90
106
|
/**
|
|
91
107
|
* Derive the publish scope from login membership when none was supplied:
|
|
92
|
-
* 1 org
|
|
93
|
-
* 0 orgs →
|
|
94
|
-
* prompts — never username-derived; cold-start per handle-origin (ii))
|
|
95
|
-
* N orgs → pick
|
|
108
|
+
* ≥1 org → confirm (1) or pick (N), personal org labeled + first
|
|
109
|
+
* 0 orgs → the first-publish picker (personal default, lazily claimed)
|
|
96
110
|
* Returns the chosen bare handle (no `@`), or null if cancelled. Persists
|
|
97
111
|
* nothing — the caller records it. Bails in non-interactive mode.
|
|
98
112
|
*
|
|
99
113
|
* @param {Object} p
|
|
100
114
|
* @param {string} p.apiBase
|
|
101
115
|
* @param {string} p.token
|
|
102
|
-
* @param {string|null} [p.accountHandle] - the
|
|
116
|
+
* @param {string|null} [p.accountHandle] - fallback only; the server's
|
|
117
|
+
* account_handle from the orgs read is authoritative
|
|
103
118
|
* @param {string[]} [p.args] - argv slice; checked for --non-interactive
|
|
104
119
|
* @returns {Promise<string|null>}
|
|
105
120
|
*/
|
|
106
121
|
export async function deriveScope({ apiBase, token, accountHandle = null, args = [] }) {
|
|
107
|
-
const
|
|
122
|
+
const envelope = await fetchOrgs({ apiBase, token })
|
|
123
|
+
const { orgs } = envelope
|
|
124
|
+
const personal = envelope.account_handle || (accountHandle ? bareHandle(accountHandle) : null)
|
|
125
|
+
const personalOrgExists = envelope.personal_org_exists
|
|
108
126
|
const { isNonInteractive } = await import('./interactive.js')
|
|
109
127
|
const nonInteractive = isNonInteractive(args)
|
|
128
|
+
const isPersonal = (h) => personal && h === personal
|
|
110
129
|
|
|
111
130
|
if (orgs.length === 1) {
|
|
112
131
|
const h = orgs[0].handle
|
|
132
|
+
const label = isPersonal(h) ? `your personal org @${h}` : `your org @${h}`
|
|
113
133
|
if (nonInteractive) {
|
|
114
|
-
console.log(`Publishing under
|
|
134
|
+
console.log(`Publishing under ${label.replace(`@${h}`, `\x1b[1m@${h}\x1b[0m`)}.`)
|
|
115
135
|
return h
|
|
116
136
|
}
|
|
117
137
|
const prompts = (await import('prompts')).default
|
|
118
138
|
const { ok } = await prompts({
|
|
119
|
-
type: 'confirm', name: 'ok', message: `Publish under
|
|
139
|
+
type: 'confirm', name: 'ok', message: `Publish under ${label}?`, initial: true,
|
|
120
140
|
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
121
141
|
if (!ok) {
|
|
122
142
|
console.log('Pass --scope @org, or create another with `uniweb org create <handle>`.')
|
|
@@ -126,47 +146,95 @@ export async function deriveScope({ apiBase, token, accountHandle = null, args =
|
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
if (orgs.length > 1) {
|
|
149
|
+
// Personal org first; the rest in server order (primary-first).
|
|
150
|
+
const ordered = [...orgs].sort((a, b) => (isPersonal(b.handle) ? 1 : 0) - (isPersonal(a.handle) ? 1 : 0))
|
|
129
151
|
if (nonInteractive) {
|
|
130
|
-
const
|
|
131
|
-
console.log(`Multiple orgs; using
|
|
132
|
-
return
|
|
152
|
+
const pick = ordered.find((u) => isPersonal(u.handle)) || orgs.find((u) => u.is_primary) || orgs[0]
|
|
153
|
+
console.log(`Multiple orgs; using \x1b[1m@${pick.handle}\x1b[0m (non-interactive).`)
|
|
154
|
+
return pick.handle
|
|
133
155
|
}
|
|
134
156
|
const prompts = (await import('prompts')).default
|
|
135
157
|
const { choice } = await prompts({
|
|
136
158
|
type: 'select',
|
|
137
159
|
name: 'choice',
|
|
138
160
|
message: 'Publish under which org?',
|
|
139
|
-
choices:
|
|
161
|
+
choices: ordered.map((u) => ({
|
|
162
|
+
title: `@${u.handle}${isPersonal(u.handle) ? ' — your personal org' : u.is_primary ? ' (primary)' : ''}`,
|
|
163
|
+
value: u.handle,
|
|
164
|
+
})),
|
|
165
|
+
initial: 0,
|
|
140
166
|
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
141
167
|
return choice || null
|
|
142
168
|
}
|
|
143
169
|
|
|
144
|
-
// 0 orgs →
|
|
170
|
+
// 0 orgs → the first-publish picker.
|
|
145
171
|
if (nonInteractive) {
|
|
146
172
|
console.error('\x1b[31m✗\x1b[0m You have no org to publish under. Create one with `uniweb org create <handle>`, or pass --scope @org.')
|
|
147
173
|
process.exit(1)
|
|
148
174
|
}
|
|
149
|
-
return offerCreateOrg({ apiBase, token, accountHandle })
|
|
175
|
+
return offerCreateOrg({ apiBase, token, accountHandle: personal, personalOrgExists })
|
|
150
176
|
}
|
|
151
177
|
|
|
152
178
|
/**
|
|
153
|
-
* Cold-start
|
|
154
|
-
*
|
|
155
|
-
*
|
|
179
|
+
* Cold-start (0 orgs) — the first-publish org choice. Every account handle
|
|
180
|
+
* is a reserved, ready-to-go org handle (globally unique across accounts +
|
|
181
|
+
* orgs; only the owner can claim it), so the default is one keystroke:
|
|
182
|
+
*
|
|
183
|
+
* ? Publish under which org?
|
|
184
|
+
* ❯ @jane — your personal org (created on first publish)
|
|
185
|
+
* A new organization…
|
|
186
|
+
*
|
|
187
|
+
* The personal org is materialized LAZILY here — at first publish, never at
|
|
188
|
+
* signup. Two guards:
|
|
189
|
+
* - no account handle (Service/System accounts — real signups always mint
|
|
190
|
+
* one) → a crisp pointer instead of a prompt;
|
|
191
|
+
* - the personal org exists but the caller is no longer a member
|
|
192
|
+
* (created-then-left) → the lazy claim would 409; don't offer it.
|
|
193
|
+
* Returns the chosen bare handle, or null if cancelled/failed.
|
|
156
194
|
*/
|
|
157
|
-
export async function offerCreateOrg({ apiBase, token, accountHandle = null }) {
|
|
195
|
+
export async function offerCreateOrg({ apiBase, token, accountHandle = null, personalOrgExists = false }) {
|
|
158
196
|
const prompts = (await import('prompts')).default
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
197
|
+
const personal = accountHandle && !validateHandle(accountHandle) ? bareHandle(accountHandle) : null
|
|
198
|
+
|
|
199
|
+
if (!personal) {
|
|
200
|
+
console.error(
|
|
201
|
+
'\x1b[31m✗\x1b[0m This account has no handle (service accounts don\'t get one), so there is no ready-to-go org.\n' +
|
|
202
|
+
' Log in with a personal account or set a handle in the app — or pass --scope @org / `uniweb org create <handle>`.'
|
|
203
|
+
)
|
|
204
|
+
return null
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const canClaimPersonal = !personalOrgExists
|
|
208
|
+
const choices = [
|
|
209
|
+
...(canClaimPersonal
|
|
210
|
+
? [{ title: `@${personal} — your personal org (created on first publish)`, value: personal }]
|
|
211
|
+
: []),
|
|
212
|
+
{ title: 'A new organization…', value: ':new' },
|
|
213
|
+
]
|
|
214
|
+
if (!canClaimPersonal) {
|
|
215
|
+
console.log(`\x1b[2m@${personal} exists but you're not a member of it — ask its admin, or create another org.\x1b[0m`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const { choice } = await prompts({
|
|
219
|
+
type: 'select',
|
|
220
|
+
name: 'choice',
|
|
221
|
+
message: 'Publish under which org?',
|
|
222
|
+
choices,
|
|
223
|
+
initial: 0,
|
|
168
224
|
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
169
|
-
if (!
|
|
225
|
+
if (!choice) return null
|
|
226
|
+
|
|
227
|
+
let handle = choice
|
|
228
|
+
if (choice === ':new') {
|
|
229
|
+
const answer = await prompts({
|
|
230
|
+
type: 'text',
|
|
231
|
+
name: 'handle',
|
|
232
|
+
message: 'Org handle (e.g. acme):',
|
|
233
|
+
validate: (v) => validateHandle(v) || true,
|
|
234
|
+
}, { onCancel: () => { console.log('\nCancelled.'); process.exit(0) } })
|
|
235
|
+
if (!answer.handle) return null
|
|
236
|
+
handle = answer.handle
|
|
237
|
+
}
|
|
170
238
|
|
|
171
239
|
try {
|
|
172
240
|
const org = await createOrg({ apiBase, token, handle })
|
|
@@ -0,0 +1,163 @@
|
|
|
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')) files.push(fileEntry(full, rel))
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
walk(appDir, '')
|
|
82
|
+
const worker = join(distDir, WORKER_RUNTIME)
|
|
83
|
+
if (existsSync(worker)) files.push(fileEntry(worker, WORKER_RUNTIME))
|
|
84
|
+
// The SSR isolate's globalThis-bridge shims ride alongside worker-runtime.js,
|
|
85
|
+
// served at shims/*.js. They're part of the ssr-edge artifact SET — the isolate
|
|
86
|
+
// can't resolve `react` without them — so collect the whole dir when present.
|
|
87
|
+
const shimsDir = join(distDir, SHIMS_DIR)
|
|
88
|
+
if (existsSync(shimsDir)) walk(shimsDir, SHIMS_DIR)
|
|
89
|
+
// MANIFEST LAST: the backend keys a version's existence on manifest.json (no
|
|
90
|
+
// server confirm step), so uploading it last means a partial delivery never
|
|
91
|
+
// advertises a half-built version. Reorder regardless of walk order.
|
|
92
|
+
const manifest = files.filter((f) => f.path === MANIFEST)
|
|
93
|
+
if (!manifest.length) return files
|
|
94
|
+
return [...files.filter((f) => f.path !== MANIFEST), ...manifest]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when the worker SSR bundle is in the collected set. */
|
|
98
|
+
export function hasWorkerRuntime(files) {
|
|
99
|
+
return files.some((f) => f.path === WORKER_RUNTIME)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when any SSR-isolate shim (shims/*.js) is in the collected set. */
|
|
103
|
+
export function hasShims(files) {
|
|
104
|
+
return files.some((f) => f.path.startsWith(`${SHIMS_DIR}/`))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Plan + upload a built runtime. Throws on a plan-level failure (the caller maps
|
|
109
|
+
* 403 → "@std only"); per-file PUT failures surface in `failed`.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} opts - { apiBase, token, version, distDir, files?, onProgress? }
|
|
112
|
+
* @returns {Promise<{ mode, uploaded: string[], failed: Array, serveBase: string|null }>}
|
|
113
|
+
*/
|
|
114
|
+
export async function uploadRuntime({ apiBase, token, version, distDir, files, onProgress = () => {} }) {
|
|
115
|
+
const list = files || collectRuntimeFiles(distDir)
|
|
116
|
+
if (!list.length) return { mode: 'none', uploaded: [], failed: [], serveBase: null }
|
|
117
|
+
|
|
118
|
+
const origin = apiBase.replace(/\/$/, '')
|
|
119
|
+
const planRes = await fetch(`${origin}/dev/runtime`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
|
122
|
+
body: JSON.stringify({
|
|
123
|
+
version,
|
|
124
|
+
files: list.map(({ path, content_type, size, sha256 }) => ({ path, content_type, size, sha256 })),
|
|
125
|
+
}),
|
|
126
|
+
})
|
|
127
|
+
if (!planRes.ok) {
|
|
128
|
+
const detail = await planRes.text().catch(() => '')
|
|
129
|
+
const err = new Error(`runtime plan rejected: HTTP ${planRes.status}${detail ? ` — ${detail.slice(0, 300)}` : ''}`)
|
|
130
|
+
err.status = planRes.status
|
|
131
|
+
throw err
|
|
132
|
+
}
|
|
133
|
+
const plan = await planRes.json()
|
|
134
|
+
const targets = new Map((plan.uploads || []).map((u) => [u.path, u]))
|
|
135
|
+
// The one mode-aware bit: direct PUTs are bearer-authed backend routes;
|
|
136
|
+
// presigned URLs are self-authorizing and must NOT carry a foreign bearer.
|
|
137
|
+
const authHeaders = plan.mode === 'presigned' ? {} : { Authorization: `Bearer ${token}` }
|
|
138
|
+
|
|
139
|
+
const uploaded = []
|
|
140
|
+
const failed = []
|
|
141
|
+
for (const f of list) {
|
|
142
|
+
const target = targets.get(f.path)
|
|
143
|
+
if (!target) {
|
|
144
|
+
failed.push({ path: f.path, status: 0, detail: 'no upload target in plan' })
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
onProgress(`↑ ${f.path}`)
|
|
148
|
+
let res
|
|
149
|
+
try {
|
|
150
|
+
res = await fetch(new URL(target.url, origin), {
|
|
151
|
+
method: target.method || 'PUT',
|
|
152
|
+
headers: { ...(target.headers || {}), ...authHeaders, 'x-uniweb-sha256': f.sha256 },
|
|
153
|
+
body: readFileSync(f.diskPath),
|
|
154
|
+
})
|
|
155
|
+
} catch (err) {
|
|
156
|
+
failed.push({ path: f.path, status: 0, detail: err.message })
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
if (res.ok) uploaded.push(f.path)
|
|
160
|
+
else failed.push({ path: f.path, status: res.status, detail: (await res.text().catch(() => '')).slice(0, 200) })
|
|
161
|
+
}
|
|
162
|
+
return { mode: plan.mode || 'direct', uploaded, failed, serveBase: plan.serve_base || null }
|
|
163
|
+
}
|