unpeek 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +245 -2
- package/bin/unpeek.mjs +7 -0
- package/dist/ast/cold-flow-guard.d.ts +2 -0
- package/dist/ast/cold-flow-guard.js +39 -0
- package/dist/ast/hot-path-detector.d.ts +10 -0
- package/dist/ast/hot-path-detector.js +235 -0
- package/dist/ast/index.d.ts +4 -0
- package/dist/ast/index.js +55 -0
- package/dist/ast/polymorphic-mangler.d.ts +2 -0
- package/dist/ast/polymorphic-mangler.js +270 -0
- package/dist/ast/string-vault.d.ts +7 -0
- package/dist/ast/string-vault.js +139 -0
- package/dist/bundler/detector.d.ts +6 -0
- package/dist/bundler/detector.js +149 -0
- package/dist/bundler/sourcemap-vault.d.ts +2 -0
- package/dist/bundler/sourcemap-vault.js +98 -0
- package/dist/bundler/unplugin.d.ts +7 -0
- package/dist/bundler/unplugin.js +55 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +105 -0
- package/dist/cli/init.d.ts +5 -0
- package/dist/cli/init.js +49 -0
- package/dist/cli/protect.d.ts +2 -0
- package/dist/cli/protect.js +88 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +8 -0
- package/dist/runtime/devtools-deterrent.d.ts +7 -0
- package/dist/runtime/devtools-deterrent.js +27 -0
- package/dist/runtime/index.d.ts +4 -0
- package/dist/runtime/index.js +16 -0
- package/dist/runtime/integrity-guard.d.ts +11 -0
- package/dist/runtime/integrity-guard.js +25 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/package.json +65 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Efe Arabacı & unpeek contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,246 @@
|
|
|
1
|
-
#
|
|
1
|
+
# unpeek
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Zero-config, call-graph-aware polymorphic JavaScript protection and runtime anti-tamper toolkit with <2% CPU overhead.**
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](packages/unpeek/benchmarks/overhead.bench.ts)
|
|
7
|
+
[](packages/unpeek/benchmarks/overhead.bench.ts)
|
|
8
|
+
[](https://woffluon.github.io/unpeek)
|
|
9
|
+
|
|
10
|
+
English | [Türkçe](./README.tr.md) · [Live Interactive Playground](https://woffluon.github.io/unpeek) · [Agent Architecture Guide](./AGENTS.md) · [AI Skill](./skills/unpeek/SKILL.md)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<baglam>
|
|
15
|
+
|
|
16
|
+
## Why unpeek?
|
|
17
|
+
|
|
18
|
+
Shipping a modern web application in Next.js, Vite, or Webpack exposes client-side business logic, internal API endpoints, feature flags, and proprietary algorithms to anyone who opens Chrome DevTools or runs an automated AST deobfuscator (`webcrack`, `synchrony`).
|
|
19
|
+
|
|
20
|
+
Existing tools force an uncomfortable trade-off:
|
|
21
|
+
- **Standard minifiers (`esbuild`, `SWC`, `Terser`)** compress whitespace and shorten variable names, leaving string literals, endpoint URLs, and control flow readable after a single click on the DevTools Pretty-Print (`{}`) button.
|
|
22
|
+
- **Legacy AST obfuscators (`javascript-obfuscator`)** apply indiscriminate control-flow flattening, `eval` traps, and eager string-array rotation to every function in the bundle. This inflates bundle sizes by 40–300%, breaks V8 TurboFan JIT inlining inside 60fps UI rendering loops, and triggers false-positive browser freezes.
|
|
23
|
+
|
|
24
|
+
`unpeek` resolves this trade-off by combining static call-graph analysis with polymorphic compile-time encryption:
|
|
25
|
+
|
|
26
|
+
| Capability | Standard Minifiers (`Terser` / `SWC`) | Legacy AST Obfuscators | `unpeek` |
|
|
27
|
+
| :--- | :--- | :--- | :--- |
|
|
28
|
+
| **Runtime CPU Overhead** | ~0% | 15% – 85% (throttles UI loops) | **< 2.0%** (Call-Graph Hot-Path Exemption) |
|
|
29
|
+
| **Gzip Bundle Growth** | 0% (Baseline) | +45% – +300% | **< 15%** (O(1) Lazy String Vault) |
|
|
30
|
+
| **String & Endpoint Encryption** | Plaintext in bundle | Eager array rotation (high CPU) | **Polymorphic XOR + O(1) Memoized Vault** |
|
|
31
|
+
| **DevTools Pretty-Print (`{}`) Defense** | None | `debugger` infinite loops (easily bypassed) | **`Function.prototype.toString()` FNV-1a Integrity** |
|
|
32
|
+
| **Source Map Isolation** | Manual CI scripts | Manual configuration | **Automatic `.unpeek-maps/` Vault + Comment Strip** |
|
|
33
|
+
| **Static Deobfuscator Resistance** | None | Fixed templates (fingerprinted by `webcrack`) | **Per-Build Polymorphic AST Seed** |
|
|
34
|
+
| **SSR / Next.js Compatibility** | Native | Frequently breaks server hydration | **Client-Only Auto-Detection + SSR Guard** |
|
|
35
|
+
|
|
36
|
+
### Core Architecture
|
|
37
|
+
|
|
38
|
+
1. **Call-Graph Hot-Path Exemption (`<2%` CPU Budget)**
|
|
39
|
+
Before modifying a single AST node, [`detectHotPaths`](packages/unpeek/src/ast/index.ts) traverses the file's call graph. Functions containing loops (`for`, `while`, `do-while`, `for-of`), array higher-order callbacks (`.map`, `.filter`, `.reduce`, `.forEach`), `requestAnimationFrame` tickers, and React/UI render components (`useState`, `useEffect`, `createElement`) are flagged as hot paths. `unpeek` mangles identifiers and encrypts string constants across the entire bundle, while injecting opaque control-flow predicates exclusively into cold functions (such as license checks, request signers, and auth validators). V8 JIT optimization inside hot loops remains untouched.
|
|
40
|
+
2. **O(1) Lazy String Vault (`<15%` Gzip Budget)**
|
|
41
|
+
Plaintext string literals (`minStringLength >= 4`) are encrypted at build time with a polymorphic XOR stream cipher keyed by a per-build 32-bit seed. Instead of decrypting every string on page startup, the injected vault helper decodes each string lazily on its first access and caches the plaintext result in its array slot. Subsequent reads resolve in O(1) time via direct array indexing.
|
|
42
|
+
3. **Self-Defending `Function.prototype.toString()` Integrity & Passive Deterrent**
|
|
43
|
+
When an analyst formats a protected chunk using DevTools Pretty-Print (`{}`) or edits a branch instruction in the Sources panel, the formatted source alters `Function.prototype.toString()`. [`verifyCodeIntegrity`](packages/unpeek/src/runtime/index.ts) validates a seeded 32-bit FNV-1a checksum against the function's source representation with zero false positives on untouched minified code, triggering gradual state degradation (`deterrentMode: 'gradual'`) rather than an obvious crash point that attackers can patch out.
|
|
44
|
+
4. **Automatic Source Map Vault (`.unpeek-maps/`)**
|
|
45
|
+
Leaving `.js.map` files in a public build directory reconstructs your original TypeScript project tree inside DevTools. [`protectDirectory`](packages/unpeek/src/cli/index.ts) automatically extracts `.map` files from your public build directory into a private `.unpeek-maps/` folder and strips `//# sourceMappingURL=` comments from every client bundle so you can upload maps to Sentry or Datadog without serving them to end users.
|
|
46
|
+
|
|
47
|
+
</baglam>
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
<talimat>
|
|
52
|
+
|
|
53
|
+
## Quick Start
|
|
54
|
+
|
|
55
|
+
### 1. One-Command Project Setup (`npx unpeek init`)
|
|
56
|
+
|
|
57
|
+
Run the initializer from your project root (Next.js, Vite, Webpack, or vanilla):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx unpeek init
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
This command performs three non-destructive setup actions:
|
|
64
|
+
1. Detects your framework (`nextjs`, `vite`, `webpack`, or `vanilla`) from `package.json` and build config files.
|
|
65
|
+
2. Adds `"postbuild": "unpeek protect"` to your `package.json` scripts so every production build is automatically protected.
|
|
66
|
+
3. Appends `.unpeek-maps/` to `.gitignore` so extracted source maps stay out of version control.
|
|
67
|
+
|
|
68
|
+
### 2. CLI Usage (`unpeek protect`)
|
|
69
|
+
|
|
70
|
+
You can also install `unpeek` as a development dependency and run it directly in CI/CD or local builds:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
npm install -D unpeek
|
|
74
|
+
npx unpeek protect
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
#### CLI Options
|
|
78
|
+
|
|
79
|
+
| Flag | Description | Default |
|
|
80
|
+
| :--- | :--- | :--- |
|
|
81
|
+
| `--dir <path>` | Explicit client output directory override (e.g., `dist/assets`, `.next/static/chunks`) | Auto-detected |
|
|
82
|
+
| `--seed <number>` | Deterministic 32-bit polymorphic build seed | Random per build |
|
|
83
|
+
| `--mode <gradual\|passive>` | Runtime anti-tamper deterrent mode | `gradual` |
|
|
84
|
+
| `--min-str <number>` | Minimum character length for O(1) lazy string vault encryption | `4` |
|
|
85
|
+
| `--no-shield` | Skip injecting the runtime integrity and DevTools telemetry preamble | Shield enabled |
|
|
86
|
+
| `--delete-maps` | Permanently delete `.map` files instead of moving them to `.unpeek-maps/` | Moves to `.unpeek-maps/` |
|
|
87
|
+
|
|
88
|
+
### 3. Universal Bundler Plugin (`unpeek/unplugin`)
|
|
89
|
+
|
|
90
|
+
If you prefer in-memory chunk transformations during the bundler lifecycle rather than a post-build CLI pass, use [`unpeek/unplugin`](packages/unpeek/src/bundler/unplugin.ts):
|
|
91
|
+
|
|
92
|
+
#### Vite (`vite.config.ts`)
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { defineConfig } from 'vite';
|
|
96
|
+
import unpeek from 'unpeek/unplugin';
|
|
97
|
+
|
|
98
|
+
export default defineConfig({
|
|
99
|
+
build: {
|
|
100
|
+
sourcemap: true, // Extracted into .unpeek-maps/ automatically
|
|
101
|
+
},
|
|
102
|
+
plugins: [
|
|
103
|
+
unpeek.vite({
|
|
104
|
+
deterrentMode: 'gradual',
|
|
105
|
+
keepPrivateMaps: true,
|
|
106
|
+
}),
|
|
107
|
+
],
|
|
108
|
+
});
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
#### Next.js (`next.config.mjs`)
|
|
112
|
+
|
|
113
|
+
Scope the plugin to client bundles (`!isServer`) so Node.js server chunks keep their native stack traces:
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import unpeek from 'unpeek/unplugin';
|
|
117
|
+
|
|
118
|
+
/** @type {import('next').NextConfig} */
|
|
119
|
+
const nextConfig = {
|
|
120
|
+
webpack(config, { dev, isServer }) {
|
|
121
|
+
if (!dev && !isServer) {
|
|
122
|
+
config.plugins.push(
|
|
123
|
+
unpeek.webpack({
|
|
124
|
+
deterrentMode: 'gradual',
|
|
125
|
+
keepPrivateMaps: true,
|
|
126
|
+
})
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return config;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export default nextConfig;
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
#### Programmatic API
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { transformJavaScript, protectDirectory } from 'unpeek';
|
|
140
|
+
|
|
141
|
+
// Transform an individual JavaScript string in memory
|
|
142
|
+
const result = transformJavaScript(sourceCode, {
|
|
143
|
+
seed: 0x7f3a91,
|
|
144
|
+
minStringLength: 4,
|
|
145
|
+
injectRuntimeShield: true,
|
|
146
|
+
deterrentMode: 'gradual',
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
console.log(result.hotPathReport);
|
|
150
|
+
// { totalFunctions: 12, hotFunctionsSkipped: ['renderLoop'], coldFunctionsProtected: ['signRequest'] }
|
|
151
|
+
|
|
152
|
+
// Or protect an entire build directory
|
|
153
|
+
const summary = await protectDirectory({
|
|
154
|
+
cwd: process.cwd(),
|
|
155
|
+
keepPrivateMaps: true,
|
|
156
|
+
privateMapsDir: '.unpeek-maps',
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## `.unpeek-maps/` Source Map Vault & Sentry Workflow
|
|
163
|
+
|
|
164
|
+
When `keepPrivateMaps` is enabled (the default), `unpeek` relocates all `.js.map` files from your public output directory into `.unpeek-maps/` while preserving their relative filenames, and strips `//# sourceMappingURL=` directives from the public `.js` chunks.
|
|
165
|
+
|
|
166
|
+
To retain full stack-trace symbolication in production error trackers:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# 1. Build your app and run unpeek (automatically triggered if wired via postbuild)
|
|
170
|
+
npm run build
|
|
171
|
+
|
|
172
|
+
# 2. Upload the private source maps from .unpeek-maps/ to Sentry or Datadog
|
|
173
|
+
npx @sentry/cli sourcemaps upload .unpeek-maps --release="$GIT_COMMIT_SHA"
|
|
174
|
+
|
|
175
|
+
# 3. Deploy your public build folder (.next/, dist/, or build/)
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Official AI Agent Skill Installation
|
|
181
|
+
|
|
182
|
+
`unpeek` ships with an official, token-optimized AI Agent Skill ([`skills/unpeek/SKILL.md`](skills/unpeek/SKILL.md)) following Agent Skills Discovery Optimization (SDO) standards. Installing this skill teaches your coding agent (Claude Code, Cursor, Antigravity, OpenAI Codex, or Gemini CLI) how to configure `unpeek`, guard SSR chunks, and wire `.unpeek-maps/` into CI/CD pipelines.
|
|
183
|
+
|
|
184
|
+
### Project-Scoped Installation (Shared with Team via Git)
|
|
185
|
+
|
|
186
|
+
Copy the skill into your repository's `.agents/skills/unpeek/` directory:
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
mkdir -p .agents/skills/unpeek
|
|
190
|
+
curl -fsSL https://raw.githubusercontent.com/Woffluon/unpeek/main/skills/unpeek/SKILL.md \
|
|
191
|
+
-o .agents/skills/unpeek/SKILL.md
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Global Machine Installation (Antigravity / Gemini CLI / Claude Code)
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
# Antigravity & Gemini CLI
|
|
198
|
+
mkdir -p ~/.gemini/config/skills/unpeek
|
|
199
|
+
curl -fsSL https://raw.githubusercontent.com/Woffluon/unpeek/main/skills/unpeek/SKILL.md \
|
|
200
|
+
-o ~/.gemini/config/skills/unpeek/SKILL.md
|
|
201
|
+
|
|
202
|
+
# Cross-agent standard (~/.agents/skills)
|
|
203
|
+
mkdir -p ~/.agents/skills/unpeek
|
|
204
|
+
curl -fsSL https://raw.githubusercontent.com/Woffluon/unpeek/main/skills/unpeek/SKILL.md \
|
|
205
|
+
-o ~/.agents/skills/unpeek/SKILL.md
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
</talimat>
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
<dogrulama>
|
|
213
|
+
|
|
214
|
+
## Verification, Testing & Benchmarks
|
|
215
|
+
|
|
216
|
+
This monorepo is managed with `pnpm` and verified against strict semantic equivalence, deobfuscation resistance, and runtime overhead budgets:
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
# Install workspace dependencies
|
|
220
|
+
pnpm install
|
|
221
|
+
|
|
222
|
+
# Run unit & reverse-engineering resistance test suites (Vitest)
|
|
223
|
+
pnpm test
|
|
224
|
+
|
|
225
|
+
# Run the V8 VM CPU overhead (<2%) & Gzip delta (<15%) benchmark
|
|
226
|
+
pnpm bench
|
|
227
|
+
|
|
228
|
+
# Run strict TypeScript typechecking across packages/unpeek and apps/web
|
|
229
|
+
pnpm typecheck
|
|
230
|
+
|
|
231
|
+
# Build the core package and static interactive web playground
|
|
232
|
+
pnpm build
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Benchmark Output Reference ([`packages/unpeek/benchmarks/overhead.bench.ts`](packages/unpeek/benchmarks/overhead.bench.ts))
|
|
236
|
+
|
|
237
|
+
- **Build / Transform Time**: `< 3000 ms`
|
|
238
|
+
- **Gzip Size Delta**: `< 15%` on production application bundles
|
|
239
|
+
- **Runtime CPU Overhead**: `< 2.00%` (interleaved multi-round V8 JIT benchmark)
|
|
240
|
+
- **Plaintext Leak Rate**: `0%` for string literals meeting `minStringLength`
|
|
241
|
+
|
|
242
|
+
</dogrulama>
|
|
243
|
+
|
|
244
|
+
## License
|
|
245
|
+
|
|
246
|
+
Released under the [MIT License](./LICENSE).
|
package/bin/unpeek.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as acorn from 'acorn';
|
|
2
|
+
import * as walk from 'acorn-walk';
|
|
3
|
+
function createOpaqueGuardStatement(seed, fnIndex) {
|
|
4
|
+
// Deterministic polymorphic constants where ((A ^ B) & 7) === Expected
|
|
5
|
+
const a = ((seed >>> 0) + Math.imul(fnIndex + 1, 0x45d9f3b)) & 0xffff;
|
|
6
|
+
const b = (a ^ 0x1357) & 0xffff;
|
|
7
|
+
const wrongVal = ((a ^ b) & 7) ^ 5; // Guaranteed never equal to ((a ^ b) & 7)
|
|
8
|
+
const guardCode = `if (((${a} ^ ${b}) & 7) === ${wrongVal}) return;`;
|
|
9
|
+
const parsed = acorn.parse(guardCode, {
|
|
10
|
+
ecmaVersion: 'latest',
|
|
11
|
+
allowReturnOutsideFunction: true,
|
|
12
|
+
});
|
|
13
|
+
return parsed.body[0];
|
|
14
|
+
}
|
|
15
|
+
export function applyColdFlowGuard(ast, coldFunctionNodes, seed = 0x9e3779b9) {
|
|
16
|
+
let guardedCount = 0;
|
|
17
|
+
function maybeInjectGuard(node) {
|
|
18
|
+
if (!coldFunctionNodes.has(node))
|
|
19
|
+
return;
|
|
20
|
+
if (!node.body || node.body.type !== 'BlockStatement')
|
|
21
|
+
return;
|
|
22
|
+
guardedCount++;
|
|
23
|
+
const guardStmt = createOpaqueGuardStatement(seed, guardedCount);
|
|
24
|
+
// Insert after any leading 'use strict' directive inside the function body
|
|
25
|
+
let insertIdx = 0;
|
|
26
|
+
while (insertIdx < node.body.body.length &&
|
|
27
|
+
node.body.body[insertIdx].type === 'ExpressionStatement' &&
|
|
28
|
+
node.body.body[insertIdx].directive) {
|
|
29
|
+
insertIdx++;
|
|
30
|
+
}
|
|
31
|
+
node.body.body.splice(insertIdx, 0, guardStmt);
|
|
32
|
+
}
|
|
33
|
+
walk.simple(ast, {
|
|
34
|
+
FunctionDeclaration: maybeInjectGuard,
|
|
35
|
+
FunctionExpression: maybeInjectGuard,
|
|
36
|
+
ArrowFunctionExpression: maybeInjectGuard,
|
|
37
|
+
});
|
|
38
|
+
return guardedCount;
|
|
39
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as acorn from 'acorn';
|
|
2
|
+
import type { HotPathReport } from '../types.js';
|
|
3
|
+
export interface HotPathAnalysis {
|
|
4
|
+
report: HotPathReport;
|
|
5
|
+
hotFunctionNodes: WeakSet<acorn.Node>;
|
|
6
|
+
coldFunctionNodes: WeakSet<acorn.Node>;
|
|
7
|
+
}
|
|
8
|
+
export declare function parseJavaScriptAst(code: string): acorn.Program | null;
|
|
9
|
+
export declare function analyzeAstHotPaths(ast: acorn.Program): HotPathAnalysis;
|
|
10
|
+
export declare function detectHotPaths(code: string): HotPathReport;
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import * as acorn from 'acorn';
|
|
2
|
+
import * as walk from 'acorn-walk';
|
|
3
|
+
const LOOP_NODE_TYPES = new Set([
|
|
4
|
+
'ForStatement',
|
|
5
|
+
'ForInStatement',
|
|
6
|
+
'ForOfStatement',
|
|
7
|
+
'WhileStatement',
|
|
8
|
+
'DoWhileStatement',
|
|
9
|
+
]);
|
|
10
|
+
const ARRAY_HOF_METHODS = new Set([
|
|
11
|
+
'map',
|
|
12
|
+
'filter',
|
|
13
|
+
'reduce',
|
|
14
|
+
'forEach',
|
|
15
|
+
'flatMap',
|
|
16
|
+
'find',
|
|
17
|
+
'some',
|
|
18
|
+
'every',
|
|
19
|
+
]);
|
|
20
|
+
const UI_HOOK_OR_RENDER_CALLS = new Set([
|
|
21
|
+
'useState',
|
|
22
|
+
'useEffect',
|
|
23
|
+
'useMemo',
|
|
24
|
+
'useCallback',
|
|
25
|
+
'useRef',
|
|
26
|
+
'useLayoutEffect',
|
|
27
|
+
'jsx',
|
|
28
|
+
'jsxs',
|
|
29
|
+
'_jsx',
|
|
30
|
+
'_jsxs',
|
|
31
|
+
'createElement',
|
|
32
|
+
'requestAnimationFrame',
|
|
33
|
+
]);
|
|
34
|
+
const FUNCTION_NODE_TYPES = new Set([
|
|
35
|
+
'FunctionDeclaration',
|
|
36
|
+
'FunctionExpression',
|
|
37
|
+
'ArrowFunctionExpression',
|
|
38
|
+
]);
|
|
39
|
+
export function parseJavaScriptAst(code) {
|
|
40
|
+
try {
|
|
41
|
+
return acorn.parse(code, {
|
|
42
|
+
ecmaVersion: 'latest',
|
|
43
|
+
sourceType: 'module',
|
|
44
|
+
allowReturnOutsideFunction: true,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
try {
|
|
49
|
+
return acorn.parse(code, {
|
|
50
|
+
ecmaVersion: 'latest',
|
|
51
|
+
sourceType: 'script',
|
|
52
|
+
allowReturnOutsideFunction: true,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function getFunctionLabel(node, ancestors) {
|
|
61
|
+
if (node.id && typeof node.id.name === 'string') {
|
|
62
|
+
return node.id.name;
|
|
63
|
+
}
|
|
64
|
+
const parent = ancestors[ancestors.length - 2];
|
|
65
|
+
if (parent) {
|
|
66
|
+
if (parent.type === 'VariableDeclarator' && parent.id?.type === 'Identifier') {
|
|
67
|
+
return parent.id.name;
|
|
68
|
+
}
|
|
69
|
+
if (parent.type === 'Property' && parent.key?.type === 'Identifier') {
|
|
70
|
+
return parent.key.name;
|
|
71
|
+
}
|
|
72
|
+
if (parent.type === 'MethodDefinition' && parent.key?.type === 'Identifier') {
|
|
73
|
+
return parent.key.name;
|
|
74
|
+
}
|
|
75
|
+
if (parent.type === 'AssignmentExpression' &&
|
|
76
|
+
parent.left?.type === 'Identifier') {
|
|
77
|
+
return parent.left.name;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return `anonymous_${node.start}`;
|
|
81
|
+
}
|
|
82
|
+
function isPascalCaseComponent(name) {
|
|
83
|
+
return /^[A-Z][a-zA-Z0-9]*$/.test(name);
|
|
84
|
+
}
|
|
85
|
+
export function analyzeAstHotPaths(ast) {
|
|
86
|
+
const hotFunctionNodes = new WeakSet();
|
|
87
|
+
const coldFunctionNodes = new WeakSet();
|
|
88
|
+
const allFunctions = [];
|
|
89
|
+
const fnByName = new Map();
|
|
90
|
+
const callerToCallees = new Map();
|
|
91
|
+
walk.ancestor(ast, {
|
|
92
|
+
FunctionDeclaration(node, _state, ancestors) {
|
|
93
|
+
const name = getFunctionLabel(node, ancestors);
|
|
94
|
+
allFunctions.push({ node, name });
|
|
95
|
+
fnByName.set(name, node);
|
|
96
|
+
if (isPascalCaseComponent(name)) {
|
|
97
|
+
hotFunctionNodes.add(node);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
FunctionExpression(node, _state, ancestors) {
|
|
101
|
+
const name = getFunctionLabel(node, ancestors);
|
|
102
|
+
allFunctions.push({ node, name });
|
|
103
|
+
fnByName.set(name, node);
|
|
104
|
+
if (isPascalCaseComponent(name)) {
|
|
105
|
+
hotFunctionNodes.add(node);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
ArrowFunctionExpression(node, _state, ancestors) {
|
|
109
|
+
const name = getFunctionLabel(node, ancestors);
|
|
110
|
+
allFunctions.push({ node, name });
|
|
111
|
+
fnByName.set(name, node);
|
|
112
|
+
if (isPascalCaseComponent(name)) {
|
|
113
|
+
hotFunctionNodes.add(node);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
CallExpression(node, _state, ancestors) {
|
|
117
|
+
const callee = node.callee;
|
|
118
|
+
let calleeName = '';
|
|
119
|
+
let memberProp = '';
|
|
120
|
+
if (callee?.type === 'Identifier') {
|
|
121
|
+
calleeName = callee.name;
|
|
122
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
123
|
+
if (FUNCTION_NODE_TYPES.has(ancestors[i].type)) {
|
|
124
|
+
let set = callerToCallees.get(ancestors[i]);
|
|
125
|
+
if (!set) {
|
|
126
|
+
set = new Set();
|
|
127
|
+
callerToCallees.set(ancestors[i], set);
|
|
128
|
+
}
|
|
129
|
+
set.add(calleeName);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (callee?.type === 'MemberExpression' &&
|
|
135
|
+
!callee.computed &&
|
|
136
|
+
callee.property?.type === 'Identifier') {
|
|
137
|
+
memberProp = callee.property.name;
|
|
138
|
+
}
|
|
139
|
+
// Check if arguments are hot callbacks (.map, .filter, .reduce, .forEach, .flatMap, requestAnimationFrame)
|
|
140
|
+
if (ARRAY_HOF_METHODS.has(memberProp) ||
|
|
141
|
+
calleeName === 'requestAnimationFrame' ||
|
|
142
|
+
memberProp === 'requestAnimationFrame') {
|
|
143
|
+
for (const arg of node.arguments || []) {
|
|
144
|
+
if (FUNCTION_NODE_TYPES.has(arg.type)) {
|
|
145
|
+
hotFunctionNodes.add(arg);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// Check if enclosing function calls React hooks / createElement / jsx
|
|
150
|
+
if (UI_HOOK_OR_RENDER_CALLS.has(calleeName) ||
|
|
151
|
+
UI_HOOK_OR_RENDER_CALLS.has(memberProp)) {
|
|
152
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
153
|
+
if (FUNCTION_NODE_TYPES.has(ancestors[i].type)) {
|
|
154
|
+
hotFunctionNodes.add(ancestors[i]);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
// Mark functions containing any loop statement as hot
|
|
162
|
+
walk.ancestor(ast, {
|
|
163
|
+
ForStatement(_node, _state, ancestors) {
|
|
164
|
+
markEnclosingFunctionHot(ancestors, hotFunctionNodes);
|
|
165
|
+
},
|
|
166
|
+
ForInStatement(_node, _state, ancestors) {
|
|
167
|
+
markEnclosingFunctionHot(ancestors, hotFunctionNodes);
|
|
168
|
+
},
|
|
169
|
+
ForOfStatement(_node, _state, ancestors) {
|
|
170
|
+
markEnclosingFunctionHot(ancestors, hotFunctionNodes);
|
|
171
|
+
},
|
|
172
|
+
WhileStatement(_node, _state, ancestors) {
|
|
173
|
+
markEnclosingFunctionHot(ancestors, hotFunctionNodes);
|
|
174
|
+
},
|
|
175
|
+
DoWhileStatement(_node, _state, ancestors) {
|
|
176
|
+
markEnclosingFunctionHot(ancestors, hotFunctionNodes);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
// Propagate hotness bottom-up along the static call graph (caller -> hot callee)
|
|
180
|
+
let changed = true;
|
|
181
|
+
while (changed) {
|
|
182
|
+
changed = false;
|
|
183
|
+
for (const [callerNode, calleeNames] of callerToCallees.entries()) {
|
|
184
|
+
if (hotFunctionNodes.has(callerNode))
|
|
185
|
+
continue;
|
|
186
|
+
for (const cName of calleeNames) {
|
|
187
|
+
const targetNode = fnByName.get(cName);
|
|
188
|
+
if (targetNode && hotFunctionNodes.has(targetNode)) {
|
|
189
|
+
hotFunctionNodes.add(callerNode);
|
|
190
|
+
changed = true;
|
|
191
|
+
break;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
const hotFunctionsSkipped = [];
|
|
197
|
+
const coldFunctionsProtected = [];
|
|
198
|
+
for (const fn of allFunctions) {
|
|
199
|
+
if (hotFunctionNodes.has(fn.node)) {
|
|
200
|
+
hotFunctionsSkipped.push(fn.name);
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
coldFunctionNodes.add(fn.node);
|
|
204
|
+
coldFunctionsProtected.push(fn.name);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
report: {
|
|
209
|
+
totalFunctions: allFunctions.length,
|
|
210
|
+
hotFunctionsSkipped,
|
|
211
|
+
coldFunctionsProtected,
|
|
212
|
+
},
|
|
213
|
+
hotFunctionNodes,
|
|
214
|
+
coldFunctionNodes,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function markEnclosingFunctionHot(ancestors, hotSet) {
|
|
218
|
+
for (let i = ancestors.length - 1; i >= 0; i--) {
|
|
219
|
+
if (FUNCTION_NODE_TYPES.has(ancestors[i].type)) {
|
|
220
|
+
hotSet.add(ancestors[i]);
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
export function detectHotPaths(code) {
|
|
226
|
+
const ast = parseJavaScriptAst(code);
|
|
227
|
+
if (!ast) {
|
|
228
|
+
return {
|
|
229
|
+
totalFunctions: 0,
|
|
230
|
+
hotFunctionsSkipped: [],
|
|
231
|
+
coldFunctionsProtected: [],
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
return analyzeAstHotPaths(ast).report;
|
|
235
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { TransformOptions, TransformResult } from '../types.js';
|
|
2
|
+
export { detectHotPaths } from './hot-path-detector.js';
|
|
3
|
+
export { computeFnv1aHash } from './string-vault.js';
|
|
4
|
+
export declare function transformJavaScript(code: string, options?: TransformOptions): TransformResult;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { generate } from 'astring';
|
|
2
|
+
import { analyzeAstHotPaths, parseJavaScriptAst, } from './hot-path-detector.js';
|
|
3
|
+
import { applyStringVault, computeFnv1aHash } from './string-vault.js';
|
|
4
|
+
import { applyPolymorphicMangler } from './polymorphic-mangler.js';
|
|
5
|
+
import { applyColdFlowGuard } from './cold-flow-guard.js';
|
|
6
|
+
export { detectHotPaths } from './hot-path-detector.js';
|
|
7
|
+
export { computeFnv1aHash } from './string-vault.js';
|
|
8
|
+
export function transformJavaScript(code, options = {}) {
|
|
9
|
+
const seed = options.seed ?? 0x9e3779b9;
|
|
10
|
+
const minStringLength = options.minStringLength ?? 4;
|
|
11
|
+
if (!code || !code.trim()) {
|
|
12
|
+
return {
|
|
13
|
+
code,
|
|
14
|
+
encryptedStringsCount: 0,
|
|
15
|
+
mangledSymbolsCount: 0,
|
|
16
|
+
hotPathReport: {
|
|
17
|
+
totalFunctions: 0,
|
|
18
|
+
hotFunctionsSkipped: [],
|
|
19
|
+
coldFunctionsProtected: [],
|
|
20
|
+
},
|
|
21
|
+
integrityToken: computeFnv1aHash('', seed),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
const ast = parseJavaScriptAst(code);
|
|
25
|
+
if (!ast) {
|
|
26
|
+
return {
|
|
27
|
+
code,
|
|
28
|
+
encryptedStringsCount: 0,
|
|
29
|
+
mangledSymbolsCount: 0,
|
|
30
|
+
hotPathReport: {
|
|
31
|
+
totalFunctions: 0,
|
|
32
|
+
hotFunctionsSkipped: [],
|
|
33
|
+
coldFunctionsProtected: [],
|
|
34
|
+
},
|
|
35
|
+
integrityToken: computeFnv1aHash(code, seed),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// 1. Detect hot paths (loops, array HOF callbacks, rAF, UI components)
|
|
39
|
+
const { report: hotPathReport, coldFunctionNodes } = analyzeAstHotPaths(ast);
|
|
40
|
+
// 2. Inject polymorphic opaque guards only on cold functions (<2% runtime CPU budget)
|
|
41
|
+
applyColdFlowGuard(ast, coldFunctionNodes, seed);
|
|
42
|
+
// 3. Mangle local parameters & function-scoped variables safely
|
|
43
|
+
const mangledSymbolsCount = applyPolymorphicMangler(ast, seed);
|
|
44
|
+
// 4. Encrypt eligible string literals into an O(1) lazy inline-cached vault
|
|
45
|
+
const { encryptedStringsCount, integrityToken } = applyStringVault(ast, seed, minStringLength);
|
|
46
|
+
// 5. Generate compact single-line JavaScript output
|
|
47
|
+
const outputCode = generate(ast, { indent: '', lineEnd: '' });
|
|
48
|
+
return {
|
|
49
|
+
code: outputCode,
|
|
50
|
+
encryptedStringsCount,
|
|
51
|
+
mangledSymbolsCount,
|
|
52
|
+
hotPathReport,
|
|
53
|
+
integrityToken,
|
|
54
|
+
};
|
|
55
|
+
}
|