@pyreon/compiler 0.15.0 → 0.16.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/jsx.ts CHANGED
@@ -29,31 +29,21 @@
29
29
  */
30
30
 
31
31
  import { parseSync } from 'oxc-parser'
32
- import { createRequire } from 'node:module'
33
- import { fileURLToPath } from 'node:url'
34
- import { dirname, join } from 'node:path'
35
32
  import { REACT_EVENT_REMAP } from './event-names'
33
+ import { loadNativeBinding } from './load-native'
36
34
 
37
35
  // ─── Native binary auto-detection ────────────────────────────────────────────
38
- // Try to load the Rust napi-rs binary for 3.7-8.2x faster transforms.
39
- // Falls back to the JS implementation below if the binary isn't available
40
- // (wrong platform, CI environment, WASM runtime like StackBlitz, etc.)
36
+ // Two-path resolution: in-tree binary first (dev mode), then per-platform
37
+ // npm package (production install via optionalDependencies). Falls through
38
+ // to the JS implementation below when both paths fail (wrong platform, CI
39
+ // environment, WASM runtime like StackBlitz, missing per-platform package).
41
40
  //
42
- // Uses createRequire for ESM compatibility — __dirname and require() don't
43
- // exist in ESM modules.
41
+ // See `load-native.ts` for the resolution logic.
44
42
  type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
45
- let nativeTransformJsx: NativeTransformFn | null = null
46
-
47
- try {
48
- const __filename = fileURLToPath(import.meta.url)
49
- const __dirname = dirname(__filename)
50
- const nativeRequire = createRequire(import.meta.url)
51
- const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
52
- const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
53
- nativeTransformJsx = native.transformJsx
54
- } catch {
55
- // Native binary not available — JS fallback will be used
56
- }
43
+ const nativeBinding = loadNativeBinding(import.meta.url)
44
+ const nativeTransformJsx: NativeTransformFn | null = nativeBinding
45
+ ? (nativeBinding.transformJsx as NativeTransformFn)
46
+ : null
57
47
 
58
48
  export interface CompilerWarning {
59
49
  /** Warning message */
@@ -774,19 +764,19 @@ export function transformJSX_JS(
774
764
  if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
775
765
 
776
766
  replacements.sort((a, b) => a.start - b.start)
777
- const parts: string[] = []
778
- let lastPos = 0
767
+ const outParts: string[] = []
768
+ let outPos = 0
779
769
  for (const r of replacements) {
780
- parts.push(code.slice(lastPos, r.start))
781
- parts.push(r.text)
782
- lastPos = r.end
770
+ outParts.push(code.slice(outPos, r.start))
771
+ outParts.push(r.text)
772
+ outPos = r.end
783
773
  }
784
- parts.push(code.slice(lastPos))
785
- let result = parts.join('')
774
+ outParts.push(code.slice(outPos))
775
+ let output = outParts.join('')
786
776
 
787
777
  if (hoists.length > 0) {
788
778
  const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
789
- result = preamble + result
779
+ output = preamble + output
790
780
  }
791
781
 
792
782
  if (needsTplImport) {
@@ -798,16 +788,16 @@ export function transformJSX_JS(
798
788
  const reactivityImports = needsBindImportGlobal
799
789
  ? `\nimport { _bind } from "@pyreon/reactivity";`
800
790
  : ''
801
- result =
791
+ output =
802
792
  `import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
803
- result
793
+ output
804
794
  }
805
795
 
806
796
  if (needsRpImport) {
807
- result = `import { _rp } from "@pyreon/core";\n` + result
797
+ output = `import { _rp } from "@pyreon/core";\n` + output
808
798
  }
809
799
 
810
- return { code: result, usesTemplates: needsTplImport, warnings }
800
+ return { code: output, usesTemplates: needsTplImport, warnings }
811
801
 
812
802
  // ── Template emission helpers ─────────────────────────────────────────────
813
803
 
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Native binding loader — resolves the @pyreon/compiler napi-rs binary
3
+ * via two paths in priority order:
4
+ *
5
+ * 1. **In-tree binary** at `<package>/native/pyreon-compiler.node`.
6
+ * Populated by `scripts/build-native.ts` during local development
7
+ * (Phase 2). Faster path because it skips npm-package resolution.
8
+ *
9
+ * 2. **Per-platform npm package** (Phase 5b — not active until per-
10
+ * platform packages are published). Resolves `@pyreon/compiler-
11
+ * <platform>-<arch>[-<libc>]` via the standard Node module
12
+ * resolution algorithm. End users on machines without a local
13
+ * `cargo` install will hit this path: `bun install` resolves
14
+ * `optionalDependencies` to the matching per-platform package and
15
+ * this loader picks it up.
16
+ *
17
+ * 3. **JS fallback** (caller's responsibility) — if both paths fail,
18
+ * `loadNativeBinding()` returns `null` and the caller uses the
19
+ * pure-JS implementation. Slower but correctness-equivalent.
20
+ *
21
+ * Platform detection follows the napi-rs convention. Linux variants
22
+ * include a `libc` suffix (`gnu` for glibc, `musl` for musl) per
23
+ * https://napi.rs/docs/cli/build#deployment.
24
+ *
25
+ * The two-path resolution lets dev-mode (where `cargo build` produced
26
+ * an in-tree binary) and production-mode (where the user has only the
27
+ * published per-platform package) coexist with no flag flipping.
28
+ */
29
+
30
+ import { createRequire } from 'node:module'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { dirname, join } from 'node:path'
33
+
34
+ export interface NativeBinding {
35
+ transformJsx: (
36
+ code: string,
37
+ filename: string,
38
+ ssr: boolean,
39
+ knownSignals: string[] | null,
40
+ ) => unknown
41
+ }
42
+
43
+ // Local Node-process surface. `@pyreon/runtime-dom` ships an ambient
44
+ // `declare var process: { env: { NODE_ENV?: string } }` to enforce the
45
+ // bundler-agnostic dev-gate pattern, which narrows `process` for ANY
46
+ // file pulled in by runtime-dom's typecheck — including this one when
47
+ // imported via the `bun` condition. Casting through a local interface
48
+ // restores access to the platform/arch/report fields we genuinely need.
49
+ interface NodeProcess {
50
+ platform: string
51
+ arch: string
52
+ report?: {
53
+ getReport(): unknown
54
+ }
55
+ }
56
+ const nodeProcess = process as unknown as NodeProcess
57
+
58
+ /**
59
+ * Resolve the per-platform package name following the napi-rs naming
60
+ * convention: `@pyreon/compiler-<platform>-<arch>[-<libc>]`.
61
+ *
62
+ * Examples:
63
+ * darwin + arm64 → @pyreon/compiler-darwin-arm64
64
+ * darwin + x64 → @pyreon/compiler-darwin-x64
65
+ * linux + x64 + gnu → @pyreon/compiler-linux-x64-gnu
66
+ * linux + arm64 + gnu → @pyreon/compiler-linux-arm64-gnu
67
+ * win32 + x64 + msvc → @pyreon/compiler-win32-x64-msvc
68
+ *
69
+ * Returns `null` for unsupported (platform, arch) combinations — caller
70
+ * skips per-platform resolution entirely and falls through to JS.
71
+ */
72
+ export function getPlatformPackageName(
73
+ platform: string = nodeProcess.platform,
74
+ arch: string = nodeProcess.arch,
75
+ libc: string | null = detectLibc(platform),
76
+ ): string | null {
77
+ // Build the suffix for libc-bearing platforms (Linux glibc/musl,
78
+ // Windows MSVC). Single source of truth — no per-platform branching.
79
+ const suffix = libc ? `-${libc}` : ''
80
+ // Allowlist of (platform, arch) combos that the cross-platform CI
81
+ // workflow actually builds. Keep in sync with
82
+ // `.github/workflows/release-native.yml` matrix.
83
+ const supported: Record<string, string[]> = {
84
+ darwin: ['arm64', 'x64'],
85
+ linux: ['x64', 'arm64'],
86
+ win32: ['x64'],
87
+ }
88
+ if (!supported[platform]?.includes(arch)) return null
89
+ return `@pyreon/compiler-${platform}-${arch}${suffix}`
90
+ }
91
+
92
+ /**
93
+ * Detect the libc family for the current Linux runtime. Returns:
94
+ * - `'gnu'` on glibc-based distros (Debian, Ubuntu, RHEL, …)
95
+ * - `'musl'` on musl-based distros (Alpine, …)
96
+ * - `null` on macOS / Windows (no libc differentiation)
97
+ * - `'msvc'` on Windows (we only ship MSVC binaries)
98
+ *
99
+ * `process.report.getReport().header.glibcVersionRuntime` is the
100
+ * Node-canonical detection: present on glibc, absent on musl. Falls
101
+ * back to `gnu` on read failure since glibc is the more common case.
102
+ */
103
+ function detectLibc(platform: string): string | null {
104
+ if (platform === 'win32') return 'msvc'
105
+ if (platform !== 'linux') return null
106
+ try {
107
+ const report = nodeProcess.report?.getReport()
108
+ if (typeof report === 'object' && report !== null) {
109
+ const header = (report as { header?: { glibcVersionRuntime?: string } }).header
110
+ return header?.glibcVersionRuntime ? 'gnu' : 'musl'
111
+ }
112
+ } catch {
113
+ // Best-effort detection — fall through to glibc default.
114
+ }
115
+ return 'gnu'
116
+ }
117
+
118
+ /**
119
+ * Load the native binding by trying paths in order:
120
+ * 1. In-tree binary (`<package>/native/pyreon-compiler.node`)
121
+ * 2. Per-platform npm package (`@pyreon/compiler-<triple>`)
122
+ *
123
+ * Returns `null` if both paths fail — caller falls back to the
124
+ * pure-JS implementation. NEVER throws — every error path swallows
125
+ * silently because a missing native binary is a perf optimization
126
+ * miss, not a correctness failure.
127
+ */
128
+ export function loadNativeBinding(metaUrl: string): NativeBinding | null {
129
+ const nativeRequire = createRequire(metaUrl)
130
+
131
+ // Path 1: in-tree binary (dev mode + Phase 2 local-build path).
132
+ try {
133
+ const __filename = fileURLToPath(metaUrl)
134
+ const __dirname = dirname(__filename)
135
+ const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
136
+ return nativeRequire(nativePath) as NativeBinding
137
+ } catch {
138
+ // In-tree binary not present — fall through to per-platform package.
139
+ }
140
+
141
+ // Path 2: per-platform npm package (production install path).
142
+ // Will start working once Phase 5b publishes the per-platform
143
+ // packages and `optionalDependencies` resolves them at install time.
144
+ const pkgName = getPlatformPackageName()
145
+ if (pkgName !== null) {
146
+ try {
147
+ return nativeRequire(pkgName) as NativeBinding
148
+ } catch {
149
+ // Per-platform package not installed (typical pre-Phase-5b
150
+ // state, or a platform we don't yet ship binaries for).
151
+ }
152
+ }
153
+
154
+ return null
155
+ }
@@ -41,6 +41,14 @@
41
41
  * - `as-unknown-as-vnodechild` — defensive `as unknown as VNodeChild`
42
42
  * cast on JSX returns is unnecessary (`JSX.Element`
43
43
  * is already assignable to `VNodeChild`).
44
+ * - `island-never-with-registry-entry` — an `island()` declared with
45
+ * `hydrate: 'never'` is also registered in the same
46
+ * file's `hydrateIslands({ ... })` call. The whole
47
+ * point of `'never'` is shipping zero client JS;
48
+ * registering pulls the component module into the
49
+ * client bundle graph (the runtime short-circuits
50
+ * and never calls the loader, but the bundler still
51
+ * includes the import). Drop the registry entry.
44
52
  *
45
53
  * Two-mode surface mirrors `react-intercept.ts`:
46
54
  * - `detectPyreonPatterns(code)` — diagnostics only
@@ -81,6 +89,7 @@ export type PyreonDiagnosticCode =
81
89
  | 'signal-write-as-call'
82
90
  | 'static-return-null-conditional'
83
91
  | 'as-unknown-as-vnodechild'
92
+ | 'island-never-with-registry-entry'
84
93
 
85
94
  export interface PyreonDiagnostic {
86
95
  /** Machine-readable code for filtering + programmatic handling */
@@ -114,6 +123,21 @@ interface DetectContext {
114
123
  * patterns that should be `sig.set(value)`.
115
124
  */
116
125
  signalBindings: Set<string>
126
+ /**
127
+ * Names of `island()` declarations carrying `hydrate: 'never'`. Populated
128
+ * by `collectNeverIslandNames()` before the main detection walk. Used by
129
+ * `detectIslandNeverWithRegistry` to flag entries in
130
+ * `hydrateIslands({ ... })` whose key matches a never-strategy island.
131
+ *
132
+ * Cross-call detection: the never-vs-registry mismatch is only catchable
133
+ * when both sides live in the same source. In real apps the `island()`
134
+ * declarations sit in `src/islands.ts` and the `hydrateIslands()` call
135
+ * sits in `src/entry-client.ts`. The static detector covers the common
136
+ * "all in one file" case (which catches the bug while users are first
137
+ * learning the API); the cross-file case is the territory of `pyreon
138
+ * doctor --check-islands` (separate PR / future scope).
139
+ */
140
+ neverIslandNames: Set<string>
117
141
  }
118
142
 
119
143
  function getNodeText(ctx: DetectContext, node: ts.Node): string {
@@ -647,6 +671,101 @@ function detectAsUnknownAsVNodeChild(ctx: DetectContext, node: ts.AsExpression):
647
671
  )
648
672
  }
649
673
 
674
+ // ═══════════════════════════════════════════════════════════════════════════════
675
+ // Island never-with-registry detection
676
+ // ═══════════════════════════════════════════════════════════════════════════════
677
+
678
+ /**
679
+ * Pre-pass: walk the source for `island(loader, { name: 'X', hydrate: 'never' })`
680
+ * call expressions and collect the `name` field of each never-strategy island.
681
+ *
682
+ * Recognized shape (mirrors `@pyreon/vite-plugin`'s `scanIslandDeclarations`):
683
+ *
684
+ * island(() => import('./X'), { name: 'X', hydrate: 'never' })
685
+ *
686
+ * Edge cases the AST-walker deliberately doesn't cover (unrecognized calls
687
+ * fall through and don't populate the set — false-negatives, not false
688
+ * positives):
689
+ *
690
+ * - Loader is a variable, not an inline arrow
691
+ * - Name is a variable / template / spread, not a string literal
692
+ * - Options come from a spread (`island(loader, opts)`)
693
+ *
694
+ * The same rules apply on the registry side (`detectIslandNeverWithRegistry`):
695
+ * unrecognized keys won't match. Both halves are syntactic — a semantic
696
+ * cross-package audit lives in `pyreon doctor --check-islands` (separate PR).
697
+ */
698
+ function collectNeverIslandNames(sf: ts.SourceFile): Set<string> {
699
+ const names = new Set<string>()
700
+ function walk(node: ts.Node): void {
701
+ if (
702
+ ts.isCallExpression(node) &&
703
+ ts.isIdentifier(node.expression) &&
704
+ node.expression.text === 'island' &&
705
+ node.arguments.length >= 2
706
+ ) {
707
+ const opts = node.arguments[1]
708
+ if (opts && ts.isObjectLiteralExpression(opts)) {
709
+ let nameVal: string | undefined
710
+ let hydrateVal: string | undefined
711
+ for (const prop of opts.properties) {
712
+ if (!ts.isPropertyAssignment(prop)) continue
713
+ const key = prop.name
714
+ const keyText = ts.isIdentifier(key)
715
+ ? key.text
716
+ : ts.isStringLiteral(key)
717
+ ? key.text
718
+ : ''
719
+ if (keyText === 'name' && ts.isStringLiteral(prop.initializer)) {
720
+ nameVal = prop.initializer.text
721
+ } else if (keyText === 'hydrate' && ts.isStringLiteral(prop.initializer)) {
722
+ hydrateVal = prop.initializer.text
723
+ }
724
+ }
725
+ if (nameVal && hydrateVal === 'never') {
726
+ names.add(nameVal)
727
+ }
728
+ }
729
+ }
730
+ ts.forEachChild(node, walk)
731
+ }
732
+ walk(sf)
733
+ return names
734
+ }
735
+
736
+ /**
737
+ * Flag entries in `hydrateIslands({ X: () => import('./X'), ... })` whose
738
+ * key matches an `island()` name declared with `hydrate: 'never'` in the
739
+ * same file. Each matching entry produces one diagnostic at the property's
740
+ * location so the IDE highlights exactly which key needs to go.
741
+ */
742
+ function detectIslandNeverWithRegistry(ctx: DetectContext, node: ts.CallExpression): void {
743
+ if (ctx.neverIslandNames.size === 0) return
744
+ const callee = node.expression
745
+ if (!ts.isIdentifier(callee) || callee.text !== 'hydrateIslands') return
746
+ const arg = node.arguments[0]
747
+ if (!arg || !ts.isObjectLiteralExpression(arg)) return
748
+ for (const prop of arg.properties) {
749
+ if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) continue
750
+ const key = prop.name
751
+ const keyText = ts.isIdentifier(key)
752
+ ? key.text
753
+ : ts.isStringLiteral(key)
754
+ ? key.text
755
+ : ''
756
+ if (!keyText || !ctx.neverIslandNames.has(keyText)) continue
757
+ pushDiag(
758
+ ctx,
759
+ prop,
760
+ 'island-never-with-registry-entry',
761
+ `island "${keyText}" was declared with \`hydrate: 'never'\` and MUST NOT be registered in \`hydrateIslands({ ... })\`. The whole point of the \`'never'\` strategy is shipping zero client JS — registering pulls the component module into the client bundle graph (the runtime short-circuits never-strategy before the registry lookup, but the bundler still includes the import). Drop this entry; the framework handles never-strategy islands at SSR with no client-side wiring.`,
762
+ getNodeText(ctx, prop),
763
+ `// remove the "${keyText}" entry — never-strategy islands need no registry entry`,
764
+ false,
765
+ )
766
+ }
767
+ }
768
+
650
769
  // ═══════════════════════════════════════════════════════════════════════════════
651
770
  // Visitor
652
771
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -674,6 +793,7 @@ function visitNode(ctx: DetectContext, node: ts.Node): void {
674
793
  detectEmptyTheme(ctx, node)
675
794
  detectRawEventListener(ctx, node)
676
795
  detectSignalWriteAsCall(ctx, node)
796
+ detectIslandNeverWithRegistry(ctx, node)
677
797
  }
678
798
  if (ts.isJsxAttribute(node)) {
679
799
  detectOnClickUndefined(ctx, node)
@@ -701,6 +821,7 @@ export function detectPyreonPatterns(code: string, filename = 'input.tsx'): Pyre
701
821
  code,
702
822
  diagnostics: [],
703
823
  signalBindings: collectSignalBindings(sf),
824
+ neverIslandNames: collectNeverIslandNames(sf),
704
825
  }
705
826
  visit(ctx, sf)
706
827
  // Sort by (line, column) for stable ordering when multiple patterns fire.
@@ -723,6 +844,11 @@ export function hasPyreonPatterns(code: string): boolean {
723
844
  // static-return-null-conditional: `if (...) return null` anywhere
724
845
  /\bif\s*\([^)]+\)\s*\{?\s*return\s+null\b/.test(code) ||
725
846
  // as-unknown-as-vnodechild
726
- /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code)
847
+ /\bas\s+unknown\s+as\s+VNodeChild\b/.test(code) ||
848
+ // island-never-with-registry-entry: a never-strategy declaration AND a
849
+ // hydrateIslands call must both appear in the same source for the bug
850
+ // shape to trigger. Pre-filter on EITHER half — the AST walker fast-
851
+ // exits when the never-island set is empty.
852
+ (/\bisland\s*\(/.test(code) && /\bhydrate\s*:\s*['"]never['"]/.test(code))
727
853
  )
728
854
  }