@assembly-js/app-foundation 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
19
19
  not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
20
20
  `bump: minor` change file.
21
21
 
22
+ ## 0.12.0
23
+ - [behavior] `check:crons` on a platform update turn now only warns about a cron route the app already had without a schedule; a route added this turn, or one that lost its schedule, still fails.
24
+
25
+ ## 0.11.0
26
+ - [behavior] `check:record-urls` now checks each surface on its own: a record opened on the client portal needs its own `/c` route or `useUrlState`, and an internal dynamic route no longer exempts it. It also catches a list/detail view switch held in `useState`, so a client's request page keeps its own URL that can be reloaded or shared.
27
+ - [guidance] `assembly-app-inner-location` says the record URL rule applies to the client portal too, with `app/c/[id]/page.tsx` or `?id=` through `useUrlState` as the two valid shapes.
28
+
22
29
  ## 0.10.0
23
30
  - [behavior] A call to another app through the installer client or a raw `fetch` to `/v1/apps/...` made inside a client's request now carries that client, so the platform checks the client could open the other app and the other app sees a client whose calling app is this one. A client who cannot open it gets the platform's `viewer_cannot_open_app` refusal as an error value; staff requests, cron, webhooks and queues are unchanged and send no parent.
24
31
  - [behavior] A cross-app call from a client's request whose session cannot be verified (exchange off, a rejected header or expired cookie) now throws the token-invalid error instead of running as the installing admin, and a cross-app call where the request cannot be read (`'use cache'`, prerender, `after()`) throws. Storage, connector, notification and AI proxy installer calls are untouched.
@@ -16,7 +16,8 @@
16
16
  // as warnings: those paths have no agent who can act on a failure, so failing
17
17
  // them would only strand an app that already worked. A below-floor schedule
18
18
  // the app shipped before the turn stays a warning even then (see
19
- // baselineCronSchedules).
19
+ // baselineCronSchedules), as does a cron route the app shipped with no schedule
20
+ // at all (see baselineUnscheduledRoutes).
20
21
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
21
22
  import { spawnSync } from 'node:child_process';
22
23
  import { join, relative, sep } from 'node:path';
@@ -42,7 +43,8 @@ const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
42
43
  // Set by the turn program on a platform update turn, where the update itself is
43
44
  // the mandate to fix whatever is wrong, so findings fail the build whether or
44
45
  // not the turn touched the cron wiring.
45
- const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
46
+ const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
47
+ const STRICT = process.argv.includes('--strict') || UPDATE_TURN;
46
48
  const CRON_WIRING_PATHS = ['vercel.json', 'app/api/cron/'];
47
49
 
48
50
  function report(lines, { fatal }) {
@@ -92,6 +94,44 @@ function baselineCronSchedules() {
92
94
  return schedules;
93
95
  }
94
96
 
97
+ // Cron route files (repo-relative, posix) that existed at the turn baseline with
98
+ // no vercel.json entry invoking them. An update turn has no mandate to schedule
99
+ // work the app never ran, so these only warn. An unreadable baseline yields an
100
+ // empty set, which keeps every unscheduled route fatal.
101
+ function baselineUnscheduledRoutes() {
102
+ const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
103
+ const unscheduled = new Set();
104
+ if (!baseline || !UPDATE_TURN) return unscheduled;
105
+ const listing = spawnSync('git', ['ls-tree', '-r', '--name-only', baseline, '--', 'app'], {
106
+ cwd: ROOT,
107
+ encoding: 'utf8',
108
+ });
109
+ if (listing.status !== 0) return unscheduled;
110
+ // Route groups can hold cron routes too, so list all of app/ and filter by the URL served.
111
+ const routePaths = listing.stdout
112
+ .split('\n')
113
+ .filter((p) => ROUTE_FILE.test(p.split('/').pop()))
114
+ .filter((p) => ('/' + urlSegments(p.split('/').slice(1)).join('/') + '/').startsWith(CRON_ROUTE_PREFIX));
115
+ const shown = spawnSync('git', ['show', `${baseline}:vercel.json`], { cwd: ROOT, encoding: 'utf8' });
116
+ let baselineCrons = [];
117
+ if (shown.status === 0) {
118
+ try {
119
+ const parsed = JSON.parse(shown.stdout).crons;
120
+ if (Array.isArray(parsed)) baselineCrons = parsed;
121
+ } catch {
122
+ // An unparseable baseline file scheduled nothing.
123
+ }
124
+ }
125
+ const scheduledPaths = baselineCrons
126
+ .filter((c) => typeof c?.path === 'string')
127
+ .map((c) => c.path.replace(/\/+$/, '').split('/').filter(Boolean));
128
+ for (const path of routePaths) {
129
+ const segments = urlSegments(path.split('/').slice(1));
130
+ if (!scheduledPaths.some((pathSegments) => routeServes(segments, pathSegments))) unscheduled.add(path);
131
+ }
132
+ return unscheduled;
133
+ }
134
+
95
135
  function cronKey(entry) {
96
136
  return `${entry?.path}\n${typeof entry?.schedule === 'string' ? entry.schedule.trim() : ''}`;
97
137
  }
@@ -146,8 +186,11 @@ function routeFiles(dir) {
146
186
  // The URL segments a route file serves; route groups `(group)` and parallel
147
187
  // slots `@slot` never appear in the URL.
148
188
  function urlSegmentsForRouteFile(file) {
149
- return relative(APP_DIR, file)
150
- .split(sep)
189
+ return urlSegments(relative(APP_DIR, file).split(sep));
190
+ }
191
+
192
+ function urlSegments(pathParts) {
193
+ return pathParts
151
194
  .slice(0, -1)
152
195
  .filter((seg) => !(seg.startsWith('(') && seg.endsWith(')')) && !seg.startsWith('@'));
153
196
  }
@@ -259,6 +302,7 @@ const entries = Array.isArray(crons) ? crons : [];
259
302
  const coveredRoutes = new Set();
260
303
  const shippedSchedules = baselineCronSchedules();
261
304
  const preexistingIntervals = [];
305
+ const unscheduledAtBaseline = baselineUnscheduledRoutes();
262
306
 
263
307
  entries.forEach((entry, i) => {
264
308
  const label = `vercel.json crons[${i}]`;
@@ -296,6 +340,14 @@ entries.forEach((entry, i) => {
296
340
  for (const route of cronRoutes) {
297
341
  const url = '/' + route.segments.join('/');
298
342
  if (!coveredRoutes.has(route.file)) {
343
+ const rel = relative(ROOT, route.file).split(sep).join('/');
344
+ if (unscheduledAtBaseline.has(rel)) {
345
+ preexistingIntervals.push(
346
+ `${rel} serves ${url} but no vercel.json cron entry invokes it. The app shipped this route unscheduled, ` +
347
+ `and the update does not add a schedule, so leave it as it is.`,
348
+ );
349
+ continue;
350
+ }
299
351
  problems.push(
300
352
  `${relative(ROOT, route.file)} serves ${url} but no vercel.json cron entry invokes it — it will never run ` +
301
353
  `after publish. Add { "path": "${url}", "schedule": "*/30 * * * *" } to "crons" or remove the route.`,
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
- // Fails the build when a record opened from a list (a Dialog, Sheet, Drawer or
3
- // detail panel) is driven by React state alone: the host URL never changes, so a
4
- // reload or a shared link lands on the list instead of the record. The fix is
5
- // either `useUrlState('id')` from `@assembly-js/app-foundation/client` or a
6
- // dynamic route for the record on the internal surface. The match is a
7
- // heuristic on a `selected*`-style `useState` that feeds a panel-like element.
2
+ // Fails the build when a record opened from a list (a Dialog, Sheet, Drawer,
3
+ // detail panel or a list/detail view switch) is driven by React state alone: the
4
+ // host URL never changes, so a reload or a shared link lands on the list instead
5
+ // of the record. The fix is either `useUrlState('id')` from
6
+ // `@assembly-js/app-foundation/client` or a dynamic route for the record on the
7
+ // surface that renders it. The match is a heuristic on a `selected*`-style
8
+ // `useState` that feeds a panel-like element, or a `useState<View>` whose type
9
+ // has a detail-like variant carrying an `id`.
8
10
  // Dependency free on purpose: it runs inside the generated app's sandbox before
9
11
  // any deps beyond Node are guaranteed.
10
12
  //
@@ -14,7 +16,7 @@
14
16
  // On a platform update turn (ASSEMBLY_UPDATE_TURN=1) it only warns, because an
15
17
  // update never rewrites how an app navigates.
16
18
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
17
- import { join, relative, sep } from 'node:path';
19
+ import { dirname, join, relative, sep } from 'node:path';
18
20
 
19
21
  const ROOT = process.cwd();
20
22
  const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
@@ -23,13 +25,24 @@ const MANIFEST = join(ROOT, '.assembly', 'app.json');
23
25
  const INNER_LOCATION_SKILL = join(ROOT, '.claude', 'skills', 'assembly-app-inner-location', 'SKILL.md');
24
26
  const SOURCE_FILE = /\.(tsx|jsx)$/;
25
27
  const PAGE_FILE = /^page\.(tsx|ts|jsx|js)$/;
28
+ const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js'];
29
+ const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(\s*)['"]([^'"]+)['"]/g;
26
30
 
27
31
  // `const [selectedRequest, setSelectedRequest] = useState(...)`; the setter name is not trusted.
28
32
  const USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\b/g;
29
33
  const RECORD_SELECTION = /^(?:selected|active|viewing|viewed|current|detail|open(?:ed)?)[A-Z_]/;
30
34
  // Selection of something that is not a record, or that is genuinely transient.
31
- const NOT_A_RECORD = /(?:Tab|Filter|Status|Sort|View|Option|Date|Range|Column|Mode|Step|Page|Menu)s?$|^(?:confirm|delete|remove|menu|pending)/i;
35
+ const TRANSIENT_NAME = /^(?:confirm|delete|remove|menu|pending)/i;
36
+ const NOT_A_RECORD = new RegExp(
37
+ `(?:Tab|Filter|Status|Sort|View|Option|Date|Range|Column|Mode|Step|Page|Menu)s?$|${TRANSIENT_NAME.source}`,
38
+ 'i',
39
+ );
32
40
  const PANEL_ELEMENT = /<\w*(?:Dialog|Sheet|Drawer|Panel|Modal|Detail)\w*[\s>/]/;
41
+ // `const [view, setView] = useState<View>(...)`; the generic is read by scanning, since it can nest.
42
+ const TYPED_USE_STATE = /const\s+\[\s*([A-Za-z_$][\w$]*)\s*,\s*[A-Za-z_$][\w$]*\s*\]\s*=\s*useState\s*</g;
43
+ // A view switch that can show one record: a variant naming a detail-like view, and an `id` field.
44
+ const DETAIL_VARIANT = /['"`](?:detail|show|view|open|record|item)['"`]/i;
45
+ const ID_FIELD = /\bid\??\s*:/;
33
46
  const OPT_OUT = /record-urls:\s*transient/;
34
47
 
35
48
  function sourceFiles(dir) {
@@ -47,15 +60,15 @@ function sourceFiles(dir) {
47
60
  return out;
48
61
  }
49
62
 
50
- // Prefixes of the client and notification surfaces; a dynamic route there does
51
- // not give the internal dashboard a record URL.
52
- function nonInternalPrefixes() {
53
- const prefixes = new Set(['/c', '/n']);
63
+ // Prefixes of the client and notification surfaces; a dynamic route on one
64
+ // surface does not give another surface a record URL.
65
+ function surfacePrefixes() {
66
+ const prefixes = { client: new Set(['/c']), notification: new Set(['/n']) };
54
67
  try {
55
68
  const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8'));
56
69
  for (const key of ['client', 'notification']) {
57
70
  const p = manifest?.surfaces?.[key]?.path;
58
- if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes.add(p.replace(/\/+$/, ''));
71
+ if (typeof p === 'string' && p.startsWith('/') && p !== '/') prefixes[key].add(p.replace(/\/+$/, ''));
59
72
  }
60
73
  } catch {
61
74
  // defaults
@@ -63,6 +76,10 @@ function nonInternalPrefixes() {
63
76
  return prefixes;
64
77
  }
65
78
 
79
+ function underPrefix(route, prefixes) {
80
+ return [...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
81
+ }
82
+
66
83
  function routeForPageFile(file) {
67
84
  const segments = relative(APP_DIR, file).split(sep).slice(0, -1);
68
85
  const urlSegments = segments.filter(
@@ -78,31 +95,133 @@ const contents = new Map(files.map((f) => [f, readFileSync(f, 'utf8')]));
78
95
 
79
96
  if (!existsSync(INNER_LOCATION_SKILL)) process.exit(0);
80
97
 
81
- const prefixes = nonInternalPrefixes();
82
- const hasInternalRecordRoute = files.some((f) => {
83
- if (!PAGE_FILE.test(f.split(sep).pop())) return false;
84
- const route = routeForPageFile(f);
85
- if (!route.includes('[')) return false;
86
- return ![...prefixes].some((p) => route === p || route.startsWith(`${p}/`));
87
- });
88
- if (hasInternalRecordRoute) process.exit(0);
89
-
90
98
  function escapeRe(s) {
91
99
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
100
  }
93
101
 
102
+ function relPath(file) {
103
+ return relative(ROOT, file).split(sep).join('/');
104
+ }
105
+
106
+ function resolveImport(from, spec) {
107
+ let base;
108
+ if (spec.startsWith('./') || spec.startsWith('../')) base = join(dirname(from), spec);
109
+ else if (spec.startsWith('@/')) base = join(ROOT, spec.slice(2));
110
+ else return null;
111
+ const candidates = [
112
+ base,
113
+ ...RESOLVE_EXTENSIONS.map((ext) => base + ext),
114
+ ...RESOLVE_EXTENSIONS.map((ext) => join(base, 'index' + ext)),
115
+ ];
116
+ return candidates.find((c) => contents.has(c)) ?? null;
117
+ }
118
+
119
+ // Files reachable from `roots` through relative and `@/` imports. An import the
120
+ // regex misses (a barrel re-export, a computed path) only shrinks the set.
121
+ function reachableFrom(roots) {
122
+ const seen = new Set(roots);
123
+ const queue = [...roots];
124
+ while (queue.length) {
125
+ const file = queue.pop();
126
+ for (const m of contents.get(file).matchAll(IMPORT_SPECIFIER)) {
127
+ const target = resolveImport(file, m[1]);
128
+ if (target && !seen.has(target)) {
129
+ seen.add(target);
130
+ queue.push(target);
131
+ }
132
+ }
133
+ }
134
+ return seen;
135
+ }
136
+
137
+ // The text between the `<` before `start` and its matching `>`, skipping `=>`.
138
+ function genericArgument(src, start) {
139
+ let depth = 1;
140
+ for (let i = start; i < src.length; i++) {
141
+ const ch = src[i];
142
+ if (ch === '<') depth++;
143
+ else if (ch === '>' && src[i - 1] !== '=' && --depth === 0) return src.slice(start, i);
144
+ }
145
+ return '';
146
+ }
147
+
148
+ // The body of `type Name = …` or `interface Name {…}` declared in this file.
149
+ function localTypeBody(src, name) {
150
+ const n = escapeRe(name);
151
+ const decl = new RegExp(`\\btype\\s+${n}\\b(?:<[^=]*>)?\\s*=|\\binterface\\s+${n}\\b[^{]*`).exec(src);
152
+ if (!decl) return '';
153
+ const isInterface = decl[0].startsWith('interface');
154
+ const start = decl.index + decl[0].length;
155
+ let depth = 0;
156
+ for (let i = start; i < src.length; i++) {
157
+ const ch = src[i];
158
+ if ('{(['.includes(ch)) depth++;
159
+ else if ('})]'.includes(ch)) {
160
+ depth--;
161
+ if (depth === 0 && isInterface) return src.slice(start, i + 1);
162
+ } else if (depth === 0 && !isInterface && ch === ';') {
163
+ return src.slice(start, i);
164
+ } else if (depth === 0 && !isInterface && ch === '\n' && src.slice(start, i).trim() && !/^\s*[|&]/.test(src.slice(i + 1))) {
165
+ return src.slice(start, i);
166
+ }
167
+ }
168
+ return src.slice(start);
169
+ }
170
+
171
+ function isDetailViewType(src, typeText) {
172
+ let text = typeText;
173
+ for (const ident of new Set(typeText.match(/\b[A-Z][\w$]*\b/g) ?? [])) text += '\n' + localTypeBody(src, ident);
174
+ return ID_FIELD.test(text) && DETAIL_VARIANT.test(text);
175
+ }
176
+
177
+ const prefixes = surfacePrefixes();
178
+ const clientPages = [];
179
+ const internalPages = [];
180
+ let hasClientRecordRoute = false;
181
+ let hasInternalRecordRoute = false;
182
+ for (const f of files) {
183
+ if (!PAGE_FILE.test(f.split(sep).pop())) continue;
184
+ const route = routeForPageFile(f);
185
+ if (underPrefix(route, prefixes.client)) {
186
+ clientPages.push(f);
187
+ if (route.includes('[')) hasClientRecordRoute = true;
188
+ } else if (!underPrefix(route, prefixes.notification)) {
189
+ internalPages.push(f);
190
+ if (route.includes('[')) hasInternalRecordRoute = true;
191
+ }
192
+ }
193
+ const clientReachable = reachableFrom(clientPages);
194
+ const internalReachable = reachableFrom(internalPages);
195
+
196
+ // A route exempts a file only when every surface that renders it has one. A file
197
+ // no page reaches is judged as internal, as it was before the check followed imports.
198
+ function exemptByRoute(file) {
199
+ const onClient = clientReachable.has(file);
200
+ const onInternal = internalReachable.has(file) || !onClient;
201
+ return (!onClient || hasClientRecordRoute) && (!onInternal || hasInternalRecordRoute);
202
+ }
203
+
94
204
  const violations = [];
95
205
  for (const file of files) {
96
206
  if (!SOURCE_FILE.test(file)) continue;
97
207
  const src = contents.get(file);
98
- if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) || !PANEL_ELEMENT.test(src)) continue;
99
- for (const match of src.matchAll(USE_STATE)) {
208
+ if (/\buseUrlState\b/.test(src) || OPT_OUT.test(src) || exemptByRoute(file)) continue;
209
+ const where = clientReachable.has(file) ? `${relPath(file)} [client surface]` : relPath(file);
210
+ if (PANEL_ELEMENT.test(src)) {
211
+ for (const match of src.matchAll(USE_STATE)) {
212
+ const name = match[1];
213
+ if (!RECORD_SELECTION.test(name) || NOT_A_RECORD.test(name)) continue;
214
+ // Declared and read somewhere else in the file, i.e. it feeds the panel.
215
+ const uses = src.match(new RegExp(`(?<![\\w$.])${escapeRe(name)}(?![\\w$])`, 'g')) ?? [];
216
+ if (uses.length < 3) continue;
217
+ violations.push(`${where} (useState: ${name})`);
218
+ }
219
+ }
220
+ for (const match of src.matchAll(TYPED_USE_STATE)) {
100
221
  const name = match[1];
101
- if (!RECORD_SELECTION.test(name) || NOT_A_RECORD.test(name)) continue;
102
- // Declared and read somewhere else in the file, i.e. it feeds the panel.
103
- const uses = src.match(new RegExp(`(?<![\\w$.])${escapeRe(name)}(?![\\w$])`, 'g')) ?? [];
104
- if (uses.length < 3) continue;
105
- violations.push(`${relative(ROOT, file).split(sep).join('/')} (useState: ${name})`);
222
+ if (TRANSIENT_NAME.test(name)) continue;
223
+ if (!isDetailViewType(src, genericArgument(src, match.index + match[0].length))) continue;
224
+ violations.push(`${where} (useState view switch: ${name})`);
106
225
  }
107
226
  }
108
227
 
@@ -121,7 +240,12 @@ if (violations.length > 0 && UPDATE_TURN) {
121
240
  console.error(" - Replace the useState with `const [id, setId] = useUrlState('id')` from");
122
241
  console.error(" '@assembly-js/app-foundation/client' and render the record from `id`;");
123
242
  console.error(' a cold load with ?id= must open it, and Back must close it.');
124
- console.error(' - Give the record its own route (app/requests/[id]/page.tsx) and link to it.');
243
+ console.error(' - Give the record its own route on the surface that lists it and link to it:');
244
+ console.error(' app/requests/[id]/page.tsx on the internal surface.');
245
+ console.error('A file marked [client surface] needs the same fix there: call');
246
+ console.error("`useUrlState('requestId')` in the client page component instead of a view state,");
247
+ console.error('or add app/c/[id]/page.tsx and link to it from the list. A cold load of that URL');
248
+ console.error('must open the record and Back must close it.');
125
249
  console.error('A genuinely transient UI (confirm dialog, menu) can add the comment');
126
250
  console.error('`// record-urls: transient` to the file.');
127
251
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assembly-js/app-foundation",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Versioned foundation helpers and build context for Assembly generated apps",
5
5
  "type": "module",
6
6
  "files": [
@@ -13,7 +13,7 @@ The host mirrors the app's inner page in its own address bar and can open the ap
13
13
 
14
14
  ## Records get a URL
15
15
 
16
- Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it.
16
+ Keep which record is open in the URL, not in `useState`: `const [id, setId] = useUrlState('id')` from `@assembly-js/app-foundation/client` for a panel, Sheet or Dialog, or a route like `app/requests/[id]/page.tsx`. A record opened from a list must load cold from its URL and Back must close it. `bun run build` fails `check:record-urls` for a `selectedId`-style `useState` feeding a record panel; use the helper or a route to fix it. This applies to the client surface (`/c`) as much as the internal one: a record a client opens from a list is `app/c/[id]/page.tsx` or `?id=` through `useUrlState`, never a `useState<View>` that switches between list and detail; the check fails that too.
17
17
 
18
18
  ## Notifications deep-link to the record, not the app root
19
19