@lotics/cli 0.88.0 → 0.89.1
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/dist/render_page.js +100 -100
- package/dist/src/cli.js +162 -100
- package/dist/{client.d.ts → src/client.d.ts} +9 -1
- package/dist/src/package_content_types.d.ts +70 -0
- package/dist/src/package_content_types.js +12 -0
- package/package.json +2 -2
- package/dist/app_commands.d.ts +0 -401
- package/dist/app_commands.js +0 -1520
- package/dist/app_commands.test.d.ts +0 -1
- package/dist/app_commands.test.js +0 -898
- package/dist/app_workflow_check.d.ts +0 -77
- package/dist/app_workflow_check.js +0 -169
- package/dist/app_workflow_check.test.d.ts +0 -1
- package/dist/app_workflow_check.test.js +0 -166
- package/dist/args.d.ts +0 -42
- package/dist/args.js +0 -121
- package/dist/args.test.d.ts +0 -1
- package/dist/args.test.js +0 -88
- package/dist/child_env.d.ts +0 -13
- package/dist/child_env.js +0 -24
- package/dist/cli.d.ts +0 -2
- package/dist/cli.js +0 -1095
- package/dist/cli_dispatch.test.d.ts +0 -1
- package/dist/cli_dispatch.test.js +0 -89
- package/dist/client.test.d.ts +0 -1
- package/dist/client.test.js +0 -47
- package/dist/config.d.ts +0 -124
- package/dist/config.js +0 -315
- package/dist/config.test.d.ts +0 -1
- package/dist/config.test.js +0 -319
- package/dist/dev/file_relay.d.ts +0 -39
- package/dist/dev/file_relay.js +0 -87
- package/dist/dev/file_relay.test.d.ts +0 -1
- package/dist/dev/file_relay.test.js +0 -87
- package/dist/dev/rpc_handler.d.ts +0 -21
- package/dist/dev/rpc_handler.js +0 -166
- package/dist/dev/rpc_handler.test.d.ts +0 -1
- package/dist/dev/rpc_handler.test.js +0 -107
- package/dist/dev/server.d.ts +0 -75
- package/dist/dev/server.js +0 -443
- package/dist/dev/upload_relay.d.ts +0 -36
- package/dist/dev/upload_relay.js +0 -61
- package/dist/dev/upload_relay.test.d.ts +0 -1
- package/dist/dev/upload_relay.test.js +0 -76
- package/dist/dev/wrapper_page.d.ts +0 -40
- package/dist/dev/wrapper_page.js +0 -335
- package/dist/docx.d.ts +0 -2
- package/dist/docx.js +0 -341
- package/dist/docx.test.d.ts +0 -1
- package/dist/docx.test.js +0 -145
- package/dist/file_command_io.d.ts +0 -12
- package/dist/file_command_io.js +0 -34
- package/dist/generate_app_agents_dts.d.ts +0 -16
- package/dist/generate_app_agents_dts.js +0 -62
- package/dist/generate_app_agents_dts.test.d.ts +0 -1
- package/dist/generate_app_agents_dts.test.js +0 -45
- package/dist/generate_app_fields.d.ts +0 -56
- package/dist/generate_app_fields.js +0 -148
- package/dist/generate_app_fields.test.d.ts +0 -1
- package/dist/generate_app_fields.test.js +0 -108
- package/dist/generate_app_queries_dts.d.ts +0 -21
- package/dist/generate_app_queries_dts.js +0 -57
- package/dist/generate_app_workflows_dts.d.ts +0 -39
- package/dist/generate_app_workflows_dts.js +0 -229
- package/dist/generate_app_workflows_dts.test.d.ts +0 -1
- package/dist/generate_app_workflows_dts.test.js +0 -109
- package/dist/generate_package_fields.d.ts +0 -49
- package/dist/generate_package_fields.js +0 -157
- package/dist/generate_package_fields.test.d.ts +0 -1
- package/dist/generate_package_fields.test.js +0 -59
- package/dist/inputs.d.ts +0 -38
- package/dist/inputs.js +0 -50
- package/dist/inputs.test.d.ts +0 -1
- package/dist/inputs.test.js +0 -89
- package/dist/preview.d.ts +0 -3
- package/dist/preview.js +0 -233
- package/dist/starter_template.d.ts +0 -64
- package/dist/starter_template.js +0 -753
- package/dist/starter_template.test.d.ts +0 -1
- package/dist/starter_template.test.js +0 -115
- package/dist/version.d.ts +0 -1
- package/dist/version.js +0 -6
- package/dist/xlsx.d.ts +0 -2
- package/dist/xlsx.js +0 -489
- package/dist/xlsx.test.d.ts +0 -1
- package/dist/xlsx.test.js +0 -131
- /package/dist/{client.js → src/client.js} +0 -0
package/dist/dev/server.js
DELETED
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `lotics app dev` orchestrator.
|
|
3
|
-
*
|
|
4
|
-
* Two child processes managed in one Node lifecycle:
|
|
5
|
-
* 1. Vite dev server (npx vite --port <vite-port>) — child_process.spawn,
|
|
6
|
-
* stdio inherited so Vite's own logging surfaces to the developer.
|
|
7
|
-
* 2. node:http server on <port> serving:
|
|
8
|
-
* GET / → wrapper HTML (cached: no)
|
|
9
|
-
* POST /_rpc → JSON in, dispatched via rpc_handler, JSON out
|
|
10
|
-
* POST /_agent_run → SSE, piped from the run
|
|
11
|
-
* PUT /_upload/:id → file bytes in (relayed to storage)
|
|
12
|
-
* GET /_file/:token → file bytes out (relayed from storage)
|
|
13
|
-
* * → 404
|
|
14
|
-
*
|
|
15
|
-
* SIGINT (Ctrl-C) → kill Vite child, close HTTP server, exit 0.
|
|
16
|
-
*
|
|
17
|
-
* Why the byte relays exist: dev runs against the PROD bucket (there is no dev
|
|
18
|
-
* bucket), whose CORS allowlist holds the real app origins (`https://*.lotics.app`),
|
|
19
|
-
* not `http://localhost:<port>`. So a browser transfer straight to/from the presigned
|
|
20
|
-
* URL is blocked before it leaves the page — no upload could complete, and no preview
|
|
21
|
-
* engine (PDF/Word/Excel all FETCH the bytes) could read a file. Relaying through THIS
|
|
22
|
-
* server fixes both: Node has no same-origin policy, and the one cross-origin hop that
|
|
23
|
-
* remains (the app iframe reading from us) is OUR response to allow.
|
|
24
|
-
*
|
|
25
|
-
* Neither relay ever takes a destination from the client: the page sends a `file_id`
|
|
26
|
-
* or an opaque token, and the server transfers only to/from a URL IT minted or observed
|
|
27
|
-
* for that id. No client-controlled target ⇒ no SSRF surface, nothing to allowlist.
|
|
28
|
-
*
|
|
29
|
-
* Production is untouched — it transfers direct-to-storage, keeping every byte off the
|
|
30
|
-
* API server.
|
|
31
|
-
*/
|
|
32
|
-
import http from "node:http";
|
|
33
|
-
import net from "node:net";
|
|
34
|
-
import { spawn } from "node:child_process";
|
|
35
|
-
import { ipv4ChildEnv } from "../child_env.js";
|
|
36
|
-
import { dispatchRpc } from "./rpc_handler.js";
|
|
37
|
-
import { buildWrapperPage } from "./wrapper_page.js";
|
|
38
|
-
import { createUploadRelay } from "./upload_relay.js";
|
|
39
|
-
import { createFileRelay } from "./file_relay.js";
|
|
40
|
-
const DEFAULT_PORT = 5174;
|
|
41
|
-
const DEFAULT_VITE_PORT = 5173;
|
|
42
|
-
/**
|
|
43
|
-
* Serialize an RPC result for the HTTP response body. Ops with no return
|
|
44
|
-
* value (e.g. comments.delete) resolve `undefined` — in production they
|
|
45
|
-
* travel over postMessage where undefined structured-clones fine, but
|
|
46
|
-
* `JSON.stringify(undefined)` is not a string, so the body would be EMPTY
|
|
47
|
-
* and the wrapper's `JSON.parse` throws "Unexpected end of JSON input".
|
|
48
|
-
* Serialize those as `null`.
|
|
49
|
-
*/
|
|
50
|
-
export function serializeRpcResult(result) {
|
|
51
|
-
return JSON.stringify(result ?? null);
|
|
52
|
-
}
|
|
53
|
-
export async function startDevServer(args) {
|
|
54
|
-
const wrapperPort = await pickPort(args.port ?? DEFAULT_PORT);
|
|
55
|
-
const vitePort = await pickPort(args.vitePort ?? DEFAULT_VITE_PORT, wrapperPort);
|
|
56
|
-
const viteUrl = `http://localhost:${vitePort}/`;
|
|
57
|
-
const html = buildWrapperPage({
|
|
58
|
-
app_name: args.app_name,
|
|
59
|
-
app_id: args.app_id,
|
|
60
|
-
workspace_id: args.workspace_id,
|
|
61
|
-
vite_url: viteUrl,
|
|
62
|
-
api_url: args.api_url,
|
|
63
|
-
});
|
|
64
|
-
// ── Vite child ──────────────────────────────────────────────────────────
|
|
65
|
-
// `npx vite` resolves to the project's installed Vite (devDependency in
|
|
66
|
-
// the starter). stdout/stderr are inherited so the dev sees Vite's banner
|
|
67
|
-
// + HMR logs.
|
|
68
|
-
//
|
|
69
|
-
// stdin is inherited ONLY under a TTY. Vite's dev server quits when a
|
|
70
|
-
// non-interactive stdin (a backgrounded launch, `</dev/null`, a closing
|
|
71
|
-
// pipe) reaches EOF — so inheriting it would kill the dev server the moment
|
|
72
|
-
// `lotics app dev` runs under an agent, CI, or `nohup`. A non-TTY launch
|
|
73
|
-
// gets `/dev/null` (`"ignore"`) instead, decoupling Vite's lifetime from
|
|
74
|
-
// however the wrapper was started. Vite's CLI shortcuts (`r`/`q`/`u`) are
|
|
75
|
-
// only meaningful under a TTY anyway.
|
|
76
|
-
const viteChild = spawn("npx", ["vite", "--port", String(vitePort), "--strictPort"], {
|
|
77
|
-
cwd: args.projectDir,
|
|
78
|
-
stdio: [process.stdin.isTTY ? "inherit" : "ignore", "inherit", "inherit"],
|
|
79
|
-
env: ipv4ChildEnv({ ...process.env, FORCE_COLOR: "1" }),
|
|
80
|
-
});
|
|
81
|
-
let stopped = false;
|
|
82
|
-
let stoppingResolve = null;
|
|
83
|
-
const stoppingPromise = new Promise((resolve) => {
|
|
84
|
-
stoppingResolve = resolve;
|
|
85
|
-
});
|
|
86
|
-
viteChild.on("exit", (code, signal) => {
|
|
87
|
-
if (!stopped) {
|
|
88
|
-
console.error(`Vite exited unexpectedly (code=${code} signal=${signal})`);
|
|
89
|
-
void stopAll();
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
// Belt and suspenders: kill Vite if the parent dies for any reason.
|
|
93
|
-
// process.on('exit') runs synchronously on every termination path
|
|
94
|
-
// (including uncaught exceptions). spawn() returns immediately so we
|
|
95
|
-
// can grab the pid before any failure mode.
|
|
96
|
-
const killViteOnExit = () => {
|
|
97
|
-
if (!viteChild.killed && viteChild.pid) {
|
|
98
|
-
try {
|
|
99
|
-
process.kill(viteChild.pid, "SIGTERM");
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
|
-
// process already gone — fine
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
process.once("exit", killViteOnExit);
|
|
107
|
-
const uploads = createUploadRelay();
|
|
108
|
-
// The app iframe is served from Vite's origin, so the file URLs it receives must be
|
|
109
|
-
// absolute against THIS server, and its byte reads are cross-origin to us — which is
|
|
110
|
-
// ours to allow (unlike the storage bucket's policy, which is not).
|
|
111
|
-
const wrapperOrigin = `http://localhost:${wrapperPort}`;
|
|
112
|
-
const viteOrigin = `http://localhost:${vitePort}`;
|
|
113
|
-
const files = createFileRelay(wrapperOrigin);
|
|
114
|
-
const fileCors = {
|
|
115
|
-
"Access-Control-Allow-Origin": viteOrigin,
|
|
116
|
-
"Access-Control-Allow-Headers": "range, content-type",
|
|
117
|
-
"Access-Control-Expose-Headers": "content-length, content-range, accept-ranges, content-type, content-disposition, etag",
|
|
118
|
-
};
|
|
119
|
-
// Headers a byte-reader actually needs: the type to decode, the length/range to seek.
|
|
120
|
-
const PASS_THROUGH = [
|
|
121
|
-
"content-type",
|
|
122
|
-
"content-length",
|
|
123
|
-
"content-range",
|
|
124
|
-
"accept-ranges",
|
|
125
|
-
"etag",
|
|
126
|
-
"last-modified",
|
|
127
|
-
"content-disposition",
|
|
128
|
-
];
|
|
129
|
-
// ── HTTP server ────────────────────────────────────────────────────────
|
|
130
|
-
const server = http.createServer(async (req, res) => {
|
|
131
|
-
const url = req.url ?? "/";
|
|
132
|
-
// Route by pathname — `useUrlState` writes a query string onto the wrapper
|
|
133
|
-
// URL, so a refresh / shared link requests `/?…`; the page must still serve.
|
|
134
|
-
const pathname = url.split("?")[0];
|
|
135
|
-
if (req.method === "GET" && (pathname === "/" || pathname === "/index.html")) {
|
|
136
|
-
res.writeHead(200, {
|
|
137
|
-
"Content-Type": "text/html; charset=utf-8",
|
|
138
|
-
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
139
|
-
});
|
|
140
|
-
res.end(html);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
if (req.method === "POST" && url === "/_rpc") {
|
|
144
|
-
try {
|
|
145
|
-
const body = await readJson(req);
|
|
146
|
-
const startedAt = Date.now();
|
|
147
|
-
const result = await dispatchRpc(args.client, {
|
|
148
|
-
app_id: body.app_id,
|
|
149
|
-
op: body.op,
|
|
150
|
-
payload: body.payload,
|
|
151
|
-
}, { commentsEnabled: args.commentsEnabled });
|
|
152
|
-
const ms = Date.now() - startedAt;
|
|
153
|
-
process.stderr.write(`[rpc] ${body.op} ${ms}ms\n`);
|
|
154
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
155
|
-
// `upload_url` mints a PUT destination (upload relay); every other op may carry
|
|
156
|
-
// presigned file URLs the app will read bytes from (file relay).
|
|
157
|
-
res.end(serializeRpcResult(body.op === "upload_url" ? uploads.rewriteMint(result) : files.rewrite(result)));
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
161
|
-
process.stderr.write(`[rpc] ERROR ${message}\n`);
|
|
162
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
163
|
-
res.end(JSON.stringify({ message }));
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
// Streaming agent run — the one op /_rpc can't carry (its response is a
|
|
168
|
-
// stream, not a single JSON value). Open the run with the CLI key and pipe
|
|
169
|
-
// the SSE body straight back to the wrapper page, which forwards chunks to
|
|
170
|
-
// the iframe. A wrapper disconnect (the app aborted) aborts the upstream.
|
|
171
|
-
if (req.method === "POST" && url === "/_agent_run") {
|
|
172
|
-
// Abort the upstream run only when the CLIENT disconnects — keyed on the
|
|
173
|
-
// RESPONSE closing before we finished writing it. `req.on("close")` is
|
|
174
|
-
// wrong here: in modern Node it fires as soon as the request BODY stream
|
|
175
|
-
// ends (right after readJson consumes it), which would abort every run the
|
|
176
|
-
// instant it started — a silent empty 200, no chunks.
|
|
177
|
-
const ac = new AbortController();
|
|
178
|
-
res.on("close", () => {
|
|
179
|
-
if (!res.writableEnded)
|
|
180
|
-
ac.abort();
|
|
181
|
-
});
|
|
182
|
-
try {
|
|
183
|
-
const body = await readJson(req);
|
|
184
|
-
const p = (body.payload ?? {});
|
|
185
|
-
if (typeof p.alias !== "string" || typeof p.session_id !== "string") {
|
|
186
|
-
throw new Error("agentRun payload must include `alias` and `session_id`");
|
|
187
|
-
}
|
|
188
|
-
const upstream = await args.client.appAgentRunStream(body.app_id, p.alias, { session_id: p.session_id, input: p.input ?? {} }, ac.signal);
|
|
189
|
-
if (!upstream.body)
|
|
190
|
-
throw new Error("agent run returned no stream body");
|
|
191
|
-
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform" });
|
|
192
|
-
const reader = upstream.body.getReader();
|
|
193
|
-
for (;;) {
|
|
194
|
-
const { value, done } = await reader.read();
|
|
195
|
-
if (done)
|
|
196
|
-
break;
|
|
197
|
-
res.write(Buffer.from(value));
|
|
198
|
-
}
|
|
199
|
-
res.end();
|
|
200
|
-
}
|
|
201
|
-
catch (err) {
|
|
202
|
-
if (ac.signal.aborted) {
|
|
203
|
-
res.end();
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
207
|
-
process.stderr.write(`[agent_run] ERROR ${message}\n`);
|
|
208
|
-
if (!res.headersSent) {
|
|
209
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
210
|
-
res.end(JSON.stringify({ message }));
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
res.end();
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
// File relay — the app reads file bytes from here instead of straight from storage,
|
|
219
|
-
// whose CORS doesn't admit a localhost origin. The app is on Vite's origin, so this
|
|
220
|
-
// IS cross-origin — but it's our response, so we allow it. Serving from here also
|
|
221
|
-
// means <img>/<video> and openExternal keep working unchanged.
|
|
222
|
-
if (pathname.startsWith("/_file/")) {
|
|
223
|
-
if (req.method === "OPTIONS") {
|
|
224
|
-
res.writeHead(204, fileCors);
|
|
225
|
-
res.end();
|
|
226
|
-
return;
|
|
227
|
-
}
|
|
228
|
-
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
229
|
-
res.writeHead(405, { ...fileCors, Allow: "GET, HEAD, OPTIONS" });
|
|
230
|
-
res.end();
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
const token = decodeURIComponent(pathname.slice("/_file/".length));
|
|
234
|
-
const destination = files.destinationFor(token);
|
|
235
|
-
if (!destination) {
|
|
236
|
-
// Not a URL this server handed out (or its presign has aged out). Reading from
|
|
237
|
-
// anywhere else is the thing this design refuses to do.
|
|
238
|
-
process.stderr.write(`[file] ERROR unknown or expired token ${token}\n`);
|
|
239
|
-
res.writeHead(404, { ...fileCors, "Content-Type": "application/json" });
|
|
240
|
-
res.end(JSON.stringify({ message: "No file for this token" }));
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
try {
|
|
244
|
-
// Forward Range verbatim: a PDF reader seeks rather than reading the whole file,
|
|
245
|
-
// and media scrubbing depends on 206s coming back intact.
|
|
246
|
-
const range = req.headers.range;
|
|
247
|
-
const upstream = await fetch(destination, {
|
|
248
|
-
method: req.method,
|
|
249
|
-
headers: range ? { Range: range } : undefined,
|
|
250
|
-
});
|
|
251
|
-
const headers = { ...fileCors };
|
|
252
|
-
for (const name of PASS_THROUGH) {
|
|
253
|
-
const value = upstream.headers.get(name);
|
|
254
|
-
if (value)
|
|
255
|
-
headers[name] = value;
|
|
256
|
-
}
|
|
257
|
-
// Same one-line-per-transfer visibility as [rpc] and [upload] — a preview that
|
|
258
|
-
// silently serves nothing is exactly the thing this relay exists to make legible.
|
|
259
|
-
const size = headers["content-length"] ?? "?";
|
|
260
|
-
process.stderr.write(`[file] ${token} ${upstream.status} ${size}B\n`);
|
|
261
|
-
res.writeHead(upstream.status, headers);
|
|
262
|
-
if (req.method === "HEAD" || !upstream.body) {
|
|
263
|
-
res.end();
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
const reader = upstream.body.getReader();
|
|
267
|
-
for (;;) {
|
|
268
|
-
const { value, done } = await reader.read();
|
|
269
|
-
if (done)
|
|
270
|
-
break;
|
|
271
|
-
res.write(Buffer.from(value));
|
|
272
|
-
}
|
|
273
|
-
res.end();
|
|
274
|
-
}
|
|
275
|
-
catch (err) {
|
|
276
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
277
|
-
process.stderr.write(`[file] ERROR ${token} ${message}\n`);
|
|
278
|
-
if (!res.headersSent) {
|
|
279
|
-
res.writeHead(502, { ...fileCors, "Content-Type": "application/json" });
|
|
280
|
-
res.end(JSON.stringify({ message }));
|
|
281
|
-
}
|
|
282
|
-
else {
|
|
283
|
-
res.end();
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
return;
|
|
287
|
-
}
|
|
288
|
-
// Upload relay — the wrapper page PUTs the bytes here (same-origin, so the
|
|
289
|
-
// browser never applies CORS), and Node forwards them to the presigned URL.
|
|
290
|
-
if (req.method === "PUT" && pathname.startsWith("/_upload/")) {
|
|
291
|
-
const fileId = decodeURIComponent(pathname.slice("/_upload/".length));
|
|
292
|
-
const destination = uploads.destinationFor(fileId);
|
|
293
|
-
if (!destination) {
|
|
294
|
-
// Not a URL this server minted (or its presign has aged out). Relaying
|
|
295
|
-
// anywhere else is exactly the thing this design refuses to do.
|
|
296
|
-
process.stderr.write(`[upload] ERROR unknown or expired file_id ${fileId}\n`);
|
|
297
|
-
res.writeHead(404, { "Content-Type": "application/json" });
|
|
298
|
-
res.end(JSON.stringify({ message: `No presigned upload pending for ${fileId}` }));
|
|
299
|
-
return;
|
|
300
|
-
}
|
|
301
|
-
try {
|
|
302
|
-
// Buffer rather than stream: the presign signs `content-length`, and a
|
|
303
|
-
// streamed body would go out chunked and fail the signature. Dev-only,
|
|
304
|
-
// and the size ceiling is the API's own upload limit.
|
|
305
|
-
const chunks = [];
|
|
306
|
-
for await (const chunk of req)
|
|
307
|
-
chunks.push(chunk);
|
|
308
|
-
const bytes = Buffer.concat(chunks);
|
|
309
|
-
const startedAt = Date.now();
|
|
310
|
-
const upstream = await fetch(destination, {
|
|
311
|
-
method: "PUT",
|
|
312
|
-
body: bytes,
|
|
313
|
-
headers: { "Content-Type": req.headers["content-type"] ?? "application/octet-stream" },
|
|
314
|
-
});
|
|
315
|
-
const ms = Date.now() - startedAt;
|
|
316
|
-
if (upstream.ok) {
|
|
317
|
-
uploads.settle(fileId);
|
|
318
|
-
process.stderr.write(`[upload] ${fileId} ${bytes.length}B ${ms}ms\n`);
|
|
319
|
-
}
|
|
320
|
-
else {
|
|
321
|
-
// Keep the mint: the wrapper retries 5xx, and the presign is still valid.
|
|
322
|
-
process.stderr.write(`[upload] ERROR ${fileId} storage returned ${upstream.status}\n`);
|
|
323
|
-
}
|
|
324
|
-
res.writeHead(upstream.status);
|
|
325
|
-
res.end();
|
|
326
|
-
}
|
|
327
|
-
catch (err) {
|
|
328
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
329
|
-
process.stderr.write(`[upload] ERROR ${fileId} ${message}\n`);
|
|
330
|
-
if (!res.headersSent) {
|
|
331
|
-
res.writeHead(502, { "Content-Type": "application/json" });
|
|
332
|
-
res.end(JSON.stringify({ message }));
|
|
333
|
-
}
|
|
334
|
-
else {
|
|
335
|
-
res.end();
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
return;
|
|
339
|
-
}
|
|
340
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
341
|
-
res.end("Not Found");
|
|
342
|
-
});
|
|
343
|
-
await new Promise((resolve, reject) => {
|
|
344
|
-
server.once("error", reject);
|
|
345
|
-
// Loopback ONLY — never 0.0.0.0. `/_rpc` dispatches with the developer's API key, so a
|
|
346
|
-
// server bound to every interface hands anyone on the same network full read/write on the
|
|
347
|
-
// workspace (and, since the relays, the file bytes too). Vite already binds loopback; this
|
|
348
|
-
// socket is strictly more sensitive than that one.
|
|
349
|
-
server.listen(wrapperPort, "127.0.0.1", () => {
|
|
350
|
-
server.off("error", reject);
|
|
351
|
-
resolve();
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
355
|
-
async function stopAll() {
|
|
356
|
-
if (stopped)
|
|
357
|
-
return;
|
|
358
|
-
stopped = true;
|
|
359
|
-
if (!viteChild.killed) {
|
|
360
|
-
viteChild.kill("SIGINT");
|
|
361
|
-
}
|
|
362
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
363
|
-
stoppingResolve?.();
|
|
364
|
-
}
|
|
365
|
-
// Vite startup is async — we don't get a "ready" signal cleanly across
|
|
366
|
-
// versions, so we wait for a fixed grace period before resolving `ready`.
|
|
367
|
-
// The wrapper page handles iframe load failures (Vite still warming up =
|
|
368
|
-
// the iframe shows a brief refused-connection until Vite responds).
|
|
369
|
-
const ready = new Promise((resolve) => {
|
|
370
|
-
setTimeout(resolve, 800);
|
|
371
|
-
});
|
|
372
|
-
// Expose stoppingPromise on stop() so callers can await it cleanly.
|
|
373
|
-
return {
|
|
374
|
-
port: wrapperPort,
|
|
375
|
-
vitePort,
|
|
376
|
-
ready,
|
|
377
|
-
stop: async () => {
|
|
378
|
-
await stopAll();
|
|
379
|
-
await stoppingPromise;
|
|
380
|
-
},
|
|
381
|
-
};
|
|
382
|
-
}
|
|
383
|
-
// ── Helpers ────────────────────────────────────────────────────────────────
|
|
384
|
-
async function pickPort(preferred, ...avoid) {
|
|
385
|
-
// Try preferred first. If it's taken or in `avoid`, ask the OS for any
|
|
386
|
-
// free port via listen(0).
|
|
387
|
-
if (!avoid.includes(preferred) && (await isPortFree(preferred))) {
|
|
388
|
-
return preferred;
|
|
389
|
-
}
|
|
390
|
-
return new Promise((resolve, reject) => {
|
|
391
|
-
const srv = net.createServer();
|
|
392
|
-
srv.once("error", reject);
|
|
393
|
-
srv.listen(0, () => {
|
|
394
|
-
const addr = srv.address();
|
|
395
|
-
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
396
|
-
srv.close(() => (port ? resolve(port) : reject(new Error("Failed to pick port"))));
|
|
397
|
-
});
|
|
398
|
-
});
|
|
399
|
-
}
|
|
400
|
-
function isPortFree(port) {
|
|
401
|
-
return new Promise((resolve) => {
|
|
402
|
-
const srv = net.createServer();
|
|
403
|
-
srv.once("error", () => resolve(false));
|
|
404
|
-
srv.listen(port, () => {
|
|
405
|
-
srv.close(() => resolve(true));
|
|
406
|
-
});
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
async function readJson(req) {
|
|
410
|
-
const chunks = [];
|
|
411
|
-
for await (const chunk of req) {
|
|
412
|
-
chunks.push(chunk);
|
|
413
|
-
}
|
|
414
|
-
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
415
|
-
if (!raw)
|
|
416
|
-
throw new Error("empty request body");
|
|
417
|
-
const parsed = JSON.parse(raw);
|
|
418
|
-
if (typeof parsed.app_id !== "string" || typeof parsed.op !== "string") {
|
|
419
|
-
throw new Error("body must include app_id and op");
|
|
420
|
-
}
|
|
421
|
-
return parsed;
|
|
422
|
-
}
|
|
423
|
-
/** Used by the CLI command for cross-platform `open <url>`. Best-effort —
|
|
424
|
-
* if the platform opener isn't installed (common in WSL, headless CI, some
|
|
425
|
-
* Linux minimal images), we log a note and keep the dev server running so
|
|
426
|
-
* the developer can copy/paste the URL manually. */
|
|
427
|
-
export function openBrowser(url) {
|
|
428
|
-
const cmd = process.platform === "darwin"
|
|
429
|
-
? "open"
|
|
430
|
-
: process.platform === "win32"
|
|
431
|
-
? "start"
|
|
432
|
-
: "xdg-open";
|
|
433
|
-
const child = spawn(cmd, [url], { stdio: "ignore", detached: true });
|
|
434
|
-
child.on("error", (err) => {
|
|
435
|
-
if (err.code === "ENOENT") {
|
|
436
|
-
process.stderr.write(`Note: ${cmd} not available — open ${url} manually.\n`);
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
439
|
-
process.stderr.write(`Note: could not auto-open browser (${err.message}).\n`);
|
|
440
|
-
}
|
|
441
|
-
});
|
|
442
|
-
child.unref();
|
|
443
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev-only upload relay: the bookkeeping that lets `lotics app dev` upload a file.
|
|
3
|
-
*
|
|
4
|
-
* Dev runs against the PROD bucket (there is no dev bucket), whose CORS allowlist
|
|
5
|
-
* holds the real app origins (`https://*.lotics.app`), not `http://localhost:<port>`.
|
|
6
|
-
* A browser PUT straight to the presigned URL is blocked before it leaves the page,
|
|
7
|
-
* so without a relay no file-touching app can be exercised locally at all.
|
|
8
|
-
*
|
|
9
|
-
* The dev server therefore relays the bytes: the wrapper page PUTs same-origin (no
|
|
10
|
-
* preflight, no CORS) and Node — which has no same-origin policy — forwards them on.
|
|
11
|
-
*
|
|
12
|
-
* The security property, and the reason the page never names its destination: a
|
|
13
|
-
* relay that forwarded to a client-supplied URL would be an open proxy. So the page
|
|
14
|
-
* sends only a `file_id`, and the relay writes ONLY to a presigned URL it minted
|
|
15
|
-
* itself for that id, moments earlier, via its own authenticated API call. There is
|
|
16
|
-
* no client-controlled target — hence nothing to allowlist, and no SSRF surface.
|
|
17
|
-
*
|
|
18
|
-
* Production is untouched: it PUTs direct-to-storage, keeping every byte off the
|
|
19
|
-
* API server.
|
|
20
|
-
*/
|
|
21
|
-
export interface UploadRelay {
|
|
22
|
-
/**
|
|
23
|
-
* Record the presigned PUT from an `upload_url` mint and return the result the
|
|
24
|
-
* page should see — identical but for a same-origin `upload_url`. A result that
|
|
25
|
-
* carries no mint passes through untouched: nothing is recorded, nothing is
|
|
26
|
-
* rewritten, and the page's PUT fails loudly rather than uploading into a void.
|
|
27
|
-
*/
|
|
28
|
-
rewriteMint(result: unknown): unknown;
|
|
29
|
-
/** The presigned URL minted for this id — `null` if unknown or expired. */
|
|
30
|
-
destinationFor(fileId: string): string | null;
|
|
31
|
-
/** Drop a mint once its bytes have landed. One presign, one object. */
|
|
32
|
-
settle(fileId: string): void;
|
|
33
|
-
/** Outstanding mints — for tests. */
|
|
34
|
-
size(): number;
|
|
35
|
-
}
|
|
36
|
-
export declare function createUploadRelay(now?: () => number): UploadRelay;
|
package/dist/dev/upload_relay.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dev-only upload relay: the bookkeeping that lets `lotics app dev` upload a file.
|
|
3
|
-
*
|
|
4
|
-
* Dev runs against the PROD bucket (there is no dev bucket), whose CORS allowlist
|
|
5
|
-
* holds the real app origins (`https://*.lotics.app`), not `http://localhost:<port>`.
|
|
6
|
-
* A browser PUT straight to the presigned URL is blocked before it leaves the page,
|
|
7
|
-
* so without a relay no file-touching app can be exercised locally at all.
|
|
8
|
-
*
|
|
9
|
-
* The dev server therefore relays the bytes: the wrapper page PUTs same-origin (no
|
|
10
|
-
* preflight, no CORS) and Node — which has no same-origin policy — forwards them on.
|
|
11
|
-
*
|
|
12
|
-
* The security property, and the reason the page never names its destination: a
|
|
13
|
-
* relay that forwarded to a client-supplied URL would be an open proxy. So the page
|
|
14
|
-
* sends only a `file_id`, and the relay writes ONLY to a presigned URL it minted
|
|
15
|
-
* itself for that id, moments earlier, via its own authenticated API call. There is
|
|
16
|
-
* no client-controlled target — hence nothing to allowlist, and no SSRF surface.
|
|
17
|
-
*
|
|
18
|
-
* Production is untouched: it PUTs direct-to-storage, keeping every byte off the
|
|
19
|
-
* API server.
|
|
20
|
-
*/
|
|
21
|
-
/**
|
|
22
|
-
* How long a mint is worth keeping. This is a pruning window, not a correctness gate —
|
|
23
|
-
* storage is the authority on whether a presign is still valid (an expired one is refused
|
|
24
|
-
* there, and the wrapper surfaces that). It only needs to outlive the presign (~10 min) so
|
|
25
|
-
* the relay never 404s a PUT that storage would still have accepted.
|
|
26
|
-
*/
|
|
27
|
-
const PRESIGN_TTL_MS = 15 * 60 * 1000;
|
|
28
|
-
export function createUploadRelay(now = Date.now) {
|
|
29
|
-
const minted = new Map();
|
|
30
|
-
const prune = (at) => {
|
|
31
|
-
for (const [id, entry] of minted) {
|
|
32
|
-
if (at - entry.mintedAt > PRESIGN_TTL_MS)
|
|
33
|
-
minted.delete(id);
|
|
34
|
-
}
|
|
35
|
-
};
|
|
36
|
-
return {
|
|
37
|
-
rewriteMint(result) {
|
|
38
|
-
const mint = result;
|
|
39
|
-
if (typeof mint?.file_id !== "string" || typeof mint?.upload_url !== "string")
|
|
40
|
-
return result;
|
|
41
|
-
const at = now();
|
|
42
|
-
prune(at);
|
|
43
|
-
minted.set(mint.file_id, { url: mint.upload_url, mintedAt: at });
|
|
44
|
-
return { ...mint, upload_url: `/_upload/${encodeURIComponent(mint.file_id)}` };
|
|
45
|
-
},
|
|
46
|
-
destinationFor(fileId) {
|
|
47
|
-
const entry = minted.get(fileId);
|
|
48
|
-
if (!entry)
|
|
49
|
-
return null;
|
|
50
|
-
if (now() - entry.mintedAt > PRESIGN_TTL_MS) {
|
|
51
|
-
minted.delete(fileId);
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
return entry.url;
|
|
55
|
-
},
|
|
56
|
-
settle(fileId) {
|
|
57
|
-
minted.delete(fileId);
|
|
58
|
-
},
|
|
59
|
-
size: () => minted.size,
|
|
60
|
-
};
|
|
61
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { createUploadRelay } from "./upload_relay.js";
|
|
3
|
-
const MINT = {
|
|
4
|
-
file_id: "fil_abc",
|
|
5
|
-
file_storage_key: "org/files/fil_abc/photo.jpg",
|
|
6
|
-
upload_url: "https://production.r2.cloudflarestorage.com/org/files/fil_abc/photo.jpg?X-Amz-Signature=deadbeef",
|
|
7
|
-
};
|
|
8
|
-
describe("upload relay — the page PUTs same-origin", () => {
|
|
9
|
-
it("hands the page a same-origin URL and keeps everything else it needs to finalize", () => {
|
|
10
|
-
const relay = createUploadRelay();
|
|
11
|
-
const seen = relay.rewriteMint(MINT);
|
|
12
|
-
expect(seen.upload_url).toBe("/_upload/fil_abc");
|
|
13
|
-
// file_storage_key + file_id still reach the page — `upload_complete` needs both.
|
|
14
|
-
expect(seen.file_id).toBe("fil_abc");
|
|
15
|
-
expect(seen.file_storage_key).toBe(MINT.file_storage_key);
|
|
16
|
-
});
|
|
17
|
-
it("does not mutate the mint it was handed", () => {
|
|
18
|
-
const relay = createUploadRelay();
|
|
19
|
-
relay.rewriteMint(MINT);
|
|
20
|
-
expect(MINT.upload_url).toContain("r2.cloudflarestorage.com");
|
|
21
|
-
});
|
|
22
|
-
});
|
|
23
|
-
describe("upload relay — it writes ONLY where it was told to by itself", () => {
|
|
24
|
-
it("relays to the presigned URL it minted for that id", () => {
|
|
25
|
-
const relay = createUploadRelay();
|
|
26
|
-
relay.rewriteMint(MINT);
|
|
27
|
-
expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
|
|
28
|
-
});
|
|
29
|
-
it("refuses an id it never minted — there is no client-supplied destination to honour", () => {
|
|
30
|
-
const relay = createUploadRelay();
|
|
31
|
-
expect(relay.destinationFor("fil_never_seen")).toBeNull();
|
|
32
|
-
});
|
|
33
|
-
it("refuses a mint that has aged past its presign", () => {
|
|
34
|
-
let clock = 1_000_000;
|
|
35
|
-
const relay = createUploadRelay(() => clock);
|
|
36
|
-
relay.rewriteMint(MINT);
|
|
37
|
-
clock += 16 * 60 * 1000; // presigns die at ~10 min; the relay holds 15
|
|
38
|
-
expect(relay.destinationFor("fil_abc")).toBeNull();
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
describe("upload relay — lifecycle", () => {
|
|
42
|
-
it("forgets a mint once its bytes have landed", () => {
|
|
43
|
-
const relay = createUploadRelay();
|
|
44
|
-
relay.rewriteMint(MINT);
|
|
45
|
-
relay.settle("fil_abc");
|
|
46
|
-
expect(relay.destinationFor("fil_abc")).toBeNull();
|
|
47
|
-
expect(relay.size()).toBe(0);
|
|
48
|
-
});
|
|
49
|
-
it("keeps the mint on a failed PUT so the page's retry can reuse it", () => {
|
|
50
|
-
const relay = createUploadRelay();
|
|
51
|
-
relay.rewriteMint(MINT);
|
|
52
|
-
// No settle() — the storage PUT 5xx'd and the wrapper page retries.
|
|
53
|
-
expect(relay.destinationFor("fil_abc")).toBe(MINT.upload_url);
|
|
54
|
-
});
|
|
55
|
-
it("prunes dead mints instead of growing forever across a long dev session", () => {
|
|
56
|
-
let clock = 0;
|
|
57
|
-
const relay = createUploadRelay(() => clock);
|
|
58
|
-
relay.rewriteMint({ ...MINT, file_id: "fil_old" });
|
|
59
|
-
clock += 16 * 60 * 1000;
|
|
60
|
-
relay.rewriteMint({ ...MINT, file_id: "fil_new" });
|
|
61
|
-
expect(relay.size()).toBe(1); // the stale one was swept on the next mint
|
|
62
|
-
expect(relay.destinationFor("fil_new")).toBe(MINT.upload_url);
|
|
63
|
-
});
|
|
64
|
-
});
|
|
65
|
-
describe("upload relay — a response that carries no mint", () => {
|
|
66
|
-
it("passes through untouched rather than inventing a relay for it", () => {
|
|
67
|
-
const relay = createUploadRelay();
|
|
68
|
-
const odd = { message: "upstream changed shape" };
|
|
69
|
-
expect(relay.rewriteMint(odd)).toBe(odd);
|
|
70
|
-
expect(relay.size()).toBe(0);
|
|
71
|
-
});
|
|
72
|
-
it("tolerates null without throwing", () => {
|
|
73
|
-
const relay = createUploadRelay();
|
|
74
|
-
expect(relay.rewriteMint(null)).toBeNull();
|
|
75
|
-
});
|
|
76
|
-
});
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Builds the wrapper HTML for `lotics app dev`.
|
|
3
|
-
*
|
|
4
|
-
* The wrapper page is served at http://localhost:<port>/ and embeds the
|
|
5
|
-
* project's Vite dev server in an iframe with the same
|
|
6
|
-
* sandbox="allow-scripts allow-same-origin allow-downloads" attribute
|
|
7
|
-
* production uses — safe because the Vite iframe is cross-origin to this
|
|
8
|
-
* wrapper (different port), so the app gets its own real origin (and Web
|
|
9
|
-
* Storage) without being able to reach the wrapper. `allow-downloads` lets
|
|
10
|
-
* apps trigger file downloads (generated xlsx / pdf etc.) — Chrome blocks
|
|
11
|
-
* every download path from sandboxed iframes without it. `allow="...; geolocation;
|
|
12
|
-
* web-share"` delegates the host's location permission (geofenced actions read the
|
|
13
|
-
* device position) and grants the Web Share API (apps call `navigator.share({ files })`
|
|
14
|
-
* to push e.g. container photos to the OS share sheet / gallery) — matching
|
|
15
|
-
* production. The iframe sends postMessage RPCs to
|
|
16
|
-
* this wrapper, which forwards them to the local /_rpc endpoint, which
|
|
17
|
-
* dispatches to api.lotics.ai with the CLI's API key.
|
|
18
|
-
*
|
|
19
|
-
* postMessage is origin-locked both ways — the wrapper passes its origin
|
|
20
|
-
* via `?lotics_host=` and talks only to the Vite origin.
|
|
21
|
-
*
|
|
22
|
-
* Protocol matches `frontend/features/app_ui/app_iframe_host.tsx` exactly:
|
|
23
|
-
* iframe → wrapper: { id: number, op: string, payload: unknown }
|
|
24
|
-
* wrapper → iframe: { id, type: "result", data } | { id, type: "error", message }
|
|
25
|
-
* streaming (op "agentRun"): { id, type: "stream-chunk", chunk } * → { id, type: "stream-end" };
|
|
26
|
-
* the iframe aborts with { id, type: "abort" }.
|
|
27
|
-
* urlState (useUrlState): the iframe reads/writes the wrapper's address bar via
|
|
28
|
-
* urlState.get/set; set writes in place (replaceState), and browser
|
|
29
|
-
* back/forward broadcast { type: "url-state", params } back to the iframe.
|
|
30
|
-
* (In-app routing isn't here — the app owns the iframe's own url; see
|
|
31
|
-
* @lotics/app-sdk/router.)
|
|
32
|
-
*/
|
|
33
|
-
export interface WrapperPageArgs {
|
|
34
|
-
app_name: string;
|
|
35
|
-
app_id: string;
|
|
36
|
-
workspace_id: string;
|
|
37
|
-
vite_url: string;
|
|
38
|
-
api_url: string;
|
|
39
|
-
}
|
|
40
|
-
export declare function buildWrapperPage(args: WrapperPageArgs): string;
|