@pyreon/compiler 0.14.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/README.md +17 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +1189 -30
- package/lib/types/index.d.ts +109 -2
- package/package.json +20 -2
- package/src/event-names.ts +65 -0
- package/src/index.ts +17 -0
- package/src/island-audit.ts +675 -0
- package/src/jsx.ts +162 -39
- package/src/load-native.ts +155 -0
- package/src/pyreon-intercept.ts +352 -2
- package/src/ssg-audit.ts +513 -0
- package/src/tests/detector-tag-consistency.test.ts +31 -15
- package/src/tests/island-audit.test.ts +524 -0
- package/src/tests/jsx.test.ts +236 -4
- package/src/tests/load-native.test.ts +53 -0
- package/src/tests/native-equivalence.test.ts +77 -0
- package/src/tests/pyreon-intercept.test.ts +296 -0
- package/src/tests/runtime/control-flow.test.ts +159 -0
- package/src/tests/runtime/dom-properties.test.ts +138 -0
- package/src/tests/runtime/events.test.ts +301 -0
- package/src/tests/runtime/harness.ts +94 -0
- package/src/tests/runtime/pr-352-shapes.test.ts +121 -0
- package/src/tests/runtime/reactive-props.test.ts +81 -0
- package/src/tests/runtime/signals.test.ts +129 -0
- package/src/tests/runtime/whitespace.test.ts +106 -0
- package/src/tests/ssg-audit.test.ts +402 -0
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
package/src/jsx.ts
CHANGED
|
@@ -29,30 +29,21 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { parseSync } from 'oxc-parser'
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import { dirname, join } from 'node:path'
|
|
32
|
+
import { REACT_EVENT_REMAP } from './event-names'
|
|
33
|
+
import { loadNativeBinding } from './load-native'
|
|
35
34
|
|
|
36
35
|
// ─── Native binary auto-detection ────────────────────────────────────────────
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
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).
|
|
40
40
|
//
|
|
41
|
-
//
|
|
42
|
-
// exist in ESM modules.
|
|
41
|
+
// See `load-native.ts` for the resolution logic.
|
|
43
42
|
type NativeTransformFn = (code: string, filename: string, ssr: boolean, knownSignals: string[] | null) => TransformResult
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const __dirname = dirname(__filename)
|
|
49
|
-
const nativeRequire = createRequire(import.meta.url)
|
|
50
|
-
const nativePath = join(__dirname, '..', 'native', 'pyreon-compiler.node')
|
|
51
|
-
const native = nativeRequire(nativePath) as { transformJsx: NativeTransformFn }
|
|
52
|
-
nativeTransformJsx = native.transformJsx
|
|
53
|
-
} catch {
|
|
54
|
-
// Native binary not available — JS fallback will be used
|
|
55
|
-
}
|
|
43
|
+
const nativeBinding = loadNativeBinding(import.meta.url)
|
|
44
|
+
const nativeTransformJsx: NativeTransformFn | null = nativeBinding
|
|
45
|
+
? (nativeBinding.transformJsx as NativeTransformFn)
|
|
46
|
+
: null
|
|
56
47
|
|
|
57
48
|
export interface CompilerWarning {
|
|
58
49
|
/** Warning message */
|
|
@@ -773,19 +764,19 @@ export function transformJSX_JS(
|
|
|
773
764
|
if (replacements.length === 0 && hoists.length === 0) return { code, warnings }
|
|
774
765
|
|
|
775
766
|
replacements.sort((a, b) => a.start - b.start)
|
|
776
|
-
const
|
|
777
|
-
let
|
|
767
|
+
const outParts: string[] = []
|
|
768
|
+
let outPos = 0
|
|
778
769
|
for (const r of replacements) {
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
770
|
+
outParts.push(code.slice(outPos, r.start))
|
|
771
|
+
outParts.push(r.text)
|
|
772
|
+
outPos = r.end
|
|
782
773
|
}
|
|
783
|
-
|
|
784
|
-
let
|
|
774
|
+
outParts.push(code.slice(outPos))
|
|
775
|
+
let output = outParts.join('')
|
|
785
776
|
|
|
786
777
|
if (hoists.length > 0) {
|
|
787
778
|
const preamble = hoists.map((h) => `const ${h.name} = /*@__PURE__*/ ${h.text}\n`).join('')
|
|
788
|
-
|
|
779
|
+
output = preamble + output
|
|
789
780
|
}
|
|
790
781
|
|
|
791
782
|
if (needsTplImport) {
|
|
@@ -797,16 +788,16 @@ export function transformJSX_JS(
|
|
|
797
788
|
const reactivityImports = needsBindImportGlobal
|
|
798
789
|
? `\nimport { _bind } from "@pyreon/reactivity";`
|
|
799
790
|
: ''
|
|
800
|
-
|
|
791
|
+
output =
|
|
801
792
|
`import { ${runtimeDomImports.join(', ')} } from "@pyreon/runtime-dom";${reactivityImports}\n` +
|
|
802
|
-
|
|
793
|
+
output
|
|
803
794
|
}
|
|
804
795
|
|
|
805
796
|
if (needsRpImport) {
|
|
806
|
-
|
|
797
|
+
output = `import { _rp } from "@pyreon/core";\n` + output
|
|
807
798
|
}
|
|
808
799
|
|
|
809
|
-
return { code:
|
|
800
|
+
return { code: output, usesTemplates: needsTplImport, warnings }
|
|
810
801
|
|
|
811
802
|
// ── Template emission helpers ─────────────────────────────────────────────
|
|
812
803
|
|
|
@@ -901,7 +892,26 @@ export function transformJSX_JS(
|
|
|
901
892
|
}
|
|
902
893
|
|
|
903
894
|
function emitEventListener(attr: N, attrName: string, varName: string): void {
|
|
904
|
-
|
|
895
|
+
// Translate the JSX-style React attribute name (e.g. `onKeyDown`,
|
|
896
|
+
// `onDoubleClick`) to the canonical DOM event name (`keydown`,
|
|
897
|
+
// `dblclick`).
|
|
898
|
+
//
|
|
899
|
+
// The default rule is "drop the `on` prefix and lowercase" —
|
|
900
|
+
// covers `onKeyDown` → `keydown`, `onMouseEnter` → `mouseenter`,
|
|
901
|
+
// `onPointerLeave` → `pointerleave`, `onAnimationStart` →
|
|
902
|
+
// `animationstart`, etc. Most React event names follow this rule
|
|
903
|
+
// because the underlying DOM event name is also the lowercased
|
|
904
|
+
// multi-word form.
|
|
905
|
+
//
|
|
906
|
+
// The exception list lives in `REACT_EVENT_REMAP` (event-names.ts).
|
|
907
|
+
// Every React event-prop in the official component-prop list was
|
|
908
|
+
// audited against canonical DOM event names — see the JSDoc on
|
|
909
|
+
// REACT_EVENT_REMAP for the audit. Today exactly one entry:
|
|
910
|
+
// `onDoubleClick` → `dblclick`
|
|
911
|
+
// The Rust native backend (`native/src/lib.rs:emit_event_listener`)
|
|
912
|
+
// mirrors the same table — keep them in sync if a new entry is added.
|
|
913
|
+
const lowered = attrName.slice(2).toLowerCase()
|
|
914
|
+
const eventName = REACT_EVENT_REMAP[lowered] ?? lowered
|
|
905
915
|
if (!attr.value || attr.value.type !== 'JSXExpressionContainer') return
|
|
906
916
|
const expr = attr.value.expression
|
|
907
917
|
if (!expr || expr.type === 'JSXEmptyExpression') return
|
|
@@ -952,6 +962,7 @@ export function transformJSX_JS(
|
|
|
952
962
|
function attrSetter(htmlAttrName: string, varName: string, expr: string): string {
|
|
953
963
|
if (htmlAttrName === 'class') return `${varName}.className = ${expr}`
|
|
954
964
|
if (htmlAttrName === 'style') return `${varName}.style.cssText = ${expr}`
|
|
965
|
+
if (DOM_PROPS.has(htmlAttrName)) return `${varName}.${htmlAttrName} = ${expr}`
|
|
955
966
|
return `${varName}.setAttribute("${htmlAttrName}", ${expr})`
|
|
956
967
|
}
|
|
957
968
|
|
|
@@ -970,7 +981,9 @@ export function transformJSX_JS(
|
|
|
970
981
|
? `(v) => { ${varName}.className = v == null ? "" : String(v) }`
|
|
971
982
|
: htmlAttrName === 'style'
|
|
972
983
|
? `(v) => { if (typeof v === "string") ${varName}.style.cssText = v; else if (v) Object.assign(${varName}.style, v) }`
|
|
973
|
-
:
|
|
984
|
+
: DOM_PROPS.has(htmlAttrName)
|
|
985
|
+
? `(v) => { ${varName}.${htmlAttrName} = v }`
|
|
986
|
+
: `(v) => { ${varName}.setAttribute("${htmlAttrName}", v == null ? "" : String(v)) }`
|
|
974
987
|
bindLines.push(`const ${d} = _bindDirect(${directRef}, ${updater})`)
|
|
975
988
|
return
|
|
976
989
|
}
|
|
@@ -1080,8 +1093,8 @@ export function transformJSX_JS(
|
|
|
1080
1093
|
): void {
|
|
1081
1094
|
if (child.type === 'JSXText') {
|
|
1082
1095
|
const raw = child.value ?? child.raw ?? ''
|
|
1083
|
-
const
|
|
1084
|
-
if (
|
|
1096
|
+
const cleaned = cleanJsxText(raw)
|
|
1097
|
+
if (cleaned) out.push({ kind: 'text', text: cleaned })
|
|
1085
1098
|
return
|
|
1086
1099
|
}
|
|
1087
1100
|
if (child.type === 'JSXElement') {
|
|
@@ -1108,9 +1121,19 @@ export function transformJSX_JS(
|
|
|
1108
1121
|
|
|
1109
1122
|
function analyzeChildren(flatChildren: FlatChild[]): { useMixed: boolean; useMultiExpr: boolean } {
|
|
1110
1123
|
const hasElem = flatChildren.some((c) => c.kind === 'element')
|
|
1111
|
-
const
|
|
1124
|
+
const hasText = flatChildren.some((c) => c.kind === 'text')
|
|
1112
1125
|
const exprCount = flatChildren.filter((c) => c.kind === 'expression').length
|
|
1113
|
-
|
|
1126
|
+
// `useMixed` triggers placeholder-based positional mounting (each
|
|
1127
|
+
// dynamic child gets a `<!>` comment slot in the template that
|
|
1128
|
+
// `replaceChild`-replaces at mount). It must fire whenever ≥2 of
|
|
1129
|
+
// {element, text, expression} are interleaved — otherwise dynamic
|
|
1130
|
+
// text nodes added via `appendChild` land after all static
|
|
1131
|
+
// template content, breaking source-order rendering for shapes
|
|
1132
|
+
// like `<p>foo {x()} bar</p>` (rendered "foo barX" instead of
|
|
1133
|
+
// "foo X bar"). Discovered by Phase B2's whitespace tests.
|
|
1134
|
+
const present =
|
|
1135
|
+
(hasElem ? 1 : 0) + (hasText ? 1 : 0) + (exprCount > 0 ? 1 : 0)
|
|
1136
|
+
return { useMixed: present > 1, useMultiExpr: exprCount > 1 }
|
|
1114
1137
|
}
|
|
1115
1138
|
|
|
1116
1139
|
function attrIsDynamic(attr: N): boolean {
|
|
@@ -1210,7 +1233,31 @@ export function transformJSX_JS(
|
|
|
1210
1233
|
return `_tpl("${escaped}", () => null)`
|
|
1211
1234
|
}
|
|
1212
1235
|
|
|
1213
|
-
|
|
1236
|
+
// Append `;` to every bind line so ASI can't merge consecutive
|
|
1237
|
+
// statements when the next line starts with `(`, `[`, etc.
|
|
1238
|
+
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
1239
|
+
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
1240
|
+
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
1241
|
+
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
1242
|
+
// so the parser merges them into a single function call:
|
|
1243
|
+
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
1244
|
+
// — calling `children[N]` as a function with the arrow as argument,
|
|
1245
|
+
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
1246
|
+
// terminates each statement deterministically. Trailing `;` after
|
|
1247
|
+
// a `{...}` block is a harmless empty statement.
|
|
1248
|
+
// Append `;` to every bind line so ASI can't merge consecutive
|
|
1249
|
+
// statements when the next line starts with `(`, `[`, etc.
|
|
1250
|
+
// Concrete bug shape (pre-fix): a child element with `hasDynamic=true`
|
|
1251
|
+
// emits `const __e0 = __root.children[N]` followed by a ref-callback
|
|
1252
|
+
// line `((el) => { x = el })(__e0)`. JS does NOT insert ASI here
|
|
1253
|
+
// because `__root.children[N]((el) => ...)` is a valid expression,
|
|
1254
|
+
// so the parser merges them into a single function call:
|
|
1255
|
+
// `const __e0 = __root.children[N]((el) => ...)(__e0)`
|
|
1256
|
+
// — calling `children[N]` as a function with the arrow as argument,
|
|
1257
|
+
// and self-referencing `__e0` before assignment. Adding the `;`
|
|
1258
|
+
// terminates each statement deterministically. Trailing `;` after
|
|
1259
|
+
// a `{...}` block is a harmless empty statement.
|
|
1260
|
+
let body = bindLines.map((l) => ` ${l};`).join('\n')
|
|
1214
1261
|
if (disposerNames.length > 0) {
|
|
1215
1262
|
body += `\n return () => { ${disposerNames.map((d) => `${d}()`).join('; ')} }`
|
|
1216
1263
|
} else {
|
|
@@ -1244,6 +1291,16 @@ export function transformJSX_JS(
|
|
|
1244
1291
|
if (node.type === 'Identifier' && isActiveSignal(node.name)) {
|
|
1245
1292
|
const parent = findParent(node)
|
|
1246
1293
|
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return false
|
|
1294
|
+
// signal.X(...) — operating on the signal object (calling a method).
|
|
1295
|
+
// Mirrors the same narrow skip in findSignalIdents below.
|
|
1296
|
+
if (
|
|
1297
|
+
parent &&
|
|
1298
|
+
parent.type === 'MemberExpression' &&
|
|
1299
|
+
parent.object === node
|
|
1300
|
+
) {
|
|
1301
|
+
const grand = findParent(parent)
|
|
1302
|
+
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return false
|
|
1303
|
+
}
|
|
1247
1304
|
if (parent && parent.type === 'CallExpression' && parent.callee === node) return false // already called
|
|
1248
1305
|
return true
|
|
1249
1306
|
}
|
|
@@ -1269,6 +1326,27 @@ export function transformJSX_JS(
|
|
|
1269
1326
|
const parent = findParent(node)
|
|
1270
1327
|
// Skip property name positions (obj.name)
|
|
1271
1328
|
if (parent && parent.type === 'MemberExpression' && parent.property === node && !parent.computed) return
|
|
1329
|
+
// Skip when the identifier is the OBJECT of a member access AND
|
|
1330
|
+
// the result is being CALLED (signal.set(...), signal.peek(),
|
|
1331
|
+
// signal.update(...)). The user is invoking a method on the
|
|
1332
|
+
// signal OBJECT — auto-calling would produce `signal().set(...)`
|
|
1333
|
+
// which calls the signal, gets its value (string/number/etc),
|
|
1334
|
+
// then `.set` on the value is undefined → TypeError. Every event
|
|
1335
|
+
// handler that did `signal.set(x)` was silently broken.
|
|
1336
|
+
//
|
|
1337
|
+
// Note: bare `signal.value` (member access NOT followed by call)
|
|
1338
|
+
// STILL auto-calls — keeps the existing convention where
|
|
1339
|
+
// `signal({a:1})` followed by `signal.a` reads the signal's
|
|
1340
|
+
// value's property (see "signal as member expression object IS
|
|
1341
|
+
// auto-called" test).
|
|
1342
|
+
if (
|
|
1343
|
+
parent &&
|
|
1344
|
+
parent.type === 'MemberExpression' &&
|
|
1345
|
+
parent.object === node
|
|
1346
|
+
) {
|
|
1347
|
+
const grand = findParent(parent)
|
|
1348
|
+
if (grand && grand.type === 'CallExpression' && grand.callee === parent) return
|
|
1349
|
+
}
|
|
1272
1350
|
// Skip if already being called: signal()
|
|
1273
1351
|
if (parent && parent.type === 'CallExpression' && parent.callee === node) return
|
|
1274
1352
|
// Skip declaration positions
|
|
@@ -1313,6 +1391,24 @@ const JSX_TO_HTML_ATTR: Record<string, string> = {
|
|
|
1313
1391
|
htmlFor: 'for',
|
|
1314
1392
|
}
|
|
1315
1393
|
|
|
1394
|
+
// DOM properties whose live value diverges from the content attribute.
|
|
1395
|
+
// For these, emit property assignment (`el.value = v`) instead of
|
|
1396
|
+
// `setAttribute("value", v)`. Otherwise the property and attribute drift
|
|
1397
|
+
// apart in user-driven flows: typing in a controlled <input> updates the
|
|
1398
|
+
// .value property, but `input.set('')` clearing the signal only resets
|
|
1399
|
+
// the attribute — the stale typed text stays visible. Same for `checked`
|
|
1400
|
+
// on checkboxes (presence of the attribute means checked regardless of
|
|
1401
|
+
// value: `setAttribute("checked", "false")` still checks the box).
|
|
1402
|
+
const DOM_PROPS = new Set([
|
|
1403
|
+
'value',
|
|
1404
|
+
'checked',
|
|
1405
|
+
'selected',
|
|
1406
|
+
'disabled',
|
|
1407
|
+
'multiple',
|
|
1408
|
+
'readOnly',
|
|
1409
|
+
'indeterminate',
|
|
1410
|
+
])
|
|
1411
|
+
|
|
1316
1412
|
const STATEFUL_CALLS = new Set([
|
|
1317
1413
|
'signal', 'computed', 'effect', 'batch',
|
|
1318
1414
|
'createContext', 'createReactiveContext',
|
|
@@ -1364,6 +1460,33 @@ function escapeHtmlText(s: string): string {
|
|
|
1364
1460
|
return s.replace(/&(?!(?:#\d+|#x[\da-fA-F]+|[a-zA-Z]\w*);)/g, '&').replace(/</g, '<')
|
|
1365
1461
|
}
|
|
1366
1462
|
|
|
1463
|
+
// React/Babel JSX whitespace algorithm (cleanJSXElementLiteralChild).
|
|
1464
|
+
// Same-line text is preserved verbatim so adjacent expressions keep their
|
|
1465
|
+
// spacing (`<p>doubled: {x}</p>` keeps the trailing space). Multi-line text
|
|
1466
|
+
// strips leading whitespace from non-first lines and trailing whitespace
|
|
1467
|
+
// from non-last lines, drops fully-empty lines, and joins the survivors
|
|
1468
|
+
// with a single space — collapsing JSX indentation without losing
|
|
1469
|
+
// intentional inline spacing.
|
|
1470
|
+
function cleanJsxText(raw: string): string {
|
|
1471
|
+
if (!raw.includes('\n') && !raw.includes('\r')) return raw
|
|
1472
|
+
const lines = raw.split(/\r\n|\n|\r/)
|
|
1473
|
+
let lastNonEmpty = -1
|
|
1474
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1475
|
+
if (/[^ \t]/.test(lines[i] ?? '')) lastNonEmpty = i
|
|
1476
|
+
}
|
|
1477
|
+
let str = ''
|
|
1478
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1479
|
+
let line = (lines[i] ?? '').replace(/\t/g, ' ')
|
|
1480
|
+
if (i !== 0) line = line.replace(/^ +/, '')
|
|
1481
|
+
if (i !== lines.length - 1) line = line.replace(/ +$/, '')
|
|
1482
|
+
if (line) {
|
|
1483
|
+
if (i !== lastNonEmpty) line += ' '
|
|
1484
|
+
str += line
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
return str
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1367
1490
|
function isStaticJSXNode(node: N): boolean {
|
|
1368
1491
|
if (node.type === 'JSXElement' && node.openingElement?.selfClosing) {
|
|
1369
1492
|
return isStaticAttrs(node.openingElement.attributes ?? [])
|
|
@@ -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
|
+
}
|