@llui/vite-plugin 0.0.31 → 0.0.34

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/dist/index.d.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  import type { Plugin } from 'vite';
2
- import { type DiagnosticRule } from './diagnostics.js';
3
- export type { DiagnosticRule } from './diagnostics.js';
4
2
  export interface LluiPluginOptions {
5
3
  /**
6
4
  * Port for the MCP debug bridge. In dev mode, the runtime relay connects
@@ -17,28 +15,6 @@ export interface LluiPluginOptions {
17
15
  * silently skips the connection — no retry noise.
18
16
  */
19
17
  mcpPort?: number | false;
20
- /**
21
- * Treat every compiler diagnostic as a build error.
22
- *
23
- * Default `false` — diagnostics are emitted via rollup's `this.warn` and
24
- * can be ignored. Set to `true` in CI so lint-style regressions (namespace
25
- * imports, bitmask overflow, spread-in-children, `.map()` on state, etc.)
26
- * fail the build without requiring a custom `build.rollupOptions.onwarn`
27
- * handler.
28
- */
29
- failOnWarning?: boolean;
30
- /**
31
- * Silence specific diagnostic rules without disabling the whole lint
32
- * pass. Each message is tagged with a rule name (shown in brackets at
33
- * the start of every warning, e.g. `[spread-in-children]`). Listing
34
- * a rule here drops all diagnostics with that tag before rollup sees
35
- * them — so they don't fire via `this.warn` and don't fail the build
36
- * even when `failOnWarning` is enabled.
37
- *
38
- * The valid rule names are enumerated by the `DiagnosticRule` type
39
- * re-exported from this module. Unknown rule names are ignored.
40
- */
41
- disabledWarnings?: readonly DiagnosticRule[];
42
18
  /**
43
19
  * Emit `[llui]`-prefixed `console.info` logs for every transformed
44
20
  * component file — state-path bit assignments, mask injections, and
@@ -66,13 +42,13 @@ export interface LluiPluginOptions {
66
42
  */
67
43
  agent?: boolean | AgentPluginConfig;
68
44
  }
69
- export type AgentPluginConfig = {
70
- /**
71
- * HMAC signing key for tokens. ≥32 bytes. Rotation invalidates all
72
- * tokens. Falls back to `process.env.AGENT_SIGNING_KEY`, then to a
73
- * per-session random key (dev-only).
74
- */
75
- signingKey?: string;
76
- };
45
+ /**
46
+ * Reserved for future agent-server config. Empty today — opaque tokens
47
+ * (post-0.0.35) need no signing key, and the dev server hard-codes the
48
+ * identity resolver to `'dev-user'`. The shape is kept so callers can
49
+ * pass `agent: { ... }` and we can grow options without churning the
50
+ * public type.
51
+ */
52
+ export type AgentPluginConfig = Record<string, never>;
77
53
  export default function llui(options?: LluiPluginOptions): Plugin;
78
54
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAA;AAOjD,OAAO,EAAY,KAAK,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAEhE,YAAY,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAA;AAqBtD,MAAM,WAAW,iBAAiB;IAChC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAExB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,OAAO,CAAA;IAEvB;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;IAE5C;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;;;;;;;;;;;;;OAgBG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,iBAAiB,CAAA;CACpC;AAED,MAAM,MAAM,iBAAiB,GAAG;IAC9B;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAmLD,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CA6UpE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAA;AAsOjD,MAAM,WAAW,iBAAiB;IAChC;;;;;;;;;;;;;OAaG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,KAAK,CAAA;IAExB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IAEjB;;;;;;;;;;;;;;;;OAgBG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,iBAAiB,CAAA;CACpC;AAED;;;;;;GAMG;AACH,MAAM,MAAM,iBAAiB,GAAG,MAAM,CAAC,MAAM,EAAE,KAAK,CAAC,CAAA;AAuMrD,MAAM,CAAC,OAAO,UAAU,IAAI,CAAC,OAAO,GAAE,iBAAsB,GAAG,MAAM,CAqWpE"}
package/dist/index.js CHANGED
@@ -1,10 +1,182 @@
1
1
  import MagicString from 'magic-string';
2
2
  import { existsSync, readFileSync, writeFileSync, watch as fsWatch } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
3
4
  import { dirname, relative, resolve } from 'node:path';
4
5
  import { createRequire } from 'node:module';
5
6
  import { spawn } from 'node:child_process';
6
- import { transformLlui, transformUseClientSsr, hasUseClientDirective } from './transform.js';
7
- import { diagnose } from './diagnostics.js';
7
+ import { transformLlui, transformUseClientSsr, hasUseClientDirective, } from './transform.js';
8
+ import { findTypeSource, readComponentTypeArgNames, extractMsgAnnotationsCrossFile, extractDiscriminatedUnionSchemaCrossFile, } from './cross-file-resolver.js';
9
+ import ts from 'typescript';
10
+ /**
11
+ * Pre-resolution step run before `transformLlui`. Scans the source for
12
+ * `component<State, Msg, Effect>(...)` calls; for each type argument that
13
+ * is an identifier (the common case), walks imports and re-exports to
14
+ * find the source file declaring that alias. The result is plumbed into
15
+ * `transformLlui` so the schema/annotation extractors operate on the
16
+ * declaring file's source instead of silently returning `null` when the
17
+ * type lives in a separate file.
18
+ *
19
+ * Returns `undefined` (no external sources) when:
20
+ * - No `component<...>()` call is in the file
21
+ * - No type arguments are identifiers we can chase
22
+ * - All type arguments are declared locally (the resolver returns the
23
+ * same source we already have, so external sources are redundant)
24
+ *
25
+ * `resolveModule` comes from Rollup's `this.resolve()`; we wrap it to
26
+ * return the absolute id (or null when unresolved) and read the source
27
+ * via `fs/promises.readFile`.
28
+ */
29
+ async function preResolveTypeSources(source, filePath, rollupResolve) {
30
+ // Cheap filter: nothing to resolve unless the file contains a
31
+ // component<...>() call. Avoids parsing every TS file in the project.
32
+ if (!/\bcomponent\s*</.test(source))
33
+ return undefined;
34
+ // Find the first component<...>() call and read its type arg names.
35
+ // Multiple component() calls in one file would each technically need
36
+ // their own type-arg lookup; we resolve based on the first call and
37
+ // accept the (rare) edge case where two component() calls in one file
38
+ // use different non-local Msg types. The lint rule catches divergence.
39
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
40
+ const args = findFirstComponentTypeArgs(sf);
41
+ if (!args)
42
+ return undefined;
43
+ const ctx = {
44
+ resolveModule: async (spec, importer) => {
45
+ const result = await rollupResolve(spec, importer);
46
+ if (!result || result.external)
47
+ return null;
48
+ // Rollup ids can include query/hash suffixes for virtual modules;
49
+ // strip those so fs.readFile sees a real path. Also skip files
50
+ // outside our control (node_modules) — we don't want to follow
51
+ // imports into third-party packages just to scrape types.
52
+ const idStripped = result.id.split('?')[0]?.split('#')[0];
53
+ if (!idStripped)
54
+ return null;
55
+ if (idStripped.includes('/node_modules/'))
56
+ return null;
57
+ return idStripped;
58
+ },
59
+ readSource: async (p) => {
60
+ return await readFile(p, 'utf8');
61
+ },
62
+ };
63
+ // Helper to resolve one type-arg name into an external source if it
64
+ // isn't declared locally (or if the resolver chases through imports).
65
+ const resolve = async (typeName) => {
66
+ if (!typeName)
67
+ return undefined;
68
+ const found = await findTypeSource(typeName, source, filePath, ctx);
69
+ if (!found)
70
+ return undefined;
71
+ // If the alias was declared locally, the existing extractor path
72
+ // already handles it — no need to populate external sources.
73
+ if (found.filePath === filePath)
74
+ return undefined;
75
+ return { source: found.source, typeName: found.localName };
76
+ };
77
+ const [state, msg, effect] = await Promise.all([
78
+ resolve(args.state),
79
+ resolve(args.msg),
80
+ resolve(args.effect),
81
+ ]);
82
+ if (!state && !msg && !effect)
83
+ return undefined;
84
+ return { state, msg, effect };
85
+ }
86
+ /**
87
+ * Cross-file + composition-aware schema extraction. The extractors
88
+ * follow imports/re-exports AND walk into TypeReferences inside Msg /
89
+ * Effect unions, so a developer who organises types as
90
+ * `type Msg = ImportedFoo | { type: 'extra' }` gets every variant in
91
+ * `__msgAnnotations` and `__msgSchema`. Without this step the
92
+ * file-local sync extractors would silently emit half-annotations
93
+ * (only the inline TypeLiteral members) — the worst kind of failure
94
+ * mode because the build appears to succeed.
95
+ *
96
+ * Returns `undefined` (no pre-extraction) when there's no
97
+ * `component()` call to resolve types for.
98
+ */
99
+ async function preExtractCompositional(source, filePath, rollupResolve) {
100
+ if (!/\bcomponent\s*</.test(source))
101
+ return undefined;
102
+ const sf = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
103
+ const args = findFirstComponentTypeArgs(sf);
104
+ if (!args)
105
+ return undefined;
106
+ // No identifier type args at all → nothing for the resolver to chase.
107
+ if (!args.msg && !args.effect && !args.state)
108
+ return undefined;
109
+ const ctx = {
110
+ resolveModule: async (spec, importer) => {
111
+ const result = await rollupResolve(spec, importer);
112
+ if (!result || result.external)
113
+ return null;
114
+ const idStripped = result.id.split('?')[0]?.split('#')[0];
115
+ if (!idStripped)
116
+ return null;
117
+ if (idStripped.includes('/node_modules/'))
118
+ return null;
119
+ return idStripped;
120
+ },
121
+ readSource: async (p) => readFile(p, 'utf8'),
122
+ };
123
+ const [msgAnnotations, msgSchema, effectSchema] = await Promise.all([
124
+ args.msg
125
+ ? extractMsgAnnotationsCrossFile(source, args.msg, filePath, ctx)
126
+ : Promise.resolve(null),
127
+ args.msg
128
+ ? extractDiscriminatedUnionSchemaCrossFile(source, args.msg, filePath, ctx)
129
+ : Promise.resolve(null),
130
+ args.effect
131
+ ? extractDiscriminatedUnionSchemaCrossFile(source, args.effect, filePath, ctx)
132
+ : Promise.resolve(null),
133
+ ]);
134
+ // Only return a populated payload when we actually extracted
135
+ // something useful. Returning `undefined` lets transformLlui fall
136
+ // back to its file-local extractors, which is the right behavior
137
+ // for the (rare) case where every type the resolver sees is
138
+ // unreachable.
139
+ if (msgAnnotations === null && msgSchema === null && effectSchema === null)
140
+ return undefined;
141
+ // Note: state schema isn't a discriminated union, so composition
142
+ // doesn't apply. We leave state on the simpler `typeSources` path
143
+ // (already plumbed through preResolveTypeSources) which the
144
+ // file-local `extractStateSchema` consumes.
145
+ const out = {};
146
+ if (msgAnnotations !== null)
147
+ out.msgAnnotations = msgAnnotations;
148
+ if (msgSchema !== null)
149
+ out.msgSchema = msgSchema;
150
+ if (effectSchema !== null)
151
+ out.effectSchema = effectSchema;
152
+ return out;
153
+ }
154
+ function findFirstComponentTypeArgs(sf) {
155
+ let result = null;
156
+ const visit = (node) => {
157
+ if (result)
158
+ return true;
159
+ if (ts.isCallExpression(node) &&
160
+ ts.isIdentifier(node.expression) &&
161
+ node.expression.text === 'component' &&
162
+ node.typeArguments) {
163
+ result = readComponentTypeArgNames(node);
164
+ return true;
165
+ }
166
+ let stopped = false;
167
+ ts.forEachChild(node, (child) => {
168
+ if (stopped)
169
+ return;
170
+ if (visit(child))
171
+ stopped = true;
172
+ });
173
+ return stopped;
174
+ };
175
+ ts.forEachChild(sf, (child) => {
176
+ visit(child);
177
+ });
178
+ return result;
179
+ }
8
180
  /**
9
181
  * Locate the workspace root so we share the MCP active marker file
10
182
  * with @llui/mcp regardless of which subdirectory the dev server runs in.
@@ -67,7 +239,7 @@ function resolveMcpCliPath(root) {
67
239
  * construct an agent server instance. Returns null if @llui/agent isn't
68
240
  * installed — the plugin degrades to "prod schema emission only" mode.
69
241
  */
70
- async function loadAgentServer(appRoot, cfg) {
242
+ async function loadAgentServer(appRoot, _cfg) {
71
243
  let serverModule;
72
244
  try {
73
245
  // Walk up from the app root to find node_modules/@llui/agent. Works
@@ -90,10 +262,11 @@ async function loadAgentServer(appRoot, cfg) {
90
262
  (e instanceof Error ? e.message : String(e)));
91
263
  return null;
92
264
  }
93
- const { randomBytes } = await import('node:crypto');
94
- const signingKey = cfg.signingKey ?? process.env['AGENT_SIGNING_KEY'] ?? randomBytes(32).toString('base64url');
265
+ // The pre-0.0.35 agent server required an HMAC signingKey for JWT
266
+ // tokens. The opaque-token rewrite removed that option; the dev
267
+ // server here just calls the factory with no auth config — the
268
+ // in-memory token store is the source of truth.
95
269
  return serverModule.createLluiAgentServer({
96
- signingKey,
97
270
  identityResolver: async () => 'dev-user',
98
271
  });
99
272
  }
@@ -106,12 +279,31 @@ function registerAgentMiddleware(server, agent) {
106
279
  // Connect-style middleware. Vite's middleware chain runs in order, so
107
280
  // synchronous registration during configureServer places us ahead of
108
281
  // Vite's catch-all fallback.
282
+ //
283
+ // Dual-path: handle the canonical `/agent/*` (every project) AND
284
+ // `/cdn-cgi/agent/*` (defensive — Cloudflare's `@cloudflare/vite-plugin`
285
+ // routes everything except `/cdn-cgi/*` to the worker, which means
286
+ // canonical `/agent/*` paths are shadowed in cloudflare-vite projects).
287
+ // The cdn-cgi prefix is stripped before forwarding so the agent
288
+ // server's router sees its own canonical paths regardless of which
289
+ // public URL the client used. This matches the dual-path strategy
290
+ // used for `/__llui_mcp_status`.
109
291
  server.middlewares.use((req, res, next) => {
110
292
  const url = req.url ?? '/';
111
- if (!url.startsWith('/agent/') && url !== '/agent') {
293
+ let stripped = null;
294
+ if (url.startsWith('/agent/') || url === '/agent')
295
+ stripped = url;
296
+ else if (url.startsWith('/cdn-cgi/agent/') || url === '/cdn-cgi/agent') {
297
+ stripped = url.slice('/cdn-cgi'.length);
298
+ }
299
+ if (stripped === null) {
112
300
  next();
113
301
  return;
114
302
  }
303
+ // Rewrite the request URL in-place so handleAgentRequest's path
304
+ // matching sees `/agent/*`. Connect middleware can mutate req.url
305
+ // for downstream handlers; we own the request from here.
306
+ req.url = stripped;
115
307
  void handleAgentRequest(req, res, agent.router).catch((e) => {
116
308
  console.error('[llui] agent middleware error:', e);
117
309
  next(e);
@@ -119,14 +311,18 @@ function registerAgentMiddleware(server, agent) {
119
311
  });
120
312
  // WS upgrade: only /agent/ws goes to the agent. Vite's own HMR upgrade
121
313
  // uses a different path and runs as a separate listener on the same
122
- // event, so this filter keeps both coexisting.
314
+ // event, so this filter keeps both coexisting. Same dual-path
315
+ // accommodation as the HTTP middleware — the WS-upgrade path doesn't
316
+ // actually matter to most cloudflare setups (the worker handles WS
317
+ // upgrades natively), but keeping the parity simplifies the mental
318
+ // model for ops.
123
319
  server.httpServer?.on('upgrade', (req, socket, head) => {
124
320
  const url = new URL(req.url ?? '/', 'http://localhost');
125
- if (url.pathname === '/agent/ws') {
321
+ if (url.pathname === '/agent/ws' || url.pathname === '/cdn-cgi/agent/ws') {
126
322
  agent.wsUpgrade(req, socket, head);
127
323
  }
128
324
  });
129
- console.info('[llui] agent dev endpoints active: POST /agent/mint, WS /agent/ws, LAP /agent/lap/v1/*');
325
+ console.info('[llui] agent dev endpoints active: POST /agent/mint, WS /agent/ws, LAP /agent/lap/v1/* (also reachable under /cdn-cgi/agent/* for cloudflare-vite parity)');
130
326
  }
131
327
  /**
132
328
  * Walk up from `start` looking for `node_modules/<pkgName>`. Returns the
@@ -187,8 +383,6 @@ export default function llui(options = {}) {
187
383
  let mcpMode = 'disabled';
188
384
  let mcpCliPath = null;
189
385
  let mcpChild = null;
190
- const failOnWarning = options.failOnWarning === true;
191
- const disabledWarnings = new Set(options.disabledWarnings ?? []);
192
386
  const verbose = options.verbose === true;
193
387
  const agent = options.agent ?? false;
194
388
  const agentConfig = typeof agent === 'object' ? agent : {};
@@ -342,7 +536,17 @@ export default function llui(options = {}) {
342
536
  // the import.meta.hot listener registers get dropped — and lets
343
537
  // the browser connect to the actual port (which may differ from
344
538
  // the compile-time default if MCP was started with LLUI_MCP_PORT).
345
- server.middlewares.use('/__llui_mcp_status', (_req, res) => {
539
+ //
540
+ // Two paths register the same handler:
541
+ // * `/__llui_mcp_status` — canonical, served from any Vite
542
+ // project.
543
+ // * `/cdn-cgi/llui_mcp_status` — fallback for projects that
544
+ // bundle `@cloudflare/vite-plugin`. The cloudflare plugin
545
+ // intercepts every HTTP request in `configureServer` and
546
+ // routes it to the worker, except `/cdn-cgi/*` which it
547
+ // explicitly lets through. Without this fallback, MCP
548
+ // auto-discovery silently fails under workerd.
549
+ const mcpStatusHandler = (_req, res) => {
346
550
  const marker = readMcpMarker();
347
551
  if (marker === null) {
348
552
  res.statusCode = 404;
@@ -352,7 +556,9 @@ export default function llui(options = {}) {
352
556
  res.statusCode = 200;
353
557
  res.setHeader('content-type', 'application/json');
354
558
  res.end(JSON.stringify({ port: marker.port }));
355
- });
559
+ };
560
+ server.middlewares.use('/__llui_mcp_status', mcpStatusHandler);
561
+ server.middlewares.use('/cdn-cgi/llui_mcp_status', mcpStatusHandler);
356
562
  // Watch the marker file for create/delete. fs.watch on the parent
357
563
  // directory catches both events; the file itself may not exist
358
564
  // when we start watching.
@@ -436,7 +642,7 @@ export default function llui(options = {}) {
436
642
  // themselves — configureServer also fires in middleware mode, but
437
643
  // there server.httpServer is null so the upgrade hook is a no-op.
438
644
  },
439
- transform(code, id, options) {
645
+ async transform(code, id, options) {
440
646
  if (!id.endsWith('.ts') && !id.endsWith('.tsx'))
441
647
  return;
442
648
  // `'use client'` directive — SSR builds replace the module with a
@@ -455,28 +661,31 @@ export default function llui(options = {}) {
455
661
  return { code: result.output, map: { mappings: '' } };
456
662
  }
457
663
  }
458
- const diagnostics = diagnose(code);
459
- if (diagnostics.length > 0) {
460
- // Prefix every diagnostic with `<file>:<line>:<col>` plus the
461
- // `[rule-name]` tag so consumers logging `warning.message` in a
462
- // custom onwarn handler see both the location and the rule they
463
- // could silence via `disabledWarnings`.
464
- const cwd = process.cwd();
465
- const rel = relative(cwd, id);
466
- const display = rel.startsWith('..') ? id : rel;
467
- for (const d of diagnostics) {
468
- if (disabledWarnings.has(d.rule))
469
- continue;
470
- const message = `${display}:${d.line}:${d.column}: [${d.rule}] ${d.message}`;
471
- if (failOnWarning) {
472
- this.error({ message, loc: { line: d.line, column: d.column, file: id } });
473
- }
474
- else {
475
- this.warn(message, { line: d.line, column: d.column });
476
- }
477
- }
478
- }
479
- const result = transformLlui(code, id, devMode, Boolean(agent), mcpPort, verbose);
664
+ // Pre-resolve cross-file type sources for any `component<S, M, E>()`
665
+ // call in this file. The extractors look for `type Msg = ...` etc.
666
+ // in a single source string; if the user keeps `Msg` in a sibling
667
+ // file, the local extraction returns null and the plugin emits no
668
+ // annotations. Pre-resolution chases imports and re-exports to
669
+ // find the declaring file, so the schema/annotation extractors run
670
+ // against the right source. See cross-file-resolver.ts.
671
+ //
672
+ // `this.resolve` may be undefined in test harnesses that call the
673
+ // hook directly without going through Rollup; in that case skip
674
+ // pre-resolution and the local extractors handle whatever's in
675
+ // the source string.
676
+ const resolverAvailable = typeof this.resolve === 'function';
677
+ const [typeSources, preExtracted] = resolverAvailable
678
+ ? await Promise.all([
679
+ preResolveTypeSources(code, id, this.resolve.bind(this)),
680
+ // Cross-file + composition-aware extraction. Replaces the
681
+ // file-local sync extractors when active. Without this step
682
+ // a `type Msg = ImportedFoo | { type: 'extra' }`
683
+ // composition would only see the inline `'extra'` variant
684
+ // and silently emit half-annotations.
685
+ preExtractCompositional(code, id, this.resolve.bind(this)),
686
+ ])
687
+ : [undefined, undefined];
688
+ const result = transformLlui(code, id, devMode, Boolean(agent), mcpPort, verbose, typeSources, preExtracted);
480
689
  if (!result)
481
690
  return undefined;
482
691
  // Apply per-statement edits via MagicString for accurate source maps.