@anby/platform-sdk 0.7.2 → 0.8.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/src/vite/index.ts CHANGED
@@ -1,33 +1,26 @@
1
1
  /**
2
- * @anby/platform-sdk/vite — Vite plugin for auto-codegen of Anby app types.
2
+ * @anby/platform-sdk/vite — Vite plugin for Anby app integration.
3
3
  *
4
- * PLAN-app-bootstrap-phase2 PR5.
4
+ * Two responsibilities:
5
5
  *
6
- * Drop into a consumer app's vite.config.ts:
6
+ * 1. **Auto-scan** source code to discover entity/event declarations and
7
+ * keep `anby-app.manifest.json` in sync — developer never edits
8
+ * provides/requires by hand.
7
9
  *
8
- * ```ts
9
- * import { anbyVitePlugin } from '@anby/platform-sdk/vite';
10
+ * 2. **Codegen** typed module augmentations (`.anby/types.d.ts`) so
11
+ * `publishEvent` gets compile-time type checking.
10
12
  *
11
- * export default defineConfig({
12
- * plugins: [
13
- * anbyVitePlugin(),
14
- * remix({...}),
15
- * ],
16
- * });
17
- * ```
18
- *
19
- * On every dev start (and on every change to `anby-app.manifest.json`),
20
- * the plugin reads the manifest's `provides.events` and `requires.events`
21
- * arrays and writes typed module augmentations to `.anby/types.d.ts`. The
22
- * SDK's `publishEvent` overload picks up the merged `AppProvidedEvents`
23
- * interface and gives the dev compile-time errors on unknown event names.
24
- *
25
- * The plugin imports `vite` only as a TYPE — `import type { Plugin }` is
26
- * erased at compile time, so the SDK package does NOT need to ship vite
27
- * as a runtime dependency. Apps that use this plugin already have vite.
13
+ * Runs on every dev start and on source/manifest file changes.
28
14
  */
29
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
30
- import { dirname, resolve, isAbsolute } from 'node:path';
15
+ import {
16
+ readFileSync,
17
+ writeFileSync,
18
+ mkdirSync,
19
+ existsSync,
20
+ readdirSync,
21
+ statSync,
22
+ } from 'node:fs';
23
+ import { dirname, resolve, isAbsolute, join, basename, relative } from 'node:path';
31
24
  import type { Plugin } from 'vite';
32
25
  import { getInlinedManifest } from '../apps/publish.js';
33
26
 
@@ -36,24 +29,207 @@ export interface AnbyVitePluginOptions {
36
29
  manifestPath?: string;
37
30
  /** Output path for generated types relative to vite root. Default: `./.anby/types.d.ts`. */
38
31
  outFile?: string;
39
- /**
40
- * Output path for the wire-format manifest static asset, relative to
41
- * vite root. Default: `./public/_anby/manifest.json`.
42
- *
43
- * The Marketplace Submit-app form fetches this file from
44
- * `${publicUrl}/_anby/manifest.json` to publish a running app without
45
- * the operator pasting any JSON. Vite (dev) and Remix (prod) both
46
- * serve files in `public/` as static assets, so the same artifact
47
- * works in every runtime — no middleware, no Remix route file.
48
- */
32
+ /** Output path for the wire-format manifest static asset. Default: `./public/_anby/manifest.json`. */
49
33
  manifestArtifactPath?: string;
34
+ /** Source directories to scan for entity/event usage. Default: `['./app']`. */
35
+ scanDirs?: string[];
36
+ /** Disable auto-scan (only codegen from manifest). Default: false. */
37
+ disableAutoScan?: boolean;
50
38
  }
51
39
 
52
40
  interface ManifestEventsShape {
53
- provides?: { events?: string[] };
54
- requires?: { events?: string[] };
41
+ provides?: { events?: string[]; entities?: Array<string | { name: string; version: string; schema?: unknown }> };
42
+ requires?: { events?: string[]; entities?: string[] };
43
+ }
44
+
45
+ // ── Auto-scan: discover entities/events from source code ──────────────
46
+
47
+ interface ScanResult {
48
+ providesEntities: Array<{ name: string; version: string; schema: string }>;
49
+ providesEvents: string[];
50
+ requiresEntities: string[];
51
+ }
52
+
53
+ /**
54
+ * Scan source files for SDK API usage patterns:
55
+ * - getEntityClient('org.period@v1') → requires entity
56
+ * - publishEvent({ type: 'org.node.created' ... }) → provides event
57
+ * - app/schemas/*.v*.json files → provides entities
58
+ */
59
+ function scanSourceCode(root: string, scanDirs: string[]): ScanResult {
60
+ const providesEvents = new Set<string>();
61
+ const requiresEntities = new Set<string>();
62
+ const providesEntities: Array<{ name: string; version: string; schema: string }> = [];
63
+
64
+ // 1. Scan for entity schema files: app/schemas/{name}.{version}.json
65
+ const schemasDir = resolve(root, 'app/schemas');
66
+ if (existsSync(schemasDir)) {
67
+ try {
68
+ const files = readdirSync(schemasDir);
69
+ for (const file of files) {
70
+ // Match: org-period.v1.json → name=org.period, version=v1
71
+ const match = file.match(/^(.+)\.(v\d+)\.json$/);
72
+ if (match) {
73
+ const rawName = match[1]; // "org-period"
74
+ const version = match[2]; // "v1"
75
+ // Convert filename to entity name: "org-period" → "org.period"
76
+ const entityName = rawName.replace(/-/g, '.');
77
+ const schemaPath = `./app/schemas/${file}`;
78
+ providesEntities.push({ name: entityName, version, schema: schemaPath });
79
+ }
80
+ }
81
+ } catch {
82
+ // schemas dir unreadable — skip
83
+ }
84
+ }
85
+
86
+ // 2. Scan source files for API usage patterns
87
+ for (const dir of scanDirs) {
88
+ const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
89
+ if (!existsSync(absDir)) continue;
90
+ walkFiles(absDir, (filePath) => {
91
+ if (!filePath.match(/\.(ts|tsx|js|jsx)$/)) return;
92
+ // Skip node_modules and generated files
93
+ if (filePath.includes('node_modules') || filePath.includes('.anby/')) return;
94
+
95
+ let content: string;
96
+ try {
97
+ content = readFileSync(filePath, 'utf-8');
98
+ } catch {
99
+ return;
100
+ }
101
+
102
+ // Pattern 1: getEntityClient('org.period@v1')
103
+ const entityClientRegex1 = /getEntityClient\s*\(\s*['"]([^'"]+@[^'"]+)['"]/g;
104
+ for (const match of content.matchAll(entityClientRegex1)) {
105
+ requiresEntities.add(match[1]); // e.g. "org.period@v1"
106
+ }
107
+
108
+ // Pattern 2: getEntityClient(tenantId, 'org.period', 'v1') or getEntityClient<T>(tenantId, 'org.period', 'v1')
109
+ const entityClientRegex2 = /getEntityClient\s*(?:<[^>]+>)?\s*\([^,]+,\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"]/g;
110
+ for (const match of content.matchAll(entityClientRegex2)) {
111
+ requiresEntities.add(`${match[1]}@${match[2]}`); // e.g. "org.period@v1"
112
+ }
113
+
114
+ // publishEvent({ type: 'org.node.created' ... })
115
+ const publishEventRegex = /publishEvent\s*\(\s*\{[^}]*?type\s*:\s*['"]([^'"]+)['"]/g;
116
+ for (const match of content.matchAll(publishEventRegex)) {
117
+ providesEvents.add(match[1]);
118
+ }
119
+
120
+ // createEvent({ type: 'org.node.created' ... })
121
+ const createEventRegex = /createEvent\s*\(\s*\{[^}]*?type\s*:\s*['"]([^'"]+)['"]/g;
122
+ for (const match of content.matchAll(createEventRegex)) {
123
+ providesEvents.add(match[1]);
124
+ }
125
+
126
+ // Type union declarations near publishEvent context:
127
+ // type: 'org.node.created' | 'org.node.updated' | 'org.node.deleted'
128
+ // Matches dotted string literals in union type annotations
129
+ const typeUnionRegex = /type\s*:\s*((?:['"][a-z][a-z0-9.]*[a-z0-9]['"](?:\s*\|\s*)?)+)/g;
130
+ for (const unionMatch of content.matchAll(typeUnionRegex)) {
131
+ const unionStr = unionMatch[1];
132
+ const literalRegex = /['"]([a-z][a-z0-9]*(?:\.[a-z][a-z0-9]*)+)['"]/g;
133
+ for (const litMatch of unionStr.matchAll(literalRegex)) {
134
+ providesEvents.add(litMatch[1]);
135
+ }
136
+ }
137
+ });
138
+ }
139
+
140
+ return {
141
+ providesEntities,
142
+ providesEvents: [...providesEvents].sort(),
143
+ requiresEntities: [...requiresEntities].sort(),
144
+ };
55
145
  }
56
146
 
147
+ function walkFiles(dir: string, callback: (path: string) => void): void {
148
+ let entries: string[];
149
+ try {
150
+ entries = readdirSync(dir);
151
+ } catch {
152
+ return;
153
+ }
154
+ for (const entry of entries) {
155
+ const full = join(dir, entry);
156
+ try {
157
+ const stat = statSync(full);
158
+ if (stat.isDirectory()) {
159
+ if (entry === 'node_modules' || entry === '.anby' || entry === 'build') continue;
160
+ walkFiles(full, callback);
161
+ } else if (stat.isFile()) {
162
+ callback(full);
163
+ }
164
+ } catch {
165
+ // skip unreadable
166
+ }
167
+ }
168
+ }
169
+
170
+ // ── Manifest sync: merge scan results into manifest ───────────────────
171
+
172
+ function syncManifest(manifestPath: string, scan: ScanResult): boolean {
173
+ if (!existsSync(manifestPath)) return false;
174
+
175
+ let raw: string;
176
+ try {
177
+ raw = readFileSync(manifestPath, 'utf-8');
178
+ } catch {
179
+ return false;
180
+ }
181
+
182
+ const manifest = JSON.parse(raw);
183
+ let changed = false;
184
+
185
+ // Sync provides.entities
186
+ if (scan.providesEntities.length > 0) {
187
+ const current = JSON.stringify(manifest.provides?.entities ?? []);
188
+ const scanned = JSON.stringify(scan.providesEntities);
189
+ if (current !== scanned) {
190
+ manifest.provides = manifest.provides || {};
191
+ manifest.provides.entities = scan.providesEntities;
192
+ changed = true;
193
+ }
194
+ }
195
+
196
+ // Sync provides.events
197
+ if (scan.providesEvents.length > 0) {
198
+ const current = JSON.stringify((manifest.provides?.events ?? []).slice().sort());
199
+ const scanned = JSON.stringify(scan.providesEvents);
200
+ if (current !== scanned) {
201
+ manifest.provides = manifest.provides || {};
202
+ manifest.provides.events = scan.providesEvents;
203
+ changed = true;
204
+ }
205
+ }
206
+
207
+ // Sync requires.entities
208
+ if (scan.requiresEntities.length > 0) {
209
+ const current = JSON.stringify((manifest.requires?.entities ?? []).slice().sort());
210
+ const scanned = JSON.stringify(scan.requiresEntities);
211
+ if (current !== scanned) {
212
+ manifest.requires = manifest.requires || {};
213
+ manifest.requires.entities = scan.requiresEntities;
214
+ changed = true;
215
+ }
216
+ }
217
+
218
+ if (changed) {
219
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
220
+ console.log('[anby-vite] ✔ Auto-updated manifest from source code scan');
221
+ const parts: string[] = [];
222
+ if (scan.providesEntities.length) parts.push(`provides ${scan.providesEntities.length} entities`);
223
+ if (scan.providesEvents.length) parts.push(`provides ${scan.providesEvents.length} events`);
224
+ if (scan.requiresEntities.length) parts.push(`requires ${scan.requiresEntities.length} entities`);
225
+ if (parts.length) console.log(`[anby-vite] ${parts.join(', ')}`);
226
+ }
227
+
228
+ return changed;
229
+ }
230
+
231
+ // ── Codegen: typed module augmentations ───────────────────────────────
232
+
57
233
  function renderTypes(provides: string[], requires: string[]): string {
58
234
  const sections: string[] = [];
59
235
 
@@ -84,7 +260,6 @@ function renderTypes(provides: string[], requires: string[]): string {
84
260
 
85
261
  sections.push('}');
86
262
  sections.push('');
87
- // Pure declaration file marker
88
263
  sections.push('export {};');
89
264
  sections.push('');
90
265
 
@@ -102,16 +277,32 @@ function readManifestEvents(manifestAbsPath: string): {
102
277
  return { provides, requires };
103
278
  }
104
279
 
280
+ // ── Plugin ────────────────────────────────────────────────────────────
281
+
105
282
  export function anbyVitePlugin(opts: AnbyVitePluginOptions = {}): Plugin {
106
283
  const manifestRelative = opts.manifestPath ?? './anby-app.manifest.json';
107
284
  const outRelative = opts.outFile ?? './.anby/types.d.ts';
108
285
  const artifactRelative =
109
286
  opts.manifestArtifactPath ?? './public/_anby/manifest.json';
287
+ const scanDirsRelative = opts.scanDirs ?? ['./app'];
288
+ const autoScan = opts.disableAutoScan !== true;
110
289
 
290
+ let root = '';
111
291
  let resolvedManifest = '';
112
292
  let resolvedOut = '';
113
293
  let resolvedArtifact = '';
114
294
 
295
+ function runAutoScan() {
296
+ if (!autoScan) return;
297
+ if (!existsSync(resolvedManifest)) return;
298
+ try {
299
+ const scan = scanSourceCode(root, scanDirsRelative);
300
+ syncManifest(resolvedManifest, scan);
301
+ } catch (err) {
302
+ console.warn(`[anby-vite] auto-scan failed: ${(err as Error).message}`);
303
+ }
304
+ }
305
+
115
306
  function generateTypes() {
116
307
  if (!existsSync(resolvedManifest)) {
117
308
  console.warn(
@@ -131,13 +322,6 @@ export function anbyVitePlugin(opts: AnbyVitePluginOptions = {}): Plugin {
131
322
  }
132
323
  }
133
324
 
134
- /**
135
- * Generate `public/_anby/manifest.json` — the wire-format manifest
136
- * with all `provides.entities[].schema` paths resolved to inline JSON
137
- * content. Vite/Remix serve `public/` as static assets in both dev
138
- * and production, so this single artifact powers the Marketplace
139
- * Submit-app form everywhere.
140
- */
141
325
  async function generateManifestArtifact() {
142
326
  if (!existsSync(resolvedManifest)) return;
143
327
  try {
@@ -154,17 +338,16 @@ export function anbyVitePlugin(opts: AnbyVitePluginOptions = {}): Plugin {
154
338
  }
155
339
 
156
340
  function regenerateAll() {
157
- generateTypes();
158
- void generateManifestArtifact();
341
+ runAutoScan(); // 1. Scan code → update manifest
342
+ generateTypes(); // 2. Read manifest → generate types
343
+ void generateManifestArtifact(); // 3. Read manifest → generate wire artifact
159
344
  }
160
345
 
161
346
  return {
162
347
  name: '@anby/platform-sdk/vite',
163
348
 
164
349
  configResolved(config) {
165
- // Resolve relative paths against the Vite project root, not cwd.
166
- // This is robust against pnpm workspaces / symlinks / monorepos.
167
- const root = config.root;
350
+ root = config.root;
168
351
  resolvedManifest = isAbsolute(manifestRelative)
169
352
  ? manifestRelative
170
353
  : resolve(root, manifestRelative);
@@ -181,12 +364,21 @@ export function anbyVitePlugin(opts: AnbyVitePluginOptions = {}): Plugin {
181
364
  },
182
365
 
183
366
  configureServer(server) {
184
- // Watch the resolved absolute path. Compare in the change handler
185
- // against the resolved path so we don't false-trigger on other
186
- // manifest.json files in the workspace (e.g., node_modules).
367
+ // Watch manifest + source dirs
187
368
  server.watcher.add(resolvedManifest);
369
+ for (const dir of scanDirsRelative) {
370
+ const absDir = isAbsolute(dir) ? dir : resolve(root, dir);
371
+ if (existsSync(absDir)) server.watcher.add(absDir);
372
+ }
373
+
188
374
  server.watcher.on('change', (changedPath) => {
189
- if (resolve(changedPath) === resolvedManifest) {
375
+ const abs = resolve(changedPath);
376
+ if (abs === resolvedManifest) {
377
+ // Manifest changed directly — regenerate types + artifact (no re-scan)
378
+ generateTypes();
379
+ void generateManifestArtifact();
380
+ } else if (changedPath.match(/\.(ts|tsx|js|jsx)$/)) {
381
+ // Source file changed — re-scan → update manifest → regenerate
190
382
  regenerateAll();
191
383
  }
192
384
  });