rastack 0.0.57 → 0.0.58
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/CHANGELOG.md +2 -0
- package/admin/index.tsx +19 -13
- package/admin/ui/BootScreen.tsx +201 -0
- package/dist/admin.js +156 -18
- package/dist/dev/harness.js +20 -3
- package/dist/rastack-dev.js +190 -4
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/package.json +2 -1
- package/src/dev/harness.ts +20 -3
- package/src/rastack-dev.ts +222 -6
- package/wasm/rastack_wasm_bg.wasm +0 -0
package/dist/dev/harness.js
CHANGED
|
@@ -202,13 +202,30 @@ function renderShell(title = "React API Stack — admin") {
|
|
|
202
202
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
203
203
|
<title>${title}</title>
|
|
204
204
|
<style>
|
|
205
|
-
html, body { margin: 0; height: 100%;
|
|
205
|
+
html, body { margin: 0; height: 100%; background: #F2F5FB;
|
|
206
|
+
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
207
|
+
@media (prefers-color-scheme: dark) { html, body { background: #06080F; } }
|
|
206
208
|
#root { min-height: 100%; }
|
|
207
|
-
.rastack-boot {
|
|
209
|
+
.rastack-boot { min-height: 100vh; display: flex; flex-direction: column;
|
|
210
|
+
align-items: center; justify-content: center; gap: 20px; }
|
|
211
|
+
.rastack-boot-logo { width: 60px; height: 60px; border-radius: 18px; background: #4F6BFF;
|
|
212
|
+
box-shadow: 0 8px 24px rgba(79,107,255,0.30); animation: rastack-boot-bob 3s ease-in-out infinite; }
|
|
213
|
+
.rastack-boot-track { position: relative; width: 168px; height: 4px; border-radius: 999px;
|
|
214
|
+
overflow: hidden; background: rgba(11,16,32,0.10); }
|
|
215
|
+
@media (prefers-color-scheme: dark) { .rastack-boot-track { background: rgba(233,239,255,0.14); } }
|
|
216
|
+
.rastack-boot-track::after { content: ""; position: absolute; top: 0; left: -42%; height: 100%;
|
|
217
|
+
width: 40%; border-radius: inherit; background: linear-gradient(90deg, transparent, #4F6BFF);
|
|
218
|
+
animation: rastack-boot-slide 1.25s cubic-bezier(0.5,0.1,0.3,1) infinite; }
|
|
219
|
+
@keyframes rastack-boot-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
|
220
|
+
@keyframes rastack-boot-slide { to { left: 100%; } }
|
|
221
|
+
@media (prefers-reduced-motion: reduce) {
|
|
222
|
+
.rastack-boot-logo { animation: none; }
|
|
223
|
+
.rastack-boot-track::after { animation: none; left: 0; width: 100%; }
|
|
224
|
+
}
|
|
208
225
|
</style>
|
|
209
226
|
</head>
|
|
210
227
|
<body>
|
|
211
|
-
<div id="root"><div class="rastack-boot"
|
|
228
|
+
<div id="root"><div class="rastack-boot"><div class="rastack-boot-logo"></div><div class="rastack-boot-track"></div></div></div>
|
|
212
229
|
<script src="/dev/admin.js"></script>
|
|
213
230
|
</body>
|
|
214
231
|
</html>
|
package/dist/rastack-dev.js
CHANGED
|
@@ -57,6 +57,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
57
57
|
const child_process_1 = require("child_process");
|
|
58
58
|
const fs = __importStar(require("fs"));
|
|
59
59
|
const http = __importStar(require("http"));
|
|
60
|
+
const net = __importStar(require("net"));
|
|
60
61
|
const path = __importStar(require("path"));
|
|
61
62
|
const compile_1 = require("./compile");
|
|
62
63
|
const harness_1 = require("./dev/harness");
|
|
@@ -144,14 +145,198 @@ function openBrowser(url) {
|
|
|
144
145
|
/* non-fatal — the URL is printed anyway */
|
|
145
146
|
}
|
|
146
147
|
}
|
|
147
|
-
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Live-dev mode — `rastack dev` for an Expo / react-native-web app.
|
|
150
|
+
//
|
|
151
|
+
// A React Native (Expo) app has no static `index.html`: its web build is a
|
|
152
|
+
// Metro bundle. Rather than make the developer run a second tool, `dev` starts
|
|
153
|
+
// Expo's Metro dev server itself and reverse-proxies to it, so `rastack dev`
|
|
154
|
+
// stays the single command *and* keeps Metro's fast-refresh. The WASM
|
|
155
|
+
// static-serve path remains the default for apps that ship a built index.html.
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
/** True when the project is an Expo app (so `dev` should drive Metro). */
|
|
158
|
+
function isExpoProject(cwd) {
|
|
159
|
+
try {
|
|
160
|
+
const appJson = JSON.parse(fs.readFileSync(path.join(cwd, "app.json"), "utf-8"));
|
|
161
|
+
if (appJson && appJson.expo)
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
/* no app.json — fall through to package.json */
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf-8"));
|
|
169
|
+
if (pkg?.dependencies?.expo || pkg?.devDependencies?.expo)
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* no package.json */
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
/** Locate the prebuilt wasm bundle without the cargo fallback (optional here). */
|
|
178
|
+
function findWasmDir() {
|
|
179
|
+
for (const dir of (0, harness_1.wasmBundleCandidates)(HERE)) {
|
|
180
|
+
if (fs.existsSync(path.join(dir, harness_1.WASM_ENTRY)))
|
|
181
|
+
return dir;
|
|
182
|
+
}
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
/** Grab an ephemeral free TCP port for Metro (avoids a fixed-port clash). */
|
|
186
|
+
function getFreePort() {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const srv = net.createServer();
|
|
189
|
+
srv.on("error", reject);
|
|
190
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
191
|
+
const addr = srv.address();
|
|
192
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
193
|
+
srv.close(() => (port ? resolve(port) : reject(new Error("no free port"))));
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
/** Start `expo start --web` on `port`, with a reload-enabled environment. */
|
|
198
|
+
function startMetro(cwd, port) {
|
|
199
|
+
// A live dev server needs watch mode, so we must NOT set CI (Expo reads it as
|
|
200
|
+
// "reloads disabled"). BROWSER=none stops Expo opening its own tab — we front
|
|
201
|
+
// it on the rastack port. `shell:true` keeps the npx / npx.cmd split portable.
|
|
202
|
+
const env = {
|
|
203
|
+
...process.env,
|
|
204
|
+
BROWSER: "none",
|
|
205
|
+
EXPO_NO_TELEMETRY: "1",
|
|
206
|
+
};
|
|
207
|
+
delete env.CI;
|
|
208
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
209
|
+
const child = (0, child_process_1.spawn)(`npx expo start --web --port ${port}`, {
|
|
210
|
+
cwd,
|
|
211
|
+
env,
|
|
212
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
213
|
+
shell: true,
|
|
214
|
+
});
|
|
215
|
+
// Surface Metro's own errors (bundling failures, port issues) with a prefix.
|
|
216
|
+
child.stderr?.on("data", (d) => process.stderr.write(` [metro] ${d}`));
|
|
217
|
+
return child;
|
|
218
|
+
}
|
|
219
|
+
/** Poll Metro's `/status` until it answers `packager-status:running`. */
|
|
220
|
+
function waitForMetro(port, timeoutMs = 120000) {
|
|
221
|
+
const deadline = Date.now() + timeoutMs;
|
|
222
|
+
return new Promise((resolve) => {
|
|
223
|
+
const retry = () => Date.now() > deadline ? resolve(false) : setTimeout(tick, 400);
|
|
224
|
+
const tick = () => {
|
|
225
|
+
const req = http.get({ host: "127.0.0.1", port, path: "/status", timeout: 2000 }, (res) => {
|
|
226
|
+
let body = "";
|
|
227
|
+
res.on("data", (c) => (body += c));
|
|
228
|
+
res.on("end", () => body.includes("packager-status:running") ? resolve(true) : retry());
|
|
229
|
+
});
|
|
230
|
+
req.on("error", retry);
|
|
231
|
+
req.on("timeout", () => req.destroy());
|
|
232
|
+
};
|
|
233
|
+
tick();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** Reverse-proxy one HTTP request/response pair to Metro. */
|
|
237
|
+
function proxyToMetro(req, res, port) {
|
|
238
|
+
const proxyReq = http.request({ host: "127.0.0.1", port, method: req.method, path: req.url, headers: req.headers }, (proxyRes) => {
|
|
239
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
240
|
+
proxyRes.pipe(res);
|
|
241
|
+
});
|
|
242
|
+
proxyReq.on("error", () => {
|
|
243
|
+
if (!res.headersSent)
|
|
244
|
+
res.writeHead(502);
|
|
245
|
+
res.end("Metro is not reachable.");
|
|
246
|
+
});
|
|
247
|
+
req.pipe(proxyReq);
|
|
248
|
+
}
|
|
249
|
+
/** Reverse-proxy a WebSocket upgrade (Metro HMR / fast-refresh) to Metro. */
|
|
250
|
+
function proxyUpgrade(req, clientSocket, head, port) {
|
|
251
|
+
const upstream = net.connect(port, "127.0.0.1", () => {
|
|
252
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
253
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
254
|
+
if (Array.isArray(v))
|
|
255
|
+
v.forEach((vv) => lines.push(`${k}: ${vv}`));
|
|
256
|
+
else if (v != null)
|
|
257
|
+
lines.push(`${k}: ${v}`);
|
|
258
|
+
}
|
|
259
|
+
upstream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
260
|
+
if (head && head.length)
|
|
261
|
+
upstream.write(head);
|
|
262
|
+
upstream.pipe(clientSocket);
|
|
263
|
+
clientSocket.pipe(upstream);
|
|
264
|
+
});
|
|
265
|
+
upstream.on("error", () => clientSocket.destroy());
|
|
266
|
+
clientSocket.on("error", () => upstream.destroy());
|
|
267
|
+
}
|
|
268
|
+
/** `rastack dev` for an Expo app: start Metro and reverse-proxy to it. */
|
|
269
|
+
async function runMetroMode(args, cwd) {
|
|
270
|
+
// The wasm engine is optional here — an Expo/RN-web app runs its own engine —
|
|
271
|
+
// so a missing bundle is fine. When present, the reserved backend routes work.
|
|
272
|
+
const wasmDir = findWasmDir();
|
|
273
|
+
const metroPort = await getFreePort();
|
|
274
|
+
console.log(`▸ Starting Metro bundler (Expo) on :${metroPort} …`);
|
|
275
|
+
const metro = startMetro(cwd, metroPort);
|
|
276
|
+
let shuttingDown = false;
|
|
277
|
+
const stop = (code = 0) => {
|
|
278
|
+
if (shuttingDown)
|
|
279
|
+
return;
|
|
280
|
+
shuttingDown = true;
|
|
281
|
+
try {
|
|
282
|
+
metro.kill();
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
/* already gone */
|
|
286
|
+
}
|
|
287
|
+
process.exit(code);
|
|
288
|
+
};
|
|
289
|
+
process.on("SIGINT", () => stop(0));
|
|
290
|
+
process.on("SIGTERM", () => stop(0));
|
|
291
|
+
metro.on("exit", (code) => {
|
|
292
|
+
if (!shuttingDown) {
|
|
293
|
+
console.error(`\n ✗ Metro exited (code ${code ?? "?"}).`);
|
|
294
|
+
process.exit(code ?? 1);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
if (!(await waitForMetro(metroPort))) {
|
|
298
|
+
console.error(" ✗ Metro did not come up in time.");
|
|
299
|
+
return stop(1);
|
|
300
|
+
}
|
|
301
|
+
const server = http.createServer((req, res) => {
|
|
302
|
+
const url = (req.url || "/").split("?")[0];
|
|
303
|
+
// Reserved backend routes win when the engine bundle is available.
|
|
304
|
+
if (wasmDir && url.startsWith("/wasm/"))
|
|
305
|
+
return serveUnder(res, wasmDir, url.slice("/wasm/".length));
|
|
306
|
+
if (url === "/schema.rastack.json" || url === "/openapi.json")
|
|
307
|
+
return serveUnder(res, args.outDir, path.basename(url));
|
|
308
|
+
if (url.startsWith("/data/warehouse/"))
|
|
309
|
+
return serveUnder(res, args.warehouseDir, url.slice("/data/warehouse/".length));
|
|
310
|
+
// Everything else → the live Metro bundle (HTML, JS, assets, HMR polls).
|
|
311
|
+
return proxyToMetro(req, res, metroPort);
|
|
312
|
+
});
|
|
313
|
+
server.on("upgrade", (req, socket, head) => proxyUpgrade(req, socket, head, metroPort));
|
|
314
|
+
server.listen(args.port, () => {
|
|
315
|
+
const url = `http://localhost:${args.port}`;
|
|
316
|
+
console.log(` ✓ Metro live on :${metroPort} — fast refresh on`);
|
|
317
|
+
if (wasmDir)
|
|
318
|
+
console.log(` ✓ WASM engine: ${path.relative(cwd, wasmDir)}`);
|
|
319
|
+
console.log(`\n ▸ ${url}\n`);
|
|
320
|
+
console.log(" Edits reload live. Need the database console? Run `rastack admin`. Ctrl+C to stop.\n");
|
|
321
|
+
if (args.open)
|
|
322
|
+
openBrowser(url);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
async function main(argv) {
|
|
148
326
|
const args = (0, harness_1.parseDevArgs)(argv);
|
|
327
|
+
const cwd = process.cwd();
|
|
149
328
|
console.log(`\n rastack dev — your app, in your browser over WASM\n`);
|
|
150
329
|
console.log(`▸ Compiling ${args.resourcesDir} → ${args.outDir}`);
|
|
151
330
|
if (!(0, compile_1.runCompile)(args.resourcesDir, args.outDir)) {
|
|
152
331
|
console.error(" ✗ compile failed — fix the resource graph and re-run.");
|
|
153
332
|
process.exit(1);
|
|
154
333
|
}
|
|
334
|
+
// Serve the developer's own app when there is one; otherwise the welcome page.
|
|
335
|
+
const appRoot = resolveAppRoot(args.appDir);
|
|
336
|
+
// No static build, but an Expo source tree → drive Metro live (fast refresh).
|
|
337
|
+
if (!appRoot && isExpoProject(cwd)) {
|
|
338
|
+
return runMetroMode(args, cwd);
|
|
339
|
+
}
|
|
155
340
|
const wasmDir = resolveWasmDir();
|
|
156
341
|
if (!wasmDir) {
|
|
157
342
|
console.error("\n ✗ No WASM engine available.\n" +
|
|
@@ -161,8 +346,6 @@ function main(argv) {
|
|
|
161
346
|
" cargo install wasm-bindgen-cli\n");
|
|
162
347
|
process.exit(1);
|
|
163
348
|
}
|
|
164
|
-
// Serve the developer's own app when there is one; otherwise the welcome page.
|
|
165
|
-
const appRoot = resolveAppRoot(args.appDir);
|
|
166
349
|
const server = http.createServer((req, res) => {
|
|
167
350
|
const url = (req.url || "/").split("?")[0];
|
|
168
351
|
if (req.method !== "GET") {
|
|
@@ -213,5 +396,8 @@ function main(argv) {
|
|
|
213
396
|
});
|
|
214
397
|
}
|
|
215
398
|
if (require.main === module) {
|
|
216
|
-
main(process.argv.slice(2))
|
|
399
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
400
|
+
console.error(err);
|
|
401
|
+
process.exit(1);
|
|
402
|
+
});
|
|
217
403
|
}
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rastack",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.58",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "runtime.ts",
|
|
6
6
|
"types": "runtime.ts",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"scripts": {
|
|
15
15
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
|
16
16
|
"build": "tsc && cp src/.prettierrc dist/.prettierrc && node scripts/copy-templates.js && node scripts/build-admin.js && node scripts/copy-wasm.js",
|
|
17
|
+
"dev:install": "node scripts/dev-install.mjs",
|
|
17
18
|
"start": "node dist/index.js",
|
|
18
19
|
"test": "jest"
|
|
19
20
|
},
|
package/src/dev/harness.ts
CHANGED
|
@@ -191,13 +191,30 @@ export function renderShell(title = "React API Stack — admin"): string {
|
|
|
191
191
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
192
192
|
<title>${title}</title>
|
|
193
193
|
<style>
|
|
194
|
-
html, body { margin: 0; height: 100%;
|
|
194
|
+
html, body { margin: 0; height: 100%; background: #F2F5FB;
|
|
195
|
+
font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
196
|
+
@media (prefers-color-scheme: dark) { html, body { background: #06080F; } }
|
|
195
197
|
#root { min-height: 100%; }
|
|
196
|
-
.rastack-boot {
|
|
198
|
+
.rastack-boot { min-height: 100vh; display: flex; flex-direction: column;
|
|
199
|
+
align-items: center; justify-content: center; gap: 20px; }
|
|
200
|
+
.rastack-boot-logo { width: 60px; height: 60px; border-radius: 18px; background: #4F6BFF;
|
|
201
|
+
box-shadow: 0 8px 24px rgba(79,107,255,0.30); animation: rastack-boot-bob 3s ease-in-out infinite; }
|
|
202
|
+
.rastack-boot-track { position: relative; width: 168px; height: 4px; border-radius: 999px;
|
|
203
|
+
overflow: hidden; background: rgba(11,16,32,0.10); }
|
|
204
|
+
@media (prefers-color-scheme: dark) { .rastack-boot-track { background: rgba(233,239,255,0.14); } }
|
|
205
|
+
.rastack-boot-track::after { content: ""; position: absolute; top: 0; left: -42%; height: 100%;
|
|
206
|
+
width: 40%; border-radius: inherit; background: linear-gradient(90deg, transparent, #4F6BFF);
|
|
207
|
+
animation: rastack-boot-slide 1.25s cubic-bezier(0.5,0.1,0.3,1) infinite; }
|
|
208
|
+
@keyframes rastack-boot-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
|
|
209
|
+
@keyframes rastack-boot-slide { to { left: 100%; } }
|
|
210
|
+
@media (prefers-reduced-motion: reduce) {
|
|
211
|
+
.rastack-boot-logo { animation: none; }
|
|
212
|
+
.rastack-boot-track::after { animation: none; left: 0; width: 100%; }
|
|
213
|
+
}
|
|
197
214
|
</style>
|
|
198
215
|
</head>
|
|
199
216
|
<body>
|
|
200
|
-
<div id="root"><div class="rastack-boot"
|
|
217
|
+
<div id="root"><div class="rastack-boot"><div class="rastack-boot-logo"></div><div class="rastack-boot-track"></div></div></div>
|
|
201
218
|
<script src="/dev/admin.js"></script>
|
|
202
219
|
</body>
|
|
203
220
|
</html>
|
package/src/rastack-dev.ts
CHANGED
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
* but a Rust + wasm toolchain is, it is built from source as a fallback.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { execFileSync, spawnSync } from "child_process";
|
|
24
|
+
import { execFileSync, spawn, spawnSync, type ChildProcess } from "child_process";
|
|
25
25
|
import * as fs from "fs";
|
|
26
26
|
import * as http from "http";
|
|
27
|
+
import * as net from "net";
|
|
27
28
|
import * as path from "path";
|
|
28
29
|
import { runCompile } from "./compile";
|
|
29
30
|
import {
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
WASM_ENTRY,
|
|
32
33
|
appRootCandidates,
|
|
33
34
|
contentType,
|
|
35
|
+
type DevArgs,
|
|
34
36
|
parseDevArgs,
|
|
35
37
|
renderWelcome,
|
|
36
38
|
wasmBundleCandidates,
|
|
@@ -128,8 +130,214 @@ function openBrowser(url: string): void {
|
|
|
128
130
|
}
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Live-dev mode — `rastack dev` for an Expo / react-native-web app.
|
|
135
|
+
//
|
|
136
|
+
// A React Native (Expo) app has no static `index.html`: its web build is a
|
|
137
|
+
// Metro bundle. Rather than make the developer run a second tool, `dev` starts
|
|
138
|
+
// Expo's Metro dev server itself and reverse-proxies to it, so `rastack dev`
|
|
139
|
+
// stays the single command *and* keeps Metro's fast-refresh. The WASM
|
|
140
|
+
// static-serve path remains the default for apps that ship a built index.html.
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
/** True when the project is an Expo app (so `dev` should drive Metro). */
|
|
144
|
+
function isExpoProject(cwd: string): boolean {
|
|
145
|
+
try {
|
|
146
|
+
const appJson = JSON.parse(
|
|
147
|
+
fs.readFileSync(path.join(cwd, "app.json"), "utf-8"),
|
|
148
|
+
);
|
|
149
|
+
if (appJson && appJson.expo) return true;
|
|
150
|
+
} catch {
|
|
151
|
+
/* no app.json — fall through to package.json */
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const pkg = JSON.parse(
|
|
155
|
+
fs.readFileSync(path.join(cwd, "package.json"), "utf-8"),
|
|
156
|
+
);
|
|
157
|
+
if (pkg?.dependencies?.expo || pkg?.devDependencies?.expo) return true;
|
|
158
|
+
} catch {
|
|
159
|
+
/* no package.json */
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Locate the prebuilt wasm bundle without the cargo fallback (optional here). */
|
|
165
|
+
function findWasmDir(): string | null {
|
|
166
|
+
for (const dir of wasmBundleCandidates(HERE)) {
|
|
167
|
+
if (fs.existsSync(path.join(dir, WASM_ENTRY))) return dir;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Grab an ephemeral free TCP port for Metro (avoids a fixed-port clash). */
|
|
173
|
+
function getFreePort(): Promise<number> {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
const srv = net.createServer();
|
|
176
|
+
srv.on("error", reject);
|
|
177
|
+
srv.listen(0, "127.0.0.1", () => {
|
|
178
|
+
const addr = srv.address();
|
|
179
|
+
const port = addr && typeof addr === "object" ? addr.port : 0;
|
|
180
|
+
srv.close(() => (port ? resolve(port) : reject(new Error("no free port"))));
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Start `expo start --web` on `port`, with a reload-enabled environment. */
|
|
186
|
+
function startMetro(cwd: string, port: number): ChildProcess {
|
|
187
|
+
// A live dev server needs watch mode, so we must NOT set CI (Expo reads it as
|
|
188
|
+
// "reloads disabled"). BROWSER=none stops Expo opening its own tab — we front
|
|
189
|
+
// it on the rastack port. `shell:true` keeps the npx / npx.cmd split portable.
|
|
190
|
+
const env: NodeJS.ProcessEnv = {
|
|
191
|
+
...process.env,
|
|
192
|
+
BROWSER: "none",
|
|
193
|
+
EXPO_NO_TELEMETRY: "1",
|
|
194
|
+
};
|
|
195
|
+
delete env.CI;
|
|
196
|
+
// nosemgrep: javascript.lang.security.detect-child-process.detect-child-process
|
|
197
|
+
const child = spawn(`npx expo start --web --port ${port}`, {
|
|
198
|
+
cwd,
|
|
199
|
+
env,
|
|
200
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
201
|
+
shell: true,
|
|
202
|
+
});
|
|
203
|
+
// Surface Metro's own errors (bundling failures, port issues) with a prefix.
|
|
204
|
+
child.stderr?.on("data", (d) => process.stderr.write(` [metro] ${d}`));
|
|
205
|
+
return child;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Poll Metro's `/status` until it answers `packager-status:running`. */
|
|
209
|
+
function waitForMetro(port: number, timeoutMs = 120_000): Promise<boolean> {
|
|
210
|
+
const deadline = Date.now() + timeoutMs;
|
|
211
|
+
return new Promise((resolve) => {
|
|
212
|
+
const retry = () =>
|
|
213
|
+
Date.now() > deadline ? resolve(false) : setTimeout(tick, 400);
|
|
214
|
+
const tick = () => {
|
|
215
|
+
const req = http.get(
|
|
216
|
+
{ host: "127.0.0.1", port, path: "/status", timeout: 2000 },
|
|
217
|
+
(res) => {
|
|
218
|
+
let body = "";
|
|
219
|
+
res.on("data", (c) => (body += c));
|
|
220
|
+
res.on("end", () =>
|
|
221
|
+
body.includes("packager-status:running") ? resolve(true) : retry(),
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
req.on("error", retry);
|
|
226
|
+
req.on("timeout", () => req.destroy());
|
|
227
|
+
};
|
|
228
|
+
tick();
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Reverse-proxy one HTTP request/response pair to Metro. */
|
|
233
|
+
function proxyToMetro(
|
|
234
|
+
req: http.IncomingMessage,
|
|
235
|
+
res: http.ServerResponse,
|
|
236
|
+
port: number,
|
|
237
|
+
): void {
|
|
238
|
+
const proxyReq = http.request(
|
|
239
|
+
{ host: "127.0.0.1", port, method: req.method, path: req.url, headers: req.headers },
|
|
240
|
+
(proxyRes) => {
|
|
241
|
+
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
|
|
242
|
+
proxyRes.pipe(res);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
proxyReq.on("error", () => {
|
|
246
|
+
if (!res.headersSent) res.writeHead(502);
|
|
247
|
+
res.end("Metro is not reachable.");
|
|
248
|
+
});
|
|
249
|
+
req.pipe(proxyReq);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Reverse-proxy a WebSocket upgrade (Metro HMR / fast-refresh) to Metro. */
|
|
253
|
+
function proxyUpgrade(
|
|
254
|
+
req: http.IncomingMessage,
|
|
255
|
+
clientSocket: net.Socket,
|
|
256
|
+
head: Buffer,
|
|
257
|
+
port: number,
|
|
258
|
+
): void {
|
|
259
|
+
const upstream = net.connect(port, "127.0.0.1", () => {
|
|
260
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
261
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
262
|
+
if (Array.isArray(v)) v.forEach((vv) => lines.push(`${k}: ${vv}`));
|
|
263
|
+
else if (v != null) lines.push(`${k}: ${v}`);
|
|
264
|
+
}
|
|
265
|
+
upstream.write(lines.join("\r\n") + "\r\n\r\n");
|
|
266
|
+
if (head && head.length) upstream.write(head);
|
|
267
|
+
upstream.pipe(clientSocket);
|
|
268
|
+
clientSocket.pipe(upstream);
|
|
269
|
+
});
|
|
270
|
+
upstream.on("error", () => clientSocket.destroy());
|
|
271
|
+
clientSocket.on("error", () => upstream.destroy());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** `rastack dev` for an Expo app: start Metro and reverse-proxy to it. */
|
|
275
|
+
async function runMetroMode(args: DevArgs, cwd: string): Promise<void> {
|
|
276
|
+
// The wasm engine is optional here — an Expo/RN-web app runs its own engine —
|
|
277
|
+
// so a missing bundle is fine. When present, the reserved backend routes work.
|
|
278
|
+
const wasmDir = findWasmDir();
|
|
279
|
+
|
|
280
|
+
const metroPort = await getFreePort();
|
|
281
|
+
console.log(`▸ Starting Metro bundler (Expo) on :${metroPort} …`);
|
|
282
|
+
const metro = startMetro(cwd, metroPort);
|
|
283
|
+
|
|
284
|
+
let shuttingDown = false;
|
|
285
|
+
const stop = (code = 0) => {
|
|
286
|
+
if (shuttingDown) return;
|
|
287
|
+
shuttingDown = true;
|
|
288
|
+
try {
|
|
289
|
+
metro.kill();
|
|
290
|
+
} catch {
|
|
291
|
+
/* already gone */
|
|
292
|
+
}
|
|
293
|
+
process.exit(code);
|
|
294
|
+
};
|
|
295
|
+
process.on("SIGINT", () => stop(0));
|
|
296
|
+
process.on("SIGTERM", () => stop(0));
|
|
297
|
+
metro.on("exit", (code) => {
|
|
298
|
+
if (!shuttingDown) {
|
|
299
|
+
console.error(`\n ✗ Metro exited (code ${code ?? "?"}).`);
|
|
300
|
+
process.exit(code ?? 1);
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
if (!(await waitForMetro(metroPort))) {
|
|
305
|
+
console.error(" ✗ Metro did not come up in time.");
|
|
306
|
+
return stop(1);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const server = http.createServer((req, res) => {
|
|
310
|
+
const url = (req.url || "/").split("?")[0];
|
|
311
|
+
// Reserved backend routes win when the engine bundle is available.
|
|
312
|
+
if (wasmDir && url.startsWith("/wasm/"))
|
|
313
|
+
return serveUnder(res, wasmDir, url.slice("/wasm/".length));
|
|
314
|
+
if (url === "/schema.rastack.json" || url === "/openapi.json")
|
|
315
|
+
return serveUnder(res, args.outDir, path.basename(url));
|
|
316
|
+
if (url.startsWith("/data/warehouse/"))
|
|
317
|
+
return serveUnder(res, args.warehouseDir, url.slice("/data/warehouse/".length));
|
|
318
|
+
// Everything else → the live Metro bundle (HTML, JS, assets, HMR polls).
|
|
319
|
+
return proxyToMetro(req, res, metroPort);
|
|
320
|
+
});
|
|
321
|
+
server.on("upgrade", (req, socket, head) =>
|
|
322
|
+
proxyUpgrade(req, socket as net.Socket, head, metroPort),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
server.listen(args.port, () => {
|
|
326
|
+
const url = `http://localhost:${args.port}`;
|
|
327
|
+
console.log(` ✓ Metro live on :${metroPort} — fast refresh on`);
|
|
328
|
+
if (wasmDir)
|
|
329
|
+
console.log(` ✓ WASM engine: ${path.relative(cwd, wasmDir)}`);
|
|
330
|
+
console.log(`\n ▸ ${url}\n`);
|
|
331
|
+
console.log(
|
|
332
|
+
" Edits reload live. Need the database console? Run `rastack admin`. Ctrl+C to stop.\n",
|
|
333
|
+
);
|
|
334
|
+
if (args.open) openBrowser(url);
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function main(argv: string[]): Promise<void> {
|
|
132
339
|
const args = parseDevArgs(argv);
|
|
340
|
+
const cwd = process.cwd();
|
|
133
341
|
|
|
134
342
|
console.log(`\n rastack dev — your app, in your browser over WASM\n`);
|
|
135
343
|
console.log(`▸ Compiling ${args.resourcesDir} → ${args.outDir}`);
|
|
@@ -138,6 +346,14 @@ function main(argv: string[]): void {
|
|
|
138
346
|
process.exit(1);
|
|
139
347
|
}
|
|
140
348
|
|
|
349
|
+
// Serve the developer's own app when there is one; otherwise the welcome page.
|
|
350
|
+
const appRoot = resolveAppRoot(args.appDir);
|
|
351
|
+
|
|
352
|
+
// No static build, but an Expo source tree → drive Metro live (fast refresh).
|
|
353
|
+
if (!appRoot && isExpoProject(cwd)) {
|
|
354
|
+
return runMetroMode(args, cwd);
|
|
355
|
+
}
|
|
356
|
+
|
|
141
357
|
const wasmDir = resolveWasmDir();
|
|
142
358
|
if (!wasmDir) {
|
|
143
359
|
console.error(
|
|
@@ -150,9 +366,6 @@ function main(argv: string[]): void {
|
|
|
150
366
|
process.exit(1);
|
|
151
367
|
}
|
|
152
368
|
|
|
153
|
-
// Serve the developer's own app when there is one; otherwise the welcome page.
|
|
154
|
-
const appRoot = resolveAppRoot(args.appDir);
|
|
155
|
-
|
|
156
369
|
const server = http.createServer((req, res) => {
|
|
157
370
|
const url = (req.url || "/").split("?")[0];
|
|
158
371
|
if (req.method !== "GET") {
|
|
@@ -212,5 +425,8 @@ function main(argv: string[]): void {
|
|
|
212
425
|
}
|
|
213
426
|
|
|
214
427
|
if (require.main === module) {
|
|
215
|
-
main(process.argv.slice(2))
|
|
428
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
429
|
+
console.error(err);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
});
|
|
216
432
|
}
|
|
Binary file
|