Skip to content

Commit 853b251

Browse files
authored
Sync pyscript.core (#46)
1 parent 27231c2 commit 853b251

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

91 files changed

+2824
-314
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
'use strict';
2+
require("@ungap/with-resolvers");
3+
const { $$ } = require("basic-devtools");
4+
5+
const { assign, create } = require("./utils.js");
6+
const { getDetails } = require("./script-handler.js");
7+
const {
8+
registry: defaultRegistry,
9+
prefixes,
10+
configs
11+
} = require("./interpreters.js");
12+
const { getRuntimeID } = require("./loader.js");
13+
const { io } = require("./interpreter/_utils.js");
14+
const { addAllListeners } = require("./listeners.js");
15+
16+
const CUSTOM_SELECTORS = [];
17+
exports.CUSTOM_SELECTORS = CUSTOM_SELECTORS;
18+
19+
/**
20+
* @typedef {Object} Runtime custom configuration
21+
* @prop {object} interpreter the bootstrapped interpreter
22+
* @prop {(url:string, options?: object) => Worker} XWorker an XWorker constructor that defaults to same interpreter on the Worker.
23+
* @prop {object} config a cloned config used to bootstrap the interpreter
24+
* @prop {(code:string) => any} run an utility to run code within the interpreter
25+
* @prop {(code:string) => Promise<any>} runAsync an utility to run code asynchronously within the interpreter
26+
* @prop {(path:string, data:ArrayBuffer) => void} writeFile an utility to write a file in the virtual FS, if available
27+
*/
28+
29+
const types = new Map();
30+
const waitList = new Map();
31+
32+
// REQUIRES INTEGRATION TEST
33+
/* c8 ignore start */
34+
/**
35+
* @param {Element} node any DOM element registered via define.
36+
*/
37+
const handleCustomType = (node) => {
38+
for (const selector of CUSTOM_SELECTORS) {
39+
if (node.matches(selector)) {
40+
const type = types.get(selector);
41+
const { resolve } = waitList.get(type);
42+
const { options, known } = registry.get(type);
43+
if (!known.has(node)) {
44+
known.add(node);
45+
const {
46+
interpreter: runtime,
47+
version,
48+
config,
49+
env,
50+
onRuntimeReady,
51+
} = options;
52+
const name = getRuntimeID(runtime, version);
53+
const id = env || `${name}${config ? `|${config}` : ""}`;
54+
const { interpreter: engine, XWorker: Worker } = getDetails(
55+
runtime,
56+
id,
57+
name,
58+
version,
59+
config,
60+
);
61+
engine.then((interpreter) => {
62+
const module = create(defaultRegistry.get(runtime));
63+
64+
const {
65+
onBeforeRun,
66+
onBeforeRunAsync,
67+
onAfterRun,
68+
onAfterRunAsync,
69+
codeBeforeRunWorker,
70+
codeBeforeRunWorkerAsync,
71+
codeAfterRunWorker,
72+
codeAfterRunWorkerAsync,
73+
} = options;
74+
75+
const hooks = {
76+
beforeRun: codeBeforeRunWorker?.(),
77+
beforeRunAsync: codeBeforeRunWorkerAsync?.(),
78+
afterRun: codeAfterRunWorker?.(),
79+
afterRunAsync: codeAfterRunWorkerAsync?.(),
80+
};
81+
82+
const XWorker = function XWorker(...args) {
83+
return Worker.apply(hooks, args);
84+
};
85+
86+
// These two loops mimic a `new Map(arrayContent)` without needing
87+
// the new Map overhead so that [name, [before, after]] can be easily destructured
88+
// and new sync or async patches become easy to add (when the logic is the same).
89+
90+
// patch sync
91+
for (const [name, [before, after]] of [
92+
["run", [onBeforeRun, onAfterRun]],
93+
]) {
94+
const method = module[name];
95+
module[name] = function (interpreter, code) {
96+
if (before) before.call(this, resolved, node);
97+
const result = method.call(this, interpreter, code);
98+
if (after) after.call(this, resolved, node);
99+
return result;
100+
};
101+
}
102+
103+
// patch async
104+
for (const [name, [before, after]] of [
105+
["runAsync", [onBeforeRunAsync, onAfterRunAsync]],
106+
]) {
107+
const method = module[name];
108+
module[name] = async function (interpreter, code) {
109+
if (before) await before.call(this, resolved, node);
110+
const result = await method.call(
111+
this,
112+
interpreter,
113+
code,
114+
);
115+
if (after) await after.call(this, resolved, node);
116+
return result;
117+
};
118+
}
119+
120+
module.setGlobal(interpreter, "XWorker", XWorker);
121+
122+
const resolved = {
123+
type,
124+
interpreter,
125+
XWorker,
126+
io: io.get(interpreter),
127+
config: structuredClone(configs.get(name)),
128+
run: module.run.bind(module, interpreter),
129+
runAsync: module.runAsync.bind(module, interpreter),
130+
};
131+
132+
resolve(resolved);
133+
134+
onRuntimeReady?.(resolved, node);
135+
});
136+
}
137+
}
138+
}
139+
};
140+
exports.handleCustomType = handleCustomType;
141+
142+
/**
143+
* @type {Map<string, {options:object, known:WeakSet<Element>}>}
144+
*/
145+
const registry = new Map();
146+
147+
/**
148+
* @typedef {Object} PluginOptions custom configuration
149+
* @prop {'pyodide' | 'micropython' | 'wasmoon' | 'ruby-wasm-wasi'} interpreter the interpreter to use
150+
* @prop {string} [version] the optional interpreter version to use
151+
* @prop {string} [config] the optional config to use within such interpreter
152+
* @prop {(environment: object, node: Element) => void} [onRuntimeReady] the callback that will be invoked once
153+
*/
154+
155+
/**
156+
* Allows custom types and components on the page to receive interpreters to execute any code
157+
* @param {string} type the unique `<script type="...">` identifier
158+
* @param {PluginOptions} options the custom type configuration
159+
*/
160+
const define = (type, options) => {
161+
if (defaultRegistry.has(type) || registry.has(type))
162+
throw new Error(`<script type="${type}"> already registered`);
163+
164+
if (!defaultRegistry.has(options?.interpreter))
165+
throw new Error(`Unspecified interpreter`);
166+
167+
// allows reaching out the interpreter helpers on events
168+
defaultRegistry.set(type, defaultRegistry.get(options?.interpreter));
169+
170+
// ensure a Promise can resolve once a custom type has been bootstrapped
171+
whenDefined(type);
172+
173+
// allows selector -> registry by type
174+
const selectors = [`script[type="${type}"]`, `${type}-script`];
175+
for (const selector of selectors) types.set(selector, type);
176+
177+
CUSTOM_SELECTORS.push(...selectors);
178+
prefixes.push(`${type}-`);
179+
180+
// ensure always same env for this custom type
181+
registry.set(type, {
182+
options: assign({ env: type }, options),
183+
known: new WeakSet(),
184+
});
185+
186+
addAllListeners(document);
187+
$$(selectors.join(",")).forEach(handleCustomType);
188+
};
189+
exports.define = define;
190+
191+
/**
192+
* Resolves whenever a defined custom type is bootstrapped on the page
193+
* @param {string} type the unique `<script type="...">` identifier
194+
* @returns {Promise<object>}
195+
*/
196+
const whenDefined = (type) => {
197+
if (!waitList.has(type)) waitList.set(type, Promise.withResolvers());
198+
return waitList.get(type).promise;
199+
};
200+
exports.whenDefined = whenDefined;
201+
/* c8 ignore stop */
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
'use strict';
2+
require("@ungap/with-resolvers");
3+
const { $ } = require("basic-devtools");
4+
5+
const { define } = require("../index.js");
6+
const { queryTarget } = require("../script-handler.js");
7+
const { defineProperty } = require("../utils.js");
8+
const { getText } = require("../fetch-utils.js");
9+
10+
// TODO: should this utility be in core instead?
11+
const { robustFetch: fetch } = require("./pyscript/fetch.js");
12+
13+
// append ASAP CSS to avoid showing content
14+
document.head.appendChild(document.createElement("style")).textContent = `
15+
py-script, py-config {
16+
display: none;
17+
}
18+
`;
19+
20+
(async () => {
21+
// create a unique identifier when/if needed
22+
let id = 0;
23+
const getID = (prefix = "py") => `${prefix}-${id++}`;
24+
25+
// find the shared config for all py-script elements
26+
let config;
27+
let pyConfig = $("py-config");
28+
if (pyConfig) config = pyConfig.getAttribute("src") || pyConfig.textContent;
29+
else {
30+
pyConfig = $('script[type="py"]');
31+
config = pyConfig?.getAttribute("config");
32+
}
33+
34+
if (/^https?:\/\//.test(config)) config = await fetch(config).then(getText);
35+
36+
// generic helper to disambiguate between custom element and script
37+
const isScript = (element) => element.tagName === "SCRIPT";
38+
39+
// helper for all script[type="py"] out there
40+
const before = (script) => {
41+
defineProperty(document, "currentScript", {
42+
configurable: true,
43+
get: () => script,
44+
});
45+
};
46+
47+
const after = () => {
48+
delete document.currentScript;
49+
};
50+
51+
/**
52+
* Given a generic DOM Element, tries to fetch the 'src' attribute, if present.
53+
* It either throws an error if the 'src' can't be fetched or it returns a fallback
54+
* content as source.
55+
*/
56+
const fetchSource = async (tag) => {
57+
if (tag.hasAttribute("src")) {
58+
try {
59+
const response = await fetch(tag.getAttribute("src"));
60+
return response.then(getText);
61+
} catch (error) {
62+
// TODO _createAlertBanner(err) instead ?
63+
alert(error.message);
64+
throw error;
65+
}
66+
}
67+
return tag.textContent;
68+
};
69+
70+
// common life-cycle handlers for any node
71+
const bootstrapNodeAndPlugins = (pyodide, element, callback, hook) => {
72+
if (isScript(element)) callback(element);
73+
for (const fn of hooks[hook]) fn(pyodide, element);
74+
};
75+
76+
const addDisplay = (element) => {
77+
const id = isScript(element) ? element.target.id : element.id;
78+
return `
79+
# this code is just for demo purpose but the basics work
80+
def _display(what, target="${id}", append=True):
81+
from js import document
82+
element = document.getElementById(target)
83+
element.textContent = what
84+
display = _display
85+
`;
86+
};
87+
88+
// define the module as both `<script type="py">` and `<py-script>`
89+
define("py", {
90+
config,
91+
env: "py-script",
92+
interpreter: "pyodide",
93+
codeBeforeRunWorker() {
94+
const { codeBeforeRunWorker: set } = hooks;
95+
const prefix = 'print("codeBeforeRunWorker")';
96+
return [prefix].concat(...set).join("\n");
97+
},
98+
codeAfterRunWorker() {
99+
const { codeAfterRunWorker: set } = hooks;
100+
const prefix = 'print("codeAfterRunWorker")';
101+
return [prefix].concat(...set).join("\n");
102+
},
103+
onBeforeRun(pyodide, element) {
104+
bootstrapNodeAndPlugins(pyodide, element, before, "onBeforeRun");
105+
pyodide.interpreter.runPython(addDisplay(element));
106+
},
107+
onBeforeRunAync(pyodide, element) {
108+
pyodide.interpreter.runPython(addDisplay(element));
109+
bootstrapNodeAndPlugins(
110+
pyodide,
111+
element,
112+
before,
113+
"onBeforeRunAync",
114+
);
115+
},
116+
onAfterRun(pyodide, element) {
117+
bootstrapNodeAndPlugins(pyodide, element, after, "onAfterRun");
118+
},
119+
onAfterRunAsync(pyodide, element) {
120+
bootstrapNodeAndPlugins(pyodide, element, after, "onAfterRunAsync");
121+
},
122+
async onRuntimeReady(pyodide, element) {
123+
// allows plugins to do whatever they want with the element
124+
// before regular stuff happens in here
125+
for (const callback of hooks.onRuntimeReady)
126+
callback(pyodide, element);
127+
if (isScript(element)) {
128+
const {
129+
attributes: { async: isAsync, target },
130+
} = element;
131+
const hasTarget = !!target?.value;
132+
const show = hasTarget
133+
? queryTarget(target.value)
134+
: document.createElement("script-py");
135+
136+
if (!hasTarget) element.after(show);
137+
if (!show.id) show.id = getID();
138+
139+
// allows the code to retrieve the target element via
140+
// document.currentScript.target if needed
141+
defineProperty(element, "target", { value: show });
142+
143+
pyodide[`run${isAsync ? "Async" : ""}`](
144+
await fetchSource(element),
145+
);
146+
} else {
147+
// resolve PyScriptElement to allow connectedCallback
148+
element._pyodide.resolve(pyodide);
149+
}
150+
},
151+
});
152+
153+
class PyScriptElement extends HTMLElement {
154+
constructor() {
155+
if (!super().id) this.id = getID();
156+
this._pyodide = Promise.withResolvers();
157+
this.srcCode = "";
158+
this.executed = false;
159+
}
160+
async connectedCallback() {
161+
if (!this.executed) {
162+
this.executed = true;
163+
const { run } = await this._pyodide.promise;
164+
this.srcCode = await fetchSource(this);
165+
this.textContent = "";
166+
const result = run(this.srcCode);
167+
if (!this.textContent && result) this.textContent = result;
168+
this.style.display = "block";
169+
}
170+
}
171+
}
172+
173+
customElements.define("py-script", PyScriptElement);
174+
})();
175+
176+
const hooks = {
177+
/** @type {Set<function>} */
178+
onBeforeRun: new Set(),
179+
/** @type {Set<function>} */
180+
onBeforeRunAync: new Set(),
181+
/** @type {Set<function>} */
182+
onAfterRun: new Set(),
183+
/** @type {Set<function>} */
184+
onAfterRunAsync: new Set(),
185+
/** @type {Set<function>} */
186+
onRuntimeReady: new Set(),
187+
188+
/** @type {Set<string>} */
189+
codeBeforeRunWorker: new Set(),
190+
/** @type {Set<string>} */
191+
codeBeforeRunWorkerAsync: new Set(),
192+
/** @type {Set<string>} */
193+
codeAfterRunWorker: new Set(),
194+
/** @type {Set<string>} */
195+
codeAfterRunWorkerAsync: new Set(),
196+
};
197+
exports.hooks = hooks;

0 commit comments

Comments
 (0)