Skip to content

Commit f643a5a

Browse files
committed
Docs: clarify ZIP download flow and add streaming
Update documentation and implementation to reflect that space ZIP downloads and hosted sharing fetch archives from the authenticated backend `folder_download` endpoint instead of creating them inline. Document that app-file fetches percent-decode URL path segments before normalization so browser-encoded filenames (spaces, brackets, unicode, `#`, `?`) resolve correctly while rejecting encoded separators (`%2F`, `%5C`). Replace host `zip` binary with in-process `archiver`
1 parent 654c961 commit f643a5a

14 files changed

Lines changed: 1078 additions & 83 deletions

File tree

app/L0/_all/mod/_core/documentation/docs/app/spaces-and-widgets.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,10 @@ The current-space share button opens a spaces-owned modal that keeps local impor
5757

5858
Current behaviors:
5959

60-
- `Download ZIP` always exports the current `~/spaces/<spaceId>/` folder as a ZIP archive
60+
- `Download ZIP` always exports the current `~/spaces/<spaceId>/` folder through the authenticated backend `folder_download` endpoint, with a `HEAD` preflight for inline errors and the real ZIP download handed off to the browser as an attachment
6161
- `Upload ZIP` always validates through the backend import endpoint; if the current space already has meaningful content, the modal asks whether to overwrite that current space or keep it and import as a new `imported-N` space
6262
- imported destinations ignore the incoming archive id or title for naming; non-overwrite imports are always installed as `imported-1`, `imported-2`, and so on
63-
- when `CLOUD_SHARE_URL` resolves to a base URL, the same native spaces dialog shows the hosted-share panel first, uploads the current space ZIP to that receiver, returns the share link in an inline copy field, and keeps any hosted-share errors inside that panel while logging the underlying exception to the console
63+
- when `CLOUD_SHARE_URL` resolves to a base URL, the same native spaces dialog shows the hosted-share panel first, fetches the current space ZIP from the authenticated backend `folder_download` endpoint, uploads it to that receiver, returns the share link in an inline copy field, and keeps any hosted-share errors inside that panel while logging the underlying exception to the console
6464
- the hosted-share branch can optionally encrypt the ZIP in the browser with a password before upload, using the same public `share-crypto` helper as the public share-open page
6565
- local ZIP export and import stay available even when hosted sharing is disabled or the remote receiver rejects uploads
6666

app/L0/_all/mod/_core/documentation/docs/server/api/files.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ It is the right tool for catalog discovery, not for raw filesystem walking.
130130
- supports `HEAD` for permission-only validation
131131
- supports `GET` and `POST` for the actual ZIP download
132132
- resolves readable folders through the shared permission model
133-
- creates the archive in `server/tmp/`
133+
- creates the archive in `server/tmp/` with the backend Node ZIP helper instead of a host `zip` binary
134134
- streams the attachment instead of buffering the ZIP into browser memory
135135

136136
Frontend code that wants an attachment-style download should prefer:

app/L0/_all/mod/_core/documentation/docs/server/request-flow-and-pages.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ The router supports direct authenticated fetches for app files:
113113

114114
These paths stay logical even when writable storage is relocated through `CUSTOMWARE_PATH`.
115115

116+
Path handling and delivery details:
117+
118+
- direct app-file fetches percent-decode each URL path segment before logical path normalization, so normal browser-encoded filenames such as spaces, brackets, unicode, `#`, and `?` resolve to the actual on-disk file name
119+
- encoded separators such as `%2F` and `%5C` are rejected instead of being allowed to become filesystem path separators during later normalization
120+
- direct app-file fetches, page assets, and module files are streamed from disk after a stat check instead of being fully buffered into server RAM before the response starts
121+
116122
## Cross-Worker Visibility
117123

118124
Clustered writes are ordered through the primary watchdog owner and the shared state version.

app/L0/_all/mod/_core/spaces/AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ Current files and folders:
5757
- `scripts/`: current-space shared JavaScript modules loaded from widget renderers through `ctx.import("scripts/...")`
5858
- the routed header share button should always open the spaces-owned share modal, even when hosted cloud sharing is disabled, because local ZIP export and import are always available
5959
- ZIP import should accept raw space archives exported from a space root, prompt before replacing a non-empty current space, and otherwise install the incoming archive as a new `imported-N` space without trusting the archive title or folder id for the destination name
60-
- hosted cloud sharing is optional UI inside that same modal: when `CLOUD_SHARE_URL` resolves to a non-empty base URL, or falls back to the schema default `share.space-agent.ai`, the modal may upload the current space ZIP there, optionally encrypting the ZIP in the browser with `server/pages/res/share-crypto.js` before upload; the modal should treat hosted sharing as additive and must not block local ZIP export or import when the hosted receiver is unavailable
60+
- hosted cloud sharing is optional UI inside that same modal: when `CLOUD_SHARE_URL` resolves to a non-empty base URL, or falls back to the schema default `share.space-agent.ai`, the modal may fetch the current space ZIP from the authenticated backend `folder_download` endpoint, optionally encrypt it in the browser with `server/pages/res/share-crypto.js`, and upload it to the hosted receiver; hosted sharing is additive and must not block local ZIP export or import when the hosted receiver is unavailable
61+
- local `Download ZIP` from the spaces share modal should use the authenticated backend `folder_download` attachment path, preflight it with `HEAD` for user-visible errors, and let the browser download the streamed ZIP without buffering it into a browser blob first
6162
- `thumbnail.webp` or `thumbnail.jpg`: optional experimental dashboard-card background image captured from the currently open routed space; keep the capture logic browser-owned, target a square roughly `200x200` image, bias the crop toward the visible widget bounds instead of empty canvas area, prefer `thumbnail.webp`, remove stale thumbnail files when a space no longer has visible widget content to capture, and trigger thumbnail refresh from the shared current-space post-save reload path rather than from special onboarding-only callsites
6263
- new spaces are created empty; do not seed starter widgets into fresh manifests or widget folders
6364
- the first-login onboarding space should stay copy-driven and easy to edit: `_core/spaces/onboarding/first-login-onboarding.js` should install `_core/spaces/onboarding/onboarding_space/` only when the user has no spaces yet, and on the main `/` shell it should rewrite the initial route to that copied space before the router defaults to `#/dashboard`

app/L0/_all/mod/_core/spaces/space-share-modal.js

Lines changed: 46 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -188,22 +188,55 @@ async function shouldPromptForOverwrite(spaceId, currentSpace) {
188188
}
189189
}
190190

191-
async function fetchSpaceArchiveBytes(spaceId) {
191+
function buildSpaceDownloadPath(spaceId) {
192+
return buildSpaceRootPath(spaceId);
193+
}
194+
195+
function createSpaceDownloadUrl(spaceId) {
192196
const runtime = getRuntime();
193-
const response = await fetch(runtime.api.folderDownloadUrl(buildSpaceRootPath(spaceId)), {
194-
method: "GET",
197+
return runtime.api.folderDownloadUrl(buildSpaceDownloadPath(spaceId));
198+
}
199+
200+
async function readResponseErrorMessage(response, fallback) {
201+
let payload = null;
202+
203+
try {
204+
payload = await response.clone().json();
205+
} catch {}
206+
207+
if (payload && typeof payload === "object" && String(payload.error || "").trim()) {
208+
return String(payload.error).trim();
209+
}
210+
211+
try {
212+
const text = String(await response.text()).trim();
213+
214+
if (text) {
215+
return text;
216+
}
217+
} catch {}
218+
219+
return fallback;
220+
}
221+
222+
async function verifySpaceArchiveDownload(spaceId) {
223+
const runtime = getRuntime();
224+
225+
await runtime.api.call("folder_download", {
226+
method: "HEAD",
227+
query: {
228+
path: buildSpaceDownloadPath(spaceId)
229+
}
230+
});
231+
}
232+
233+
async function fetchSpaceArchiveBytes(spaceId) {
234+
const response = await fetch(createSpaceDownloadUrl(spaceId), {
195235
credentials: "same-origin"
196236
});
197237

198238
if (!response.ok) {
199-
let detail = "Unable to create the space ZIP.";
200-
201-
try {
202-
const payload = await response.json();
203-
detail = String(payload?.error || detail);
204-
} catch {}
205-
206-
throw new Error(detail);
239+
throw new Error(await readResponseErrorMessage(response, "Unable to create the ZIP."));
207240
}
208241

209242
return new Uint8Array(await response.arrayBuffer());
@@ -422,16 +455,9 @@ const model = {
422455
this.archiveStatusText = "Preparing ZIP download...";
423456

424457
try {
425-
const runtime = getRuntime();
426-
const spaceRoot = buildSpaceRootPath(this.spaceId);
427-
await runtime.api.call("folder_download", {
428-
method: "HEAD",
429-
query: {
430-
path: spaceRoot
431-
}
432-
});
458+
await verifySpaceArchiveDownload(this.spaceId);
433459
createAnchorDownload(
434-
runtime.api.folderDownloadUrl(spaceRoot),
460+
createSpaceDownloadUrl(this.spaceId),
435461
createDownloadFilename(this.spaceId, this.spaceTitle)
436462
);
437463
this.archiveStatusText = "ZIP download started.";
@@ -620,4 +646,3 @@ export async function openSpaceShareModal(options = {}) {
620646

621647
runtime.spaces = runtime.spaces || {};
622648
runtime.spaces.openShareModal = openSpaceShareModal;
623-

app/share/spaces/7GEKj7px.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"createdAt": "2026-04-21T06:58:56.778Z",
3+
"encrypted": false,
4+
"encryption": null,
5+
"lastUsedAt": "2026-04-21T06:59:16.586Z",
6+
"sizeBytes": 20461,
7+
"token": "7GEKj7px"
8+
}

app/share/spaces/7GEKj7px.zip

20 KB
Binary file not shown.

0 commit comments

Comments
 (0)