diff --git a/packages/anywidget/__tests__/widget.test.ts b/packages/anywidget/__tests__/widget.test.ts index 3b112bd6..572e3b4c 100644 --- a/packages/anywidget/__tests__/widget.test.ts +++ b/packages/anywidget/__tests__/widget.test.ts @@ -1,7 +1,7 @@ import * as widgets from "@jupyter-widgets/base"; import * as baseManager from "@jupyter-widgets/base-manager"; -import { afterEach, expect, it } from "vite-plus/test"; -import { page, userEvent } from "vite-plus/test/browser"; +import { afterEach, expect, it } from "vitest"; +import { page, userEvent } from "vitest/browser"; import create_anywidget from "../src/widget.js"; diff --git a/packages/anywidget/scripts/jlab.config.cjs b/packages/anywidget/scripts/jlab.config.cjs index bbab6060..24907882 100644 --- a/packages/anywidget/scripts/jlab.config.cjs +++ b/packages/anywidget/scripts/jlab.config.cjs @@ -18,6 +18,14 @@ module.exports = { filename: "[name].[contenthash:8].js", path: path.resolve(out, "static"), }, + module: { + rules: [ + { + test: /\.ts$/, + loader: "builtin:swc-loader", + }, + ], + }, plugins: [ new rspack.DefinePlugin({ "globalThis.VERSION": JSON.stringify(pkg.version), diff --git a/packages/anywidget/src/binding.ts b/packages/anywidget/src/binding.ts new file mode 100644 index 00000000..88d16c22 --- /dev/null +++ b/packages/anywidget/src/binding.ts @@ -0,0 +1,127 @@ +import type { Experimental, Host } from "@anywidget/types"; +import type { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base"; + +import type { AnyWidget } from "./load.ts"; +import { INITIALIZE_MARKER, model_proxy } from "./model-proxy.ts"; +import { type Awaitable, promise_with_resolvers, safe_cleanup } from "./util.ts"; + +function is_safe_cleanup_function(x: unknown): x is () => Awaitable { + return typeof x === "function"; +} + +export class WidgetBinding { + #controller: AbortController | undefined; + #widget_def: AnyWidget | undefined; + #exports: unknown; + #model: DOMWidgetModel; + ready: Promise; + #resolvers: PromiseWithResolvers; + + constructor(model: DOMWidgetModel) { + this.#model = model; + this.#resolvers = promise_with_resolvers(); + this.ready = this.#resolvers.promise; + } + + async bind( + widget_def: AnyWidget, + { experimental }: { experimental: Experimental }, + ): Promise { + if (this.#widget_def === widget_def) return; + + if (this.#widget_def && this.#widget_def !== widget_def) { + this.#controller?.abort(); + this.#resolvers = promise_with_resolvers(); + this.ready = this.#resolvers.promise; + } + + this.#widget_def = widget_def; + this.#controller = new AbortController(); + let signal = this.#controller.signal; + let model = this.#model; + + model.off(null, null, INITIALIZE_MARKER); + + let result = await widget_def.initialize?.({ + model: model_proxy(model, INITIALIZE_MARKER), + signal, + experimental, + }); + + if (signal.aborted) { + await safe_cleanup(is_safe_cleanup_function(result) ? result : undefined, "esm update"); + return; + } + + if (is_safe_cleanup_function(result)) { + signal.addEventListener("abort", () => safe_cleanup(result, "esm update")); + this.#exports = undefined; + } else if (typeof result === "object" && result !== null) { + this.#exports = result; + } else { + this.#exports = undefined; + } + + this.#resolvers.resolve(this.#exports); + } + + async create_view( + view: DOMWidgetView, + { signal, experimental, host }: { signal: AbortSignal; experimental: Experimental; host: Host }, + ): Promise<(() => void) | undefined> { + await this.ready; + if (!this.#widget_def?.render) return; + let controller = new AbortController(); + let combined = AbortSignal.any([signal, controller.signal]); + let cleanup = await this.#widget_def.render({ + model: model_proxy(this.#model, view), + el: view.el, + signal: combined, + host, + experimental, + }); + if (combined.aborted) { + await safe_cleanup(cleanup, "dispose view - already aborted"); + return; + } + combined.addEventListener("abort", () => safe_cleanup(cleanup, "dispose view - aborted")); + return () => controller.abort(); + } + + get exports(): unknown { + return this.#exports; + } + + destroy(): void { + this.#controller?.abort(); + this.#controller = undefined; + this.#widget_def = undefined; + } +} + +class BindingManager { + #bindings = new Map(); + + get_or_create(model: DOMWidgetModel): WidgetBinding { + let binding = this.#bindings.get(model); + if (!binding) { + binding = new WidgetBinding(model); + this.#bindings.set(model, binding); + } + return binding; + } + + get(model: DOMWidgetModel): WidgetBinding | undefined { + return this.#bindings.get(model); + } + + destroy(model: DOMWidgetModel): void { + let binding = this.#bindings.get(model); + if (binding) { + binding.destroy(); + this.#bindings.delete(model); + } + } +} + +export let BINDINGS = new BindingManager(); diff --git a/packages/anywidget/src/host.ts b/packages/anywidget/src/host.ts new file mode 100644 index 00000000..be25fc3f --- /dev/null +++ b/packages/anywidget/src/host.ts @@ -0,0 +1,63 @@ +import type { Host } from "@anywidget/types"; +import type { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base"; + +import { BINDINGS } from "./binding.ts"; +import { invoke } from "./invoke.ts"; +import { model_proxy } from "./model-proxy.ts"; +import { parse_widget_ref } from "./widget-ref.ts"; + +export function create_host(model: DOMWidgetModel, { signal }: { signal: AbortSignal }): Host { + let host: Host = { + // @ts-expect-error - model_proxy returns AnyModel; generic T is erased at runtime + async getModel(ref) { + let model_id = parse_widget_ref(ref); + let child_model = await model.widget_manager.get_model(model_id); + let context = Symbol("anywidget.host.getModel"); + signal.addEventListener("abort", () => child_model.off(null, null, context)); + return model_proxy(child_model, context); + }, + // @ts-expect-error - generic T is erased at runtime, exports typed as unknown + async getWidget(ref) { + let model_id = parse_widget_ref(ref); + let child_model = await model.widget_manager.get_model(model_id); + let child_binding = BINDINGS.get(child_model); + if (!child_binding) { + throw new Error(`[anywidget] No binding found for widget ${model_id}`); + } + let exports = await Promise.race([ + child_binding.ready, + new Promise((_, reject) => + AbortSignal.timeout(10000).addEventListener("abort", () => + reject(new Error(`[anywidget] Timed out waiting for widget ${model_id} to initialize`)), + ), + ), + ]); + return { + exports, + async render({ el, signal: view_signal }) { + let child_view_signal = view_signal ?? signal; + // Create a minimal view-like object for the binding + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- intentionally creating a partial DOMWidgetView for child rendering + let child_view = { + model: child_model, + el, + $el: { + empty() { + el.innerHTML = ""; + }, + }, + } as unknown as DOMWidgetView; + await child_binding.create_view(child_view, { + signal: child_view_signal, + experimental: { + // @ts-expect-error - bind isn't working + invoke: invoke.bind(null, child_model), + }, + host, + }); + }, + }; + }, + }; + return host; +} diff --git a/packages/anywidget/src/index.js b/packages/anywidget/src/index.js index 3c78d3ee..395da725 100644 --- a/packages/anywidget/src/index.js +++ b/packages/anywidget/src/index.js @@ -1,4 +1,4 @@ -import create from "./widget.js"; +import create from "./widget.ts"; // @ts-expect-error -- define is a global provided by the notebook runtime. define(["@jupyter-widgets/base"], create); diff --git a/packages/anywidget/src/invoke.ts b/packages/anywidget/src/invoke.ts new file mode 100644 index 00000000..2c62f51b --- /dev/null +++ b/packages/anywidget/src/invoke.ts @@ -0,0 +1,40 @@ +import type { AnyModel } from "@anywidget/types"; +import * as uuid from "@lukeed/uuid"; + +export interface InvokeOptions { + buffers?: DataView[]; + signal?: AbortSignal; +} + +export function invoke( + model: AnyModel, + name: string, + msg?: unknown, + options: InvokeOptions = {}, +): Promise<[T, DataView[]]> { + // crypto.randomUUID() is not available in non-secure contexts (i.e., http://) + // so we use simple (non-secure) polyfill. + let id = uuid.v4(); + let signal = options.signal ?? AbortSignal.timeout(3000); + + return new Promise((resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + } + signal.addEventListener("abort", () => { + model.off("msg:custom", handler); + reject(signal.reason); + }); + + function handler( + msg: { id: string; kind: "anywidget-command-response"; response: T }, + buffers: DataView[], + ): void { + if (!(msg.id === id)) return; + resolve([msg.response, buffers]); + model.off("msg:custom", handler); + } + model.on("msg:custom", handler); + model.send({ id, kind: "anywidget-command", name, msg }, undefined, options.buffers ?? []); + }); +} diff --git a/packages/anywidget/src/load.ts b/packages/anywidget/src/load.ts new file mode 100644 index 00000000..5e4e1b4c --- /dev/null +++ b/packages/anywidget/src/load.ts @@ -0,0 +1,115 @@ +import type { Initialize, Render } from "@anywidget/types"; + +import { assert } from "./util.ts"; + +export interface AnyWidget { + initialize?: Initialize; + render?: Render; +} + +export interface AnyWidgetModule { + render?: Render; + default?: AnyWidget | (() => AnyWidget | Promise); +} + +function is_href(str: string): str is `https://${string}` | `http://${string}` { + return str.startsWith("http://") || str.startsWith("https://"); +} + +async function load_css_href(href: string, anywidget_id: string): Promise { + let prev = document.querySelector(`link[id='${anywidget_id}']`); + + // Adapted from https://github.com/vitejs/vite/blob/d59e1acc2efc0307488364e9f2fad528ec57f204/packages/vite/src/client/client.ts#L185-L201 + // Swaps out old styles with new, but avoids flash of unstyled content. + // No need to await the load since we already have styles applied. + if (prev) { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Node.cloneNode() returns Node; we know prev is HTMLLinkElement so the clone is too + let newLink = prev.cloneNode() as HTMLLinkElement; + newLink.href = href; + newLink.addEventListener("load", () => prev?.remove()); + newLink.addEventListener("error", () => prev?.remove()); + prev.after(newLink); + return; + } + + return new Promise((resolve) => { + let link = Object.assign(document.createElement("link"), { + rel: "stylesheet", + href, + onload: resolve, + }); + document.head.appendChild(link); + }); +} + +function load_css_text(css_text: string, anywidget_id: string): void { + let prev = document.querySelector(`style[id='${anywidget_id}']`); + if (prev) { + // replace instead of creating a new DOM node + prev.textContent = css_text; + return; + } + let style = Object.assign(document.createElement("style"), { + id: anywidget_id, + type: "text/css", + }); + style.appendChild(document.createTextNode(css_text)); + document.head.appendChild(style); +} + +export async function load_css(css: string | undefined, anywidget_id: string): Promise { + if (!css || !anywidget_id) return; + if (is_href(css)) return load_css_href(css, anywidget_id); + return load_css_text(css, anywidget_id); +} + +async function load_esm(esm: string): Promise { + if (is_href(esm)) { + return await import(/* webpackIgnore: true */ /* @vite-ignore */ esm); + } + let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" })); + let mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ url); + URL.revokeObjectURL(url); + return mod; +} + +function warn_render_deprecation(anywidget_id: string): void { + console.warn(`\ +[anywidget] Deprecation Warning for ${anywidget_id}: Direct export of a 'render' will likely be deprecated in the future. To migrate ... + +Remove the 'export' keyword from 'render' +----------------------------------------- + +export function render({ model, el }) { ... } +^^^^^^ + +Create a default export that returns an object with 'render' +------------------------------------------------------------ + +function render({ model, el }) { ... } + ^^^^^^ +export default { render } + ^^^^^^ + +Pin to anywidget>=0.9.0 in your pyproject.toml +---------------------------------------------- + +dependencies = ["anywidget>=0.9.0"] + +To learn more, please see: https://github.com/manzt/anywidget/pull/395. +`); +} + +export async function load_widget(esm: string, anywidget_id: string): Promise { + let mod = await load_esm(esm); + if (mod.render) { + warn_render_deprecation(anywidget_id); + return { + async initialize() {}, + render: mod.render, + }; + } + assert(mod.default, `[anywidget] module must export a default function or object.`); + let widget = typeof mod.default === "function" ? await mod.default() : mod.default; + return widget; +} diff --git a/packages/anywidget/src/model-proxy.ts b/packages/anywidget/src/model-proxy.ts new file mode 100644 index 00000000..ed75f8dd --- /dev/null +++ b/packages/anywidget/src/model-proxy.ts @@ -0,0 +1,35 @@ +import type { AnyModel } from "@anywidget/types"; +import type { DOMWidgetModel } from "@jupyter-widgets/base"; + +/** + * This is a trick so that we can cleanup event listeners added + * by the user-defined function. + */ +export let INITIALIZE_MARKER = Symbol("anywidget.initialize"); + +/** + * Prunes the view down to the minimum context necessary. + * + * Calls to `model.get` and `model.set` automatically add the + * `context`, so we can gracefully unsubscribe from events + * added by user-defined hooks. + */ +export function model_proxy(model: DOMWidgetModel, context: unknown): AnyModel { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- DOMWidgetModel.get/set/on/off have wider signatures than AnyModel, so bound versions don't narrow cleanly; the shape is structurally compatible + return { + get: model.get.bind(model), + set: model.set.bind(model), + save_changes: model.save_changes.bind(model), + send: model.send.bind(model), + on(name, callback) { + model.on(name, callback, context); + }, + off(name, callback) { + model.off(name, callback, context); + }, + // The widget_manager type is wider than what we want to expose to + // developers. In a future version, we will expose a more limited API but + // that can wait for a minor version bump. + widget_manager: model.widget_manager, + } as AnyModel; +} diff --git a/packages/anywidget/src/observe.ts b/packages/anywidget/src/observe.ts new file mode 100644 index 00000000..32a28752 --- /dev/null +++ b/packages/anywidget/src/observe.ts @@ -0,0 +1,16 @@ +import type { AnyModel } from "@anywidget/types"; +import * as solid from "solid-js"; + +export function observe, K extends keyof T & string>( + model: AnyModel, + name: K, + { signal }: { signal?: AbortSignal }, +): solid.Accessor { + let [get, set] = solid.createSignal(model.get(name)); + let update = () => set(() => model.get(name)); + model.on(`change:${name}`, update); + signal?.addEventListener("abort", () => { + model.off(`change:${name}`, update); + }); + return get; +} diff --git a/packages/anywidget/src/plugin.js b/packages/anywidget/src/plugin.js index f021d87b..6e15b8fb 100644 --- a/packages/anywidget/src/plugin.js +++ b/packages/anywidget/src/plugin.js @@ -1,6 +1,6 @@ import * as base from "@jupyter-widgets/base"; -import create from "./widget.js"; +import create from "./widget.ts"; /** * @typedef JupyterLabRegistry diff --git a/packages/anywidget/src/runtime.ts b/packages/anywidget/src/runtime.ts new file mode 100644 index 00000000..c06e33ce --- /dev/null +++ b/packages/anywidget/src/runtime.ts @@ -0,0 +1,113 @@ +import type { AnyModel, Experimental } from "@anywidget/types"; +import type { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base"; +import * as solid from "solid-js"; + +import { BINDINGS } from "./binding.ts"; +import { create_host } from "./host.ts"; +import { invoke } from "./invoke.ts"; +import { type AnyWidget, load_css, load_widget } from "./load.ts"; +import { observe } from "./observe.ts"; +import { assert, promise_with_resolvers, type Result, throw_anywidget_error } from "./util.ts"; + +interface State { + [key: string]: unknown; + _esm: string; + _anywidget_id: string; + _css: string | undefined; +} + +export class Runtime { + // @ts-expect-error - Set synchronously in constructor. + #widget_result: solid.Accessor>; + #signal: AbortSignal; + ready: Promise; + + constructor(model: DOMWidgetModel, options: { signal: AbortSignal }) { + let resolvers = promise_with_resolvers(); + this.ready = resolvers.promise; + this.#signal = options.signal; + this.#signal.throwIfAborted(); + this.#signal.addEventListener("abort", () => dispose()); + AbortSignal.timeout(2000).addEventListener("abort", () => { + resolvers.reject(new Error("[anywidget] Failed to initialize model.")); + }); + let binding = BINDINGS.get_or_create(model); + let experimental: Experimental = { + // @ts-expect-error - invoke.bind loses generic type parameter + invoke: invoke.bind(null, model), + }; + let dispose = solid.createRoot((dispose) => { + // DOMWidgetModel is untyped by trait shape; we know the anywidget traits, so narrow to AnyModel for type-safe `.get()` access + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- see above + let typed_model = model as unknown as AnyModel; + let id = typed_model.get("_anywidget_id"); + let css = observe(typed_model, "_css", { signal: this.#signal }); + let esm = observe(typed_model, "_esm", { signal: this.#signal }); + let [widget_result, set_widget_result] = solid.createSignal>({ + status: "pending", + }); + this.#widget_result = widget_result; + + solid.createEffect( + solid.on(css, () => console.debug(`[anywidget] css hot updated: ${id}`), { defer: true }), + ); + solid.createEffect( + solid.on(esm, () => console.debug(`[anywidget] esm hot updated: ${id}`), { defer: true }), + ); + solid.createEffect(() => { + return load_css(css(), id); + }); + solid.createEffect(() => { + load_widget(esm(), id) + .then(async (widget) => { + await binding.bind(widget, { experimental }); + set_widget_result({ status: "ready", data: widget }); + resolvers.resolve(); + }) + .catch((error) => set_widget_result({ status: "error", error })); + }); + + return dispose; + }); + } + + async create_view(view: DOMWidgetView, options: { signal: AbortSignal }): Promise { + let model = view.model; + let signal = AbortSignal.any([this.#signal, options.signal]); // either model or view destroyed + signal.throwIfAborted(); + signal.addEventListener("abort", () => dispose()); + let binding = BINDINGS.get(model); + assert(binding, "[anywidget] WidgetBinding not found."); + let experimental: Experimental = { + // @ts-expect-error - invoke.bind loses generic type parameter + invoke: invoke.bind(null, model), + }; + let host = create_host(model, { signal }); + let dispose = solid.createRoot((dispose) => { + solid.createEffect(() => { + // Clear all previous event listeners from this hook. + model.off(null, null, view); + view.$el.empty(); + let result = this.#widget_result(); + if (result.status === "pending") { + return; + } + if (result.status === "error") { + throw_anywidget_error(result.error); + } + let controller = new AbortController(); + solid.onCleanup(() => controller.abort()); + Promise.resolve() + .then(() => + binding.create_view(view, { + signal: AbortSignal.any([signal, controller.signal]), + experimental, + host, + }), + ) + .catch((error) => throw_anywidget_error(error)); + }); + return () => dispose(); + }); + } +} diff --git a/packages/anywidget/src/util.ts b/packages/anywidget/src/util.ts new file mode 100644 index 00000000..7816a869 --- /dev/null +++ b/packages/anywidget/src/util.ts @@ -0,0 +1,63 @@ +export type Awaitable = T | PromiseLike; + +export interface Ready { + status: "ready"; + data: T; +} + +export interface Pending { + status: "pending"; +} + +export interface Errored { + status: "error"; + error: unknown; +} + +export type Result = Pending | Ready | Errored; + +export function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +export async function safe_cleanup( + fn: void | (() => Awaitable) | undefined, + kind: string, +): Promise { + return Promise.resolve() + .then(() => fn?.()) + .catch((e) => console.warn(`[anywidget] error cleaning up ${kind}.`, e)); +} + +/** + * Cleans up the stack trace at anywidget boundary. + * You can fully inspect the entire stack trace in the console interactively, + * but the initial error message is cleaned up to be more user-friendly. + */ +export function throw_anywidget_error(source: unknown): never { + if (!(source instanceof Error)) { + // Don't know what to do with this. + throw source; + } + let lines = source.stack?.split("\n") ?? []; + let anywidget_index = lines.findIndex((line) => line.includes("anywidget")); + let clean_stack = anywidget_index === -1 ? lines : lines.slice(0, anywidget_index + 1); + source.stack = clean_stack.join("\n"); + console.error(source); + throw source; +} + +/** + * Polyfill for {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers Promise.withResolvers} + * + * Trevor(2025-03-14): Should be able to remove once more stable across browsers. + */ +export function promise_with_resolvers(): PromiseWithResolvers { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + let promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} diff --git a/packages/anywidget/src/widget-ref.ts b/packages/anywidget/src/widget-ref.ts new file mode 100644 index 00000000..58d2d8ab --- /dev/null +++ b/packages/anywidget/src/widget-ref.ts @@ -0,0 +1,8 @@ +export let WIDGET_REF_PREFIX = "anywidget:"; + +export function parse_widget_ref(ref: unknown): string { + if (typeof ref === "string" && ref.startsWith(WIDGET_REF_PREFIX)) { + return ref.slice(WIDGET_REF_PREFIX.length); + } + throw new Error(`[anywidget] Invalid widget reference: ${JSON.stringify(ref)}`); +} diff --git a/packages/anywidget/src/widget.js b/packages/anywidget/src/widget.js deleted file mode 100644 index cb8c50aa..00000000 --- a/packages/anywidget/src/widget.js +++ /dev/null @@ -1,741 +0,0 @@ -import * as uuid from "@lukeed/uuid"; -import * as solid from "solid-js"; - -/** @import { DOMWidgetModel, DOMWidgetView } from "@jupyter-widgets/base" */ -/** @import { Initialize, Render, AnyModel } from "@anywidget/types" */ - -/** - * @template T - * @typedef {T | PromiseLike} Awaitable - */ - -/** - * @typedef AnyWidget - * @prop initialize {Initialize} - * @prop render {Render} - */ - -/** - * @typedef AnyWidgetModule - * @prop render {Render=} - * @prop default {AnyWidget | (() => AnyWidget | Promise)=} - */ - -/** - * @param {unknown} condition - * @param {string} message - * @returns {asserts condition} - */ -function assert(condition, message) { - if (!condition) throw new Error(message); -} - -/** - * @param {string} str - * @returns {str is "https://${string}" | "http://${string}"} - */ -function is_href(str) { - return str.startsWith("http://") || str.startsWith("https://"); -} - -/** - * @param {string} href - * @param {string} anywidget_id - * @returns {Promise} - */ -async function load_css_href(href, anywidget_id) { - /** @type {HTMLLinkElement | null} */ - let prev = document.querySelector(`link[id='${anywidget_id}']`); - - // Adapted from https://github.com/vitejs/vite/blob/d59e1acc2efc0307488364e9f2fad528ec57f204/packages/vite/src/client/client.ts#L185-L201 - // Swaps out old styles with new, but avoids flash of unstyled content. - // No need to await the load since we already have styles applied. - if (prev) { - /** @type {HTMLLinkElement} */ - // @ts-expect-error - we know it's an HTMLLinkElement because prev is an HTMLLinkElement - let newLink = prev.cloneNode(); - newLink.href = href; - newLink.addEventListener("load", () => prev?.remove()); - newLink.addEventListener("error", () => prev?.remove()); - prev.after(newLink); - return; - } - - return new Promise((resolve) => { - let link = Object.assign(document.createElement("link"), { - rel: "stylesheet", - href, - onload: resolve, - }); - document.head.appendChild(link); - }); -} - -/** - * @param {string} css_text - * @param {string} anywidget_id - * @returns {void} - */ -function load_css_text(css_text, anywidget_id) { - /** @type {HTMLStyleElement | null} */ - let prev = document.querySelector(`style[id='${anywidget_id}']`); - if (prev) { - // replace instead of creating a new DOM node - prev.textContent = css_text; - return; - } - let style = Object.assign(document.createElement("style"), { - id: anywidget_id, - type: "text/css", - }); - style.appendChild(document.createTextNode(css_text)); - document.head.appendChild(style); -} - -/** - * @param {string | undefined} css - * @param {string} anywidget_id - * @returns {Promise} - */ -async function load_css(css, anywidget_id) { - if (!css || !anywidget_id) return; - if (is_href(css)) return load_css_href(css, anywidget_id); - return load_css_text(css, anywidget_id); -} - -/** - * @param {string} esm - * @returns {Promise} - */ -async function load_esm(esm) { - if (is_href(esm)) { - return await import(/* webpackIgnore: true */ /* @vite-ignore */ esm); - } - let url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" })); - let mod = await import(/* webpackIgnore: true */ /* @vite-ignore */ url); - URL.revokeObjectURL(url); - return mod; -} - -/** @param {string} anywidget_id */ -function warn_render_deprecation(anywidget_id) { - console.warn(`\ -[anywidget] Deprecation Warning for ${anywidget_id}: Direct export of a 'render' will likely be deprecated in the future. To migrate ... - -Remove the 'export' keyword from 'render' ------------------------------------------ - -export function render({ model, el }) { ... } -^^^^^^ - -Create a default export that returns an object with 'render' ------------------------------------------------------------- - -function render({ model, el }) { ... } - ^^^^^^ -export default { render } - ^^^^^^ - -Pin to anywidget>=0.9.0 in your pyproject.toml ----------------------------------------------- - -dependencies = ["anywidget>=0.9.0"] - -To learn more, please see: https://github.com/manzt/anywidget/pull/395. -`); -} - -/** - * @param {string} esm - * @param {string} anywidget_id - * @returns {Promise} - */ -async function load_widget(esm, anywidget_id) { - let mod = await load_esm(esm); - if (mod.render) { - warn_render_deprecation(anywidget_id); - return { - async initialize() {}, - render: mod.render, - }; - } - assert(mod.default, `[anywidget] module must export a default function or object.`); - let widget = typeof mod.default === "function" ? await mod.default() : mod.default; - return widget; -} - -/** - * This is a trick so that we can cleanup event listeners added - * by the user-defined function. - */ -let INITIALIZE_MARKER = Symbol("anywidget.initialize"); - -let WIDGET_REF_PREFIX = "anywidget:"; - -/** - * @param {string} ref - * @returns {string} - */ -function parse_widget_ref(ref) { - if (typeof ref === "string" && ref.startsWith(WIDGET_REF_PREFIX)) { - return ref.slice(WIDGET_REF_PREFIX.length); - } - throw new Error(`[anywidget] Invalid widget reference: ${ref}`); -} - -/** - * @param {DOMWidgetModel} model - * @param {unknown} context - * @return {import("@anywidget/types").AnyModel} - * - * Prunes the view down to the minimum context necessary. - * - * Calls to `model.get` and `model.set` automatically add the - * `context`, so we can gracefully unsubscribe from events - * added by user-defined hooks. - */ -function model_proxy(model, context) { - return { - get: model.get.bind(model), - set: model.set.bind(model), - save_changes: model.save_changes.bind(model), - send: model.send.bind(model), - on(name, callback) { - model.on(name, callback, context); - }, - off(name, callback) { - model.off(name, callback, context); - }, - // @ts-expect-error - the widget_manager type is wider than what - // we want to expose to developers. - // In a future version, we will expose a more limited API but - // that can wait for a minor version bump. - widget_manager: model.widget_manager, - }; -} - -/** - * @param {void | (() => Awaitable)} fn - * @param {string} kind - */ -async function safe_cleanup(fn, kind) { - return Promise.resolve() - .then(() => fn?.()) - .catch((e) => console.warn(`[anywidget] error cleaning up ${kind}.`, e)); -} - -/** - * @template T - * @typedef Ready - * @property {"ready"} status - * @property {T} data - */ - -/** - * @typedef Pending - * @property {"pending"} status - */ - -/** - * @typedef Errored - * @property {"error"} status - * @property {unknown} error - */ - -/** - * @template T - * @typedef {Pending | Ready | Errored} Result - */ - -/** - * Cleans up the stack trace at anywidget boundary. - * You can fully inspect the entire stack trace in the console interactively, - * but the initial error message is cleaned up to be more user-friendly. - * - * @param {unknown} source - */ -function throw_anywidget_error(source) { - if (!(source instanceof Error)) { - // Don't know what to do with this. - throw source; - } - let lines = source.stack?.split("\n") ?? []; - let anywidget_index = lines.findIndex((line) => line.includes("anywidget")); - let clean_stack = anywidget_index === -1 ? lines : lines.slice(0, anywidget_index + 1); - source.stack = clean_stack.join("\n"); - console.error(source); - throw source; -} - -/** - * @typedef InvokeOptions - * @prop {DataView[]} [buffers] - * @prop {AbortSignal} [signal] - */ - -/** - * @template T - * @param {import("@anywidget/types").AnyModel} model - * @param {string} name - * @param {any} [msg] - * @param {InvokeOptions} [options] - * @return {Promise<[T, DataView[]]>} - */ -export function invoke(model, name, msg, options = {}) { - // crypto.randomUUID() is not available in non-secure contexts (i.e., http://) - // so we use simple (non-secure) polyfill. - let id = uuid.v4(); - let signal = options.signal ?? AbortSignal.timeout(3000); - - return new Promise((resolve, reject) => { - if (signal.aborted) { - reject(signal.reason); - } - signal.addEventListener("abort", () => { - model.off("msg:custom", handler); - reject(signal.reason); - }); - - /** - * @param {{ id: string, kind: "anywidget-command-response", response: T }} msg - * @param {DataView[]} buffers - */ - function handler(msg, buffers) { - if (!(msg.id === id)) return; - resolve([msg.response, buffers]); - model.off("msg:custom", handler); - } - model.on("msg:custom", handler); - model.send({ id, kind: "anywidget-command", name, msg }, undefined, options.buffers ?? []); - }); -} - -/** - * Polyfill for {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/withResolvers Promise.withResolvers} - * - * Trevor(2025-03-14): Should be able to remove once more stable across browsers. - * - * @template T - * @returns {PromiseWithResolvers} - */ -function promise_with_resolvers() { - let resolve; - let reject; - let promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - // @ts-expect-error - We know these types are ok - return { promise, resolve, reject }; -} - -/** - * @template {Record} T - * @template {keyof T & string} K - * @param {AnyModel} model - * @param {K} name - * @param {{ signal?: AbortSignal}} options - * @returns {solid.Accessor} - */ -function observe(model, name, { signal }) { - let [get, set] = solid.createSignal(model.get(name)); - let update = () => set(() => model.get(name)); - model.on(`change:${name}`, update); - signal?.addEventListener("abort", () => { - model.off(`change:${name}`, update); - }); - return get; -} - -/** - * @typedef State - * @property {string} _esm - * @property {string} _anywidget_id - * @property {string | undefined} _css - */ - -class WidgetBinding { - /** @type {AbortController | undefined} */ - #controller; - /** @type {AnyWidget | undefined} */ - #widget_def; - /** @type {unknown} */ - #exports; - /** @type {DOMWidgetModel} */ - #model; - /** @type {Promise} */ - ready; - /** @type {PromiseWithResolvers} */ - #resolvers; - - /** @param {DOMWidgetModel} model */ - constructor(model) { - this.#model = model; - this.#resolvers = promise_with_resolvers(); - this.ready = this.#resolvers.promise; - } - - /** - * @param {AnyWidget} widget_def - * @param {{ experimental: import("@anywidget/types").Experimental }} options - */ - async bind(widget_def, { experimental }) { - if (this.#widget_def === widget_def) return; - - if (this.#widget_def && this.#widget_def !== widget_def) { - this.#controller?.abort(); - this.#resolvers = promise_with_resolvers(); - this.ready = this.#resolvers.promise; - } - - this.#widget_def = widget_def; - this.#controller = new AbortController(); - let signal = this.#controller.signal; - let model = this.#model; - - model.off(null, null, INITIALIZE_MARKER); - - let result = await widget_def.initialize?.({ - model: model_proxy(model, INITIALIZE_MARKER), - signal, - experimental, - }); - - if (signal.aborted) { - // @ts-expect-error - TS can't narrow Function to () => Awaitable - await safe_cleanup(typeof result === "function" ? result : undefined, "esm update"); - return; - } - - if (typeof result === "function") { - // @ts-expect-error - TS can't narrow Function to () => Awaitable - signal.addEventListener("abort", () => safe_cleanup(result, "esm update")); - this.#exports = undefined; - } else if (typeof result === "object" && result !== null) { - this.#exports = result; - } else { - this.#exports = undefined; - } - - this.#resolvers.resolve(this.#exports); - } - - /** - * @param {DOMWidgetView} view - * @param {{ signal: AbortSignal, experimental: import("@anywidget/types").Experimental, host: import("@anywidget/types").Host }} options - */ - async create_view(view, { signal, experimental, host }) { - await this.ready; - if (!this.#widget_def?.render) return; - let controller = new AbortController(); - let combined = AbortSignal.any([signal, controller.signal]); - let cleanup = await this.#widget_def.render({ - model: model_proxy(this.#model, view), - el: view.el, - signal: combined, - host, - experimental, - }); - if (combined.aborted) { - return safe_cleanup(cleanup, "dispose view - already aborted"); - } - combined.addEventListener("abort", () => safe_cleanup(cleanup, "dispose view - aborted")); - return () => controller.abort(); - } - - get exports() { - return this.#exports; - } - - destroy() { - this.#controller?.abort(); - this.#controller = undefined; - this.#widget_def = undefined; - } -} - -class BindingManager { - /** @type {Map} */ - #bindings = new Map(); - - /** @param {DOMWidgetModel} model */ - get_or_create(model) { - let binding = this.#bindings.get(model); - if (!binding) { - binding = new WidgetBinding(model); - this.#bindings.set(model, binding); - } - return binding; - } - - /** @param {DOMWidgetModel} model */ - get(model) { - return this.#bindings.get(model); - } - - /** @param {DOMWidgetModel} model */ - destroy(model) { - let binding = this.#bindings.get(model); - if (binding) { - binding.destroy(); - this.#bindings.delete(model); - } - } -} - -let BINDINGS = new BindingManager(); - -class Runtime { - /** @type {solid.Accessor>} */ - // @ts-expect-error - Set synchronously in constructor. - #widget_result; - /** @type {AbortSignal} */ - #signal; - /** @type {Promise} */ - ready; - - /** - * @param {DOMWidgetModel} model - * @param {{ signal: AbortSignal }} options - */ - constructor(model, options) { - /** @type {PromiseWithResolvers} */ - let resolvers = promise_with_resolvers(); - this.ready = resolvers.promise; - this.#signal = options.signal; - this.#signal.throwIfAborted(); - this.#signal.addEventListener("abort", () => dispose()); - AbortSignal.timeout(2000).addEventListener("abort", () => { - resolvers.reject(new Error("[anywidget] Failed to initialize model.")); - }); - let binding = BINDINGS.get_or_create(model); - /** @type {import("@anywidget/types").Experimental} */ - let experimental = { - // @ts-expect-error - invoke.bind loses generic type parameter - invoke: invoke.bind(null, model), - }; - let dispose = solid.createRoot((dispose) => { - /** @type {AnyModel} */ - // @ts-expect-error - Types don't sufficiently overlap, so we cast here for type-safe access - let typed_model = model; - let id = typed_model.get("_anywidget_id"); - let css = observe(typed_model, "_css", { signal: this.#signal }); - let esm = observe(typed_model, "_esm", { signal: this.#signal }); - let [widget_result, set_widget_result] = solid.createSignal( - /** @type {Result} */ ({ status: "pending" }), - ); - this.#widget_result = widget_result; - - solid.createEffect( - solid.on(css, () => console.debug(`[anywidget] css hot updated: ${id}`), { defer: true }), - ); - solid.createEffect( - solid.on(esm, () => console.debug(`[anywidget] esm hot updated: ${id}`), { defer: true }), - ); - solid.createEffect(() => { - return load_css(css(), id); - }); - solid.createEffect(() => { - load_widget(esm(), id) - .then(async (widget) => { - await binding.bind(widget, { experimental }); - set_widget_result({ status: "ready", data: widget }); - resolvers.resolve(); - }) - .catch((error) => set_widget_result({ status: "error", error })); - }); - - return dispose; - }); - } - - /** - * @param {DOMWidgetView} view - * @param {{ signal: AbortSignal }} options - * @returns {Promise} - */ - async create_view(view, options) { - let model = view.model; - let signal = AbortSignal.any([this.#signal, options.signal]); // either model or view destroyed - signal.throwIfAborted(); - signal.addEventListener("abort", () => dispose()); - let binding = BINDINGS.get(model); - assert(binding, "[anywidget] WidgetBinding not found."); - /** @type {import("@anywidget/types").Experimental} */ - let experimental = { - // @ts-expect-error - invoke.bind loses generic type parameter - invoke: invoke.bind(null, model), - }; - /** @type {import("@anywidget/types").Host} */ - let host = { - // @ts-expect-error - widget_manager.get_model returns WidgetModel, not AnyModel - async getModel(ref) { - let model_id = parse_widget_ref(ref); - return model.widget_manager.get_model(model_id); - }, - // @ts-expect-error - generic T is erased at runtime, exports typed as unknown - async getWidget(ref) { - let model_id = parse_widget_ref(ref); - let child_model = await model.widget_manager.get_model(model_id); - let child_binding = BINDINGS.get(child_model); - if (!child_binding) { - throw new Error(`[anywidget] No binding found for widget ${model_id}`); - } - let exports = await Promise.race([ - child_binding.ready, - new Promise((_, reject) => - AbortSignal.timeout(10000).addEventListener("abort", () => - reject( - new Error(`[anywidget] Timed out waiting for widget ${model_id} to initialize`), - ), - ), - ), - ]); - return { - exports, - async render({ el, signal: view_signal }) { - let child_view_signal = view_signal ?? signal; - // Create a minimal view-like object for the binding - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- intentionally creating a partial DOMWidgetView for child rendering - let child_view = /** @type {DOMWidgetView} */ ({ - model: child_model, - el, - $el: { - empty() { - el.innerHTML = ""; - }, - }, - }); - await child_binding.create_view(child_view, { - signal: child_view_signal, - experimental: { - // @ts-expect-error - bind isn't working - invoke: invoke.bind(null, child_model), - }, - host, - }); - }, - }; - }, - }; - let dispose = solid.createRoot((dispose) => { - solid.createEffect(() => { - // Clear all previous event listeners from this hook. - model.off(null, null, view); - view.$el.empty(); - let result = this.#widget_result(); - if (result.status === "pending") { - return; - } - if (result.status === "error") { - throw_anywidget_error(result.error); - return; - } - let controller = new AbortController(); - solid.onCleanup(() => controller.abort()); - Promise.resolve() - .then(() => - binding.create_view(view, { - signal: AbortSignal.any([signal, controller.signal]), - experimental, - host, - }), - ) - .catch((error) => throw_anywidget_error(error)); - }); - return () => dispose(); - }); - } -} - -// @ts-expect-error - injected by bundler -let version = globalThis.VERSION; - -/** - * @param {{ - * DOMWidgetModel: typeof import("@jupyter-widgets/base").DOMWidgetModel, - * DOMWidgetView: typeof import("@jupyter-widgets/base").DOMWidgetView - * }} options - * @returns {{ AnyModel: typeof import("@jupyter-widgets/base").DOMWidgetModel, AnyView: typeof import("@jupyter-widgets/base").DOMWidgetView }} - */ -export default function ({ DOMWidgetModel, DOMWidgetView }) { - /** @type {WeakMap} */ - let RUNTIMES = new WeakMap(); - - class AnyModel extends DOMWidgetModel { - static model_name = "AnyModel"; - static model_module = "anywidget"; - static model_module_version = version; - - static view_name = "AnyView"; - static view_module = "anywidget"; - static view_module_version = version; - - /** @param {Parameters["initialize"]>} args */ - initialize(...args) { - super.initialize(...args); - let controller = new AbortController(); - this.once("destroy", () => { - controller.abort("[anywidget] Runtime destroyed."); - BINDINGS.destroy(this); - RUNTIMES.delete(this); - }); - RUNTIMES.set(this, new Runtime(this, { signal: controller.signal })); - } - - /** @param {Parameters["_handle_comm_msg"]>} msg */ - async _handle_comm_msg(...msg) { - let runtime = RUNTIMES.get(this); - await runtime?.ready; - return super._handle_comm_msg(...msg); - } - - /** - * @param {Record} state - * - * We override to support binary trailets because JSON.parse(JSON.stringify()) - * does not properly clone binary data (it just returns an empty object). - * - * https://github.com/jupyter-widgets/ipywidgets/blob/47058a373d2c2b3acf101677b2745e14b76dd74b/packages/base/src/widget.ts#L562-L583 - */ - serialize(state) { - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- accessing static `.serializers` on `this.constructor` - let serializers = /** @type {typeof DOMWidgetModel} */ (this.constructor).serializers || {}; - for (let k of Object.keys(state)) { - try { - let serialize = serializers[k]?.serialize; - if (serialize) { - state[k] = serialize(state[k], this); - } else if (k === "layout" || k === "style") { - // These keys come from ipywidgets, rely on JSON.stringify trick. - state[k] = JSON.parse(JSON.stringify(state[k])); - } else { - state[k] = structuredClone(state[k]); - } - if (typeof state[k]?.toJSON === "function") { - state[k] = state[k].toJSON(); - } - } catch (e) { - console.error("Error serializing widget state attribute: ", k); - throw e; - } - } - return state; - } - } - - class AnyView extends DOMWidgetView { - #controller = new AbortController(); - async render() { - let runtime = RUNTIMES.get(this.model); - assert(runtime, "[anywidget] Runtime not found."); - await runtime.create_view(this, { signal: this.#controller.signal }); - } - remove() { - this.#controller.abort("[anywidget] View destroyed."); - super.remove(); - } - } - - return { AnyModel, AnyView }; -} diff --git a/packages/anywidget/src/widget.ts b/packages/anywidget/src/widget.ts new file mode 100644 index 00000000..871eeed3 --- /dev/null +++ b/packages/anywidget/src/widget.ts @@ -0,0 +1,98 @@ +import { BINDINGS } from "./binding.ts"; +import { Runtime } from "./runtime.ts"; +import { assert } from "./util.ts"; + +// @ts-expect-error - injected by bundler +let version: string = globalThis.VERSION; + +interface WidgetFactoryOptions { + DOMWidgetModel: typeof import("@jupyter-widgets/base").DOMWidgetModel; + DOMWidgetView: typeof import("@jupyter-widgets/base").DOMWidgetView; +} + +interface WidgetFactoryResult { + AnyModel: typeof import("@jupyter-widgets/base").DOMWidgetModel; + AnyView: typeof import("@jupyter-widgets/base").DOMWidgetView; +} + +export default function ({ + DOMWidgetModel, + DOMWidgetView, +}: WidgetFactoryOptions): WidgetFactoryResult { + let RUNTIMES = new WeakMap, Runtime>(); + + class AnyModel extends DOMWidgetModel { + static model_name = "AnyModel"; + static model_module = "anywidget"; + static model_module_version = version; + + static view_name = "AnyView"; + static view_module = "anywidget"; + static view_module_version = version; + + initialize(...args: Parameters["initialize"]>): void { + super.initialize(...args); + let controller = new AbortController(); + this.once("destroy", () => { + controller.abort("[anywidget] Runtime destroyed."); + BINDINGS.destroy(this); + RUNTIMES.delete(this); + }); + RUNTIMES.set(this, new Runtime(this, { signal: controller.signal })); + } + + async _handle_comm_msg( + ...msg: Parameters["_handle_comm_msg"]> + ): Promise { + let runtime = RUNTIMES.get(this); + await runtime?.ready; + return super._handle_comm_msg(...msg); + } + + /** + * We override to support binary trailets because JSON.parse(JSON.stringify()) + * does not properly clone binary data (it just returns an empty object). + * + * https://github.com/jupyter-widgets/ipywidgets/blob/47058a373d2c2b3acf101677b2745e14b76dd74b/packages/base/src/widget.ts#L562-L583 + */ + serialize(state: Record): Record { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- accessing static `.serializers` on `this.constructor` + let serializers = (this.constructor as typeof DOMWidgetModel).serializers || {}; + for (let k of Object.keys(state)) { + try { + let serialize = serializers[k]?.serialize; + if (serialize) { + state[k] = serialize(state[k], this); + } else if (k === "layout" || k === "style") { + // These keys come from ipywidgets, rely on JSON.stringify trick. + state[k] = JSON.parse(JSON.stringify(state[k])); + } else { + state[k] = structuredClone(state[k]); + } + if (typeof state[k]?.toJSON === "function") { + state[k] = state[k].toJSON(); + } + } catch (e) { + console.error("Error serializing widget state attribute: ", k); + throw e; + } + } + return state; + } + } + + class AnyView extends DOMWidgetView { + #controller = new AbortController(); + async render(): Promise { + let runtime = RUNTIMES.get(this.model); + assert(runtime, "[anywidget] Runtime not found."); + await runtime.create_view(this, { signal: this.#controller.signal }); + } + remove(): void { + this.#controller.abort("[anywidget] View destroyed."); + super.remove(); + } + } + + return { AnyModel, AnyView }; +} diff --git a/packages/deno/src/install.ts b/packages/deno/src/install.ts index ee1adf82..6b59197e 100644 --- a/packages/deno/src/install.ts +++ b/packages/deno/src/install.ts @@ -15,7 +15,7 @@ import * as path from "@std/path"; import * as unzipit from "unzipit"; import * as z from "zod"; -import { find_data_dir, system_data_dirs, user_data_dir } from "./jupyter_paths.ts"; +import { findDataDir, systemDataDirs, userDataDir } from "./jupyter-paths.ts"; let ReleaseSchema = z.object({ packagetype: z.string(), @@ -28,13 +28,13 @@ let PackageSchema = z.object({ urls: z.array(ReleaseSchema), }); -async function fetch_package_info(name: string) { +async function fetchPackageInfo(name: string) { let response = await fetch(`https://pypi.org/pypi/${name}/json`); let json = await response.json(); return PackageSchema.parse(json); } -async function fetch_wheel( +async function fetchWheel( info: z.infer, version?: string, ): Promise<{ version: string; wheel: unzipit.ZipInfo }> { @@ -54,27 +54,27 @@ async function fetch_wheel( }; } -function extract_data_files(zip: unzipit.ZipInfo): Promise<[string, Uint8Array][]> { - let data_prefix = /^.*\.data\/data\/share\/jupyter\//; +function extractDataFiles(zip: unzipit.ZipInfo): Promise<[string, Uint8Array][]> { + let dataPrefix = /^.*\.data\/data\/share\/jupyter\//; return Promise.all( Object.entries(zip.entries) - .filter(([name]) => data_prefix.test(name)) + .filter(([name]) => dataPrefix.test(name)) .map(async ([name, reader]) => { - return [name.replace(data_prefix, ""), new Uint8Array(await reader.arrayBuffer())]; + return [name.replace(dataPrefix, ""), new Uint8Array(await reader.arrayBuffer())]; }), ); } -async function write_files(files: [string, Uint8Array][], out_dir: string) { - for (let [data_file_path, bytes] of files) { - let file_path = path.resolve(out_dir, data_file_path); - await fs.ensureFile(file_path); - await Deno.writeFile(file_path, bytes); +async function writeFiles(files: [string, Uint8Array][], outDir: string) { + for (let [dataFilePath, bytes] of files) { + let filePath = path.resolve(outDir, dataFilePath); + await fs.ensureFile(filePath); + await Deno.writeFile(filePath, bytes); } } -async function has_jupyter_widgets() { - for (let dir of [out_dir, user_data_dir(), ...system_data_dirs()]) { +async function hasJupyterWidgets() { + for (let dir of [outDir, userDataDir(), ...systemDataDirs()]) { let contains = await Deno.stat(path.resolve(dir, "@jupyter-widgets")) .then((stat) => stat.isDirectory) .catch(() => false); @@ -86,17 +86,17 @@ async function has_jupyter_widgets() { } let args = cli.parseArgs(Deno.args); -let out_dir = await find_data_dir(); +let outDir = await findDataDir(); { - let info = await fetch_package_info("anywidget"); - let { version, wheel } = await fetch_wheel(info, args.version); - let data_files = await extract_data_files(wheel); - await write_files(data_files, out_dir); - console.log(`✅ Installed anywidget ${version} in ${out_dir}`); + let info = await fetchPackageInfo("anywidget"); + let { version, wheel } = await fetchWheel(info, args.version); + let dataFiles = await extractDataFiles(wheel); + await writeFiles(dataFiles, outDir); + console.log(`✅ Installed anywidget ${version} in ${outDir}`); } -if (!(await has_jupyter_widgets())) { +if (!(await hasJupyterWidgets())) { /** * NB: The anywidget front-end code relies on @jupyter-widgets/base, * which is supplied by the _python_ `jupyterlab_widgets` package. @@ -111,9 +111,9 @@ if (!(await has_jupyter_widgets())) { * For now, we get that latest data files from `jupyterlab_widgets` * if `@jupyter-widgets` is not present in any of the Jupyter data dirs. */ - let info = await fetch_package_info("jupyterlab_widgets"); - let { version, wheel } = await fetch_wheel(info); - let data_files = await extract_data_files(wheel); - await write_files(data_files, out_dir); - console.log(`✅ Installed jupyterlab_widgets ${version} in ${out_dir}`); + let info = await fetchPackageInfo("jupyterlab_widgets"); + let { version, wheel } = await fetchWheel(info); + let dataFiles = await extractDataFiles(wheel); + await writeFiles(dataFiles, outDir); + console.log(`✅ Installed jupyterlab_widgets ${version} in ${outDir}`); } diff --git a/packages/types/index.test-d.ts b/packages/types/index.test-d.ts index 70c88e13..cb7e3fe2 100644 --- a/packages/types/index.test-d.ts +++ b/packages/types/index.test-d.ts @@ -1,6 +1,6 @@ -import { describe, expectTypeOf, it } from "vite-plus/test"; +import { describe, expectTypeOf, it } from "vitest"; -import type { AnyModel, AnyWidget, Host } from "./index.js"; +import type { AnyModel, AnyWidget, Host } from "./index.ts"; declare let model: AnyModel; declare let typedModel: AnyModel<{ value: number; name: string }>; diff --git a/tsconfig.json b/tsconfig.json index 88d9d465..ecceb22d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,9 @@ "verbatimModuleSyntax": true, "skipLibCheck": true, "allowJs": true, - "checkJs": true + "checkJs": true, + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true }, "files": [], "references": [