| <!-- |
| Licensed to the Apache Software Foundation (ASF) under one or more |
| contributor license agreements. See the NOTICE file distributed with |
| this work for additional information regarding copyright ownership. |
| The ASF licenses this file to you under the Apache License, Version 2.0 |
| (the "License"); you may not use this file except in compliance with |
| the License. You may obtain a copy of the License at |
| |
| http://www.apache.org/licenses/LICENSE-2.0 |
| |
| Unless required by applicable law or agreed to in writing, software |
| distributed under the License is distributed on an "AS IS" BASIS, |
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| See the License for the specific language governing permissions and |
| limitations under the License. |
| --> |
| |
| # Node.js Dynamic Extension Design |
| |
| Status: pre-RFC binding-specific design proposal. The current Node.js binding |
| remains one native addon with compile-time service and layer selection. |
| |
| The exact-release extension design is useful for Node.js. Node-API makes the base |
| addon portable across supported Node.js versions, but it does not stabilize the |
| Rust interface between separately built OpenDAL native packages. |
| |
| This document applies the [shared extension architecture](README.md) with |
| Node-specific package loading, addon initialization, Worker, and event-loop |
| constraints. |
| The [shared compatibility contract](compatibility.md) is canonical for native |
| ABI, configuration, lifetime, and loader rules; this document defines Node.js |
| deltas. |
| |
| ## Current Constraints |
| |
| - `bindings/nodejs` builds one napi-rs `cdylib` using Node-API version 6. |
| - Services are selected by Cargo features. The default includes S3 and WebDAV; |
| the published feature set has explicit exclusions and target differences. |
| - `Operator` owns both asynchronous and blocking OpenDAL operators. |
| - Current layers are binding-local `NodeLayer` trait objects wrapped in |
| napi-rs `External<Layer>` values. |
| - An `External<T>` from one independently built addon is not a supported handle |
| for another addon. Its Rust type and layout belong to the addon that created |
| it. |
| - The generated loader already selects target packages by operating system, |
| architecture, and Linux libc, but it loads only one monolithic `.node` file. |
| - The package currently exposes only its root and `package.json` subpaths. |
| - ESM and CommonJS wrappers maintain central layer export lists and must stay in |
| sync. |
| |
| The dynamic design should reuse the target-package pattern without exposing |
| napi-rs implementation types as the extension contract. |
| |
| ## npm Package Layout |
| |
| The proposed family is: |
| |
| ```text |
| opendal Node-API adapter and JavaScript interface |
| @opendal/runtime runtime loader and target resolver |
| @opendal/runtime-linux-x64-gnu target-specific runtime addon |
| @opendal/service-s3 S3 JavaScript stub and types |
| @opendal/service-s3-linux-x64-gnu target-specific S3 native addon/library |
| @opendal/layer-timeout Timeout stub and types |
| @opendal/layer-foyer Foyer stub and types |
| ``` |
| |
| Each root extension package declares: |
| |
| - An exact peer dependency on the compatible `@opendal/runtime` release. |
| - A compatible peer dependency on the `opendal` adapter when it exposes a |
| JavaScript API through that package. |
| - Target packages as optional dependencies with `os`, `cpu`, and `libc` |
| metadata where available. |
| - ESM and CommonJS entry points that use one registration implementation. |
| - A mandatory embedded OpenDAL version check. |
| - A clear error for optional dependencies omitted during installation. |
| |
| The `opendal` adapter declares its `required_runtime_protocol`. |
| `@opendal/runtime` exposes its minimum and current protocol levels for the |
| adapter to check. Applications depend on `opendal` and their selected |
| extensions; the package manager resolves `@opendal/runtime` and its target |
| package. Package names remain provisional pending npm namespace and release |
| prototypes. |
| |
| One root package plus several target packages per extension creates a large |
| publication matrix. A registry with 1,000 manifest records can be efficient; |
| that does not prove that publishing or loading 1,000 native npm package families |
| is operationally practical. |
| |
| ## Runtime Ownership |
| |
| The native architecture gives process resources and Node environment state |
| different owners: |
| |
| ```text |
| loaded opendal native module |
| ProcessRuntime |
| OpenDAL core, Tokio, registries, activation state, handle identity |
| process-lifetime native library leases |
| |
| main napi_env Worker napi_env |
| EnvironmentAdapter A EnvironmentAdapter B |
| JS wrappers and callbacks JS wrappers and callbacks |
| Promise completion bridge Promise completion bridge |
| ``` |
| |
| One loaded runtime native module owns one `ProcessRuntime`. The exact peer |
| dependency should normally produce one such module in a process; nested |
| incompatible runtime installations can load distinct modules with distinct |
| process-runtime identities. The `ProcessRuntime` owns manifest conflicts, |
| activate-once state, native factories, Tokio/core resources, native handles, and |
| library leases. |
| |
| Every `napi_env` owns one `EnvironmentAdapter`. It owns JavaScript wrappers, |
| references, resolver/activation callbacks, Promise completion bridges, and |
| environment cleanup hooks. Registering through an environment adapter commits |
| the manifest atomically into its `ProcessRuntime`; an identical process-level |
| registration is idempotent and a different owner is a conflict. Native package |
| activation runs once per `ProcessRuntime`, not once per Worker. |
| |
| Node-API environments can be initialized and destroyed multiple times and can |
| run concurrently in Workers. A `napi_env`, `napi_value`, reference, JavaScript |
| callback, or public wrapper must never move between environment adapters. Node |
| documents these environment-lifecycle rules in its [Node-API |
| documentation](https://nodejs.org/api/n-api.html). |
| |
| Environment cleanup stops new calls, cancels or finishes environment-owned |
| asynchronous work, releases all JavaScript references, and removes its local |
| callbacks. It does not unload process-pinned extension libraries or invalidate |
| native handles owned by another environment. A Worker can therefore terminate |
| independently of operations in another Worker. |
| |
| ## Native Registration Adapter |
| |
| The first Node prototype should compare two packaging implementations: |
| |
| 1. The `ProcessRuntime` loads a language-neutral native library directly from |
| the package manifest. |
| 2. An environment-bound JavaScript activator loads a target-specific Node-API |
| bootstrap addon in the calling `napi_env`. |
| |
| Both implementations use the same bootstrap metadata and selected encoding, |
| OpenDAL version, factory contract, and conformance suite. A literal separately |
| installed shared Rust `dylib` is not a design assumption; npm/pnpm/Yarn layouts, |
| rpaths, and Windows DLL discovery must prove it first. |
| |
| Direct host loading is the preferred starting point because it naturally |
| preserves JSON registration, process-level activate-once behavior, and |
| construction-time native activation. |
| |
| The Node-API bootstrap variant requires this explicit environment-bound |
| activation sequence: |
| |
| 1. The JavaScript registration stub gives its `EnvironmentAdapter` a JSON |
| manifest and a local activation callback. It loads no native target package. |
| 2. First construction asks the adapter, on its JavaScript thread, to invoke that |
| callback. |
| 3. The callback resolves the package's target artifact and synchronously loads |
| its `.node` addon in the same `napi_env`. ESM and CommonJS wrappers call one |
| shared loader implementation. |
| 4. The addon uses the normal Node-API initializer and returns the bootstrap |
| status, encoding discriminant, and payload. The payload is JSON bytes or a |
| `napi_external` pointing to C-layout metadata. It does not return a napi-rs |
| class or an `External<T>` shared with the base. |
| 5. The base validates the status, length, and selected encoding before giving |
| its release-specific entry to the `ProcessRuntime`. The process runtime |
| installs the factory and records an explicit process-lifetime library lease. |
| 6. The environment adapter releases its activation callback after success. The |
| factory does not retain `napi_env`, `napi_value`, JavaScript references, or |
| thread-safe functions. |
| |
| The variant is viable only if the platform prototype can retain a native |
| library lease independently from the initiating environment. A variant that |
| loads the HDFS addon during registration, or whose callbacks become invalid |
| when the initiating Worker exits, fails the design requirements. |
| |
| The base schedules extension futures on runtime-owned native resources and |
| adapts completion into the calling environment. |
| |
| ## Registration Interface |
| |
| Explicit registration avoids bundler-dependent side effects: |
| |
| ```javascript |
| import { Operator } from "opendal"; |
| import { registerS3 } from "@opendal/service-s3"; |
| |
| registerS3(); |
| |
| const op = Operator.fromUri("s3://photos/archive", { |
| region: "us-east-1", |
| }); |
| ``` |
| |
| `registerS3()` is idempotent for the same package and `ProcessRuntime`. It |
| registers a JSON manifest without activating native code. Under the bootstrap |
| addon prototype it also installs one environment-local activation callback; |
| under direct host loading the manifest's artifact path is sufficient. |
| |
| A package may offer a documented side-effect registration subpath for |
| convenience, but it must mark that subpath appropriately for bundlers. The |
| explicit function remains the unambiguous interface. |
| |
| ## Proposed Layer Interface |
| |
| `Operator.layer()` should accept a base-owned opaque `Layer`, not |
| `ExternalObject<Layer>` from napi-rs internals. |
| |
| Synchronous factories work for layers without asynchronous resources: |
| |
| ```javascript |
| import { TimeoutLayer } from "@opendal/layer-timeout"; |
| import { ThrottleLayer } from "@opendal/layer-throttle"; |
| |
| const timeout = new TimeoutLayer(); |
| timeout.timeout = 60_000; |
| timeout.ioTimeout = 10_000; |
| |
| const limit = new ThrottleLayer(10 * 1024, 10 * 1024 * 1024); |
| |
| const layered = op.layer(limit.build()).layer(timeout.build()); |
| ``` |
| |
| The compatibility `opendal` package preserves these current constructors, |
| setters, and `.build()` calls. Internally, `.build()` returns a base-owned opaque |
| `Layer` instead of a package-local napi-rs `External<T>`. If the generated |
| `ExternalObject<Layer>` TypeScript name cannot remain as a deprecated alias, the |
| type-name change must wait for the binding's next breaking public release; it |
| does not justify weakening the native handle boundary. |
| |
| Foyer uses a Promise-returning factory because JavaScript constructors cannot |
| be asynchronous: |
| |
| ```javascript |
| import { FoyerLayer } from "@opendal/layer-foyer"; |
| |
| const cache = await FoyerLayer.create({ |
| memoryCapacity: 64 << 20, |
| storagePath: "/var/cache/opendal", |
| }); |
| |
| const cached = op.layer(cache); |
| ``` |
| |
| The binding should not provide a synchronous Foyer constructor that blocks the |
| event loop. A separately documented worker/off-thread helper can be evaluated |
| later. |
| |
| Layer packages validate JavaScript numbers before calling Rust. Timeout values |
| use non-negative integer milliseconds and convert to the shared |
| [`SignedDuration`](compatibility.md#configuration-value-contract) |
| representation. |
| The adapter rejects values outside `SignedDuration`'s `i64`-seconds range and |
| never truncates, saturates, or wraps them. Throttle bandwidth and burst must be |
| positive integers in the supported `u32` range so invalid input cannot reach the |
| core constructor's assertions. Other options convert through the same shared |
| `ConfigValue` grammar; native factories never receive JavaScript objects. |
| |
| Applying one Throttle or Foyer handle to several operators preserves shared |
| native state. Later `.layer()` calls remain outer layers. The adapter preserves |
| the [canonical Timeout/Retry order](compatibility.md#layer-compatibility-rules) |
| and rejects the known unsafe composition when both layer IDs are visible. |
| |
| ## Async Operations and Cancellation |
| |
| The base adapter owns the conversion between runtime futures and JavaScript |
| Promises. Extension factories and operations do not call Node-API from Tokio |
| worker threads. |
| |
| Promise cancellation policy must be explicit because JavaScript Promises do not |
| provide universal cancellation. Where an operation accepts an `AbortSignal`, |
| the adapter forwards it into the runtime and drops or aborts the native future |
| according to OpenDAL semantics. |
| |
| Worker termination must not leave a callback targeting a destroyed `napi_env`. |
| Process-level native work may outlive one environment only when it has no |
| environment-owned completion callback and its resources have an explicit owner. |
| |
| ## ESM, CommonJS, and Bundlers |
| |
| - ESM and CommonJS exports converge on one `EnvironmentAdapter` per Node |
| environment and the same `ProcessRuntime` for that loaded runtime module. |
| - Calling registration through both module systems is idempotent. |
| - Export maps include a supported registration/runtime subpath instead of |
| relying on generated private files. |
| - Side-effect-only registration modules declare their side effects so bundlers |
| do not remove them. |
| - Package stubs resolve native artifacts relative to their own installed |
| package, not the current working directory. |
| - Errors distinguish an unsupported target from installation with |
| `--omit=optional` and from an incompatible runtime. |
| |
| Dynamic native extensions are scoped to native Node-API targets. WASI and other |
| environments without compatible dynamic loading require a static bundled |
| design and should not silently fall back to this interface. |
| |
| ## Version and Error Behavior |
| |
| Node-API version compatibility and OpenDAL runtime compatibility remain |
| separate. The generated package-version checks provide an early diagnostic; the |
| embedded exact OpenDAL version check remains authoritative. |
| |
| Extension lifecycle errors should be JavaScript `Error` subclasses or errors |
| with stable codes: |
| |
| ```text |
| OPENDAL_EXTENSION_NOT_INSTALLED |
| OPENDAL_EXTENSION_LOAD_FAILED |
| OPENDAL_EXTENSION_INCOMPATIBLE |
| OPENDAL_EXTENSION_CONFLICT |
| OPENDAL_LAYER_INITIALIZATION_FAILED |
| ``` |
| |
| Errors retain package ID, scheme/layer ID, target, and construction operation. |
| They do not expose credentials or an unredacted option object. Normal OpenDAL |
| errors retain their structured kind instead of becoming only a formatted |
| reason string. |
| |
| ## Multiple Runtime Versions |
| |
| npm can install nested copies of a package. An extension stub may therefore see |
| a different `opendal` instance from the one that created an operator. |
| |
| The design applies three defenses: |
| |
| 1. Exact runtime peer dependencies make the intended singleton visible to the |
| package manager. |
| 2. Registration records the specific `ProcessRuntime` identity. |
| 3. A layer/operator wrapper verifies process-runtime identity before native |
| handle use and throws `OPENDAL_EXTENSION_INCOMPATIBLE` on mismatch. |
| |
| The adapter must never reinterpret a handle from another `ProcessRuntime`, even |
| when package versions appear equal. JavaScript wrappers also remain confined to |
| their creating `EnvironmentAdapter`. |
| |
| ## Migration |
| |
| 1. Introduce `ProcessRuntime`, `EnvironmentAdapter`, and the extension registry |
| inside the current addon. |
| 2. Replace the public `ExternalObject<Layer>` detail with a base-owned opaque |
| layer wrapper while retaining current constructors. |
| 3. Make compiled services/layers use the internal extension factory model. |
| 4. Publish runtime and target packages using the existing platform-loader |
| experience. |
| 5. Extract S3 and Timeout as tracer package families and remove them from the |
| base package after their packages are available. |
| 6. Add Foyer async creation and HDFS lazy activation as design gates. |
| 7. Test both direct native loading and Node-API bootstrap addons before |
| selecting the physical linking model. |
| 8. Publish the third-party SDK only after Worker, ESM/CommonJS, target, and |
| lifetime conformance passes. |
| |
| ## Node.js Conformance Gates |
| |
| - ESM/CommonJS double registration, one environment adapter per `napi_env`, and |
| process-level activate-once behavior. |
| - Main thread plus multiple Workers importing, using, and terminating adapters. |
| - Two incompatible nested runtime versions rejecting cross-instance handles. |
| - Missing optional target package and unsupported target diagnostics. |
| - glibc/musl, macOS, and Windows artifact selection. |
| - Bundler retention for any documented side-effect registration entry. |
| - S3/WebDAV construction without central JavaScript config schemas. |
| - HDFS registration without Java/Hadoop and isolated activation failure. |
| - Foyer Promise construction, rejection, cleanup, and reusable handle state. |
| - Timeout executor behavior and Throttle shared identity through extracted |
| packages. |
| - No callback into a destroyed Node environment or unloaded native library. |
| - 1,000 synthetic registrations without loading 1,000 native addons. |