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.
- package/CHANGELOG.md +11 -0
- package/README.md +190 -0
- package/admin/index.tsx +19 -13
- package/admin/ui/BootScreen.tsx +201 -0
- package/dist/admin.js +156 -18
- package/dist/deploy/ci.js +2 -2
- package/dist/deploy/io.js +2 -2
- package/dist/dev/harness.d.ts +1 -1
- package/dist/dev/harness.js +21 -5
- package/dist/external/cache.d.ts +28 -0
- package/dist/external/cache.js +35 -0
- package/dist/external/envelope.d.ts +35 -0
- package/dist/external/envelope.js +48 -0
- package/dist/external/graphql.d.ts +25 -0
- package/dist/external/graphql.js +78 -0
- package/dist/external/handler.d.ts +52 -0
- package/dist/external/handler.js +139 -0
- package/dist/external/index.d.ts +19 -0
- package/dist/external/index.js +38 -0
- package/dist/external/lambda.d.ts +49 -0
- package/dist/external/lambda.js +86 -0
- package/dist/external/rest.d.ts +17 -0
- package/dist/external/rest.js +46 -0
- package/dist/external/route.d.ts +12 -0
- package/dist/external/route.js +33 -0
- package/dist/external/service.d.ts +50 -0
- package/dist/external/service.js +59 -0
- package/dist/external/types.d.ts +107 -0
- package/dist/external/types.js +22 -0
- package/dist/external/util.d.ts +16 -0
- package/dist/external/util.js +58 -0
- package/dist/rastack-dev.js +200 -4
- package/dist/rastack-wasm-build.d.ts +1 -1
- package/dist/rastack-wasm-build.js +6 -6
- package/dist/wasm/rastack_wasm_bg.wasm +0 -0
- package/external.ts +10 -0
- package/hooks/query/delete.ts +12 -11
- package/hooks/query/fetch.ts +6 -8
- package/hooks/query/interfaces.ts +1 -4
- package/hooks/query/list.ts +16 -9
- package/hooks/query/update.ts +8 -6
- package/package.json +25 -2
- package/scripts/copy-templates.js +1 -1
- package/scripts/copy-wasm.js +1 -1
- package/scripts/dev-install.mjs +63 -0
- package/src/deploy/ci.ts +2 -2
- package/src/deploy/io.ts +2 -2
- package/src/dev/harness.ts +21 -5
- package/src/external/cache.ts +53 -0
- package/src/external/envelope.ts +78 -0
- package/src/external/graphql.ts +98 -0
- package/src/external/handler.ts +207 -0
- package/src/external/index.ts +25 -0
- package/src/external/lambda.ts +108 -0
- package/src/external/rest.ts +60 -0
- package/src/external/route.ts +33 -0
- package/src/external/service.ts +86 -0
- package/src/external/types.ts +121 -0
- package/src/external/util.ts +57 -0
- package/src/rastack-dev.ts +233 -6
- package/src/rastack-wasm-build.ts +6 -6
- package/jest.config.cjs +0 -12
- package/test/auth.spec.ts +0 -207
- package/test/cache.spec.ts +0 -263
- package/test/cognito.spec.ts +0 -281
- package/test/compile.spec.ts +0 -240
- package/test/components.spec.ts +0 -524
- package/test/csv-schema.spec.ts +0 -143
- package/test/deploy.spec.ts +0 -571
- package/test/dev.spec.ts +0 -414
- package/test/entities.spec.ts +0 -597
- package/test/import.spec.ts +0 -241
- package/test/plugin.spec.ts +0 -315
- package/test/runtime-manifest.spec.ts +0 -309
- package/test/schema-entities.spec.ts +0 -688
- package/test/tokens.spec.ts +0 -302
- package/test/transitions.spec.ts +0 -372
- package/test/typed-hooks.spec.ts +0 -412
- package/test/update.spec.ts +0 -152
- package/test/validate.spec.ts +0 -319
- package/tsconfig.json +0 -27
- package/wasm/package.json +0 -4
- package/wasm/rastack_wasm.d.ts +0 -100
- package/wasm/rastack_wasm.js +0 -745
- package/wasm/rastack_wasm_bg.wasm +0 -0
- 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
|
+
}
|
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,225 @@ 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
|
+
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 →
|
|
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, "
|
|
25
|
-
const outDir = path.join(repoRoot, "
|
|
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 →
|
|
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:
|
|
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
|
-
};
|