@palettelab/cli 0.3.43 → 0.3.45
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/README.md +14 -0
- package/backend-sdk/palette_sdk/storage.py +140 -1
- package/docs/python-backend-sdk.md +21 -3
- package/lib/commands/dev.js +1 -1
- package/lib/commands/publish.js +25 -3
- package/lib/commands/status.js +1 -0
- package/lib/dev-simulator.js +112 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -328,6 +328,8 @@ async def sync_invoices(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
328
328
|
return {"room": room, "folder": folder, "bytes": len(content or b"")}
|
|
329
329
|
```
|
|
330
330
|
|
|
331
|
+
App storage is different from Data Room uploads. Use `ctx.storage` or `palette.storage` for app-owned files written directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` / `palette.dataRooms` only when the file should be managed as a Data Room document.
|
|
332
|
+
|
|
331
333
|
Python backend app-storage example:
|
|
332
334
|
|
|
333
335
|
```python
|
|
@@ -340,6 +342,18 @@ async def save_report(ctx: PluginContext = Depends(get_plugin_context)):
|
|
|
340
342
|
key="reports/summary.json",
|
|
341
343
|
)
|
|
342
344
|
return saved
|
|
345
|
+
|
|
346
|
+
@router.post("/exports", dependencies=[require_permission("reports:write")])
|
|
347
|
+
async def save_large_export(ctx: PluginContext = Depends(get_plugin_context)):
|
|
348
|
+
progress = []
|
|
349
|
+
saved = await ctx.storage.upload_file_path(
|
|
350
|
+
"/tmp/export.zip",
|
|
351
|
+
"application/zip",
|
|
352
|
+
key="exports/export.zip",
|
|
353
|
+
chunk_size=8 * 1024 * 1024,
|
|
354
|
+
on_progress=lambda event: progress.append(event),
|
|
355
|
+
)
|
|
356
|
+
return {"saved": saved, "progress": progress[-1:]}
|
|
343
357
|
```
|
|
344
358
|
|
|
345
359
|
Frontend apps can use `createPaletteClient(platform).storage.upload(file, {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import inspect
|
|
3
4
|
import mimetypes
|
|
5
|
+
import os
|
|
4
6
|
import re
|
|
5
7
|
import uuid
|
|
6
8
|
from dataclasses import dataclass
|
|
7
9
|
from pathlib import Path
|
|
8
|
-
from typing import Any
|
|
10
|
+
from typing import Any, BinaryIO, Callable
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
def _slug_segment(value: str | None, fallback: str) -> str:
|
|
@@ -31,6 +33,48 @@ def _validate_relative_key(key: str) -> str:
|
|
|
31
33
|
return "/".join(_sanitize_filename(part) for part in parts)
|
|
32
34
|
|
|
33
35
|
|
|
36
|
+
MIN_CHUNK_SIZE = 256 * 1024
|
|
37
|
+
DEFAULT_CHUNK_SIZE = 8 * 1024 * 1024
|
|
38
|
+
|
|
39
|
+
ProgressCallback = Callable[[dict[str, Any]], Any]
|
|
40
|
+
|
|
41
|
+
def _normalize_chunk_size(value: int | None) -> int:
|
|
42
|
+
raw = max(value or DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE)
|
|
43
|
+
return ((raw + MIN_CHUNK_SIZE - 1) // MIN_CHUNK_SIZE) * MIN_CHUNK_SIZE
|
|
44
|
+
|
|
45
|
+
def _infer_stream_size(stream: BinaryIO) -> int | None:
|
|
46
|
+
try:
|
|
47
|
+
current = stream.tell()
|
|
48
|
+
stream.seek(0, os.SEEK_END)
|
|
49
|
+
end = stream.tell()
|
|
50
|
+
stream.seek(current, os.SEEK_SET)
|
|
51
|
+
return max(0, end - current)
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def _maybe_report_progress(
|
|
56
|
+
callback: ProgressCallback | None,
|
|
57
|
+
*,
|
|
58
|
+
loaded: int,
|
|
59
|
+
total: int,
|
|
60
|
+
chunk_index: int,
|
|
61
|
+
chunk_count: int,
|
|
62
|
+
state: str,
|
|
63
|
+
) -> None:
|
|
64
|
+
if callback is None:
|
|
65
|
+
return
|
|
66
|
+
payload = {
|
|
67
|
+
"loaded": loaded,
|
|
68
|
+
"total": total,
|
|
69
|
+
"percentage": min(100, round((loaded / total) * 10000) / 100) if total > 0 else 100,
|
|
70
|
+
"chunk_index": chunk_index,
|
|
71
|
+
"chunk_count": chunk_count,
|
|
72
|
+
"state": state,
|
|
73
|
+
}
|
|
74
|
+
result = callback(payload)
|
|
75
|
+
if inspect.isawaitable(result):
|
|
76
|
+
await result
|
|
77
|
+
|
|
34
78
|
def _content_type(filename: str | None, content_type: str | None) -> str:
|
|
35
79
|
browser_ct = content_type or "application/octet-stream"
|
|
36
80
|
if browser_ct == "application/octet-stream":
|
|
@@ -86,6 +130,101 @@ class LocalStorageService:
|
|
|
86
130
|
"size": len(content),
|
|
87
131
|
}
|
|
88
132
|
|
|
133
|
+
async def upload_file_stream(
|
|
134
|
+
self,
|
|
135
|
+
filename: str,
|
|
136
|
+
stream: BinaryIO,
|
|
137
|
+
content_type: str | None = None,
|
|
138
|
+
*,
|
|
139
|
+
size: int | None = None,
|
|
140
|
+
key: str | None = None,
|
|
141
|
+
chunk_size: int | None = None,
|
|
142
|
+
on_progress: ProgressCallback | None = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
total = size if size is not None else _infer_stream_size(stream)
|
|
145
|
+
if total is None:
|
|
146
|
+
raise ValueError("size is required for non-seekable storage streams")
|
|
147
|
+
chunk_bytes = _normalize_chunk_size(chunk_size)
|
|
148
|
+
chunk_count = max(1, (total + chunk_bytes - 1) // chunk_bytes)
|
|
149
|
+
object_path = self.object_path(filename, key=key)
|
|
150
|
+
target = self._target(object_path)
|
|
151
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
loaded = 0
|
|
153
|
+
await _maybe_report_progress(
|
|
154
|
+
on_progress,
|
|
155
|
+
loaded=0,
|
|
156
|
+
total=total,
|
|
157
|
+
chunk_index=0,
|
|
158
|
+
chunk_count=chunk_count,
|
|
159
|
+
state="starting",
|
|
160
|
+
)
|
|
161
|
+
if total == 0:
|
|
162
|
+
target.write_bytes(b"")
|
|
163
|
+
await _maybe_report_progress(
|
|
164
|
+
on_progress,
|
|
165
|
+
loaded=0,
|
|
166
|
+
total=0,
|
|
167
|
+
chunk_index=0,
|
|
168
|
+
chunk_count=chunk_count,
|
|
169
|
+
state="complete",
|
|
170
|
+
)
|
|
171
|
+
return {
|
|
172
|
+
"bucket": "local",
|
|
173
|
+
"object_path": object_path,
|
|
174
|
+
"file_url": target.as_uri(),
|
|
175
|
+
"content_type": _content_type(filename, content_type),
|
|
176
|
+
"size": 0,
|
|
177
|
+
}
|
|
178
|
+
with target.open("wb") as out:
|
|
179
|
+
chunk_index = 0
|
|
180
|
+
while True:
|
|
181
|
+
chunk = stream.read(chunk_bytes)
|
|
182
|
+
if not chunk:
|
|
183
|
+
break
|
|
184
|
+
chunk_index += 1
|
|
185
|
+
out.write(chunk)
|
|
186
|
+
loaded += len(chunk)
|
|
187
|
+
await _maybe_report_progress(
|
|
188
|
+
on_progress,
|
|
189
|
+
loaded=loaded,
|
|
190
|
+
total=total,
|
|
191
|
+
chunk_index=chunk_index,
|
|
192
|
+
chunk_count=chunk_count,
|
|
193
|
+
state="complete" if loaded >= total else "uploading",
|
|
194
|
+
)
|
|
195
|
+
if loaded != total:
|
|
196
|
+
raise RuntimeError(f"storage upload incomplete: uploaded {loaded} of {total} bytes")
|
|
197
|
+
return {
|
|
198
|
+
"bucket": "local",
|
|
199
|
+
"object_path": object_path,
|
|
200
|
+
"file_url": target.as_uri(),
|
|
201
|
+
"content_type": _content_type(filename, content_type),
|
|
202
|
+
"size": loaded,
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async def upload_file_path(
|
|
206
|
+
self,
|
|
207
|
+
path: str | Path,
|
|
208
|
+
content_type: str | None = None,
|
|
209
|
+
*,
|
|
210
|
+
filename: str | None = None,
|
|
211
|
+
key: str | None = None,
|
|
212
|
+
chunk_size: int | None = None,
|
|
213
|
+
on_progress: ProgressCallback | None = None,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
source = Path(path)
|
|
216
|
+
resolved_filename = filename or source.name
|
|
217
|
+
with source.open("rb") as stream:
|
|
218
|
+
return await self.upload_file_stream(
|
|
219
|
+
resolved_filename,
|
|
220
|
+
stream,
|
|
221
|
+
content_type,
|
|
222
|
+
size=source.stat().st_size,
|
|
223
|
+
key=key,
|
|
224
|
+
chunk_size=chunk_size,
|
|
225
|
+
on_progress=on_progress,
|
|
226
|
+
)
|
|
227
|
+
|
|
89
228
|
async def create_resumable_upload(
|
|
90
229
|
self,
|
|
91
230
|
filename: str,
|
|
@@ -740,6 +740,8 @@ Local mocks live in `.palette/app-services.local.json`:
|
|
|
740
740
|
}
|
|
741
741
|
```
|
|
742
742
|
|
|
743
|
+
App storage is separate from Data Rooms. Use `ctx.storage` and `palette.storage` for app-owned files that go directly to the OS-configured storage backend, currently GCS in hosted environments. Use `ctx.data_rooms` or `palette.dataRooms` only when the file should be visible and governed as a Data Room document.
|
|
744
|
+
|
|
743
745
|
Palette scopes storage the same way. Files written through `ctx.storage` or the
|
|
744
746
|
frontend storage client live under:
|
|
745
747
|
|
|
@@ -757,12 +759,28 @@ saved = await ctx.storage.upload_file(
|
|
|
757
759
|
key="reports/summary.json",
|
|
758
760
|
)
|
|
759
761
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
+
# Large backend-generated files should stream from disk. This avoids loading
|
|
763
|
+
# GB-sized files into memory and reports chunk progress.
|
|
764
|
+
progress = []
|
|
765
|
+
saved = await ctx.storage.upload_file_path(
|
|
766
|
+
"/tmp/video.mp4",
|
|
762
767
|
"video/mp4",
|
|
763
|
-
size=video_size,
|
|
764
768
|
key="videos/video.mp4",
|
|
769
|
+
chunk_size=8 * 1024 * 1024,
|
|
770
|
+
on_progress=lambda event: progress.append(event),
|
|
765
771
|
)
|
|
772
|
+
|
|
773
|
+
# If you already have an open binary stream, use upload_file_stream(...).
|
|
774
|
+
# Non-seekable streams must pass size=.
|
|
775
|
+
with open("/tmp/video.mp4", "rb") as stream:
|
|
776
|
+
saved = await ctx.storage.upload_file_stream(
|
|
777
|
+
"video.mp4",
|
|
778
|
+
stream,
|
|
779
|
+
"video/mp4",
|
|
780
|
+
size=video_size,
|
|
781
|
+
key="videos/video.mp4",
|
|
782
|
+
on_progress=lambda event: progress.append(event),
|
|
783
|
+
)
|
|
766
784
|
```
|
|
767
785
|
|
|
768
786
|
Frontend apps can use the browser SDK for chunked/resumable upload progress:
|
package/lib/commands/dev.js
CHANGED
|
@@ -83,7 +83,7 @@ async function run(args, { cwd }) {
|
|
|
83
83
|
loadLocalEnv(cwd)
|
|
84
84
|
if (cloud) {
|
|
85
85
|
const json = args.includes("--json")
|
|
86
|
-
const publishArgs = []
|
|
86
|
+
const publishArgs = ["--publish-type", "preview"]
|
|
87
87
|
if (flags.env) publishArgs.push("--env", flags.env)
|
|
88
88
|
if (flags.yes) publishArgs.push("--yes")
|
|
89
89
|
if (args.includes("--json")) publishArgs.push("--json")
|
package/lib/commands/publish.js
CHANGED
|
@@ -165,6 +165,20 @@ function runPreflight(cwd, json) {
|
|
|
165
165
|
process.exit(res.status || 1)
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
|
|
169
|
+
function parsePublishType(argv) {
|
|
170
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
171
|
+
const arg = argv[i]
|
|
172
|
+
if (arg === "--preview") return "preview"
|
|
173
|
+
if (arg === "--release") return "release"
|
|
174
|
+
if (arg === "--publish-type") return argv[i + 1] === "preview" ? "preview" : "release"
|
|
175
|
+
if (arg.startsWith("--publish-type=")) {
|
|
176
|
+
return arg.slice("--publish-type=".length) === "preview" ? "preview" : "release"
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return "release"
|
|
180
|
+
}
|
|
181
|
+
|
|
168
182
|
function makeApi(env) {
|
|
169
183
|
return async function api(pathname, { method = "GET", body, headers = {} } = {}) {
|
|
170
184
|
const url = `${env.url}${pathname}`
|
|
@@ -266,6 +280,7 @@ async function run(argv, { cwd }) {
|
|
|
266
280
|
}
|
|
267
281
|
|
|
268
282
|
const manifest = loadManifest(cwd)
|
|
283
|
+
const publishType = parsePublishType(argv)
|
|
269
284
|
loadLocalEnv(cwd)
|
|
270
285
|
const errors = validateManifest(manifest)
|
|
271
286
|
if (errors.length) {
|
|
@@ -310,6 +325,7 @@ async function run(argv, { cwd }) {
|
|
|
310
325
|
plugin_id: manifest.id,
|
|
311
326
|
version: manifest.version,
|
|
312
327
|
bundle_sha256: backendSha,
|
|
328
|
+
publish_type: publishType,
|
|
313
329
|
},
|
|
314
330
|
})
|
|
315
331
|
|
|
@@ -334,6 +350,7 @@ async function run(argv, { cwd }) {
|
|
|
334
350
|
bundle_path: signed.bundle_path,
|
|
335
351
|
bundle_sha256: backendSha,
|
|
336
352
|
manifest,
|
|
353
|
+
publish_type: publishType,
|
|
337
354
|
environment: env.name,
|
|
338
355
|
}
|
|
339
356
|
if (Object.keys(pluginSecrets).length) publishBody.plugin_secrets = pluginSecrets
|
|
@@ -357,6 +374,7 @@ async function run(argv, { cwd }) {
|
|
|
357
374
|
version: record.version,
|
|
358
375
|
env: env.name,
|
|
359
376
|
url: env.url,
|
|
377
|
+
publish_type: record.publish_type || publishType,
|
|
360
378
|
preview_url: record.preview_url,
|
|
361
379
|
preview_expires_at: record.preview_expires_at,
|
|
362
380
|
published_at: new Date().toISOString(),
|
|
@@ -375,12 +393,16 @@ async function run(argv, { cwd }) {
|
|
|
375
393
|
}
|
|
376
394
|
|
|
377
395
|
console.log(
|
|
378
|
-
`[pltt] published ${record.plugin_id}@${record.version} (status=${record.status})`,
|
|
396
|
+
`[pltt] ${publishType === "preview" ? "previewed" : "published"} ${record.plugin_id}@${record.version} (status=${record.status})`,
|
|
379
397
|
)
|
|
380
398
|
if (record.status === "active") {
|
|
381
399
|
console.log(`[pltt] active on ${env.url}`)
|
|
400
|
+
} else if (record.status === "preview_ready") {
|
|
401
|
+
console.log(`[pltt] preview ready on ${env.url}`)
|
|
402
|
+
} else if (record.status === "preview_pending") {
|
|
403
|
+
console.log(`[pltt] awaiting superadmin preview approval on ${env.url}`)
|
|
382
404
|
} else {
|
|
383
|
-
console.log(`[pltt] awaiting superadmin
|
|
405
|
+
console.log(`[pltt] awaiting superadmin publish approval on ${env.url}`)
|
|
384
406
|
}
|
|
385
407
|
if (record.review_url && record.status !== "active") {
|
|
386
408
|
console.log(`[pltt] review queue: ${record.review_url}`)
|
|
@@ -393,7 +415,7 @@ async function run(argv, { cwd }) {
|
|
|
393
415
|
}
|
|
394
416
|
if (record.status === "active") {
|
|
395
417
|
console.log(`[pltt] live at ${env.url}${record.catalog_url}`)
|
|
396
|
-
} else {
|
|
418
|
+
} else if (publishType === "release") {
|
|
397
419
|
console.log(`[pltt] once approved, live at ${env.url}${record.catalog_url}`)
|
|
398
420
|
}
|
|
399
421
|
return record
|
package/lib/commands/status.js
CHANGED
|
@@ -63,6 +63,7 @@ async function run(argv, { cwd }) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
console.log(`[pltt] ${data.plugin_id}@${data.version}`)
|
|
66
|
+
if (data.publish_type) console.log(` type: ${data.publish_type}`)
|
|
66
67
|
console.log(` status: ${data.status}`)
|
|
67
68
|
if (data.review_decision) console.log(` decision: ${data.review_decision}`)
|
|
68
69
|
if (data.review_reason) console.log(` reason: ${data.review_reason}`)
|
package/lib/dev-simulator.js
CHANGED
|
@@ -145,7 +145,7 @@ function ensurePythonEnv(cwd, devDir, manifest) {
|
|
|
145
145
|
return venvPython
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
function writeBackendRunner(cwd, devDir, manifest, backendEntry) {
|
|
148
|
+
function writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort) {
|
|
149
149
|
const runner = path.join(devDir, "backend_runner.py")
|
|
150
150
|
const sdkPath = localBackendSdkPath()
|
|
151
151
|
const databasePath = path.join(devDir, `${manifest.id}.sqlite3`)
|
|
@@ -159,10 +159,12 @@ import importlib.util
|
|
|
159
159
|
import json
|
|
160
160
|
import os
|
|
161
161
|
import pathlib
|
|
162
|
+
import re
|
|
162
163
|
import sys
|
|
164
|
+
import uuid
|
|
163
165
|
from types import SimpleNamespace
|
|
164
166
|
|
|
165
|
-
from fastapi import FastAPI, Request
|
|
167
|
+
from fastapi import FastAPI, HTTPException, Request, Response
|
|
166
168
|
from fastapi.middleware.cors import CORSMiddleware
|
|
167
169
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
168
170
|
|
|
@@ -175,6 +177,7 @@ DATABASE_URL = os.environ.get("PALETTE_DEV_DATABASE_URL", "sqlite+aiosqlite:///$
|
|
|
175
177
|
DEV_SECRETS = json.loads(${JSON.stringify(JSON.stringify(devSecrets))})
|
|
176
178
|
DEV_CONNECTIONS = json.loads(${JSON.stringify(JSON.stringify(devConnections))})
|
|
177
179
|
DEV_APP_MOCKS = json.loads(${JSON.stringify(JSON.stringify(devAppMocks))})
|
|
180
|
+
BACKEND_BASE = "http://127.0.0.1:${backendPort}"
|
|
178
181
|
|
|
179
182
|
def _service_enabled(name: str) -> bool:
|
|
180
183
|
services = MANIFEST.get("platform_services") or []
|
|
@@ -209,6 +212,34 @@ if _service_enabled("storage"):
|
|
|
209
212
|
organization_name="Palette Dev",
|
|
210
213
|
)
|
|
211
214
|
|
|
215
|
+
LOCAL_UPLOADS = {}
|
|
216
|
+
CONTENT_RANGE_RE = re.compile(r"^bytes (?P<start>\\d+)-(?P<end>\\d+)/(?P<total>\\d+)$")
|
|
217
|
+
|
|
218
|
+
def _local_storage_enabled():
|
|
219
|
+
return _service_enabled("storage") and DEV_STORAGE is not None
|
|
220
|
+
|
|
221
|
+
def _parse_content_range(value: str | None):
|
|
222
|
+
if not value:
|
|
223
|
+
raise HTTPException(status_code=411, detail="Content-Range header is required")
|
|
224
|
+
match = CONTENT_RANGE_RE.match(value)
|
|
225
|
+
if not match:
|
|
226
|
+
raise HTTPException(status_code=400, detail="Invalid Content-Range header")
|
|
227
|
+
start = int(match.group("start"))
|
|
228
|
+
end = int(match.group("end"))
|
|
229
|
+
total = int(match.group("total"))
|
|
230
|
+
if end < start or total < 0 or end >= total:
|
|
231
|
+
raise HTTPException(status_code=400, detail="Invalid Content-Range byte range")
|
|
232
|
+
return start, end, total
|
|
233
|
+
|
|
234
|
+
def _require_local_storage(plugin_id: str):
|
|
235
|
+
if plugin_id != MANIFEST.get("id", ""):
|
|
236
|
+
raise HTTPException(status_code=404, detail="App not found")
|
|
237
|
+
if not _local_storage_enabled():
|
|
238
|
+
raise HTTPException(status_code=403, detail='App must declare platform_services: ["storage"]')
|
|
239
|
+
|
|
240
|
+
def _api_url(path: str):
|
|
241
|
+
return f"{BACKEND_BASE}/api/v1{path}"
|
|
242
|
+
|
|
212
243
|
class LocalAppInteropService:
|
|
213
244
|
def service(self, service_id: str):
|
|
214
245
|
return LocalAppServiceClient(service_id)
|
|
@@ -310,6 +341,84 @@ app.add_middleware(
|
|
|
310
341
|
app.add_middleware(DevPluginContextMiddleware)
|
|
311
342
|
app.include_router(router, prefix=f"/api/v1/plugins/{MANIFEST['id']}")
|
|
312
343
|
|
|
344
|
+
@app.post("/api/v1/app-storage/{plugin_id}/uploads")
|
|
345
|
+
async def create_local_app_storage_upload(plugin_id: str, request: Request):
|
|
346
|
+
_require_local_storage(plugin_id)
|
|
347
|
+
body = await request.json()
|
|
348
|
+
filename = body.get("filename") or "upload"
|
|
349
|
+
content_type = body.get("content_type") or "application/octet-stream"
|
|
350
|
+
size = int(body.get("size") or 0)
|
|
351
|
+
key = body.get("key")
|
|
352
|
+
chunk_size = int(body.get("chunk_size") or 8 * 1024 * 1024)
|
|
353
|
+
upload_id = uuid.uuid4().hex
|
|
354
|
+
object_path = DEV_STORAGE.object_path(filename, key=key)
|
|
355
|
+
temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
|
|
356
|
+
temp_path.parent.mkdir(parents=True, exist_ok=True)
|
|
357
|
+
temp_path.write_bytes(b"")
|
|
358
|
+
LOCAL_UPLOADS[upload_id] = {
|
|
359
|
+
"plugin_id": plugin_id,
|
|
360
|
+
"object_path": object_path,
|
|
361
|
+
"content_type": content_type,
|
|
362
|
+
"size": size,
|
|
363
|
+
"uploaded_bytes": 0,
|
|
364
|
+
"complete": False,
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
"upload_id": upload_id,
|
|
368
|
+
"mode": "local_resumable",
|
|
369
|
+
"bucket": "local",
|
|
370
|
+
"object_path": object_path,
|
|
371
|
+
"file_url": DEV_STORAGE._target(object_path).as_uri(),
|
|
372
|
+
"upload_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/chunks"),
|
|
373
|
+
"status_url": _api_url(f"/app-storage/{plugin_id}/uploads/{upload_id}/status"),
|
|
374
|
+
"content_type": content_type,
|
|
375
|
+
"size": size,
|
|
376
|
+
"chunk_size": chunk_size,
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
@app.get("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/status")
|
|
380
|
+
async def local_app_storage_upload_status(plugin_id: str, upload_id: str):
|
|
381
|
+
_require_local_storage(plugin_id)
|
|
382
|
+
session = LOCAL_UPLOADS.get(upload_id)
|
|
383
|
+
if not session or session["plugin_id"] != plugin_id:
|
|
384
|
+
raise HTTPException(status_code=404, detail="Upload session not found")
|
|
385
|
+
return {
|
|
386
|
+
"upload_id": upload_id,
|
|
387
|
+
"object_path": session["object_path"],
|
|
388
|
+
"uploaded_bytes": session["uploaded_bytes"],
|
|
389
|
+
"size": session["size"],
|
|
390
|
+
"complete": session["complete"],
|
|
391
|
+
"file_url": DEV_STORAGE._target(session["object_path"]).as_uri(),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
@app.put("/api/v1/app-storage/{plugin_id}/uploads/{upload_id}/chunks")
|
|
395
|
+
async def upload_local_app_storage_chunk(plugin_id: str, upload_id: str, request: Request):
|
|
396
|
+
_require_local_storage(plugin_id)
|
|
397
|
+
session = LOCAL_UPLOADS.get(upload_id)
|
|
398
|
+
if not session or session["plugin_id"] != plugin_id:
|
|
399
|
+
raise HTTPException(status_code=404, detail="Upload session not found")
|
|
400
|
+
if session["complete"]:
|
|
401
|
+
return Response(status_code=204)
|
|
402
|
+
start, end, total = _parse_content_range(request.headers.get("content-range"))
|
|
403
|
+
if total != session["size"]:
|
|
404
|
+
raise HTTPException(status_code=400, detail="Chunk total does not match upload size")
|
|
405
|
+
if start != session["uploaded_bytes"]:
|
|
406
|
+
raise HTTPException(status_code=409, detail=f"Expected chunk to start at byte {session['uploaded_bytes']}")
|
|
407
|
+
payload = await request.body()
|
|
408
|
+
if len(payload) != end - start + 1:
|
|
409
|
+
raise HTTPException(status_code=400, detail="Chunk size does not match Content-Range")
|
|
410
|
+
temp_path = DEV_STORAGE._target(f".tmp/app-storage/{upload_id}.part")
|
|
411
|
+
with temp_path.open("ab") as fh:
|
|
412
|
+
fh.write(payload)
|
|
413
|
+
session["uploaded_bytes"] = end + 1
|
|
414
|
+
if session["uploaded_bytes"] >= session["size"]:
|
|
415
|
+
final_path = DEV_STORAGE._target(session["object_path"])
|
|
416
|
+
final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
417
|
+
temp_path.replace(final_path)
|
|
418
|
+
session["complete"] = True
|
|
419
|
+
return Response(status_code=201)
|
|
420
|
+
return Response(status_code=308, headers={"Range": f"bytes=0-{session['uploaded_bytes'] - 1}"})
|
|
421
|
+
|
|
313
422
|
@app.on_event("startup")
|
|
314
423
|
async def create_local_database_tables():
|
|
315
424
|
if engine is None:
|
|
@@ -328,7 +437,7 @@ function startBackend(cwd, devDir, manifest, backendPort) {
|
|
|
328
437
|
if (!fs.existsSync(absEntry)) throw new Error(`backend entry not found: ${backendEntry}`)
|
|
329
438
|
|
|
330
439
|
const python = ensurePythonEnv(cwd, devDir, manifest)
|
|
331
|
-
const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry)
|
|
440
|
+
const runner = writeBackendRunner(cwd, devDir, manifest, backendEntry, backendPort)
|
|
332
441
|
const sdkPath = localBackendSdkPath()
|
|
333
442
|
const env = { ...process.env }
|
|
334
443
|
if (sdkPath) {
|