rastack 0.0.58 → 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 (83) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +190 -0
  3. package/dist/deploy/ci.js +2 -2
  4. package/dist/deploy/io.js +2 -2
  5. package/dist/dev/harness.d.ts +1 -1
  6. package/dist/dev/harness.js +1 -2
  7. package/dist/external/cache.d.ts +28 -0
  8. package/dist/external/cache.js +35 -0
  9. package/dist/external/envelope.d.ts +35 -0
  10. package/dist/external/envelope.js +48 -0
  11. package/dist/external/graphql.d.ts +25 -0
  12. package/dist/external/graphql.js +78 -0
  13. package/dist/external/handler.d.ts +52 -0
  14. package/dist/external/handler.js +139 -0
  15. package/dist/external/index.d.ts +19 -0
  16. package/dist/external/index.js +38 -0
  17. package/dist/external/lambda.d.ts +49 -0
  18. package/dist/external/lambda.js +86 -0
  19. package/dist/external/rest.d.ts +17 -0
  20. package/dist/external/rest.js +46 -0
  21. package/dist/external/route.d.ts +12 -0
  22. package/dist/external/route.js +33 -0
  23. package/dist/external/service.d.ts +50 -0
  24. package/dist/external/service.js +59 -0
  25. package/dist/external/types.d.ts +107 -0
  26. package/dist/external/types.js +22 -0
  27. package/dist/external/util.d.ts +16 -0
  28. package/dist/external/util.js +58 -0
  29. package/dist/rastack-dev.js +10 -0
  30. package/dist/rastack-wasm-build.d.ts +1 -1
  31. package/dist/rastack-wasm-build.js +6 -6
  32. package/dist/wasm/rastack_wasm_bg.wasm +0 -0
  33. package/external.ts +10 -0
  34. package/hooks/query/delete.ts +12 -11
  35. package/hooks/query/fetch.ts +6 -8
  36. package/hooks/query/interfaces.ts +1 -4
  37. package/hooks/query/list.ts +16 -9
  38. package/hooks/query/update.ts +8 -6
  39. package/package.json +24 -2
  40. package/scripts/copy-templates.js +1 -1
  41. package/scripts/copy-wasm.js +1 -1
  42. package/scripts/dev-install.mjs +63 -0
  43. package/src/deploy/ci.ts +2 -2
  44. package/src/deploy/io.ts +2 -2
  45. package/src/dev/harness.ts +1 -2
  46. package/src/external/cache.ts +53 -0
  47. package/src/external/envelope.ts +78 -0
  48. package/src/external/graphql.ts +98 -0
  49. package/src/external/handler.ts +207 -0
  50. package/src/external/index.ts +25 -0
  51. package/src/external/lambda.ts +108 -0
  52. package/src/external/rest.ts +60 -0
  53. package/src/external/route.ts +33 -0
  54. package/src/external/service.ts +86 -0
  55. package/src/external/types.ts +121 -0
  56. package/src/external/util.ts +57 -0
  57. package/src/rastack-dev.ts +11 -0
  58. package/src/rastack-wasm-build.ts +6 -6
  59. package/jest.config.cjs +0 -12
  60. package/test/auth.spec.ts +0 -207
  61. package/test/cache.spec.ts +0 -263
  62. package/test/cognito.spec.ts +0 -281
  63. package/test/compile.spec.ts +0 -240
  64. package/test/components.spec.ts +0 -524
  65. package/test/csv-schema.spec.ts +0 -143
  66. package/test/deploy.spec.ts +0 -571
  67. package/test/dev.spec.ts +0 -414
  68. package/test/entities.spec.ts +0 -597
  69. package/test/import.spec.ts +0 -241
  70. package/test/plugin.spec.ts +0 -315
  71. package/test/runtime-manifest.spec.ts +0 -309
  72. package/test/schema-entities.spec.ts +0 -688
  73. package/test/tokens.spec.ts +0 -302
  74. package/test/transitions.spec.ts +0 -372
  75. package/test/typed-hooks.spec.ts +0 -412
  76. package/test/update.spec.ts +0 -152
  77. package/test/validate.spec.ts +0 -319
  78. package/tsconfig.json +0 -27
  79. package/wasm/package.json +0 -4
  80. package/wasm/rastack_wasm.d.ts +0 -100
  81. package/wasm/rastack_wasm.js +0 -745
  82. package/wasm/rastack_wasm_bg.wasm +0 -0
  83. package/wasm/rastack_wasm_bg.wasm.d.ts +0 -28
@@ -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>;
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ /**
3
+ * The AWS Lambda adapter — the deployed twin of the `rastack dev` mini service.
4
+ *
5
+ * Both are thin shells over the same pure {@link handleExternalRequest} core:
6
+ * parse the incoming request into a {@link RastackRequest}, run the handler,
7
+ * serialise the result. The differences are injected, not branched:
8
+ * • **auth** — dev reads `GITHUB_TOKEN` from the environment; the Lambda's
9
+ * `deps.authHeaders` reads AWS Secrets Manager (the app supplies that
10
+ * closure, so no AWS SDK import leaks into the framework).
11
+ * • **cache** — dev uses an in-process `Map`; the Lambda can pass a
12
+ * DynamoDB/ElastiCache-backed {@link ProxyCache} for cross-invocation hits.
13
+ *
14
+ * Authn/z is handled *in front* by an API Gateway Cognito authorizer, so by the
15
+ * time an event reaches here the caller's JWT is already verified; this adapter
16
+ * only serves data. It accepts both API Gateway payload formats (HTTP API v2 and
17
+ * REST/proxy v1), so it drops into either integration.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.eventToUrl = eventToUrl;
21
+ exports.eventMethod = eventMethod;
22
+ exports.handleLambdaEvent = handleLambdaEvent;
23
+ const handler_1 = require("./handler");
24
+ const route_1 = require("./route");
25
+ const JSON_HEADERS = {
26
+ "Content-Type": "application/json; charset=utf-8",
27
+ "Cache-Control": "no-store",
28
+ };
29
+ /** Reconstruct the request URL (path + query) from either payload format. */
30
+ function eventToUrl(event) {
31
+ if (event.rawPath != null) {
32
+ const qs = event.rawQueryString ? `?${event.rawQueryString}` : "";
33
+ return `${event.rawPath}${qs}`;
34
+ }
35
+ const path = event.path ?? "/";
36
+ const search = new URLSearchParams();
37
+ const multi = event.multiValueQueryStringParameters;
38
+ if (multi) {
39
+ for (const [key, values] of Object.entries(multi)) {
40
+ for (const v of values ?? [])
41
+ if (v != null)
42
+ search.append(key, v);
43
+ }
44
+ }
45
+ else if (event.queryStringParameters) {
46
+ for (const [key, v] of Object.entries(event.queryStringParameters)) {
47
+ if (v != null)
48
+ search.append(key, v);
49
+ }
50
+ }
51
+ const qs = search.toString();
52
+ return `${path}${qs ? `?${qs}` : ""}`;
53
+ }
54
+ /** The HTTP method from either payload format (defaults to GET). */
55
+ function eventMethod(event) {
56
+ return (event.requestContext?.http?.method ?? event.httpMethod ?? "GET").toUpperCase();
57
+ }
58
+ /**
59
+ * Handle one API Gateway event: parse → run the proxy core → serialise. Returns
60
+ * a proxy-integration result (`statusCode`/`headers`/`body`) for either format.
61
+ */
62
+ async function handleLambdaEvent(event, bindings, deps) {
63
+ // Reads are open (viewing is public; the authorizer gated the request); the
64
+ // proxy only serves GET. Anything else is a fast, cheap rejection.
65
+ if (eventMethod(event) !== "GET") {
66
+ return {
67
+ statusCode: 405,
68
+ headers: JSON_HEADERS,
69
+ body: JSON.stringify({ detail: "Method not allowed" }),
70
+ };
71
+ }
72
+ const req = (0, route_1.parseApiPath)(eventToUrl(event));
73
+ if (!req) {
74
+ return {
75
+ statusCode: 404,
76
+ headers: JSON_HEADERS,
77
+ body: JSON.stringify({ detail: "Not an /api/{app}/v1/{model}/ route." }),
78
+ };
79
+ }
80
+ const result = await (0, handler_1.handleExternalRequest)(req, bindings, deps);
81
+ return {
82
+ statusCode: result.status,
83
+ headers: { ...JSON_HEADERS, "X-Rastack-Cache": result.cached ? "hit" : "miss" },
84
+ body: JSON.stringify(result.body),
85
+ };
86
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Build the upstream HTTP request for a REST-backed resource, and pull the item
3
+ * array out of the response body. Pure string/URL work — no network — so it is
4
+ * unit-tested directly.
5
+ */
6
+ import type { RestBinding } from "./types";
7
+ export interface UpstreamRequest {
8
+ url: string;
9
+ method: string;
10
+ headers: Record<string, string>;
11
+ }
12
+ /** Build the upstream list request from a rastack list query. */
13
+ export declare function restListRequest(binding: RestBinding, query: Record<string, string[]>, headers: Record<string, string>): UpstreamRequest;
14
+ /** Build the upstream detail request. Throws if the binding has no detail path. */
15
+ export declare function restDetailRequest(binding: RestBinding, id: string, headers: Record<string, string>): UpstreamRequest;
16
+ /** Extract the item array from a REST list body (honours `itemsPath`). */
17
+ export declare function restListItems(binding: RestBinding, body: unknown): unknown;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ /**
3
+ * Build the upstream HTTP request for a REST-backed resource, and pull the item
4
+ * array out of the response body. Pure string/URL work — no network — so it is
5
+ * unit-tested directly.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.restListRequest = restListRequest;
9
+ exports.restDetailRequest = restDetailRequest;
10
+ exports.restListItems = restListItems;
11
+ const util_1 = require("./util");
12
+ /** The rastack→upstream default query-param names, before a binding overrides them. */
13
+ const DEFAULT_QUERY_MAP = {
14
+ page: "page",
15
+ page_size: "per_page",
16
+ search: "q",
17
+ order_by: "sort",
18
+ };
19
+ /** Build the upstream list request from a rastack list query. */
20
+ function restListRequest(binding, query, headers) {
21
+ const map = { ...DEFAULT_QUERY_MAP, ...(binding.queryMap ?? {}) };
22
+ const search = new URLSearchParams();
23
+ for (const key of ["page", "page_size", "search", "order_by"]) {
24
+ const value = (0, util_1.firstParam)(query, key);
25
+ if (value != null && value !== "")
26
+ search.set(map[key], value);
27
+ }
28
+ const qs = search.toString();
29
+ return {
30
+ url: `${binding.baseUrl}${binding.listPath}${qs ? `?${qs}` : ""}`,
31
+ method: "GET",
32
+ headers,
33
+ };
34
+ }
35
+ /** Build the upstream detail request. Throws if the binding has no detail path. */
36
+ function restDetailRequest(binding, id, headers) {
37
+ if (!binding.detailPath) {
38
+ throw new Error(`REST binding ${binding.app}.${binding.model} has no detailPath.`);
39
+ }
40
+ const path = binding.detailPath.replace(/\{id\}/g, encodeURIComponent(id));
41
+ return { url: `${binding.baseUrl}${path}`, method: "GET", headers };
42
+ }
43
+ /** Extract the item array from a REST list body (honours `itemsPath`). */
44
+ function restListItems(binding, body) {
45
+ return (0, util_1.getPath)(body, binding.itemsPath);
46
+ }
@@ -0,0 +1,12 @@
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
+ import type { RastackRequest } from "./handler";
11
+ /** Parse a request URL (path + optional query) into a rastack request, or null. */
12
+ export declare function parseApiPath(url: string): RastackRequest | null;
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ /**
3
+ * Parse the rastack REST path both the mini `rastack dev` service and the
4
+ * deployed Lambda receive into a {@link RastackRequest}. The contract is fixed:
5
+ *
6
+ * /api/{app}/v1/{model}/ → list
7
+ * /api/{app}/v1/{model}/{id}/ → detail
8
+ *
9
+ * Kept pure and separate from the servers so it is tested without a socket.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.parseApiPath = parseApiPath;
13
+ /** Parse a request URL (path + optional query) into a rastack request, or null. */
14
+ function parseApiPath(url) {
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
+ const query = {};
24
+ for (const pair of rawQuery.split("&")) {
25
+ if (!pair)
26
+ 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] ?? (query[key] = [])).push(value);
31
+ }
32
+ return { app, model, id, query };
33
+ }
@@ -0,0 +1,50 @@
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
+ import { type ProxyCache } from "./cache";
14
+ import { type HandlerDeps } from "./handler";
15
+ import type { FetchLike, ResourceBinding, ResourceResolver } from "./types";
16
+ export interface ExternalService {
17
+ /** True when the path is an `/api/{app}/v1/{model}/` route this service owns. */
18
+ handles(url: string): boolean;
19
+ /**
20
+ * Handle a request. Returns the HTTP status, JSON body string, and whether it
21
+ * was a cache hit (for a dev-log line). Returns `null` when the path isn't an
22
+ * API route, so the caller falls through to its other handlers.
23
+ */
24
+ serve(url: string): Promise<{
25
+ status: number;
26
+ json: string;
27
+ cached: boolean;
28
+ } | null>;
29
+ }
30
+ export interface ServiceOptions {
31
+ bindings: ResourceBinding[];
32
+ /** Injected fetch; defaults to the global `fetch` (Node 18+/browsers). */
33
+ fetch?: FetchLike;
34
+ cache?: ProxyCache;
35
+ now?: () => number;
36
+ /** Per-app upstream auth headers. In dev, typically read from `process.env`. */
37
+ authHeaders?: HandlerDeps["authHeaders"];
38
+ /** Named resolvers for `resolver`-transport bindings (e.g. an AWS resolver). */
39
+ resolvers?: Record<string, ResourceResolver>;
40
+ }
41
+ /**
42
+ * Read a dev token for an app from the environment, e.g. `GITHUB_TOKEN`,
43
+ * `EXPO_TOKEN`. Returns a Bearer header when present, else nothing (public
44
+ * endpoints still work; private ones 401 with a clear upstream message). This is
45
+ * the *only* place a raw token is read locally — production reads Secrets Manager
46
+ * in the Lambda instead, and no token is ever sent to the client.
47
+ */
48
+ export declare function envAuthHeaders(env?: Record<string, string | undefined>): NonNullable<HandlerDeps["authHeaders"]>;
49
+ /** Create the mini external-API service `rastack dev` mounts. */
50
+ export declare function createExternalService(opts: ServiceOptions): ExternalService;
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ /**
3
+ * The mini local proxy service — the "lambda you run while iterating".
4
+ *
5
+ * `rastack dev` already serves the app + admin over WASM for the stack's *own*
6
+ * resources. This adds the other half for **external** resources: a request
7
+ * handler it mounts under `/api/`, so `useData("github.repo")` works locally,
8
+ * cached, before any real Lambda is deployed. It is a thin adapter over the pure
9
+ * {@link handleExternalRequest} core — parse the path, resolve dev auth from env
10
+ * vars, run the handler, serialise JSON — reused verbatim by the deployed Lambda
11
+ * (which swaps the env-var auth for Secrets Manager and the Map cache for a
12
+ * shared store).
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.envAuthHeaders = envAuthHeaders;
16
+ exports.createExternalService = createExternalService;
17
+ const cache_1 = require("./cache");
18
+ const handler_1 = require("./handler");
19
+ const route_1 = require("./route");
20
+ /**
21
+ * Read a dev token for an app from the environment, e.g. `GITHUB_TOKEN`,
22
+ * `EXPO_TOKEN`. Returns a Bearer header when present, else nothing (public
23
+ * endpoints still work; private ones 401 with a clear upstream message). This is
24
+ * the *only* place a raw token is read locally — production reads Secrets Manager
25
+ * in the Lambda instead, and no token is ever sent to the client.
26
+ */
27
+ function envAuthHeaders(env = process.env) {
28
+ return (app) => {
29
+ const token = env[`${app.toUpperCase()}_TOKEN`];
30
+ return token ? { Authorization: `Bearer ${token}` } : {};
31
+ };
32
+ }
33
+ /** Create the mini external-API service `rastack dev` mounts. */
34
+ function createExternalService(opts) {
35
+ const deps = {
36
+ fetch: opts.fetch ?? globalThis.fetch,
37
+ cache: opts.cache ?? (0, cache_1.memoryCache)(),
38
+ now: opts.now ?? (() => Date.now()),
39
+ authHeaders: opts.authHeaders ?? envAuthHeaders(),
40
+ resolvers: opts.resolvers,
41
+ };
42
+ if (!deps.fetch) {
43
+ throw new Error("createExternalService needs a `fetch` (none on globalThis).");
44
+ }
45
+ return {
46
+ handles: (url) => (0, route_1.parseApiPath)(url) !== null,
47
+ async serve(url) {
48
+ const req = (0, route_1.parseApiPath)(url);
49
+ if (!req)
50
+ return null;
51
+ const result = await (0, handler_1.handleExternalRequest)(req, opts.bindings, deps);
52
+ return {
53
+ status: result.status,
54
+ json: JSON.stringify(result.body),
55
+ cached: result.cached,
56
+ };
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,107 @@
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
+ /** A minimal `fetch` surface — the global `fetch` satisfies it; tests fake it. */
17
+ export interface FetchResponse {
18
+ status: number;
19
+ headers: {
20
+ get(name: string): string | null;
21
+ };
22
+ json(): Promise<unknown>;
23
+ text(): Promise<string>;
24
+ }
25
+ export type FetchLike = (url: string, init?: {
26
+ method?: string;
27
+ headers?: Record<string, string>;
28
+ body?: string;
29
+ }) => Promise<FetchResponse>;
30
+ /** Common binding fields, independent of transport. */
31
+ interface BindingBase {
32
+ /** The rastack app namespace, e.g. `"github"`. */
33
+ app: string;
34
+ /** The rastack model, the `{model}` in `/api/{app}/v1/{model}/`. */
35
+ model: string;
36
+ /** The upstream field that is the row's identity. Default `"id"`. */
37
+ primaryKey?: string;
38
+ /** Cache TTL in ms for this resource's responses. Default 60_000. */
39
+ ttlMs?: number;
40
+ /** Whitelist of upstream fields to project into each row (default: all). */
41
+ fields?: string[];
42
+ /** Static headers merged into every upstream request (auth is separate). */
43
+ headers?: Record<string, string>;
44
+ }
45
+ /** Serve a model from an upstream REST endpoint. */
46
+ export interface RestBinding extends BindingBase {
47
+ transport: "rest";
48
+ /** Upstream origin, e.g. `"https://api.github.com"`. */
49
+ baseUrl: string;
50
+ /** Path to the list, e.g. `"/user/repos"`. */
51
+ listPath: string;
52
+ /** Path to a detail; `{id}` is substituted, e.g. `"/repositories/{id}"`. */
53
+ detailPath?: string;
54
+ /**
55
+ * Map rastack list params (`page`, `page_size`, `search`, `order_by`) to the
56
+ * upstream's own names (`page`, `per_page`, `q`, `sort`). Unmapped params are
57
+ * dropped, so the upstream never sees rastack-only query keys.
58
+ */
59
+ queryMap?: Partial<Record<"page" | "page_size" | "search" | "order_by", string>>;
60
+ /** Dot path to the array inside the list response (default: the body *is* the array). */
61
+ itemsPath?: string;
62
+ }
63
+ /** Serve a model from an upstream GraphQL endpoint. */
64
+ export interface GraphQLBinding extends BindingBase {
65
+ transport: "graphql";
66
+ /** The GraphQL endpoint URL, e.g. `"https://api.expo.dev/graphql"`. */
67
+ baseUrl: string;
68
+ /** The list query. Variables are filled from {@link variableMap}. */
69
+ listQuery: string;
70
+ /** Dot path into `data` to the array, e.g. `"me.accounts.0.apps"`. */
71
+ itemsPath: string;
72
+ /** Optional detail query; `$id` variable is bound to the requested id. */
73
+ detailQuery?: string;
74
+ /** Dot path into `data` to the detail object. */
75
+ detailPath?: string;
76
+ /** Map rastack params to GraphQL variable names (e.g. `page_size` → `first`). */
77
+ variableMap?: Partial<Record<"page" | "page_size" | "search" | "order_by" | "id", string>>;
78
+ /** Static variables merged into every query. */
79
+ variables?: Record<string, unknown>;
80
+ }
81
+ /**
82
+ * Serve a model from an injected resolver function rather than a plain HTTP
83
+ * call — for upstreams that need an SDK/signing the proxy core shouldn't import
84
+ * (AWS SigV4, say). The named resolver is provided via `HandlerDeps.resolvers`,
85
+ * so the framework stays dependency-free while the app plugs in, e.g., an AWS
86
+ * CloudFormation resolver. Still consumed through the normal `useData` contract.
87
+ */
88
+ export interface ResolverBinding extends BindingBase {
89
+ transport: "resolver";
90
+ /** Key into `HandlerDeps.resolvers` for the function that produces the data. */
91
+ resolver: string;
92
+ }
93
+ export type ResourceBinding = RestBinding | GraphQLBinding | ResolverBinding;
94
+ /**
95
+ * A resolver produces the upstream data for a {@link ResolverBinding}: an array
96
+ * for a list request, or a single object for a detail request. Normalisation
97
+ * into the rastack envelope is done by the core, exactly as for HTTP transports.
98
+ */
99
+ export type ResourceResolver = (args: {
100
+ app: string;
101
+ model: string;
102
+ id?: string;
103
+ query: Record<string, string[]>;
104
+ }) => Promise<unknown>;
105
+ /** Look a binding up by `app`/`model`. */
106
+ export declare function findBinding(bindings: ResourceBinding[], app: string, model: string): ResourceBinding | undefined;
107
+ export {};