rastack 0.0.56 → 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/dist/rastack.js CHANGED
@@ -3,8 +3,10 @@
3
3
  /**
4
4
  * rastack — unified CLI for React API Stack
5
5
  *
6
+ * Compilation is not a command of its own: it runs in-process inside the two
7
+ * modalities of use — `rastack dev`/`admin` (local) and `rastack ci` (deploy).
8
+ *
6
9
  * Commands:
7
- * rastack compile [resourcesDir] [outDir] TypeScript resources → schema.rastack.json + openapi.json
8
10
  * rastack check [resourcesDir] Validate the resource graph
9
11
  * rastack list [resourcesDir] Print resources, fields and relations
10
12
  * rastack urls [resourcesDir] Print the generated /api/{app}/v1/{model}/ routes
@@ -64,6 +66,15 @@ switch (command) {
64
66
  printVersion();
65
67
  break;
66
68
  case "compile":
69
+ // Compilation is no longer a standalone command — it runs in-process as
70
+ // part of the two modalities of use. Point the way rather than 404.
71
+ console.error("`rastack compile` has been removed — compilation now runs automatically\n" +
72
+ "inside the two modalities of use:\n" +
73
+ " • rastack dev local development (compiles, then serves over WASM)\n" +
74
+ " • rastack ci the deploy pipeline (compiles the manifest + OpenAPI)\n\n" +
75
+ "Introspect the resource graph with: rastack check | list | urls\n");
76
+ process.exit(1);
77
+ break;
67
78
  case "check":
68
79
  case "list":
69
80
  case "urls":
@@ -133,7 +144,6 @@ switch (command) {
133
144
  default:
134
145
  console.error(`Unknown command: "${command}"\n\n` +
135
146
  `Usage:\n` +
136
- ` rastack compile [resourcesDir] [outDir]\n` +
137
147
  ` rastack check | list | urls [resourcesDir]\n` +
138
148
  ` rastack generate [--schema-only]\n` +
139
149
  ` rastack tokens [input] [outDir] [--prefix p] Compile design tokens → CSS vars + typed theme\n` +
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rastack",
3
- "version": "0.0.56",
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
  },
@@ -21,6 +21,7 @@ export type {
21
21
  EntityTypeExport,
22
22
  } from "./entities";
23
23
  export { buildManifest } from "./manifest";
24
+ export { runCompile } from "./run";
24
25
  export { buildOpenApi, writeSchema } from "./openapi";
25
26
  export * from "./model";
26
27
 
@@ -60,7 +61,10 @@ export function resolveResourceFiles(input: string): string[] {
60
61
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
61
62
  if (entry.isDirectory()) {
62
63
  if (!IGNORE_DIRS.has(entry.name)) walk(path.join(dir, entry.name));
63
- } else if (/\.tsx?$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
64
+ } else if (
65
+ /\.tsx?$/.test(entry.name) &&
66
+ !entry.name.endsWith(".d.ts")
67
+ ) {
64
68
  files.push(path.resolve(dir, entry.name));
65
69
  }
66
70
  }
@@ -96,10 +100,14 @@ export function analyzeProject(input: string): ProjectAnalysis {
96
100
  const defined = new Set(fromDsl.resources.map((r) => `${r.app}.${r.model}`));
97
101
  const resources = [
98
102
  ...fromDsl.resources,
99
- ...fromInterfaces.resources.filter((r) => !defined.has(`${r.app}.${r.model}`)),
103
+ ...fromInterfaces.resources.filter(
104
+ (r) => !defined.has(`${r.app}.${r.model}`),
105
+ ),
100
106
  ];
101
107
  resources.sort((a, b) =>
102
- a.app === b.app ? a.model.localeCompare(b.model) : a.app.localeCompare(b.app),
108
+ a.app === b.app
109
+ ? a.model.localeCompare(b.model)
110
+ : a.app.localeCompare(b.app),
103
111
  );
104
112
 
105
113
  // Registry entries follow the same merge rule as the resources: the DSL
@@ -237,7 +245,8 @@ export function collisions(resources: ResourceModel[]): string[] {
237
245
  }
238
246
  }
239
247
  for (const [key, count] of seen) {
240
- if (count > 1) errors.push(`Duplicate resource ${key} defined ${count} times.`);
248
+ if (count > 1)
249
+ errors.push(`Duplicate resource ${key} defined ${count} times.`);
241
250
  }
242
251
  return errors;
243
252
  }
@@ -0,0 +1,26 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { compile } from "./index";
4
+
5
+ /**
6
+ * Run the compiler **in-process**, writing `schema.rastack.json` +
7
+ * `openapi.json` (and `rastack-env.d.ts`) into `outDir`. Prints any warnings
8
+ * and, on failure, the error; returns `true` on success.
9
+ *
10
+ * This is the single entry point the two real modalities of use share instead
11
+ * of shelling out to a standalone `compile` command:
12
+ * • `rastack dev` / `rastack admin` — local development (compile, then serve
13
+ * the app + admin in the browser over WASM), and
14
+ * • `rastack ci` — the deploy pipeline (compile the manifest the Rust backend
15
+ * reads + the OpenAPI contract, then build/ship).
16
+ */
17
+ export function runCompile(resourcesDir: string, outDir: string): boolean {
18
+ try {
19
+ const result = compile(resourcesDir, outDir);
20
+ for (const d of result.diagnostics) console.warn(` ⚠ ${d.message}`);
21
+ return fs.existsSync(path.join(outDir, "schema.rastack.json"));
22
+ } catch (err) {
23
+ console.error(` ✗ ${(err as Error).message}`);
24
+ return false;
25
+ }
26
+ }
package/src/deploy/ci.ts CHANGED
@@ -23,10 +23,31 @@ export type CiPhase = "check" | "build" | "deploy";
23
23
  export interface CiStep {
24
24
  /** Human label, echoed before the step runs. */
25
25
  name: string;
26
- /** The shell command to execute. */
26
+ /** The shell command to execute (or, for an in-process step, a description). */
27
27
  run: string;
28
28
  /** Working directory relative to the repo root (default: repo root). */
29
29
  cwd?: string;
30
+ /**
31
+ * When set, the step is executed **in-process by the CLI** rather than through
32
+ * a shell — `run` is then a human-readable description, not a command. Today
33
+ * the only in-process action is `"compile"`, which the CLI runs via the shared
34
+ * compiler (`runCompile`) instead of shelling out to a standalone command.
35
+ */
36
+ action?: "compile";
37
+ }
38
+
39
+ /**
40
+ * The in-process compile step shared by `check` and `build`: it produces
41
+ * `schema.rastack.json` + `openapi.json` in `cfg.outDir` without a subprocess,
42
+ * so compilation lives inside the two modalities of use (`dev` and `ci`) rather
43
+ * than in a separately-invoked `compile` command.
44
+ */
45
+ function compileStep(cfg: DeployConfig): CiStep {
46
+ return {
47
+ name: "Compile manifest + OpenAPI",
48
+ run: `compile ${cfg.resourcesDir} ${cfg.outDir} (in-process)`,
49
+ action: "compile",
50
+ };
30
51
  }
31
52
 
32
53
  export interface CiPlanOptions {
@@ -72,10 +93,7 @@ export function checkSteps(cfg: DeployConfig, bin: string): CiStep[] {
72
93
  name: "Validate resource graph",
73
94
  run: `${bin} check ${cfg.resourcesDir}`,
74
95
  },
75
- {
76
- name: "Compile manifest + OpenAPI",
77
- run: `${bin} compile ${cfg.resourcesDir} ${cfg.outDir}`,
78
- },
96
+ compileStep(cfg),
79
97
  ];
80
98
  }
81
99
 
@@ -88,7 +106,6 @@ export function buildSteps(
88
106
  cfg: DeployConfig,
89
107
  opts: CiPlanOptions = {},
90
108
  ): CiStep[] {
91
- const bin = opts.bin ?? "rastack";
92
109
  const stage = stageDir(cfg);
93
110
  const archFlag = cfg.lambda.arch === "arm64" ? "--arm64" : "--x86-64";
94
111
  const jwks = stackOutput(
@@ -97,10 +114,7 @@ export function buildSteps(
97
114
  opts.jwksUrl ?? cfg.outputs?.jwksUrl,
98
115
  );
99
116
  return [
100
- {
101
- name: "Compile manifest + OpenAPI",
102
- run: `${bin} compile ${cfg.resourcesDir} ${cfg.outDir}`,
103
- },
117
+ compileStep(cfg),
104
118
  {
105
119
  name: "Cross-build the Lambda binary",
106
120
  run: `cargo lambda build --release ${archFlag} --bin ${cfg.lambda.bin} --features ${cfg.lambda.features}`,
@@ -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 { font: 14px system-ui, sans-serif; padding: 2rem; color: #555; }
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">Loading the WASM engine…</div></div>
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>
@@ -20,10 +20,11 @@
20
20
  * falls back to a from-source cargo build.
21
21
  */
22
22
 
23
- import { execFileSync, spawnSync } from "child_process";
23
+ import { execFileSync } from "child_process";
24
24
  import * as fs from "fs";
25
25
  import * as http from "http";
26
26
  import * as path from "path";
27
+ import { runCompile } from "./compile";
27
28
  import {
28
29
  WASM_ENTRY,
29
30
  contentType,
@@ -34,18 +35,6 @@ import {
34
35
 
35
36
  const HERE = __dirname; // dist/ when installed — the bundle lives beside us.
36
37
 
37
- /** Compile the resources to schema.rastack.json + openapi.json in outDir. */
38
- function compile(resourcesDir: string, outDir: string): boolean {
39
- const compiler = path.join(HERE, "rastack-compile.js");
40
- const result = spawnSync(
41
- process.execPath,
42
- [compiler, "compile", resourcesDir, outDir],
43
- { stdio: "inherit" },
44
- );
45
- if (result.status !== 0) return false;
46
- return fs.existsSync(path.join(outDir, "schema.rastack.json"));
47
- }
48
-
49
38
  /** Locate the prebuilt wasm bundle dir shipped in the package (no cargo fallback). */
50
39
  function resolveWasmDir(): string | null {
51
40
  for (const dir of wasmBundleCandidates(HERE)) {
@@ -106,9 +95,11 @@ function openBrowser(url: string): void {
106
95
  function main(argv: string[]): void {
107
96
  const args = parseAdminArgs(argv);
108
97
 
109
- console.log(`\n rastack admin — the database console, built with React API Stack\n`);
98
+ console.log(
99
+ `\n rastack admin — the database console, built with React API Stack\n`,
100
+ );
110
101
  console.log(`▸ Compiling ${args.resourcesDir} → ${args.outDir}`);
111
- if (!compile(args.resourcesDir, args.outDir)) {
102
+ if (!runCompile(args.resourcesDir, args.outDir)) {
112
103
  console.error(" ✗ compile failed — fix the resource graph and re-run.");
113
104
  process.exit(1);
114
105
  }
package/src/rastack-ci.ts CHANGED
@@ -18,6 +18,8 @@
18
18
  */
19
19
 
20
20
  import { execSync } from "child_process";
21
+ import { runCompile } from "./compile";
22
+ import { DeployConfig } from "./deploy/config";
21
23
  import { CiPhase, CiPlanOptions, CiStep, ciPlan } from "./deploy/ci";
22
24
  import { loadConfig } from "./deploy/io";
23
25
 
@@ -85,13 +87,24 @@ function printPlan(phase: CiPhase, steps: CiStep[]): void {
85
87
  steps.forEach((s, i) => {
86
88
  const at = s.cwd ? ` (in ${s.cwd}/)` : "";
87
89
  console.log(` ${i + 1}. ${s.name}${at}`);
88
- console.log(` $ ${s.run}\n`);
90
+ // In-process steps have no shell command; `run` is a description.
91
+ console.log(` ${s.action ? "» " : "$ "}${s.run}\n`);
89
92
  });
90
93
  }
91
94
 
92
- function runSteps(phase: CiPhase, steps: CiStep[]): void {
95
+ function runSteps(phase: CiPhase, steps: CiStep[], cfg: DeployConfig): void {
93
96
  for (const step of steps) {
94
97
  console.log(`\n▸ ${step.name}${step.cwd ? ` (in ${step.cwd}/)` : ""}`);
98
+ if (step.action === "compile") {
99
+ // Compilation runs in-process — it lives inside `ci` (and `dev`), not in a
100
+ // separately-invoked `compile` command.
101
+ console.log(` » ${step.run}`);
102
+ if (!runCompile(cfg.resourcesDir, cfg.outDir)) {
103
+ console.error(`\n ✗ step failed: ${step.name}`);
104
+ process.exit(1);
105
+ }
106
+ continue;
107
+ }
95
108
  console.log(` $ ${step.run}`);
96
109
  try {
97
110
  // Runs a CI pipeline step derived from the developer's own deploy config
@@ -123,7 +136,7 @@ function main(argv: string[]): void {
123
136
  }
124
137
  const steps = ciPlan(phase, cfg, opts);
125
138
  if (plan) printPlan(phase, steps);
126
- else runSteps(phase, steps);
139
+ else runSteps(phase, steps, cfg);
127
140
  }
128
141
 
129
142
  if (require.main === module) {
@@ -1,40 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * `rastack compile | check | list | urls` — the TypeScript-first replacement for the
5
- * old Django `manage.py rastack` command.
4
+ * `rastack check | list | urls` — introspection over the TypeScript-first
5
+ * resource graph.
6
6
  *
7
- * rastack compile [resourcesDir] [outDir] defs → schema.rastack.json + openapi.json
8
7
  * rastack check [resourcesDir] validate the resource graph (exit != 0 on error)
9
8
  * rastack list [resourcesDir] print every resource + its fields/relations
10
9
  * rastack urls [resourcesDir] print the generated /api/{app}/v1/{model}/ routes
10
+ *
11
+ * Compilation itself (defs → schema.rastack.json + openapi.json) is no longer a
12
+ * command here: it runs in-process inside the two modalities of use — `rastack
13
+ * dev`/`admin` (local) and `rastack ci` (deploy) — via the shared `runCompile`.
11
14
  */
12
15
 
13
16
  import {
14
17
  analyzeProject,
15
18
  analyzeResources,
16
19
  collisions,
17
- compile,
18
20
  urlsFor,
19
21
  } from "./compile";
20
22
 
21
23
  const DEFAULT_RESOURCES = "resources";
22
- const DEFAULT_OUT = ".rastack";
23
24
 
24
25
  function main(argv: string[]): void {
25
26
  const [sub, ...rest] = argv;
26
27
 
27
28
  switch (sub) {
28
- case "compile": {
29
- const resourcesDir = rest[0] || DEFAULT_RESOURCES;
30
- const outDir = rest[1] || DEFAULT_OUT;
31
- const result = compile(resourcesDir, outDir);
32
- for (const d of result.diagnostics) console.warn(` ⚠ ${d.message}`);
33
- console.log(
34
- `✓ compiled ${result.manifest.resources.length} resource(s) → ${result.files.join(", ")}`,
35
- );
36
- break;
37
- }
38
29
  case "check": {
39
30
  const { resources, diagnostics } = analyzeProject(
40
31
  rest[0] || DEFAULT_RESOURCES,
@@ -65,7 +56,9 @@ function main(argv: string[]): void {
65
56
  for (const r of resources) {
66
57
  console.log(`${r.app}.${r.model}`);
67
58
  for (const f of r.fields) {
68
- const rel = f.relation ? ` → ${f.relation.app}.${f.relation.model}` : "";
59
+ const rel = f.relation
60
+ ? ` → ${f.relation.app}.${f.relation.model}`
61
+ : "";
69
62
  console.log(` ${f.name}: ${f.type}${rel}`);
70
63
  }
71
64
  if (r.sync) {
@@ -89,9 +82,8 @@ function main(argv: string[]): void {
89
82
  }
90
83
  default:
91
84
  console.error(
92
- `Unknown compile subcommand: "${sub}"\n\n` +
85
+ `Unknown subcommand: "${sub}"\n\n` +
93
86
  `Usage:\n` +
94
- ` rastack compile [resourcesDir] [outDir]\n` +
95
87
  ` rastack check [resourcesDir]\n` +
96
88
  ` rastack list [resourcesDir]\n` +
97
89
  ` rastack urls [resourcesDir]\n`,
@@ -21,15 +21,18 @@
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";
29
+ import { runCompile } from "./compile";
28
30
  import {
29
31
  APP_ENTRY,
30
32
  WASM_ENTRY,
31
33
  appRootCandidates,
32
34
  contentType,
35
+ type DevArgs,
33
36
  parseDevArgs,
34
37
  renderWelcome,
35
38
  wasmBundleCandidates,
@@ -37,24 +40,10 @@ import {
37
40
 
38
41
  const HERE = __dirname; // dist/ when installed
39
42
 
40
- /** Compile the resources to schema.rastack.json + openapi.json in outDir. */
41
- function compile(resourcesDir: string, outDir: string): boolean {
42
- const compiler = path.join(HERE, "rastack-compile.js");
43
- const result = spawnSync(
44
- process.execPath,
45
- [compiler, "compile", resourcesDir, outDir],
46
- { stdio: "inherit" },
47
- );
48
- if (result.status !== 0) return false;
49
- return fs.existsSync(path.join(outDir, "schema.rastack.json"));
50
- }
51
-
52
43
  /** True when `cargo` is on PATH (the from-source wasm fallback needs it). */
53
44
  function cargoAvailable(): boolean {
54
45
  try {
55
- return (
56
- spawnSync("cargo", ["--version"], { stdio: "ignore" }).status === 0
57
- );
46
+ return spawnSync("cargo", ["--version"], { stdio: "ignore" }).status === 0;
58
47
  } catch {
59
48
  return false;
60
49
  }
@@ -141,16 +130,230 @@ function openBrowser(url: string): void {
141
130
  }
142
131
  }
143
132
 
144
- function main(argv: string[]): void {
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> {
145
339
  const args = parseDevArgs(argv);
340
+ const cwd = process.cwd();
146
341
 
147
342
  console.log(`\n rastack dev — your app, in your browser over WASM\n`);
148
343
  console.log(`▸ Compiling ${args.resourcesDir} → ${args.outDir}`);
149
- if (!compile(args.resourcesDir, args.outDir)) {
344
+ if (!runCompile(args.resourcesDir, args.outDir)) {
150
345
  console.error(" ✗ compile failed — fix the resource graph and re-run.");
151
346
  process.exit(1);
152
347
  }
153
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
+
154
357
  const wasmDir = resolveWasmDir();
155
358
  if (!wasmDir) {
156
359
  console.error(
@@ -163,9 +366,6 @@ function main(argv: string[]): void {
163
366
  process.exit(1);
164
367
  }
165
368
 
166
- // Serve the developer's own app when there is one; otherwise the welcome page.
167
- const appRoot = resolveAppRoot(args.appDir);
168
-
169
369
  const server = http.createServer((req, res) => {
170
370
  const url = (req.url || "/").split("?")[0];
171
371
  if (req.method !== "GET") {
@@ -225,5 +425,8 @@ function main(argv: string[]): void {
225
425
  }
226
426
 
227
427
  if (require.main === module) {
228
- main(process.argv.slice(2));
428
+ main(process.argv.slice(2)).catch((err) => {
429
+ console.error(err);
430
+ process.exit(1);
431
+ });
229
432
  }