@jxsuite/studio 5.5.0 → 5.6.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/LICENSE +1 -1
- package/dist/chunks/{2020-g8mwpbkq.js → 2020-zvax9fmb.js} +3 -3
- package/dist/chunks/adapter-6zhjbpmd.js +4 -0
- package/dist/chunks/cf-account-picker-zfycs4k2.js +4 -0
- package/dist/chunks/cf-settings-fpa485q3.js +4 -0
- package/dist/chunks/components-b0vh4sq8.js +4 -0
- package/dist/chunks/content-source-z5t0d0h9.js +4 -0
- package/dist/chunks/convert-to-repeater-x19jr6c7.js +4 -0
- package/dist/chunks/editor-7e23n4a8.js +4 -0
- package/dist/chunks/entry-commands-5cm6aba2.js +4 -0
- package/dist/chunks/extension-sections-y90sa0n4.js +4 -0
- package/dist/chunks/file-ops-hv7gf757.js +4 -0
- package/dist/chunks/files-kb0epdxf.js +4 -0
- package/dist/chunks/format-host-eg1wy2bp.js +4 -0
- package/dist/chunks/fs-events-ky3vxfmh.js +4 -0
- package/dist/chunks/git-panel-yyg3bzp2.js +4 -0
- package/dist/chunks/grid-view-5mqv1cbd.css +1 -0
- package/dist/chunks/grid-view-d9mybhn0.js +51 -0
- package/dist/chunks/iframe-host-9wdf0hes.js +4 -0
- package/dist/chunks/index-2tf0m2ne.js +4 -0
- package/dist/chunks/{index-arg188jq.js → index-vnwy9d44.js} +3 -3
- package/dist/chunks/{javascript-zcdmye2k.js → javascript-f0nrbdjb.js} +2 -2
- package/dist/chunks/{jsonMode-6x5e1nbf.js → jsonMode-nfa90dtf.js} +5 -5
- package/dist/chunks/jx-validate-7w11db9v.js +4 -0
- package/dist/chunks/layers-7zasrpse.js +4 -0
- package/dist/chunks/layers-panel-9nzc4fdq.js +4 -0
- package/dist/chunks/lit-html-5qr7nbng.js +4 -0
- package/dist/chunks/media-picker-maevn9d4.js +4 -0
- package/dist/chunks/menu-wq6q0v3y.js +4 -0
- package/dist/chunks/{monaco-binding-4az2kh5p.js → monaco-binding-antn79jf.js} +3 -3
- package/dist/chunks/monaco-buffer-9ry2rn5x.js +4 -0
- package/dist/chunks/{monaco-setup-d7mwkcx3.js → monaco-setup-k8s40vwg.js} +3 -3
- package/dist/chunks/notify-g91gjd6w.js +4 -0
- package/dist/chunks/platform-jdwjp2vt.js +4 -0
- package/dist/chunks/publish-panel-8yym447p.js +4 -0
- package/dist/chunks/quick-search-9bzm9kh3.js +4 -0
- package/dist/chunks/references-znssfqvq.js +4 -0
- package/dist/chunks/right-panel-czbzktm8.js +4 -0
- package/dist/chunks/section-registry-0rtq73qs.js +4 -0
- package/dist/chunks/studio-011em5cf.js +5 -0
- package/dist/chunks/studio-02y9cb4h.js +6 -0
- package/dist/chunks/{studio-7gqyhjns.js → studio-09n3fyy0.js} +4 -4
- package/dist/chunks/{studio-xcng8nx7.js → studio-0c3fpdqj.js} +5 -5
- package/dist/chunks/studio-0mm38y62.js +5 -0
- package/dist/chunks/studio-0nm2skex.js +4 -0
- package/dist/chunks/{studio-73czgk6a.js → studio-153mwkyq.js} +4 -4
- package/dist/chunks/{studio-f8jyp45j.js → studio-1pxvgx1t.js} +4 -4
- package/dist/chunks/studio-27tc8q0h.js +5 -0
- package/dist/chunks/studio-28pz4s1k.js +5 -0
- package/dist/chunks/{studio-x5esgzct.js → studio-36s6qjgq.js} +4 -4
- package/dist/chunks/studio-4yztve2t.js +5 -0
- package/dist/chunks/{studio-x9sagq2d.js → studio-5fc0728f.js} +7 -7
- package/dist/chunks/{studio-7jazdy6y.js → studio-654fxcr0.js} +4 -4
- package/dist/chunks/studio-6exg5z4x.js +4 -0
- package/dist/chunks/studio-6k1eswp9.js +5 -0
- package/dist/chunks/{studio-5wfrgee9.js → studio-6zyt56ek.js} +4 -4
- package/dist/chunks/studio-7djymzt2.js +5 -0
- package/dist/chunks/{studio-b4epb26c.js → studio-7w04zw8h.js} +7 -7
- package/dist/chunks/studio-805fwp9k.js +5 -0
- package/dist/chunks/{studio-k1eaqyzm.js → studio-81twpmzj.js} +4 -4
- package/dist/chunks/studio-891t1tv9.js +5 -0
- package/dist/chunks/{studio-5n1km79v.js → studio-9n2j9nqk.js} +4 -4
- package/dist/chunks/{studio-he2fhrgp.js → studio-9tkaykt5.js} +4 -4
- package/dist/chunks/studio-ant1zbp3.js +5 -0
- package/dist/chunks/studio-b29h7g7s.js +5 -0
- package/dist/chunks/studio-b5rxwkj2.js +5 -0
- package/dist/chunks/studio-d007j0z3.js +7 -0
- package/dist/chunks/studio-dknr8m7b.js +14 -0
- package/dist/chunks/{studio-eq27rrr0.js → studio-dmymvk2j.js} +3 -3
- package/dist/chunks/studio-dtmdjmqb.js +5 -0
- package/dist/chunks/studio-eb5sty2k.js +5 -0
- package/dist/chunks/{studio-hc9q4j24.js → studio-ebt49j3c.js} +5 -5
- package/dist/chunks/studio-eegyf5vg.js +5 -0
- package/dist/chunks/studio-frv7vjhw.js +5 -0
- package/dist/chunks/studio-ft8pfpp0.js +5 -0
- package/dist/chunks/{studio-mhhc9btt.js → studio-gj4j9e7k.js} +4 -4
- package/dist/chunks/{studio-hf4qdjc8.js → studio-gxna6yta.js} +4 -4
- package/dist/chunks/studio-je15p5v8.js +6 -0
- package/dist/chunks/{studio-21enxdr8.js → studio-jsr14n1s.js} +4 -4
- package/dist/chunks/studio-k9pwvmz8.js +5 -0
- package/dist/chunks/{studio-p1eggxcf.js → studio-ks0ecgjx.js} +3 -3
- package/dist/chunks/studio-mb8jhtr4.js +64 -0
- package/dist/chunks/studio-mf41717j.js +5 -0
- package/dist/chunks/{studio-625ezz59.js → studio-mm8x32gg.js} +4 -4
- package/dist/chunks/{studio-pkkw32j0.js → studio-n2e133d6.js} +4 -4
- package/dist/chunks/{studio-c4pnt1c6.js → studio-njmg0e73.js} +5 -5
- package/dist/chunks/studio-nvykrgbj.js +4 -0
- package/dist/chunks/studio-nw4q9sdn.js +5 -0
- package/dist/chunks/studio-p6avneaq.js +5 -0
- package/dist/chunks/studio-pvj970st.js +5 -0
- package/dist/chunks/studio-r7yeeqc4.js +5 -0
- package/dist/chunks/studio-s85vah66.js +6 -0
- package/dist/chunks/studio-sadckcpp.js +7 -0
- package/dist/chunks/studio-sgzss898.js +9 -0
- package/dist/chunks/{studio-7by9a93k.js → studio-t0v47dpc.js} +4 -4
- package/dist/chunks/{studio-jnb285we.js → studio-tj28pxa1.js} +4 -4
- package/dist/chunks/{studio-6j2jn55a.js → studio-tzs6njnb.js} +8 -8
- package/dist/chunks/studio-vz7b43ng.js +5 -0
- package/dist/chunks/studio-w8w9frjy.js +16 -0
- package/dist/chunks/{studio-xc8ebsts.js → studio-wr0pq67n.js} +4 -4
- package/dist/chunks/{studio-8r4y2x0z.js → studio-y164brht.js} +4 -4
- package/dist/chunks/studio-y17505ge.js +928 -0
- package/dist/chunks/studio-ymsjnvp9.js +5 -0
- package/dist/chunks/studio-z1vdxq0e.js +5 -0
- package/dist/chunks/studio-z47mm077.js +10 -0
- package/dist/chunks/studio-zkkdbbvd.js +9 -0
- package/dist/chunks/{studio-awqghqxv.js → studio-zkvyd3ev.js} +4 -4
- package/dist/chunks/{tsMode-rtwt7w1a.js → tsMode-eq26t9gs.js} +5 -5
- package/dist/chunks/workspace-emt8454d.js +4 -0
- package/dist/chunks/ws-client-3gxqp06s.js +4 -0
- package/dist/iframe-entry.js +14 -14
- package/dist/manifest.json +62 -59
- package/dist/studio.css +1 -1
- package/dist/studio.js +115 -117
- package/package.json +5 -5
- package/src/account/action-flow.ts +294 -0
- package/src/account/action-required.ts +68 -0
- package/src/account-status.ts +29 -1
- package/src/browse/library-model.ts +3 -1
- package/src/browse/library-preview.ts +6 -2
- package/src/canvas/asset-refs.ts +3 -1
- package/src/canvas/asset-resolve.ts +79 -1
- package/src/canvas/iframe-entry.ts +38 -1
- package/src/canvas/iframe-host.ts +85 -0
- package/src/canvas/iframe-protocol.ts +13 -0
- package/src/files/asset-versions.ts +294 -0
- package/src/files/files.ts +61 -9
- package/src/files/fs-events.ts +279 -28
- package/src/files/media-meta.ts +2 -1
- package/src/files/media-paths.ts +5 -2
- package/src/files/media-upload.ts +5 -1
- package/src/new-project/add-repo-modal.ts +12 -1
- package/src/new-project/new-project-modal.ts +12 -1
- package/src/panels/git-panel.ts +25 -2
- package/src/platform-errors.ts +61 -1
- package/src/platforms/cloud.ts +389 -50
- package/src/platforms/devserver.ts +17 -1
- package/src/publish/pages-service.ts +17 -7
- package/src/services/ai-models.ts +30 -6
- package/src/services/ai-system-prompt.ts +81 -51
- package/src/settings/preferences-accounts.ts +42 -3
- package/src/settings/preferences-dialog.ts +8 -1
- package/src/studio.ts +18 -1
- package/src/surfaces/dialog.ts +5 -0
- package/src/surfaces/welcome.json +86 -1
- package/src/surfaces/welcome.ts +61 -2
- package/src/types.ts +84 -2
- package/src/ui/ai-managed-connect.ts +41 -4
- package/src/ui/media-picker.ts +2 -1
- package/dist/chunks/adapter-bsbs78x9.js +0 -4
- package/dist/chunks/cf-account-picker-gnm7xeqb.js +0 -4
- package/dist/chunks/cf-settings-9y6jyscp.js +0 -4
- package/dist/chunks/components-s51h3b7c.js +0 -4
- package/dist/chunks/content-source-ty3be0qg.js +0 -4
- package/dist/chunks/convert-to-repeater-c3e7q61z.js +0 -4
- package/dist/chunks/editor-t002b41m.js +0 -4
- package/dist/chunks/entry-commands-sb72e9gy.js +0 -4
- package/dist/chunks/extension-sections-ktn2ntnj.js +0 -4
- package/dist/chunks/file-ops-78zeytkm.js +0 -4
- package/dist/chunks/files-gatzrz8v.js +0 -4
- package/dist/chunks/format-host-r59b1651.js +0 -4
- package/dist/chunks/fs-events-f8h91ndt.js +0 -4
- package/dist/chunks/git-panel-y12zf7nj.js +0 -4
- package/dist/chunks/grid-view-rfd8b2pg.js +0 -51
- package/dist/chunks/grid-view-tafenfm2.css +0 -1
- package/dist/chunks/iframe-host-vsrsj20g.js +0 -4
- package/dist/chunks/index-5aghyrfh.js +0 -4
- package/dist/chunks/jx-validate-653krkrf.js +0 -4
- package/dist/chunks/layers-32p8gtae.js +0 -4
- package/dist/chunks/layers-panel-rs58m105.js +0 -4
- package/dist/chunks/lit-html-54bmkwz3.js +0 -4
- package/dist/chunks/media-picker-5bb04wct.js +0 -4
- package/dist/chunks/menu-cbha7w2e.js +0 -4
- package/dist/chunks/monaco-buffer-9vbbyfr1.js +0 -4
- package/dist/chunks/notify-dc1nz5ar.js +0 -4
- package/dist/chunks/platform-2m49s1zf.js +0 -4
- package/dist/chunks/publish-panel-nx3ycmma.js +0 -4
- package/dist/chunks/quick-search-yzrzh9x3.js +0 -4
- package/dist/chunks/references-vx7mbj9f.js +0 -4
- package/dist/chunks/right-panel-7jzpbxja.js +0 -4
- package/dist/chunks/section-registry-gjryse41.js +0 -4
- package/dist/chunks/studio-3znfhcxg.js +0 -5
- package/dist/chunks/studio-58qam27v.js +0 -4
- package/dist/chunks/studio-6j59fwaw.js +0 -5
- package/dist/chunks/studio-7z69g1gx.js +0 -5
- package/dist/chunks/studio-93j3v6zx.js +0 -5
- package/dist/chunks/studio-9gf6v8zy.js +0 -5
- package/dist/chunks/studio-9rhmettf.js +0 -4
- package/dist/chunks/studio-ac5aq677.js +0 -10
- package/dist/chunks/studio-as65vbns.js +0 -5
- package/dist/chunks/studio-bkv0sga4.js +0 -6
- package/dist/chunks/studio-bttmqef4.js +0 -5
- package/dist/chunks/studio-c3y9d41k.js +0 -5
- package/dist/chunks/studio-ccegp7gz.js +0 -9
- package/dist/chunks/studio-chxabn98.js +0 -6
- package/dist/chunks/studio-cktcdsxz.js +0 -16
- package/dist/chunks/studio-cnspfke5.js +0 -5
- package/dist/chunks/studio-de3p1ktm.js +0 -5
- package/dist/chunks/studio-fsbcn530.js +0 -928
- package/dist/chunks/studio-gftga7a3.js +0 -64
- package/dist/chunks/studio-hf1rpcq0.js +0 -4
- package/dist/chunks/studio-hz02hq70.js +0 -5
- package/dist/chunks/studio-jsv3s3s1.js +0 -5
- package/dist/chunks/studio-ks7cksh0.js +0 -5
- package/dist/chunks/studio-nptx0dbs.js +0 -5
- package/dist/chunks/studio-pg0z7k98.js +0 -5
- package/dist/chunks/studio-pve0b2f2.js +0 -5
- package/dist/chunks/studio-q7zmjv3b.js +0 -6
- package/dist/chunks/studio-rpmvw7kj.js +0 -5
- package/dist/chunks/studio-rwhss52s.js +0 -5
- package/dist/chunks/studio-sbg3g3h0.js +0 -5
- package/dist/chunks/studio-sq1bn60g.js +0 -14
- package/dist/chunks/studio-vwz7rma2.js +0 -5
- package/dist/chunks/studio-wa55e8gf.js +0 -5
- package/dist/chunks/studio-x0mzdha2.js +0 -5
- package/dist/chunks/studio-xrgpakvc.js +0 -9
- package/dist/chunks/studio-xt3djk1w.js +0 -7
- package/dist/chunks/studio-yh3nggx1.js +0 -7
- package/dist/chunks/studio-zmecp6hq.js +0 -5
- package/dist/chunks/workspace-g6dvaete.js +0 -4
- package/dist/chunks/ws-client-shf41j5p.js +0 -4
package/src/platforms/cloud.ts
CHANGED
|
@@ -13,12 +13,17 @@
|
|
|
13
13
|
|
|
14
14
|
import { negotiateCollab } from "@jxsuite/collab/negotiate";
|
|
15
15
|
import type { CollabNegotiation } from "@jxsuite/collab/negotiate";
|
|
16
|
+
// Static, and free to be: `reconnect.ts` imports nothing, so no Yjs reaches the initial bundle.
|
|
17
|
+
import { createReconnectScheduler } from "@jxsuite/collab/reconnect";
|
|
16
18
|
import type { WsCollabConnection } from "@jxsuite/collab/client";
|
|
17
19
|
import type { ProjectConfig } from "@jxsuite/schema/types";
|
|
18
20
|
import { componentMetaFrom } from "@jxsuite/schema/component-meta";
|
|
19
21
|
import { streamImport } from "../services/import-client";
|
|
20
22
|
import type {
|
|
23
|
+
AccountEntry,
|
|
24
|
+
AccountNotice,
|
|
21
25
|
AccountStatus,
|
|
26
|
+
ActionOutcome,
|
|
22
27
|
CfAccountSummary,
|
|
23
28
|
CfConnection,
|
|
24
29
|
CfConnectOutcome,
|
|
@@ -28,12 +33,14 @@ import type {
|
|
|
28
33
|
ExtensionCatalogEntry,
|
|
29
34
|
ExtensionsInfo,
|
|
30
35
|
FsEvent,
|
|
36
|
+
FsResyncReason,
|
|
31
37
|
GitBranchesResult,
|
|
32
38
|
GitLogEntry,
|
|
33
39
|
GitStatusResult,
|
|
34
40
|
ImportProgressEvent,
|
|
35
41
|
ImportReadyEvent,
|
|
36
42
|
ImportSiteOptions,
|
|
43
|
+
OfferedAction,
|
|
37
44
|
PackageInfo,
|
|
38
45
|
ProjectListEntry,
|
|
39
46
|
SiteBuildResult,
|
|
@@ -46,6 +53,8 @@ import type {
|
|
|
46
53
|
} from "../types";
|
|
47
54
|
import { problemDetail, problemSlug } from "@jxsuite/protocol";
|
|
48
55
|
import type { ReadFilesRequest, ReadFilesResult, UploadResult } from "@jxsuite/protocol";
|
|
56
|
+
import { reportActionRequired } from "../account/action-required";
|
|
57
|
+
import { offeredActions, retryHint } from "../platform-errors";
|
|
49
58
|
import { createReadBatcher } from "./read-batcher";
|
|
50
59
|
import type { BatchAnswer } from "./read-batcher";
|
|
51
60
|
|
|
@@ -115,13 +124,95 @@ async function isReadOnlyRefusal(res: Response): Promise<boolean> {
|
|
|
115
124
|
return code === "read_only" || code === "read-only";
|
|
116
125
|
}
|
|
117
126
|
|
|
127
|
+
/** A failure body's machine-readable members, each one optional and none of them trusted. */
|
|
128
|
+
interface StructuredBody extends ErrorBody {
|
|
129
|
+
type?: unknown;
|
|
130
|
+
installUrl?: unknown;
|
|
131
|
+
actions?: unknown;
|
|
132
|
+
heading?: unknown;
|
|
133
|
+
retry?: unknown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The refusal's heading, when the body carries a usable one. */
|
|
137
|
+
function headingOf(body: StructuredBody | null): string | undefined {
|
|
138
|
+
return typeof body?.heading === "string" && body.heading ? body.heading : undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The error a failed response becomes: the backend's sentence as the message, and every member a
|
|
143
|
+
* surface can act on carried alongside it for `platform-errors.ts` to recover — the install link a
|
|
144
|
+
* missing App installation sends, and the actions, heading and retry hint an `action-required`
|
|
145
|
+
* refusal sends (desktop.md §10.4).
|
|
146
|
+
*
|
|
147
|
+
* This used to be the message alone, everywhere but `createProject`, which had grown its own copy
|
|
148
|
+
* of this function to keep the install link. So a refusal reaching any other member arrived as flat
|
|
149
|
+
* text: the members that say "this is fixable, and here is how" were read and dropped.
|
|
150
|
+
*/
|
|
151
|
+
async function platformError(res: Response, fallback: string): Promise<Error> {
|
|
152
|
+
let body: StructuredBody | null;
|
|
153
|
+
try {
|
|
154
|
+
body = (await res.json()) as StructuredBody | null;
|
|
155
|
+
} catch {
|
|
156
|
+
body = null;
|
|
157
|
+
}
|
|
158
|
+
const code = problemSlug(body?.type) ?? body?.code;
|
|
159
|
+
const actions = offeredActions(body?.actions);
|
|
160
|
+
const heading = headingOf(body);
|
|
161
|
+
const retry = retryHint(body?.retry);
|
|
162
|
+
return Object.assign(new Error(problemDetail(body) ?? fallback), {
|
|
163
|
+
status: res.status,
|
|
164
|
+
...(code ? { code } : {}),
|
|
165
|
+
...(typeof body?.installUrl === "string" ? { installUrl: body.installUrl } : {}),
|
|
166
|
+
...(actions.length > 0 ? { actions } : {}),
|
|
167
|
+
...(heading === undefined ? {} : { heading }),
|
|
168
|
+
...(retry === undefined ? {} : { retry }),
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
118
172
|
async function okJson<T>(res: Response, fallback: string): Promise<T> {
|
|
119
173
|
if (!res.ok) {
|
|
120
|
-
throw
|
|
174
|
+
throw await platformError(res, fallback);
|
|
121
175
|
}
|
|
122
176
|
return (await res.json()) as T;
|
|
123
177
|
}
|
|
124
178
|
|
|
179
|
+
/** Whether a failure body is an `action-required` refusal, by problem type or legacy code. */
|
|
180
|
+
function isActionRequiredBody(body: StructuredBody | null): boolean {
|
|
181
|
+
const code = problemSlug(body?.type) ?? body?.code;
|
|
182
|
+
return code === "action-required" || code === "action_required";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Raise an `action-required` refusal on the channel, read from a CLONE so the caller still gets the
|
|
187
|
+
* body it is about to parse for its own error. Only a refusal that offers something is raised: with
|
|
188
|
+
* no actions there is nothing to offer, and the caller's own error says the rest. Every other 403 —
|
|
189
|
+
* a read-only collaborator, a missing installation — is the caller's alone.
|
|
190
|
+
*/
|
|
191
|
+
async function reportRefusal(res: Response): Promise<void> {
|
|
192
|
+
let body: StructuredBody | null;
|
|
193
|
+
try {
|
|
194
|
+
body = (await res.json()) as StructuredBody | null;
|
|
195
|
+
} catch {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (!isActionRequiredBody(body)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const actions = offeredActions(body?.actions);
|
|
202
|
+
if (actions.length === 0) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const detail = problemDetail(body);
|
|
206
|
+
const heading = headingOf(body);
|
|
207
|
+
const retry = retryHint(body?.retry);
|
|
208
|
+
reportActionRequired({
|
|
209
|
+
actions,
|
|
210
|
+
...(detail === null ? {} : { detail }),
|
|
211
|
+
...(heading === undefined ? {} : { heading }),
|
|
212
|
+
...(retry === undefined ? {} : { retry }),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
125
216
|
/** Editor URL for a project session (mirrors the shell's route). */
|
|
126
217
|
export function editUrl(project: CloudProject): string {
|
|
127
218
|
return `/edit/${project.owner}/${project.repo}@${encodeURIComponent(project.branch)}`;
|
|
@@ -222,17 +313,20 @@ let cfConnectFlow: CfConnectFlow | null = null;
|
|
|
222
313
|
* Drive one hosted OAuth connect to a {@link CfConnectOutcome}. See `cfConnect` for the semantics;
|
|
223
314
|
* this lives at module scope so the single-flight handle can too.
|
|
224
315
|
*/
|
|
225
|
-
async function runCfConnect(
|
|
316
|
+
async function runCfConnect(
|
|
317
|
+
handle: CfConnectFlow,
|
|
318
|
+
connectUrl: string,
|
|
319
|
+
): Promise<CfConnectOutcome | null> {
|
|
226
320
|
/* The baseline is read BEFORE the popup opens, and it is what keeps the poll honest: a row that
|
|
227
321
|
was already healthy proves nothing about THIS flow, so the poll must not settle on one. Reading
|
|
228
322
|
it after the popup opened would race the callback and could capture the new row as "old". */
|
|
229
323
|
const baseline = await fetchCfConnection().catch(() => null);
|
|
230
324
|
const healthyBaseline = Boolean(baseline?.connected && !baseline.needsReconnect);
|
|
231
|
-
const popup = window.open(
|
|
325
|
+
const popup = window.open(connectUrl, "cf-connect", "width=980,height=780");
|
|
232
326
|
if (!popup) {
|
|
233
327
|
/* Popup blocked: the whole page is now navigating to the broker. Not a failure — the caller
|
|
234
328
|
must render nothing at all rather than an error it will never get to show. */
|
|
235
|
-
location.assign(
|
|
329
|
+
location.assign(connectUrl);
|
|
236
330
|
return { status: "redirect" };
|
|
237
331
|
}
|
|
238
332
|
handle.popup = popup;
|
|
@@ -319,6 +413,152 @@ async function runCfConnect(handle: CfConnectFlow): Promise<CfConnectOutcome | n
|
|
|
319
413
|
});
|
|
320
414
|
}
|
|
321
415
|
|
|
416
|
+
// ─── Backend-directed access (desktop.md §10.4) ──────────────────────────────
|
|
417
|
+
|
|
418
|
+
/** `GET /api/v1/me`, as far as this adapter reads it. */
|
|
419
|
+
interface MeWire {
|
|
420
|
+
installations?: { id: number; account: string | null; manageUrl?: string }[];
|
|
421
|
+
appInstallUrl?: string;
|
|
422
|
+
entries?: unknown;
|
|
423
|
+
notices?: unknown;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** A wire value as a non-empty string, or undefined. */
|
|
427
|
+
function wireText(value: unknown): string | undefined {
|
|
428
|
+
return typeof value === "string" && value ? value : undefined;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The account rows a `/me` body holds, each one checked. A row needs an id, a label and a detail to
|
|
433
|
+
* be drawn at all; a malformed one is dropped rather than drawn half-empty.
|
|
434
|
+
*/
|
|
435
|
+
function accountEntries(value: unknown): AccountEntry[] {
|
|
436
|
+
if (!Array.isArray(value)) {
|
|
437
|
+
return [];
|
|
438
|
+
}
|
|
439
|
+
return value.flatMap((raw: Record<string, unknown> | null): AccountEntry[] => {
|
|
440
|
+
const id = wireText(raw?.["id"]);
|
|
441
|
+
const label = wireText(raw?.["label"]);
|
|
442
|
+
const detail = wireText(raw?.["detail"]);
|
|
443
|
+
if (!id || !label || !detail) {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
const actions = offeredActions(raw?.["actions"]);
|
|
447
|
+
return [
|
|
448
|
+
{
|
|
449
|
+
detail,
|
|
450
|
+
id,
|
|
451
|
+
label,
|
|
452
|
+
...(typeof raw?.["connected"] === "boolean" ? { connected: raw["connected"] } : {}),
|
|
453
|
+
...(actions.length > 0 ? { actions } : {}),
|
|
454
|
+
},
|
|
455
|
+
];
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** The notices a `/me` body holds, each one checked, on the same terms as the rows. */
|
|
460
|
+
function accountNotices(value: unknown): AccountNotice[] {
|
|
461
|
+
if (!Array.isArray(value)) {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
return value.flatMap((raw: Record<string, unknown> | null): AccountNotice[] => {
|
|
465
|
+
const id = wireText(raw?.["id"]);
|
|
466
|
+
const title = wireText(raw?.["title"]);
|
|
467
|
+
const message = wireText(raw?.["message"]);
|
|
468
|
+
if (!id || !title || !message) {
|
|
469
|
+
return [];
|
|
470
|
+
}
|
|
471
|
+
const actions = offeredActions(raw?.["actions"]);
|
|
472
|
+
const level = raw?.["level"];
|
|
473
|
+
const display = raw?.["display"];
|
|
474
|
+
return [
|
|
475
|
+
{
|
|
476
|
+
id,
|
|
477
|
+
message,
|
|
478
|
+
title,
|
|
479
|
+
...(level === "info" || level === "warning" ? { level } : {}),
|
|
480
|
+
...(display === "banner" || display === "toast" ? { display } : {}),
|
|
481
|
+
...(actions.length > 0 ? { actions } : {}),
|
|
482
|
+
},
|
|
483
|
+
];
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
interface ActionFlow {
|
|
488
|
+
popup: Window | null;
|
|
489
|
+
promise: Promise<ActionOutcome | null>;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* The action window currently open, or null — module-level for `cfConnectFlow`'s reason: the
|
|
494
|
+
* window's target name is global to the browsing context, and two flows would share one window.
|
|
495
|
+
*/
|
|
496
|
+
let actionFlow: ActionFlow | null = null;
|
|
497
|
+
|
|
498
|
+
/** How long an open action window is waited on before the flow gives up on hearing back. */
|
|
499
|
+
const ACTION_DEADLINE_MS = 15 * 60_000;
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* How long a closed window is given for its relay to land. A page that relays and then closes
|
|
503
|
+
* itself can be seen closed a moment before its message is delivered, and reading that as "closed
|
|
504
|
+
* without saying" would put an offer back in front of someone who just completed it.
|
|
505
|
+
*/
|
|
506
|
+
const CLOSE_GRACE_MS = 300;
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Wait on an action window the caller has ALREADY opened (opening it is the caller's, because only
|
|
510
|
+
* code still inside the click may). Settles on the window's own relay — `{source: "jx-action",
|
|
511
|
+
* status}`, same-origin, which is the platform's to send from wherever the action ends — or
|
|
512
|
+
* `unknown` when the window closes, or the deadline passes, without one.
|
|
513
|
+
*/
|
|
514
|
+
function awaitAction(popup: Window): Promise<ActionOutcome> {
|
|
515
|
+
const deadline = Date.now() + ACTION_DEADLINE_MS;
|
|
516
|
+
return new Promise<ActionOutcome>((resolve, reject) => {
|
|
517
|
+
let timer = 0;
|
|
518
|
+
const cleanup = () => {
|
|
519
|
+
window.removeEventListener("message", onMessage);
|
|
520
|
+
window.clearTimeout(timer);
|
|
521
|
+
if (!popup.closed) {
|
|
522
|
+
popup.close();
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const settle = (outcome: ActionOutcome) => {
|
|
526
|
+
cleanup();
|
|
527
|
+
resolve(outcome);
|
|
528
|
+
};
|
|
529
|
+
const onMessage = (event: MessageEvent) => {
|
|
530
|
+
if (event.origin !== location.origin) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const data = event.data as { source?: string; status?: string; reason?: string | null };
|
|
534
|
+
if (!data || data.source !== "jx-action") {
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (data.status === "done" || data.status === "canceled") {
|
|
538
|
+
settle({ status: data.status });
|
|
539
|
+
} else if (data.status === "error") {
|
|
540
|
+
cleanup();
|
|
541
|
+
reject(new Error(data.reason ?? "The action could not be completed"));
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
window.addEventListener("message", onMessage);
|
|
545
|
+
const watch = () => {
|
|
546
|
+
if (Date.now() > deadline) {
|
|
547
|
+
settle({ status: "unknown" });
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (popup.closed) {
|
|
551
|
+
timer = window.setTimeout(() => {
|
|
552
|
+
settle({ status: "unknown" });
|
|
553
|
+
}, CLOSE_GRACE_MS);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
timer = window.setTimeout(watch, 1000);
|
|
557
|
+
};
|
|
558
|
+
timer = window.setTimeout(watch, 1000);
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
322
562
|
/** Parse an "owner/repo@branch" root key; null when malformed. */
|
|
323
563
|
export function parseRootKey(root: string): CloudProject | null {
|
|
324
564
|
const match = /^([^/@]+)\/([^/@]+)@(.+)$/.exec(root);
|
|
@@ -387,13 +627,35 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
387
627
|
/** Lazy subprotocol negotiation from the gateway's capability probe (null = not asked yet). */
|
|
388
628
|
let collabNegotiation: Promise<CollabNegotiation> | null = null;
|
|
389
629
|
|
|
390
|
-
|
|
630
|
+
/**
|
|
631
|
+
* Every session request, and the one place an `action-required` refusal is noticed.
|
|
632
|
+
*
|
|
633
|
+
* A refusal is reported on the channel and then returned like any other failure, so the caller
|
|
634
|
+
* still throws its own error with its own wording. The channel is what tells the user what they
|
|
635
|
+
* can do; the caller's error is what tells it the request failed. Neither has to know about the
|
|
636
|
+
* other, which is what lets a directory listing deep in the file tree stay ignorant of access
|
|
637
|
+
* rules.
|
|
638
|
+
*/
|
|
639
|
+
async function api(path: string, init?: RequestInit): Promise<Response> {
|
|
391
640
|
if (!project) {
|
|
392
|
-
|
|
641
|
+
throw new Error("No project is open in this session");
|
|
393
642
|
}
|
|
394
|
-
|
|
643
|
+
const res = await fetch(`${base}${path}`, { credentials: "include", ...init });
|
|
644
|
+
if (res.status === 403) {
|
|
645
|
+
await reportRefusal(res.clone());
|
|
646
|
+
}
|
|
647
|
+
return res;
|
|
395
648
|
}
|
|
396
649
|
|
|
650
|
+
/**
|
|
651
|
+
* The bound project as the hint the platform's unscoped routes read (`owner/repo`), or null in
|
|
652
|
+
* the hub. The AI and Cloudflare routes are not under the session base, so this is the only way
|
|
653
|
+
* they learn which project a call is for — which is what a backend whose access rules depend on
|
|
654
|
+
* the repository needs to know. A hint, not a credential: the platform verifies it against the
|
|
655
|
+
* user's own access before it counts.
|
|
656
|
+
*/
|
|
657
|
+
const projectHint = project ? `${project.owner}/${project.repo}` : null;
|
|
658
|
+
|
|
397
659
|
function postJson(path: string, body: unknown): Promise<Response> {
|
|
398
660
|
return api(path, {
|
|
399
661
|
method: "POST",
|
|
@@ -520,11 +782,17 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
520
782
|
(owner, name, visibility) rather than a folder. */
|
|
521
783
|
createDestination: "repo",
|
|
522
784
|
|
|
785
|
+
/* A refusal throws rather than resolving as though the session were live. This used to ignore
|
|
786
|
+
the response entirely, so a project the backend would not open "activated" fine and the first
|
|
787
|
+
visible failure was a generic "Failed to load project" two calls later. */
|
|
523
788
|
async activate() {
|
|
524
789
|
if (!project) {
|
|
525
790
|
return;
|
|
526
791
|
}
|
|
527
|
-
await postJson("/activate", {});
|
|
792
|
+
const res = await postJson("/activate", {});
|
|
793
|
+
if (!res.ok) {
|
|
794
|
+
throw await platformError(res, "Failed to open the project session");
|
|
795
|
+
}
|
|
528
796
|
},
|
|
529
797
|
|
|
530
798
|
/* The shell binds the session to one repo+branch before Studio boots, so
|
|
@@ -692,11 +960,25 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
692
960
|
},
|
|
693
961
|
|
|
694
962
|
/**
|
|
695
|
-
* Live session events over the gateway WebSocket.
|
|
696
|
-
*
|
|
697
|
-
*
|
|
963
|
+
* Live session events over the gateway WebSocket. The DO pushes {kind:"fs"} batches for file
|
|
964
|
+
* mutations (including those from other tabs) and {kind:"git"} notices.
|
|
965
|
+
*
|
|
966
|
+
* The socket reconnects on jittered backoff, cut short by the browser's wake signals
|
|
967
|
+
* (`@jxsuite/collab/reconnect`): every Studio on a gateway loses it at the same deploy, and a
|
|
968
|
+
* laptop that wakes onto a network should not sit out a 30-second timer first.
|
|
969
|
+
*
|
|
970
|
+
* Two things the event stream cannot say on its own are reported through `onResync`. A socket
|
|
971
|
+
* that came back missed every batch sent while it was gone, and the DO does not replay them —
|
|
972
|
+
* so every open after the first is a `"reconnect"`. The first open is an `"open"`: the
|
|
973
|
+
* session's listings carry content versions, the boot's first listings race the handshake, and
|
|
974
|
+
* a change broadcast before this socket joined (a first connect that fails during a gateway
|
|
975
|
+
* deploy can stretch that to a whole backoff) reached nobody. And a commit lands with a
|
|
976
|
+
* `committed` notice and no per-file events, so it is a `"commit"`.
|
|
698
977
|
*/
|
|
699
|
-
subscribeFileEvents(
|
|
978
|
+
subscribeFileEvents(
|
|
979
|
+
handler: (events: FsEvent[]) => void,
|
|
980
|
+
options?: { onResync?: (reason: FsResyncReason) => void },
|
|
981
|
+
) {
|
|
700
982
|
// No project means an empty base, so the bare `/events` URL hits no gateway route.
|
|
701
983
|
// That fails in a reconnect loop; mirror `collab`'s guard and degrade to no-op on the hub.
|
|
702
984
|
if (!project || typeof WebSocket === "undefined" || typeof location === "undefined") {
|
|
@@ -704,11 +986,12 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
704
986
|
}
|
|
705
987
|
let socket: WebSocket | null = null;
|
|
706
988
|
let closed = false;
|
|
707
|
-
let
|
|
989
|
+
let everOpened = false;
|
|
708
990
|
const connect = () => {
|
|
709
991
|
const scheme = location.protocol === "https:" ? "wss" : "ws";
|
|
710
|
-
|
|
711
|
-
socket
|
|
992
|
+
const ws = new WebSocket(`${scheme}://${location.host}${base}/events`);
|
|
993
|
+
socket = ws;
|
|
994
|
+
ws.addEventListener("message", (ev: MessageEvent) => {
|
|
712
995
|
let payload: SessionEventWire;
|
|
713
996
|
try {
|
|
714
997
|
payload = JSON.parse(ev.data as string) as SessionEventWire;
|
|
@@ -717,21 +1000,26 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
717
1000
|
}
|
|
718
1001
|
if (payload.kind === "fs" && payload.events?.length) {
|
|
719
1002
|
handler(payload.events);
|
|
1003
|
+
} else if (payload.kind === "git" && payload.event === "committed") {
|
|
1004
|
+
options?.onResync?.("commit");
|
|
720
1005
|
}
|
|
721
1006
|
});
|
|
722
|
-
|
|
723
|
-
|
|
1007
|
+
ws.addEventListener("open", () => {
|
|
1008
|
+
scheduler.opened();
|
|
1009
|
+
options?.onResync?.(everOpened ? "reconnect" : "open");
|
|
1010
|
+
everOpened = true;
|
|
724
1011
|
});
|
|
725
|
-
|
|
1012
|
+
ws.addEventListener("close", () => {
|
|
726
1013
|
if (!closed) {
|
|
727
|
-
|
|
728
|
-
retryMs = Math.min(retryMs * 2, 30_000);
|
|
1014
|
+
scheduler.scheduleRetry();
|
|
729
1015
|
}
|
|
730
1016
|
});
|
|
731
1017
|
};
|
|
1018
|
+
const scheduler = createReconnectScheduler(connect);
|
|
732
1019
|
connect();
|
|
733
1020
|
return () => {
|
|
734
1021
|
closed = true;
|
|
1022
|
+
scheduler.dispose();
|
|
735
1023
|
socket?.close();
|
|
736
1024
|
};
|
|
737
1025
|
},
|
|
@@ -1190,22 +1478,9 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1190
1478
|
}),
|
|
1191
1479
|
});
|
|
1192
1480
|
if (!res.ok) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
| (ErrorBody & { installUrl?: string; type?: string })
|
|
1197
|
-
| null;
|
|
1198
|
-
/*
|
|
1199
|
-
* `code` survives as the machine-readable discriminator the modal branches on, and a
|
|
1200
|
-
* problem document supplies it from its `type` — `problemSlug` derives the same string the
|
|
1201
|
-
* old `code` field carried, so the modal's branch is unchanged either way.
|
|
1202
|
-
*/
|
|
1203
|
-
throw Object.assign(new Error(problemDetail(body) ?? "Failed to create project"), {
|
|
1204
|
-
...((problemSlug(body?.type) ?? body?.code)
|
|
1205
|
-
? { code: problemSlug(body?.type) ?? body?.code }
|
|
1206
|
-
: {}),
|
|
1207
|
-
...(body?.installUrl ? { installUrl: body.installUrl } : {}),
|
|
1208
|
-
});
|
|
1481
|
+
/* Structured, so the New Project modal can act on what failed: an install link for a
|
|
1482
|
+
missing App installation, the offered actions of an `action-required` refusal. */
|
|
1483
|
+
throw await platformError(res, "Failed to create project");
|
|
1209
1484
|
}
|
|
1210
1485
|
const created = (await res.json()) as { owner: string; name: string; defaultBranch: string };
|
|
1211
1486
|
return {
|
|
@@ -1305,18 +1580,19 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1305
1580
|
},
|
|
1306
1581
|
|
|
1307
1582
|
/**
|
|
1308
|
-
*
|
|
1309
|
-
* picker's "grant access to more repositories" links (`manageUrl`
|
|
1583
|
+
* The account status from /me: GitHub-App installation coverage — which powers the welcome
|
|
1584
|
+
* install prompt and the repo picker's "grant access to more repositories" links (`manageUrl`
|
|
1585
|
+
* per installation) — and whatever account rows and notices the platform sends (desktop.md
|
|
1586
|
+
* §10.4), each checked before it is drawn.
|
|
1310
1587
|
*/
|
|
1311
1588
|
async getAccountStatus() {
|
|
1312
1589
|
const res = await fetch("/api/v1/me", { credentials: "include" });
|
|
1313
1590
|
if (!res.ok) {
|
|
1314
1591
|
return null;
|
|
1315
1592
|
}
|
|
1316
|
-
const me = (await res.json()) as
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
};
|
|
1593
|
+
const me = (await res.json()) as MeWire;
|
|
1594
|
+
const entries = accountEntries(me.entries);
|
|
1595
|
+
const notices = accountNotices(me.notices);
|
|
1320
1596
|
return {
|
|
1321
1597
|
installations: (me.installations ?? []).map((entry) => {
|
|
1322
1598
|
const installation: AccountStatus["installations"][number] = {
|
|
@@ -1329,9 +1605,49 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1329
1605
|
return installation;
|
|
1330
1606
|
}),
|
|
1331
1607
|
...(me.appInstallUrl ? { appInstallUrl: me.appInstallUrl } : {}),
|
|
1608
|
+
...(entries.length > 0 ? { entries } : {}),
|
|
1609
|
+
...(notices.length > 0 ? { notices } : {}),
|
|
1332
1610
|
};
|
|
1333
1611
|
},
|
|
1334
1612
|
|
|
1613
|
+
/**
|
|
1614
|
+
* An offered action, in a window of its own. Synchronous up to `window.open`, which is the
|
|
1615
|
+
* whole contract (types.ts): this runs inside a click, and a window opened after an `await` is
|
|
1616
|
+
* one the browser blocks. So the target is the action's own `href`, never something that has to
|
|
1617
|
+
* be fetched first; an action with no `href` is not one this platform performs.
|
|
1618
|
+
*
|
|
1619
|
+
* A blocked window navigates the page itself, and whatever the platform has to say about how
|
|
1620
|
+
* that ended arrives as a `toast` notice once the page is back. Concurrent calls join the
|
|
1621
|
+
* running flow, for `cfConnect`'s reason.
|
|
1622
|
+
*/
|
|
1623
|
+
performAction(action: OfferedAction) {
|
|
1624
|
+
if (typeof window === "undefined" || typeof location === "undefined" || !action.href) {
|
|
1625
|
+
return Promise.resolve(null);
|
|
1626
|
+
}
|
|
1627
|
+
const running = actionFlow;
|
|
1628
|
+
if (running) {
|
|
1629
|
+
try {
|
|
1630
|
+
running.popup?.focus();
|
|
1631
|
+
} catch {
|
|
1632
|
+
// A cross-origin window may refuse focus; joining the flow is what mattered.
|
|
1633
|
+
}
|
|
1634
|
+
return running.promise;
|
|
1635
|
+
}
|
|
1636
|
+
const popup = window.open(action.href, "jx-action", "width=980,height=820");
|
|
1637
|
+
if (!popup) {
|
|
1638
|
+
location.assign(action.href);
|
|
1639
|
+
return Promise.resolve({ status: "redirect" } as const);
|
|
1640
|
+
}
|
|
1641
|
+
const handle: ActionFlow = { popup, promise: Promise.resolve(null) };
|
|
1642
|
+
actionFlow = handle;
|
|
1643
|
+
handle.promise = awaitAction(popup).finally(() => {
|
|
1644
|
+
if (actionFlow === handle) {
|
|
1645
|
+
actionFlow = null;
|
|
1646
|
+
}
|
|
1647
|
+
});
|
|
1648
|
+
return handle.promise;
|
|
1649
|
+
},
|
|
1650
|
+
|
|
1335
1651
|
/** Open a PR from this session's branch (ProjectSession /git/pr). */
|
|
1336
1652
|
async createPullRequest(opts: { title: string; body?: string; head?: string; base?: string }) {
|
|
1337
1653
|
return okJson<{ url: string; number: number }>(
|
|
@@ -1378,7 +1694,12 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1378
1694
|
}
|
|
1379
1695
|
const handle: CfConnectFlow = { popup: null, promise: Promise.resolve(null) };
|
|
1380
1696
|
cfConnectFlow = handle;
|
|
1381
|
-
|
|
1697
|
+
/* The project rides along so access rules keyed on the repository can apply to the connect,
|
|
1698
|
+
which is not under the session base and would otherwise not know which project asked. */
|
|
1699
|
+
const connectUrl = projectHint
|
|
1700
|
+
? `/api/v1/cf/connect?project=${encodeURIComponent(projectHint)}`
|
|
1701
|
+
: "/api/v1/cf/connect";
|
|
1702
|
+
handle.promise = runCfConnect(handle, connectUrl).finally(() => {
|
|
1382
1703
|
if (cfConnectFlow === handle) {
|
|
1383
1704
|
cfConnectFlow = null;
|
|
1384
1705
|
}
|
|
@@ -1423,16 +1744,30 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1423
1744
|
|
|
1424
1745
|
/** Allowlisted Cloudflare API passthrough (platform injects the OAuth token). */
|
|
1425
1746
|
async cfApi(apiPath: string, init?: { method?: string; body?: unknown }) {
|
|
1747
|
+
/* The project rides in a HEADER, never the query: the platform forwards this path's query
|
|
1748
|
+
string to Cloudflare verbatim. */
|
|
1749
|
+
const headers: Record<string, string> = {
|
|
1750
|
+
...(projectHint ? { "X-Jx-Project": projectHint } : {}),
|
|
1751
|
+
...(init?.body === undefined ? {} : { "Content-Type": "application/json" }),
|
|
1752
|
+
};
|
|
1426
1753
|
const res = await fetch(`/api/v1/cf/proxy${apiPath}`, {
|
|
1427
1754
|
method: init?.method ?? "GET",
|
|
1428
1755
|
credentials: "include",
|
|
1429
|
-
...(
|
|
1430
|
-
|
|
1431
|
-
: {
|
|
1432
|
-
headers: { "Content-Type": "application/json" },
|
|
1433
|
-
body: JSON.stringify(init.body),
|
|
1434
|
-
}),
|
|
1756
|
+
...(Object.keys(headers).length === 0 ? {} : { headers }),
|
|
1757
|
+
...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
1435
1758
|
});
|
|
1759
|
+
/* An `action-required` refusal is the platform's answer, not Cloudflare's, so it is not
|
|
1760
|
+
dressed as one: it keeps its own sentence and the actions the publish flow offers. Read
|
|
1761
|
+
from a clone, because every other 403 is Cloudflare's own and is parsed below. */
|
|
1762
|
+
if (res.status === 403) {
|
|
1763
|
+
const refusal = (await res
|
|
1764
|
+
.clone()
|
|
1765
|
+
.json()
|
|
1766
|
+
.catch(() => null)) as StructuredBody | null;
|
|
1767
|
+
if (isActionRequiredBody(refusal)) {
|
|
1768
|
+
throw await platformError(res, "Publishing was refused");
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1436
1771
|
const envelope = (await res.json()) as {
|
|
1437
1772
|
success?: boolean;
|
|
1438
1773
|
result?: unknown;
|
|
@@ -1449,8 +1784,12 @@ export function createCloudPlatform(project: CloudProject | null): StudioPlatfor
|
|
|
1449
1784
|
|
|
1450
1785
|
// ─── AI (platform Workers AI proxy, StreamEvent SSE) ───────────────────
|
|
1451
1786
|
|
|
1787
|
+
/* With a project bound, the chat route is told which one — `siblingRoute` keeps the query, so
|
|
1788
|
+
the `/models` probe carries it too. */
|
|
1452
1789
|
aiChatUrl() {
|
|
1453
|
-
return
|
|
1790
|
+
return projectHint
|
|
1791
|
+
? `/api/v1/ai/chat?project=${encodeURIComponent(projectHint)}`
|
|
1792
|
+
: "/api/v1/ai/chat";
|
|
1454
1793
|
},
|
|
1455
1794
|
};
|
|
1456
1795
|
|
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
ExtensionCatalogEntry,
|
|
29
29
|
ExtensionsInfo,
|
|
30
30
|
FsEvent,
|
|
31
|
+
FsResyncReason,
|
|
31
32
|
ImportProgressEvent,
|
|
32
33
|
ImportReadyEvent,
|
|
33
34
|
ImportSiteOptions,
|
|
@@ -495,12 +496,27 @@ export function createDevServerPlatform() {
|
|
|
495
496
|
* Subscribe to filesystem change events over the dev server's SSE stream. Listens for the named
|
|
496
497
|
* "fs" event (the preview iframe's default `onmessage` ignores it), strips paths to
|
|
497
498
|
* project-relative, and drops events for sibling projects outside the active root.
|
|
499
|
+
*
|
|
500
|
+
* `EventSource` reconnects by itself, and the dev server does not replay what it sent while the
|
|
501
|
+
* stream was down — a restarted `bun run dev` is the usual cause. So every `open` after the
|
|
502
|
+
* first reports a `"reconnect"` resync: one re-list of the cached tree is cheap next to an
|
|
503
|
+
* author editing a file the sidebar says is not there.
|
|
498
504
|
*/
|
|
499
|
-
subscribeFileEvents(
|
|
505
|
+
subscribeFileEvents(
|
|
506
|
+
handler: (events: FsEvent[]) => void,
|
|
507
|
+
options?: { onResync?: (reason: FsResyncReason) => void },
|
|
508
|
+
) {
|
|
500
509
|
if (typeof EventSource === "undefined") {
|
|
501
510
|
return () => {};
|
|
502
511
|
}
|
|
503
512
|
const es = new EventSource("/__reload");
|
|
513
|
+
let opens = 0;
|
|
514
|
+
es.addEventListener("open", () => {
|
|
515
|
+
opens += 1;
|
|
516
|
+
if (opens > 1) {
|
|
517
|
+
options?.onResync?.("reconnect");
|
|
518
|
+
}
|
|
519
|
+
});
|
|
504
520
|
es.addEventListener("fs", (ev: MessageEvent) => {
|
|
505
521
|
let payload: { events?: FsEvent[] };
|
|
506
522
|
try {
|