@assembly-js/app-foundation 0.0.51 → 0.0.53
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 +7 -0
- package/CLAUDE.md +7 -2
- package/bin/check-client-boundary.mjs +304 -0
- package/package.json +1 -1
- package/skills/assembly-notifications/SKILL.md +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,13 @@ A foundation update reads the sections between an app's version and the target:
|
|
|
14
14
|
from every bullet except `[guidance]`, agent work from the check scripts those `[action]`
|
|
15
15
|
bullets ship. Write `[fix]`, `[behavior]` and `[action]` bullets for the app's owner.
|
|
16
16
|
|
|
17
|
+
## 0.0.53
|
|
18
|
+
- [action] A page or server action that calls a helper exported from a client-component file errors for every visitor who opens it; the build now reports the call, and the helper moves to a plain module both sides import.
|
|
19
|
+
- [guidance] Tell the build agent that a helper shared between a server-rendered page and a client component belongs in a plain module, not the client component's file.
|
|
20
|
+
|
|
21
|
+
## 0.0.52
|
|
22
|
+
- [guidance] The build agent is told that the CRM tab loads the app's client route as staff, with the client or company of that page in the token, so it renders a staff view scoped to that record instead of the client view or an error. It is also told to send notifications for submissions, status changes, approvals and required alerts even when the request does not mention notifications.
|
|
23
|
+
|
|
17
24
|
## 0.0.51
|
|
18
25
|
- [guidance] The build agent is told that its preview is `localhost:3000` and that the public sandbox host is unreachable from inside the sandbox.
|
|
19
26
|
|
package/CLAUDE.md
CHANGED
|
@@ -31,10 +31,10 @@ Assembly-specific gotchas live in `.claude/skills/<name>/SKILL.md`. Invoke them
|
|
|
31
31
|
- `assembly-native-resources` — native message channels, forms, portal files/file channels: never hardcode their IDs, discover them at runtime
|
|
32
32
|
- `assembly-mutations` — minimal `requestBody` rule for `update*` calls
|
|
33
33
|
<!-- !feature:notification-settings -->
|
|
34
|
-
- `assembly-notifications` — in-product + email notifications via createNotification; `ctaParams` for notification CTA URLs (the `/n` surface renders from those, not the best-effort `notificationId` claim)
|
|
34
|
+
- `assembly-notifications` — in-product + email notifications via createNotification; `ctaParams` for notification CTA URLs (the `/n` surface renders from those, not the best-effort `notificationId` claim) Send them unprompted when the app has submissions, status changes, approvals, or alerts someone must act on, even if the request never says "notify".
|
|
35
35
|
<!-- /feature:notification-settings -->
|
|
36
36
|
<!-- feature:notification-settings -->
|
|
37
|
-
- `assembly-notifications` — in-product + email notifications via createNotification; `ctaParams` for notification CTA URLs (the `/n` surface renders from those, not the best-effort `notificationId` claim); per-user toggleable categories via manifest `notificationSettings` + `notificationSettingId`
|
|
37
|
+
- `assembly-notifications` — in-product + email notifications via createNotification; `ctaParams` for notification CTA URLs (the `/n` surface renders from those, not the best-effort `notificationId` claim); per-user toggleable categories via manifest `notificationSettings` + `notificationSettingId` Send them unprompted when the app has submissions, status changes, approvals, or alerts someone must act on, even if the request never says "notify".
|
|
38
38
|
<!-- /feature:notification-settings -->
|
|
39
39
|
- `clearing-client-notifications` — REQUIRED when the app sends client notifications: clear the portal sidebar badge with markNotificationRead (clear-on-view vs clear-on-action)
|
|
40
40
|
<!-- feature:api-resources -->
|
|
@@ -138,6 +138,8 @@ The token tells you which type the current viewer is:
|
|
|
138
138
|
|
|
139
139
|
The app should **detect the viewer type from the token** and render audience-appropriate UI. Don't show "Create new" buttons to clients if creation is staff-only.
|
|
140
140
|
|
|
141
|
+
**The CRM tab runs your client route as staff.** It loads the same `clientUrl` as the Client Portal, but its token carries the staff member's `internalUserId` plus the record the tab sits on: `clientId` (with its `companyId`) on a client's page, `companyId` alone on a company's page. So every page under the client surface has three viewers: a client, staff on a client's tab, and staff on a company's tab. Check `internalUserId` first. When it is set, render the staff view of that client or company, with the actions staff take there (review, upload, manage), never the client-only view and never a "portal users only" error. On a company's tab, show that company's records only, not the whole workspace.
|
|
142
|
+
|
|
141
143
|
**The CRM tab is never the only path to per-client data.** The CRM tab is a shortcut: it renders with a specific `clientId`/`companyId` already in the token. But any client/company-specific data or action it exposes must *also* be reachable from the Dashboard main view — even if it takes a few clicks. Build the main view so an internal user can browse to a specific client/company (a list, or a `UserCompanySelector` that drills into a per-entity detail screen) and reach the same data and actions there. The main-view detail screen doesn't have to match the CRM-tab layout — it can reuse the same components or present them differently — but the main view must never be empty or aggregate-only when the CRM tab has per-client functionality. Don't leave `internalUrl` as a dead end.
|
|
142
144
|
|
|
143
145
|
<!-- !feature:app-owned-header -->
|
|
@@ -668,6 +670,8 @@ If `tsc --noEmit` is clean and the task says "verify the build", run `bun run bu
|
|
|
668
670
|
|
|
669
671
|
`bun run build` also **fails** if any page reads `searchParams.token`. Auth is in the bearer cookie, not the URL — no page (not even a surface entry page) needs it; `getAssembly()` / `getSessionIdentity()` read the bearer. Fix as the error instructs — fetch that page's data via `useServerAction`/`authFetch` instead. Run `bun run check:token-usage` to check it on its own.
|
|
670
672
|
|
|
673
|
+
`bun run build` also **fails** if server code calls a function it imported from a `'use client'` file — a page, layout, or server action, or any module they import that has no directive, calling a helper that lives next to a client component. The server receives a client reference, not the function, so the page 500s at request time while `tsc` passes. Fix as the error instructs — move the helper to a plain module with no directive that both sides import. Run `bun run check:client-boundary` to check it on its own.
|
|
674
|
+
|
|
671
675
|
<!-- !feature:app-owned-header -->
|
|
672
676
|
`bun run build` also **fails** if a page route is unreachable — no link, `router.push`, redirect, or header CTA anywhere else points at it. Fix as the error instructs by adding an entry point on a reachable page. Run `bun run check:route-reachability` to check it on its own.
|
|
673
677
|
<!-- /feature:app-owned-header -->
|
|
@@ -1472,6 +1476,7 @@ Use `AssemblyBridge.header.*` for breadcrumbs, CTAs, and actions menu. See `.cla
|
|
|
1472
1476
|
- ❌ Don't preview a PDF (or any file) in an `<iframe>`, `<embed>` or `<object>` — the portal frame is sandboxed and Chrome won't run its PDF viewer inside it, so the preview renders blank while the build passes. Open the pre-signed URL in a new tab (`assembly-storage` Pattern A) or render pages to a canvas client-side with `next/dynamic` + `ssr: false`.
|
|
1473
1477
|
- ❌ Don't call a change verified, working, or safe to publish because `tsc --noEmit` and `bun run build` passed — name the checks that ran and the signed-in screens left unopened. See "What a passing build proves" above.
|
|
1474
1478
|
- ❌ Don't pass a function — or an object that holds one, like a question/field config with `visibleWhen`, `validate`, `render`, or `format` callbacks — as a prop from a server component or page into a `'use client'` component. Props cross that boundary as serialized data, so the render throws `Functions cannot be passed directly to Client Components` and the page 500s for every visitor no matter what the record holds. `tsc --noEmit` and `bun run build` both pass. Keep such a config in a shared module the client component imports itself, or express the logic as data (`{ showWhen: { field: 'entityType', equals: 'llc' } }`) and interpret it client-side.
|
|
1479
|
+
- ❌ Don't call a function imported from a `'use client'` file from a server component, layout, or server action — e.g. an answer formatter defined in the client component file that the `/n` notification page also renders. The server gets a client reference, not the function, so the render throws and the route 500s for every visitor; `tsc --noEmit` passes. A `'use client'` file should export only components and hooks; put a shared helper in a plain module (`lib/…`) both sides import. `bun run build` fails on this (`check:client-boundary`).
|
|
1475
1480
|
- ❌ Don't use `Select` or `Combobox` to pick any Assembly person or organization record (clients, internal users, team members, staff, companies). Always use `UserCompanySelector` — in forms, filters, dropdowns, "assign to" controls, everywhere.
|
|
1476
1481
|
- ❌ Don't use `Combobox` when the user asks for a "dropdown" or "select" — use `Select`. Only use `Combobox` when the user explicitly needs type-to-search/filter functionality for non-user/non-company data.
|
|
1477
1482
|
- ❌ Don't manually render the selected value in a `Select` trigger. Use `<SelectValue placeholder="..." />` inside `SelectTrigger` **and** pass `items={options}` to the `<Select>` root — without `items`, Base UI cannot resolve labels and will display raw values (UUIDs) in the trigger. See "Select and Combobox — base-ui differences" for the full pattern.
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Fails the build when server code calls a function it imported from a
|
|
3
|
+
// 'use client' module. Next hands server code a client *reference* for every
|
|
4
|
+
// export of such a module: rendering one as JSX is fine, calling one throws
|
|
5
|
+
// ("Attempted to call X() from the server but X is on the client"), and the
|
|
6
|
+
// route answers 500 for every visitor. tsc and `next build` both pass — the
|
|
7
|
+
// module typechecks, and a dynamic page is never prerendered — so the agent's
|
|
8
|
+
// own build is the only gate that sees it before a user does.
|
|
9
|
+
//
|
|
10
|
+
// Server code is found by walking imports from Next's server entry files under
|
|
11
|
+
// app/ (page, layout, route, …) and from 'use server' modules, stopping at the
|
|
12
|
+
// first 'use client' module; everything reached without crossing that line is
|
|
13
|
+
// evaluated on the server. Parsed with the app's own TypeScript, parse-only
|
|
14
|
+
// like scan-routes.mjs (ts.createSourceFile, no Program, no type check): an
|
|
15
|
+
// import is an edge only when it survives to the emitted module, and a finding
|
|
16
|
+
// is a real call, `new`, or tagged template whose callee is an imported
|
|
17
|
+
// binding — never a name inside a string, a comment, or JSX text. A binding the
|
|
18
|
+
// file also declares locally is left alone rather than guessed at. Known
|
|
19
|
+
// limits: a binding re-exported through a barrel, and a helper in a shared
|
|
20
|
+
// module that only client code ever calls, are reported or missed by shape
|
|
21
|
+
// (the fix — a helper in a plain module — is right either way).
|
|
22
|
+
//
|
|
23
|
+
// A finding is a warning on an ordinary turn and fails the build under
|
|
24
|
+
// --strict (the update acceptance) or when ASSEMBLY_TURN_BASELINE_SHA says this
|
|
25
|
+
// turn changed a file involved: the caller, the client file, or any import on
|
|
26
|
+
// the chain that put the caller on the server — a new page that renders an
|
|
27
|
+
// existing shared component makes that component's call run on the server for
|
|
28
|
+
// the first time. A Vercel build or a template clone has no agent who can act
|
|
29
|
+
// on a failure, and the route was already broken, so neither is failed.
|
|
30
|
+
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
|
|
31
|
+
import { spawnSync } from 'node:child_process';
|
|
32
|
+
import { createRequire } from 'node:module';
|
|
33
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
34
|
+
|
|
35
|
+
const ROOT = process.cwd();
|
|
36
|
+
const STRICT = process.argv.includes('--strict');
|
|
37
|
+
const APP_DIR = join(ROOT, 'app');
|
|
38
|
+
const SOURCE_DIRS = ['app', 'components', 'lib'];
|
|
39
|
+
const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
|
|
40
|
+
const RESOLVE_EXTENSIONS = ['.tsx', '.ts', '.jsx', '.js', '.mjs'];
|
|
41
|
+
// Next renders or invokes these on the server unless the file opts into the client.
|
|
42
|
+
const SERVER_ENTRY_FILE = /^(page|layout|template|default|loading|not-found|route)\.(tsx|ts|jsx|js)$/;
|
|
43
|
+
// tsconfig's `@/*` → `./*`, the only alias the scaffold defines.
|
|
44
|
+
const PATH_ALIAS = '@/';
|
|
45
|
+
// The turn program exports the SHA the sandbox started this turn at; a finding
|
|
46
|
+
// is the agent's own when a file it involves changed since then.
|
|
47
|
+
const TURN_BASELINE_ENV = 'ASSEMBLY_TURN_BASELINE_SHA';
|
|
48
|
+
|
|
49
|
+
// Resolved from this file: inside an app it sits in node_modules/ or
|
|
50
|
+
// .assembly/, so this finds the app's own typescript (the one `next build`
|
|
51
|
+
// uses); in this repo it finds the package's devDependency.
|
|
52
|
+
function loadTypeScript() {
|
|
53
|
+
try {
|
|
54
|
+
return createRequire(import.meta.url)('typescript');
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sourceFiles(dir) {
|
|
61
|
+
const out = [];
|
|
62
|
+
if (!existsSync(dir)) return out;
|
|
63
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
64
|
+
const full = join(dir, entry.name);
|
|
65
|
+
if (entry.isDirectory()) {
|
|
66
|
+
if (entry.name === 'node_modules' || entry.name === '.next') continue;
|
|
67
|
+
out.push(...sourceFiles(full));
|
|
68
|
+
} else if (SOURCE_FILE.test(entry.name) && !/\.test\.\w+$/.test(entry.name)) {
|
|
69
|
+
out.push(full);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toPosix = (file) => relative(ROOT, file).split(sep).join('/');
|
|
76
|
+
|
|
77
|
+
function resolveImport(fromFile, specifier) {
|
|
78
|
+
let base;
|
|
79
|
+
if (specifier.startsWith(PATH_ALIAS)) base = join(ROOT, specifier.slice(PATH_ALIAS.length));
|
|
80
|
+
else if (specifier.startsWith('.')) base = resolve(dirname(fromFile), specifier);
|
|
81
|
+
else return null; // a package: not this app's code
|
|
82
|
+
const candidates = [
|
|
83
|
+
base,
|
|
84
|
+
...RESOLVE_EXTENSIONS.map((ext) => base + ext),
|
|
85
|
+
...RESOLVE_EXTENSIONS.map((ext) => join(base, 'index' + ext)),
|
|
86
|
+
];
|
|
87
|
+
for (const candidate of candidates) {
|
|
88
|
+
try {
|
|
89
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
90
|
+
} catch {
|
|
91
|
+
// not this candidate
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 'client' | 'server' | '' — a directive is the file's first statement, which
|
|
98
|
+
// the parser keeps as an expression statement holding the string.
|
|
99
|
+
function directiveOf(ts, sf) {
|
|
100
|
+
const first = sf.statements[0];
|
|
101
|
+
if (!first || !ts.isExpressionStatement(first) || !ts.isStringLiteral(first.expression)) return '';
|
|
102
|
+
const match = /^use (client|server)$/.exec(first.expression.text);
|
|
103
|
+
return match ? match[1] : '';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Static imports as the emitted module keeps them. TypeScript drops
|
|
107
|
+
// `import type`, a clause whose specifiers are all `type X`, and the matching
|
|
108
|
+
// `export type … from` forms, so a module reached only that way never runs.
|
|
109
|
+
function staticImports(ts, sf, file) {
|
|
110
|
+
const imports = [];
|
|
111
|
+
const edges = new Set();
|
|
112
|
+
const edge = (specifier) => {
|
|
113
|
+
const target = resolveImport(file, specifier);
|
|
114
|
+
if (target) edges.add(target);
|
|
115
|
+
return target;
|
|
116
|
+
};
|
|
117
|
+
for (const st of sf.statements) {
|
|
118
|
+
if (ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
119
|
+
const clause = st.importClause;
|
|
120
|
+
if (!clause) {
|
|
121
|
+
edge(st.moduleSpecifier.text); // side-effect import
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (clause.isTypeOnly) continue;
|
|
125
|
+
const bindings = [];
|
|
126
|
+
if (clause.name) bindings.push({ local: clause.name.text, kind: 'default' });
|
|
127
|
+
const named = clause.namedBindings;
|
|
128
|
+
if (named && ts.isNamespaceImport(named)) bindings.push({ local: named.name.text, kind: 'namespace' });
|
|
129
|
+
if (named && ts.isNamedImports(named)) {
|
|
130
|
+
for (const el of named.elements) if (!el.isTypeOnly) bindings.push({ local: el.name.text, kind: 'named' });
|
|
131
|
+
}
|
|
132
|
+
if (bindings.length === 0) continue;
|
|
133
|
+
const target = edge(st.moduleSpecifier.text);
|
|
134
|
+
if (target) imports.push({ target, bindings });
|
|
135
|
+
} else if (ts.isExportDeclaration(st) && st.moduleSpecifier && ts.isStringLiteral(st.moduleSpecifier)) {
|
|
136
|
+
if (st.isTypeOnly) continue;
|
|
137
|
+
const clause = st.exportClause;
|
|
138
|
+
if (clause && ts.isNamedExports(clause) && clause.elements.every((el) => el.isTypeOnly)) continue;
|
|
139
|
+
edge(st.moduleSpecifier.text);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { imports, edges };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Every call-like use of an identifier (`name(…)`, `name.member(…)`, `new
|
|
146
|
+
// name(…)`, name`…`), every name the file declares itself, and the targets of
|
|
147
|
+
// dynamic `import('…')` — one walk over the tree.
|
|
148
|
+
function walkUses(ts, sf, file, edges) {
|
|
149
|
+
const calls = [];
|
|
150
|
+
const declared = new Set();
|
|
151
|
+
const lineOf = (node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
152
|
+
const visit = (node) => {
|
|
153
|
+
if (ts.isCallExpression(node) || ts.isNewExpression(node) || ts.isTaggedTemplateExpression(node)) {
|
|
154
|
+
const callee = ts.isTaggedTemplateExpression(node) ? node.tag : node.expression;
|
|
155
|
+
if (callee.kind === ts.SyntaxKind.ImportKeyword) {
|
|
156
|
+
const arg = node.arguments?.[0];
|
|
157
|
+
if (arg && ts.isStringLiteral(arg)) {
|
|
158
|
+
const target = resolveImport(file, arg.text);
|
|
159
|
+
if (target) edges.add(target);
|
|
160
|
+
}
|
|
161
|
+
} else if (ts.isIdentifier(callee)) {
|
|
162
|
+
calls.push({ name: callee.text, member: '', line: lineOf(callee) });
|
|
163
|
+
} else if (ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression)) {
|
|
164
|
+
calls.push({ name: callee.expression.text, member: callee.name.text, line: lineOf(callee) });
|
|
165
|
+
}
|
|
166
|
+
} else if (
|
|
167
|
+
(ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node) ||
|
|
168
|
+
ts.isFunctionDeclaration(node) || ts.isClassDeclaration(node)) &&
|
|
169
|
+
node.name && ts.isIdentifier(node.name)
|
|
170
|
+
) {
|
|
171
|
+
declared.add(node.name.text);
|
|
172
|
+
}
|
|
173
|
+
ts.forEachChild(node, visit);
|
|
174
|
+
};
|
|
175
|
+
visit(sf);
|
|
176
|
+
return { calls, declared };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readModule(ts, file) {
|
|
180
|
+
const text = readFileSync(file, 'utf8');
|
|
181
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, false, file.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
182
|
+
const { imports, edges } = staticImports(ts, sf, file);
|
|
183
|
+
const { calls, declared } = walkUses(ts, sf, file, edges);
|
|
184
|
+
return { file, directive: directiveOf(ts, sf), imports, edges, calls, declared };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function isServerEntry(file) {
|
|
188
|
+
return file.startsWith(APP_DIR + sep) && SERVER_ENTRY_FILE.test(file.split(sep).pop());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// The repo-relative paths this turn changed: commits since the baseline plus
|
|
192
|
+
// whatever is still uncommitted. No baseline means no turn (a Vercel build, a
|
|
193
|
+
// clone bootstrap), so nothing is the agent's to fix. A git failure with a
|
|
194
|
+
// baseline fails closed by treating every file as changed — the sandbox is the
|
|
195
|
+
// one place a finding can still be acted on.
|
|
196
|
+
const EVERY_FILE = { has: () => true };
|
|
197
|
+
function changedThisTurn() {
|
|
198
|
+
const baseline = (process.env[TURN_BASELINE_ENV] ?? '').trim();
|
|
199
|
+
if (!baseline) return new Set();
|
|
200
|
+
const changed = new Set();
|
|
201
|
+
const listings = [
|
|
202
|
+
{ args: ['diff', '--name-only', `${baseline}..HEAD`], parse: (line) => line },
|
|
203
|
+
// Porcelain rows are "XY path", or "XY old -> new" for a rename.
|
|
204
|
+
{ args: ['status', '--porcelain', '--untracked-files=all'], parse: (line) => line.slice(3).split(' -> ').pop() },
|
|
205
|
+
];
|
|
206
|
+
for (const { args, parse } of listings) {
|
|
207
|
+
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8' });
|
|
208
|
+
if (result.status !== 0) return EVERY_FILE;
|
|
209
|
+
for (const line of result.stdout.split('\n')) {
|
|
210
|
+
const file = parse(line).trim();
|
|
211
|
+
if (file) changed.add(file);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return changed;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function report(findings, { fatal }) {
|
|
218
|
+
console.error('');
|
|
219
|
+
console.error(
|
|
220
|
+
fatal
|
|
221
|
+
? "Server code calls a function exported from a 'use client' file:"
|
|
222
|
+
: "Server code calls a function exported from a 'use client' file (pre-existing; this build is not failed for it):",
|
|
223
|
+
);
|
|
224
|
+
console.error('');
|
|
225
|
+
for (const f of findings) console.error(` - ${f.file}:${f.line} calls ${f.name}() imported from ${f.target}`);
|
|
226
|
+
console.error('');
|
|
227
|
+
console.error("A module that starts with 'use client' reaches server code as client references,");
|
|
228
|
+
console.error('not as functions: rendering one as JSX works, calling one throws, and the page');
|
|
229
|
+
console.error('answers 500 for every visitor. tsc and next build both pass.');
|
|
230
|
+
console.error('');
|
|
231
|
+
console.error('Move the helper into a plain module with no directive (lib/<name>.ts) and import');
|
|
232
|
+
console.error("it from both sides. A 'use client' file should export only components and hooks.");
|
|
233
|
+
if (fatal) process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!existsSync(APP_DIR)) process.exit(0);
|
|
237
|
+
|
|
238
|
+
const ts = loadTypeScript();
|
|
239
|
+
if (!ts) {
|
|
240
|
+
// Every scaffolded app depends on typescript; without it there is no parser
|
|
241
|
+
// and no `next build` either, so there is nothing for this check to guard.
|
|
242
|
+
console.error('check-client-boundary: typescript is not installed; skipped.');
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const modules = new Map();
|
|
247
|
+
for (const dir of SOURCE_DIRS) {
|
|
248
|
+
for (const file of sourceFiles(join(ROOT, dir))) modules.set(file, readModule(ts, file));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Everything reachable from a server entry without crossing into a client
|
|
252
|
+
// module runs on the server. A client module is a boundary, not a node: what
|
|
253
|
+
// it imports is its own business.
|
|
254
|
+
const serverReachable = new Set();
|
|
255
|
+
const queue = [...modules.values()]
|
|
256
|
+
.filter((m) => m.directive === 'server' || (m.directive !== 'client' && isServerEntry(m.file)))
|
|
257
|
+
.map((m) => m.file);
|
|
258
|
+
while (queue.length > 0) {
|
|
259
|
+
const file = queue.pop();
|
|
260
|
+
if (serverReachable.has(file)) continue;
|
|
261
|
+
serverReachable.add(file);
|
|
262
|
+
for (const target of modules.get(file).edges) {
|
|
263
|
+
const next = modules.get(target);
|
|
264
|
+
if (next && next.directive !== 'client' && !serverReachable.has(target)) queue.push(target);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const findings = [];
|
|
269
|
+
for (const file of serverReachable) {
|
|
270
|
+
const mod = modules.get(file);
|
|
271
|
+
for (const { target, bindings } of mod.imports) {
|
|
272
|
+
if (modules.get(target)?.directive !== 'client') continue;
|
|
273
|
+
for (const binding of bindings) {
|
|
274
|
+
if (mod.declared.has(binding.local)) continue; // shadowed somewhere in the file
|
|
275
|
+
const call = mod.calls.find((c) =>
|
|
276
|
+
c.name === binding.local && (binding.kind === 'namespace' ? c.member !== '' : c.member === ''),
|
|
277
|
+
);
|
|
278
|
+
if (!call) continue;
|
|
279
|
+
const name = call.member ? `${call.name}.${call.member}` : call.name;
|
|
280
|
+
findings.push({ file: toPosix(file), line: call.line, target: toPosix(target), name });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (findings.length === 0) process.exit(0);
|
|
286
|
+
|
|
287
|
+
// A finding is the turn's own when the turn wrote the call, the client file, or
|
|
288
|
+
// any link in the import chain that puts the caller on the server. Walk forward
|
|
289
|
+
// from every server-side file the turn changed; what that reaches is the turn's.
|
|
290
|
+
const changed = STRICT ? EVERY_FILE : changedThisTurn();
|
|
291
|
+
const ownedByTurn = new Set();
|
|
292
|
+
const seeds = [...serverReachable].filter((file) => changed.has(toPosix(file)));
|
|
293
|
+
while (seeds.length > 0) {
|
|
294
|
+
const file = seeds.pop();
|
|
295
|
+
if (ownedByTurn.has(toPosix(file))) continue;
|
|
296
|
+
ownedByTurn.add(toPosix(file));
|
|
297
|
+
for (const target of modules.get(file).edges) {
|
|
298
|
+
if (serverReachable.has(target) && !ownedByTurn.has(toPosix(target))) seeds.push(target);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const own = findings.filter((f) => ownedByTurn.has(f.file) || changed.has(f.target));
|
|
302
|
+
const preexisting = findings.filter((f) => !own.includes(f));
|
|
303
|
+
if (preexisting.length > 0) report(preexisting, { fatal: false });
|
|
304
|
+
if (own.length > 0) report(own, { fatal: true });
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: assembly-notifications
|
|
3
3
|
# <!-- !feature:notification-settings -->
|
|
4
|
-
description: Use when the app must notify internal users or clients about something that happened — in-product, email, or both. Covers createNotification, the Notification Center /n surface, and clearing the client badge (see clearing-client-notifications).
|
|
4
|
+
description: Use when the app must notify internal users or clients about something that happened — in-product, email, or both — including unprompted, whenever it has submissions, status changes, approvals, or required alerts. Covers createNotification, the Notification Center /n surface, and clearing the client badge (see clearing-client-notifications).
|
|
5
5
|
# <!-- /feature:notification-settings -->
|
|
6
6
|
# <!-- feature:notification-settings -->
|
|
7
|
-
description: Use when the app must notify internal users or clients, or needs per-user notification preferences (Product/Email toggles). Covers createNotification, the /n surface, notificationSettings + notificationSettingId, and clearing the client badge (see clearing-client-notifications).
|
|
7
|
+
description: Use when the app must notify internal users or clients (including unprompted, whenever it has submissions, status changes, approvals, or required alerts), or needs per-user notification preferences (Product/Email toggles). Covers createNotification, the /n surface, notificationSettings + notificationSettingId, and clearing the client badge (see clearing-client-notifications).
|
|
8
8
|
# <!-- /feature:notification-settings -->
|
|
9
9
|
---
|
|
10
10
|
|