Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ import { storyHtml, when } from '@bquery/bquery/storybook';
| --------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Core** | Stable | Selectors, DOM manipulation, events, traversal, and typed utilities |
| **Reactive** | Stable | `signal`, `computed`, `effect`, `watchDebounce`, `watchThrottle`, async data, HTTP clients, polling, pagination, WebSocket / SSE, and REST helpers |
| **Concurrency** | Experimental | Zero-build worker tasks, explicit RPC helpers, optional reactive state wrappers, bounded worker pools, high-level collection helpers, and an optional fluent pipeline layer |
| **Concurrency** | Experimental | Zero-build worker tasks, explicit RPC helpers, optional reactive state wrappers, bounded worker pools, high-level collection helpers, an optional fluent pipeline layer, CSP-safe module workers, and client UI-scheduling primitives (targeting Stable in 1.15.0) |
| **Component** | Stable | Typed Web Components with scoped reactivity and configurable Shadow DOM |
| **Storybook** | Beta | Safe story template helpers with boolean-attribute shorthand |
| **Motion** | Stable | View transitions, FLIP, morphing, parallax, typewriter, springs, and timelines |
Expand Down
239 changes: 232 additions & 7 deletions docs/guide/concurrency.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Concurrency

::: tip What's new in 1.15.0
Concurrency adds **CSP-safe module workers** — `defineWorker()` / `defineRpcWorker()` on the main thread plus `exposeTask()` / `exposeRpc()` inside the worker — so worker offloading no longer requires `'unsafe-eval'`. It also adds client UI-scheduling primitives `suspense()`, `startTransition()`, and `deferred()`. The module is targeting **Stable** in 1.15.0 — see [Stability](#stability-targeting-stable-in-1-15-0).
:::

::: tip What's new in 1.14.0
Concurrency gained `withTransferables`, `createSharedBuffer`, RPC `maxInFlight`, pool priorities, `pause` / `resume` / `onIdle`, and rolling reactive metrics in 1.14.0. See the [1.14.0 release notes](/release-notes/1.14#additive-module-expansions).
:::
Expand All @@ -25,21 +29,105 @@ import {
createRpcWorker,
createRpcPool,
callWorkerMethod,
deferred,
defineRpcWorker,
defineWorker,
every,
exposeRpc,
exposeTask,
filter,
find,
getConcurrencySupport,
isConcurrencySupported,
isModuleWorkerSupported,
isWorkerModule,
map,
parallel,
pipeline,
reduce,
runTask,
some,
startTransition,
suspense,
withTransferables,
} from '@bquery/bquery/concurrency';
```

## Execution modes: module (CSP-safe) vs dynamic

Worker handlers can be supplied two ways. Every factory — `runTask()`,
`createTaskWorker()`, `createTaskPool()`, `createRpcWorker()`, `createRpcPool()`,
`callWorkerMethod()`, and the reactive wrappers — accepts either form:

- **Module mode (default, CSP-safe):** supply a `defineWorker()` /
`defineRpcWorker()` URL. The worker is a pre-bundled module loaded by URL, so
it needs **no `'unsafe-eval'`** — only the usual `worker-src` policy.
- **Dynamic mode (opt-in):** supply an inline standalone function or handler
map. The body is revived with `new Function(...)`, so it requires
`'unsafe-eval'` plus `worker-src blob:`.

Module mode is the recommended, CSP-clean default. Dynamic mode stays available
for quick zero-build experiments where a relaxed CSP is acceptable; it revives
the handler with `new Function(...)`, which is why it needs `'unsafe-eval'`.

### `defineWorker()` and `exposeTask()`

Point `defineWorker()` at a pre-bundled worker module and wire the worker side
up with `exposeTask()`. No function body is serialized, so no `'unsafe-eval'` is
required.

```ts
// square.worker.ts
import { exposeTask } from '@bquery/bquery/concurrency';

exposeTask((value: number) => value * value);
```

```ts
// main.ts
import { createTaskWorker, defineWorker } from '@bquery/bquery/concurrency';

const square = defineWorker<number, number>(new URL('./square.worker.ts', import.meta.url));

const worker = createTaskWorker(square);
const result = await worker.run(12); // 144
worker.terminate();
```

`defineWorker()` defaults to `{ type: 'module' }`; pass `{ type: 'classic' }` for
a classic worker script. The descriptor is a frozen `WorkerModule`; check one
with `isWorkerModule()`.

### `defineRpcWorker()` and `exposeRpc()`

The same split applies to named RPC dispatch.

```ts
// calc.worker.ts
import { exposeRpc } from '@bquery/bquery/concurrency';

exposeRpc({
sum: ({ values }: { values: number[] }) => values.reduce((total, value) => total + value, 0),
});
```

```ts
// main.ts
import { createRpcWorker, defineRpcWorker } from '@bquery/bquery/concurrency';

type Routes = { sum(input: { values: number[] }): number };
const calc = defineRpcWorker<Routes>(new URL('./calc.worker.ts', import.meta.url));

const rpc = createRpcWorker(calc);
const total = await rpc.call('sum', { values: [1, 2, 3] }); // 6
rpc.terminate();
```

`exposeTask()` and `exposeRpc()` default to the ambient worker `self`; pass an
explicit `WorkerHostScope` to host the protocol on a `MessagePort` or a test
double. Use `isModuleWorkerSupported()` to feature-detect module workers (only
the `Worker` constructor is required — no `Blob`/`URL.createObjectURL`).

## Current scope

### Included now
Expand Down Expand Up @@ -100,6 +188,60 @@ need reactive state monitoring.
| Fluent pipelines | pipeline builders | **Adapted** via `pipeline()` as an optional fluent layer over the existing collection helpers |
| Decorators / implicit magic | broad decorator suite | **Not adopted**; conflicts with bQuery's explicit, lightweight browser-first design |
| Node / Deno / Bun adapters | universal runtime adapters | **Not adopted** in this browser-focused package |
| Worker execution mode | eval-based revival | **CSP-safe by default** via `defineWorker()` module workers; dynamic eval mode kept as explicit opt-in |

## Stability: targeting Stable in 1.15.0

`concurrency` was introduced as **Experimental** in 1.10.0 and expanded in
1.14.0. The work to graduate it is tracked in
[#133](https://github.com/bQuery/bQuery/issues/133); its adoption-blocking
prerequisite — a CSP-safe default that needs no `'unsafe-eval'`
([#134](https://github.com/bQuery/bQuery/issues/134)) — is resolved by the
[module workers](#execution-modes-module-csp-safe-vs-dynamic) above. Promotion to
**Stable** then follows one minor cycle with the public surface frozen.

### Exit criteria

- [x] **CSP-safe default worker mode, no `'unsafe-eval'`** ([#134](https://github.com/bQuery/bQuery/issues/134)) — `defineWorker()` / `defineRpcWorker()` module workers; dynamic eval mode is now an explicit opt-in.
- [x] **Serializable-handler constraint documented and enforced** — dynamic-mode handlers fail fast with `TaskWorkerSerializationError`; module-mode handlers are exempt by design.
- [x] **Coverage across task / pool / RPC / shared buffer** plus module mode and the client primitives (`tests/concurrency.test.ts`, `tests/concurrency-stable.test.ts`).
- [x] **Runtime boundary stated** — browser-focused, consistent with the module's non-goals (below).
- [ ] **Public surface frozen for one minor** (no additive breaking changes) — demonstrated across the 1.15 cycle.
- [ ] **A dedicated `bq-suspense` view directive** ([#135](https://github.com/bQuery/bQuery/issues/135)) — the programmatic `suspense()` boundary ships now; the compiler directive is tracked separately and not required for the freeze.

### Frozen surface

The contract that must not break once Stable:

- **Workers/pools:** `runTask`, `createTaskWorker`, `createTaskPool`, `createRpcWorker`, `createRpcPool`, `callWorkerMethod`, and the reactive wrappers — each accepting an inline handler (dynamic) or a `WorkerModule` (module).
- **Module workers:** `defineWorker`, `defineRpcWorker`, `isWorkerModule`, `exposeTask`, `exposeRpc`.
- **High-level helpers:** `parallel`, `batchTasks`, `map`, `filter`, `reduce`, `some`, `every`, `find`, `pipeline`.
- **Client primitives:** `suspense`, `startTransition`, `deferred`.
- **Detection + utilities:** `getConcurrencySupport`, `isConcurrencySupported`, `isModuleWorkerSupported`, `withTransferables`, `createSharedBuffer`.
- **Errors:** `TaskWorkerError`, `TaskWorkerUnsupportedError`, `TaskWorkerSerializationError`, `TaskWorkerTimeoutError`, `TaskWorkerAbortError`, and the `TaskWorkerErrorCode` set.

### Runtime boundary

`concurrency` is **browser-focused** by design. It uses standard Web Worker
primitives and does not ship Node/Deno/Bun worker adapters — an explicit non-goal
that keeps the package lean. `createSharedBuffer()` additionally needs
`crossOriginIsolated` with COOP/COEP headers (inherent to `SharedArrayBuffer`,
not a bQuery choice). The browser focus is a scoping decision and does not block
stability.

### Per-environment support matrix

| Capability | Modern browser | Worker-capable runtime | Strict CSP (no `unsafe-eval`) |
| ------------------------------------------- | -------------- | ---------------------- | ----------------------------- |
| Module workers (`defineWorker`) | yes | yes | **yes** |
| Dynamic workers (inline functions) | yes | yes | no (needs `'unsafe-eval'`) |
| Pools / RPC / high-level helpers | yes | yes | yes (module mode) |
| Client primitives (`suspense`/`deferred`/…) | yes | yes | yes (no workers involved) |
| Shared buffers (`createSharedBuffer`) | COOP/COEP | COOP/COEP | COOP/COEP |

The client primitives are pure signal scheduling and run anywhere signals do,
including SSR. Worker features require the `Worker` constructor; only dynamic
mode additionally needs `Blob` + `URL.createObjectURL` and a relaxed CSP.

## `runTask()`

Expand Down Expand Up @@ -482,6 +624,80 @@ console.log(results); // [6, 8]
- It keeps the same browser-only serialization boundaries as the underlying helpers
- Like the rest of the module, it relies on serializable standalone functions and inline worker evaluation

## Client async-concurrency primitives

Worker concurrency offloads CPU work; these primitives are about **UI
scheduling** — keeping the interface responsive while async work is in flight.
They build directly on signals, add no dependencies, and are tree-shakeable.
They pair with SSR suspense streaming (`renderToStreamSuspense` / `defer`) so the
same async boundaries can stream on the server and suspend on the client.

### `suspense()`

`suspense()` is a declarative async boundary. Pass one source or an array of
sources — promises, reactive async states from `useAsyncData()` / `useResource()`,
or plain pending getters — and it aggregates them into reactive `pending`,
`settled`, and `error` signals.

```ts
import { suspense } from '@bquery/bquery/concurrency';
import { useAsyncData } from '@bquery/bquery/reactive';

const user = useAsyncData(() => fetchUser(id));
const boundary = suspense(user);

// drive fallback vs content with existing view bindings:
// <div bq-show="boundary.pending">Loading…</div>
// <section bq-show="boundary.settled">…content…</section>

boundary.error.value; // first error surfaced by any tracked source, or null
boundary.dispose(); // detach internal effects + promise listeners
```

- `pending` is `true` while any tracked source is pending
- `settled` becomes `true` once every source has settled at least once
- By default the boundary re-enters `pending` if a source becomes busy again;
pass `{ retrigger: false }` to latch `settled` after the first resolution

### `startTransition()`

`startTransition()` marks an update as non-urgent so urgent input stays snappy.
`start(scope)` flips `isPending` immediately, then runs `scope` on a low-priority
schedule inside a reactive `batch`, decoupling the expensive update from the
event that triggered it.

```ts
import { startTransition } from '@bquery/bquery/concurrency';

const [isPending, start] = startTransition();

input.addEventListener('input', (event) => {
query.value = event.target.value; // urgent: keeps the field responsive
start(() => (filter.value = event.target.value)); // non-urgent: heavy list re-render
});

// <span bq-show="isPending">Updating…</span>
```

### `deferred()`

`deferred()` returns a readonly signal that lags behind its source, throttling
expensive derived UI. Rapid source changes coalesce into a single trailing
update, so a heavy computed driven by the deferred value does not recompute on
every keystroke.

```ts
import { deferred } from '@bquery/bquery/concurrency';
import { computed } from '@bquery/bquery/reactive';

const deferredQuery = deferred(query); // lags `query`
const results = computed(() => expensiveSearch(deferredQuery.value));
```

Pass `{ timeout }` to bound how long the deferred value may lag its source.
`deferred()` accepts a signal, a `readonly()` wrapper, a computed, a getter
function, or a plain value.

## Timeout and abort

```ts
Expand Down Expand Up @@ -544,19 +760,27 @@ await runTask((input: ArrayBuffer) => input.byteLength, buffer, {

## Limitations

- Task handlers must be **standalone functions**; they cannot rely on outer closures
- The module currently targets **browser worker primitives** (`Worker`, `Blob`, `URL.createObjectURL`)
- CSP setups may need `worker-src blob:` for inline worker creation
- Stricter CSP policies may also require allowing `'unsafe-eval'` because handler
validation/revival uses `new Function(...)` on the main thread and inside worker scripts
- If your environment forbids dynamic evaluation, avoid the concurrency module in that deployment
- **Dynamic-mode** handlers must be **standalone functions**; they cannot rely on
outer closures, because they are serialized and revived in the worker. This is
a permanent design boundary of dynamic mode, enforced with a clear
`TaskWorkerSerializationError`. **Module mode has no such limit** — a
`defineWorker()` script is a normal module and may import and close over
anything it likes.
- The module targets **browser worker primitives**. Module mode needs only the
`Worker` constructor; dynamic mode also needs `Blob` + `URL.createObjectURL`.
- **Dynamic mode** requires a relaxed CSP (`'unsafe-eval'` plus `worker-src blob:`)
because it revives handlers with `new Function(...)`. If your environment
forbids dynamic evaluation, use **module mode** (`defineWorker()` /
`defineRpcWorker()`), which is CSP-clean.
- Reactive wrappers are opt-in via `createReactiveTaskWorker()`, `createReactiveRpcWorker()`, `createReactiveTaskPool()`, and `createReactiveRpcPool()`

<!-- uniform-template-footer -->

## Pitfalls and gotchas

- Worker bodies are serialized via `new Function(...)` on the main thread — your CSP must allow `'unsafe-eval'`.
- **Dynamic-mode** worker bodies are serialized via `new Function(...)` — that CSP must allow `'unsafe-eval'`. Prefer **module mode** (`defineWorker()`) for a CSP-clean default.
- A `defineWorker()` URL must resolve to a real, bundled worker script that calls `exposeTask()` / `exposeRpc()`; pass it through `new URL('./x.worker.ts', import.meta.url)` so your bundler emits it.
- `startTransition()` and `deferred()` schedule on idle/macrotasks, not synchronously — read `isPending` / the deferred signal inside an `effect()` rather than immediately after calling `start()`.
- Transferables (`ArrayBuffer`, `MessagePort`) are detached on send; pass `withTransferables()` to mark them explicitly.
- `maxInFlight` on RPC pools is per worker, not global — multiply by pool size for total concurrency.
- `pause()` / `resume()` drain in-flight tasks first; queued tasks resume only after `resume()`.
Expand All @@ -581,5 +805,6 @@ await runTask((input: ArrayBuffer) => input.byteLength, buffer, {

## Version history

- **1.15.0** — CSP-safe module workers (`defineWorker` / `defineRpcWorker` / `exposeTask` / `exposeRpc` / `isWorkerModule` / `isModuleWorkerSupported`), client UI-scheduling primitives (`suspense` / `startTransition` / `deferred`); module graduates toward **Stable**.
- **1.14.0** — `withTransferables`, `createSharedBuffer`, RPC `maxInFlight`, pool priorities, `pause` / `resume` / `onIdle`, rolling reactive metrics.
- **1.10.0** — zero-build worker tasks, RPC dispatch, reactive wrappers, support detection, timeout/abort.
2 changes: 2 additions & 0 deletions docs/introduction.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Stable modules will not introduce breaking changes between minor releases. Beta

`server` is also **targeting Stable in 1.15.0**: its session/middleware prerequisite is resolved (first-party sessions, CSRF, guards, and auth helpers), and the `ctx`/`app` contract is now frozen for one minor cycle. See the [Server Stability section](/guide/server) for the exit-criteria checklist, frozen surface, and per-runtime support matrix.

`concurrency` is also **targeting Stable in 1.15.0**: its adoption-blocking prerequisite is resolved — CSP-safe module workers (`defineWorker` / `exposeTask`) remove the mandatory `'unsafe-eval'` — and the public surface is now frozen for one minor cycle. It also gains client UI-scheduling primitives (`suspense`, `startTransition`, `deferred`). See the [Concurrency Stability section](/guide/concurrency) for the exit-criteria checklist, frozen surface, and per-environment support matrix.

## When to use bQuery

bQuery is a good fit when you want:
Expand Down
26 changes: 25 additions & 1 deletion src/concurrency/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,13 @@ export {
} from './errors';
export { createSharedBuffer, withTransferables } from './helpers';
export { batchTasks, every, filter, find, map, parallel, reduce, some } from './high-level';
export {
defineRpcWorker,
defineWorker,
exposeRpc,
exposeTask,
isWorkerModule,
} from './module-worker';
export { pipeline } from './pipeline';
export { createRpcPool, createTaskPool } from './pool';
export {
Expand All @@ -26,7 +33,8 @@ export {
createReactiveTaskWorker,
} from './reactive';
export { callWorkerMethod, createRpcWorker } from './rpc';
export { getConcurrencySupport, isConcurrencySupported } from './support';
export { deferred, startTransition, suspense } from './scheduling';
export { getConcurrencySupport, isConcurrencySupported, isModuleWorkerSupported } from './support';
export { createTaskWorker, runTask } from './task';

export type {
Expand All @@ -35,6 +43,9 @@ export type {
ConcurrencyPipeline,
ConcurrencyPipelineOptions,
ConcurrencySupport,
DefineWorkerOptions,
DeferredOptions,
DeferredSource,
PoolMetrics,
CreateRpcPoolOptions,
CreateRpcWorkerOptions,
Expand All @@ -54,13 +65,26 @@ export type {
ReactiveTaskWorker,
RpcPool,
RpcWorker,
RpcWorkerModule,
RunTaskOptions,
SuspendableState,
SuspenseBoundary,
SuspenseOptions,
SuspenseSource,
TaskPool,
TaskRunOptions,
TaskWorker,
TaskWorkerErrorCode,
TaskWorkerState,
StartTransitionOptions,
Transition,
TransitionStart,
WorkerExecutionMode,
WorkerHostScope,
WorkerModule,
WorkerRpcHandler,
WorkerRpcHandlers,
WorkerRpcSource,
WorkerTaskHandler,
WorkerTaskSource,
} from './types';
Loading