myjs 0.0.2__py3-none-any.whl
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.
- myjs/__init__.py +65 -0
- myjs/__main__.py +3 -0
- myjs/_engine.py +125 -0
- myjs/cli.py +87 -0
- myjs/ffi.py +238 -0
- myjs/host.py +568 -0
- myjs/repl.py +90 -0
- myjs-0.0.2.dist-info/METADATA +90 -0
- myjs-0.0.2.dist-info/RECORD +13 -0
- myjs-0.0.2.dist-info/WHEEL +5 -0
- myjs-0.0.2.dist-info/entry_points.txt +2 -0
- myjs-0.0.2.dist-info/licenses/LICENSE +21 -0
- myjs-0.0.2.dist-info/top_level.txt +1 -0
myjs/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""myjs -- a small JavaScript interpreter for Python.
|
|
2
|
+
|
|
3
|
+
It runs a practical subset of ECMAScript (via the ``domonic_libs.acorn`` port and
|
|
4
|
+
its tree-walking evaluator) against domonic's server-side DOM, and hands scripts
|
|
5
|
+
an ``ffi`` global for calling native C libraries through ``ctypes``.
|
|
6
|
+
|
|
7
|
+
import myjs
|
|
8
|
+
|
|
9
|
+
myjs.eval("window.console.log('hi from myjs')")
|
|
10
|
+
myjs.run("script.js")
|
|
11
|
+
|
|
12
|
+
s = myjs.Session()
|
|
13
|
+
s.eval("const x = 21;")
|
|
14
|
+
s.eval("x * 2") # -> 42
|
|
15
|
+
|
|
16
|
+
Command line: ``myjs`` starts a REPL, ``myjs file.js`` executes a file.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def _read_version() -> str:
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
f = Path(__file__).resolve().parents[2] / "VERSION" # repo-root source checkout
|
|
22
|
+
if f.is_file():
|
|
23
|
+
return f.read_text().strip()
|
|
24
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
25
|
+
try:
|
|
26
|
+
return version("myjs")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
return "0+unknown"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__version__ = _read_version()
|
|
32
|
+
|
|
33
|
+
from ._engine import JSError, Session
|
|
34
|
+
|
|
35
|
+
__all__ = ["Session", "JSError", "eval", "run", "repl", "__version__"]
|
|
36
|
+
|
|
37
|
+
_shared: Session | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _session() -> Session:
|
|
41
|
+
global _shared
|
|
42
|
+
if _shared is None:
|
|
43
|
+
_shared = Session()
|
|
44
|
+
return _shared
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def eval(src: str, *, scope: dict | None = None):
|
|
48
|
+
"""Evaluate a string of JavaScript and return its completion value.
|
|
49
|
+
|
|
50
|
+
With ``scope`` a throwaway :class:`Session` is used; otherwise a process-wide
|
|
51
|
+
session persists definitions between calls.
|
|
52
|
+
"""
|
|
53
|
+
return (Session(scope=scope) if scope else _session()).eval(src)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def run(path: str, *, scope: dict | None = None):
|
|
57
|
+
"""Execute a ``.js`` file. Returns its completion value."""
|
|
58
|
+
return Session(scope=scope).run_file(path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def repl(**kwargs) -> int:
|
|
62
|
+
"""Start the interactive REPL. Returns a process exit code."""
|
|
63
|
+
from .repl import repl as _repl
|
|
64
|
+
|
|
65
|
+
return _repl(**kwargs)
|
myjs/__main__.py
ADDED
myjs/_engine.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""The myjs session: a persistent JS interpreter over domonic's DOM + ``ffi``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from domonic_libs.acorn.interpret import (
|
|
8
|
+
UNDEFINED,
|
|
9
|
+
JSThrow,
|
|
10
|
+
_Console,
|
|
11
|
+
_stringify,
|
|
12
|
+
make_interpreter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .ffi import scope as _ffi_scope
|
|
16
|
+
from .host import async_fetch as _async_fetch
|
|
17
|
+
from .host import scope as _host_scope
|
|
18
|
+
from .host import websocket_ctor as _websocket_ctor
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PrintConsole(_Console):
|
|
22
|
+
"""Captures like the base console, and echoes each line to stdout."""
|
|
23
|
+
|
|
24
|
+
def log(self, *a):
|
|
25
|
+
super().log(*a)
|
|
26
|
+
print(self.lines[-1])
|
|
27
|
+
|
|
28
|
+
warn = error = info = debug = trace = log
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class JSError(Exception):
|
|
32
|
+
"""A JavaScript exception (or syntax error) that escaped the script."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message, *, name="Error", line=None, trace=None):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.js_name = name
|
|
37
|
+
self.js_line = line
|
|
38
|
+
self.js_trace = trace
|
|
39
|
+
|
|
40
|
+
def __str__(self):
|
|
41
|
+
base = super().__str__()
|
|
42
|
+
where = f" (line {self.js_line})" if self.js_line else ""
|
|
43
|
+
stack = f"\n at {self.js_trace}" if self.js_trace else ""
|
|
44
|
+
return f"{self.js_name}: {base}{where}{stack}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _reraise(exc):
|
|
48
|
+
val = getattr(exc, "value", None)
|
|
49
|
+
if isinstance(val, dict):
|
|
50
|
+
raise JSError(
|
|
51
|
+
val.get("message", ""),
|
|
52
|
+
name=val.get("name", "Error"),
|
|
53
|
+
line=getattr(exc, "js_line", None),
|
|
54
|
+
trace=getattr(exc, "js_trace", None),
|
|
55
|
+
) from None
|
|
56
|
+
raise JSError(
|
|
57
|
+
str(exc),
|
|
58
|
+
name=type(exc).__name__,
|
|
59
|
+
line=getattr(exc, "js_line", None),
|
|
60
|
+
trace=getattr(exc, "js_trace", None),
|
|
61
|
+
) from None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Session:
|
|
65
|
+
"""One JS global scope. Feed it source with :meth:`eval`; state persists."""
|
|
66
|
+
|
|
67
|
+
def __init__(self, scope=None, console=None):
|
|
68
|
+
from . import __version__
|
|
69
|
+
|
|
70
|
+
extra = {**_ffi_scope(), **_host_scope(__version__)}
|
|
71
|
+
if scope:
|
|
72
|
+
extra.update(scope)
|
|
73
|
+
self.interp, self.console, self.document, self.window = make_interpreter(
|
|
74
|
+
extra_globals=extra, console=console
|
|
75
|
+
)
|
|
76
|
+
for name, value in (
|
|
77
|
+
("fetch", _async_fetch(self.interp.loop)),
|
|
78
|
+
("WebSocket", _websocket_ctor(self.interp.loop)),
|
|
79
|
+
):
|
|
80
|
+
self.interp.global_env.declare(name, value)
|
|
81
|
+
self.window._own[name] = value
|
|
82
|
+
|
|
83
|
+
def eval(self, src, ecma_version=2022):
|
|
84
|
+
"""Run ``src`` and return its completion value (``None`` for undefined)."""
|
|
85
|
+
try:
|
|
86
|
+
result = self.interp.run(src, ecma_version=ecma_version)
|
|
87
|
+
except (JSThrow, SyntaxError) as exc:
|
|
88
|
+
_reraise(exc)
|
|
89
|
+
return None if result is UNDEFINED else result
|
|
90
|
+
|
|
91
|
+
def run_file(self, path, ecma_version=2022):
|
|
92
|
+
p = Path(path).resolve()
|
|
93
|
+
self.interp.module_base = str(p.parent)
|
|
94
|
+
self.interp._cur_module["dir"] = str(p.parent)
|
|
95
|
+
return self.eval(p.read_text(), ecma_version=ecma_version)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def console_lines(self):
|
|
99
|
+
return list(getattr(self.console, "lines", []))
|
|
100
|
+
|
|
101
|
+
def render_html(self):
|
|
102
|
+
"""Serialise the DOM the script built into a standalone HTML document."""
|
|
103
|
+
try:
|
|
104
|
+
title = self.document.title
|
|
105
|
+
except Exception:
|
|
106
|
+
title = None
|
|
107
|
+
try:
|
|
108
|
+
root = str(self.document.documentElement)
|
|
109
|
+
except Exception:
|
|
110
|
+
root = f"<html><head></head><body>{self.document.body}</body></html>"
|
|
111
|
+
if title and isinstance(title, str) and "<title>" not in root:
|
|
112
|
+
root = root.replace("<head>", f"<head><title>{title}</title>", 1)
|
|
113
|
+
return "<!doctype html>\n" + root
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def display(value):
|
|
117
|
+
"""Human-readable form of a completion value, for a REPL."""
|
|
118
|
+
if value is None or value is UNDEFINED:
|
|
119
|
+
return "undefined"
|
|
120
|
+
if isinstance(value, str):
|
|
121
|
+
return repr(value)
|
|
122
|
+
try:
|
|
123
|
+
return _stringify(value)
|
|
124
|
+
except Exception:
|
|
125
|
+
return repr(value)
|
myjs/cli.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""``myjs`` command line: run a script, evaluate a snippet, or start a REPL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from ._engine import JSError, PrintConsole, Session
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
p = argparse.ArgumentParser(
|
|
14
|
+
prog="myjs",
|
|
15
|
+
description="A small JavaScript interpreter on domonic's DOM, with an ffi bridge to native C.",
|
|
16
|
+
)
|
|
17
|
+
p.add_argument("script", nargs="?", help="a .js file to execute (omit for a REPL)")
|
|
18
|
+
p.add_argument("args", nargs="*", help="arguments passed to the script as globalThis.argv")
|
|
19
|
+
p.add_argument("-e", "--eval", dest="code", metavar="JS", help="evaluate a string and print the result")
|
|
20
|
+
p.add_argument("-i", "--interactive", action="store_true", help="enter the REPL after running")
|
|
21
|
+
p.add_argument("--gui", action="store_true",
|
|
22
|
+
help="after running, show the DOM the script built in a native window")
|
|
23
|
+
p.add_argument("--version", action="version", version=f"myjs {__version__}")
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _open_gui(session: Session) -> int:
|
|
28
|
+
try:
|
|
29
|
+
import webview
|
|
30
|
+
except ImportError:
|
|
31
|
+
print("myjs --gui needs pywebview: pip install 'domonic-libs[app]'", file=sys.stderr)
|
|
32
|
+
return 1
|
|
33
|
+
try:
|
|
34
|
+
title = session.eval("document.title") or "myjs"
|
|
35
|
+
except JSError:
|
|
36
|
+
title = "myjs"
|
|
37
|
+
webview.create_window(str(title) or "myjs", html=session.render_html(),
|
|
38
|
+
width=960, height=720)
|
|
39
|
+
webview.start()
|
|
40
|
+
return 0
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _report(exc: JSError) -> None:
|
|
44
|
+
print(exc, file=sys.stderr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def main(argv=None) -> int:
|
|
48
|
+
args = build_parser().parse_args(argv)
|
|
49
|
+
|
|
50
|
+
if args.code is not None:
|
|
51
|
+
session = Session(console=PrintConsole())
|
|
52
|
+
try:
|
|
53
|
+
result = session.eval(args.code)
|
|
54
|
+
except JSError as exc:
|
|
55
|
+
_report(exc)
|
|
56
|
+
return 1
|
|
57
|
+
if result is not None:
|
|
58
|
+
print(result if isinstance(result, str) else session.display(result))
|
|
59
|
+
if args.interactive:
|
|
60
|
+
from .repl import repl
|
|
61
|
+
return repl(session)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
if args.script:
|
|
65
|
+
session = Session(console=PrintConsole())
|
|
66
|
+
session.interp.global_env.declare("argv", list(args.args))
|
|
67
|
+
try:
|
|
68
|
+
session.run_file(args.script)
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
print(f"myjs: cannot open {args.script}", file=sys.stderr)
|
|
71
|
+
return 1
|
|
72
|
+
except JSError as exc:
|
|
73
|
+
_report(exc)
|
|
74
|
+
return 1
|
|
75
|
+
if args.gui:
|
|
76
|
+
return _open_gui(session)
|
|
77
|
+
if args.interactive:
|
|
78
|
+
from .repl import repl
|
|
79
|
+
return repl(session)
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
from .repl import repl
|
|
83
|
+
return repl()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
raise SystemExit(main())
|
myjs/ffi.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""``ffi`` -- call native C libraries from JavaScript, via Python's ``ctypes``.
|
|
2
|
+
|
|
3
|
+
Exposed to scripts as the global ``ffi`` (plus a small ``os`` namespace). The
|
|
4
|
+
interpreter reaches arbitrary Python objects through ``getattr`` / ``setattr`` /
|
|
5
|
+
``__call__``, so the wrappers here are thin: a :class:`Library` yields
|
|
6
|
+
:class:`CFunc` proxies that marshal JS values to and from C.
|
|
7
|
+
|
|
8
|
+
const libc = ffi.loadLibrary("c");
|
|
9
|
+
libc.abs.argtypes = [ffi.types.int];
|
|
10
|
+
libc.abs.restype = ffi.types.int;
|
|
11
|
+
console.log(libc.abs(-42)); // 42
|
|
12
|
+
|
|
13
|
+
Strings are encoded to UTF-8 ``bytes`` on the way in and decoded on the way out;
|
|
14
|
+
a :class:`Buffer` (from ``ffi.createStringBuffer``) passes its underlying storage
|
|
15
|
+
straight through. C -> JS callbacks are built with ``ffi.callback``.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ctypes
|
|
21
|
+
import ctypes.util
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
25
|
+
# --- C type table ---------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
TYPES = {
|
|
28
|
+
"void": None,
|
|
29
|
+
"bool": ctypes.c_bool,
|
|
30
|
+
"char": ctypes.c_char,
|
|
31
|
+
"schar": ctypes.c_char,
|
|
32
|
+
"uchar": ctypes.c_ubyte,
|
|
33
|
+
"byte": ctypes.c_ubyte,
|
|
34
|
+
"short": ctypes.c_short,
|
|
35
|
+
"ushort": ctypes.c_ushort,
|
|
36
|
+
"int": ctypes.c_int,
|
|
37
|
+
"uint": ctypes.c_uint,
|
|
38
|
+
"long": ctypes.c_long,
|
|
39
|
+
"ulong": ctypes.c_ulong,
|
|
40
|
+
"longlong": ctypes.c_longlong,
|
|
41
|
+
"ulonglong": ctypes.c_ulonglong,
|
|
42
|
+
"int8": ctypes.c_int8,
|
|
43
|
+
"uint8": ctypes.c_uint8,
|
|
44
|
+
"int16": ctypes.c_int16,
|
|
45
|
+
"uint16": ctypes.c_uint16,
|
|
46
|
+
"int32": ctypes.c_int32,
|
|
47
|
+
"uint32": ctypes.c_uint32,
|
|
48
|
+
"int64": ctypes.c_int64,
|
|
49
|
+
"uint64": ctypes.c_uint64,
|
|
50
|
+
"size_t": ctypes.c_size_t,
|
|
51
|
+
"ssize_t": ctypes.c_ssize_t,
|
|
52
|
+
"float": ctypes.c_float,
|
|
53
|
+
"double": ctypes.c_double,
|
|
54
|
+
"string": ctypes.c_char_p,
|
|
55
|
+
"wstring": ctypes.c_wchar_p,
|
|
56
|
+
"pointer": ctypes.c_void_p,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- marshalling ---------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def _to_c(value):
|
|
63
|
+
"""JS value -> something ctypes accepts as an argument."""
|
|
64
|
+
if isinstance(value, Buffer):
|
|
65
|
+
return value._buf
|
|
66
|
+
if isinstance(value, CFunc):
|
|
67
|
+
return value._ptr
|
|
68
|
+
if isinstance(value, str):
|
|
69
|
+
return value.encode("utf-8")
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return value
|
|
72
|
+
if isinstance(value, float) and value.is_integer():
|
|
73
|
+
return int(value)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _from_c(value):
|
|
78
|
+
"""ctypes return value -> JS-friendly value."""
|
|
79
|
+
if isinstance(value, bytes):
|
|
80
|
+
return value.decode("utf-8", "replace")
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- library / function proxies ---------------------------------------------
|
|
85
|
+
|
|
86
|
+
class CFunc:
|
|
87
|
+
"""A single C function. ``argtypes`` / ``restype`` accept ``ffi.types`` and
|
|
88
|
+
are forwarded to the underlying ctypes pointer."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, ptr, name):
|
|
91
|
+
object.__setattr__(self, "_ptr", ptr)
|
|
92
|
+
object.__setattr__(self, "_name", name)
|
|
93
|
+
|
|
94
|
+
def __call__(self, *args):
|
|
95
|
+
try:
|
|
96
|
+
return _from_c(self._ptr(*[_to_c(a) for a in args]))
|
|
97
|
+
except Exception as exc: # surfaced to JS as a thrown error
|
|
98
|
+
raise RuntimeError(f"ffi call {self._name}(): {exc}") from exc
|
|
99
|
+
|
|
100
|
+
def __setattr__(self, key, value):
|
|
101
|
+
if key == "argtypes":
|
|
102
|
+
self._ptr.argtypes = [t for t in value if t is not None] or None
|
|
103
|
+
elif key == "restype":
|
|
104
|
+
self._ptr.restype = value
|
|
105
|
+
elif key == "errcheck":
|
|
106
|
+
self._ptr.errcheck = value
|
|
107
|
+
else:
|
|
108
|
+
object.__setattr__(self, key, value)
|
|
109
|
+
|
|
110
|
+
def __getattr__(self, key):
|
|
111
|
+
if key.startswith("__") and key.endswith("__"):
|
|
112
|
+
raise AttributeError(key)
|
|
113
|
+
return getattr(object.__getattribute__(self, "_ptr"), key)
|
|
114
|
+
|
|
115
|
+
def __repr__(self):
|
|
116
|
+
return f"<ffi function {self._name}>"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class Library:
|
|
120
|
+
"""A loaded shared library. ``lib.<name>`` resolves a :class:`CFunc`."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, cdll, name):
|
|
123
|
+
object.__setattr__(self, "_cdll", cdll)
|
|
124
|
+
object.__setattr__(self, "_name", name)
|
|
125
|
+
object.__setattr__(self, "_cache", {})
|
|
126
|
+
|
|
127
|
+
def __getattr__(self, key):
|
|
128
|
+
if key.startswith("__") and key.endswith("__"):
|
|
129
|
+
raise AttributeError(key)
|
|
130
|
+
cache = object.__getattribute__(self, "_cache")
|
|
131
|
+
if key not in cache:
|
|
132
|
+
try:
|
|
133
|
+
cache[key] = CFunc(getattr(self._cdll, key), key)
|
|
134
|
+
except AttributeError:
|
|
135
|
+
raise AttributeError(f"{self._name!r} has no symbol {key!r}")
|
|
136
|
+
return cache[key]
|
|
137
|
+
|
|
138
|
+
def __repr__(self):
|
|
139
|
+
return f"<ffi library {self._name!r}>"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Buffer:
|
|
143
|
+
"""A mutable C string / byte buffer (``ffi.createStringBuffer``)."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, buf):
|
|
146
|
+
object.__setattr__(self, "_buf", buf)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def value(self):
|
|
150
|
+
return self._buf.value.decode("utf-8", "replace")
|
|
151
|
+
|
|
152
|
+
@value.setter
|
|
153
|
+
def value(self, v):
|
|
154
|
+
self._buf.value = v.encode("utf-8") if isinstance(v, str) else v
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def raw(self):
|
|
158
|
+
return self._buf.raw
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def length(self):
|
|
162
|
+
return len(self._buf)
|
|
163
|
+
|
|
164
|
+
def __len__(self):
|
|
165
|
+
return len(self._buf)
|
|
166
|
+
|
|
167
|
+
def __repr__(self):
|
|
168
|
+
return f"<ffi buffer {self.value!r}>"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# --- loading -----------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
def load_library(name):
|
|
174
|
+
"""Load a shared library by short name (``"c"``, ``"m"``), by path
|
|
175
|
+
(``"./libfoo.so"``), or -- on macOS -- by system-framework name
|
|
176
|
+
(``"CoreFoundation"``)."""
|
|
177
|
+
if os.sep in str(name) or str(name).endswith((".so", ".dylib", ".dll")):
|
|
178
|
+
return Library(ctypes.CDLL(name), name)
|
|
179
|
+
found = ctypes.util.find_library(name)
|
|
180
|
+
if found:
|
|
181
|
+
return Library(ctypes.CDLL(found), name)
|
|
182
|
+
if sys.platform == "darwin":
|
|
183
|
+
fw = f"/System/Library/Frameworks/{name}.framework/{name}"
|
|
184
|
+
if os.path.exists(fw):
|
|
185
|
+
return Library(ctypes.CDLL(fw), name)
|
|
186
|
+
return Library(ctypes.CDLL(name), name) # let ctypes raise a clear OSError
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def create_string_buffer(init):
|
|
190
|
+
if isinstance(init, str):
|
|
191
|
+
return Buffer(ctypes.create_string_buffer(init.encode("utf-8")))
|
|
192
|
+
if isinstance(init, (int, float)):
|
|
193
|
+
return Buffer(ctypes.create_string_buffer(int(init)))
|
|
194
|
+
if isinstance(init, (bytes, bytearray)):
|
|
195
|
+
return Buffer(ctypes.create_string_buffer(bytes(init)))
|
|
196
|
+
raise TypeError("createStringBuffer expects a string or a size")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def make_callback(restype, argtypes, fn):
|
|
200
|
+
"""Wrap a JS function as a C callback pointer (``CFUNCTYPE``)."""
|
|
201
|
+
proto = ctypes.CFUNCTYPE(restype, *[t for t in (argtypes or []) if t is not None])
|
|
202
|
+
|
|
203
|
+
def thunk(*c_args):
|
|
204
|
+
return _to_c(fn(*[_from_c(a) for a in c_args]))
|
|
205
|
+
|
|
206
|
+
holder = proto(thunk)
|
|
207
|
+
_CALLBACK_KEEPALIVE.append(holder) # ctypes callbacks must outlive the call
|
|
208
|
+
return holder
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_CALLBACK_KEEPALIVE = []
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def c_string(ptr):
|
|
215
|
+
"""Read a NUL-terminated C string at ``ptr`` (an int address or c_char_p)."""
|
|
216
|
+
return ctypes.cast(ptr, ctypes.c_char_p).value.decode("utf-8", "replace")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- the scope handed to the interpreter -----------------------------------
|
|
220
|
+
|
|
221
|
+
FFI = {
|
|
222
|
+
"loadLibrary": load_library,
|
|
223
|
+
"createStringBuffer": create_string_buffer,
|
|
224
|
+
"callback": make_callback,
|
|
225
|
+
"string": c_string,
|
|
226
|
+
"sizeof": lambda t: ctypes.sizeof(t),
|
|
227
|
+
"addressof": lambda b: ctypes.addressof(b._buf if isinstance(b, Buffer) else b),
|
|
228
|
+
"cast": lambda v, t: ctypes.cast(_to_c(v), t),
|
|
229
|
+
"pointer": ctypes.pointer,
|
|
230
|
+
"byref": ctypes.byref,
|
|
231
|
+
"NULL": None,
|
|
232
|
+
"errno": lambda: ctypes.get_errno(),
|
|
233
|
+
"types": dict(TYPES),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def scope():
|
|
237
|
+
"""Fresh copies so one session cannot mutate another's ``ffi.types``."""
|
|
238
|
+
return {"ffi": {**FFI, "types": dict(TYPES)}}
|
myjs/host.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""Host bindings for myjs -- the things that make "it's just JavaScript" land:
|
|
2
|
+
a filesystem (``fs``), shell (``sh``), synchronous HTTP (``http`` / ``fetch``),
|
|
3
|
+
a ``process`` object, and ``py`` for reaching into the whole Python ecosystem.
|
|
4
|
+
|
|
5
|
+
Everything here is synchronous on purpose -- there is no event loop yet, and a
|
|
6
|
+
one-liner that blocks is what reads as magic in a REPL. All of it is handed to
|
|
7
|
+
scripts as globals (see :func:`scope`).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64 as _base64
|
|
13
|
+
import hashlib as _hashlib
|
|
14
|
+
import importlib
|
|
15
|
+
import json as _json
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import shutil
|
|
19
|
+
import socket as _socket
|
|
20
|
+
import ssl as _ssl
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time as _time
|
|
25
|
+
import urllib.error
|
|
26
|
+
import urllib.parse
|
|
27
|
+
import urllib.request
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from domonic_libs.acorn.interpret import JSArray, JSObject
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- fs --------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
def _read_file(path, encoding="utf-8"):
|
|
36
|
+
data = Path(path).read_bytes()
|
|
37
|
+
return data if encoding in (None, "buffer", "binary") else data.decode(encoding)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_file(path, data, encoding="utf-8"):
|
|
41
|
+
p = Path(path)
|
|
42
|
+
if isinstance(data, (bytes, bytearray)):
|
|
43
|
+
p.write_bytes(bytes(data))
|
|
44
|
+
else:
|
|
45
|
+
p.write_text(str(data), encoding=encoding)
|
|
46
|
+
return len(data)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _append_file(path, data, encoding="utf-8"):
|
|
50
|
+
with open(path, "a", encoding=encoding) as fh:
|
|
51
|
+
fh.write(str(data))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _stat(path):
|
|
55
|
+
st = os.stat(path)
|
|
56
|
+
return JSObject({
|
|
57
|
+
"size": st.st_size,
|
|
58
|
+
"mtimeMs": st.st_mtime * 1000,
|
|
59
|
+
"isFile": os.path.isfile(path),
|
|
60
|
+
"isDirectory": os.path.isdir(path),
|
|
61
|
+
"mode": st.st_mode,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _mkdir(path, opts=None):
|
|
66
|
+
recursive = bool(opts and opts.get("recursive"))
|
|
67
|
+
Path(path).mkdir(parents=recursive, exist_ok=recursive)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _rm(path, opts=None):
|
|
71
|
+
opts = opts or {}
|
|
72
|
+
p = Path(path)
|
|
73
|
+
if p.is_dir() and opts.get("recursive"):
|
|
74
|
+
shutil.rmtree(p, ignore_errors=bool(opts.get("force")))
|
|
75
|
+
elif p.exists() or not opts.get("force"):
|
|
76
|
+
p.unlink()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
FS = {
|
|
80
|
+
"readFileSync": _read_file,
|
|
81
|
+
"writeFileSync": _write_file,
|
|
82
|
+
"appendFileSync": _append_file,
|
|
83
|
+
"existsSync": lambda p: Path(p).exists(),
|
|
84
|
+
"readdirSync": lambda p: JSArray(sorted(os.listdir(p))),
|
|
85
|
+
"mkdirSync": _mkdir,
|
|
86
|
+
"rmSync": _rm,
|
|
87
|
+
"renameSync": lambda a, b: os.replace(a, b),
|
|
88
|
+
"copyFileSync": lambda a, b: shutil.copyfile(a, b),
|
|
89
|
+
"statSync": _stat,
|
|
90
|
+
"realpathSync": lambda p: str(Path(p).resolve()),
|
|
91
|
+
"cwd": os.getcwd,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
PATH = {
|
|
95
|
+
"join": lambda *parts: os.path.join(*[str(p) for p in parts]),
|
|
96
|
+
"dirname": lambda p: os.path.dirname(p),
|
|
97
|
+
"basename": lambda p, ext="": os.path.basename(p)[: -len(ext)] if ext and os.path.basename(p).endswith(ext) else os.path.basename(p),
|
|
98
|
+
"extname": lambda p: os.path.splitext(p)[1],
|
|
99
|
+
"resolve": lambda *parts: str(Path(*[str(p) for p in parts]).resolve()) if parts else os.getcwd(),
|
|
100
|
+
"sep": os.sep,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- sh -------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
class ShellResult(JSObject):
|
|
107
|
+
def __init__(self, proc):
|
|
108
|
+
super().__init__({
|
|
109
|
+
"stdout": proc.stdout,
|
|
110
|
+
"stderr": proc.stderr,
|
|
111
|
+
"code": proc.returncode,
|
|
112
|
+
"ok": proc.returncode == 0,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
def __str__(self):
|
|
116
|
+
return self["stdout"]
|
|
117
|
+
|
|
118
|
+
def trim(self):
|
|
119
|
+
return self["stdout"].strip()
|
|
120
|
+
|
|
121
|
+
def json(self):
|
|
122
|
+
return _to_js(_json.loads(self["stdout"]))
|
|
123
|
+
|
|
124
|
+
def lines(self):
|
|
125
|
+
return JSArray(self["stdout"].splitlines())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _sh(cmd, opts=None):
|
|
129
|
+
opts = opts or {}
|
|
130
|
+
proc = subprocess.run(
|
|
131
|
+
cmd, shell=True, capture_output=True, text=True,
|
|
132
|
+
cwd=opts.get("cwd"), timeout=opts.get("timeout"),
|
|
133
|
+
)
|
|
134
|
+
return ShellResult(proc)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _say(text, *_):
|
|
138
|
+
if sys.platform == "darwin":
|
|
139
|
+
subprocess.run(["say", str(text)], check=False)
|
|
140
|
+
elif sys.platform.startswith("linux") and shutil.which("espeak"):
|
|
141
|
+
subprocess.run(["espeak", str(text)], check=False)
|
|
142
|
+
else:
|
|
143
|
+
print(f"\a(say) {text}")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _osa(script):
|
|
147
|
+
subprocess.run(["osascript", "-e", script], check=False, capture_output=True)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _notify(text, title="myjs"):
|
|
151
|
+
if sys.platform == "darwin":
|
|
152
|
+
_osa(f'display notification {_json.dumps(str(text))} with title {_json.dumps(str(title))}')
|
|
153
|
+
elif shutil.which("notify-send"):
|
|
154
|
+
subprocess.run(["notify-send", str(title), str(text)], check=False)
|
|
155
|
+
else:
|
|
156
|
+
print(f"(notify) {title}: {text}")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _alert(text, title="myjs"):
|
|
160
|
+
if sys.platform == "darwin":
|
|
161
|
+
_osa(f'display dialog {_json.dumps(str(text))} with title {_json.dumps(str(title))} '
|
|
162
|
+
f'buttons {{"OK"}} default button "OK"')
|
|
163
|
+
else:
|
|
164
|
+
print(f"(alert) {title}: {text}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _open(target, *_):
|
|
168
|
+
opener = {"darwin": "open", "win32": "start"}.get(sys.platform, "xdg-open")
|
|
169
|
+
subprocess.run([opener, str(target)], check=False, shell=(opener == "start"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# --- http / fetch --------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
class Response(JSObject):
|
|
175
|
+
def __init__(self, url, status, headers, body):
|
|
176
|
+
super().__init__({
|
|
177
|
+
"url": url,
|
|
178
|
+
"status": status,
|
|
179
|
+
"ok": 200 <= status < 300,
|
|
180
|
+
"headers": JSObject({k.lower(): v for k, v in headers}),
|
|
181
|
+
})
|
|
182
|
+
self._body = body
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def text(self):
|
|
186
|
+
return self._body.decode("utf-8", "replace")
|
|
187
|
+
|
|
188
|
+
def json(self):
|
|
189
|
+
return _to_js(_json.loads(self._body.decode("utf-8", "replace")))
|
|
190
|
+
|
|
191
|
+
def bytes(self):
|
|
192
|
+
return self._body
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _request(url, opts=None):
|
|
196
|
+
opts = opts or {}
|
|
197
|
+
data = opts.get("body")
|
|
198
|
+
if data is not None and not isinstance(data, (bytes, bytearray)):
|
|
199
|
+
data = str(data).encode("utf-8")
|
|
200
|
+
headers = {k: str(v) for k, v in (opts.get("headers") or {}).items()}
|
|
201
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=opts.get("method"))
|
|
202
|
+
try:
|
|
203
|
+
with urllib.request.urlopen(req, timeout=opts.get("timeout", 30)) as resp:
|
|
204
|
+
return Response(resp.geturl(), resp.status, resp.getheaders(), resp.read())
|
|
205
|
+
except urllib.error.HTTPError as e:
|
|
206
|
+
return Response(url, e.code, list(e.headers.items()), e.read())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
HTTP = {
|
|
210
|
+
"get": lambda url, opts=None: _request(url, {**(opts or {}), "method": "GET"}),
|
|
211
|
+
"post": lambda url, body=None, opts=None: _request(url, {**(opts or {}), "method": "POST", "body": body}),
|
|
212
|
+
"request": _request,
|
|
213
|
+
"requestSync": _request,
|
|
214
|
+
"serve": None, # set in scope() -- needs the calling session's interpreter
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def async_fetch(loop):
|
|
219
|
+
"""A real asynchronous ``fetch``: the request runs on a background thread and
|
|
220
|
+
settles a promise through ``loop``, so ``await Promise.all([...])`` genuinely
|
|
221
|
+
runs requests concurrently."""
|
|
222
|
+
import threading
|
|
223
|
+
|
|
224
|
+
from domonic_libs.acorn.interpret import _make_error, _Promise
|
|
225
|
+
|
|
226
|
+
def fetch(url, opts=None):
|
|
227
|
+
p = _Promise(loop)
|
|
228
|
+
loop.io_start()
|
|
229
|
+
|
|
230
|
+
def worker():
|
|
231
|
+
try:
|
|
232
|
+
resp = _request(url, opts)
|
|
233
|
+
loop.io_finish(lambda: p._resolve(resp))
|
|
234
|
+
except Exception as exc: # noqa: BLE001
|
|
235
|
+
loop.io_finish(lambda exc=exc: p._reject(_make_error("TypeError", f"fetch failed: {exc}")))
|
|
236
|
+
|
|
237
|
+
threading.Thread(target=worker, daemon=True).start()
|
|
238
|
+
return p
|
|
239
|
+
|
|
240
|
+
return fetch
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _make_serve():
|
|
244
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
245
|
+
|
|
246
|
+
def serve(port, handler):
|
|
247
|
+
class H(BaseHTTPRequestHandler):
|
|
248
|
+
def _run(self):
|
|
249
|
+
length = int(self.headers.get("content-length", 0) or 0)
|
|
250
|
+
body = self.rfile.read(length).decode("utf-8", "replace") if length else ""
|
|
251
|
+
req = JSObject({
|
|
252
|
+
"method": self.command,
|
|
253
|
+
"path": self.path,
|
|
254
|
+
"headers": JSObject(dict(self.headers)),
|
|
255
|
+
"body": body,
|
|
256
|
+
})
|
|
257
|
+
out = handler(req)
|
|
258
|
+
if isinstance(out, dict):
|
|
259
|
+
status = int(out.get("status", 200))
|
|
260
|
+
text = str(out.get("body", ""))
|
|
261
|
+
extra = out.get("headers") or {}
|
|
262
|
+
else:
|
|
263
|
+
status, text, extra = 200, str(out), {}
|
|
264
|
+
payload = text.encode("utf-8")
|
|
265
|
+
self.send_response(status)
|
|
266
|
+
self.send_header("content-type", extra.get("content-type", "text/html; charset=utf-8"))
|
|
267
|
+
self.send_header("content-length", str(len(payload)))
|
|
268
|
+
for k, v in extra.items():
|
|
269
|
+
if k != "content-type":
|
|
270
|
+
self.send_header(k, str(v))
|
|
271
|
+
self.end_headers()
|
|
272
|
+
self.wfile.write(payload)
|
|
273
|
+
|
|
274
|
+
do_GET = do_POST = do_PUT = do_DELETE = _run
|
|
275
|
+
|
|
276
|
+
def log_message(self, *a): # quiet
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
srv = HTTPServer(("127.0.0.1", int(port)), H)
|
|
280
|
+
print(f"myjs http.serve listening on http://127.0.0.1:{int(port)} (Ctrl-C to stop)")
|
|
281
|
+
try:
|
|
282
|
+
srv.serve_forever()
|
|
283
|
+
except KeyboardInterrupt:
|
|
284
|
+
print("\nstopped")
|
|
285
|
+
finally:
|
|
286
|
+
srv.server_close()
|
|
287
|
+
|
|
288
|
+
return serve
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# --- WebSocket (RFC 6455 client, dependency-free) --------------------------
|
|
292
|
+
|
|
293
|
+
def _fire(handler, event):
|
|
294
|
+
if callable(handler):
|
|
295
|
+
handler(event)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
class WebSocketClient:
|
|
299
|
+
CONNECTING, OPEN, CLOSING, CLOSED = 0, 1, 2, 3
|
|
300
|
+
|
|
301
|
+
def __init__(self, url, loop, protocols=None):
|
|
302
|
+
self.url = url
|
|
303
|
+
self.protocol = ""
|
|
304
|
+
self.readyState = self.CONNECTING
|
|
305
|
+
self.onopen = self.onmessage = self.onclose = self.onerror = None
|
|
306
|
+
self._loop = loop
|
|
307
|
+
self._sock = None
|
|
308
|
+
self._buf = b""
|
|
309
|
+
loop.io_start()
|
|
310
|
+
threading.Thread(target=self._run, daemon=True).start()
|
|
311
|
+
|
|
312
|
+
# -- lifecycle
|
|
313
|
+
def _run(self):
|
|
314
|
+
try:
|
|
315
|
+
self._connect()
|
|
316
|
+
self.readyState = self.OPEN
|
|
317
|
+
self._loop.post(lambda: _fire(self.onopen, JSObject({"type": "open"})))
|
|
318
|
+
self._read_loop()
|
|
319
|
+
except Exception as exc: # noqa: BLE001
|
|
320
|
+
self._loop.post(lambda exc=exc: _fire(self.onerror, JSObject({"type": "error", "message": str(exc)})))
|
|
321
|
+
finally:
|
|
322
|
+
self.readyState = self.CLOSED
|
|
323
|
+
try:
|
|
324
|
+
self._sock.close()
|
|
325
|
+
except Exception:
|
|
326
|
+
pass
|
|
327
|
+
self._loop.io_finish(lambda: _fire(self.onclose, JSObject({"type": "close"})))
|
|
328
|
+
|
|
329
|
+
def _connect(self):
|
|
330
|
+
u = urllib.parse.urlparse(self.url)
|
|
331
|
+
port = u.port or (443 if u.scheme == "wss" else 80)
|
|
332
|
+
raw = _socket.create_connection((u.hostname, port), timeout=10)
|
|
333
|
+
if u.scheme == "wss":
|
|
334
|
+
raw = _ssl.create_default_context().wrap_socket(raw, server_hostname=u.hostname)
|
|
335
|
+
key = _base64.b64encode(os.urandom(16)).decode()
|
|
336
|
+
path = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
337
|
+
raw.sendall((
|
|
338
|
+
f"GET {path} HTTP/1.1\r\nHost: {u.hostname}\r\nUpgrade: websocket\r\n"
|
|
339
|
+
f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\nSec-WebSocket-Version: 13\r\n\r\n"
|
|
340
|
+
).encode())
|
|
341
|
+
resp = b""
|
|
342
|
+
while b"\r\n\r\n" not in resp:
|
|
343
|
+
chunk = raw.recv(4096)
|
|
344
|
+
if not chunk:
|
|
345
|
+
raise ConnectionError("connection closed during handshake")
|
|
346
|
+
resp += chunk
|
|
347
|
+
if b" 101 " not in resp.split(b"\r\n", 1)[0]:
|
|
348
|
+
raise RuntimeError(f"handshake rejected: {resp.splitlines()[0].decode('latin1')}")
|
|
349
|
+
self._sock = raw
|
|
350
|
+
self._buf = resp.split(b"\r\n\r\n", 1)[1]
|
|
351
|
+
|
|
352
|
+
# -- framing
|
|
353
|
+
def _recv(self, n):
|
|
354
|
+
while len(self._buf) < n:
|
|
355
|
+
chunk = self._sock.recv(65536)
|
|
356
|
+
if not chunk:
|
|
357
|
+
raise ConnectionError("closed")
|
|
358
|
+
self._buf += chunk
|
|
359
|
+
out, self._buf = self._buf[:n], self._buf[n:]
|
|
360
|
+
return out
|
|
361
|
+
|
|
362
|
+
def _read_loop(self):
|
|
363
|
+
while self.readyState == self.OPEN:
|
|
364
|
+
b1, b2 = self._recv(2)
|
|
365
|
+
opcode = b1 & 0x0F
|
|
366
|
+
length = b2 & 0x7F
|
|
367
|
+
if length == 126:
|
|
368
|
+
length = int.from_bytes(self._recv(2), "big")
|
|
369
|
+
elif length == 127:
|
|
370
|
+
length = int.from_bytes(self._recv(8), "big")
|
|
371
|
+
payload = self._recv(length)
|
|
372
|
+
if b2 & 0x80:
|
|
373
|
+
mask = payload[:4]
|
|
374
|
+
payload = bytes(c ^ mask[i % 4] for i, c in enumerate(payload[4:]))
|
|
375
|
+
if opcode == 0x8:
|
|
376
|
+
return
|
|
377
|
+
if opcode == 0x9:
|
|
378
|
+
self._frame(0xA, payload)
|
|
379
|
+
continue
|
|
380
|
+
if opcode in (0x1, 0x2):
|
|
381
|
+
data = payload.decode("utf-8", "replace") if opcode == 0x1 else payload
|
|
382
|
+
self._loop.post(lambda d=data: _fire(self.onmessage, JSObject({"type": "message", "data": d})))
|
|
383
|
+
|
|
384
|
+
def _frame(self, opcode, data):
|
|
385
|
+
if isinstance(data, str):
|
|
386
|
+
data = data.encode("utf-8")
|
|
387
|
+
mask = os.urandom(4)
|
|
388
|
+
body = bytes(c ^ mask[i % 4] for i, c in enumerate(data))
|
|
389
|
+
header = bytes([0x80 | opcode])
|
|
390
|
+
n = len(data)
|
|
391
|
+
if n < 126:
|
|
392
|
+
header += bytes([0x80 | n])
|
|
393
|
+
elif n < 65536:
|
|
394
|
+
header += bytes([0x80 | 126]) + n.to_bytes(2, "big")
|
|
395
|
+
else:
|
|
396
|
+
header += bytes([0x80 | 127]) + n.to_bytes(8, "big")
|
|
397
|
+
self._sock.sendall(header + mask + body)
|
|
398
|
+
|
|
399
|
+
# -- JS surface
|
|
400
|
+
def send(self, data):
|
|
401
|
+
if self.readyState != self.OPEN:
|
|
402
|
+
raise RuntimeError("WebSocket is not open")
|
|
403
|
+
self._frame(0x1 if isinstance(data, str) else 0x2, data)
|
|
404
|
+
|
|
405
|
+
def close(self, code=1000, reason=""):
|
|
406
|
+
if self.readyState == self.OPEN:
|
|
407
|
+
self.readyState = self.CLOSING
|
|
408
|
+
try:
|
|
409
|
+
self._frame(0x8, int(code).to_bytes(2, "big"))
|
|
410
|
+
except Exception:
|
|
411
|
+
pass
|
|
412
|
+
|
|
413
|
+
def addEventListener(self, event, fn):
|
|
414
|
+
setattr(self, "on" + event, fn)
|
|
415
|
+
|
|
416
|
+
def removeEventListener(self, event, fn=None):
|
|
417
|
+
setattr(self, "on" + event, None)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def websocket_ctor(loop):
|
|
421
|
+
def WebSocket(url, protocols=None, *_):
|
|
422
|
+
return WebSocketClient(url, loop, protocols)
|
|
423
|
+
WebSocket.CONNECTING = 0
|
|
424
|
+
WebSocket.OPEN = 1
|
|
425
|
+
WebSocket.CLOSING = 2
|
|
426
|
+
WebSocket.CLOSED = 3
|
|
427
|
+
return WebSocket
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# --- py: the whole Python ecosystem ------------------------------------------
|
|
431
|
+
|
|
432
|
+
def _to_js(v):
|
|
433
|
+
if isinstance(v, dict):
|
|
434
|
+
return JSObject({k: _to_js(val) for k, val in v.items()})
|
|
435
|
+
if isinstance(v, (list, tuple)):
|
|
436
|
+
return JSArray(_to_js(x) for x in v)
|
|
437
|
+
return v
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
PY = {
|
|
441
|
+
"import": importlib.import_module,
|
|
442
|
+
"eval": lambda expr: eval(expr, {}), # noqa: S307 - explicit host feature
|
|
443
|
+
"list": lambda it: JSArray(it),
|
|
444
|
+
"dict": lambda o: JSObject(dict(o)),
|
|
445
|
+
"tuple": lambda it: tuple(it),
|
|
446
|
+
"repr": repr,
|
|
447
|
+
"str": str,
|
|
448
|
+
"int": int,
|
|
449
|
+
"float": float,
|
|
450
|
+
"len": len,
|
|
451
|
+
"dir": lambda o: JSArray(dir(o)),
|
|
452
|
+
"type": lambda o: type(o).__name__,
|
|
453
|
+
"toJS": _to_js,
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
# --- os (Node-flavoured) -------------------------------------------------
|
|
458
|
+
|
|
459
|
+
def _totalmem():
|
|
460
|
+
try:
|
|
461
|
+
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
|
462
|
+
except (ValueError, AttributeError, OSError):
|
|
463
|
+
return None
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _cpus():
|
|
467
|
+
n = os.cpu_count() or 1
|
|
468
|
+
model = platform.processor() or platform.machine()
|
|
469
|
+
if sys.platform == "darwin":
|
|
470
|
+
try:
|
|
471
|
+
model = subprocess.run(
|
|
472
|
+
["sysctl", "-n", "machdep.cpu.brand_string"],
|
|
473
|
+
capture_output=True, text=True, check=True,
|
|
474
|
+
).stdout.strip() or model
|
|
475
|
+
except Exception:
|
|
476
|
+
pass
|
|
477
|
+
return JSArray(JSObject({"model": model}) for _ in range(n))
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
OS = {
|
|
481
|
+
"platform": lambda: sys.platform if sys.platform != "win32" else "win32",
|
|
482
|
+
"arch": lambda: {"AMD64": "x64", "x86_64": "x64"}.get(platform.machine(), platform.machine()),
|
|
483
|
+
"type": platform.system,
|
|
484
|
+
"release": platform.release,
|
|
485
|
+
"version": lambda: platform.version(),
|
|
486
|
+
"hostname": platform.node,
|
|
487
|
+
"homedir": lambda: os.path.expanduser("~"),
|
|
488
|
+
"tmpdir": lambda: __import__("tempfile").gettempdir(),
|
|
489
|
+
"cpus": _cpus,
|
|
490
|
+
"totalmem": _totalmem,
|
|
491
|
+
"uptime": lambda: _time.clock_gettime(_time.CLOCK_MONOTONIC) if hasattr(_time, "CLOCK_MONOTONIC") else None,
|
|
492
|
+
"userInfo": lambda *_: JSObject({
|
|
493
|
+
"username": __import__("getpass").getuser(),
|
|
494
|
+
"homedir": os.path.expanduser("~"),
|
|
495
|
+
"shell": os.environ.get("SHELL"),
|
|
496
|
+
}),
|
|
497
|
+
"EOL": os.linesep,
|
|
498
|
+
"python": platform.python_version(),
|
|
499
|
+
# convenience shorthands (not Node, but handy in a REPL)
|
|
500
|
+
"system": platform.system,
|
|
501
|
+
"name": platform.system(),
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# --- process --------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
def _process(myjs_version):
|
|
508
|
+
return JSObject({
|
|
509
|
+
"argv": JSArray(sys.argv[1:]),
|
|
510
|
+
"env": JSObject(dict(os.environ)),
|
|
511
|
+
"platform": sys.platform,
|
|
512
|
+
"arch": platform.machine(),
|
|
513
|
+
"pid": os.getpid(),
|
|
514
|
+
"version": f"myjs/{myjs_version}",
|
|
515
|
+
"cwd": os.getcwd,
|
|
516
|
+
"chdir": os.chdir,
|
|
517
|
+
"exit": lambda code=0: sys.exit(int(code)),
|
|
518
|
+
"hrtime": lambda *_: _time.perf_counter_ns(),
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
# --- assembly ------------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def scope(myjs_version="0.0.1"):
|
|
525
|
+
fs = dict(FS)
|
|
526
|
+
http = dict(HTTP)
|
|
527
|
+
http["serve"] = _make_serve()
|
|
528
|
+
os_ns = dict(OS)
|
|
529
|
+
require_table = {
|
|
530
|
+
"fs": fs, "path": dict(PATH), "http": http, "https": http, "os": os_ns,
|
|
531
|
+
"child_process": {"exec": _sh, "execSync": lambda c, o=None: _sh(c, o)["stdout"]},
|
|
532
|
+
"crypto": {
|
|
533
|
+
"md5": lambda s: _hashlib.md5(str(s).encode()).hexdigest(),
|
|
534
|
+
"sha256": lambda s: _hashlib.sha256(str(s).encode()).hexdigest(),
|
|
535
|
+
"randomUUID": lambda: __import__("uuid").uuid4().hex,
|
|
536
|
+
},
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
def require(name):
|
|
540
|
+
if name.startswith("node:"):
|
|
541
|
+
name = name[5:]
|
|
542
|
+
if name in require_table and require_table[name] is not None:
|
|
543
|
+
return require_table[name]
|
|
544
|
+
return importlib.import_module(name) # fall through to any Python module
|
|
545
|
+
|
|
546
|
+
return {
|
|
547
|
+
"fs": fs,
|
|
548
|
+
"path": dict(PATH),
|
|
549
|
+
"http": http,
|
|
550
|
+
"os": os_ns,
|
|
551
|
+
"sh": _sh,
|
|
552
|
+
"say": _say,
|
|
553
|
+
"notify": _notify,
|
|
554
|
+
"alert": _alert,
|
|
555
|
+
"open": _open,
|
|
556
|
+
"fetchSync": _request, # `fetch` (async) is installed per-session by the engine
|
|
557
|
+
"process": _process(myjs_version),
|
|
558
|
+
"py": dict(PY),
|
|
559
|
+
"require": require,
|
|
560
|
+
"atob": lambda s: _base64.b64decode(s).decode("utf-8", "replace"),
|
|
561
|
+
"btoa": lambda s: _base64.b64encode(str(s).encode("utf-8")).decode("ascii"),
|
|
562
|
+
"sleep": _time.sleep,
|
|
563
|
+
"now": lambda: _time.time() * 1000,
|
|
564
|
+
"hash": {
|
|
565
|
+
"md5": lambda s: _hashlib.md5(str(s).encode()).hexdigest(),
|
|
566
|
+
"sha256": lambda s: _hashlib.sha256(str(s).encode()).hexdigest(),
|
|
567
|
+
},
|
|
568
|
+
}
|
myjs/repl.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""The ``myjs`` interactive REPL."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from . import __version__
|
|
8
|
+
from ._engine import JSError, PrintConsole, Session
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _incomplete(src: str) -> bool:
|
|
12
|
+
"""True if ``src`` has unclosed brackets / strings / block comments -- a
|
|
13
|
+
cheap heuristic so the REPL keeps reading multi-line input."""
|
|
14
|
+
depth = 0
|
|
15
|
+
i, n = 0, len(src)
|
|
16
|
+
quote = None
|
|
17
|
+
while i < n:
|
|
18
|
+
c = src[i]
|
|
19
|
+
if quote:
|
|
20
|
+
if c == "\\":
|
|
21
|
+
i += 2
|
|
22
|
+
continue
|
|
23
|
+
if c == quote:
|
|
24
|
+
quote = None
|
|
25
|
+
elif c in "\"'`":
|
|
26
|
+
quote = c
|
|
27
|
+
elif c == "/" and i + 1 < n and src[i + 1] == "/":
|
|
28
|
+
nl = src.find("\n", i)
|
|
29
|
+
if nl == -1:
|
|
30
|
+
return False
|
|
31
|
+
i = nl
|
|
32
|
+
elif c == "/" and i + 1 < n and src[i + 1] == "*":
|
|
33
|
+
end = src.find("*/", i + 2)
|
|
34
|
+
if end == -1:
|
|
35
|
+
return True
|
|
36
|
+
i = end + 1
|
|
37
|
+
elif c in "([{":
|
|
38
|
+
depth += 1
|
|
39
|
+
elif c in ")]}":
|
|
40
|
+
depth -= 1
|
|
41
|
+
i += 1
|
|
42
|
+
return depth > 0 or quote in ('`',)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def repl(session: Session | None = None) -> int:
|
|
46
|
+
session = session or Session(console=PrintConsole())
|
|
47
|
+
print(f"myjs {__version__} - JavaScript on domonic. Ctrl-D or .exit to quit, .help for help.")
|
|
48
|
+
buffer = ""
|
|
49
|
+
while True:
|
|
50
|
+
prompt = "... " if buffer else "js> "
|
|
51
|
+
try:
|
|
52
|
+
line = input(prompt)
|
|
53
|
+
except EOFError:
|
|
54
|
+
print()
|
|
55
|
+
return 0
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
print("^C")
|
|
58
|
+
buffer = ""
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if not buffer and line.strip() in (".exit", ".quit"):
|
|
62
|
+
return 0
|
|
63
|
+
if not buffer and line.strip() == ".help":
|
|
64
|
+
print(" .exit quit\n .clear reset the session\n .help this message\n"
|
|
65
|
+
" _ the last result\n ffi, os native bindings; window/document the DOM")
|
|
66
|
+
continue
|
|
67
|
+
if not buffer and line.strip() == ".clear":
|
|
68
|
+
session = Session(console=PrintConsole())
|
|
69
|
+
print("(session cleared)")
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
buffer = f"{buffer}\n{line}" if buffer else line
|
|
73
|
+
if _incomplete(buffer):
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
src, buffer = buffer, ""
|
|
77
|
+
if not src.strip():
|
|
78
|
+
continue
|
|
79
|
+
try:
|
|
80
|
+
result = session.eval(src)
|
|
81
|
+
except JSError as exc:
|
|
82
|
+
print(exc, file=sys.stderr)
|
|
83
|
+
continue
|
|
84
|
+
except RecursionError:
|
|
85
|
+
print("RangeError: Maximum call stack size exceeded", file=sys.stderr)
|
|
86
|
+
continue
|
|
87
|
+
if result is not None:
|
|
88
|
+
session.interp.global_env.declare("_", result)
|
|
89
|
+
print(session.display(result))
|
|
90
|
+
return 0
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: myjs
|
|
3
|
+
Version: 0.0.2
|
|
4
|
+
Summary: A JavaScript interpreter for Python -- run .js against a DOM, call native C via ffi, reach the Python ecosystem
|
|
5
|
+
Author-email: byteface <byteface@googlemail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/byteface/domonic-libs/tree/master/src/myjs
|
|
8
|
+
Project-URL: Source, https://github.com/byteface/domonic-libs
|
|
9
|
+
Project-URL: Documentation, https://github.com/byteface/domonic-libs/blob/master/docs/myjs.md
|
|
10
|
+
Project-URL: Tracker, https://github.com/byteface/domonic-libs/issues
|
|
11
|
+
Keywords: javascript,interpreter,ecmascript,acorn,ffi,ctypes,dom,repl,domonic
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: JavaScript
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Interpreters
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Requires-Dist: domonic-libs>=0.0.2
|
|
28
|
+
Provides-Extra: gui
|
|
29
|
+
Requires-Dist: pywebview>=5.0; extra == "gui"
|
|
30
|
+
Dynamic: license-file
|
|
31
|
+
|
|
32
|
+
# myjs
|
|
33
|
+
|
|
34
|
+
A JavaScript interpreter for Python. It runs a practical subset of ECMAScript against a server-side DOM, hands scripts an `ffi` bridge to native C libraries, and lets JavaScript reach the entire Python ecosystem.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install myjs
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
myjs # start a REPL
|
|
42
|
+
myjs script.js # execute a file
|
|
43
|
+
myjs -e "6 * 7" # evaluate a snippet
|
|
44
|
+
myjs --gui app.js # run, then show the DOM the script built in a native window
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
import myjs
|
|
49
|
+
|
|
50
|
+
myjs.eval("1 + 2 * 3") # -> 7
|
|
51
|
+
myjs.run("script.js")
|
|
52
|
+
|
|
53
|
+
s = myjs.Session() # an isolated global scope
|
|
54
|
+
s.eval("const x = 21;")
|
|
55
|
+
s.eval("x * 2") # -> 42
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## What a script can reach
|
|
59
|
+
|
|
60
|
+
- **The DOM** — `document`, `window`, and ~190 constructors (`URL`, `Headers`, `Event`, `DOMRect`, …). `document.createElement(...).appendChild(...)` builds a real object tree.
|
|
61
|
+
- **An event loop** — `Promise` (with `all` / `allSettled` / `race` / `any`), `async` / `await`, `setTimeout` / `setInterval`, `queueMicrotask`. Microtasks run before timers.
|
|
62
|
+
- **ES modules** — `import` / `export` from `.js` files; a bare specifier resolves to a Python module.
|
|
63
|
+
- **`ffi`** — call native C libraries through `ctypes`, no compiler:
|
|
64
|
+
|
|
65
|
+
```javascript
|
|
66
|
+
const libc = ffi.loadLibrary("c");
|
|
67
|
+
libc.abs.argtypes = [ffi.types.int];
|
|
68
|
+
libc.abs.restype = ffi.types.int;
|
|
69
|
+
libc.abs(-42); // 42
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
- **Host bindings** — `fs`, `path`, `sh` (shell), `http` + an async `fetch`, `os` (Node-flavoured), `process`, `WebSocket` (a real RFC 6455 client), `say` / `notify` / `open`, `hash`, `atob` / `btoa`.
|
|
73
|
+
- **`py`** — `py.import("numpy")`, `py.eval(...)`, `py.list(iter)` → the whole Python package index from JavaScript.
|
|
74
|
+
|
|
75
|
+
The language layer passes a curated test262-style battery: closures, classes (extends / super / fields / getters-setters / private / static), destructuring, generators, labelled break, `async` / `await`, and the `Array` / `String` / `Object` / `Number` / `Math` / `JSON` / `RegExp` built-ins. Not covered: real prototype chains, `Proxy` / `Symbol`, `with`.
|
|
76
|
+
|
|
77
|
+
## Language embedding
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
s = myjs.Session(scope={"answer": 42})
|
|
81
|
+
s.eval("answer * 2") # -> 84
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
A JavaScript exception that escapes becomes `myjs.JSError` with `js_name`, `js_line`, and `js_trace` (the call stack).
|
|
85
|
+
|
|
86
|
+
## How it is built
|
|
87
|
+
|
|
88
|
+
`myjs` is the runtime layer on top of the [`domonic-libs`](https://pypi.org/project/domonic-libs/) `acorn` port — a faithful Python port of the [acorn](https://github.com/acornjs/acorn) parser plus a tree-walking evaluator — which in turn runs on [`domonic`](https://pypi.org/project/domonic/)'s DOM. Full reference: [docs/myjs.md](https://github.com/byteface/domonic-libs/blob/master/docs/myjs.md).
|
|
89
|
+
|
|
90
|
+
MIT licensed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
myjs/__init__.py,sha256=nie5sW_zre7MxT0V3_V4h8ajGsqKQN_abdWlPzjFSfY,1866
|
|
2
|
+
myjs/__main__.py,sha256=k1ocEWawweo1qCJWNFAAvyxz3tcY13dzvCenHszij30,48
|
|
3
|
+
myjs/_engine.py,sha256=HejwkF_qXZFI7TEHr6LxNY9-zTeO-1Ks0y4XDzKEL6A,4032
|
|
4
|
+
myjs/cli.py,sha256=4w9aDliQC9Q2ggBIY9mrEcfMf8RYV9UTzKcF-bpj1V8,2798
|
|
5
|
+
myjs/ffi.py,sha256=k1aO7Ffxci1PSqutWw_tlqC4Xig7QtwOhzUVkjHHX64,7613
|
|
6
|
+
myjs/host.py,sha256=1SnluwQjIzzqMtpRdmcDGD_vyuKckL67eZlN6M2RbQo,18915
|
|
7
|
+
myjs/repl.py,sha256=XELA1ilpPTov4E2pyXgevZvCCCjt_FhQdtpLmbAgMsc,2761
|
|
8
|
+
myjs-0.0.2.dist-info/licenses/LICENSE,sha256=QAxrfKvz2-qZ3wES7ocFTmPJDj74dyL-5uxRjiDhZn4,1065
|
|
9
|
+
myjs-0.0.2.dist-info/METADATA,sha256=r3MvNDFNk3EIqgyd7F7jMv0iCftdn5AA-XxT_CbWm90,4210
|
|
10
|
+
myjs-0.0.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
11
|
+
myjs-0.0.2.dist-info/entry_points.txt,sha256=erJBPK-zEJBVlNW2u_tmAiQJCJHpXVhBDERgnIO4pAM,39
|
|
12
|
+
myjs-0.0.2.dist-info/top_level.txt,sha256=bfUZ6eSxZ2MUr9ybXAUbnPAWL0IGnZkujU5ToVClU3U,5
|
|
13
|
+
myjs-0.0.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 byteface
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
myjs
|