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
package/dist/deploy/ci.js CHANGED
@@ -50,7 +50,7 @@ function stackOutput(cfg, outputKey, literal) {
50
50
  }
51
51
  /** The Lambda staging directory `cargo lambda build` writes the binary into. */
52
52
  function stageDir(cfg) {
53
- return `rust/target/lambda/${cfg.lambda.bin}`;
53
+ return `engine/target/lambda/${cfg.lambda.bin}`;
54
54
  }
55
55
  /** `check` — validate the resource graph and compile the manifest. A CI gate. */
56
56
  function checkSteps(cfg, bin) {
@@ -76,7 +76,7 @@ function buildSteps(cfg, opts = {}) {
76
76
  {
77
77
  name: "Cross-build the Lambda binary",
78
78
  run: `cargo lambda build --release ${archFlag} --bin ${cfg.lambda.bin} --features ${cfg.lambda.features}`,
79
- cwd: "rust",
79
+ cwd: "engine",
80
80
  },
81
81
  {
82
82
  name: "Bake the Cognito JWKS into the bundle",
package/dist/deploy/io.js CHANGED
@@ -85,8 +85,8 @@ function resolveDeployAsset(relPath, cwd = process.cwd()) {
85
85
  const candidates = [
86
86
  // Installed package: dist/deploy/io.js → dist/templates/cloudformation/…
87
87
  path.join(__dirname, "..", "templates", "cloudformation", relPath),
88
- // Running from TS source: tools/src/deploy/io.ts → repo-root deploy/…
89
- path.join(__dirname, "..", "..", "..", "deploy", "cloudformation", relPath),
88
+ // Running from TS source: src/deploy/io.ts → repo-root deploy/…
89
+ path.join(__dirname, "..", "..", "deploy", "cloudformation", relPath),
90
90
  // Invoked inside the framework repo itself (cwd is the repo root).
91
91
  path.join(cwd, "deploy", "cloudformation", relPath),
92
92
  ];
@@ -74,7 +74,7 @@ export declare function contentType(filePath: string): string;
74
74
  * Ordered directories to look for the prebuilt wasm bundle (`rastack_wasm.js` +
75
75
  * `rastack_wasm_bg.wasm`). The bundle is shipped inside the package
76
76
  * (`dist/wasm/`, copied at build), so `rastack dev` works from any repo with no
77
- * cargo; the repo-local `tools/wasm/` (from a source build) and a sibling of the
77
+ * cargo; the repo-local `wasm/` (from a source build) and a sibling of the
78
78
  * running module are fallbacks for development. `dirnameDir` is the directory of
79
79
  * the compiled `rastack-dev.js` (`dist/`), so `../wasm` and `./wasm` cover both
80
80
  * `dist/rastack-dev.js` → `dist/wasm` and `dist/dev/…` layouts.
@@ -171,7 +171,7 @@ function contentType(filePath) {
171
171
  * Ordered directories to look for the prebuilt wasm bundle (`rastack_wasm.js` +
172
172
  * `rastack_wasm_bg.wasm`). The bundle is shipped inside the package
173
173
  * (`dist/wasm/`, copied at build), so `rastack dev` works from any repo with no
174
- * cargo; the repo-local `tools/wasm/` (from a source build) and a sibling of the
174
+ * cargo; the repo-local `wasm/` (from a source build) and a sibling of the
175
175
  * running module are fallbacks for development. `dirnameDir` is the directory of
176
176
  * the compiled `rastack-dev.js` (`dist/`), so `../wasm` and `./wasm` cover both
177
177
  * `dist/rastack-dev.js` → `dist/wasm` and `dist/dev/…` layouts.
@@ -180,7 +180,6 @@ function wasmBundleCandidates(dirnameDir, cwd = process.cwd()) {
180
180
  return [
181
181
  path.join(dirnameDir, "wasm"),
182
182
  path.join(dirnameDir, "..", "wasm"),
183
- path.join(cwd, "tools", "wasm"),
184
183
  path.join(cwd, "wasm"),
185
184
  ];
186
185
  }
@@ -202,13 +201,30 @@ function renderShell(title = "React API Stack — admin") {
202
201
  <meta name="viewport" content="width=device-width, initial-scale=1" />
203
202
  <title>${title}</title>
204
203
  <style>
205
- html, body { margin: 0; height: 100%; }
204
+ html, body { margin: 0; height: 100%; background: #F2F5FB;
205
+ font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
206
+ @media (prefers-color-scheme: dark) { html, body { background: #06080F; } }
206
207
  #root { min-height: 100%; }
207
- .rastack-boot { font: 14px system-ui, sans-serif; padding: 2rem; color: #555; }
208
+ .rastack-boot { min-height: 100vh; display: flex; flex-direction: column;
209
+ align-items: center; justify-content: center; gap: 20px; }
210
+ .rastack-boot-logo { width: 60px; height: 60px; border-radius: 18px; background: #4F6BFF;
211
+ box-shadow: 0 8px 24px rgba(79,107,255,0.30); animation: rastack-boot-bob 3s ease-in-out infinite; }
212
+ .rastack-boot-track { position: relative; width: 168px; height: 4px; border-radius: 999px;
213
+ overflow: hidden; background: rgba(11,16,32,0.10); }
214
+ @media (prefers-color-scheme: dark) { .rastack-boot-track { background: rgba(233,239,255,0.14); } }
215
+ .rastack-boot-track::after { content: ""; position: absolute; top: 0; left: -42%; height: 100%;
216
+ width: 40%; border-radius: inherit; background: linear-gradient(90deg, transparent, #4F6BFF);
217
+ animation: rastack-boot-slide 1.25s cubic-bezier(0.5,0.1,0.3,1) infinite; }
218
+ @keyframes rastack-boot-bob { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-3px); } }
219
+ @keyframes rastack-boot-slide { to { left: 100%; } }
220
+ @media (prefers-reduced-motion: reduce) {
221
+ .rastack-boot-logo { animation: none; }
222
+ .rastack-boot-track::after { animation: none; left: 0; width: 100%; }
223
+ }
208
224
  </style>
209
225
  </head>
210
226
  <body>
211
- <div id="root"><div class="rastack-boot">Loading the WASM engine…</div></div>
227
+ <div id="root"><div class="rastack-boot"><div class="rastack-boot-logo"></div><div class="rastack-boot-track"></div></div></div>
212
228
  <script src="/dev/admin.js"></script>
213
229
  </body>
214
230
  </html>
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The proxy's response cache — the reason `all API calls are proxied in Lambda`.
3
+ *
4
+ * A tiny pure abstraction (`get`/`set` over string keys) so the *same* handler
5
+ * runs against an in-memory `Map` (the mini `rastack dev` service, a warm Lambda
6
+ * container) or a shared store (DynamoDB/ElastiCache in production) with no code
7
+ * change. Freshness and ETag bookkeeping are pure functions here; the transport
8
+ * (conditional `If-None-Match` requests) lives in the handler.
9
+ */
10
+ export interface CacheEntry {
11
+ /** The normalised rastack response body that was cached. */
12
+ value: unknown;
13
+ /** Epoch ms the entry was (re)validated. */
14
+ storedAt: number;
15
+ /** The upstream ETag, if any, for conditional revalidation. */
16
+ etag?: string;
17
+ }
18
+ export interface ProxyCache {
19
+ get(key: string): CacheEntry | undefined;
20
+ set(key: string, entry: CacheEntry): void;
21
+ delete(key: string): void;
22
+ }
23
+ /** An in-memory cache — the default for `rastack dev` and warm Lambda containers. */
24
+ export declare function memoryCache(): ProxyCache;
25
+ /** A stable cache key for a request (order-independent over query params). */
26
+ export declare function cacheKey(app: string, model: string, id: string | undefined, query: Record<string, string[]>): string;
27
+ /** Whether an entry is still within its TTL (and so served without an upstream call). */
28
+ export declare function isFresh(entry: CacheEntry, ttlMs: number, now: number): boolean;
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ /**
3
+ * The proxy's response cache — the reason `all API calls are proxied in Lambda`.
4
+ *
5
+ * A tiny pure abstraction (`get`/`set` over string keys) so the *same* handler
6
+ * runs against an in-memory `Map` (the mini `rastack dev` service, a warm Lambda
7
+ * container) or a shared store (DynamoDB/ElastiCache in production) with no code
8
+ * change. Freshness and ETag bookkeeping are pure functions here; the transport
9
+ * (conditional `If-None-Match` requests) lives in the handler.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.memoryCache = memoryCache;
13
+ exports.cacheKey = cacheKey;
14
+ exports.isFresh = isFresh;
15
+ /** An in-memory cache — the default for `rastack dev` and warm Lambda containers. */
16
+ function memoryCache() {
17
+ const store = new Map();
18
+ return {
19
+ get: (key) => store.get(key),
20
+ set: (key, entry) => void store.set(key, entry),
21
+ delete: (key) => void store.delete(key),
22
+ };
23
+ }
24
+ /** A stable cache key for a request (order-independent over query params). */
25
+ function cacheKey(app, model, id, query) {
26
+ const params = Object.keys(query)
27
+ .sort()
28
+ .map((k) => `${k}=${[...query[k]].sort().join(",")}`)
29
+ .join("&");
30
+ return `${app}.${model}/${id ?? "list"}?${params}`;
31
+ }
32
+ /** Whether an entry is still within its TTL (and so served without an upstream call). */
33
+ function isFresh(entry, ttlMs, now) {
34
+ return now - entry.storedAt < ttlMs;
35
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Normalise an upstream response into rastack's REST contract — the DRF-shaped
3
+ * list envelope and bare detail object the typed hooks and `<DataTable>` expect,
4
+ * identical to what the Rust API and the WASM/local engine emit.
5
+ *
6
+ * External APIs rarely page the way rastack does (GitHub uses `per_page` + Link
7
+ * headers; a GraphQL list just returns an array), so the envelope is synthesised
8
+ * from what we have: the page the client asked for and the number of items this
9
+ * page actually returned. `count`/`last` are best-effort when the upstream total
10
+ * is unknown — enough for the pager to advance while there is a full page.
11
+ */
12
+ export interface ListEnvelope {
13
+ results: Record<string, unknown>[];
14
+ pagination: {
15
+ current: number;
16
+ last: number;
17
+ next: number | null;
18
+ previous: number | null;
19
+ page_size: number;
20
+ /** Best-effort total; equals what we can prove when the upstream omits it. */
21
+ count: number;
22
+ };
23
+ }
24
+ export interface NormalizeOptions {
25
+ page: number;
26
+ pageSize: number;
27
+ primaryKey: string;
28
+ fields?: string[];
29
+ /** A known upstream total, if the transport could determine one. */
30
+ totalCount?: number;
31
+ }
32
+ /** Shape a page of upstream items into the list envelope. */
33
+ export declare function toListEnvelope(items: unknown, opts: NormalizeOptions): ListEnvelope;
34
+ /** Shape a single upstream object into a rastack detail row. */
35
+ export declare function toDetail(item: unknown, primaryKey: string, fields?: string[]): Record<string, unknown> | null;
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ /**
3
+ * Normalise an upstream response into rastack's REST contract — the DRF-shaped
4
+ * list envelope and bare detail object the typed hooks and `<DataTable>` expect,
5
+ * identical to what the Rust API and the WASM/local engine emit.
6
+ *
7
+ * External APIs rarely page the way rastack does (GitHub uses `per_page` + Link
8
+ * headers; a GraphQL list just returns an array), so the envelope is synthesised
9
+ * from what we have: the page the client asked for and the number of items this
10
+ * page actually returned. `count`/`last` are best-effort when the upstream total
11
+ * is unknown — enough for the pager to advance while there is a full page.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.toListEnvelope = toListEnvelope;
15
+ exports.toDetail = toDetail;
16
+ const util_1 = require("./util");
17
+ /** Shape a page of upstream items into the list envelope. */
18
+ function toListEnvelope(items, opts) {
19
+ const rows = (Array.isArray(items) ? items : [])
20
+ .filter((r) => !!r && typeof r === "object")
21
+ .map((r) => (0, util_1.projectFields)((0, util_1.ensureId)(r, opts.primaryKey), opts.fields));
22
+ const { page, pageSize } = opts;
23
+ // A full page implies there may be more; a short page is the last one.
24
+ const hasMore = rows.length >= pageSize;
25
+ const count = opts.totalCount ?? (page - 1) * pageSize + rows.length;
26
+ const last = opts.totalCount
27
+ ? Math.max(1, Math.ceil(opts.totalCount / pageSize))
28
+ : hasMore
29
+ ? page + 1
30
+ : page;
31
+ return {
32
+ results: rows,
33
+ pagination: {
34
+ current: page,
35
+ last,
36
+ next: page < last ? page + 1 : null,
37
+ previous: page > 1 ? page - 1 : null,
38
+ page_size: pageSize,
39
+ count,
40
+ },
41
+ };
42
+ }
43
+ /** Shape a single upstream object into a rastack detail row. */
44
+ function toDetail(item, primaryKey, fields) {
45
+ if (!item || typeof item !== "object")
46
+ return null;
47
+ return (0, util_1.projectFields)((0, util_1.ensureId)(item, primaryKey), fields);
48
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Build the upstream request for a GraphQL-backed resource (Expo's API is
3
+ * GraphQL, for instance), and pull the item array / detail object out of the
4
+ * `data` payload. A GraphQL call is always a POST of `{ query, variables }`; the
5
+ * rastack list params map onto the query's variables (`page_size` → `first`,
6
+ * etc.), so the same `/api/{app}/v1/{model}/` contract fronts a GraphQL API with
7
+ * no client awareness that it isn't REST.
8
+ */
9
+ import type { GraphQLBinding } from "./types";
10
+ export interface GraphQLRequest {
11
+ url: string;
12
+ method: "POST";
13
+ headers: Record<string, string>;
14
+ body: string;
15
+ }
16
+ /** Build the GraphQL list request. */
17
+ export declare function graphqlListRequest(binding: GraphQLBinding, query: Record<string, string[]>, headers: Record<string, string>): GraphQLRequest;
18
+ /** Build the GraphQL detail request. Throws if the binding has no detail query. */
19
+ export declare function graphqlDetailRequest(binding: GraphQLBinding, id: string, headers: Record<string, string>): GraphQLRequest;
20
+ /** A GraphQL response is `{ data, errors }`; surface errors, else return `data`. */
21
+ export declare function graphqlData(body: unknown): unknown;
22
+ /** Extract the item array from a GraphQL list response. */
23
+ export declare function graphqlListItems(binding: GraphQLBinding, body: unknown): unknown;
24
+ /** Extract the detail object from a GraphQL detail response. */
25
+ export declare function graphqlDetailItem(binding: GraphQLBinding, body: unknown): unknown;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * Build the upstream request for a GraphQL-backed resource (Expo's API is
4
+ * GraphQL, for instance), and pull the item array / detail object out of the
5
+ * `data` payload. A GraphQL call is always a POST of `{ query, variables }`; the
6
+ * rastack list params map onto the query's variables (`page_size` → `first`,
7
+ * etc.), so the same `/api/{app}/v1/{model}/` contract fronts a GraphQL API with
8
+ * no client awareness that it isn't REST.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.graphqlListRequest = graphqlListRequest;
12
+ exports.graphqlDetailRequest = graphqlDetailRequest;
13
+ exports.graphqlData = graphqlData;
14
+ exports.graphqlListItems = graphqlListItems;
15
+ exports.graphqlDetailItem = graphqlDetailItem;
16
+ const util_1 = require("./util");
17
+ function buildVariables(binding, query, id) {
18
+ const vars = { ...(binding.variables ?? {}) };
19
+ const map = binding.variableMap ?? {};
20
+ const put = (rastackKey, raw) => {
21
+ const name = map[rastackKey];
22
+ if (!name || raw == null || raw === "")
23
+ return;
24
+ // page_size / page are numbers in GraphQL land; coerce when they parse.
25
+ const num = Number(raw);
26
+ vars[name] = Number.isFinite(num) && /^\d+$/.test(raw) ? num : raw;
27
+ };
28
+ put("page", (0, util_1.firstParam)(query, "page"));
29
+ put("page_size", (0, util_1.firstParam)(query, "page_size"));
30
+ put("search", (0, util_1.firstParam)(query, "search"));
31
+ put("order_by", (0, util_1.firstParam)(query, "order_by"));
32
+ if (id != null && map.id)
33
+ vars[map.id] = id;
34
+ return vars;
35
+ }
36
+ /** Build the GraphQL list request. */
37
+ function graphqlListRequest(binding, query, headers) {
38
+ return {
39
+ url: binding.baseUrl,
40
+ method: "POST",
41
+ headers: { "Content-Type": "application/json", ...headers },
42
+ body: JSON.stringify({
43
+ query: binding.listQuery,
44
+ variables: buildVariables(binding, query),
45
+ }),
46
+ };
47
+ }
48
+ /** Build the GraphQL detail request. Throws if the binding has no detail query. */
49
+ function graphqlDetailRequest(binding, id, headers) {
50
+ if (!binding.detailQuery) {
51
+ throw new Error(`GraphQL binding ${binding.app}.${binding.model} has no detailQuery.`);
52
+ }
53
+ return {
54
+ url: binding.baseUrl,
55
+ method: "POST",
56
+ headers: { "Content-Type": "application/json", ...headers },
57
+ body: JSON.stringify({
58
+ query: binding.detailQuery,
59
+ variables: buildVariables(binding, {}, id),
60
+ }),
61
+ };
62
+ }
63
+ /** A GraphQL response is `{ data, errors }`; surface errors, else return `data`. */
64
+ function graphqlData(body) {
65
+ const payload = body;
66
+ if (payload?.errors && payload.errors.length) {
67
+ throw new Error(`GraphQL error: ${payload.errors.map((e) => e.message ?? "unknown").join("; ")}`);
68
+ }
69
+ return payload?.data;
70
+ }
71
+ /** Extract the item array from a GraphQL list response. */
72
+ function graphqlListItems(binding, body) {
73
+ return (0, util_1.getPath)(graphqlData(body), binding.itemsPath);
74
+ }
75
+ /** Extract the detail object from a GraphQL detail response. */
76
+ function graphqlDetailItem(binding, body) {
77
+ return (0, util_1.getPath)(graphqlData(body), binding.detailPath ?? binding.itemsPath);
78
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The proxy core — one pure(ish) function from a rastack request to a rastack
3
+ * response, given the bindings and injected effects (`fetch`, cache, clock,
4
+ * auth). This is the "work" the user's Lambda does; the mini service inside
5
+ * `rastack dev` and the deployed Lambda are both thin adapters that parse an
6
+ * HTTP request into {@link RastackRequest}, call {@link handleExternalRequest},
7
+ * and serialise the result.
8
+ *
9
+ * Responsibilities, in order:
10
+ * 1. Resolve the binding for `app.model` (404 if none — not an external model).
11
+ * 2. Serve a fresh cache entry without any upstream call (the cache is the
12
+ * whole point of the proxy).
13
+ * 3. Otherwise call the upstream (REST or GraphQL), using a conditional
14
+ * `If-None-Match` when we hold an ETag so a `304` costs no rate-limit.
15
+ * 4. Normalise into the DRF envelope / detail row, cache it, and return it.
16
+ *
17
+ * Auth (the GitHub/Expo token from Secrets Manager in prod, an env var in dev)
18
+ * is injected by `deps.authHeaders`, so **no credential is ever hard-coded here**.
19
+ */
20
+ import { type ProxyCache } from "./cache";
21
+ import { type FetchLike, type ResourceBinding, type ResourceResolver } from "./types";
22
+ export interface RastackRequest {
23
+ app: string;
24
+ model: string;
25
+ /** Present for a detail request (`/…/{model}/{id}/`). */
26
+ id?: string;
27
+ query: Record<string, string[]>;
28
+ }
29
+ export interface RastackResponse {
30
+ status: number;
31
+ body: unknown;
32
+ /** Whether this response was served from cache without an upstream call. */
33
+ cached: boolean;
34
+ }
35
+ export interface HandlerDeps {
36
+ fetch: FetchLike;
37
+ cache: ProxyCache;
38
+ now: () => number;
39
+ /**
40
+ * Resolve upstream auth/headers for an app — e.g. `{ Authorization: "Bearer …" }`.
41
+ * Reads Secrets Manager in the Lambda; reads env vars in `rastack dev`.
42
+ */
43
+ authHeaders?: (app: string) => Record<string, string> | Promise<Record<string, string>>;
44
+ /**
45
+ * Named resolvers for {@link ResolverBinding}s — the app plugs in transports
46
+ * the core doesn't ship (e.g. an AWS SDK resolver), keeping the core
47
+ * dependency-free.
48
+ */
49
+ resolvers?: Record<string, ResourceResolver>;
50
+ }
51
+ /** Handle one rastack request against the external bindings. */
52
+ export declare function handleExternalRequest(req: RastackRequest, bindings: ResourceBinding[], deps: HandlerDeps): Promise<RastackResponse>;
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ /**
3
+ * The proxy core — one pure(ish) function from a rastack request to a rastack
4
+ * response, given the bindings and injected effects (`fetch`, cache, clock,
5
+ * auth). This is the "work" the user's Lambda does; the mini service inside
6
+ * `rastack dev` and the deployed Lambda are both thin adapters that parse an
7
+ * HTTP request into {@link RastackRequest}, call {@link handleExternalRequest},
8
+ * and serialise the result.
9
+ *
10
+ * Responsibilities, in order:
11
+ * 1. Resolve the binding for `app.model` (404 if none — not an external model).
12
+ * 2. Serve a fresh cache entry without any upstream call (the cache is the
13
+ * whole point of the proxy).
14
+ * 3. Otherwise call the upstream (REST or GraphQL), using a conditional
15
+ * `If-None-Match` when we hold an ETag so a `304` costs no rate-limit.
16
+ * 4. Normalise into the DRF envelope / detail row, cache it, and return it.
17
+ *
18
+ * Auth (the GitHub/Expo token from Secrets Manager in prod, an env var in dev)
19
+ * is injected by `deps.authHeaders`, so **no credential is ever hard-coded here**.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.handleExternalRequest = handleExternalRequest;
23
+ const cache_1 = require("./cache");
24
+ const envelope_1 = require("./envelope");
25
+ const graphql_1 = require("./graphql");
26
+ const rest_1 = require("./rest");
27
+ const types_1 = require("./types");
28
+ const util_1 = require("./util");
29
+ const DEFAULT_TTL_MS = 60000;
30
+ function pageParams(query) {
31
+ const page = Math.max(1, parseInt((0, util_1.firstParam)(query, "page") ?? "1", 10) || 1);
32
+ const pageSize = Math.max(1, parseInt((0, util_1.firstParam)(query, "page_size") ?? "20", 10) || 20);
33
+ return { page, pageSize };
34
+ }
35
+ async function upstreamHeaders(binding, deps) {
36
+ const auth = deps.authHeaders ? await deps.authHeaders(binding.app) : {};
37
+ return { ...(binding.headers ?? {}), ...auth };
38
+ }
39
+ /** Fetch + parse an upstream request, applying a conditional ETag when supplied. */
40
+ async function callUpstream(req, deps, etag) {
41
+ const headers = { ...req.headers };
42
+ if (etag)
43
+ headers["If-None-Match"] = etag;
44
+ const res = await deps.fetch(req.url, {
45
+ method: req.method,
46
+ headers,
47
+ body: "body" in req ? req.body : undefined,
48
+ });
49
+ if (res.status === 304)
50
+ return { status: 304, body: undefined, etag };
51
+ const nextEtag = res.headers.get("etag") ?? undefined;
52
+ const body = res.status === 204 ? null : await res.json();
53
+ return { status: res.status, body, etag: nextEtag };
54
+ }
55
+ /** Handle one rastack request against the external bindings. */
56
+ async function handleExternalRequest(req, bindings, deps) {
57
+ const binding = (0, types_1.findBinding)(bindings, req.app, req.model);
58
+ if (!binding) {
59
+ return { status: 404, body: { detail: `No external binding for ${req.app}.${req.model}.` }, cached: false };
60
+ }
61
+ const ttl = binding.ttlMs ?? DEFAULT_TTL_MS;
62
+ const pk = binding.primaryKey ?? "id";
63
+ const key = (0, cache_1.cacheKey)(req.app, req.model, req.id, req.query);
64
+ const now = deps.now();
65
+ const hit = deps.cache.get(key);
66
+ if (hit && (0, cache_1.isFresh)(hit, ttl, now)) {
67
+ return { status: 200, body: hit.value, cached: true };
68
+ }
69
+ const headers = await upstreamHeaders(binding, deps);
70
+ const isDetail = req.id != null;
71
+ try {
72
+ // Resolver transport: no HTTP — call the injected resolver, normalise, cache.
73
+ if (binding.transport === "resolver") {
74
+ const resolver = deps.resolvers?.[binding.resolver];
75
+ if (!resolver) {
76
+ return {
77
+ status: 501,
78
+ body: { detail: `No resolver "${binding.resolver}" registered for ${req.app}.${req.model}.` },
79
+ cached: false,
80
+ };
81
+ }
82
+ const data = await resolver({ app: req.app, model: req.model, id: req.id, query: req.query });
83
+ let value;
84
+ if (isDetail) {
85
+ value = (0, envelope_1.toDetail)(data, pk, binding.fields);
86
+ if (value == null)
87
+ return { status: 404, body: { detail: "Not found" }, cached: false };
88
+ }
89
+ else {
90
+ const { page, pageSize } = pageParams(req.query);
91
+ value = (0, envelope_1.toListEnvelope)(data, { page, pageSize, primaryKey: pk, fields: binding.fields });
92
+ }
93
+ deps.cache.set(key, { value, storedAt: now });
94
+ return { status: 200, body: value, cached: false };
95
+ }
96
+ // Build the upstream request for this transport + list/detail shape.
97
+ const upstream = binding.transport === "rest"
98
+ ? isDetail
99
+ ? (0, rest_1.restDetailRequest)(binding, req.id, headers)
100
+ : (0, rest_1.restListRequest)(binding, req.query, headers)
101
+ : isDetail
102
+ ? (0, graphql_1.graphqlDetailRequest)(binding, req.id, headers)
103
+ : (0, graphql_1.graphqlListRequest)(binding, req.query, headers);
104
+ const result = await callUpstream(upstream, deps, hit?.etag);
105
+ // A revalidated ETag: refresh the stored timestamp and serve the cache.
106
+ if (result.status === 304 && hit) {
107
+ deps.cache.set(key, { ...hit, storedAt: now });
108
+ return { status: 200, body: hit.value, cached: true };
109
+ }
110
+ if (result.status >= 400) {
111
+ return { status: result.status, body: result.body ?? { detail: "Upstream error." }, cached: false };
112
+ }
113
+ // Extract the item(s) for this transport and normalise into the contract.
114
+ let value;
115
+ if (isDetail) {
116
+ const item = binding.transport === "rest"
117
+ ? result.body
118
+ : (0, graphql_1.graphqlDetailItem)(binding, result.body);
119
+ value = (0, envelope_1.toDetail)(item, pk, binding.fields);
120
+ if (value == null)
121
+ return { status: 404, body: { detail: "Not found" }, cached: false };
122
+ }
123
+ else {
124
+ const items = binding.transport === "rest"
125
+ ? (0, rest_1.restListItems)(binding, result.body)
126
+ : (0, graphql_1.graphqlListItems)(binding, result.body);
127
+ const { page, pageSize } = pageParams(req.query);
128
+ value = (0, envelope_1.toListEnvelope)(items, { page, pageSize, primaryKey: pk, fields: binding.fields });
129
+ }
130
+ deps.cache.set(key, { value, storedAt: now, etag: result.etag });
131
+ return { status: 200, body: value, cached: false };
132
+ }
133
+ catch (err) {
134
+ // A stale cache entry is better than a hard failure while iterating.
135
+ if (hit)
136
+ return { status: 200, body: hit.value, cached: true };
137
+ return { status: 502, body: { detail: err.message }, cached: false };
138
+ }
139
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `rastack` external-API proxy core.
3
+ *
4
+ * Serves external REST/GraphQL APIs (GitHub, Expo, AWS) over the rastack
5
+ * `/api/{app}/v1/{model}/` contract so the *normal typed hooks* consume them
6
+ * unchanged: caches responses, injects auth, and normalises upstream payloads
7
+ * into the DRF envelope. Transport-agnostic and effect-injected, so the identical
8
+ * core runs in the deployed Lambda and in the mini service inside `rastack dev`.
9
+ */
10
+ export * from "./types";
11
+ export * from "./cache";
12
+ export * from "./envelope";
13
+ export * from "./rest";
14
+ export * from "./graphql";
15
+ export * from "./route";
16
+ export * from "./service";
17
+ export * from "./lambda";
18
+ export { handleExternalRequest, type RastackRequest, type RastackResponse, type HandlerDeps, } from "./handler";
19
+ export { getPath } from "./util";
@@ -0,0 +1,38 @@
1
+ "use strict";
2
+ /**
3
+ * `rastack` external-API proxy core.
4
+ *
5
+ * Serves external REST/GraphQL APIs (GitHub, Expo, AWS) over the rastack
6
+ * `/api/{app}/v1/{model}/` contract so the *normal typed hooks* consume them
7
+ * unchanged: caches responses, injects auth, and normalises upstream payloads
8
+ * into the DRF envelope. Transport-agnostic and effect-injected, so the identical
9
+ * core runs in the deployed Lambda and in the mini service inside `rastack dev`.
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
23
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.getPath = exports.handleExternalRequest = void 0;
27
+ __exportStar(require("./types"), exports);
28
+ __exportStar(require("./cache"), exports);
29
+ __exportStar(require("./envelope"), exports);
30
+ __exportStar(require("./rest"), exports);
31
+ __exportStar(require("./graphql"), exports);
32
+ __exportStar(require("./route"), exports);
33
+ __exportStar(require("./service"), exports);
34
+ __exportStar(require("./lambda"), exports);
35
+ var handler_1 = require("./handler");
36
+ Object.defineProperty(exports, "handleExternalRequest", { enumerable: true, get: function () { return handler_1.handleExternalRequest; } });
37
+ var util_1 = require("./util");
38
+ Object.defineProperty(exports, "getPath", { enumerable: true, get: function () { return util_1.getPath; } });
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The AWS Lambda adapter — the deployed twin of the `rastack dev` mini service.
3
+ *
4
+ * Both are thin shells over the same pure {@link handleExternalRequest} core:
5
+ * parse the incoming request into a {@link RastackRequest}, run the handler,
6
+ * serialise the result. The differences are injected, not branched:
7
+ * • **auth** — dev reads `GITHUB_TOKEN` from the environment; the Lambda's
8
+ * `deps.authHeaders` reads AWS Secrets Manager (the app supplies that
9
+ * closure, so no AWS SDK import leaks into the framework).
10
+ * • **cache** — dev uses an in-process `Map`; the Lambda can pass a
11
+ * DynamoDB/ElastiCache-backed {@link ProxyCache} for cross-invocation hits.
12
+ *
13
+ * Authn/z is handled *in front* by an API Gateway Cognito authorizer, so by the
14
+ * time an event reaches here the caller's JWT is already verified; this adapter
15
+ * only serves data. It accepts both API Gateway payload formats (HTTP API v2 and
16
+ * REST/proxy v1), so it drops into either integration.
17
+ */
18
+ import { type HandlerDeps } from "./handler";
19
+ import type { ResourceBinding } from "./types";
20
+ /** The subset of an API Gateway event this adapter reads (v1 and v2 tolerant). */
21
+ export interface ApiGatewayEvent {
22
+ /** HTTP API (payload v2). */
23
+ rawPath?: string;
24
+ rawQueryString?: string;
25
+ requestContext?: {
26
+ http?: {
27
+ method?: string;
28
+ };
29
+ };
30
+ /** REST API / proxy (payload v1). */
31
+ path?: string;
32
+ httpMethod?: string;
33
+ queryStringParameters?: Record<string, string | undefined> | null;
34
+ multiValueQueryStringParameters?: Record<string, string[] | undefined> | null;
35
+ }
36
+ export interface ApiGatewayResult {
37
+ statusCode: number;
38
+ headers: Record<string, string>;
39
+ body: string;
40
+ }
41
+ /** Reconstruct the request URL (path + query) from either payload format. */
42
+ export declare function eventToUrl(event: ApiGatewayEvent): string;
43
+ /** The HTTP method from either payload format (defaults to GET). */
44
+ export declare function eventMethod(event: ApiGatewayEvent): string;
45
+ /**
46
+ * Handle one API Gateway event: parse → run the proxy core → serialise. Returns
47
+ * a proxy-integration result (`statusCode`/`headers`/`body`) for either format.
48
+ */
49
+ export declare function handleLambdaEvent(event: ApiGatewayEvent, bindings: ResourceBinding[], deps: HandlerDeps): Promise<ApiGatewayResult>;