@perspective-dev/client 4.4.0 → 4.5.0

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.
package/package.json CHANGED
@@ -1,29 +1,53 @@
1
1
  {
2
2
  "name": "@perspective-dev/client",
3
- "version": "4.4.0",
4
- "description": "",
3
+ "version": "4.5.0",
4
+ "description": "Client for Perspective, a high-performance WASM engine with reactive Tables, Views, and joins.",
5
+ "keywords": [
6
+ "perspective",
7
+ "data",
8
+ "analytics",
9
+ "streaming",
10
+ "wasm",
11
+ "arrow",
12
+ "duckdb",
13
+ "clickhouse"
14
+ ],
15
+ "homepage": "https://perspective-dev.github.io",
5
16
  "repository": {
6
17
  "type": "git",
7
18
  "url": "https://github.com/perspective-dev/perspective"
8
19
  },
9
20
  "type": "module",
10
21
  "license": "Apache-2.0",
22
+ "sideEffects": false,
11
23
  "unpkg": "dist/cdn/perspective.js",
12
24
  "jsdelivr": "dist/cdn/perspective.js",
13
25
  "exports": {
14
26
  ".": {
15
27
  "node": {
16
28
  "types": "./dist/esm/perspective.node.d.ts",
29
+ "import": "./dist/esm/perspective.node.js",
17
30
  "default": "./dist/esm/perspective.node.js"
18
31
  },
19
32
  "types": "./dist/esm/perspective.browser.d.ts",
33
+ "import": "./dist/esm/perspective.js",
20
34
  "default": "./dist/esm/perspective.js"
21
35
  },
22
36
  "./node": {
23
37
  "types": "./dist/esm/perspective.node.d.ts",
38
+ "import": "./dist/esm/perspective.node.js",
24
39
  "default": "./dist/esm/perspective.node.js"
25
40
  },
26
- "./virtual_servers/*": "./dist/esm/virtual_servers/*",
41
+ "./inline": {
42
+ "types": "./dist/esm/perspective.browser.d.ts",
43
+ "import": "./dist/esm/perspective.inline.js",
44
+ "default": "./dist/esm/perspective.inline.js"
45
+ },
46
+ "./virtual_servers/*": {
47
+ "types": "./dist/esm/virtual_servers/*.d.ts",
48
+ "import": "./dist/esm/virtual_servers/*.js",
49
+ "default": "./dist/esm/virtual_servers/*.js"
50
+ },
27
51
  "./dist/*": "./dist/*",
28
52
  "./src/*": "./src/*",
29
53
  "./test/*": "./test/*",
@@ -481,6 +481,34 @@ impl Client {
481
481
  Ok(Table(self.client.open_table(entity_id).await?))
482
482
  }
483
483
 
484
+ /// Unsafely gets a [`View`] by raw ID, useful for JavaScript multi-threaded
485
+ /// (via Web Worker) context where a standard `View` cannot otherwise be
486
+ /// shared because its wrapper is not serializable.
487
+ ///
488
+ /// # Safety
489
+ ///
490
+ /// This method is unsafe because the lifetime of a [`View`] is bound to
491
+ /// the [`Client`] which created it.
492
+ ///
493
+ /// The caller must guarantee that `entity_id` corresponds to a live
494
+ /// [`crate::View`] on the connected server (obtained from another
495
+ /// [`Client`]'s [`crate::View::get_name`] and forwarded across the
496
+ /// serialization boundary).
497
+ ///
498
+ /// # JavaScript Examples
499
+ ///
500
+ /// ```javascript
501
+ /// const view = client.__unsafe_open_view(name_from_main_thread);
502
+ /// const cols = await view.to_columns();
503
+ /// ```
504
+ #[wasm_bindgen]
505
+ pub fn __unsafe_open_view(&self, entity_id: String) -> crate::view::View {
506
+ crate::view::View(perspective_client::View::new(
507
+ entity_id,
508
+ self.client.clone(),
509
+ ))
510
+ }
511
+
484
512
  /// Retrieves the names of all tables that this client has access to.
485
513
  ///
486
514
  /// `name` is a string identifier unique to the [`Table`] (per [`Client`]),
package/src/rust/lib.rs CHANGED
@@ -29,6 +29,7 @@ mod client;
29
29
  mod generic_sql_model;
30
30
  mod table;
31
31
  mod table_data;
32
+ mod typed_array;
32
33
  pub mod utils;
33
34
  mod view;
34
35
  mod virtual_server;
@@ -40,6 +41,7 @@ pub use crate::client::Client;
40
41
  pub use crate::generic_sql_model::*;
41
42
  pub use crate::table::*;
42
43
  pub use crate::table_data::*;
44
+ pub use crate::typed_array::*;
43
45
  pub use crate::virtual_server::*;
44
46
 
45
47
  #[cfg(feature = "export-init")]
@@ -61,10 +63,12 @@ export type * from "../../src/ts/ts-rs/Filter.d.ts";
61
63
  export type * from "../../src/ts/ts-rs/ViewConfig.d.ts";
62
64
  export type * from "../../src/ts/ts-rs/JoinOptions.ts";
63
65
  export type * from "../../src/ts/ts-rs/JoinType.ts";
66
+ export type * from "../../src/ts/ts-rs/TypedArrayWindow.ts";
64
67
 
65
68
  import type {ColumnWindow} from "../../src/ts/ts-rs/ColumnWindow.d.ts";
66
69
  import type {ColumnType} from "../../src/ts/ts-rs/ColumnType.d.ts";
67
70
  import type {ViewWindow} from "../../src/ts/ts-rs/ViewWindow.d.ts";
71
+ import type {TypedArrayWindow} from "../../src/ts/ts-rs/TypedArrayWindow.ts";
68
72
  import type {TableInitOptions} from "../../src/ts/ts-rs/TableInitOptions.d.ts";
69
73
  import type {JoinOptions} from "../../src/ts/ts-rs/JoinOptions.ts";
70
74
  import type {JoinType} from "../../src/ts/ts-rs/JoinType.ts";
@@ -0,0 +1,243 @@
1
+ // ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
2
+ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃
3
+ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃
4
+ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃
5
+ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃
6
+ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
7
+ // ┃ Copyright (c) 2017, the Perspective Authors. ┃
8
+ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
9
+ // ┃ This file is part of the Perspective library, distributed under the terms ┃
10
+ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
11
+ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
12
+
13
+ use std::io::Cursor;
14
+
15
+ use arrow_array::Array as _;
16
+ use arrow_array::cast::AsArray;
17
+ use arrow_array::types::*;
18
+ use arrow_ipc::reader::StreamReader;
19
+ use arrow_schema::{DataType, TimeUnit};
20
+ use js_sys::{Array, Function, JsString, Uint8Array};
21
+ use perspective_client::ViewWindow;
22
+ use ts_rs::TS;
23
+ use wasm_bindgen::JsCast;
24
+ use wasm_bindgen::prelude::*;
25
+
26
+ #[wasm_bindgen]
27
+ unsafe extern "C" {
28
+ #[wasm_bindgen(typescript_type = "TypedArrayWindow")]
29
+ #[derive(Clone)]
30
+ pub type JsTypedArrayWindow;
31
+ }
32
+
33
+ /// Options for `with_typed_arrays`, extending `ViewWindow` with
34
+ /// typed-array-specific options.
35
+ #[derive(Default, serde::Deserialize, TS)]
36
+ pub struct TypedArrayWindow {
37
+ #[serde(flatten)]
38
+ pub view_window: ViewWindow,
39
+
40
+ /// When `true`, Float64/Date32/Timestamp columns are output as
41
+ /// `Float32Array` instead of `Float64Array`.
42
+ #[serde(default)]
43
+ pub float32: bool,
44
+ }
45
+
46
+ impl From<TypedArrayWindow> for ViewWindow {
47
+ fn from(w: TypedArrayWindow) -> Self {
48
+ w.view_window
49
+ }
50
+ }
51
+
52
+ /// Decode an Arrow IPC batch and call `callback` once with all columns.
53
+ ///
54
+ /// Callback signature:
55
+ /// ```js
56
+ /// callback(names: string[], values: TypedArray[], validities: (Uint8Array|null)[], dictionaries: (string[]|null)[]) => void | Promise<void>
57
+ /// ```
58
+ ///
59
+ /// If the callback returns a `Promise`, it is awaited before the Arrow
60
+ /// batch (and therefore the zero-copy typed-array views into it) is
61
+ /// dropped. A synchronous callback returning `undefined` is supported
62
+ /// with no promise-handling overhead.
63
+ pub(crate) async fn decode_and_call(
64
+ arrow: &[u8],
65
+ float32: bool,
66
+ callback: &Function,
67
+ ) -> Result<(), JsValue> {
68
+ let cursor = Cursor::new(arrow);
69
+ let reader = StreamReader::try_new(cursor, None)
70
+ .map_err(|e| JsValue::from_str(&format!("Arrow decode error: {e}")))?;
71
+
72
+ let batch = reader
73
+ .into_iter()
74
+ .next()
75
+ .ok_or_else(|| JsValue::from_str("Arrow IPC contained no record batches"))?
76
+ .map_err(|e| JsValue::from_str(&format!("Arrow batch error: {e}")))?;
77
+
78
+ let schema = batch.schema();
79
+ let num_cols = batch.num_columns();
80
+
81
+ let js_names = Array::new_with_length(num_cols as u32);
82
+ let js_values = Array::new_with_length(num_cols as u32);
83
+ let js_validities = Array::new_with_length(num_cols as u32);
84
+ let js_dicts = Array::new_with_length(num_cols as u32);
85
+
86
+ // Storage for allocated conversion buffers. These MUST outlive the
87
+ // callback because `js_sys::*Array::view()` creates zero-copy views
88
+ // into their heap memory. Using `Box<[T]>` (rather than `Vec<T>`)
89
+ // yields a stable pointer that won't move when the outer Vec grows.
90
+ let mut f32_storage: Vec<Box<[f32]>> = Vec::new();
91
+ let mut f64_storage: Vec<Box<[f64]>> = Vec::new();
92
+
93
+ for col_idx in 0..num_cols {
94
+ let field = schema.field(col_idx);
95
+ let col = batch.column(col_idx);
96
+ let validity = col.nulls().map(|nulls| nulls.validity());
97
+
98
+ js_names.set(col_idx as u32, JsString::from(field.name().as_str()).into());
99
+
100
+ match col.data_type() {
101
+ DataType::UInt32 => {
102
+ let vals = col.as_primitive::<UInt32Type>().values();
103
+ let arr = unsafe { js_sys::Uint32Array::view(vals.as_ref()) };
104
+ js_values.set(col_idx as u32, arr.into());
105
+ js_dicts.set(col_idx as u32, JsValue::NULL);
106
+ },
107
+ DataType::Int32 => {
108
+ let vals = col.as_primitive::<Int32Type>().values();
109
+ let arr = unsafe { js_sys::Int32Array::view(vals.as_ref()) };
110
+ js_values.set(col_idx as u32, arr.into());
111
+ js_dicts.set(col_idx as u32, JsValue::NULL);
112
+ },
113
+ DataType::Float32 => {
114
+ let vals = col.as_primitive::<Float32Type>().values();
115
+ let arr = unsafe { js_sys::Float32Array::view(vals.as_ref()) };
116
+ js_values.set(col_idx as u32, arr.into());
117
+ js_dicts.set(col_idx as u32, JsValue::NULL);
118
+ },
119
+ DataType::Float64 => {
120
+ if float32 {
121
+ let vals = col.as_primitive::<Float64Type>().values();
122
+ f32_storage.push(vals.iter().map(|&v| v as f32).collect());
123
+ } else {
124
+ let vals = col.as_primitive::<Float64Type>().values();
125
+ let arr = unsafe { js_sys::Float64Array::view(vals.as_ref()) };
126
+ js_values.set(col_idx as u32, arr.into());
127
+ }
128
+ js_dicts.set(col_idx as u32, JsValue::NULL);
129
+ },
130
+ DataType::Date32 => {
131
+ // Datetime values are always emitted as Float64 — narrowing
132
+ // epoch-ms to f32 collapses ~256 ms of resolution at modern
133
+ // timestamps, so the `float32` flag is intentionally ignored
134
+ // for date/timestamp columns.
135
+ let typed = col.as_primitive::<Date32Type>();
136
+ f64_storage.push(
137
+ typed
138
+ .values()
139
+ .iter()
140
+ .map(|&v| v as f64 * 86_400_000.0)
141
+ .collect(),
142
+ );
143
+ js_dicts.set(col_idx as u32, JsValue::NULL);
144
+ },
145
+ DataType::Timestamp(TimeUnit::Millisecond, _) => {
146
+ let typed = col.as_primitive::<TimestampMillisecondType>();
147
+ f64_storage.push(typed.values().iter().map(|&v| v as f64).collect());
148
+ js_dicts.set(col_idx as u32, JsValue::NULL);
149
+ },
150
+ DataType::Int64 => {
151
+ let typed = col.as_primitive::<Int64Type>();
152
+ if float32 {
153
+ f32_storage.push(typed.values().iter().map(|&v| v as f32).collect());
154
+ } else {
155
+ f64_storage.push(typed.values().iter().map(|&v| v as f64).collect());
156
+ }
157
+ js_dicts.set(col_idx as u32, JsValue::NULL);
158
+ },
159
+ DataType::Dictionary(..) => {
160
+ let dict = col.as_dictionary::<Int32Type>();
161
+ let keys = dict.keys();
162
+ let arr = unsafe { js_sys::Int32Array::view(keys.values().as_ref()) };
163
+ js_values.set(col_idx as u32, arr.into());
164
+
165
+ let values = dict.values().as_string::<i32>();
166
+ let js_dict = Array::new_with_length(values.len() as u32);
167
+ for i in 0..values.len() {
168
+ js_dict.set(i as u32, JsValue::from_str(values.value(i)));
169
+ }
170
+ js_dicts.set(col_idx as u32, js_dict.into());
171
+ },
172
+ dt => {
173
+ return Err(JsValue::from_str(&format!(
174
+ "Unsupported column type for typed array: {dt}"
175
+ )));
176
+ },
177
+ }
178
+
179
+ // SAFETY: Validity bitmap is owned by `batch` which outlives the
180
+ // callback — safe to view zero-copy.
181
+ let js_validity = validity.map(|v| unsafe { Uint8Array::view(v) });
182
+ js_validities.set(
183
+ col_idx as u32,
184
+ js_validity
185
+ .as_ref()
186
+ .map(JsValue::from)
187
+ .unwrap_or(JsValue::NULL),
188
+ );
189
+ }
190
+
191
+ // Second pass: fill in value views for columns backed by f32_storage /
192
+ // f64_storage. The Box<[T]> buffers are heap-allocated and stable; their
193
+ // data pointers remain valid even as the outer Vec grows.
194
+ let mut f32_idx = 0;
195
+ let mut f64_idx = 0;
196
+ for col_idx in 0..num_cols {
197
+ let col = batch.column(col_idx);
198
+ let uses_f32_storage = matches!(
199
+ (col.data_type(), float32),
200
+ (DataType::Float64, true) | (DataType::Int64, true),
201
+ );
202
+ let uses_f64_storage = matches!(
203
+ (col.data_type(), float32),
204
+ (DataType::Date32, _)
205
+ | (DataType::Timestamp(TimeUnit::Millisecond, _), _)
206
+ | (DataType::Int64, false),
207
+ );
208
+
209
+ if uses_f32_storage {
210
+ let arr = unsafe { js_sys::Float32Array::view(&f32_storage[f32_idx]) };
211
+ js_values.set(col_idx as u32, arr.into());
212
+ f32_idx += 1;
213
+ } else if uses_f64_storage {
214
+ let arr = unsafe { js_sys::Float64Array::view(&f64_storage[f64_idx]) };
215
+ js_values.set(col_idx as u32, arr.into());
216
+ f64_idx += 1;
217
+ }
218
+ }
219
+
220
+ let ret = callback.call4(
221
+ &JsValue::UNDEFINED,
222
+ &js_names.into(),
223
+ &js_values.into(),
224
+ &js_validities.into(),
225
+ &js_dicts.into(),
226
+ )?;
227
+
228
+ // If the callback returned a Promise, await it before releasing the
229
+ // batch — zero-copy TypedArray views into `batch`/`f32_storage`/
230
+ // `f64_storage` must remain valid for the full lifetime of the
231
+ // awaited work.
232
+ if ret.is_instance_of::<js_sys::Promise>() {
233
+ let promise: js_sys::Promise = ret.unchecked_into();
234
+ wasm_bindgen_futures::JsFuture::from(promise).await?;
235
+ }
236
+
237
+ // Keep storage alive until after the callback (and its awaited
238
+ // promise, if any) returns.
239
+ drop(f32_storage);
240
+ drop(f64_storage);
241
+
242
+ Ok(())
243
+ }
package/src/rust/view.rs CHANGED
@@ -88,6 +88,12 @@ impl View {
88
88
  self.clone()
89
89
  }
90
90
 
91
+ #[wasm_bindgen]
92
+ #[doc(hidden)]
93
+ pub fn __unsafe_get_name(&self) -> String {
94
+ self.0.name.clone()
95
+ }
96
+
91
97
  /// Returns an array of strings containing the column paths of the [`View`]
92
98
  /// without any of the source columns.
93
99
  ///
@@ -247,6 +253,38 @@ impl View {
247
253
  Ok(self.0.to_csv(window.unwrap_or_default()).await?)
248
254
  }
249
255
 
256
+ /// Fetches columns from the [`View`] in Arrow format, decodes them, and
257
+ /// passes typed array views to `callback`. All arrays are only valid for
258
+ /// the duration of the callback — if `callback` returns a `Promise`, it
259
+ /// is awaited before the backing Arrow buffer is released, so async
260
+ /// callbacks may use the views for the full duration of the awaited
261
+ /// work (e.g. across an `await requestAnimationFrame`-backed promise).
262
+ ///
263
+ /// # Arguments
264
+ ///
265
+ /// - `window` - Optional [`TypedArrayWindow`] controlling row/column
266
+ /// windowing and output options (e.g., `float32` mode).
267
+ /// - `callback` - A JS function called with `(names: string[], values:
268
+ /// TypedArray[], validities: (Uint8Array|null)[], dictionaries:
269
+ /// (string[]|null)[]) => void | Promise<void>`.
270
+ #[wasm_bindgen]
271
+ pub async fn with_typed_arrays(
272
+ &self,
273
+ window: Option<crate::typed_array::JsTypedArrayWindow>,
274
+ callback: Function,
275
+ ) -> ApiResult<()> {
276
+ let opts: crate::typed_array::TypedArrayWindow = window
277
+ .into_serde_ext::<Option<crate::typed_array::TypedArrayWindow>>()?
278
+ .unwrap_or_default();
279
+
280
+ let float32 = opts.float32;
281
+ let mut view_window: ViewWindow = opts.into();
282
+ view_window.emit_legacy_row_path_names = Some(false);
283
+ let arrow = self.0.to_arrow(view_window).await?;
284
+ crate::typed_array::decode_and_call(&arrow, float32, &callback).await?;
285
+ Ok(())
286
+ }
287
+
250
288
  /// Register a callback with this [`View`]. Whenever the view's underlying
251
289
  /// table emits an update, this callback will be invoked with an object
252
290
  /// containing `port_id`, indicating which port the update fired on, and
@@ -372,7 +372,7 @@ impl VirtualServerHandler for JsServerHandler {
372
372
 
373
373
  let handler = self.0.clone();
374
374
  let view_id = view_id.to_string();
375
- let config_value = serde_wasm_bindgen::to_value(config).unwrap();
375
+ let config_value = JsValue::from_serde_ext(config).unwrap();
376
376
  let config = config.clone();
377
377
  Box::pin(async move {
378
378
  let this = JsServerHandler(handler);
@@ -493,7 +493,7 @@ impl VirtualServerHandler for JsServerHandler {
493
493
  let handler = self.0.clone();
494
494
  let view_id = view_id.to_string();
495
495
  let column_name = column_name.to_string();
496
- let config_js = serde_wasm_bindgen::to_value(config).unwrap();
496
+ let config_js = JsValue::from_serde_ext(config).unwrap();
497
497
  Box::pin(async move {
498
498
  let this = JsServerHandler(handler);
499
499
  let args = Array::new();
@@ -518,8 +518,8 @@ impl VirtualServerHandler for JsServerHandler {
518
518
  let handler = self.0.clone();
519
519
  let view_id = view_id.to_string();
520
520
  let window: JsViewPort = viewport.clone().into();
521
- let config_value = serde_wasm_bindgen::to_value(config).unwrap();
522
- let window_value = serde_wasm_bindgen::to_value(&window).unwrap();
521
+ let config_value = JsValue::from_serde_ext(config).unwrap();
522
+ let window_value = JsValue::from_serde_ext(&window).unwrap();
523
523
  let schema_value = JsValue::from_serde_ext(&schema).unwrap();
524
524
 
525
525
  Box::pin(async move {
@@ -65,13 +65,32 @@ export function init_server(
65
65
  }
66
66
 
67
67
  let GLOBAL_CLIENT_WASM: Promise<typeof psp>;
68
+ let GLOBAL_CLIENT_MODULE: Promise<WebAssembly.Module> | undefined;
69
+
70
+ async function compile_module(wasm: any): Promise<WebAssembly.Module> {
71
+ if (wasm instanceof WebAssembly.Module) {
72
+ return wasm;
73
+ }
74
+
75
+ if (typeof Response !== "undefined" && wasm instanceof Response) {
76
+ return WebAssembly.compileStreaming(wasm);
77
+ }
78
+
79
+ return WebAssembly.compile(wasm);
80
+ }
68
81
 
69
82
  async function compilerize(
70
83
  wasm: PerspectiveWasm,
71
84
  disable_stage_0: boolean = false,
72
85
  ) {
73
86
  const wasm_buff = disable_stage_0 ? wasm : await load_wasm_stage_0(wasm);
74
- await wasm_module.default({ module_or_path: wasm_buff });
87
+ // Compile to a `WebAssembly.Module` once so it can be both instantiated
88
+ // locally and forwarded to other workers via `getCompiledClientWasm()`.
89
+ // `WebAssembly.Module` is structured-cloneable across workers, so the
90
+ // recipient can instantiate without re-fetching or re-compiling.
91
+ const compiled = await compile_module(wasm_buff);
92
+ GLOBAL_CLIENT_MODULE = Promise.resolve(compiled);
93
+ await wasm_module.default({ module_or_path: compiled });
75
94
  await wasm_module.init();
76
95
  return wasm_module;
77
96
  }
@@ -103,6 +122,14 @@ function get_client() {
103
122
  const viewer_class: any = customElements.get("perspective-viewer");
104
123
  if (viewer_class) {
105
124
  GLOBAL_CLIENT_WASM = Promise.resolve(viewer_class.__wasm_module__);
125
+ if (
126
+ GLOBAL_CLIENT_MODULE === undefined &&
127
+ viewer_class.__wasm_client_module__
128
+ ) {
129
+ GLOBAL_CLIENT_MODULE = Promise.resolve(
130
+ viewer_class.__wasm_client_module__,
131
+ );
132
+ }
106
133
  } else if (GLOBAL_CLIENT_WASM === undefined) {
107
134
  throw new Error("Missing perspective-client.wasm");
108
135
  }
@@ -110,6 +137,43 @@ function get_client() {
110
137
  return GLOBAL_CLIENT_WASM;
111
138
  }
112
139
 
140
+ /**
141
+ * Returns the compiled `WebAssembly.Module` for the perspective-js client
142
+ * runtime. The module is structured-cloneable, so it can be sent via
143
+ * `postMessage` to a Worker which can instantiate its own `Client` without
144
+ * re-fetching or re-compiling the wasm binary.
145
+ *
146
+ * Requires that the client wasm has been initialized — typically by a prior
147
+ * call to `init_client(...)`, or implicitly by mounting a `<perspective-viewer>`
148
+ * element. Throws otherwise.
149
+ *
150
+ * # Examples
151
+ *
152
+ * ```javascript
153
+ * const mod = await perspective.getCompiledClientWasm();
154
+ * worker.postMessage({ kind: "init", clientWasm: mod }, [port]);
155
+ * ```
156
+ */
157
+ export async function getCompiledClientWasm(): Promise<WebAssembly.Module> {
158
+ if (GLOBAL_CLIENT_MODULE !== undefined) {
159
+ return GLOBAL_CLIENT_MODULE;
160
+ }
161
+
162
+ const viewer_class: any = customElements.get("perspective-viewer");
163
+ if (viewer_class?.__wasm_client_module__) {
164
+ GLOBAL_CLIENT_MODULE = Promise.resolve(
165
+ viewer_class.__wasm_client_module__,
166
+ );
167
+ return GLOBAL_CLIENT_MODULE;
168
+ }
169
+
170
+ throw new Error(
171
+ "perspective-js client wasm has not been compiled yet — call " +
172
+ "`init_client(...)` or `perspective.worker()` before " +
173
+ "`getCompiledClientWasm()`.",
174
+ );
175
+ }
176
+
113
177
  function get_server() {
114
178
  if (GLOBAL_SERVER_WASM === undefined) {
115
179
  throw new Error("Missing perspective-server.wasm");
@@ -122,13 +186,30 @@ function get_server() {
122
186
 
123
187
  let GLOBAL_WORKER: undefined | (() => Promise<Worker>) = undefined;
124
188
 
125
- // Inline the worker for now. This code will eventually allow outlining this resource
126
- // @ts-ignore
127
- import perspective_wasm_worker from "../../src/ts/perspective-server.worker.js";
128
-
129
- function get_worker(): Promise<Worker> {
189
+ // `WorkerPlugin` resolves this import to a stub that exports
190
+ // `getPerspectiveWorkerURL(): Promise<string>`. The URL is either a
191
+ // Blob URL (inline mode — production builds) or a real file path
192
+ // resolved against `import.meta.url` (file mode — debug builds).
193
+ // Constructing the `Worker` lives here in the consumer rather than
194
+ // inside the plugin so the same module text can also be loaded
195
+ // in-process via dynamic `import(url)` when a future caller wants
196
+ // it; the plugin no longer owns Worker lifecycle.
197
+ //
198
+ // `initialize()` constructs `new Worker(blobUrl)` and falls back to
199
+ // running the worker source on the main thread via `new Function(...)`
200
+ // when Worker construction is unavailable (e.g. `file://` origins where
201
+ // module-Worker support is gated). The shim it returns is
202
+ // MessagePort-shaped so downstream code can treat it like a real
203
+ // Worker.
204
+ // @ts-ignore — resolved at build time by `@perspective-dev/esbuild-plugin/worker`
205
+ import { initialize as initializePerspectiveWorker } from "../../src/ts/perspective-server.worker.js";
206
+
207
+ async function get_worker(): Promise<Worker> {
130
208
  if (GLOBAL_WORKER === undefined) {
131
- return perspective_wasm_worker();
209
+ return await initializePerspectiveWorker({
210
+ type: "module",
211
+ name: "perspective-server",
212
+ });
132
213
  }
133
214
 
134
215
  return GLOBAL_WORKER();
@@ -154,6 +235,7 @@ export default {
154
235
  init_client,
155
236
  init_server,
156
237
  createMessageHandler,
238
+ getCompiledClientWasm,
157
239
  GenericSQLVirtualServerModel,
158
240
  VirtualDataSlice,
159
241
  VirtualServer,
@@ -108,6 +108,7 @@ const CONTENT_TYPES: Record<string, string> = {
108
108
  ".arrow": "arraybuffer",
109
109
  ".feather": "arraybuffer",
110
110
  ".wasm": "application/wasm",
111
+ ".svg": "image/svg+xml",
111
112
  };
112
113
 
113
114
  /**
@@ -120,7 +121,7 @@ export async function cwd_static_file_handler(
120
121
  request: http.IncomingMessage,
121
122
  response: http.ServerResponse<http.IncomingMessage>,
122
123
  assets = ["./"],
123
- { debug = true } = {},
124
+ { debug = false } = {},
124
125
  ) {
125
126
  let url =
126
127
  request.url
@@ -142,14 +143,16 @@ export async function cwd_static_file_handler(
142
143
  if (debug) {
143
144
  console.log(`200 ${url}`);
144
145
  }
146
+
145
147
  response.writeHead(200, {
146
148
  "Content-Type": contentType,
147
149
  "Access-Control-Allow-Origin": "*",
148
150
  });
151
+
149
152
  if (extname === ".arrow" || extname === ".feather") {
150
- response.end(content, "utf-8");
153
+ response.end(content, "utf8");
151
154
  } else {
152
- response.end(content);
155
+ response.end(content, "utf8");
153
156
  }
154
157
 
155
158
  return;
@@ -192,6 +195,18 @@ function buffer_to_arraybuffer(
192
195
  }
193
196
  }
194
197
 
198
+ /**
199
+ * A simple Node `http`-based WebSocket adapter that exposes a
200
+ * `PerspectiveServer` over the wire.
201
+ *
202
+ * @remarks
203
+ *
204
+ * **Security.** `WebSocketServer` is a reference integration with no
205
+ * authentication, authorization, origin enforcement, or rate limiting, and
206
+ * is not safe to expose to untrusted networks — see
207
+ * [`SECURITY.md`](https://github.com/perspective-dev/perspective/blob/master/SECURITY.md)
208
+ * for the full threat model.
209
+ */
195
210
  export class WebSocketServer {
196
211
  _server: http.Server | any; // stoppable has no type ...
197
212
  _wss: HttpWebSocketServer;
@@ -0,0 +1,26 @@
1
+ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
2
+
3
+ /**
4
+ * Options for `with_typed_arrays`, extending `ViewWindow` with
5
+ * typed-array-specific options.
6
+ */
7
+ export type TypedArrayWindow = {
8
+ /**
9
+ * When `true`, Float64/Date32/Timestamp columns are output as
10
+ * `Float32Array` instead of `Float64Array`.
11
+ */
12
+ float32: boolean, start_row?: number, start_col?: number, end_row?: number, end_col?: number, id?: boolean, index?: boolean,
13
+ /**
14
+ * Only impacts [`View::to_csv`]
15
+ */
16
+ formatted?: boolean,
17
+ /**
18
+ * Only impacts [`View::to_arrow`]
19
+ */
20
+ compression?: string,
21
+ /**
22
+ * When `true`, group-by columns use legacy `"colname (Group by N)"`
23
+ * naming. When `false`, they use `__ROW_PATH_N__` naming consistent
24
+ * with the SQL backend. Defaults to `true` for backwards compatibility.
25
+ */
26
+ emit_legacy_row_path_names?: boolean, };
@@ -14,4 +14,10 @@ formatted?: boolean,
14
14
  /**
15
15
  * Only impacts [`View::to_arrow`]
16
16
  */
17
- compression?: string, };
17
+ compression?: string,
18
+ /**
19
+ * When `true`, group-by columns use legacy `"colname (Group by N)"`
20
+ * naming. When `false`, they use `__ROW_PATH_N__` naming consistent
21
+ * with the SQL backend. Defaults to `true` for backwards compatibility.
22
+ */
23
+ emit_legacy_row_path_names?: boolean, };