rastack 0.0.42 → 0.0.44

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 (41) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/admin/AdminApp.tsx +308 -0
  3. package/admin/adapters.ts +247 -0
  4. package/admin/index.tsx +88 -0
  5. package/admin/useAdmin.ts +299 -0
  6. package/dist/admin.js +81 -0
  7. package/dist/dev/harness.d.ts +53 -0
  8. package/dist/dev/harness.js +151 -0
  9. package/dist/rastack-dev.d.ts +18 -0
  10. package/dist/rastack-dev.js +197 -0
  11. package/dist/rastack-init.d.ts +3 -2
  12. package/dist/rastack-init.js +23 -44
  13. package/dist/rastack-update.js +16 -8
  14. package/dist/rastack-wasm-build.d.ts +9 -4
  15. package/dist/rastack-wasm-build.js +44 -33
  16. package/dist/rastack.d.ts +1 -2
  17. package/dist/rastack.js +7 -10
  18. package/dist/update.d.ts +11 -0
  19. package/dist/update.js +14 -0
  20. package/dist/wasm/package.json +4 -0
  21. package/dist/wasm/rastack_wasm.d.ts +100 -0
  22. package/dist/wasm/rastack_wasm.js +745 -0
  23. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  24. package/dist/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
  25. package/package.json +6 -2
  26. package/scripts/build-admin.js +48 -0
  27. package/scripts/copy-wasm.js +35 -0
  28. package/src/dev/harness.ts +130 -0
  29. package/src/rastack-dev.ts +197 -0
  30. package/src/rastack-init.ts +26 -62
  31. package/src/rastack-update.ts +22 -9
  32. package/src/rastack-wasm-build.ts +52 -41
  33. package/src/rastack.ts +7 -10
  34. package/src/update.ts +14 -0
  35. package/test/dev.spec.ts +277 -0
  36. package/test/update.spec.ts +13 -0
  37. package/wasm/package.json +4 -0
  38. package/wasm/rastack_wasm.d.ts +100 -0
  39. package/wasm/rastack_wasm.js +745 -0
  40. package/wasm/rastack_wasm_bg.wasm +0 -0
  41. package/wasm/rastack_wasm_bg.wasm.d.ts +28 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ### [0.0.44](https://github.com/theserverkid/reactapistack/compare/v0.0.43...v0.0.44) (2026-07-12)
6
+
7
+
8
+ ### Features
9
+
10
+ * **dev:** replace `rastack serve`/`wasm` with `rastack dev` — full in-browser admin over WASM ([6f3f579](https://github.com/theserverkid/reactapistack/commit/6f3f579422624bd0dd3d834b6c0eff47eeef2214))
11
+
12
+ ### [0.0.43](https://github.com/theserverkid/reactapistack/compare/v0.0.42...v0.0.43) (2026-07-12)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * **init:** require the bootstrap stack instead of creating it ([085c59d](https://github.com/theserverkid/reactapistack/commit/085c59df4a61c5d28312edb7d0895c5b2f0442b5))
18
+ * **update:** silence Node DEP0190 when launching npm on Windows ([959fe61](https://github.com/theserverkid/reactapistack/commit/959fe6177ec4e6e0d634e2751a1099717f2bda3b))
19
+
5
20
  ### [0.0.42](https://github.com/theserverkid/reactapistack/compare/v0.0.41...v0.0.42) (2026-07-12)
6
21
 
7
22
 
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The in-browser admin — a Django-admin-style, manifest-driven UI that runs
3
+ * entirely over the WASM engine (`useRastackClient().engine`). It reuses the
4
+ * headless `rastack/components` (`DataTable`, `AutoForm`, primitives) and is
5
+ * app-generic: every screen is built from the compiled manifest, so the same
6
+ * bundle admins any app. Faithful to `rust/rastack-api/src/admin.rs`.
7
+ */
8
+
9
+ import React, { useEffect, useMemo, useState } from "react";
10
+ import { useRastackClient } from "../provider";
11
+ import {
12
+ Button,
13
+ Card,
14
+ DataTable,
15
+ AutoForm,
16
+ Spinner,
17
+ type DataTableColumnInput,
18
+ } from "../components";
19
+ import {
20
+ AdminResource,
21
+ adminResources,
22
+ changelistHeaders,
23
+ detailPath,
24
+ findResource,
25
+ parseManifest,
26
+ } from "./adapters";
27
+ import { useDeleteRecord, useResourceForm, useResourceTable } from "./useAdmin";
28
+ import type { RastackEngine } from "../provider/types";
29
+
30
+ // -- hash routing (dependency-free) -----------------------------------------
31
+
32
+ type Route =
33
+ | { name: "index" }
34
+ | { name: "list"; app: string; model: string }
35
+ | { name: "add"; app: string; model: string }
36
+ | { name: "change"; app: string; model: string; id: string };
37
+
38
+ function parseRoute(hash: string): Route {
39
+ const parts = hash.replace(/^#\/?/, "").split("/").filter(Boolean);
40
+ if (parts.length < 2) return { name: "index" };
41
+ const [app, model, third] = parts;
42
+ if (!third) return { name: "list", app, model };
43
+ if (third === "add") return { name: "add", app, model };
44
+ return { name: "change", app, model, id: decodeURIComponent(third) };
45
+ }
46
+
47
+ function useRoute(): Route {
48
+ const [hash, setHash] = useState(() =>
49
+ typeof location !== "undefined" ? location.hash : "",
50
+ );
51
+ useEffect(() => {
52
+ const on = () => setHash(location.hash);
53
+ window.addEventListener("hashchange", on);
54
+ return () => window.removeEventListener("hashchange", on);
55
+ }, []);
56
+ return useMemo(() => parseRoute(hash), [hash]);
57
+ }
58
+
59
+ const go = (hash: string) => {
60
+ location.hash = hash;
61
+ };
62
+
63
+ // -- shell ------------------------------------------------------------------
64
+
65
+ function Shell(props: {
66
+ crumbs: { label: string; hash?: string }[];
67
+ children: React.ReactNode;
68
+ }): React.JSX.Element {
69
+ return (
70
+ <div style={{ maxWidth: 1024, margin: "0 auto", padding: "24px 20px" }}>
71
+ <nav style={{ marginBottom: 20, fontSize: 14 }}>
72
+ {props.crumbs.map((c, i) => (
73
+ <span key={i}>
74
+ {i > 0 ? <span style={{ opacity: 0.5 }}> / </span> : null}
75
+ {c.hash ? (
76
+ <a href={c.hash} style={{ textDecoration: "none" }}>
77
+ {c.label}
78
+ </a>
79
+ ) : (
80
+ <span>{c.label}</span>
81
+ )}
82
+ </span>
83
+ ))}
84
+ </nav>
85
+ {props.children}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ // -- screens ----------------------------------------------------------------
91
+
92
+ function IndexScreen(props: { resources: AdminResource[] }): React.JSX.Element {
93
+ const byApp = useMemo(() => {
94
+ const map = new Map<string, AdminResource[]>();
95
+ for (const r of adminResources(props.resources)) {
96
+ (map.get(r.app) ?? map.set(r.app, []).get(r.app)!).push(r);
97
+ }
98
+ return [...map.entries()].sort(([a], [b]) => a.localeCompare(b));
99
+ }, [props.resources]);
100
+
101
+ return (
102
+ <Shell crumbs={[{ label: "Admin" }]}>
103
+ <h1 style={{ fontSize: 22, margin: "0 0 16px" }}>Administration</h1>
104
+ {byApp.map(([app, models]) => (
105
+ <Card key={app} style={{ marginBottom: 16, padding: 16 }}>
106
+ <h2 style={{ fontSize: 15, textTransform: "capitalize", margin: "0 0 8px" }}>
107
+ {app}
108
+ </h2>
109
+ <ul style={{ margin: 0, paddingLeft: 18 }}>
110
+ {models
111
+ .slice()
112
+ .sort((a, b) => a.model.localeCompare(b.model))
113
+ .map((r) => (
114
+ <li key={r.key} style={{ margin: "4px 0" }}>
115
+ <a href={`#/${r.app}/${r.model}/`}>{r.label}</a>
116
+ </li>
117
+ ))}
118
+ </ul>
119
+ </Card>
120
+ ))}
121
+ </Shell>
122
+ );
123
+ }
124
+
125
+ /** DataTable columns with id + FK cells rendered as links (like the Rust admin). */
126
+ function columnsFor(resource: AdminResource): DataTableColumnInput[] {
127
+ return changelistHeaders(resource).map((h) => {
128
+ if (h.key === "id") {
129
+ return {
130
+ key: "id",
131
+ header: "id",
132
+ render: (row: Record<string, unknown>, value: unknown) => (
133
+ <a href={`#/${resource.app}/${resource.model}/${String(value)}`}>
134
+ {String(value)}
135
+ </a>
136
+ ),
137
+ };
138
+ }
139
+ const field = resource.fields.find((f) => f.name === h.key);
140
+ if (field?.type === "fk" && field.relation) {
141
+ const { app, model } = field.relation;
142
+ return {
143
+ key: h.key,
144
+ header: h.name,
145
+ render: (_row: Record<string, unknown>, value: unknown) =>
146
+ value == null || value === "" ? (
147
+ ""
148
+ ) : (
149
+ <a href={`#/${app}/${model}/${String(value)}`}>
150
+ {model} #{String(value)}
151
+ </a>
152
+ ),
153
+ };
154
+ }
155
+ return { key: h.key, header: h.name };
156
+ });
157
+ }
158
+
159
+ function ListScreen(props: {
160
+ engine: RastackEngine;
161
+ resource: AdminResource;
162
+ }): React.JSX.Element {
163
+ const { engine, resource } = props;
164
+ const { query } = useResourceTable(engine, resource);
165
+ const columns = useMemo(() => columnsFor(resource), [resource.key]);
166
+ return (
167
+ <Shell
168
+ crumbs={[
169
+ { label: "Admin", hash: "#/" },
170
+ { label: resource.label },
171
+ ]}
172
+ >
173
+ <div
174
+ style={{
175
+ display: "flex",
176
+ justifyContent: "space-between",
177
+ alignItems: "center",
178
+ marginBottom: 12,
179
+ }}
180
+ >
181
+ <h1 style={{ fontSize: 20, margin: 0 }}>{resource.label}</h1>
182
+ <Button onClick={() => go(`#/${resource.app}/${resource.model}/add`)}>
183
+ Add {resource.label}
184
+ </Button>
185
+ </div>
186
+ <DataTable
187
+ query={query}
188
+ columns={columns}
189
+ onRowClick={(row) =>
190
+ go(`#/${resource.app}/${resource.model}/${String(row.id)}`)
191
+ }
192
+ />
193
+ </Shell>
194
+ );
195
+ }
196
+
197
+ function FormScreen(props: {
198
+ engine: RastackEngine;
199
+ resource: AdminResource;
200
+ id?: string;
201
+ }): React.JSX.Element {
202
+ const { engine, resource, id } = props;
203
+ const { model, loading, loadError, submitError, done } = useResourceForm(
204
+ engine,
205
+ resource,
206
+ id,
207
+ );
208
+ const del = useDeleteRecord(engine, resource);
209
+ const listHash = `#/${resource.app}/${resource.model}/`;
210
+
211
+ // On successful save, return to the changelist.
212
+ useEffect(() => {
213
+ if (done) go(listHash);
214
+ }, [done]);
215
+
216
+ const onDelete = async () => {
217
+ if (!id) return;
218
+ if (typeof confirm === "function" && !confirm("Delete this record?")) return;
219
+ if (await del(id)) go(listHash);
220
+ };
221
+
222
+ return (
223
+ <Shell
224
+ crumbs={[
225
+ { label: "Admin", hash: "#/" },
226
+ { label: resource.label, hash: listHash },
227
+ { label: id ? `Edit #${id}` : "Add" },
228
+ ]}
229
+ >
230
+ <h1 style={{ fontSize: 20, margin: "0 0 16px" }}>
231
+ {id ? `Edit ${resource.label} #${id}` : `Add ${resource.label}`}
232
+ </h1>
233
+ {loadError ? (
234
+ <p style={{ color: "#c93c37" }}>{loadError}</p>
235
+ ) : loading ? (
236
+ <Spinner />
237
+ ) : (
238
+ <Card style={{ padding: 20, maxWidth: 560 }}>
239
+ <AutoForm form={model} onCancel={() => go(listHash)} />
240
+ {submitError ? (
241
+ <p style={{ color: "#c93c37", marginTop: 12 }}>{submitError}</p>
242
+ ) : null}
243
+ {id ? (
244
+ <div style={{ marginTop: 16 }}>
245
+ <Button variant="danger" onClick={onDelete}>
246
+ Delete
247
+ </Button>
248
+ </div>
249
+ ) : null}
250
+ </Card>
251
+ )}
252
+ </Shell>
253
+ );
254
+ }
255
+
256
+ // -- root -------------------------------------------------------------------
257
+
258
+ export function AdminApp(props: {
259
+ manifest: object | string;
260
+ }): React.JSX.Element {
261
+ const client = useRastackClient();
262
+ const route = useRoute();
263
+ const resources = useMemo(() => parseManifest(props.manifest), [props.manifest]);
264
+
265
+ if (client.status === "error") {
266
+ return (
267
+ <Shell crumbs={[{ label: "Admin" }]}>
268
+ <p style={{ color: "#c93c37" }}>
269
+ The WASM engine failed to start. Check the console.
270
+ </p>
271
+ </Shell>
272
+ );
273
+ }
274
+ if (!client.engine || client.status !== "ready") {
275
+ return (
276
+ <Shell crumbs={[{ label: "Admin" }]}>
277
+ <div style={{ display: "flex", gap: 10, alignItems: "center" }}>
278
+ <Spinner />
279
+ <span>
280
+ {client.status === "seeding"
281
+ ? "Seeding the local warehouse…"
282
+ : "Starting the WASM engine…"}
283
+ </span>
284
+ </div>
285
+ </Shell>
286
+ );
287
+ }
288
+
289
+ if (route.name === "index") return <IndexScreen resources={resources} />;
290
+
291
+ const resource = findResource(resources, route.app, route.model);
292
+ if (!resource) {
293
+ return (
294
+ <Shell crumbs={[{ label: "Admin", hash: "#/" }, { label: "Not found" }]}>
295
+ <p>
296
+ Unknown resource <code>{route.app}.{route.model}</code>.
297
+ </p>
298
+ </Shell>
299
+ );
300
+ }
301
+ if (route.name === "list")
302
+ return <ListScreen engine={client.engine} resource={resource} />;
303
+ if (route.name === "add")
304
+ return <FormScreen engine={client.engine} resource={resource} />;
305
+ return (
306
+ <FormScreen engine={client.engine} resource={resource} id={route.id} />
307
+ );
308
+ }
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Pure adapters that turn the compiled manifest (`schema.rastack.json`) into the
3
+ * shapes the in-browser admin renders from — resource/field descriptors, the
4
+ * changelist columns, form field controls, value coercion, and the REST paths
5
+ * the WASM engine's `handle` answers. No React, no DOM, no engine — unit-tested
6
+ * directly (mirrors the Rust admin's manifest-driven rendering in
7
+ * `rust/rastack-api/src/admin.rs`, which derives every control from `field.ty`).
8
+ */
9
+
10
+ import type { FormType } from "../hooks/form/interfaces";
11
+ import type { FieldOption } from "../hooks/form/interfaces";
12
+ import type { ReturnedHeader } from "../hooks/query/interfaces";
13
+ import { humanizeKey } from "../components/util";
14
+
15
+ /** The manifest's scalar/fk field type, as serialized in schema.rastack.json. */
16
+ export type ManifestFieldType =
17
+ | "string"
18
+ | "int"
19
+ | "float"
20
+ | "bool"
21
+ | "datetime"
22
+ | "uuid"
23
+ | "fk";
24
+
25
+ export interface AdminField {
26
+ name: string;
27
+ type: ManifestFieldType;
28
+ primaryKey: boolean;
29
+ maxLength?: number;
30
+ nullable: boolean;
31
+ /** `{ app, model }` for a foreign key (`type === "fk"`). */
32
+ relation?: { app: string; model: string };
33
+ }
34
+
35
+ export interface AdminResource {
36
+ app: string;
37
+ model: string;
38
+ /** `"app.model"` — the stable id used in routes. */
39
+ key: string;
40
+ label: string;
41
+ fields: AdminField[];
42
+ /** Searchable field names (drives the search box + the API `search` param). */
43
+ search: string[];
44
+ /** `admin.listDisplay` columns, or null to fall back to all fields. */
45
+ listDisplay: string[] | null;
46
+ /** Whether this resource is shown in the admin (admin !== false). */
47
+ adminEnabled: boolean;
48
+ }
49
+
50
+ interface RawField {
51
+ name: string;
52
+ type: ManifestFieldType;
53
+ primaryKey?: boolean;
54
+ maxLength?: number;
55
+ null?: boolean;
56
+ relation?: { app: string; model: string };
57
+ }
58
+ interface RawResource {
59
+ app: string;
60
+ model: string;
61
+ fields: RawField[];
62
+ search?: string[];
63
+ admin?: { listDisplay?: string[] } | false;
64
+ }
65
+ interface RawManifest {
66
+ resources?: RawResource[];
67
+ }
68
+
69
+ /** Parse a manifest (object or JSON string) into admin resource descriptors. */
70
+ export function parseManifest(manifest: object | string): AdminResource[] {
71
+ const doc: RawManifest =
72
+ typeof manifest === "string" ? JSON.parse(manifest) : (manifest as RawManifest);
73
+ return (doc.resources ?? []).map((r) => ({
74
+ app: r.app,
75
+ model: r.model,
76
+ key: `${r.app}.${r.model}`,
77
+ label: humanizeKey(r.model),
78
+ fields: (r.fields ?? []).map((f) => ({
79
+ name: f.name,
80
+ type: f.type,
81
+ primaryKey: f.primaryKey === true,
82
+ maxLength: f.maxLength,
83
+ nullable: f.null === true,
84
+ relation: f.relation,
85
+ })),
86
+ search: r.search ?? [],
87
+ listDisplay:
88
+ r.admin && Array.isArray(r.admin.listDisplay) ? r.admin.listDisplay : null,
89
+ adminEnabled: r.admin !== false,
90
+ }));
91
+ }
92
+
93
+ /** The resources shown in the admin index (admin not disabled). */
94
+ export function adminResources(resources: AdminResource[]): AdminResource[] {
95
+ return resources.filter((r) => r.adminEnabled);
96
+ }
97
+
98
+ export function findResource(
99
+ resources: AdminResource[],
100
+ app: string,
101
+ model: string,
102
+ ): AdminResource | undefined {
103
+ return resources.find((r) => r.app === app && r.model === model);
104
+ }
105
+
106
+ // -- REST paths the WASM engine's `handle` answers --------------------------
107
+
108
+ export function listPath(app: string, model: string): string {
109
+ return `/api/${app}/v1/${model}/`;
110
+ }
111
+ export function detailPath(app: string, model: string, id: string | number): string {
112
+ return `/api/${app}/v1/${model}/${id}/`;
113
+ }
114
+
115
+ // -- changelist -------------------------------------------------------------
116
+
117
+ /** The DRF list envelope the engine returns for a list request. */
118
+ export interface ListEnvelope {
119
+ count: number;
120
+ results: Record<string, unknown>[];
121
+ }
122
+
123
+ /** Read the DRF `{ count, results }` list envelope defensively. */
124
+ export function readListEnvelope(body: unknown): ListEnvelope {
125
+ const b = (body ?? {}) as { count?: unknown; results?: unknown };
126
+ return {
127
+ count: typeof b.count === "number" ? b.count : 0,
128
+ results: Array.isArray(b.results) ? (b.results as Record<string, unknown>[]) : [],
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Changelist columns: an `id` column first, then `admin.listDisplay` if set,
134
+ * else every field name — matching the Rust admin (`admin.rs` changelist).
135
+ */
136
+ export function changelistHeaders(resource: AdminResource): ReturnedHeader[] {
137
+ const names =
138
+ resource.listDisplay ?? resource.fields.map((f) => f.name);
139
+ const keys = ["id", ...names.filter((n) => n !== "id")];
140
+ return keys.map((key) => ({ key, name: humanizeKey(key) }));
141
+ }
142
+
143
+ /** Page count for a total + page size (never below 1, like the Rust admin). */
144
+ export function pageCount(total: number, pageSize: number): number {
145
+ return Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
146
+ }
147
+
148
+ /** The admin's fixed changelist page size (matches `admin.rs`). */
149
+ export const PAGE_SIZE = 25;
150
+
151
+ // -- forms ------------------------------------------------------------------
152
+
153
+ /** The fields an add/change form renders — every field except the primary key. */
154
+ export function formFields(resource: AdminResource): AdminField[] {
155
+ return resource.fields.filter((f) => !f.primaryKey);
156
+ }
157
+
158
+ /**
159
+ * The `<AutoForm>` control kind for a manifest field. Foreign keys become a
160
+ * `select` (populated from the target table); the rest map by scalar type —
161
+ * exactly the control choices the Rust admin's `render_input` makes.
162
+ */
163
+ export function fieldControlType(field: AdminField): FormType {
164
+ switch (field.type) {
165
+ case "fk":
166
+ return "select";
167
+ case "bool":
168
+ return "boolean";
169
+ case "int":
170
+ case "float":
171
+ return "number";
172
+ case "datetime":
173
+ return "datetime";
174
+ default:
175
+ return "text";
176
+ }
177
+ }
178
+
179
+ /**
180
+ * A human label for a row in an FK `<select>`: the first non-`id` string field's
181
+ * value, else `#{id}` — the same heuristic as the Rust admin's `option_label`.
182
+ */
183
+ export function optionLabel(row: Record<string, unknown>): string {
184
+ for (const [key, value] of Object.entries(row)) {
185
+ if (key !== "id" && typeof value === "string" && value.length > 0) {
186
+ return `${value} (#${row.id})`;
187
+ }
188
+ }
189
+ return `#${row.id ?? ""}`;
190
+ }
191
+
192
+ /** FK `<select>` options from the target table's rows. */
193
+ export function optionsFromRows(rows: Record<string, unknown>[]): FieldOption[] {
194
+ return rows.map((row) => ({
195
+ label: optionLabel(row),
196
+ value: (row.id as string | number) ?? "",
197
+ }));
198
+ }
199
+
200
+ /** Initial form values keyed by field name (drives AutoForm's field order). */
201
+ export function initialFormValues(
202
+ resource: AdminResource,
203
+ ): Record<string, unknown> {
204
+ const values: Record<string, unknown> = {};
205
+ for (const field of formFields(resource)) {
206
+ values[field.name] = field.type === "bool" ? false : "";
207
+ }
208
+ return values;
209
+ }
210
+
211
+ /**
212
+ * Coerce one raw form value to the type the API expects, mirroring the Rust
213
+ * admin's `form_to_row`: booleans always sent; numbers/FKs parsed (raw string
214
+ * on parse failure); empty non-bool values are omitted (so nullable fields are
215
+ * not sent as `""`). Returns `undefined` to signal "omit this field".
216
+ */
217
+ export function coerceFormValue(field: AdminField, raw: unknown): unknown {
218
+ if (field.type === "bool") return raw === true || raw === "true";
219
+ if (raw === "" || raw === null || raw === undefined) return undefined;
220
+ if (field.type === "int" || field.type === "float" || field.type === "fk") {
221
+ const n = Number(raw);
222
+ return Number.isFinite(n) ? n : raw;
223
+ }
224
+ return raw;
225
+ }
226
+
227
+ /** Build the request body from form values, applying `coerceFormValue`. */
228
+ export function buildWriteBody(
229
+ resource: AdminResource,
230
+ values: Record<string, unknown>,
231
+ ): Record<string, unknown> {
232
+ const body: Record<string, unknown> = {};
233
+ for (const field of formFields(resource)) {
234
+ const coerced = coerceFormValue(field, values[field.name]);
235
+ if (coerced !== undefined) body[field.name] = coerced;
236
+ }
237
+ return body;
238
+ }
239
+
240
+ /** Extract a `{detail}` error message from a non-2xx `handle` body. */
241
+ export function errorDetail(body: unknown): string {
242
+ if (body && typeof body === "object" && "detail" in body) {
243
+ const detail = (body as { detail: unknown }).detail;
244
+ return typeof detail === "string" ? detail : JSON.stringify(detail);
245
+ }
246
+ return typeof body === "string" ? body : "Request failed";
247
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Entry point for the `rastack dev` in-browser admin, bundled by esbuild into
3
+ * `dist/admin.js` and served at `/dev/admin.js`. It fetches the compiled
4
+ * schema/openapi, boots the WASM engine via `<RAStackProvider mode="local">`
5
+ * (seeding from the served `/data/warehouse/`), themes everything with
6
+ * `<ThemeProvider>`, and renders the manifest-driven `<AdminApp>`.
7
+ */
8
+
9
+ import React, { useEffect, useState } from "react";
10
+ import { createRoot } from "react-dom/client";
11
+ import { RAStackProvider } from "../provider";
12
+ import type { RastackWasmModule } from "../provider/types";
13
+ import { ThemeProvider } from "../theme";
14
+ import { defineTheme } from "../tokens";
15
+ import { AdminApp } from "./AdminApp";
16
+
17
+ /**
18
+ * Load the wasm engine from the URL `rastack dev` serves it at (`/wasm/…`).
19
+ * The dynamic `import()` is hidden behind `new Function` so esbuild leaves it
20
+ * for the browser to resolve at runtime (the same trick `defaultEngineLoader`
21
+ * uses) rather than trying to bundle the `.wasm` at build time.
22
+ */
23
+ const loadEngine = async (): Promise<RastackWasmModule> => {
24
+ const runtimeImport = new Function("s", "return import(s)") as (
25
+ s: string,
26
+ ) => Promise<any>;
27
+ const mod: RastackWasmModule = await runtimeImport("/wasm/rastack_wasm.js");
28
+ const init = mod.default || mod.init;
29
+ if (typeof init === "function") await init();
30
+ return mod;
31
+ };
32
+
33
+ const theme = defineTheme({ brand: "#2f6feb", radius: "rounded" });
34
+
35
+ interface Compiled {
36
+ manifest: object;
37
+ openapi?: object;
38
+ }
39
+
40
+ function Boot(): React.JSX.Element {
41
+ const [compiled, setCompiled] = useState<Compiled | null>(null);
42
+ const [error, setError] = useState<string | undefined>();
43
+
44
+ useEffect(() => {
45
+ (async () => {
46
+ const manifestRes = await fetch("/schema.rastack.json");
47
+ if (!manifestRes.ok)
48
+ throw new Error(`schema.rastack.json (${manifestRes.status})`);
49
+ const manifest = await manifestRes.json();
50
+ let openapi: object | undefined;
51
+ try {
52
+ const openapiRes = await fetch("/openapi.json");
53
+ if (openapiRes.ok) openapi = await openapiRes.json();
54
+ } catch {
55
+ /* openapi is optional */
56
+ }
57
+ setCompiled({ manifest, openapi });
58
+ })().catch((e) => setError(String(e)));
59
+ }, []);
60
+
61
+ if (error)
62
+ return (
63
+ <div className="rastack-boot">
64
+ Failed to load the compiled schema: {error}
65
+ </div>
66
+ );
67
+ if (!compiled)
68
+ return <div className="rastack-boot">Compiling the schema…</div>;
69
+
70
+ return (
71
+ <RAStackProvider
72
+ mode="local"
73
+ manifest={compiled.manifest}
74
+ openapi={compiled.openapi}
75
+ loadEngine={loadEngine}
76
+ warehouseBaseUrl="/data/warehouse/"
77
+ persist={false}
78
+ cache={false}
79
+ >
80
+ <ThemeProvider tokens={theme}>
81
+ <AdminApp manifest={compiled.manifest} />
82
+ </ThemeProvider>
83
+ </RAStackProvider>
84
+ );
85
+ }
86
+
87
+ const container = document.getElementById("root");
88
+ if (container) createRoot(container).render(<Boot />);