mirror of
https://github.com/pyscript/pyscript.git
synced 2025-12-19 10:17:23 -05:00
164 lines
6.6 KiB
JavaScript
164 lines
6.6 KiB
JavaScript
/*! (c) PyScript Development Team */
|
|
|
|
const { stringify } = JSON;
|
|
const { assign, create, entries } = Object;
|
|
|
|
const el = (name, props) => assign(document.createElement(name), props);
|
|
|
|
/**
|
|
* Transform a list of keys into a Python dictionary.
|
|
* `['a', 'b']` => `{ "a": a, "b": b }`
|
|
* @param {Iterable<string>} keys
|
|
* @returns {string}
|
|
*/
|
|
const dictionary = keys => {
|
|
const fields = [];
|
|
for (const key of keys)
|
|
fields.push(`${stringify(key)}: ${key}`);
|
|
return `{ ${fields.join(',')} }`;
|
|
};
|
|
|
|
/**
|
|
* Resolve properly config files relative URLs.
|
|
* @param {string|Object} config - The configuration to normalize.
|
|
* @param {string} base - The base URL to resolve relative URLs against.
|
|
* @returns {string} - The JSON serialized config.
|
|
*/
|
|
const normalize = async (config, base) => {
|
|
if (typeof config === 'string') {
|
|
base = config;
|
|
config = await fetch(config).then(res => res.json());
|
|
}
|
|
if (typeof config.files === 'object') {
|
|
const files = {};
|
|
for (const [key, value] of entries(config.files)) {
|
|
files[key.startsWith('{') ? key : new URL(key, base)] = value;
|
|
}
|
|
config.files = files;
|
|
}
|
|
return stringify(config);
|
|
};
|
|
|
|
// this logic is based on a 3 levels cache ...
|
|
const cache = new Map;
|
|
|
|
/**
|
|
* Return a bridge to a Python module via a `.js` file that has a `.py` alter ego.
|
|
* @param {string} url - The URL of the JS module that has a Python counterpart.
|
|
* @param {Object} options - The options for the bridge.
|
|
* @param {string} [options.type='py'] - The `py` or `mpy` interpreter type, `py` by default.
|
|
* @param {boolean} [options.worker=true] - Whether to use a worker, `true` by default.
|
|
* @param {string|Object} [options.config=null] - The configuration for the bridge, `null` by default.
|
|
* @param {string} [options.env=null] - The optional shared environment to use.
|
|
* @param {string} [options.serviceWorker=null] - The optional service worker to use as fallback.
|
|
* @returns {Object} - The bridge to the Python module.
|
|
*/
|
|
export default (url, {
|
|
type = 'py',
|
|
worker = true,
|
|
config = null,
|
|
env = null,
|
|
serviceWorker = null,
|
|
pyscript = null,
|
|
} = {}) => {
|
|
const { protocol, host, pathname } = new URL(url);
|
|
const py = pathname.replace(/\.m?js(?:\/\+\w+)?$/, '.py');
|
|
const file = `${protocol}//${host}${py}`;
|
|
|
|
// the first cache is about the desired file in the wild ...
|
|
if (!cache.has(file)) {
|
|
// the second cache is about all fields one needs to access out there
|
|
const exports = new Map;
|
|
let python;
|
|
|
|
cache.set(file, new Proxy(create(null), {
|
|
get(_, field) {
|
|
if (!exports.has(field)) {
|
|
// create an async callback once and always return the same later on
|
|
exports.set(field, async (...args) => {
|
|
// the third cache is about reaching lazily the code only once
|
|
// augmenting its content with exports once and drop it on done
|
|
if (!python) {
|
|
// do not await or multiple calls will fetch multiple times
|
|
// just assign the fetch `Promise` once and return it
|
|
python = fetch(file).then(async response => {
|
|
const code = await response.text();
|
|
// create a unique identifier for the Python context
|
|
const identifier = pathname.replace(/[^a-zA-Z0-9_]/g, '');
|
|
const name = `__pyscript_${identifier}${Date.now()}`;
|
|
// create a Python dictionary with all accessed fields
|
|
const detail = `{"detail":${dictionary(exports.keys())}}`;
|
|
// create the arguments for the `dispatchEvent` call
|
|
const eventArgs = `${stringify(name)},${name}to_ts(${detail})`;
|
|
// bootstrap the script element type and its attributes
|
|
const script = el('script', { type, textContent: [
|
|
'\n', code, '\n',
|
|
// this is to avoid local scope name clashing
|
|
`from pyscript import window as ${name}`,
|
|
`from pyscript.ffi import to_js as ${name}to_ts`,
|
|
`${name}.dispatchEvent(${name}.CustomEvent.new(${eventArgs}))`,
|
|
// remove these references even if non-clashing to keep
|
|
// the local scope clean from undesired entries
|
|
`del ${name}`,
|
|
`del ${name}to_ts`,
|
|
].join('\n') });
|
|
|
|
// if config is provided it needs to be a worker to avoid
|
|
// conflicting with main config on the main thread (just like always)
|
|
script.toggleAttribute('worker', !!config || !!worker);
|
|
if (config) {
|
|
const attribute = await normalize(config, file);
|
|
script.setAttribute('config', attribute);
|
|
}
|
|
|
|
if (env) script.setAttribute('env', env);
|
|
if (serviceWorker) script.setAttribute('service-worker', serviceWorker);
|
|
|
|
// let PyScript resolve and execute this script
|
|
document.body.appendChild(script);
|
|
|
|
// intercept once the unique event identifier with all exports
|
|
globalThis.addEventListener(
|
|
name,
|
|
event => {
|
|
resolve(event.detail);
|
|
script.remove();
|
|
},
|
|
{ once: true }
|
|
);
|
|
|
|
// return a promise that will resolve only once the event
|
|
// has been emitted and the interpreter evaluated the code
|
|
const { promise, resolve } = Promise.withResolvers();
|
|
|
|
if (!(Symbol.for('@pyscript/core') in globalThis)) {
|
|
// bring in PyScript if not available already
|
|
const cdn = pyscript ?
|
|
`https://pyscript.net/releases/${pyscript}` :
|
|
// ⚠️ fallback to developers' channel !!!
|
|
'https://cdn.jsdelivr.net/npm/@pyscript/core/dist'
|
|
;
|
|
document.head.appendChild(
|
|
el('link', { rel: 'stylesheet', href: `${cdn}/core.css` }),
|
|
);
|
|
try { await import(`${cdn}/core.js`) }
|
|
catch {}
|
|
}
|
|
return promise;
|
|
});
|
|
}
|
|
|
|
// return the `Promise` that will after invoke the exported field
|
|
return python.then(foreign => foreign[field](...args));
|
|
});
|
|
}
|
|
|
|
// return the lazily to be resolved once callback to invoke
|
|
return exports.get(field);
|
|
}
|
|
}));
|
|
}
|
|
|
|
return cache.get(file);
|
|
};
|