Flask-Node 0.1.2__tar.gz → 0.2.0__tar.gz

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.
Files changed (31) hide show
  1. {flask_node-0.1.2/src/Flask_Node.egg-info → flask_node-0.2.0}/PKG-INFO +75 -2
  2. {flask_node-0.1.2 → flask_node-0.2.0}/README.md +74 -1
  3. {flask_node-0.1.2 → flask_node-0.2.0}/pyproject.toml +1 -1
  4. {flask_node-0.1.2 → flask_node-0.2.0/src/Flask_Node.egg-info}/PKG-INFO +75 -2
  5. {flask_node-0.1.2 → flask_node-0.2.0}/src/Flask_Node.egg-info/SOURCES.txt +2 -0
  6. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/__init__.py +2 -0
  7. flask_node-0.2.0/src/flask_node/entry.py +101 -0
  8. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/exceptions.py +4 -0
  9. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/extension.py +3 -0
  10. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/manager.py +11 -0
  11. flask_node-0.2.0/tests/test_entry.py +364 -0
  12. {flask_node-0.1.2 → flask_node-0.2.0}/LICENSE +0 -0
  13. {flask_node-0.1.2 → flask_node-0.2.0}/MANIFEST.in +0 -0
  14. {flask_node-0.1.2 → flask_node-0.2.0}/setup.cfg +0 -0
  15. {flask_node-0.1.2 → flask_node-0.2.0}/src/Flask_Node.egg-info/dependency_links.txt +0 -0
  16. {flask_node-0.1.2 → flask_node-0.2.0}/src/Flask_Node.egg-info/requires.txt +0 -0
  17. {flask_node-0.1.2 → flask_node-0.2.0}/src/Flask_Node.egg-info/top_level.txt +0 -0
  18. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/assets.py +0 -0
  19. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/cli.py +0 -0
  20. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/config.py +0 -0
  21. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/package.py +0 -0
  22. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/py.typed +0 -0
  23. {flask_node-0.1.2 → flask_node-0.2.0}/src/flask_node/runner.py +0 -0
  24. {flask_node-0.1.2 → flask_node-0.2.0}/tests/conftest.py +0 -0
  25. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_assets.py +0 -0
  26. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_cli.py +0 -0
  27. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_declarative.py +0 -0
  28. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_extension.py +0 -0
  29. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_manager.py +0 -0
  30. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_package.py +0 -0
  31. {flask_node-0.1.2 → flask_node-0.2.0}/tests/test_runner.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Flask-Node
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Isolated Node/npm infrastructure for Flask extensions
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -224,6 +224,78 @@ packages raise `PackageNotFoundError`; unsafe or missing assets raise
224
224
  `AssetResolutionError`. This is filesystem path validation, not protection
225
225
  against concurrent malicious filesystem changes.
226
226
 
227
+ ## Installed package entries
228
+
229
+ After packages have been installed explicitly, consumers can resolve their
230
+ JavaScript entry points through either the manager or the `Node` facade:
231
+
232
+ ```python
233
+ entry = manager.resolve_entry("example")
234
+ plugin = manager.resolve_entry("example", "plugin")
235
+ scoped_entry = manager.resolve_entry("@scope/plugin")
236
+ # Equivalent, inside the current application's context:
237
+ entry = node.resolve_entry("example")
238
+ ```
239
+
240
+ `resolve_entry(name, subpath=None)` returns an absolute, existing regular-file
241
+ `pathlib.Path`. Package and subpath are separate arguments; do not put a subpath
242
+ in `name`. Only locally installed packages under the manager's `node_modules`
243
+ are accepted. Resolution is anchored to that manager, independent of the Python
244
+ process's working directory or the location of consumer-owned source files.
245
+ It does not require or generate a managed project manifest.
246
+
247
+ The resolver invokes the configured `NODE_BIN` through `CommandRunner` and uses
248
+ Node's `module.createRequire(...).resolve(...)`. It never loads or evaluates the
249
+ target package, installs packages, invokes npm/npx, downloads files, copies
250
+ assets, or writes helper files. Invoke it during an explicit consumer operation
251
+ after installation; Flask-Node does not resolve entries or run processes during
252
+ normal Flask initialization.
253
+
254
+ Resolution follows Node's **require** semantics:
255
+
256
+ - `exports`, when present, takes precedence over `main` and controls which root
257
+ and subpath entries are accessible. Blocked/private subpaths fail even when a
258
+ matching file exists.
259
+ - Conditional exports use Node's active require conditions, including `node`,
260
+ `require`, and applicable `default` branches, in manifest order. An
261
+ `import`-only entry is unavailable. Additional conditions supplied by the Node
262
+ runtime/configuration, such as version-dependent `module-sync`, follow Node's
263
+ own behavior; Flask-Node does not emulate or override them.
264
+ - Without `exports`, Node uses `main` and its legacy file/directory lookup,
265
+ including `.js`, `.json`, `.node` extension probing and index fallbacks.
266
+ Export targets require their exact files; legacy extension/index probing does
267
+ not apply to those targets. Nonstandard fields such as `module` are not used.
268
+
269
+ These semantics suit consumers using require-style JavaScript plugin resolution.
270
+ Consumers needing import-condition selection must not assume this API selects
271
+ that branch. Resolution is separate from loading: a returned path may identify
272
+ ESM, JSON, or a native addon, and does not guarantee that a consumer's loader can
273
+ execute it. See Node's [resolution documentation](https://nodejs.org/api/modules.html#requireresolverequest-options)
274
+ and [conditional exports](https://nodejs.org/api/packages.html#conditional-exports).
275
+
276
+ Package names use the existing registry-name validation. A supplied subpath must
277
+ be a nonempty relative string with no empty, `.` or `..` segments, absolute or
278
+ Windows drive paths, backslashes, NULs, or percent encoding. The package,
279
+ `node_modules`, and package manifest follow the existing symlink containment
280
+ checks. The resolved entry must remain within that installed package's resolved
281
+ root; internal symlinks are allowed, escaping symlinks are rejected. Ancestor or
282
+ global packages, built-ins, directories, and other non-file results are
283
+ unsupported. These are filesystem checks, not protection against concurrent
284
+ malicious filesystem changes.
285
+
286
+ Invalid package names raise `ConfigurationError`; missing or malformed installed
287
+ packages raise `PackageNotFoundError`; package containment failures retain
288
+ `AssetResolutionError`. Entry validation, Node resolution failures, and invalid
289
+ results raise the publicly exported `EntryResolutionError`, derived from
290
+ `NodeError`, with the requested entry and Node error code/message where available.
291
+ Missing Node and runner failures propagate as `ExecutableNotFoundError` and
292
+ `CommandExecutionError`; command diagnostics remain intact. Invalid resolver
293
+ responses include captured output in the error. Existing `resolve(name, asset)`
294
+ continues to resolve explicit filesystem paths and does not consult `exports`.
295
+
296
+ Package-entry resolution is available starting with **0.2.0**. Consumer
297
+ extensions using this API should declare `Flask-Node>=0.2.0`.
298
+
227
299
  ## Publishing browser assets
228
300
 
229
301
  Consuming extensions choose which npm dependencies and browser assets they need.
@@ -317,7 +389,8 @@ python -m venv .venv
317
389
  ```
318
390
 
319
391
  Tests inject/mock the runner and subprocess boundary. They never download npm
320
- packages or require Node. A custom runner can be supplied to `Node(runner=...)`
392
+ packages. Offline integration tests use temporary fake packages and real Node
393
+ when available; pytest reports explicit skips when Node is unavailable. A custom runner can be supplied to `Node(runner=...)`
321
394
  or `NodeManager(..., runner=...)` for testing. This dependency injection is not a
322
395
  consumer plugin protocol.
323
396
 
@@ -210,6 +210,78 @@ packages raise `PackageNotFoundError`; unsafe or missing assets raise
210
210
  `AssetResolutionError`. This is filesystem path validation, not protection
211
211
  against concurrent malicious filesystem changes.
212
212
 
213
+ ## Installed package entries
214
+
215
+ After packages have been installed explicitly, consumers can resolve their
216
+ JavaScript entry points through either the manager or the `Node` facade:
217
+
218
+ ```python
219
+ entry = manager.resolve_entry("example")
220
+ plugin = manager.resolve_entry("example", "plugin")
221
+ scoped_entry = manager.resolve_entry("@scope/plugin")
222
+ # Equivalent, inside the current application's context:
223
+ entry = node.resolve_entry("example")
224
+ ```
225
+
226
+ `resolve_entry(name, subpath=None)` returns an absolute, existing regular-file
227
+ `pathlib.Path`. Package and subpath are separate arguments; do not put a subpath
228
+ in `name`. Only locally installed packages under the manager's `node_modules`
229
+ are accepted. Resolution is anchored to that manager, independent of the Python
230
+ process's working directory or the location of consumer-owned source files.
231
+ It does not require or generate a managed project manifest.
232
+
233
+ The resolver invokes the configured `NODE_BIN` through `CommandRunner` and uses
234
+ Node's `module.createRequire(...).resolve(...)`. It never loads or evaluates the
235
+ target package, installs packages, invokes npm/npx, downloads files, copies
236
+ assets, or writes helper files. Invoke it during an explicit consumer operation
237
+ after installation; Flask-Node does not resolve entries or run processes during
238
+ normal Flask initialization.
239
+
240
+ Resolution follows Node's **require** semantics:
241
+
242
+ - `exports`, when present, takes precedence over `main` and controls which root
243
+ and subpath entries are accessible. Blocked/private subpaths fail even when a
244
+ matching file exists.
245
+ - Conditional exports use Node's active require conditions, including `node`,
246
+ `require`, and applicable `default` branches, in manifest order. An
247
+ `import`-only entry is unavailable. Additional conditions supplied by the Node
248
+ runtime/configuration, such as version-dependent `module-sync`, follow Node's
249
+ own behavior; Flask-Node does not emulate or override them.
250
+ - Without `exports`, Node uses `main` and its legacy file/directory lookup,
251
+ including `.js`, `.json`, `.node` extension probing and index fallbacks.
252
+ Export targets require their exact files; legacy extension/index probing does
253
+ not apply to those targets. Nonstandard fields such as `module` are not used.
254
+
255
+ These semantics suit consumers using require-style JavaScript plugin resolution.
256
+ Consumers needing import-condition selection must not assume this API selects
257
+ that branch. Resolution is separate from loading: a returned path may identify
258
+ ESM, JSON, or a native addon, and does not guarantee that a consumer's loader can
259
+ execute it. See Node's [resolution documentation](https://nodejs.org/api/modules.html#requireresolverequest-options)
260
+ and [conditional exports](https://nodejs.org/api/packages.html#conditional-exports).
261
+
262
+ Package names use the existing registry-name validation. A supplied subpath must
263
+ be a nonempty relative string with no empty, `.` or `..` segments, absolute or
264
+ Windows drive paths, backslashes, NULs, or percent encoding. The package,
265
+ `node_modules`, and package manifest follow the existing symlink containment
266
+ checks. The resolved entry must remain within that installed package's resolved
267
+ root; internal symlinks are allowed, escaping symlinks are rejected. Ancestor or
268
+ global packages, built-ins, directories, and other non-file results are
269
+ unsupported. These are filesystem checks, not protection against concurrent
270
+ malicious filesystem changes.
271
+
272
+ Invalid package names raise `ConfigurationError`; missing or malformed installed
273
+ packages raise `PackageNotFoundError`; package containment failures retain
274
+ `AssetResolutionError`. Entry validation, Node resolution failures, and invalid
275
+ results raise the publicly exported `EntryResolutionError`, derived from
276
+ `NodeError`, with the requested entry and Node error code/message where available.
277
+ Missing Node and runner failures propagate as `ExecutableNotFoundError` and
278
+ `CommandExecutionError`; command diagnostics remain intact. Invalid resolver
279
+ responses include captured output in the error. Existing `resolve(name, asset)`
280
+ continues to resolve explicit filesystem paths and does not consult `exports`.
281
+
282
+ Package-entry resolution is available starting with **0.2.0**. Consumer
283
+ extensions using this API should declare `Flask-Node>=0.2.0`.
284
+
213
285
  ## Publishing browser assets
214
286
 
215
287
  Consuming extensions choose which npm dependencies and browser assets they need.
@@ -303,7 +375,8 @@ python -m venv .venv
303
375
  ```
304
376
 
305
377
  Tests inject/mock the runner and subprocess boundary. They never download npm
306
- packages or require Node. A custom runner can be supplied to `Node(runner=...)`
378
+ packages. Offline integration tests use temporary fake packages and real Node
379
+ when available; pytest reports explicit skips when Node is unavailable. A custom runner can be supplied to `Node(runner=...)`
307
380
  or `NodeManager(..., runner=...)` for testing. This dependency injection is not a
308
381
  consumer plugin protocol.
309
382
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "Flask-Node"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  description = "Isolated Node/npm infrastructure for Flask extensions"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: Flask-Node
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Isolated Node/npm infrastructure for Flask extensions
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -224,6 +224,78 @@ packages raise `PackageNotFoundError`; unsafe or missing assets raise
224
224
  `AssetResolutionError`. This is filesystem path validation, not protection
225
225
  against concurrent malicious filesystem changes.
226
226
 
227
+ ## Installed package entries
228
+
229
+ After packages have been installed explicitly, consumers can resolve their
230
+ JavaScript entry points through either the manager or the `Node` facade:
231
+
232
+ ```python
233
+ entry = manager.resolve_entry("example")
234
+ plugin = manager.resolve_entry("example", "plugin")
235
+ scoped_entry = manager.resolve_entry("@scope/plugin")
236
+ # Equivalent, inside the current application's context:
237
+ entry = node.resolve_entry("example")
238
+ ```
239
+
240
+ `resolve_entry(name, subpath=None)` returns an absolute, existing regular-file
241
+ `pathlib.Path`. Package and subpath are separate arguments; do not put a subpath
242
+ in `name`. Only locally installed packages under the manager's `node_modules`
243
+ are accepted. Resolution is anchored to that manager, independent of the Python
244
+ process's working directory or the location of consumer-owned source files.
245
+ It does not require or generate a managed project manifest.
246
+
247
+ The resolver invokes the configured `NODE_BIN` through `CommandRunner` and uses
248
+ Node's `module.createRequire(...).resolve(...)`. It never loads or evaluates the
249
+ target package, installs packages, invokes npm/npx, downloads files, copies
250
+ assets, or writes helper files. Invoke it during an explicit consumer operation
251
+ after installation; Flask-Node does not resolve entries or run processes during
252
+ normal Flask initialization.
253
+
254
+ Resolution follows Node's **require** semantics:
255
+
256
+ - `exports`, when present, takes precedence over `main` and controls which root
257
+ and subpath entries are accessible. Blocked/private subpaths fail even when a
258
+ matching file exists.
259
+ - Conditional exports use Node's active require conditions, including `node`,
260
+ `require`, and applicable `default` branches, in manifest order. An
261
+ `import`-only entry is unavailable. Additional conditions supplied by the Node
262
+ runtime/configuration, such as version-dependent `module-sync`, follow Node's
263
+ own behavior; Flask-Node does not emulate or override them.
264
+ - Without `exports`, Node uses `main` and its legacy file/directory lookup,
265
+ including `.js`, `.json`, `.node` extension probing and index fallbacks.
266
+ Export targets require their exact files; legacy extension/index probing does
267
+ not apply to those targets. Nonstandard fields such as `module` are not used.
268
+
269
+ These semantics suit consumers using require-style JavaScript plugin resolution.
270
+ Consumers needing import-condition selection must not assume this API selects
271
+ that branch. Resolution is separate from loading: a returned path may identify
272
+ ESM, JSON, or a native addon, and does not guarantee that a consumer's loader can
273
+ execute it. See Node's [resolution documentation](https://nodejs.org/api/modules.html#requireresolverequest-options)
274
+ and [conditional exports](https://nodejs.org/api/packages.html#conditional-exports).
275
+
276
+ Package names use the existing registry-name validation. A supplied subpath must
277
+ be a nonempty relative string with no empty, `.` or `..` segments, absolute or
278
+ Windows drive paths, backslashes, NULs, or percent encoding. The package,
279
+ `node_modules`, and package manifest follow the existing symlink containment
280
+ checks. The resolved entry must remain within that installed package's resolved
281
+ root; internal symlinks are allowed, escaping symlinks are rejected. Ancestor or
282
+ global packages, built-ins, directories, and other non-file results are
283
+ unsupported. These are filesystem checks, not protection against concurrent
284
+ malicious filesystem changes.
285
+
286
+ Invalid package names raise `ConfigurationError`; missing or malformed installed
287
+ packages raise `PackageNotFoundError`; package containment failures retain
288
+ `AssetResolutionError`. Entry validation, Node resolution failures, and invalid
289
+ results raise the publicly exported `EntryResolutionError`, derived from
290
+ `NodeError`, with the requested entry and Node error code/message where available.
291
+ Missing Node and runner failures propagate as `ExecutableNotFoundError` and
292
+ `CommandExecutionError`; command diagnostics remain intact. Invalid resolver
293
+ responses include captured output in the error. Existing `resolve(name, asset)`
294
+ continues to resolve explicit filesystem paths and does not consult `exports`.
295
+
296
+ Package-entry resolution is available starting with **0.2.0**. Consumer
297
+ extensions using this API should declare `Flask-Node>=0.2.0`.
298
+
227
299
  ## Publishing browser assets
228
300
 
229
301
  Consuming extensions choose which npm dependencies and browser assets they need.
@@ -317,7 +389,8 @@ python -m venv .venv
317
389
  ```
318
390
 
319
391
  Tests inject/mock the runner and subprocess boundary. They never download npm
320
- packages or require Node. A custom runner can be supplied to `Node(runner=...)`
392
+ packages. Offline integration tests use temporary fake packages and real Node
393
+ when available; pytest reports explicit skips when Node is unavailable. A custom runner can be supplied to `Node(runner=...)`
321
394
  or `NodeManager(..., runner=...)` for testing. This dependency injection is not a
322
395
  consumer plugin protocol.
323
396
 
@@ -11,6 +11,7 @@ src/flask_node/__init__.py
11
11
  src/flask_node/assets.py
12
12
  src/flask_node/cli.py
13
13
  src/flask_node/config.py
14
+ src/flask_node/entry.py
14
15
  src/flask_node/exceptions.py
15
16
  src/flask_node/extension.py
16
17
  src/flask_node/manager.py
@@ -21,6 +22,7 @@ tests/conftest.py
21
22
  tests/test_assets.py
22
23
  tests/test_cli.py
23
24
  tests/test_declarative.py
25
+ tests/test_entry.py
24
26
  tests/test_extension.py
25
27
  tests/test_manager.py
26
28
  tests/test_package.py
@@ -8,6 +8,7 @@ from .exceptions import (
8
8
  CommandExecutionError,
9
9
  ConfigurationError,
10
10
  DependencyConflictError,
11
+ EntryResolutionError,
11
12
  EnvironmentError,
12
13
  ExecutableNotFoundError,
13
14
  NodeError,
@@ -27,6 +28,7 @@ __all__ = [
27
28
  "CommandRunner",
28
29
  "ConfigurationError",
29
30
  "DependencyConflictError",
31
+ "EntryResolutionError",
30
32
  "EnvironmentError",
31
33
  "ExecutableNotFoundError",
32
34
  "Node",
@@ -0,0 +1,101 @@
1
+ """Resolve installed entries using Node, without loading package code."""
2
+
3
+ import json
4
+ from pathlib import Path, PureWindowsPath
5
+
6
+ from .exceptions import EntryResolutionError
7
+ from .runner import CommandRunner
8
+
9
+ # Only built-in modules are loaded. The synthetic anchor need not exist.
10
+ _SCRIPT = """
11
+ const { createRequire, isBuiltin } = require('node:module');
12
+ const [anchor, specifier] = process.argv.slice(1);
13
+ try {
14
+ if (isBuiltin(specifier)) {
15
+ throw Object.assign(new Error('Built-in modules are unsupported'), {
16
+ code: 'ERR_UNSUPPORTED_BUILTIN'
17
+ });
18
+ }
19
+ const resolved = createRequire(anchor).resolve(specifier);
20
+ process.stdout.write(JSON.stringify({path: resolved}));
21
+ } catch (error) {
22
+ process.stdout.write(JSON.stringify({error: {
23
+ code: error.code || 'ERR_ENTRY_RESOLUTION', message: error.message
24
+ }}));
25
+ }
26
+ """
27
+
28
+
29
+ def validate_subpath(subpath: str | None) -> str | None:
30
+ if subpath is None:
31
+ return None
32
+ if (
33
+ not isinstance(subpath, str)
34
+ or not subpath
35
+ or Path(subpath).is_absolute()
36
+ or PureWindowsPath(subpath).drive
37
+ or "\\" in subpath
38
+ or "\x00" in subpath
39
+ or "%" in subpath
40
+ or any(part in ("", ".", "..") for part in subpath.split("/"))
41
+ ):
42
+ raise EntryResolutionError(f"Unsafe package entry subpath: {subpath!r}")
43
+ return subpath
44
+
45
+
46
+ def resolve_entry(
47
+ runner: CommandRunner,
48
+ node_bin: str,
49
+ directory: Path,
50
+ root: Path,
51
+ specifier: str,
52
+ ) -> Path:
53
+ result = runner.run(
54
+ node_bin,
55
+ (
56
+ "--input-type=commonjs",
57
+ "--eval",
58
+ _SCRIPT,
59
+ "--",
60
+ str(directory / ".flask-node-resolver.cjs"),
61
+ specifier,
62
+ ),
63
+ cwd=directory,
64
+ capture_output=True,
65
+ )
66
+ try:
67
+ payload = json.loads(result.stdout or "")
68
+ if not isinstance(payload, dict):
69
+ raise TypeError("expected an object")
70
+ if "error" in payload:
71
+ error = payload["error"]
72
+ if not isinstance(error, dict):
73
+ raise ValueError("invalid error response")
74
+ detail = (
75
+ f"Cannot resolve entry {specifier!r}: "
76
+ f"{error.get('code', 'ERR_ENTRY_RESOLUTION')}: "
77
+ f"{error.get('message', 'unknown resolution failure')}"
78
+ )
79
+ if result.stderr:
80
+ detail += f"; stderr={result.stderr!r}"
81
+ raise EntryResolutionError(detail)
82
+ value = payload.get("path")
83
+ if not isinstance(value, str) or not Path(value).is_absolute():
84
+ raise ValueError(
85
+ "expected an absolute file path; built-ins are unsupported"
86
+ )
87
+ target = Path(value).resolve(strict=True)
88
+ if not target.is_relative_to(root):
89
+ raise EntryResolutionError(
90
+ f"Entry {specifier!r} escapes its installed package: {target}"
91
+ )
92
+ if not target.is_file():
93
+ raise EntryResolutionError(
94
+ f"Entry {specifier!r} is not a regular file: {target}"
95
+ )
96
+ return target
97
+ except (ValueError, OSError, RuntimeError, TypeError) as exc:
98
+ raise EntryResolutionError(
99
+ f"Cannot resolve entry {specifier!r}: {exc}; "
100
+ f"stdout={result.stdout!r}; stderr={result.stderr!r}"
101
+ ) from exc
@@ -54,3 +54,7 @@ class AssetConflictError(NodeError):
54
54
 
55
55
  class AssetPublicationError(NodeError):
56
56
  pass
57
+
58
+
59
+ class EntryResolutionError(NodeError):
60
+ """An installed package entry is unavailable, unsafe, or unsupported."""
@@ -113,6 +113,9 @@ class Node:
113
113
  def resolve(self, name: str, asset: str | Path) -> Path:
114
114
  return self.get_manager().resolve(name, asset)
115
115
 
116
+ def resolve_entry(self, name: str, subpath: str | None = None) -> Path:
117
+ return self.get_manager().resolve_entry(name, subpath)
118
+
116
119
  @property
117
120
  def assets(self) -> tuple[NodeAsset, ...]:
118
121
  return self.get_manager().assets
@@ -8,6 +8,7 @@ from typing import Any
8
8
 
9
9
  from .assets import NodeAsset, destination_path, publish, validate_tree
10
10
  from .config import read_configuration
11
+ from .entry import resolve_entry, validate_subpath
11
12
  from .exceptions import (
12
13
  AssetConflictError,
13
14
  AssetPublicationError,
@@ -319,6 +320,16 @@ class NodeManager:
319
320
  def resolve(self, name: str, asset: str | Path) -> Path:
320
321
  return self.package(name).resolve(asset)
321
322
 
323
+ def resolve_entry(self, name: str, subpath: str | None = None) -> Path:
324
+ """Resolve an installed entry using Node's require conditions, without loading it."""
325
+ validate_name(name)
326
+ subpath = validate_subpath(subpath)
327
+ package = self.package(name)
328
+ specifier = name if subpath is None else f"{name}/{subpath}"
329
+ return resolve_entry(
330
+ self.runner, self.node_bin, self.directory, package.root, specifier
331
+ )
332
+
322
333
  def status(self) -> dict[str, Any]:
323
334
  return {
324
335
  "directory": str(self.directory),
@@ -0,0 +1,364 @@
1
+ import json
2
+ import shutil
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from flask import Flask
7
+
8
+ from flask_node import (
9
+ AssetResolutionError,
10
+ CommandExecutionError,
11
+ CommandResult,
12
+ CommandRunner,
13
+ ConfigurationError,
14
+ EntryResolutionError,
15
+ ExecutableNotFoundError,
16
+ Node,
17
+ NodeManager,
18
+ PackageNotFoundError,
19
+ )
20
+
21
+
22
+ def installed(manager, name="example", **manifest):
23
+ root = manager.directory / "node_modules" / name
24
+ root.mkdir(parents=True)
25
+ (root / "package.json").write_text(json.dumps({"version": "1", **manifest}))
26
+ for filename in ("index.js", "plugin.js", "require.cjs", "import.mjs", "main.js"):
27
+ # Loading this code would fail, proving resolution does not evaluate it.
28
+ (root / filename).write_text("throw new Error('PACKAGE EXECUTED');")
29
+ return root
30
+
31
+
32
+ def response(runner, path):
33
+ runner.run.return_value = CommandResult(
34
+ ("node",), 0, json.dumps({"path": str(path)})
35
+ )
36
+
37
+
38
+ @pytest.mark.parametrize(
39
+ "name,subpath", [("example", None), ("@scope/example", None), ("example", "plugin")]
40
+ )
41
+ def test_boundary(manager, runner, name, subpath):
42
+ root = installed(manager, name)
43
+ target = root / ("plugin.js" if subpath else "index.js")
44
+ response(runner, target)
45
+ assert manager.resolve_entry(name, subpath) == target
46
+ executable, args = runner.run.call_args.args
47
+ assert executable == manager.node_bin
48
+ assert args[-2:] == (
49
+ str(manager.directory / ".flask-node-resolver.cjs"),
50
+ name + (f"/{subpath}" if subpath else ""),
51
+ )
52
+ assert runner.run.call_args.kwargs == {
53
+ "cwd": manager.directory,
54
+ "capture_output": True,
55
+ }
56
+ assert not manager.manifest_path.exists()
57
+ assert not (manager.directory / ".flask-node-resolver.cjs").exists()
58
+ runner.locate.assert_not_called()
59
+
60
+
61
+ @pytest.mark.parametrize(
62
+ "name", [None, "", "../x", "/x", "x/plugin", "node:fs", "@scope/../x", "C:\\x"]
63
+ )
64
+ def test_invalid_name(manager, runner, name):
65
+ with pytest.raises(ConfigurationError):
66
+ manager.resolve_entry(name)
67
+ runner.run.assert_not_called()
68
+ assert not manager.directory.exists()
69
+
70
+
71
+ @pytest.mark.parametrize(
72
+ "subpath",
73
+ [
74
+ "",
75
+ ".",
76
+ "..",
77
+ "../x",
78
+ "x/../y",
79
+ "/x",
80
+ "C:/x",
81
+ "x\\y",
82
+ "x//y",
83
+ "./x",
84
+ "x/",
85
+ "\x00",
86
+ "%2e%2e/x",
87
+ "x%2fy",
88
+ 1,
89
+ Path("plugin"),
90
+ ],
91
+ )
92
+ def test_invalid_subpath(manager, runner, subpath):
93
+ with pytest.raises(EntryResolutionError):
94
+ manager.resolve_entry("example", subpath)
95
+ runner.run.assert_not_called()
96
+ assert not manager.directory.exists()
97
+
98
+
99
+ def test_missing_package(manager, runner):
100
+ with pytest.raises(PackageNotFoundError):
101
+ manager.resolve_entry("example")
102
+ runner.run.assert_not_called()
103
+ assert not manager.directory.exists()
104
+
105
+
106
+ @pytest.mark.parametrize("value", ["fs", "node:fs", "relative.js", None])
107
+ def test_unsupported_results(manager, runner, value):
108
+ installed(manager)
109
+ runner.run.return_value = CommandResult(("node",), 0, json.dumps({"path": value}))
110
+ with pytest.raises(EntryResolutionError):
111
+ manager.resolve_entry("example")
112
+
113
+
114
+ @pytest.mark.parametrize("output", ["", "garbage", "[]", '{"error": null}'])
115
+ def test_malformed_response(manager, runner, output):
116
+ installed(manager)
117
+ runner.run.return_value = CommandResult(("node",), 0, output, "diagnostic")
118
+ with pytest.raises(EntryResolutionError, match="diagnostic"):
119
+ manager.resolve_entry("example")
120
+
121
+
122
+ @pytest.mark.parametrize("code", ["MODULE_NOT_FOUND", "ERR_PACKAGE_PATH_NOT_EXPORTED"])
123
+ def test_node_error(manager, runner, code):
124
+ installed(manager)
125
+ runner.run.return_value = CommandResult(
126
+ ("node",), 0, json.dumps({"error": {"code": code, "message": "blocked entry"}})
127
+ )
128
+ with pytest.raises(EntryResolutionError, match=f"{code}: blocked entry"):
129
+ manager.resolve_entry("example", "plugin")
130
+
131
+
132
+ @pytest.mark.parametrize(
133
+ "error",
134
+ [
135
+ ExecutableNotFoundError("missing node"),
136
+ CommandExecutionError(
137
+ "failed",
138
+ args=("node",),
139
+ cwd="managed",
140
+ returncode=7,
141
+ stdout="out",
142
+ stderr="err",
143
+ ),
144
+ ],
145
+ )
146
+ def test_runner_errors_preserved(manager, runner, error):
147
+ installed(manager)
148
+ runner.run.side_effect = error
149
+ with pytest.raises(type(error)) as caught:
150
+ manager.resolve_entry("example")
151
+ assert caught.value is error
152
+
153
+
154
+ def test_missing_executable(manager, monkeypatch):
155
+ installed(manager)
156
+ manager.runner = CommandRunner()
157
+ monkeypatch.setattr("flask_node.runner.shutil.which", lambda executable: None)
158
+ with pytest.raises(ExecutableNotFoundError):
159
+ manager.resolve_entry("example")
160
+
161
+
162
+ @pytest.mark.parametrize(
163
+ "kind", ["outside", "sibling", "directory", "missing", "symlink"]
164
+ )
165
+ def test_containment_and_files(manager, runner, tmp_path, kind):
166
+ root = installed(manager)
167
+ outside = tmp_path / "outside.js"
168
+ outside.write_text("outside")
169
+ if kind == "sibling":
170
+ target = installed(manager, "other") / "index.js"
171
+ elif kind == "directory":
172
+ target = root
173
+ elif kind == "missing":
174
+ target = root / "missing.js"
175
+ elif kind == "symlink":
176
+ target = root / "link.js"
177
+ target.symlink_to(outside)
178
+ else:
179
+ target = outside
180
+ response(runner, target)
181
+ with pytest.raises(EntryResolutionError):
182
+ manager.resolve_entry("example")
183
+
184
+
185
+ @pytest.mark.parametrize("kind", ["modules", "package", "manifest"])
186
+ def test_preflight_symlink_escape(manager, runner, tmp_path, kind):
187
+ root = installed(manager)
188
+ if kind == "modules":
189
+ shutil.rmtree(manager.directory / "node_modules")
190
+ (manager.directory / "node_modules").symlink_to(
191
+ tmp_path, target_is_directory=True
192
+ )
193
+ elif kind == "package":
194
+ shutil.rmtree(root)
195
+ root.symlink_to(tmp_path, target_is_directory=True)
196
+ else:
197
+ (root / "package.json").unlink()
198
+ (root / "package.json").symlink_to(tmp_path / "outside.json")
199
+ with pytest.raises(AssetResolutionError):
200
+ manager.resolve_entry("example")
201
+ runner.run.assert_not_called()
202
+
203
+
204
+ def test_facade_isolation(tmp_path, runner):
205
+ node = Node(runner=runner)
206
+ for name in ("one", "two"):
207
+ app = Flask(name, root_path=str(tmp_path / name))
208
+ app.config["NODE_DIR"] = "custom"
209
+ node.init_app(app)
210
+ runner.run.assert_not_called()
211
+ manager = node.get_manager(app)
212
+ assert not manager.directory.exists()
213
+ root = installed(manager)
214
+ response(runner, root / "plugin.js")
215
+ with app.app_context():
216
+ assert node.resolve_entry("example", "plugin") == root / "plugin.js"
217
+ assert runner.run.call_args.kwargs["cwd"] == tmp_path / name / "custom"
218
+ runner.reset_mock()
219
+
220
+
221
+ @pytest.fixture
222
+ def real_manager(tmp_path):
223
+ executable = shutil.which("node")
224
+ if executable is None:
225
+ pytest.skip("Node executable unavailable: offline entry integration skipped")
226
+ return NodeManager(tmp_path / "custom-managed", node_bin=executable)
227
+
228
+
229
+ @pytest.mark.parametrize(
230
+ "name,subpath,manifest,expected",
231
+ [
232
+ ("example", None, {}, "index.js"),
233
+ ("@scope/example", None, {"main": "main"}, "main.js"),
234
+ ("example", "plugin", {}, "plugin.js"),
235
+ (
236
+ "example",
237
+ None,
238
+ {"main": "main.js", "exports": "./require.cjs"},
239
+ "require.cjs",
240
+ ),
241
+ (
242
+ "example",
243
+ None,
244
+ {
245
+ "exports": {
246
+ "import": "./import.mjs",
247
+ "require": "./require.cjs",
248
+ "default": "./main.js",
249
+ }
250
+ },
251
+ "require.cjs",
252
+ ),
253
+ (
254
+ "example",
255
+ None,
256
+ {"exports": {"node": "./main.js", "default": "./index.js"}},
257
+ "main.js",
258
+ ),
259
+ ("example", None, {"exports": {"default": "./main.js"}}, "main.js"),
260
+ (
261
+ "@scope/example",
262
+ "plugin",
263
+ {"exports": {"./plugin": "./require.cjs"}},
264
+ "require.cjs",
265
+ ),
266
+ ],
267
+ )
268
+ def test_offline_real_resolution(
269
+ real_manager, tmp_path, monkeypatch, name, subpath, manifest, expected
270
+ ):
271
+ root = installed(real_manager, name, **manifest)
272
+ unrelated = tmp_path / "unrelated"
273
+ unrelated.mkdir()
274
+ monkeypatch.chdir(unrelated)
275
+ before = {
276
+ p: p.read_bytes() for p in real_manager.directory.rglob("*") if p.is_file()
277
+ }
278
+ assert real_manager.resolve_entry(name, subpath) == root / expected
279
+ after = {
280
+ p: p.read_bytes() for p in real_manager.directory.rglob("*") if p.is_file()
281
+ }
282
+ assert before == after
283
+
284
+
285
+ @pytest.mark.parametrize(
286
+ "manifest,subpath",
287
+ [
288
+ ({}, "missing"),
289
+ ({"exports": {".": "./index.js"}}, "plugin"),
290
+ ({"exports": {"import": "./import.mjs"}}, None),
291
+ ({"exports": "./missing.js"}, None),
292
+ ({"exports": "./main"}, None),
293
+ ({"exports": {"./plugin": None}}, "plugin"),
294
+ ],
295
+ )
296
+ def test_offline_missing_or_blocked(real_manager, manifest, subpath):
297
+ installed(real_manager, **manifest)
298
+ with pytest.raises(EntryResolutionError, match="example"):
299
+ real_manager.resolve_entry("example", subpath)
300
+
301
+
302
+ def test_offline_builtin(real_manager):
303
+ installed(real_manager, "fs")
304
+ with pytest.raises(EntryResolutionError, match="ERR_UNSUPPORTED_BUILTIN"):
305
+ real_manager.resolve_entry("fs")
306
+
307
+
308
+ def test_offline_internal_symlink(real_manager):
309
+ root = installed(real_manager)
310
+ (root / "plugin.js").unlink()
311
+ (root / "plugin.js").symlink_to(root / "index.js")
312
+ assert real_manager.resolve_entry("example", "plugin") == root / "index.js"
313
+
314
+
315
+ def test_offline_directory_index(real_manager):
316
+ root = installed(real_manager)
317
+ (root / "plugin.js").unlink()
318
+ (root / "plugin").mkdir()
319
+ target = root / "plugin/index.js"
320
+ target.write_text("throw new Error('PACKAGE EXECUTED');")
321
+ assert real_manager.resolve_entry("example", "plugin") == target
322
+
323
+
324
+ def test_offline_entry_symlink_escape(real_manager, tmp_path):
325
+ root = installed(real_manager)
326
+ outside = tmp_path / "outside.js"
327
+ outside.write_text("throw new Error('PACKAGE EXECUTED');")
328
+ (root / "index.js").unlink()
329
+ (root / "index.js").symlink_to(outside)
330
+ with pytest.raises(EntryResolutionError, match="escapes"):
331
+ real_manager.resolve_entry("example")
332
+
333
+
334
+ def test_no_ancestor_fallback(real_manager, tmp_path):
335
+ ancestor = NodeManager(tmp_path)
336
+ installed(ancestor)
337
+ with pytest.raises(PackageNotFoundError):
338
+ real_manager.resolve_entry("example")
339
+ assert not real_manager.directory.exists()
340
+
341
+
342
+ def test_offline_exports_directory_rejected(real_manager):
343
+ installed(real_manager, exports="./")
344
+ with pytest.raises(EntryResolutionError):
345
+ real_manager.resolve_entry("example")
346
+
347
+
348
+ def test_resolution_error_retains_stderr(manager, runner):
349
+ installed(manager)
350
+ runner.run.return_value = CommandResult(
351
+ ("node",),
352
+ 0,
353
+ json.dumps({"error": {"code": "MODULE_NOT_FOUND", "message": "missing"}}),
354
+ "runtime diagnostic",
355
+ )
356
+ with pytest.raises(EntryResolutionError, match="runtime diagnostic"):
357
+ manager.resolve_entry("example")
358
+
359
+
360
+ def test_explicit_asset_ignores_exports(real_manager):
361
+ root = installed(real_manager, exports={".": "./index.js"})
362
+ assert real_manager.resolve("example", "plugin.js") == root / "plugin.js"
363
+ with pytest.raises(EntryResolutionError, match="ERR_PACKAGE_PATH_NOT_EXPORTED"):
364
+ real_manager.resolve_entry("example", "plugin")
File without changes
File without changes
File without changes
File without changes
File without changes