@ecopages/react 0.2.0-alpha.9 → 0.2.1
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/CHANGELOG.md +13 -11
- package/README.md +10 -0
- package/package.json +6 -6
- package/src/react-hmr-strategy.d.ts +4 -2
- package/src/react-hmr-strategy.js +36 -3
- package/src/react-renderer.d.ts +25 -37
- package/src/react-renderer.js +190 -142
- package/src/react.plugin.d.ts +0 -12
- package/src/react.plugin.js +2 -13
- package/src/services/react-bundle.service.d.ts +3 -1
- package/src/services/react-bundle.service.js +20 -2
- package/src/services/react-hmr-page-metadata-cache.d.ts +9 -0
- package/src/services/react-hmr-page-metadata-cache.js +18 -2
- package/src/services/react-hydration-asset.service.d.ts +7 -6
- package/src/services/react-hydration-asset.service.js +26 -14
- package/src/services/react-page-module.service.js +5 -2
- package/src/services/react-runtime-bundle.service.d.ts +2 -0
- package/src/services/react-runtime-bundle.service.js +5 -0
- package/src/utils/client-graph-boundary-plugin.js +2 -2
- package/src/utils/declared-modules.js +4 -1
- package/src/utils/hydration-scripts.d.ts +1 -3
- package/src/utils/hydration-scripts.js +31 -19
- package/src/react-hmr-strategy.ts +0 -386
- package/src/react-renderer.ts +0 -803
- package/src/react.plugin.ts +0 -276
- package/src/router-adapter.ts +0 -95
- package/src/services/react-bundle.service.ts +0 -108
- package/src/services/react-hmr-page-metadata-cache.ts +0 -24
- package/src/services/react-hydration-asset.service.ts +0 -263
- package/src/services/react-page-module.service.ts +0 -224
- package/src/services/react-runtime-bundle.service.ts +0 -172
- package/src/utils/client-graph-boundary-plugin.ts +0 -831
- package/src/utils/client-only.ts +0 -27
- package/src/utils/declared-modules.ts +0 -99
- package/src/utils/dynamic.ts +0 -27
- package/src/utils/hmr-scripts.ts +0 -47
- package/src/utils/html-boundary.ts +0 -66
- package/src/utils/hydration-scripts.ts +0 -459
- package/src/utils/reachability-analyzer.ts +0 -593
- package/src/utils/react-dom-runtime-interop-plugin.ts +0 -33
- package/src/utils/react-mdx-loader-plugin.ts +0 -63
- package/src/utils/react-runtime-specifier-map.ts +0 -45
- package/src/utils/use-sync-external-store-shim-plugin.ts +0 -45
|
@@ -1,831 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module ClientGraphBoundaryPlugin
|
|
3
|
-
*
|
|
4
|
-
* This module defines the primary esbuild plugin responsible for securing the Ecopages
|
|
5
|
-
* isomorphic compilation pipeline. It ensures that backend-only code, sensitive Node.js APIs,
|
|
6
|
-
* and massive server utilities do not accidentally leak into the browser bundle.
|
|
7
|
-
*
|
|
8
|
-
* It achieves this by intercepting all client module compilation passes and applying the
|
|
9
|
-
* `analyzeReachability` AST pass. If a forbidden import (e.g. `node:fs` or `*.server.ts`)
|
|
10
|
-
* is completely unreachable from the client component's `render` function, it is surgically
|
|
11
|
-
* pruned. If a forbidden import IS reachable, the build is intentionally failed to prevent
|
|
12
|
-
* runtime hydration crashes.
|
|
13
|
-
*
|
|
14
|
-
* Additionally, this plugin provides a build-time transform that statically resolves and
|
|
15
|
-
* inlines `fs.readFileSync(path.resolve(...))` calls to prevent server/client data mismatches.
|
|
16
|
-
*/
|
|
17
|
-
|
|
18
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
-
import { dirname, extname, resolve } from 'node:path';
|
|
20
|
-
import type { EcoBuildPlugin } from '@ecopages/core/build/build-types';
|
|
21
|
-
import { parseSync } from 'oxc-parser';
|
|
22
|
-
import { analyzeReachability } from './reachability-analyzer';
|
|
23
|
-
|
|
24
|
-
const SOURCE_FILE_FILTER = /\.(tsx?|jsx?)$/;
|
|
25
|
-
const SERVER_ONLY_ECO_PAGE_OPTION_KEYS = new Set([
|
|
26
|
-
'cache',
|
|
27
|
-
'middleware',
|
|
28
|
-
'requires',
|
|
29
|
-
'metadata',
|
|
30
|
-
'staticProps',
|
|
31
|
-
'staticPaths',
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Configuration options for the Client Graph Boundary esbuild plugin.
|
|
36
|
-
*
|
|
37
|
-
* This plugin serves as the primary security layer between server-only logic and the client-side JavaScript bundle.
|
|
38
|
-
* It prevents Node.js built-ins (`node:fs`, `node:path`) and backend-exclusive dependencies (e.g. `pg`, `redis`)
|
|
39
|
-
* from accidentally leaking into the browser compilation step, which would cause immediate crashes.
|
|
40
|
-
*/
|
|
41
|
-
type ClientGraphBoundaryOptions = {
|
|
42
|
-
/** Absolute path to the current working directory, used as a root fallback for resolving inline file reads. */
|
|
43
|
-
absWorkingDir?: string;
|
|
44
|
-
/**
|
|
45
|
-
* Array of module specifiers that are explicitly whitelisted to be bundled in the client code.
|
|
46
|
-
* This is typically populated by parsing `modules: ["..."]` declarations in React/Lit components.
|
|
47
|
-
*/
|
|
48
|
-
declaredModules?: string[];
|
|
49
|
-
/** Array of emergency escape-hatch specifiers that always bypass the boundary checks regardless of component declarations. */
|
|
50
|
-
alwaysAllowSpecifiers?: string[];
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Evaluates whether a module import is referencing an external package dependency
|
|
55
|
-
* (e.g., `react` or `lodash`) as opposed to a local internal file (e.g., `./component` or `/absolute/path`).
|
|
56
|
-
*
|
|
57
|
-
* This is a critical building block for the graph boundary. We only want to restrict
|
|
58
|
-
* specific external dependencies (like server-only utilities) from entering the client bundle,
|
|
59
|
-
* while allowing all normal local relative UI component imports to flow through Esbuild safely.
|
|
60
|
-
*
|
|
61
|
-
* @param specifier - The raw import string found in the source code (e.g., `./Button.tsx` or `node:fs`)
|
|
62
|
-
* @returns True if the import string refers to a top-level package or Node built-in.
|
|
63
|
-
*/
|
|
64
|
-
function isBareSpecifier(specifier: string): boolean {
|
|
65
|
-
if (specifier.startsWith('.')) return false;
|
|
66
|
-
if (specifier.startsWith('/')) return false;
|
|
67
|
-
if (specifier.includes('://')) return false;
|
|
68
|
-
return true;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function isProjectAliasSpecifier(specifier: string): boolean {
|
|
72
|
-
return specifier.startsWith('@/') || specifier.startsWith('~/') || specifier.startsWith('ecopages:');
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Determines whether a specifier should be treated as server-only.
|
|
77
|
-
*
|
|
78
|
-
* This covers Node built-ins as well as local module conventions such as
|
|
79
|
-
* `.server.ts` and extensionless imports that resolve to `.server.*` files.
|
|
80
|
-
*
|
|
81
|
-
* @param specifier - Raw import specifier from the module source.
|
|
82
|
-
* @returns True when the import must never become client-reachable.
|
|
83
|
-
*/
|
|
84
|
-
function isServerOnlySpecifier(specifier: string): boolean {
|
|
85
|
-
if (specifier.startsWith('node:')) return true;
|
|
86
|
-
return /(?:^|[/])[^/]+\.server(?:$|\.)/.test(specifier);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Strips down a deep path module specifier to its foundational root package name.
|
|
91
|
-
*
|
|
92
|
-
* When checking against the "allowed modules" whitelist, a component might import something deeply
|
|
93
|
-
* nested like `lodash/fp/map` or `@myorg/ui/button`. However, the user configuration only whitelists
|
|
94
|
-
* the root `lodash` or `@myorg/ui` package. This normalizer ensures we are comparing apples to apples
|
|
95
|
-
* by extracting the base package scope before checking the authorization list.
|
|
96
|
-
*
|
|
97
|
-
* @example
|
|
98
|
-
* toModuleBaseSpecifier('@scope/package/deep/file') -> '@scope/package'
|
|
99
|
-
* toModuleBaseSpecifier('lodash/cloneDeep') -> 'lodash'
|
|
100
|
-
* toModuleBaseSpecifier('node:fs') -> 'node:fs'
|
|
101
|
-
*
|
|
102
|
-
* @param specifier - The raw import specifier from the code.
|
|
103
|
-
* @returns The root package name, preserving scoped npm organizations.
|
|
104
|
-
*/
|
|
105
|
-
function toModuleBaseSpecifier(specifier: string): string {
|
|
106
|
-
if (!isBareSpecifier(specifier) || specifier.startsWith('node:')) {
|
|
107
|
-
return specifier;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (specifier.startsWith('@')) {
|
|
111
|
-
const [scope, name] = specifier.split('/');
|
|
112
|
-
if (!scope || !name) return specifier;
|
|
113
|
-
return `${scope}/${name}`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const [name] = specifier.split('/');
|
|
117
|
-
return name ?? specifier;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Parses the grammar syntax of declared modules.
|
|
122
|
-
* Handles patterns like `@pkg/name` and `@pkg/name{namedImport,anotherImport}`
|
|
123
|
-
* returning a map of base packages to their explicitly allowed specifiers.
|
|
124
|
-
*
|
|
125
|
-
* @param moduleDeclarations - A list of module declaration strings.
|
|
126
|
-
* @returns A structured map of allowed packages and their named exports.
|
|
127
|
-
*/
|
|
128
|
-
function parseDeclaredModules(moduleDeclarations: string[] | undefined): Map<string, Set<string> | '*'> {
|
|
129
|
-
const map = new Map<string, Set<string> | '*'>();
|
|
130
|
-
for (const declaration of moduleDeclarations ?? []) {
|
|
131
|
-
const source = declaration.trim();
|
|
132
|
-
if (source.length === 0) continue;
|
|
133
|
-
const openBraceIndex = source.indexOf('{');
|
|
134
|
-
if (openBraceIndex < 0) {
|
|
135
|
-
map.set(toModuleBaseSpecifier(source), '*');
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const closeBraceIndex = source.indexOf('}', openBraceIndex);
|
|
140
|
-
const rawPkg = source.slice(0, openBraceIndex).trim();
|
|
141
|
-
if (rawPkg.length === 0) continue;
|
|
142
|
-
const pkg = toModuleBaseSpecifier(rawPkg);
|
|
143
|
-
|
|
144
|
-
const namedImportsStr =
|
|
145
|
-
closeBraceIndex > openBraceIndex
|
|
146
|
-
? source.slice(openBraceIndex + 1, closeBraceIndex)
|
|
147
|
-
: source.slice(openBraceIndex + 1);
|
|
148
|
-
|
|
149
|
-
const namedImports = namedImportsStr
|
|
150
|
-
.split(',')
|
|
151
|
-
.map((s) => s.trim())
|
|
152
|
-
.filter(Boolean);
|
|
153
|
-
|
|
154
|
-
const existing = map.get(pkg);
|
|
155
|
-
if (existing === '*') continue;
|
|
156
|
-
|
|
157
|
-
if (!existing) {
|
|
158
|
-
if (namedImports.length === 0) {
|
|
159
|
-
map.set(pkg, '*');
|
|
160
|
-
} else {
|
|
161
|
-
map.set(pkg, new Set(namedImports));
|
|
162
|
-
}
|
|
163
|
-
} else {
|
|
164
|
-
for (const name of namedImports) {
|
|
165
|
-
existing.add(name);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
return map;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Merges two declared module maps, combining their allowable specific scopes.
|
|
174
|
-
*
|
|
175
|
-
* @param a - First map of declared modules.
|
|
176
|
-
* @param b - Second map of declared modules to merge into the first.
|
|
177
|
-
* @returns A unified map of both declarations.
|
|
178
|
-
*/
|
|
179
|
-
function mergeDeclaredModulesMap(
|
|
180
|
-
a: Map<string, Set<string> | '*'>,
|
|
181
|
-
b: Map<string, Set<string> | '*'>,
|
|
182
|
-
): Map<string, Set<string> | '*'> {
|
|
183
|
-
const result = new Map(a);
|
|
184
|
-
for (const [pkg, imports] of b.entries()) {
|
|
185
|
-
const existing = result.get(pkg);
|
|
186
|
-
if (existing === '*') continue;
|
|
187
|
-
if (imports === '*') {
|
|
188
|
-
result.set(pkg, '*');
|
|
189
|
-
continue;
|
|
190
|
-
}
|
|
191
|
-
if (!existing) {
|
|
192
|
-
result.set(pkg, imports);
|
|
193
|
-
} else {
|
|
194
|
-
for (const name of imports) {
|
|
195
|
-
existing.add(name);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
return result;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Returns the proper OxC parser dialect to use for string source parsing.
|
|
204
|
-
*
|
|
205
|
-
* @param filename - File path.
|
|
206
|
-
* @returns Language string.
|
|
207
|
-
*/
|
|
208
|
-
function parserLanguageForFile(filename: string): 'js' | 'jsx' | 'ts' | 'tsx' {
|
|
209
|
-
const extension = extname(filename).toLowerCase();
|
|
210
|
-
if (extension === '.tsx') return 'tsx';
|
|
211
|
-
if (extension === '.ts') return 'ts';
|
|
212
|
-
if (extension === '.jsx') return 'jsx';
|
|
213
|
-
return 'js';
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Extracts a static property key name from an object literal property node.
|
|
218
|
-
*
|
|
219
|
-
* The client graph boundary rewrite only strips known `eco.page(...)` keys when
|
|
220
|
-
* it can prove the property name statically. Computed or otherwise dynamic keys
|
|
221
|
-
* are ignored so the transform remains conservative.
|
|
222
|
-
*
|
|
223
|
-
* @param node - OXC AST node representing an object property key.
|
|
224
|
-
* @returns Static property key name when it can be resolved, otherwise `undefined`.
|
|
225
|
-
*/
|
|
226
|
-
function getObjectPropertyKeyName(node: any): string | undefined {
|
|
227
|
-
if (!node) return undefined;
|
|
228
|
-
if (node.type === 'Identifier') return node.name;
|
|
229
|
-
if (node.type === 'StringLiteral' || node.type === 'Literal') {
|
|
230
|
-
return typeof node.value === 'string' ? node.value : undefined;
|
|
231
|
-
}
|
|
232
|
-
return undefined;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Removes server-only `eco.page(...)` options from browser-bound modules.
|
|
237
|
-
*
|
|
238
|
-
* Import pruning alone is not sufficient because a page module can still retain
|
|
239
|
-
* references to stripped server imports through config fields like `middleware`
|
|
240
|
-
* or `metadata`. This pass rewrites the `eco.page(...)` object literal so only
|
|
241
|
-
* browser-relevant properties remain.
|
|
242
|
-
*
|
|
243
|
-
* @param source - Original or already-transformed module source.
|
|
244
|
-
* @param program - Parsed OXC program for the same source text.
|
|
245
|
-
* @returns Updated source plus a flag indicating whether any rewrite occurred.
|
|
246
|
-
*/
|
|
247
|
-
function stripServerOnlyEcoPageOptions(source: string, program: any): { transformed: string; modified: boolean } {
|
|
248
|
-
const edits: { start: number; end: number; replacement: string }[] = [];
|
|
249
|
-
|
|
250
|
-
function walk(node: any) {
|
|
251
|
-
if (!node || typeof node !== 'object') return;
|
|
252
|
-
if (Array.isArray(node)) {
|
|
253
|
-
for (const child of node) walk(child);
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
if (
|
|
258
|
-
node.type === 'CallExpression' &&
|
|
259
|
-
node.callee?.type === 'MemberExpression' &&
|
|
260
|
-
node.callee.object?.type === 'Identifier' &&
|
|
261
|
-
node.callee.object.name === 'eco' &&
|
|
262
|
-
node.callee.property?.type === 'Identifier' &&
|
|
263
|
-
node.callee.property.name === 'page' &&
|
|
264
|
-
node.arguments?.[0]?.type === 'ObjectExpression'
|
|
265
|
-
) {
|
|
266
|
-
const objectExpression = node.arguments[0];
|
|
267
|
-
const keptProperties: string[] = [];
|
|
268
|
-
let removedProperty = false;
|
|
269
|
-
|
|
270
|
-
for (const property of objectExpression.properties ?? []) {
|
|
271
|
-
if (property?.type === 'Property') {
|
|
272
|
-
const keyName = getObjectPropertyKeyName(property.key);
|
|
273
|
-
if (keyName && SERVER_ONLY_ECO_PAGE_OPTION_KEYS.has(keyName)) {
|
|
274
|
-
removedProperty = true;
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
keptProperties.push(source.slice(property.start, property.end));
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
if (removedProperty) {
|
|
283
|
-
const replacement = keptProperties.length > 0 ? `{ ${keptProperties.join(', ')} }` : '{}';
|
|
284
|
-
edits.push({
|
|
285
|
-
start: objectExpression.start,
|
|
286
|
-
end: objectExpression.end,
|
|
287
|
-
replacement,
|
|
288
|
-
});
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
for (const key in node) {
|
|
293
|
-
if (key !== 'type' && key !== 'start' && key !== 'end') {
|
|
294
|
-
walk(node[key]);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
walk(program);
|
|
300
|
-
|
|
301
|
-
if (edits.length === 0) {
|
|
302
|
-
return { transformed: source, modified: false };
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
edits.sort((a, b) => b.start - a.start);
|
|
306
|
-
let transformed = source;
|
|
307
|
-
for (const edit of edits) {
|
|
308
|
-
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
return { transformed, modified: true };
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Tracks the subset of exports that a downstream local module is allowed to expose.
|
|
316
|
-
*
|
|
317
|
-
* `'*'` means the full module namespace is reachable, while a `Set` limits the
|
|
318
|
-
* consumer to specific named exports.
|
|
319
|
-
*/
|
|
320
|
-
type RequestedExportRules = Set<string> | '*';
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Normalizes a file path into a registry key used for requested-export propagation.
|
|
324
|
-
*
|
|
325
|
-
* The normalization strips JS/TS extensions and collapses `/index` suffixes so
|
|
326
|
-
* equivalent local import forms resolve to the same key.
|
|
327
|
-
*
|
|
328
|
-
* @param pathname - Absolute or resolved local module path.
|
|
329
|
-
* @returns Stable registry key for `requestedExports`.
|
|
330
|
-
*/
|
|
331
|
-
function normalizeRequestedExportsKey(pathname: string): string {
|
|
332
|
-
let normalized = pathname.replace(/\\/g, '/');
|
|
333
|
-
normalized = normalized.replace(/\.(tsx?|jsx?)$/i, '');
|
|
334
|
-
if (normalized.endsWith('/index')) {
|
|
335
|
-
normalized = normalized.slice(0, -'/index'.length);
|
|
336
|
-
}
|
|
337
|
-
return normalized;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* Resolves a local import specifier into a requested-export registry key.
|
|
342
|
-
*
|
|
343
|
-
* Bare package specifiers and project aliases are intentionally ignored because
|
|
344
|
-
* requested-export propagation is only used for cross-file local reachability.
|
|
345
|
-
*
|
|
346
|
-
* @param importer - Absolute path of the importing module.
|
|
347
|
-
* @param specifier - Raw import or re-export specifier.
|
|
348
|
-
* @returns Registry key for a local dependency, or `undefined` when not applicable.
|
|
349
|
-
*/
|
|
350
|
-
function resolveRequestedExportsKey(importer: string, specifier: string): string | undefined {
|
|
351
|
-
if (isBareSpecifier(specifier) || isProjectAliasSpecifier(specifier)) {
|
|
352
|
-
return undefined;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
const resolved = specifier.startsWith('/') ? specifier : resolve(dirname(importer), specifier);
|
|
356
|
-
return normalizeRequestedExportsKey(resolved);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Merges newly discovered requested-export rules into the local propagation registry.
|
|
361
|
-
*
|
|
362
|
-
* Once a module is promoted to `'*'`, it stays fully reachable for the remainder
|
|
363
|
-
* of the transform pass.
|
|
364
|
-
*
|
|
365
|
-
* @param registry - Cross-module requested-export registry.
|
|
366
|
-
* @param moduleKey - Normalized local module key.
|
|
367
|
-
* @param rules - Newly observed reachable export rules for the module.
|
|
368
|
-
*/
|
|
369
|
-
function mergeRequestedExportRules(
|
|
370
|
-
registry: Map<string, RequestedExportRules>,
|
|
371
|
-
moduleKey: string,
|
|
372
|
-
rules: Set<string> | '*',
|
|
373
|
-
) {
|
|
374
|
-
const existing = registry.get(moduleKey);
|
|
375
|
-
if (existing === '*') return;
|
|
376
|
-
if (rules === '*') {
|
|
377
|
-
registry.set(moduleKey, '*');
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
if (!existing) {
|
|
381
|
-
registry.set(moduleKey, new Set(rules));
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
for (const rule of rules) {
|
|
385
|
-
existing.add(rule);
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Parses a module using Oxc AST and surgically removes forbidden imports.
|
|
391
|
-
* Filters down to the exact specifiers requested via `{namedImport}` syntax.
|
|
392
|
-
*
|
|
393
|
-
* @param source - The raw string source content of the module.
|
|
394
|
-
* @param filename - The absolute path of the module.
|
|
395
|
-
* @param globallyAllowed - A map of modules declared globally allowable by the build configuration.
|
|
396
|
-
* @param requestedExports - Local requested-export registry used to propagate named reachability across files.
|
|
397
|
-
* @returns An object containing the transformed string and a boolean indicating if changes occurred.
|
|
398
|
-
*/
|
|
399
|
-
function transformModuleImports(
|
|
400
|
-
source: string,
|
|
401
|
-
filename: string,
|
|
402
|
-
globallyAllowed: Map<string, Set<string> | '*'>,
|
|
403
|
-
requestedExports: Map<string, RequestedExportRules>,
|
|
404
|
-
): { transformed: string; modified: boolean } {
|
|
405
|
-
/**
|
|
406
|
-
* Parse the source
|
|
407
|
-
*
|
|
408
|
-
* We parse once here and then reuse the resulting `program` AST for both
|
|
409
|
-
* the local `modules` declaration walk (step 2) and the reachability analysis
|
|
410
|
-
* (step 3). Passing it through avoids a redundant second `parseSync` call inside
|
|
411
|
-
* `analyzeReachability`, cutting the per-file OxC work roughly in half.
|
|
412
|
-
*/
|
|
413
|
-
let result;
|
|
414
|
-
try {
|
|
415
|
-
result = parseSync(filename, source, {
|
|
416
|
-
sourceType: 'module',
|
|
417
|
-
lang: parserLanguageForFile(filename),
|
|
418
|
-
});
|
|
419
|
-
} catch {
|
|
420
|
-
return { transformed: source, modified: false };
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const { program } = result;
|
|
424
|
-
const localDeclared: string[] = [];
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Collect locally declared modules
|
|
428
|
-
*
|
|
429
|
-
* Walk the AST looking for `modules: [...]` array properties. These are the
|
|
430
|
-
* component-level allowlist declarations that a developer writes inside
|
|
431
|
-
* `eco.page({ modules: ['react', '@myorg/ui{Button}'] })` to explicitly opt
|
|
432
|
-
* specific packages into the client bundle.
|
|
433
|
-
*
|
|
434
|
-
* The collected specifiers are merged with the globally configured allowlist
|
|
435
|
-
* before any import filtering takes place.
|
|
436
|
-
*/
|
|
437
|
-
|
|
438
|
-
function walk(node: any) {
|
|
439
|
-
if (!node || typeof node !== 'object') return;
|
|
440
|
-
if (Array.isArray(node)) {
|
|
441
|
-
for (const child of node) walk(child);
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
if (node.type === 'Property' && node.key?.name === 'modules' && node.value?.type === 'ArrayExpression') {
|
|
446
|
-
for (const el of node.value.elements) {
|
|
447
|
-
if ((el.type === 'StringLiteral' || el.type === 'Literal') && typeof el.value === 'string') {
|
|
448
|
-
localDeclared.push(el.value);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
for (const key in node) {
|
|
454
|
-
if (key !== 'type' && key !== 'start' && key !== 'end') {
|
|
455
|
-
walk(node[key]);
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
walk(program);
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Merge allowlists and compute reachability
|
|
464
|
-
*
|
|
465
|
-
* Combine the globally declared modules (from plugin config) with the locally
|
|
466
|
-
* declared ones (from the component's `modules` array) into a single authoritative map.
|
|
467
|
-
*
|
|
468
|
-
* Then run the reachability analysis, passing the already-parsed `program` so the
|
|
469
|
-
* analyser skips its own internal `parseSync` call (see step 1 above).
|
|
470
|
-
*/
|
|
471
|
-
const locallyAllowed = parseDeclaredModules(localDeclared);
|
|
472
|
-
const allowedMap = mergeDeclaredModulesMap(globallyAllowed, locallyAllowed);
|
|
473
|
-
const explicitRequestedExports = requestedExports.get(normalizeRequestedExportsKey(filename));
|
|
474
|
-
const reachability = analyzeReachability(source, filename, program, explicitRequestedExports);
|
|
475
|
-
|
|
476
|
-
for (const statement of program.body) {
|
|
477
|
-
if (statement.type === 'ImportDeclaration') {
|
|
478
|
-
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
479
|
-
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
480
|
-
if (!requestedModuleKey || !reachableRules) continue;
|
|
481
|
-
|
|
482
|
-
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
483
|
-
continue;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
if (statement.type === 'ExportNamedDeclaration' && statement.source) {
|
|
487
|
-
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
488
|
-
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
489
|
-
if (!requestedModuleKey || !reachableRules) continue;
|
|
490
|
-
|
|
491
|
-
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
492
|
-
continue;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
if (statement.type === 'ExportAllDeclaration' && statement.source) {
|
|
496
|
-
const reachableRules = reachability.reachableImports.get(statement.source.value as string);
|
|
497
|
-
const requestedModuleKey = resolveRequestedExportsKey(filename, statement.source.value as string);
|
|
498
|
-
if (!requestedModuleKey || !reachableRules) continue;
|
|
499
|
-
|
|
500
|
-
mergeRequestedExportRules(requestedExports, requestedModuleKey, reachableRules);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Build the edit list
|
|
506
|
-
*
|
|
507
|
-
* Walk the AST a second time, this time inspecting every import/export/dynamic-import
|
|
508
|
-
* node against the combined allowlist and the reachability graph:
|
|
509
|
-
*
|
|
510
|
-
* - **Forbidden + unreachable** → replace with empty string (pruned).
|
|
511
|
-
* - **Forbidden + reachable from a known client root** → throw a build error so the
|
|
512
|
-
* developer is forced to resolve the server-client boundary violation explicitly.
|
|
513
|
-
* - **Allowed with specific named rules** → surgically rewrite the import to keep only
|
|
514
|
-
* the permitted named bindings; esbuild tree-shakes the rest.
|
|
515
|
-
* - **Allowed with no restrictions** → left untouched; esbuild handles tree-shaking.
|
|
516
|
-
*/
|
|
517
|
-
const edits: { start: number; end: number; replacement: string }[] = [];
|
|
518
|
-
|
|
519
|
-
function processSpecifier(specifier: string): { allowed: boolean; rules?: Set<string> | '*' } {
|
|
520
|
-
const moduleBase = toModuleBaseSpecifier(specifier);
|
|
521
|
-
const explicitRules = allowedMap.get(moduleBase);
|
|
522
|
-
|
|
523
|
-
if (isServerOnlySpecifier(specifier)) {
|
|
524
|
-
if (explicitRules) {
|
|
525
|
-
return { allowed: true, rules: explicitRules };
|
|
526
|
-
}
|
|
527
|
-
return { allowed: false };
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
if (isProjectAliasSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? '*' };
|
|
531
|
-
if (!isBareSpecifier(specifier)) return { allowed: true, rules: explicitRules ?? '*' };
|
|
532
|
-
|
|
533
|
-
/** By default, bare specifiers (NPM modules) are allowed entirely. */
|
|
534
|
-
return { allowed: true, rules: explicitRules ?? '*' };
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
function walkImports(node: any) {
|
|
538
|
-
if (!node || typeof node !== 'object') return;
|
|
539
|
-
if (Array.isArray(node)) {
|
|
540
|
-
for (const child of node) walkImports(child);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (node.type === 'ImportDeclaration') {
|
|
545
|
-
const specifier = node.source.value as string;
|
|
546
|
-
const reachableRules = reachability.reachableImports.get(specifier);
|
|
547
|
-
const { allowed, rules } = processSpecifier(specifier);
|
|
548
|
-
|
|
549
|
-
if (!allowed) {
|
|
550
|
-
if (reachableRules && !reachability.isFallbackRoots) {
|
|
551
|
-
throw new Error(
|
|
552
|
-
`[Ecopages Client Reachability] Forbidden client import '${specifier}' at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
|
|
553
|
-
);
|
|
554
|
-
} else {
|
|
555
|
-
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
556
|
-
}
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
/**
|
|
561
|
-
* If it IS allowed by the base specifier, we must check if there are specific named rules.
|
|
562
|
-
* If there are specific rules (a Set), we must surgically remove any specifiers that aren't in the rule.
|
|
563
|
-
*/
|
|
564
|
-
if (rules instanceof Set && node.specifiers && node.specifiers.length > 0) {
|
|
565
|
-
let keptSpecifierCount = 0;
|
|
566
|
-
let defaultImportLocal: string | undefined;
|
|
567
|
-
let namespaceImportLocal: string | undefined;
|
|
568
|
-
const namedImportNodes: string[] = [];
|
|
569
|
-
for (const spec of node.specifiers) {
|
|
570
|
-
if (spec.type === 'ImportSpecifier') {
|
|
571
|
-
const importedName =
|
|
572
|
-
spec.imported.type === 'Identifier' ? spec.imported.name : (spec.imported.value as string);
|
|
573
|
-
if (rules.has(importedName)) {
|
|
574
|
-
keptSpecifierCount += 1;
|
|
575
|
-
const localName = spec.local?.name;
|
|
576
|
-
if (localName && localName !== importedName) {
|
|
577
|
-
namedImportNodes.push(`${importedName} as ${localName}`);
|
|
578
|
-
} else {
|
|
579
|
-
namedImportNodes.push(importedName);
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
} else if (spec.type === 'ImportDefaultSpecifier') {
|
|
583
|
-
if (rules.has('default')) {
|
|
584
|
-
keptSpecifierCount += 1;
|
|
585
|
-
defaultImportLocal = spec.local.name;
|
|
586
|
-
}
|
|
587
|
-
} else if (spec.type === 'ImportNamespaceSpecifier') {
|
|
588
|
-
if (rules.has('*')) {
|
|
589
|
-
keptSpecifierCount += 1;
|
|
590
|
-
namespaceImportLocal = spec.local.name;
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
if (keptSpecifierCount === 0) {
|
|
596
|
-
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
597
|
-
} else if (keptSpecifierCount < node.specifiers.length) {
|
|
598
|
-
let newDeclaration = '';
|
|
599
|
-
if (defaultImportLocal && namespaceImportLocal) {
|
|
600
|
-
newDeclaration = `import ${defaultImportLocal}, * as ${namespaceImportLocal} from '${specifier}';`;
|
|
601
|
-
} else if (namespaceImportLocal) {
|
|
602
|
-
newDeclaration = `import * as ${namespaceImportLocal} from '${specifier}';`;
|
|
603
|
-
} else if (defaultImportLocal && namedImportNodes.length > 0) {
|
|
604
|
-
newDeclaration = `import ${defaultImportLocal}, { ${namedImportNodes.join(', ')} } from '${specifier}';`;
|
|
605
|
-
} else if (defaultImportLocal) {
|
|
606
|
-
newDeclaration = `import ${defaultImportLocal} from '${specifier}';`;
|
|
607
|
-
} else {
|
|
608
|
-
newDeclaration = `import { ${namedImportNodes.join(', ')} } from '${specifier}';`;
|
|
609
|
-
}
|
|
610
|
-
edits.push({ start: node.start, end: node.end, replacement: newDeclaration });
|
|
611
|
-
}
|
|
612
|
-
return;
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* If it IS allowed (globally or all specifiers match) and IS reachable,
|
|
617
|
-
* we can safely just leave the import alone.
|
|
618
|
-
* ESBuild will natively treeshake any bindings that are actually unused.
|
|
619
|
-
*
|
|
620
|
-
* However, if it is completely unreachable, and it's a side-effect import
|
|
621
|
-
* (no specifiers), we want to proactively prune it.
|
|
622
|
-
*/
|
|
623
|
-
if (!reachableRules && (!node.specifiers || node.specifiers.length === 0)) {
|
|
624
|
-
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
if (node.type === 'ExportNamedDeclaration' && node.source) {
|
|
631
|
-
const specifier = node.source.value as string;
|
|
632
|
-
const { allowed } = processSpecifier(specifier);
|
|
633
|
-
|
|
634
|
-
if (!allowed) {
|
|
635
|
-
const reachableRules = reachability.reachableImports.get(specifier);
|
|
636
|
-
if (reachableRules && !reachability.isFallbackRoots) {
|
|
637
|
-
throw new Error(
|
|
638
|
-
`[Ecopages Client Reachability] Forbidden client export from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
|
|
639
|
-
);
|
|
640
|
-
} else {
|
|
641
|
-
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
return;
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
if (node.type === 'ExportAllDeclaration' && node.source) {
|
|
648
|
-
const specifier = node.source.value as string;
|
|
649
|
-
const { allowed } = processSpecifier(specifier);
|
|
650
|
-
if (!allowed) {
|
|
651
|
-
const reachableRules = reachability.reachableImports.get(specifier);
|
|
652
|
-
if (reachableRules && !reachability.isFallbackRoots) {
|
|
653
|
-
throw new Error(
|
|
654
|
-
`[Ecopages Client Reachability] Forbidden client export * from '${specifier}' at ${filename}:${node.start}. This export is explicitly reachable from the React render function.`,
|
|
655
|
-
);
|
|
656
|
-
} else {
|
|
657
|
-
edits.push({ start: node.start, end: node.end, replacement: '' });
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
return;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
if (node.type === 'ImportExpression' && node.source?.value) {
|
|
664
|
-
const specifier = node.source.value as string;
|
|
665
|
-
const { allowed } = processSpecifier(specifier);
|
|
666
|
-
const reachableRules = reachability.reachableImports.get(specifier);
|
|
667
|
-
|
|
668
|
-
if (!reachableRules) {
|
|
669
|
-
if (!allowed) {
|
|
670
|
-
edits.push({ start: node.start, end: node.end, replacement: 'Promise.resolve({})' });
|
|
671
|
-
}
|
|
672
|
-
return;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
if (!allowed) {
|
|
676
|
-
if (!reachability.isFallbackRoots) {
|
|
677
|
-
throw new Error(
|
|
678
|
-
`[Ecopages Client Reachability] Forbidden dynamic import('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
|
|
679
|
-
);
|
|
680
|
-
} else {
|
|
681
|
-
edits.push({ start: node.start, end: node.end, replacement: 'Promise.resolve({})' });
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
if (node.type === 'CallExpression' && node.callee?.type === 'Identifier' && node.callee.name === 'require') {
|
|
688
|
-
const arg = node.arguments?.[0];
|
|
689
|
-
if (arg && (arg.type === 'StringLiteral' || arg.type === 'Literal') && typeof arg.value === 'string') {
|
|
690
|
-
const specifier = arg.value;
|
|
691
|
-
const { allowed } = processSpecifier(specifier);
|
|
692
|
-
const reachableRules = reachability.reachableImports.get(specifier);
|
|
693
|
-
|
|
694
|
-
if (!reachableRules) {
|
|
695
|
-
if (!allowed) {
|
|
696
|
-
edits.push({ start: node.start, end: node.end, replacement: '({})' });
|
|
697
|
-
}
|
|
698
|
-
} else if (!allowed) {
|
|
699
|
-
if (!reachability.isFallbackRoots) {
|
|
700
|
-
throw new Error(
|
|
701
|
-
`[Ecopages Client Reachability] Forbidden require('${specifier}') at ${filename}:${node.start}. This import is explicitly reachable from the React render function.`,
|
|
702
|
-
);
|
|
703
|
-
} else {
|
|
704
|
-
edits.push({ start: node.start, end: node.end, replacement: '({})' });
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
}
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
for (const key in node) {
|
|
711
|
-
if (key !== 'type' && key !== 'start' && key !== 'end') {
|
|
712
|
-
walkImports(node[key]);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
walkImports(program);
|
|
717
|
-
|
|
718
|
-
if (edits.length === 0) {
|
|
719
|
-
return stripServerOnlyEcoPageOptions(source, program);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
edits.sort((a, b) => b.start - a.start);
|
|
723
|
-
let transformed = source;
|
|
724
|
-
for (const edit of edits) {
|
|
725
|
-
transformed = transformed.slice(0, edit.start) + edit.replacement + transformed.slice(edit.end);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
let reparsedResult;
|
|
729
|
-
try {
|
|
730
|
-
reparsedResult = parseSync(filename, transformed, {
|
|
731
|
-
sourceType: 'module',
|
|
732
|
-
lang: parserLanguageForFile(filename),
|
|
733
|
-
});
|
|
734
|
-
} catch {
|
|
735
|
-
return { transformed, modified: true };
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const strippedPageOptions = stripServerOnlyEcoPageOptions(transformed, reparsedResult.program);
|
|
739
|
-
if (strippedPageOptions.modified) {
|
|
740
|
-
return strippedPageOptions;
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
return { transformed, modified: true };
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* Instantiates the client graph boundary esbuild plugin.
|
|
748
|
-
*
|
|
749
|
-
* @param options - Configuration options for the graph boundary.
|
|
750
|
-
* @returns The resulting `EcoBuildPlugin`.
|
|
751
|
-
*/
|
|
752
|
-
export function createClientGraphBoundaryPlugin(options?: ClientGraphBoundaryOptions): EcoBuildPlugin {
|
|
753
|
-
return {
|
|
754
|
-
name: 'ecopages-client-graph-boundary',
|
|
755
|
-
setup(build) {
|
|
756
|
-
const absWorkingDir = options?.absWorkingDir ?? process.cwd();
|
|
757
|
-
const globallyDeclaredSources = parseDeclaredModules(options?.declaredModules);
|
|
758
|
-
const requestedExports = new Map<string, RequestedExportRules>();
|
|
759
|
-
for (const alwaysAllow of options?.alwaysAllowSpecifiers ?? []) {
|
|
760
|
-
globallyDeclaredSources.set(toModuleBaseSpecifier(alwaysAllow), '*');
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
/**
|
|
764
|
-
* Source-level transform: replace static `fs.readFileSync(path.resolve('./...'), 'utf-8')`
|
|
765
|
-
* calls with the actual file content inlined as a string literal at build time.
|
|
766
|
-
*
|
|
767
|
-
* This prevents server/client hydration mismatches when components read files at module
|
|
768
|
-
* scope — the browser bundle will contain the same content the server rendered, so React
|
|
769
|
-
* never needs to enter client-render recovery mode.
|
|
770
|
-
*/
|
|
771
|
-
build.onLoad({ filter: SOURCE_FILE_FILTER }, (args) => {
|
|
772
|
-
let source: string;
|
|
773
|
-
try {
|
|
774
|
-
source = readFileSync(args.path, 'utf-8');
|
|
775
|
-
} catch {
|
|
776
|
-
return undefined;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
let transformed = source;
|
|
780
|
-
let modified = false;
|
|
781
|
-
|
|
782
|
-
if (source.includes('readFileSync')) {
|
|
783
|
-
const readFileTransformed = transformed.replace(
|
|
784
|
-
/\bfs\.readFileSync\s*\(\s*path\.resolve\s*\(\s*(['"`])([^'"`\n]+)\1\s*\)\s*,\s*['"`]utf-?8['"`]\s*\)/g,
|
|
785
|
-
(_match, _q, relPath) => {
|
|
786
|
-
modified = true;
|
|
787
|
-
try {
|
|
788
|
-
const sourceDir = dirname(args.path);
|
|
789
|
-
const srcDirIndex = args.path.lastIndexOf('/src/');
|
|
790
|
-
const inferredProjectRoot =
|
|
791
|
-
srcDirIndex >= 0 ? args.path.slice(0, srcDirIndex) : undefined;
|
|
792
|
-
const candidates = [
|
|
793
|
-
resolve(absWorkingDir, relPath),
|
|
794
|
-
resolve(process.cwd(), relPath),
|
|
795
|
-
resolve(sourceDir, relPath),
|
|
796
|
-
...(inferredProjectRoot ? [resolve(inferredProjectRoot, relPath)] : []),
|
|
797
|
-
];
|
|
798
|
-
|
|
799
|
-
const absolutePath = candidates.find((candidate) => existsSync(candidate));
|
|
800
|
-
if (!absolutePath) return '""';
|
|
801
|
-
|
|
802
|
-
const content = readFileSync(absolutePath, 'utf-8');
|
|
803
|
-
return JSON.stringify(content);
|
|
804
|
-
} catch {
|
|
805
|
-
return '""';
|
|
806
|
-
}
|
|
807
|
-
},
|
|
808
|
-
);
|
|
809
|
-
transformed = readFileTransformed;
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
const { transformed: oxcTransformed, modified: importsModified } = transformModuleImports(
|
|
813
|
-
transformed,
|
|
814
|
-
args.path,
|
|
815
|
-
globallyDeclaredSources,
|
|
816
|
-
requestedExports,
|
|
817
|
-
);
|
|
818
|
-
|
|
819
|
-
if (importsModified) {
|
|
820
|
-
modified = true;
|
|
821
|
-
transformed = oxcTransformed;
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
if (!modified) return undefined;
|
|
825
|
-
|
|
826
|
-
const ext = extname(args.path).slice(1) as 'ts' | 'tsx' | 'js' | 'jsx';
|
|
827
|
-
return { contents: transformed, loader: ext };
|
|
828
|
-
});
|
|
829
|
-
},
|
|
830
|
-
};
|
|
831
|
-
}
|