@softr/sdk 0.0.23 → 0.0.24

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.
@@ -46,8 +46,9 @@ function readSessionData(sessionToken) {
46
46
  permissions: payload.permissions
47
47
  };
48
48
  }
49
- function makeSessionTokenCookie(token, secure = requestIsSecure()) {
50
- return serializeCookie(sessionCookieName(SESSION_TOKEN, secure), sealEnvelope(token, SESSION_TOKEN), {
49
+ function makeSessionTokenCookie(token) {
50
+ const { name, secure } = sessionCookie(SESSION_TOKEN);
51
+ return serializeCookie(name, sealEnvelope(token, SESSION_TOKEN), {
51
52
  maxAge: sessionConfig().sessionMaxAge,
52
53
  secure
53
54
  });
@@ -71,19 +72,6 @@ function makeSessionDataCookie(session, sessionToken) {
71
72
  function clearSession() {
72
73
  clearCookies([SESSION_TOKEN, SESSION_DATA]);
73
74
  }
74
- /** The clearing pair, for the same out-of-context caller as `makeSessionTokenCookie`. */
75
- function makeSessionClearingCookies(secure) {
76
- return serializeClearingCookies([SESSION_TOKEN, SESSION_DATA], secure);
77
- }
78
- /**
79
- * The name `readSessionToken` would look the token up under on a request of this
80
- * scheme, for a caller that only needs to know whether one is present and cannot
81
- * decode it. Same `secure` argument as `makeSessionTokenCookie`, so a caller that
82
- * writes and a caller that probes cannot disagree about the name.
83
- */
84
- function sessionTokenCookieName(secure) {
85
- return sessionCookieName(SESSION_TOKEN, secure);
86
- }
87
75
  const sessionDataSchema = z.object({
88
76
  user: userSchema.nullable(),
89
77
  permissions: permissionsSchema,
@@ -95,13 +83,11 @@ const sessionDataSchema = z.object({
95
83
  * schemes, and the one left behind would revive if the scheme changed back.
96
84
  */
97
85
  function clearCookies(bases) {
98
- writeCookies(serializeClearingCookies(bases, requestIsSecure()));
99
- }
100
- function serializeClearingCookies(bases, secure) {
101
- return bases.flatMap((base) => secure ? [sessionCookieName(base, secure), base] : [base]).map((name) => serializeCookie(name, "", {
86
+ const secure = requestIsSecure();
87
+ writeCookies(bases.flatMap((base) => secure ? [sessionCookieName(base, secure), base] : [base]).map((name) => serializeCookie(name, "", {
102
88
  maxAge: 0,
103
89
  secure
104
- }));
90
+ })));
105
91
  }
106
92
  /**
107
93
  * Over the plaintext token, never the cookie value: that is resealed with a
@@ -177,4 +163,4 @@ function openEnvelope(raw, purpose) {
177
163
  }
178
164
 
179
165
  //#endregion
180
- export { clearSession, makeSessionClearingCookies, makeSessionDataCookie, makeSessionTokenCookie, readSessionData, readSessionToken, sessionTokenCookieName };
166
+ export { clearSession, makeSessionDataCookie, makeSessionTokenCookie, readSessionData, readSessionToken };
@@ -1,4 +1,4 @@
1
- import { ChartDataOptions, ChartDataResult, CreateOptions, GetOptions, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrRecord, UpdateOptions } from "../types.js";
1
+ import { ChartOptions, ChartResult, CreateOptions, GetOptions, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrRecord, UpdateOptions } from "../types.js";
2
2
 
3
3
  //#region src/core/datasource.d.ts
4
4
  declare function listRecords<Id extends string, Sel extends Record<string, string> = Record<never, never>>(datasourceId: Id, options?: Omit<ListOptions, 'select'> & {
@@ -17,7 +17,7 @@ declare function updateRecord<Id extends string, Sel extends Record<string, stri
17
17
  }): Promise<SoftrRecord<RecordFieldsFor<Id, Sel>>>;
18
18
  declare function deleteRecord(datasourceId: string, id: string): Promise<void>;
19
19
  declare function getMetric(datasourceId: string, options: MetricOptions): Promise<MetricResult>;
20
- declare function getChartData(datasourceId: string, options: ChartDataOptions): Promise<ChartDataResult>;
20
+ declare function getChart(datasourceId: string, options: ChartOptions): Promise<ChartResult>;
21
21
  declare const ds: {
22
22
  list: typeof listRecords;
23
23
  get: typeof getRecord;
@@ -25,7 +25,7 @@ declare const ds: {
25
25
  update: typeof updateRecord;
26
26
  delete: typeof deleteRecord;
27
27
  metric: typeof getMetric;
28
- chartData: typeof getChartData;
28
+ chart: typeof getChart;
29
29
  };
30
30
  //#endregion
31
31
  export { ds };
@@ -120,9 +120,10 @@ async function deleteRecord(datasourceId, id) {
120
120
  }
121
121
  async function getMetric(datasourceId, options) {
122
122
  const { select, where, aggregation, field } = options;
123
+ if (aggregation !== "COUNT" && !field) throw new Error(`field is required for aggregation "${aggregation}"`);
123
124
  const fieldId = field ? select?.[field] ?? field : void 0;
124
125
  const filterCriteria = where ? dealiasFilterCriteria(buildFilterCriteria(where), select ?? {}) : {};
125
- const res = await fetch(`${dsUrl(datasourceId)}/metric/data`, {
126
+ const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
126
127
  method: "POST",
127
128
  headers: {
128
129
  "Content-Type": "application/json",
@@ -131,10 +132,11 @@ async function getMetric(datasourceId, options) {
131
132
  },
132
133
  body: JSON.stringify({
133
134
  options: { userLocale: "en-US" },
134
- calculationField: {
135
+ groupBy: [],
136
+ metrics: [{
135
137
  field: fieldId,
136
138
  aggregation
137
- },
139
+ }],
138
140
  filterCriteria
139
141
  })
140
142
  });
@@ -142,26 +144,24 @@ async function getMetric(datasourceId, options) {
142
144
  const payload = await res.json();
143
145
  const executionTimestamp = /* @__PURE__ */ new Date(payload.executionTimestamp * 1e3);
144
146
  let value;
145
- if (aggregation === "COUNT") value = payload.data.find((item) => COUNT_CNT in item)?.[COUNT_CNT] ?? null;
146
- else {
147
- if (!fieldId) throw new Error(`field is required for aggregation "${aggregation}"`);
148
- value = payload.data.find((item) => fieldId in item)?.[fieldId] ?? null;
149
- }
147
+ const responseKey = aggregation === "COUNT" && !fieldId ? COUNT_CNT : fieldId;
148
+ value = payload.data.find((item) => responseKey in item)?.[responseKey] ?? null;
150
149
  return {
151
150
  value,
152
151
  executionTimestamp
153
152
  };
154
153
  }
155
- async function getChartData(datasourceId, options) {
154
+ async function getChart(datasourceId, options) {
156
155
  const { select, where, groupBy, metrics, orderBy } = options;
157
156
  const filterCriteria = where ? dealiasFilterCriteria(buildFilterCriteria(where), select ?? {}) : {};
158
- const sortingOption = orderBy ? dealiasOrderBy(buildOrderBy(orderBy), select ?? {})[0] : void 0;
157
+ const sortingOptions = orderBy ? dealiasOrderBy(buildOrderBy(orderBy), select ?? {}) : void 0;
159
158
  const groupByFields = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((field) => ({ field: select?.[field] ?? field }));
160
159
  const yAxisFields = Object.values(metrics).map(({ field, aggregation }) => ({
161
160
  field: field ? select?.[field] ?? field : void 0,
162
161
  aggregation
163
162
  }));
164
- const res = await fetch(`${dsUrl(datasourceId)}/chart`, {
163
+ if (!yAxisFields.length) throw new Error("chart requires at least one metric");
164
+ const res = await fetch(`${dsUrl(datasourceId)}/chart/data`, {
165
165
  method: "POST",
166
166
  headers: {
167
167
  "Content-Type": "application/json",
@@ -171,12 +171,12 @@ async function getChartData(datasourceId, options) {
171
171
  body: JSON.stringify({
172
172
  options: { userLocale: "en-US" },
173
173
  filterCriteria,
174
- sortingOption,
175
- xAxisFields: groupByFields,
176
- yAxisFields
174
+ sortingOptions,
175
+ groupBy: groupByFields,
176
+ metrics: yAxisFields
177
177
  })
178
178
  });
179
- await assertResponseOk(res, `Softr datasource chartData failed for "${datasourceId}"`);
179
+ await assertResponseOk(res, `Softr datasource chart failed for "${datasourceId}"`);
180
180
  const payload = await res.json();
181
181
  const executionTimestamp = /* @__PURE__ */ new Date(payload.executionTimestamp * 1e3);
182
182
  const aliases = {};
@@ -216,11 +216,11 @@ const ds = {
216
216
  update: updateRecord,
217
217
  delete: deleteRecord,
218
218
  metric: getMetric,
219
- chartData: getChartData
219
+ chart: getChart
220
220
  };
221
221
  function withoutTrailingSlash(url) {
222
222
  return url.replace(/\/+$/, "");
223
223
  }
224
224
 
225
225
  //#endregion
226
- export { ds };
226
+ export { ds, dsUrl };
@@ -1,7 +1,7 @@
1
1
  import { readSessionToken } from "../auth/session.js";
2
+ import { dsUrl } from "./datasource.js";
2
3
 
3
4
  //#region src/core/integration.ts
4
- const BASE_URL = () => (import.meta.env.VITE_SOFTR_BASE_URL ?? "").replace(/\/$/, "");
5
5
  async function integrationFetch(integrationId, input, init) {
6
6
  const req = new Request(input, init);
7
7
  if (req.signal.aborted) throw req.signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
@@ -16,7 +16,7 @@ async function integrationFetch(integrationId, input, init) {
16
16
  };
17
17
  const method = req.method.toUpperCase();
18
18
  if (method !== "GET" && method !== "HEAD" && req.body !== null) payload.body = await req.clone().text();
19
- return fetch(`${BASE_URL()}/api/integrations/${integrationId}/proxy`, {
19
+ return fetch(`${dsUrl(integrationId)}/proxy`, {
20
20
  method: "POST",
21
21
  headers: {
22
22
  "Content-Type": "application/json",
package/dist/index.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { q } from "./core/query.js";
2
2
  import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
3
- import { AggregationFunction, ChartDataOptions, ChartDataResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions } from "./types.js";
3
+ import { AggregationFunction, ChartOptions, ChartResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions } from "./types.js";
4
4
  import { Permissions } from "./auth/permissions.js";
5
5
  import { SoftrContext } from "./auth/context.js";
6
6
  import { AccessBehaviour, AccessCheck, AccessError, AccessReason, RouteVisibility, VisibilityHandle, defineVisibility, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError } from "./routes/visibility.js";
7
7
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser, usePermissions } from "./auth/isomorphic.js";
8
8
  import { useUserTimezone } from "./timezone.js";
9
9
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
10
- export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type ChartDataOptions, type ChartDataResult, type CreateOptions, type DatasourcePermissions, type DatasourceTypeMap, DefaultErrorOverlay, type GetOptions, type JsonValue, type ListOptions, type ListResult, type MetricOptions, type MetricResult, type Permissions, type RecordFieldsFor, type RouteVisibility, type SoftrContext, type SoftrMe, type SoftrPermissions, type SoftrRecord, type SoftrUser, type SoftrUserGroupId, type SoftrUserGroups, type UpdateOptions, type User, type VisibilityHandle, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
10
+ export { type AccessBehaviour, type AccessCheck, type AccessError, type AccessReason, type AggregationFunction, type ChartOptions, type ChartResult, type CreateOptions, type DatasourcePermissions, type DatasourceTypeMap, DefaultErrorOverlay, type GetOptions, type JsonValue, type ListOptions, type ListResult, type MetricOptions, type MetricResult, type Permissions, type RecordFieldsFor, type RouteVisibility, type SoftrContext, type SoftrMe, type SoftrPermissions, type SoftrRecord, type SoftrUser, type SoftrUserGroupId, type SoftrUserGroups, type UpdateOptions, type User, type VisibilityHandle, defineVisibility, getSoftrContext, initialSoftrContext, isAccessError, isAlreadySignedInError, isForbiddenError, isUnauthenticatedError, q, softrAuthMiddleware, useCurrentUser, usePermissions, useUserTimezone };
package/dist/types.d.ts CHANGED
@@ -90,7 +90,7 @@ interface MetricResult {
90
90
  value: number | null;
91
91
  executionTimestamp: Date;
92
92
  }
93
- interface ChartDataOptions {
93
+ interface ChartOptions {
94
94
  select?: Record<string, string>;
95
95
  where?: AnyConstructable;
96
96
  /** Field alias (resolved via select) or raw field ID. */
@@ -103,7 +103,7 @@ interface ChartDataOptions {
103
103
  /** Override the timezone for this request. */
104
104
  timeZone?: string;
105
105
  }
106
- interface ChartDataResult {
106
+ interface ChartResult {
107
107
  data: Record<string, JsonValue>[];
108
108
  executionTimestamp: Date;
109
109
  }
@@ -125,4 +125,4 @@ interface UpdateOptions {
125
125
  data: Record<string, JsonValue>;
126
126
  }
127
127
  //#endregion
128
- export { AggregationFunction, ChartDataOptions, ChartDataResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions };
128
+ export { AggregationFunction, ChartOptions, ChartResult, CreateOptions, DatasourcePermissions, DatasourceTypeMap, GetOptions, JsonValue, ListOptions, ListResult, MetricOptions, MetricResult, RecordFieldsFor, SoftrMe, SoftrPermissions, SoftrRecord, SoftrUser, UpdateOptions };
package/dist/vite.d.ts CHANGED
@@ -1,14 +1,7 @@
1
- import { Manifest, ManifestNode } from "./routes/manifest.js";
2
1
  import { PluginOption } from "vite";
3
2
 
4
3
  //#region src/vite.d.ts
5
- declare const MANIFEST_RELATIVE_PATH: string;
6
- /** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
7
- declare function generateManifest(root: string): Promise<string>;
8
- /**
9
- * Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
10
- * edit engine when the pod supplies one.
11
- */
4
+ /** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
12
5
  declare function softr(): PluginOption[];
13
6
  //#endregion
14
- export { MANIFEST_RELATIVE_PATH, type Manifest, type ManifestNode, generateManifest, softr };
7
+ export { softr };
package/dist/vite.js CHANGED
@@ -1,75 +1,10 @@
1
- import { ManifestError, generateRoutesManifest } from "./routes/manifest.js";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
1
  import path from "node:path";
4
2
  import { pathToFileURL } from "node:url";
5
3
 
6
4
  //#region src/vite.ts
7
- const MANIFEST_RELATIVE_PATH = path.join("dist", "routes.json");
8
- async function renderManifest(root) {
9
- try {
10
- return await generateRoutesManifest(root);
11
- } catch (error) {
12
- if (error instanceof ManifestError) {
13
- const { filePath, line, column, message } = error.detail;
14
- throw new Error(`[softr] ${filePath}:${line}:${column} — ${message}`, { cause: error });
15
- }
16
- throw error;
17
- }
18
- }
19
- async function writeManifest(root, contents) {
20
- const target = path.resolve(root, MANIFEST_RELATIVE_PATH);
21
- await mkdir(path.dirname(target), { recursive: true });
22
- await writeFile(target, contents, "utf8");
23
- return target;
24
- }
25
- /** Writes `<root>/dist/routes.json` and returns its path. Throws on a rule it cannot read. */
26
- async function generateManifest(root) {
27
- return writeManifest(root, await renderManifest(root));
28
- }
29
- /**
30
- * Everything Softr adds to an app's Vite config: the build-time routes manifest, plus the studio
31
- * edit engine when the pod supplies one.
32
- */
5
+ /** Everything Softr adds to an app's Vite config: the studio edit engine, when the pod supplies one. */
33
6
  function softr() {
34
- return [
35
- routesManifest(),
36
- previewIdentity(),
37
- editEngine()
38
- ];
39
- }
40
- /**
41
- * Signing the preview in writes the cookie this app's own session code reads back, so it lives here
42
- * rather than in the edit engine — one implementation of the format, no version to keep in step.
43
- *
44
- * `SOFTR_APPLICATION_ID` gates it: the pod sets it, a developer's machine has none.
45
- */
46
- async function previewIdentity() {
47
- if (!process.env.SOFTR_APPLICATION_ID) return false;
48
- const { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn } = await import("./preview-identity/preview-identity.js");
49
- return {
50
- name: "softr-preview-identity",
51
- apply: "serve",
52
- configureServer(server) {
53
- server.middlewares.use(PREVIEW_REDEMPTION_PATH, redeemGrant);
54
- server.middlewares.use(PREVIEW_SIGNED_IN_PATH, reportSignedIn);
55
- }
56
- };
57
- }
58
- /** Plugin order does not matter: this puts nothing into the module graph. */
59
- function routesManifest() {
60
- let root = process.cwd();
61
- let rendered = null;
62
- return {
63
- name: "softr",
64
- apply: "build",
65
- configResolved(config) {
66
- root = config.root;
67
- },
68
- async closeBundle() {
69
- rendered ??= renderManifest(root);
70
- await writeManifest(root, await rendered);
71
- }
72
- };
7
+ return [editEngine()];
73
8
  }
74
9
  /**
75
10
  * The edit engine is provided by the pod runtime rather than an app dependency: it is never in the
@@ -91,4 +26,4 @@ async function editEngine() {
91
26
  }
92
27
 
93
28
  //#endregion
94
- export { MANIFEST_RELATIVE_PATH, generateManifest, softr };
29
+ export { softr };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -16,17 +16,12 @@
16
16
  "typecheck": "tsc --noEmit"
17
17
  },
18
18
  "dependencies": {
19
- "@babel/parser": "^7.28.0",
20
- "@babel/traverse": "^7.28.0",
21
- "@babel/types": "^7.28.0",
22
- "@tanstack/router-generator": "^1.167.0",
23
19
  "zod": "^4.4.3"
24
20
  },
25
21
  "devDependencies": {
26
22
  "@tanstack/intent": "^0.3.6",
27
23
  "@tanstack/react-router": "^1.168.0",
28
24
  "@tanstack/react-start": "^1.168.0",
29
- "@types/babel__traverse": "^7.28.0",
30
25
  "@types/node": "^24.3.0",
31
26
  "tsdown": "^0.15.0",
32
27
  "typescript": "^5.9.2",
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.23
14
+ library_version: 0.0.24
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -21,7 +21,7 @@ metadata:
21
21
  > **CRITICAL**: The SDK is split across four entrypoints and the split is enforced. `ds` and `auth` exist only on `@softr/sdk/server` and only run inside a `createServerFn` handler. Importing them anywhere else fails the build.
22
22
  > **CRITICAL**: Every route file must declare its audience with `defineVisibility`. There is no default and nothing is inherited from a sibling. A route file without a declaration is a lint error.
23
23
  > **CRITICAL**: The app is already wired. `src/start.ts`, `src/router.tsx` and `src/routes/__root.tsx` register the middleware and resolve the session. Do not add a second registration.
24
- > **CRITICAL**: `ds.metric`, `ds.chartData`, `workflow.trigger` and `integration.fetch` are exploratory. They exist on the entrypoints below and they typecheck, but their endpoints are not final. Do not build a feature on any of them. Tell the user the capability is not ready and ask how they want to proceed.
24
+ > **CRITICAL**: `workflow.trigger` is exploratory. It exists on the entrypoints below and typechecks, but its endpoint is not final. Do not build a feature on it. Tell the user the capability is not ready and ask how they want to proceed.
25
25
 
26
26
  Each sub-skill stands alone for its task and repeats the boundary above, so most work needs **one** sub-skill and not this file. Load this one when you are unsure which sub-skill applies, or when the wiring below is what you are touching. Add a second sub-skill only when the work genuinely crosses that boundary: a page that lists records behind a group restriction needs `reading-records` plus `routes-and-visibility`.
27
27
 
@@ -124,4 +124,4 @@ type User = {
124
124
 
125
125
  ## Version
126
126
 
127
- Targets `@softr/sdk` v0.0.23.
127
+ Targets `@softr/sdk` v0.0.24.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.23
13
+ library_version: 0.0.24
14
14
  ---
15
15
 
16
16
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineVisibility`, the hooks and every type come from `@softr/sdk`. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.23
13
+ library_version: 0.0.24
14
14
  ---
15
15
 
16
16
  **Boundary.** `useCurrentUser`, `usePermissions` and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -8,7 +8,7 @@ description: >-
8
8
  metadata:
9
9
  type: sub-skill
10
10
  library: softr-sdk
11
- library_version: 0.0.23
11
+ library_version: 0.0.24
12
12
  ---
13
13
 
14
14
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -118,7 +118,7 @@ Stamping a record with who created it, or scoping an update to the person making
118
118
 
119
119
  **A datasource id built from a name.** `'ds_projects'`, the table id, or the table's name glued to a uuid are not datasource ids. The id is the opaque UUID assigned when the table was registered on the application; copy it from there. Anything else fails at request time as not found or forbidden.
120
120
 
121
- **A datasource id stored in a variable.** The build reads ids statically to build the route manifest; it cannot resolve a name. Pass the UUID as a string literal directly at the call site. The lint rule `@softr/ds-no-computed-id` catches this at development time.
121
+ **A datasource id stored in a variable.** Pass the UUID as a string literal directly at the call site. The lint rule `@softr/ds-no-computed-id` catches this at development time.
122
122
 
123
123
  **A write that sends `fields`.** The option is `data`. Only typecheck catches this, so it survives any run that does not typecheck.
124
124
 
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.23
13
+ library_version: 0.0.24
14
14
  ---
15
15
 
16
16
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `q` and every type come from `@softr/sdk` and can be imported anywhere. The app is already wired (`src/start.ts`, `src/router.tsx`, `src/routes/__root.tsx`); never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
@@ -29,13 +29,13 @@ import { q } from '@softr/sdk'
29
29
 
30
30
  Every `ds` call names its datasource by an opaque UUID from the table's registration on the application (Softr Studio's Data tab, or the `register_application_data_source` tool when an agent does it). It is not the table id, and it is never derived from the table's name — `'ds_posts'` or a name glued to a uuid fails at request time as not found or forbidden.
31
31
 
32
- **The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable — the build reads ids statically and cannot resolve a name. Write the UUID directly as the first argument:
32
+ **The datasource id must be a string literal at the call site.** Do not assign it to a variable and pass the variable. Write the UUID directly as the first argument:
33
33
 
34
34
  ```ts
35
35
  // correct
36
36
  ds.list('8ffb4814-b028-4496-b679-7face2167ebc', { ... })
37
37
 
38
- // wrong — the build cannot read this
38
+ // wrong
39
39
  const POSTS = '8ffb4814-b028-4496-b679-7face2167ebc'
40
40
  ds.list(POSTS, { ... })
41
41
  ```
@@ -7,19 +7,19 @@ description: >-
7
7
  per-reason overrides, the redirect and throw strategies, rendering an access
8
8
  error with isAccessError, isUnauthenticatedError, isForbiddenError and
9
9
  isAlreadySignedInError, guarding a layout, and the lint rule that requires a
10
- declaration alongside the build rules that reject a duplicate, computed or
10
+ declaration alongside the rules that reject a duplicate, computed or
11
11
  root-level one.
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.23
15
+ library_version: 0.0.24
16
16
  ---
17
17
 
18
18
  **Boundary.** `defineVisibility`, the error predicates and every type come from `@softr/sdk` and work anywhere; `ds` and `auth` live on `@softr/sdk/server` and run only inside a `createServerFn` handler. The app is already wired — `src/start.ts` registers `softrAuthMiddleware`, `src/routes/__root.tsx` publishes the visitor as route context — never add a second registration. Load the core `softr-sdk` skill only if you are unsure this is the right sub-skill.
19
19
 
20
20
  # Route visibility
21
21
 
22
- Every route file in a Softr app declares who may see it. The build reads that declaration out of the source text without executing it and publishes it to Softr Studio, which shows the app owner every route and its audience. That is why the rules below are strict: a rule the build cannot read is a rule Studio cannot show.
22
+ Every route file in a Softr app declares who may see it. Softr reads that declaration out of the source text without executing it and publishes it to Softr Studio, which shows the app owner every route and its audience. That is why the rules below are strict: a rule Softr cannot read is a rule Studio cannot show.
23
23
 
24
24
  ## Setup
25
25
 
@@ -43,11 +43,11 @@ function Billing() {
43
43
 
44
44
  One `defineVisibility` call per file, at module scope, with an inline object literal as its argument.
45
45
 
46
- Three rules about that shape, because the build reads the file rather than running it:
46
+ Three rules about that shape, because Softr reads the file rather than running it:
47
47
 
48
48
  - The call must initialise a top-level `const`.
49
49
  - The import must be a direct named import from `@softr/sdk`.
50
- - Never declare on `__root.tsx`. The root cannot carry an audience, so the build rejects a declaration there rather than shipping an app that only looks gated. Put the rule on a layout below the root instead.
50
+ - Never declare on `__root.tsx`. The root cannot carry an audience, so a declaration there would gate nothing while looking like it does. Softr rejects it. Put the rule on a layout below the root instead.
51
51
 
52
52
  The four rules:
53
53
 
@@ -1,121 +0,0 @@
1
- import { makeSessionClearingCookies, makeSessionTokenCookie, sessionTokenCookieName } from "../auth/session.js";
2
-
3
- //#region src/preview-identity/preview-identity.ts
4
- const PREVIEW_REDEMPTION_PATH = "/__preview/identity";
5
- const PREVIEW_SIGNED_IN_PATH = "/__preview/signed-in";
6
- /** The grant in the URL is the only credential: studio-api authorized the builder before issuing it. */
7
- const redeemGrant = async (req, res) => {
8
- res.setHeader("Cache-Control", "no-store");
9
- res.setHeader("Referrer-Policy", "no-referrer");
10
- if (req.method !== "GET") {
11
- sendJson(res, 405, { error: `${PREVIEW_REDEMPTION_PATH} only supports GET` });
12
- return;
13
- }
14
- const grant = searchParamsOf(req).get("grant");
15
- if (!grant) {
16
- sendJson(res, 400, { error: "no grant" });
17
- return;
18
- }
19
- const landing = landingPath(searchParamsOf(req).get("next"));
20
- let session;
21
- try {
22
- session = await redeemPreviewGrant(req, grant);
23
- } catch (error) {
24
- sendJson(res, statusOf(error), { error: messageOf(error) });
25
- return;
26
- }
27
- const secure = requestIsHttps(req);
28
- res.statusCode = 302;
29
- res.setHeader("Location", landing);
30
- res.setHeader("Set-Cookie", session.anonymous ? makeSessionClearingCookies(secure) : makeSessionTokenCookie(session.jwt, secure));
31
- res.end();
32
- };
33
- /** Presence only, never decoded: decoding would put an app user's identity on a route Studio reads. */
34
- const reportSignedIn = (req, res) => {
35
- res.setHeader("Cache-Control", "no-store");
36
- if (req.method !== "GET") {
37
- sendJson(res, 405, { error: `${PREVIEW_SIGNED_IN_PATH} only supports GET` });
38
- return;
39
- }
40
- sendJson(res, 200, { signedIn: hasSessionCookie(req.headers.cookie, sessionTokenCookieName(requestIsHttps(req))) });
41
- };
42
- var RedemptionError = class extends Error {
43
- status;
44
- constructor(status, message) {
45
- super(message);
46
- this.status = status;
47
- }
48
- };
49
- function statusOf(error) {
50
- return error instanceof RedemptionError ? error.status : 503;
51
- }
52
- function messageOf(error) {
53
- return error instanceof Error ? error.message : String(error);
54
- }
55
- async function redeemPreviewGrant(req, grant) {
56
- const applicationId = process.env.SOFTR_APPLICATION_ID;
57
- const studioApiUrl = process.env.SOFTR_STUDIO_API_URL?.trim() || studioApiUrlFor(req);
58
- if (!applicationId || !studioApiUrl) throw new RedemptionError(503, "studio-api is unavailable");
59
- let response;
60
- try {
61
- response = await fetch(`${studioApiUrl}/v1/applications/${encodeURIComponent(applicationId)}/vibe-coding/preview-identity/redeem`, {
62
- method: "POST",
63
- headers: { "content-type": "application/json" },
64
- body: JSON.stringify({ grant })
65
- });
66
- } catch {
67
- throw new RedemptionError(503, "studio-api is unreachable");
68
- }
69
- if (!response.ok) throw new RedemptionError(response.status, "studio-api refused the preview identity grant");
70
- const body = await response.json().catch(() => null);
71
- if (typeof body !== "object" || body === null) throw new RedemptionError(503, "studio-api returned no session");
72
- const { anonymous, jwt } = body;
73
- if (anonymous === true) return { anonymous: true };
74
- if (typeof jwt !== "string" || jwt.length === 0) throw new RedemptionError(503, "studio-api returned no token");
75
- return {
76
- anonymous: false,
77
- jwt
78
- };
79
- }
80
- const STAGING_HOST_PATTERN = /(?:^|\.)staging(\d+)\.softr\.io$/;
81
- /** The host is the only per-environment signal the app has. Anything but a staging one gives null. */
82
- function studioApiUrlFor(req) {
83
- const forwarded = req.headers["x-forwarded-host"];
84
- const name = ((Array.isArray(forwarded) ? forwarded[0] : forwarded) ?? req.headers.host ?? "").split(",")[0].trim().split(":")[0];
85
- const digits = STAGING_HOST_PATTERN.exec(name)?.[1];
86
- if (!digits) return null;
87
- return `https://studio-api-staging${digits.padStart(2, "0")}.softr.io`;
88
- }
89
- function hasSessionCookie(header, name) {
90
- if (!header) return false;
91
- return header.split(";").some((pair) => {
92
- const separator = pair.indexOf("=");
93
- return separator !== -1 && pair.slice(0, separator).trim() === name;
94
- });
95
- }
96
- /**
97
- * Exactly as strict as h3's `getRequestProtocol`, which reads the cookie back. Being more generous
98
- * writes `__Host-session_token` where the app looks up `session_token`, and the visitor is silently
99
- * anonymous with no error on either side.
100
- */
101
- function requestIsHttps(req) {
102
- const forwarded = req.headers["x-forwarded-proto"];
103
- if (forwarded === "https") return true;
104
- if (forwarded === "http") return false;
105
- return Boolean(req.socket?.encrypted);
106
- }
107
- function landingPath(next) {
108
- if (!next || !next.startsWith("/") || next.startsWith("//") || next.includes("\\") || next.includes("://") || /[\u0000-\u001f\u007f]/.test(next)) return "/";
109
- return next;
110
- }
111
- function searchParamsOf(req) {
112
- return new URL(req.url ?? "", "http://request.invalid").searchParams;
113
- }
114
- function sendJson(res, status, body) {
115
- res.statusCode = status;
116
- res.setHeader("Content-Type", "application/json");
117
- res.end(JSON.stringify(body));
118
- }
119
-
120
- //#endregion
121
- export { PREVIEW_REDEMPTION_PATH, PREVIEW_SIGNED_IN_PATH, redeemGrant, reportSignedIn };
@@ -1,14 +0,0 @@
1
- import "@tanstack/router-generator";
2
-
3
- //#region src/routes/discover.d.ts
4
-
5
- type DatasourceMethod = "list" | "get" | "create" | "update" | "delete" | "metric" | "chartData";
6
- type DatasourceAccess = {
7
- id: string;
8
- methods: Array<DatasourceMethod>;
9
- };
10
- type VisibilityValue = string | Array<VisibilityValue> | {
11
- [key: string]: VisibilityValue;
12
- };
13
- //#endregion
14
- export { DatasourceAccess, VisibilityValue };
@@ -1,305 +0,0 @@
1
- import { createReadOnlyFs } from "./mem-fs.js";
2
- import { randomUUID } from "node:crypto";
3
- import path from "node:path";
4
- import { Generator, getConfig } from "@tanstack/router-generator";
5
- import os from "node:os";
6
- import { parse } from "@babel/parser";
7
- import babelTraverse from "@babel/traverse";
8
-
9
- //#region src/routes/discover.ts
10
- async function crawlRoutes(opts) {
11
- const config = getConfig({
12
- disableLogging: true,
13
- tmpDir: TMP_DIR,
14
- generatedRouteTree: generatedRouteTreePath()
15
- }, opts.root);
16
- const generator = new Generator({
17
- config,
18
- root: opts.root,
19
- fs: createReadOnlyFs()
20
- });
21
- await generator.run();
22
- const crawlingResult = await generator.getCrawlingResult();
23
- if (!crawlingResult) throw new Error(`the route crawl produced no result for ${opts.root}`);
24
- return {
25
- rootRouteNode: crawlingResult.rootRouteNode,
26
- routeNodes: crawlingResult.acc.routeNodes,
27
- routePiecesByPath: crawlingResult.acc.routePiecesByPath,
28
- routeTree: crawlingResult.acc.routeTree,
29
- routesDirectory: path.relative(opts.root, path.resolve(opts.root, config.routesDirectory)).split(path.sep).join("/")
30
- };
31
- }
32
- /** Reads the rule out of the source text. The route file is never executed. */
33
- function extractVisibility(source, filePath) {
34
- let program;
35
- try {
36
- program = parse(source, {
37
- sourceType: "module",
38
- plugins: ["typescript", "jsx"]
39
- });
40
- } catch (error) {
41
- const position = error.loc;
42
- return fail("unparsable", `could not parse ${filePath}: ${error.message}`, filePath, position?.line ?? 1, position?.column ?? 0);
43
- }
44
- const calls = findVisibilityCalls(program);
45
- if (calls.length > 1) {
46
- const second = calls[1].call;
47
- return fail("duplicate-declaration", `${filePath} declares visibility more than once; a route has exactly one rule`, filePath, second.loc?.start.line ?? 1, second.loc?.start.column ?? 0);
48
- }
49
- const declaration = calls[0];
50
- if (declaration === void 0) return {
51
- ok: true,
52
- visibility: null
53
- };
54
- const { call, statement } = declaration;
55
- const argument = call.arguments[0];
56
- if (argument === void 0) return fail("not-literal", `${filePath} calls ${VISIBILITY_FUNCTION} with no argument`, filePath, call.loc?.start.line ?? 1, call.loc?.start.column ?? 0);
57
- const read = readLiteral(argument);
58
- if (!read.ok) return fail("not-literal", `${filePath}: the visibility rule must be a literal — ${read.reason}`, filePath, read.line, read.column);
59
- return {
60
- ok: true,
61
- visibility: {
62
- value: read.value,
63
- line: statement.loc?.start.line ?? 1,
64
- column: statement.loc?.start.column ?? 0
65
- }
66
- };
67
- }
68
- /** Reads all ds.method() calls from the source text. The route file is never executed. */
69
- function extractDatasources(source, filePath) {
70
- let program;
71
- try {
72
- program = parse(source, {
73
- sourceType: "module",
74
- plugins: ["typescript", "jsx"]
75
- });
76
- } catch (error) {
77
- const position = error.loc;
78
- return {
79
- ok: false,
80
- error: {
81
- code: "unparsable",
82
- message: `could not parse ${filePath}: ${error.message}`,
83
- filePath,
84
- line: position?.line ?? 1,
85
- column: position?.column ?? 0
86
- }
87
- };
88
- }
89
- const raw = findDatasourceCalls(program, filePath);
90
- if (!raw.ok) return raw;
91
- const byId = /* @__PURE__ */ new Map();
92
- for (const { id, method } of raw.calls) {
93
- if (!byId.has(id)) byId.set(id, /* @__PURE__ */ new Set());
94
- byId.get(id).add(method);
95
- }
96
- return {
97
- ok: true,
98
- datasources: Array.from(byId.entries()).map(([id, methods]) => ({
99
- id,
100
- methods: [...methods]
101
- }))
102
- };
103
- }
104
- /**
105
- * The generator `mkdirSync`s `tmpDir` and the directory of `generatedRouteTree` outside the `fs`
106
- * shim, so defaulted they create directories in the crawled project. Aimed at `os.tmpdir()` both
107
- * calls are no-ops, and nothing is ever written through either path. The uuid keeps the generated
108
- * tree file a miss rather than whatever a shared temp directory happens to hold.
109
- */
110
- const TMP_DIR = os.tmpdir();
111
- const generatedRouteTreePath = () => path.join(TMP_DIR, `softr-sdk-route-tree-${randomUUID()}.gen.ts`);
112
- const SDK_PACKAGE_NAME = "@softr/sdk";
113
- const SDK_SERVER_PACKAGE_NAME = "@softr/sdk/server";
114
- const VISIBILITY_FUNCTION = "defineVisibility";
115
- const DS_BINDING = "ds";
116
- const DS_METHODS = new Set([
117
- "list",
118
- "get",
119
- "create",
120
- "update",
121
- "delete",
122
- "metric",
123
- "chartData"
124
- ]);
125
- function fail(code, message, filePath, line, column) {
126
- return {
127
- ok: false,
128
- error: {
129
- code,
130
- message,
131
- filePath,
132
- line,
133
- column
134
- }
135
- };
136
- }
137
- function findDatasourceCalls(ast, filePath) {
138
- const calls = [];
139
- let error = null;
140
- traverse(ast, { CallExpression(path$1) {
141
- const { node } = path$1;
142
- const { callee } = node;
143
- if (callee.type !== "MemberExpression") return;
144
- if (callee.computed) return;
145
- if (callee.object.type !== "Identifier") return;
146
- if (callee.property.type !== "Identifier") return;
147
- const method = callee.property.name;
148
- if (!DS_METHODS.has(method)) return;
149
- if (!isDsImport(path$1, callee.object.name)) return;
150
- const firstArg = node.arguments[0];
151
- if (firstArg === void 0 || firstArg.type === "SpreadElement") {
152
- error = {
153
- code: "not-literal",
154
- message: `${filePath}: ds.${method}() must be called with a string literal datasource id`,
155
- filePath,
156
- line: node.loc?.start.line ?? 1,
157
- column: node.loc?.start.column ?? 0
158
- };
159
- path$1.stop();
160
- return;
161
- }
162
- if (firstArg.type !== "StringLiteral") {
163
- error = {
164
- code: "not-literal",
165
- message: `${filePath}: the datasource id passed to ds.${method}() must be a string literal`,
166
- filePath,
167
- line: firstArg.loc?.start.line ?? 1,
168
- column: firstArg.loc?.start.column ?? 0
169
- };
170
- path$1.stop();
171
- return;
172
- }
173
- calls.push({
174
- id: firstArg.value,
175
- method
176
- });
177
- } });
178
- if (error !== null) return {
179
- ok: false,
180
- error
181
- };
182
- return {
183
- ok: true,
184
- calls
185
- };
186
- }
187
- function isDsImport(path$1, name) {
188
- const binding = path$1.scope.getBinding(name);
189
- if (binding === void 0 || binding.kind !== "module") return false;
190
- const specifier = binding.path.node;
191
- const declaration = binding.path.parent;
192
- if (specifier.type !== "ImportSpecifier" || declaration.type !== "ImportDeclaration") return false;
193
- if (specifier.importKind === "type" || declaration.importKind === "type") return false;
194
- if (declaration.source.value !== SDK_SERVER_PACKAGE_NAME) return false;
195
- return (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) === DS_BINDING;
196
- }
197
- const traverse = babelTraverse.default ?? babelTraverse;
198
- /** Only a call initialising a top-level variable counts; one inside a function or block does not. */
199
- function findVisibilityCalls(ast) {
200
- const found = [];
201
- traverse(ast, { VariableDeclarator(path$1) {
202
- const list = path$1.parentPath;
203
- if (!list.isVariableDeclaration()) return;
204
- const statement = list.parentPath.isExportNamedDeclaration() ? list.parentPath : list;
205
- if (!statement.parentPath?.isProgram()) return;
206
- if (path$1.node.init === null || path$1.node.init === void 0) return;
207
- const call = unwrap(path$1.node.init);
208
- if (call.type !== "CallExpression" && call.type !== "OptionalCallExpression") return;
209
- if (call.callee.type !== "Identifier") return;
210
- if (!isVisibilityImport(path$1, call.callee.name)) return;
211
- found.push({
212
- call,
213
- statement: statement.node
214
- });
215
- } });
216
- return found;
217
- }
218
- function isVisibilityImport(path$1, name) {
219
- const binding = path$1.scope.getBinding(name);
220
- if (binding === void 0 || binding.kind !== "module") return false;
221
- const specifier = binding.path.node;
222
- const declaration = binding.path.parent;
223
- if (specifier.type !== "ImportSpecifier" || declaration.type !== "ImportDeclaration") return false;
224
- if (specifier.importKind === "type" || declaration.importKind === "type") return false;
225
- if (declaration.source.value !== SDK_PACKAGE_NAME) return false;
226
- return (specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value) === VISIBILITY_FUNCTION;
227
- }
228
- /** Strips the wrappers that leave the runtime value unchanged: `as const`, `satisfies`, `!`, `()`. */
229
- function unwrap(node) {
230
- switch (node.type) {
231
- case "TSAsExpression":
232
- case "TSSatisfiesExpression":
233
- case "TSNonNullExpression":
234
- case "TSTypeAssertion":
235
- case "TSInstantiationExpression":
236
- case "ParenthesizedExpression": return unwrap(node.expression);
237
- default: return node;
238
- }
239
- }
240
- function reject(node, reason) {
241
- return {
242
- ok: false,
243
- reason,
244
- line: node.loc?.start.line ?? 1,
245
- column: node.loc?.start.column ?? 0
246
- };
247
- }
248
- function readLiteral(input) {
249
- const node = unwrap(input);
250
- switch (node.type) {
251
- case "StringLiteral": return {
252
- ok: true,
253
- value: node.value
254
- };
255
- case "TemplateLiteral":
256
- if (node.expressions.length > 0) return reject(node, "a template literal with an expression is not a static value");
257
- return {
258
- ok: true,
259
- value: node.quasis[0]?.value.cooked ?? ""
260
- };
261
- case "ArrayExpression": {
262
- const values = [];
263
- for (const element of node.elements) {
264
- if (element === null) return reject(node, "an array hole is not a static value");
265
- if (element.type === "SpreadElement") return reject(element, "a spread is not a static value");
266
- const read = readLiteral(element);
267
- if (!read.ok) return read;
268
- values.push(read.value);
269
- }
270
- return {
271
- ok: true,
272
- value: values
273
- };
274
- }
275
- case "ObjectExpression": {
276
- const value = {};
277
- for (const property of node.properties) {
278
- if (property.type === "SpreadElement") return reject(property, "a spread is not a static value");
279
- if (property.type === "ObjectMethod") return reject(property, "a method is not a static value");
280
- if (property.computed) return reject(property, "a computed key is not a static value");
281
- if (property.shorthand) return reject(property, "a shorthand property is not a static value");
282
- const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "StringLiteral" ? property.key.value : null;
283
- if (key === null) return reject(property.key, "this key is not a static value");
284
- if (key === "__proto__") return reject(property.key, "a __proto__ key is not a static value");
285
- const read = readLiteral(property.value);
286
- if (!read.ok) return read;
287
- value[key] = read.value;
288
- }
289
- return {
290
- ok: true,
291
- value
292
- };
293
- }
294
- case "NumericLiteral":
295
- case "BooleanLiteral":
296
- case "NullLiteral": return reject(node, "a visibility rule is built from strings, arrays and objects");
297
- case "Identifier": return reject(node, `an identifier (${node.name}) is not a static value`);
298
- case "CallExpression":
299
- case "NewExpression": return reject(node, "a call is not a static value");
300
- default: return reject(node, `${node.type} is not a static value`);
301
- }
302
- }
303
-
304
- //#endregion
305
- export { crawlRoutes, extractDatasources, extractVisibility };
@@ -1,24 +0,0 @@
1
- import { DatasourceAccess, VisibilityValue } from "./discover.js";
2
-
3
- //#region src/routes/manifest.d.ts
4
- type DatasourceEntry = DatasourceAccess & {
5
- /** Project-relative path to the file where the datasource is accessed. */
6
- filename: string;
7
- /** Route path, if the file is a route file. */
8
- route?: string;
9
- };
10
- type ManifestNode = {
11
- routePath: string;
12
- filePath: string;
13
- _fsRouteType?: string;
14
- inferFullPath: string | null;
15
- createFileRouteProps: Array<string>;
16
- visibility: VisibilityValue | null;
17
- children: Array<ManifestNode>;
18
- };
19
- type Manifest = {
20
- root: ManifestNode;
21
- datasources: Array<DatasourceEntry>;
22
- };
23
- //#endregion
24
- export { Manifest, ManifestNode };
@@ -1,168 +0,0 @@
1
- import { crawlRoutes, extractDatasources, extractVisibility } from "./discover.js";
2
- import { readFile } from "node:fs/promises";
3
- import path from "node:path";
4
- import { inferFullPath } from "@tanstack/router-generator";
5
-
6
- //#region src/routes/manifest.ts
7
- var ManifestError = class extends Error {
8
- detail;
9
- constructor(detail) {
10
- super(detail.message);
11
- this.name = "ManifestError";
12
- this.detail = detail;
13
- }
14
- };
15
- /** Returns the manifest as the exact text to write. Throws on a declaration it cannot read. */
16
- async function generateRoutesManifest(root) {
17
- const crawl = await crawlRoutes({ root });
18
- const routeFiles = new Set([crawl.rootRouteNode.filePath]);
19
- for (const node of crawl.routeNodes) routeFiles.add(node.filePath);
20
- for (const pieces of Object.values(crawl.routePiecesByPath)) if (pieces.lazy) routeFiles.add(pieces.lazy.filePath);
21
- const sources = /* @__PURE__ */ new Map();
22
- for (const relative of routeFiles) {
23
- const absolute = path.resolve(root, crawl.routesDirectory, relative);
24
- const source = await readIfPresent(absolute);
25
- if (source !== null) sources.set(absolute, source);
26
- }
27
- const manifest = buildManifest({
28
- crawl,
29
- root,
30
- sources
31
- });
32
- return `${JSON.stringify(manifest, null, 2)}\n`;
33
- }
34
- function buildManifest(opts) {
35
- const routesDirectory = opts.crawl.routesDirectory;
36
- const datasources = [];
37
- const toNode = (node) => {
38
- const projectRelative = toProjectRelative(opts.root, routesDirectory, node.filePath);
39
- const routePath = node.routePath;
40
- if (routePath === void 0) throw new Error(`the route crawl produced a node with no route path: ${projectRelative}`);
41
- let declared = visibilityOf(opts, node.filePath);
42
- datasources.push(...datasourceEntriesOf(opts, node.filePath, routePath));
43
- const lazyPiece = opts.crawl.routePiecesByPath[routePath]?.lazy;
44
- if (lazyPiece && lazyPiece.filePath !== node.filePath) {
45
- const lazyDeclared = visibilityOf(opts, lazyPiece.filePath);
46
- if (lazyDeclared !== null && declared !== null) throw new ManifestError({
47
- code: "duplicate-declaration",
48
- message: `${routePath} declares visibility in both ${projectRelative} and ${lazyDeclared.filePath}; a route has exactly one rule`,
49
- filePath: lazyDeclared.filePath,
50
- line: lazyDeclared.line,
51
- column: lazyDeclared.column
52
- });
53
- if (lazyDeclared !== null) declared = lazyDeclared;
54
- datasources.push(...datasourceEntriesOf(opts, lazyPiece.filePath, routePath));
55
- }
56
- return {
57
- routePath,
58
- filePath: projectRelative,
59
- ...node._fsRouteType ? { _fsRouteType: node._fsRouteType } : {},
60
- inferFullPath: inferFullPath(node) ?? null,
61
- createFileRouteProps: [...node.createFileRouteProps ?? []].sort(),
62
- visibility: declared?.value ?? null,
63
- children: (node.children ?? []).map(toNode)
64
- };
65
- };
66
- const rootNode = opts.crawl.rootRouteNode;
67
- const rootRelative = toProjectRelative(opts.root, routesDirectory, rootNode.filePath);
68
- const rootDeclared = visibilityOf(opts, rootNode.filePath);
69
- if (rootDeclared !== null) throw new ManifestError({
70
- code: "root-declaration",
71
- message: "the root route cannot carry a visibility rule: it would gate sign-in with no way back in. Move this declaration to the routes you want gated.",
72
- filePath: rootRelative,
73
- line: rootDeclared.line,
74
- column: rootDeclared.column
75
- });
76
- datasources.push(...datasourceEntriesOf(opts, rootNode.filePath, ROOT_ROUTE_ID));
77
- return {
78
- root: {
79
- routePath: ROOT_ROUTE_ID,
80
- filePath: rootRelative,
81
- inferFullPath: inferFullPath(rootNode) ?? null,
82
- createFileRouteProps: [...rootNode.createFileRouteProps ?? []].sort(),
83
- visibility: null,
84
- children: opts.crawl.routeTree.map(toNode)
85
- },
86
- datasources
87
- };
88
- }
89
- /**
90
- * `rootRouteId` from `@tanstack/router-core`, which is what a consumer matches against — not the
91
- * generator's `rootPathId`, which is the base name of `__root.tsx` and names a file, not a route.
92
- */
93
- const ROOT_ROUTE_ID = "__root__";
94
- /** The crawl's paths are relative to the routes directory; the manifest's are project-relative. */
95
- function toProjectRelative(root, routesDirectory, filePath) {
96
- const absolute = path.resolve(root, routesDirectory, filePath);
97
- return path.relative(root, absolute).split(path.sep).join("/");
98
- }
99
- const USER_GROUPS = [
100
- "ALL_USERS",
101
- "NON_LOGGED_IN_USERS",
102
- "LOGGED_IN_USERS"
103
- ];
104
- function checkVisibilityShape(declared) {
105
- const reject = (message) => {
106
- throw new ManifestError({
107
- code: "invalid-visibility",
108
- message: `${declared.filePath}: ${message}`,
109
- filePath: declared.filePath,
110
- line: declared.line,
111
- column: declared.column
112
- });
113
- };
114
- const visibility = declared.value;
115
- if (typeof visibility !== "object" || Array.isArray(visibility)) reject(`the visibility rule must be an object literal like { userGroup: 'ALL_USERS' }, not ${JSON.stringify(visibility)}`);
116
- for (const key of Object.keys(visibility)) {
117
- if (key === "userGroup" || key === "userGroupIds") continue;
118
- reject(`"${key}" is not part of a visibility rule; a rule has userGroup and, under LOGGED_IN_USERS, userGroupIds. Check the spelling.`);
119
- }
120
- const userGroup = visibility["userGroup"];
121
- if (typeof userGroup !== "string" || !USER_GROUPS.includes(userGroup)) reject(`userGroup must be one of ${USER_GROUPS.join(", ")}, not ${JSON.stringify(userGroup)}. A rule the build cannot read is a route with no rule at all.`);
122
- if (!("userGroupIds" in visibility)) return;
123
- if (userGroup !== "LOGGED_IN_USERS") reject(`userGroupIds only means anything under LOGGED_IN_USERS, not under ${userGroup}`);
124
- const ids = visibility["userGroupIds"];
125
- if (!Array.isArray(ids) || ids.some((id) => typeof id !== "string")) reject(`userGroupIds must be an array of group id strings, not ${JSON.stringify(ids)}. Drop the key entirely to mean "any logged-in user".`);
126
- }
127
- function visibilityOf(opts, filePath) {
128
- const projectRelative = toProjectRelative(opts.root, opts.crawl.routesDirectory, filePath);
129
- const source = opts.sources.get(path.resolve(opts.root, opts.crawl.routesDirectory, filePath));
130
- if (source === void 0) return null;
131
- const result = extractVisibility(source, projectRelative);
132
- if (!result.ok) throw new ManifestError(result.error);
133
- if (result.visibility === null) return null;
134
- const declared = {
135
- ...result.visibility,
136
- filePath: projectRelative
137
- };
138
- checkVisibilityShape(declared);
139
- return declared;
140
- }
141
- function datasourceEntriesOf(opts, filePath, routePath) {
142
- const projectRelative = toProjectRelative(opts.root, opts.crawl.routesDirectory, filePath);
143
- const source = opts.sources.get(path.resolve(opts.root, opts.crawl.routesDirectory, filePath));
144
- if (source === void 0) return [];
145
- const result = extractDatasources(source, projectRelative);
146
- if (!result.ok) throw new ManifestError(result.error);
147
- return result.datasources.map(({ id, methods }) => ({
148
- id,
149
- methods,
150
- filename: projectRelative,
151
- route: routePath
152
- }));
153
- }
154
- /**
155
- * Only ENOENT becomes `null`. The caller reads `null` as "nothing declared here", so swallowing any
156
- * other read error would turn an unreadable declaration into an ungated route.
157
- */
158
- async function readIfPresent(filePath) {
159
- try {
160
- return await readFile(filePath, "utf8");
161
- } catch (error) {
162
- if (error.code === "ENOENT") return null;
163
- throw error;
164
- }
165
- }
166
-
167
- //#endregion
168
- export { ManifestError, generateRoutesManifest };
@@ -1,71 +0,0 @@
1
- import * as nodeFs from "node:fs/promises";
2
- import path from "node:path";
3
-
4
- //#region src/routes/mem-fs.ts
5
- /**
6
- * Reads through to disk and swallows every write. Driving the generator without this rewrites the
7
- * app's route sources and strips Start's `Register` block from its routeTree.gen.ts.
8
- */
9
- function createReadOnlyFs() {
10
- const writes = /* @__PURE__ */ new Map();
11
- return {
12
- async stat(filePath) {
13
- const stat = await statOrNull(path.resolve(filePath));
14
- if (stat === null) return {
15
- mtimeMs: SYNTHETIC_MTIME,
16
- mode: 420,
17
- uid: 0,
18
- gid: 0
19
- };
20
- return {
21
- mtimeMs: stat.mtimeMs,
22
- mode: Number(stat.mode),
23
- uid: Number(stat.uid),
24
- gid: Number(stat.gid)
25
- };
26
- },
27
- async rename(oldPath, newPath) {
28
- const from = path.resolve(oldPath);
29
- const content = writes.get(from);
30
- if (content !== void 0) {
31
- writes.delete(from);
32
- writes.set(path.resolve(newPath), content);
33
- }
34
- },
35
- async writeFile(filePath, content) {
36
- writes.set(path.resolve(filePath), content);
37
- },
38
- async readFile(filePath) {
39
- const resolved = path.resolve(filePath);
40
- const pending = writes.get(resolved);
41
- const stat = await statOrNull(resolved);
42
- if (pending !== void 0) return {
43
- stat: { mtimeMs: stat?.mtimeMs ?? SYNTHETIC_MTIME },
44
- fileContent: pending
45
- };
46
- if (stat === null) return "file-not-existing";
47
- try {
48
- const fileContent = await nodeFs.readFile(resolved, "utf8");
49
- return {
50
- stat: { mtimeMs: stat.mtimeMs },
51
- fileContent
52
- };
53
- } catch {
54
- return "file-not-existing";
55
- }
56
- },
57
- async chmod() {},
58
- async chown() {}
59
- };
60
- }
61
- const SYNTHETIC_MTIME = 1n;
62
- async function statOrNull(filePath) {
63
- try {
64
- return await nodeFs.stat(filePath, { bigint: true });
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- //#endregion
71
- export { createReadOnlyFs };