rastack 0.0.57 → 0.0.59

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +190 -0
  3. package/admin/index.tsx +19 -13
  4. package/admin/ui/BootScreen.tsx +201 -0
  5. package/dist/admin.js +156 -18
  6. package/dist/deploy/ci.js +2 -2
  7. package/dist/deploy/io.js +2 -2
  8. package/dist/dev/harness.d.ts +1 -1
  9. package/dist/dev/harness.js +21 -5
  10. package/dist/external/cache.d.ts +28 -0
  11. package/dist/external/cache.js +35 -0
  12. package/dist/external/envelope.d.ts +35 -0
  13. package/dist/external/envelope.js +48 -0
  14. package/dist/external/graphql.d.ts +25 -0
  15. package/dist/external/graphql.js +78 -0
  16. package/dist/external/handler.d.ts +52 -0
  17. package/dist/external/handler.js +139 -0
  18. package/dist/external/index.d.ts +19 -0
  19. package/dist/external/index.js +38 -0
  20. package/dist/external/lambda.d.ts +49 -0
  21. package/dist/external/lambda.js +86 -0
  22. package/dist/external/rest.d.ts +17 -0
  23. package/dist/external/rest.js +46 -0
  24. package/dist/external/route.d.ts +12 -0
  25. package/dist/external/route.js +33 -0
  26. package/dist/external/service.d.ts +50 -0
  27. package/dist/external/service.js +59 -0
  28. package/dist/external/types.d.ts +107 -0
  29. package/dist/external/types.js +22 -0
  30. package/dist/external/util.d.ts +16 -0
  31. package/dist/external/util.js +58 -0
  32. package/dist/rastack-dev.js +200 -4
  33. package/dist/rastack-wasm-build.d.ts +1 -1
  34. package/dist/rastack-wasm-build.js +6 -6
  35. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  36. package/external.ts +10 -0
  37. package/hooks/query/delete.ts +12 -11
  38. package/hooks/query/fetch.ts +6 -8
  39. package/hooks/query/interfaces.ts +1 -4
  40. package/hooks/query/list.ts +16 -9
  41. package/hooks/query/update.ts +8 -6
  42. package/package.json +25 -2
  43. package/scripts/copy-templates.js +1 -1
  44. package/scripts/copy-wasm.js +1 -1
  45. package/scripts/dev-install.mjs +63 -0
  46. package/src/deploy/ci.ts +2 -2
  47. package/src/deploy/io.ts +2 -2
  48. package/src/dev/harness.ts +21 -5
  49. package/src/external/cache.ts +53 -0
  50. package/src/external/envelope.ts +78 -0
  51. package/src/external/graphql.ts +98 -0
  52. package/src/external/handler.ts +207 -0
  53. package/src/external/index.ts +25 -0
  54. package/src/external/lambda.ts +108 -0
  55. package/src/external/rest.ts +60 -0
  56. package/src/external/route.ts +33 -0
  57. package/src/external/service.ts +86 -0
  58. package/src/external/types.ts +121 -0
  59. package/src/external/util.ts +57 -0
  60. package/src/rastack-dev.ts +233 -6
  61. package/src/rastack-wasm-build.ts +6 -6
  62. package/jest.config.cjs +0 -12
  63. package/test/auth.spec.ts +0 -207
  64. package/test/cache.spec.ts +0 -263
  65. package/test/cognito.spec.ts +0 -281
  66. package/test/compile.spec.ts +0 -240
  67. package/test/components.spec.ts +0 -524
  68. package/test/csv-schema.spec.ts +0 -143
  69. package/test/deploy.spec.ts +0 -571
  70. package/test/dev.spec.ts +0 -414
  71. package/test/entities.spec.ts +0 -597
  72. package/test/import.spec.ts +0 -241
  73. package/test/plugin.spec.ts +0 -315
  74. package/test/runtime-manifest.spec.ts +0 -309
  75. package/test/schema-entities.spec.ts +0 -688
  76. package/test/tokens.spec.ts +0 -302
  77. package/test/transitions.spec.ts +0 -372
  78. package/test/typed-hooks.spec.ts +0 -412
  79. package/test/update.spec.ts +0 -152
  80. package/test/validate.spec.ts +0 -319
  81. package/tsconfig.json +0 -27
  82. package/wasm/package.json +0 -4
  83. package/wasm/rastack_wasm.d.ts +0 -100
  84. package/wasm/rastack_wasm.js +0 -745
  85. package/wasm/rastack_wasm_bg.wasm +0 -0
  86. package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Parse the rastack REST path both the mini `rastack dev` service and the
3
+ * deployed Lambda receive into a {@link RastackRequest}. The contract is fixed:
4
+ *
5
+ * /api/{app}/v1/{model}/ → list
6
+ * /api/{app}/v1/{model}/{id}/ → detail
7
+ *
8
+ * Kept pure and separate from the servers so it is tested without a socket.
9
+ */
10
+
11
+ import type { RastackRequest } from "./handler";
12
+
13
+ /** Parse a request URL (path + optional query) into a rastack request, or null. */
14
+ export function parseApiPath(url: string): RastackRequest | null {
15
+ const [rawPath, rawQuery = ""] = url.split("?");
16
+ const segments = rawPath.split("/").filter(Boolean); // api, {app}, v1, {model}, [id]
17
+ if (segments.length < 4 || segments[0] !== "api" || segments[2] !== "v1") {
18
+ return null;
19
+ }
20
+ const app = decodeURIComponent(segments[1]);
21
+ const model = decodeURIComponent(segments[3]);
22
+ const id = segments[4] ? decodeURIComponent(segments[4]) : undefined;
23
+
24
+ const query: Record<string, string[]> = {};
25
+ for (const pair of rawQuery.split("&")) {
26
+ if (!pair) continue;
27
+ const eq = pair.indexOf("=");
28
+ const key = decodeURIComponent(eq === -1 ? pair : pair.slice(0, eq));
29
+ const value = decodeURIComponent(eq === -1 ? "" : pair.slice(eq + 1)).replace(/\+/g, " ");
30
+ (query[key] ??= []).push(value);
31
+ }
32
+ return { app, model, id, query };
33
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The mini local proxy service — the "lambda you run while iterating".
3
+ *
4
+ * `rastack dev` already serves the app + admin over WASM for the stack's *own*
5
+ * resources. This adds the other half for **external** resources: a request
6
+ * handler it mounts under `/api/`, so `useData("github.repo")` works locally,
7
+ * cached, before any real Lambda is deployed. It is a thin adapter over the pure
8
+ * {@link handleExternalRequest} core — parse the path, resolve dev auth from env
9
+ * vars, run the handler, serialise JSON — reused verbatim by the deployed Lambda
10
+ * (which swaps the env-var auth for Secrets Manager and the Map cache for a
11
+ * shared store).
12
+ */
13
+
14
+ import { memoryCache, type ProxyCache } from "./cache";
15
+ import { handleExternalRequest, type HandlerDeps } from "./handler";
16
+ import { parseApiPath } from "./route";
17
+ import type { FetchLike, ResourceBinding, ResourceResolver } from "./types";
18
+
19
+ export interface ExternalService {
20
+ /** True when the path is an `/api/{app}/v1/{model}/` route this service owns. */
21
+ handles(url: string): boolean;
22
+ /**
23
+ * Handle a request. Returns the HTTP status, JSON body string, and whether it
24
+ * was a cache hit (for a dev-log line). Returns `null` when the path isn't an
25
+ * API route, so the caller falls through to its other handlers.
26
+ */
27
+ serve(
28
+ url: string,
29
+ ): Promise<{ status: number; json: string; cached: boolean } | null>;
30
+ }
31
+
32
+ export interface ServiceOptions {
33
+ bindings: ResourceBinding[];
34
+ /** Injected fetch; defaults to the global `fetch` (Node 18+/browsers). */
35
+ fetch?: FetchLike;
36
+ cache?: ProxyCache;
37
+ now?: () => number;
38
+ /** Per-app upstream auth headers. In dev, typically read from `process.env`. */
39
+ authHeaders?: HandlerDeps["authHeaders"];
40
+ /** Named resolvers for `resolver`-transport bindings (e.g. an AWS resolver). */
41
+ resolvers?: Record<string, ResourceResolver>;
42
+ }
43
+
44
+ /**
45
+ * Read a dev token for an app from the environment, e.g. `GITHUB_TOKEN`,
46
+ * `EXPO_TOKEN`. Returns a Bearer header when present, else nothing (public
47
+ * endpoints still work; private ones 401 with a clear upstream message). This is
48
+ * the *only* place a raw token is read locally — production reads Secrets Manager
49
+ * in the Lambda instead, and no token is ever sent to the client.
50
+ */
51
+ export function envAuthHeaders(
52
+ env: Record<string, string | undefined> = process.env,
53
+ ): NonNullable<HandlerDeps["authHeaders"]> {
54
+ return (app): Record<string, string> => {
55
+ const token = env[`${app.toUpperCase()}_TOKEN`];
56
+ return token ? { Authorization: `Bearer ${token}` } : {};
57
+ };
58
+ }
59
+
60
+ /** Create the mini external-API service `rastack dev` mounts. */
61
+ export function createExternalService(opts: ServiceOptions): ExternalService {
62
+ const deps: HandlerDeps = {
63
+ fetch: opts.fetch ?? ((globalThis as { fetch?: FetchLike }).fetch as FetchLike),
64
+ cache: opts.cache ?? memoryCache(),
65
+ now: opts.now ?? (() => Date.now()),
66
+ authHeaders: opts.authHeaders ?? envAuthHeaders(),
67
+ resolvers: opts.resolvers,
68
+ };
69
+ if (!deps.fetch) {
70
+ throw new Error("createExternalService needs a `fetch` (none on globalThis).");
71
+ }
72
+
73
+ return {
74
+ handles: (url) => parseApiPath(url) !== null,
75
+ async serve(url) {
76
+ const req = parseApiPath(url);
77
+ if (!req) return null;
78
+ const result = await handleExternalRequest(req, opts.bindings, deps);
79
+ return {
80
+ status: result.status,
81
+ json: JSON.stringify(result.body),
82
+ cached: result.cached,
83
+ };
84
+ },
85
+ };
86
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * External-API bindings — how a rastack `app.model` is served from an upstream
3
+ * REST or GraphQL API.
4
+ *
5
+ * The stack's own resources are served by the Rust API / WASM engine over the
6
+ * `/api/{app}/v1/{model}/` contract. An **external** resource (a GitHub repo, an
7
+ * Expo project, an AWS stack) is served by the proxy instead: it speaks the same
8
+ * contract to the client, and translates each request into an upstream call,
9
+ * normalises the response into rastack's DRF-shaped envelope, and caches it.
10
+ *
11
+ * One {@link ResourceBinding} describes that translation for a single model. The
12
+ * proxy core ({@link ./handler}) is a pure function of a request + the bindings +
13
+ * an injected `fetch`/cache, so the *same* logic runs in the deployed Lambda and
14
+ * in the mini service inside `rastack dev`.
15
+ */
16
+
17
+ /** A minimal `fetch` surface — the global `fetch` satisfies it; tests fake it. */
18
+ export interface FetchResponse {
19
+ status: number;
20
+ headers: { get(name: string): string | null };
21
+ json(): Promise<unknown>;
22
+ text(): Promise<string>;
23
+ }
24
+ export type FetchLike = (
25
+ url: string,
26
+ init?: {
27
+ method?: string;
28
+ headers?: Record<string, string>;
29
+ body?: string;
30
+ },
31
+ ) => Promise<FetchResponse>;
32
+
33
+ /** Common binding fields, independent of transport. */
34
+ interface BindingBase {
35
+ /** The rastack app namespace, e.g. `"github"`. */
36
+ app: string;
37
+ /** The rastack model, the `{model}` in `/api/{app}/v1/{model}/`. */
38
+ model: string;
39
+ /** The upstream field that is the row's identity. Default `"id"`. */
40
+ primaryKey?: string;
41
+ /** Cache TTL in ms for this resource's responses. Default 60_000. */
42
+ ttlMs?: number;
43
+ /** Whitelist of upstream fields to project into each row (default: all). */
44
+ fields?: string[];
45
+ /** Static headers merged into every upstream request (auth is separate). */
46
+ headers?: Record<string, string>;
47
+ }
48
+
49
+ /** Serve a model from an upstream REST endpoint. */
50
+ export interface RestBinding extends BindingBase {
51
+ transport: "rest";
52
+ /** Upstream origin, e.g. `"https://api.github.com"`. */
53
+ baseUrl: string;
54
+ /** Path to the list, e.g. `"/user/repos"`. */
55
+ listPath: string;
56
+ /** Path to a detail; `{id}` is substituted, e.g. `"/repositories/{id}"`. */
57
+ detailPath?: string;
58
+ /**
59
+ * Map rastack list params (`page`, `page_size`, `search`, `order_by`) to the
60
+ * upstream's own names (`page`, `per_page`, `q`, `sort`). Unmapped params are
61
+ * dropped, so the upstream never sees rastack-only query keys.
62
+ */
63
+ queryMap?: Partial<Record<"page" | "page_size" | "search" | "order_by", string>>;
64
+ /** Dot path to the array inside the list response (default: the body *is* the array). */
65
+ itemsPath?: string;
66
+ }
67
+
68
+ /** Serve a model from an upstream GraphQL endpoint. */
69
+ export interface GraphQLBinding extends BindingBase {
70
+ transport: "graphql";
71
+ /** The GraphQL endpoint URL, e.g. `"https://api.expo.dev/graphql"`. */
72
+ baseUrl: string;
73
+ /** The list query. Variables are filled from {@link variableMap}. */
74
+ listQuery: string;
75
+ /** Dot path into `data` to the array, e.g. `"me.accounts.0.apps"`. */
76
+ itemsPath: string;
77
+ /** Optional detail query; `$id` variable is bound to the requested id. */
78
+ detailQuery?: string;
79
+ /** Dot path into `data` to the detail object. */
80
+ detailPath?: string;
81
+ /** Map rastack params to GraphQL variable names (e.g. `page_size` → `first`). */
82
+ variableMap?: Partial<Record<"page" | "page_size" | "search" | "order_by" | "id", string>>;
83
+ /** Static variables merged into every query. */
84
+ variables?: Record<string, unknown>;
85
+ }
86
+
87
+ /**
88
+ * Serve a model from an injected resolver function rather than a plain HTTP
89
+ * call — for upstreams that need an SDK/signing the proxy core shouldn't import
90
+ * (AWS SigV4, say). The named resolver is provided via `HandlerDeps.resolvers`,
91
+ * so the framework stays dependency-free while the app plugs in, e.g., an AWS
92
+ * CloudFormation resolver. Still consumed through the normal `useData` contract.
93
+ */
94
+ export interface ResolverBinding extends BindingBase {
95
+ transport: "resolver";
96
+ /** Key into `HandlerDeps.resolvers` for the function that produces the data. */
97
+ resolver: string;
98
+ }
99
+
100
+ export type ResourceBinding = RestBinding | GraphQLBinding | ResolverBinding;
101
+
102
+ /**
103
+ * A resolver produces the upstream data for a {@link ResolverBinding}: an array
104
+ * for a list request, or a single object for a detail request. Normalisation
105
+ * into the rastack envelope is done by the core, exactly as for HTTP transports.
106
+ */
107
+ export type ResourceResolver = (args: {
108
+ app: string;
109
+ model: string;
110
+ id?: string;
111
+ query: Record<string, string[]>;
112
+ }) => Promise<unknown>;
113
+
114
+ /** Look a binding up by `app`/`model`. */
115
+ export function findBinding(
116
+ bindings: ResourceBinding[],
117
+ app: string,
118
+ model: string,
119
+ ): ResourceBinding | undefined {
120
+ return bindings.find((b) => b.app === app && b.model === model);
121
+ }
@@ -0,0 +1,57 @@
1
+ /** Small pure helpers shared by the proxy core. */
2
+
3
+ /**
4
+ * Read a value at a dot path (`"me.accounts.0.apps"`) out of a JSON value.
5
+ * Numeric segments index arrays. Returns `undefined` for any missing hop, so
6
+ * callers can default gracefully rather than throw on a shape they didn't get.
7
+ */
8
+ export function getPath(value: unknown, path: string | undefined): unknown {
9
+ if (!path) return value;
10
+ let cur: unknown = value;
11
+ for (const segment of path.split(".")) {
12
+ if (cur == null) return undefined;
13
+ if (Array.isArray(cur)) {
14
+ const idx = Number(segment);
15
+ cur = Number.isInteger(idx) ? cur[idx] : undefined;
16
+ } else if (typeof cur === "object") {
17
+ cur = (cur as Record<string, unknown>)[segment];
18
+ } else {
19
+ return undefined;
20
+ }
21
+ }
22
+ return cur;
23
+ }
24
+
25
+ /** Keep only `fields` on a row (plus always its `id`). No-op when unset. */
26
+ export function projectFields(
27
+ row: Record<string, unknown>,
28
+ fields: string[] | undefined,
29
+ ): Record<string, unknown> {
30
+ if (!fields || !fields.length) return row;
31
+ const out: Record<string, unknown> = {};
32
+ if ("id" in row) out.id = row.id;
33
+ for (const f of fields) if (f in row) out[f] = row[f];
34
+ return out;
35
+ }
36
+
37
+ /**
38
+ * Ensure a row carries an `id` (the rastack detail-URL key). If the upstream's
39
+ * identity lives under a different `primaryKey`, mirror it onto `id`.
40
+ */
41
+ export function ensureId(
42
+ row: Record<string, unknown>,
43
+ primaryKey: string,
44
+ ): Record<string, unknown> {
45
+ if (primaryKey !== "id" && row.id === undefined && row[primaryKey] !== undefined) {
46
+ return { ...row, id: row[primaryKey] };
47
+ }
48
+ return row;
49
+ }
50
+
51
+ /** First value for a key in a parsed query (`{ page: ["2"] }` → `"2"`). */
52
+ export function firstParam(
53
+ query: Record<string, string[]>,
54
+ key: string,
55
+ ): string | undefined {
56
+ return query[key]?.[0];
57
+ }
@@ -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,225 @@ function openBrowser(url: string): void {
128
130
  }
129
131
  }
130
132
 
131
- 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
+ server.on("error", (err: NodeJS.ErrnoException) => {
325
+ if (err.code === "EADDRINUSE") {
326
+ console.error(
327
+ `\n ✗ Port ${args.port} is already in use — is \`rastack dev\` already running?\n` +
328
+ ` Stop it, or pick another port: rastack dev --port <n>\n`,
329
+ );
330
+ } else {
331
+ console.error(`\n ✗ dev server error: ${err.message}`);
332
+ }
333
+ stop(1);
334
+ });
335
+
336
+ server.listen(args.port, () => {
337
+ const url = `http://localhost:${args.port}`;
338
+ console.log(` ✓ Metro live on :${metroPort} — fast refresh on`);
339
+ if (wasmDir)
340
+ console.log(` ✓ WASM engine: ${path.relative(cwd, wasmDir)}`);
341
+ console.log(`\n ▸ ${url}\n`);
342
+ console.log(
343
+ " Edits reload live. Need the database console? Run `rastack admin`. Ctrl+C to stop.\n",
344
+ );
345
+ if (args.open) openBrowser(url);
346
+ });
347
+ }
348
+
349
+ async function main(argv: string[]): Promise<void> {
132
350
  const args = parseDevArgs(argv);
351
+ const cwd = process.cwd();
133
352
 
134
353
  console.log(`\n rastack dev — your app, in your browser over WASM\n`);
135
354
  console.log(`▸ Compiling ${args.resourcesDir} → ${args.outDir}`);
@@ -138,6 +357,14 @@ function main(argv: string[]): void {
138
357
  process.exit(1);
139
358
  }
140
359
 
360
+ // Serve the developer's own app when there is one; otherwise the welcome page.
361
+ const appRoot = resolveAppRoot(args.appDir);
362
+
363
+ // No static build, but an Expo source tree → drive Metro live (fast refresh).
364
+ if (!appRoot && isExpoProject(cwd)) {
365
+ return runMetroMode(args, cwd);
366
+ }
367
+
141
368
  const wasmDir = resolveWasmDir();
142
369
  if (!wasmDir) {
143
370
  console.error(
@@ -150,9 +377,6 @@ function main(argv: string[]): void {
150
377
  process.exit(1);
151
378
  }
152
379
 
153
- // Serve the developer's own app when there is one; otherwise the welcome page.
154
- const appRoot = resolveAppRoot(args.appDir);
155
-
156
380
  const server = http.createServer((req, res) => {
157
381
  const url = (req.url || "/").split("?")[0];
158
382
  if (req.method !== "GET") {
@@ -212,5 +436,8 @@ function main(argv: string[]): void {
212
436
  }
213
437
 
214
438
  if (require.main === module) {
215
- main(process.argv.slice(2));
439
+ main(process.argv.slice(2)).catch((err) => {
440
+ console.error(err);
441
+ process.exit(1);
442
+ });
216
443
  }
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * Pipeline:
8
8
  * 1. cargo build -p rastack-wasm --release --target wasm32-unknown-unknown
9
- * 2. wasm-bindgen --target web → tools/wasm/rastack_wasm.js + rastack_wasm_bg.wasm
9
+ * 2. wasm-bindgen --target web → wasm/rastack_wasm.js + rastack_wasm_bg.wasm
10
10
  * 3. wasm-opt -Oz (if available) to shrink the binary
11
11
  *
12
12
  * This is invoked by the release CI (to bundle a prebuilt engine into the npm
@@ -20,9 +20,9 @@ import { spawnSync } from "child_process";
20
20
  import fs from "fs";
21
21
  import path from "path";
22
22
 
23
- const repoRoot = path.resolve(__dirname, "..", "..");
24
- const rustDir = path.join(repoRoot, "rust");
25
- const outDir = path.join(repoRoot, "tools", "wasm");
23
+ const repoRoot = path.resolve(__dirname, "..");
24
+ const rustDir = path.join(repoRoot, "engine");
25
+ const outDir = path.join(repoRoot, "wasm");
26
26
  const wasmPath = path.join(
27
27
  rustDir,
28
28
  "target",
@@ -78,7 +78,7 @@ export function buildWasm(): void {
78
78
  rustDir,
79
79
  );
80
80
 
81
- console.log("→ wasm-bindgen → tools/wasm/");
81
+ console.log("→ wasm-bindgen → wasm/");
82
82
  run("wasm-bindgen", [
83
83
  "--target",
84
84
  "web",
@@ -105,7 +105,7 @@ export function buildWasm(): void {
105
105
  }
106
106
 
107
107
  const size = fs.statSync(path.join(outDir, "rastack_wasm_bg.wasm")).size;
108
- console.log(`✓ wasm engine built: tools/wasm/ (${(size / 1024).toFixed(0)} KB)`);
108
+ console.log(`✓ wasm engine built: wasm/ (${(size / 1024).toFixed(0)} KB)`);
109
109
  }
110
110
 
111
111
  if (require.main === module) {
package/jest.config.cjs DELETED
@@ -1,12 +0,0 @@
1
- module.exports = {
2
- preset: 'ts-jest',
3
- testEnvironment: 'node',
4
- testMatch: ['**/test/**/*.spec.ts'],
5
- moduleFileExtensions: ['ts', 'js'],
6
- // Let executed test fixtures import the package by its published subpath
7
- // names, the same way a consumer would (the resource compiler resolves these
8
- // via its own ts.Program; these mappings are for code jest actually runs).
9
- moduleNameMapper: {
10
- '^rastack/tokens$': '<rootDir>/tokens.ts',
11
- },
12
- };