Skip to content
Draft
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ versioning and publication.

### Migration notes

- This draft makes `Response`, `URL`, `Crypto`, `CryptoKey`, `DOMException`, `DOMStringList`,
`DOMTokenList`, `DOMStringMap`, `Headers`, `FormData`, `Cache`, `CacheStorage`, `IDBFactory`,
`StorageManager`, and `NavigationPreloadManager` opaque native identities. Direct property access
on the first group is replaced by typed module accessors: for example, use
`Response.ok(response)`, `URL.getHref(url)`, `URL.setHref(url, href)`, `Crypto.subtle(crypto)`,
and `SubtleCrypto.cryptoKeyType(key)`. Initializers and option dictionaries remain constructible
records. The package-wide migration is incomplete; unconverted native instance types still expose
the existing API.
- WebSocket sends and worker `Request` fetches now preserve their native receiver (`this`) rather
than calling receiver-bound methods unbound.
- `WebAPI.Request.bytes`, `WebAPI.Response.bytes`, `WebAPI.Blob.bytes`,
Expand Down
39 changes: 21 additions & 18 deletions docs/content/docs/api-surface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -114,12 +114,12 @@ when you already have a `WebAPI.Request.t`.
```ReScript
let response = await WebAPI.Fetch.fetch("https://example.com/api/users")

if response.ok {
if WebAPI.Response.ok(response) {
let body = await response->WebAPI.Response.json
Console.log(body)
} else {
Console.log("Request failed")
Console.log(response.status)
Console.log(WebAPI.Response.status(response))
}
```

Expand All @@ -146,11 +146,11 @@ let text = await response->WebAPI.Response.text

### Result-based workflows

`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings and remain unchanged. Use the
separate, opt-in `WebAPI.SafeFetch` module when request, HTTP status, and body-reading failures should
be handled as `result` values. The separate feature is an intentional breaking change for consumers
that restrict enabled features: add `WebAPI.SafeFetch` to use these helpers. Existing raw Fetch
clients are unaffected.
`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings. `Response.t` is an opaque native
type, so read its properties through typed `WebAPI.Response` accessors instead of record fields.
Use the separate, opt-in `WebAPI.SafeFetch` module when request, HTTP status, and body-reading
failures should be handled as `result` values. The separate feature is an intentional breaking change
for consumers that restrict enabled features: add `WebAPI.SafeFetch` to use these helpers.

| Stage | Result | Preserved context |
| ----------------------- | --------------------------------------------- | ------------------------------- |
Expand All @@ -160,8 +160,8 @@ clients are unaffected.

`SafeFetch.fetch` accepts a URL, while `SafeFetch.fetchWithRequest` accepts an existing
`WebAPI.Request.t`. Both promises fulfill with a `result` for these expected failures. A fulfilled
response is classified with the platform's `response.ok` value, so a 404 or 500 is distinct from a
request that did not produce a response. The public `fetchError` type contains `#FetchRejected(exn)`
response is classified with the platform's `Response.ok(response)` value, so a 404 or 500 is distinct
from a request that did not produce a response. The public `fetchError` type contains `#FetchRejected(exn)`
and `#ResponseNotOk({response})`; `bodyError` contains `#BodyReadRejected(readError)`. `readError` has
`response` and `cause` fields, and `response<'body>` retains the original response alongside the
successful body. The function signatures expose extensible error rows (`[> fetchError]` and
Expand Down Expand Up @@ -202,8 +202,8 @@ the application-specific `#InvalidUser`:
```ReScript
switch await loadUser("https://example.com/api/users/42") {
| Error(#FetchRejected(cause)) => reportUnavailable(cause)
| Error(#ResponseNotOk({response})) => handleHttpError(response.status)
| Error(#BodyReadRejected({response, cause})) => handleInvalidBody(response.status, cause)
| Error(#ResponseNotOk({response})) => handleHttpError(WebAPI.Response.status(response))
| Error(#BodyReadRejected({response, cause})) => handleInvalidBody(WebAPI.Response.status(response), cause)
| Error(#InvalidUser) => handleInvalidUser()
| Ok(user) => showUser(user)
}
Expand All @@ -217,7 +217,7 @@ let response = await WebAPI.Fetch.fetch("https://example.com/api/users")

switch response->WebAPI.SafeFetch.checkOk {
| Ok(response) => useResponse(response)
| Error(#ResponseNotOk({response})) => handleHttpError(response.status)
| Error(#ResponseNotOk({response})) => handleHttpError(WebAPI.Response.status(response))
}
```

Expand All @@ -230,7 +230,7 @@ switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") {
switch await response->WebAPI.SafeFetch.text {
| Ok({body}) => Console.error(body)
| Error(#BodyReadRejected(_)) =>
Console.error(`Request failed with ${Int.toString(response.status)}`)
Console.error(`Request failed with ${Int.toString(WebAPI.Response.status(response))}`)
}
| Error(#FetchRejected(cause)) => reportUnavailable(cause)
| Ok(response) => useResponse(response)
Expand Down Expand Up @@ -312,7 +312,7 @@ let initFromHeaders = WebAPI.HeadersInit.fromHeaders(clonedHeaders)
Response headers are normal `Headers` values.

```ReScript
let contentType = response.headers->WebAPI.Headers.get("Content-Type")
let contentType = WebAPI.Response.headers(response)->WebAPI.Headers.get("Content-Type")

switch contentType {
| Null.Value(value) => Console.log(value)
Expand Down Expand Up @@ -384,15 +384,15 @@ let created = WebAPI.Response.fromString(
let empty = WebAPI.Response.fromNull(~init={status: 204})
```

Responses expose record fields for status metadata and methods for cloning or reading
the body.
Responses are opaque native objects. Read their status metadata with typed accessors such as
`Response.ok` and `Response.status`; use module functions for cloning or reading the body.

```ReScript
if created.ok {
if WebAPI.Response.ok(created) {
let text = await created->WebAPI.Response.text
Console.log(text)
} else {
Console.log(created.statusText)
Console.log(WebAPI.Response.statusText(created))
}

let copy = created->WebAPI.Response.clone
Expand Down Expand Up @@ -483,10 +483,13 @@ switch maybeViewport {
## URLs

URL helpers expose constructors and static functions on the same public module.
The native URL object is opaque; use typed accessors rather than direct property access.

```ReScript
let url = WebAPI.URL.make(~url="/docs", ~base="https://rescript-lang.org")
let canParse = WebAPI.URL.canParse(~url="/docs", ~base="https://rescript-lang.org")
let href = WebAPI.URL.getHref(url)
WebAPI.URL.setPathname(url, "/api")
let json = url->WebAPI.URL.toJSON
```

Expand Down
20 changes: 20 additions & 0 deletions docs/content/docs/contributing/api-modelling.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,26 @@ let responseFromRequest = await WebAPI.Fetch.fetchWithRequest(request)
When several signatures differ only by optional arguments, keep a single binding with an
optional labeled argument when the browser API treats it as one operation.

## Native instances and record values

Represent browser-owned objects as abstract native types in their canonical backing module
(`type response`, with no record representation). Public modules should alias that same type
identity and expose properties through typed native `@get` and `@set` bindings. Do not expose
record fields or create record-spread inheritance for native instances: a caller-defined record
must not be coercible to the native type with `:>`. Preserve native property names with `@as`,
nullable and generic types, throwing behavior, and field documentation on the accessor.

This is intentionally different from dictionaries and data values. Request/Response initializers,
options, descriptors, callback dictionaries, and ordinary result records remain constructible
records when the browser accepts them as input or data. Classify each type by its runtime role,
not by a module or type name.

When changing an existing instance API, migrate direct field reads and writes to the module's
typed accessors and document that source-level break. Add packed-consumer negative fixtures for
literal construction, record spread, and ordinary `:>` coercion, plus a positive consumer that
obtains a real instance through a factory and uses its accessors. Expected diagnostics must match
the actual opacity boundary rather than an unrelated type or missing-module error.

## Constructor overloads

Constructors use a distinct naming convention:
Expand Down
13 changes: 11 additions & 2 deletions docs/content/docs/philosophy.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ description: Learn more about the design philosophy of @rescript/webapi.
slug: "design-philosophy"
---

The core idea of these ReScript bindings is that each interface is modeled as a record type. Where possible, inheritance is represented using record spreading, and methods are modeled as functions in a separate module. This design allows for greater familiarity with the underlying JavaScript APIs, making it more friendly for newcomers.
The opaque native-object representation is being introduced across this package. For converted APIs,
ordinary records cannot stand in for browser instances; properties are exposed through typed
accessors and methods through separate module functions. Caller-owned dictionaries, options, and
ordinary data values remain constructible records. This distinction preserves the native JavaScript
object while making invalid lookalikes harder to express.

## ReScript 13 alpha compatibility

Expand Down Expand Up @@ -32,7 +36,12 @@ let myElement: WebAPI.Element.t = document->WebAPI.Document.createElement("div")

## Interfaces

Since ReScript does not have the concept of classes or interfaces, the bindings represent the interfaces as record types. These record types can inherit properties from a "base" type through spreading. This can only be done if there are no circular references in the inheritance chain. If circular references exist, spreading is avoided, and the base properties are duplicated instead.
For converted interfaces, native instances are modeled with abstract types and typed native property
accessors. Other interfaces remain on their existing representation until migrated. Converted
inheritance relationships use the same type identities as their backing Web API bindings; accessor
receiver types remain compatible with base and derived interfaces. Do not use record spreading to
model native instances. Caller-owned dictionaries and plain data containers continue to use record
types.

## Methods

Expand Down
15 changes: 12 additions & 3 deletions docs/content/docs/project-status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,18 @@ implementation modules and internal type-holder modules are not part of that pub
Bindings are maintained and corrected by hand as well as generated; the generator is not a
reproducible source of truth for every checked-in binding.

Approximately 80 bindings still carry private-record TODOs; a blanket conversion is deferred
because the current compiler reports mutation warning 111. Generator reproducibility is also
follow-up work, not an alpha release gate.
Approximately 80 bindings still carry private-record TODOs. The native-instance opacity migration
is underway using abstract types and typed property accessors; it intentionally replaces direct
record-field access for converted APIs. This draft covers only a small subset of the 291 public
modules, so unconverted native records remain constructible and the package-wide guarantee is not
yet complete. Do not infer opacity from a private record alone: ordinary structural coercion can
bypass that restriction. Generator reproducibility is also follow-up work, not an alpha release
gate.

The partial [native-instance inventory](https://github.com/rescript-lang/experimental-rescript-webapi/blob/main/scripts/native-instance-inventory.json)
records the converted set, intentional constructible dictionary/data examples, and unconverted
groups. Stream reader/writer and worker context/source types still modeled as `unknown` have no
native identity to protect yet; they are explicitly unresolved and are not counted as opaque.

CI uses Node 26 for ReScript compilation and Node runtime fixtures. Its Playwright suite runs
compiled Chromium regressions for the confirmed alpha contracts, including typed byte/audio
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,12 @@
"test:browser": "rescript clean && npm run build && playwright test",
"test:negative": "node scripts/check-consumer.mjs --negative-only",
"build": "rescript",
"check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs",
"check:features": "node --test scripts/check-features.test.mjs && node scripts/check-features.mjs && node scripts/check-native-inventory.mjs",
"check:consumer": "node scripts/check-consumer.mjs",
"check:release": "node --test scripts/validate-release-artifact.test.mjs scripts/check-release.test.mjs && node scripts/check-release.mjs",
"check:docs-links": "node --test scripts/check-doc-links.test.mjs scripts/check-doc-type-links.test.mjs && node scripts/check-doc-links.mjs",
"format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./astro.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-release.test.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./scripts/check-doc-links.mjs ./scripts/check-doc-links.test.mjs ./scripts/check-doc-type-links.test.mjs ./package.json ./README.md ./CHANGELOG.md ./docs && prettier --write .github/workflows/*.yml ./docs/pages",
"format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./astro.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-release.test.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./scripts/check-doc-links.mjs ./scripts/check-doc-links.test.mjs ./scripts/check-doc-type-links.test.mjs ./package.json ./README.md ./CHANGELOG.md ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages",
"format": "rescript format && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/native-instance-inventory.json ./scripts/check-native-inventory.mjs ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./astro.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-release.test.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./scripts/check-doc-links.mjs ./scripts/check-doc-links.test.mjs ./scripts/check-doc-type-links.test.mjs ./package.json ./README.md ./CHANGELOG.md ./docs && prettier --write .github/workflows/*.yml ./docs/pages",
"format:check": "rescript format --check && oxfmt ./tests/index.js ./tests/fixtures.json ./tests/browser/*.mjs ./scripts/negative-fixtures.json ./scripts/native-instance-inventory.json ./scripts/check-native-inventory.mjs ./scripts/validate-release-artifact.mjs ./scripts/validate-release-artifact.test.mjs ./playwright.config.mjs ./astro.config.mjs ./scripts/check-consumer.mjs ./scripts/check-release.mjs ./scripts/check-release.test.mjs ./scripts/check-features.mjs ./scripts/check-features.test.mjs ./scripts/check-doc-links.mjs ./scripts/check-doc-links.test.mjs ./scripts/check-doc-type-links.test.mjs ./package.json ./README.md ./CHANGELOG.md ./docs --check && prettier --check .github/workflows/*.yml ./docs/pages",
"docs": "astro dev",
"prebuild:docs": "node docs/llm.js",
"build:docs": "astro build"
Expand Down
7 changes: 4 additions & 3 deletions scripts/check-consumer.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const validateTarballFiles = (files) => {
}
};

const writeProject = (directory, sourceFile, features) => {
const writeProject = (directory, sourceFile, features, warningErrors = "+8") => {
const sourceDirectory = path.join(directory, "src");
const fixturePath = path.resolve(repoRoot, sourceFile);
if (!fixturePath.startsWith(`${path.join(repoRoot, "scripts")}${path.sep}`)) {
Expand All @@ -67,7 +67,7 @@ const writeProject = (directory, sourceFile, features) => {
dependencies:
features === null ? ["@rescript/webapi"] : [{ name: "@rescript/webapi", features }],
"experimental-features": { LetUnwrap: true },
warnings: { error: "+8" },
warnings: { error: warningErrors },
"package-specs": { module: "esmodule", "in-source": true },
},
null,
Expand Down Expand Up @@ -192,6 +192,7 @@ const checkConsumers = async () => {
name: "concrete public types",
fixture: "scripts/consumer-concrete-types.res",
features: null,
warningErrors: "+8+111",
},
{
name: "Fetch-only",
Expand All @@ -217,7 +218,7 @@ const checkConsumers = async () => {
];

for (const scenario of scenarios) {
writeProject(projectDirectory, scenario.fixture, scenario.features);
writeProject(projectDirectory, scenario.fixture, scenario.features, scenario.warningErrors);
compile(projectDirectory, scenario.expectedFailure ?? null);
console.log(`[ok] packed ${scenario.name} consumer`);
}
Expand Down
45 changes: 45 additions & 0 deletions scripts/check-native-inventory.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

const scriptPath = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(scriptPath), "..");
const inventory = JSON.parse(
readFileSync(path.join(path.dirname(scriptPath), "native-instance-inventory.json"), "utf8"),
);

const source = (relativePath) => readFileSync(path.join(repoRoot, relativePath), "utf8");
const escaped = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

const abstractDeclaration = (text, typeName) => {
const match = new RegExp(`^type ${escaped(typeName)}(?:<[^>]+>)?(?:[ \\t]*\\r?$)`, "m").exec(
text,
);
return match !== null;
};

const errors = inventory.convertedNativeInstances.flatMap((entry) => {
const backingSource = source(entry.backingFile);
const publicSource = source(entry.publicFile);
const aliasExists = publicSource.includes(entry.publicAlias);
const missingAccessors = entry.accessors.filter(
(accessor) => !new RegExp(`\\b${escaped(accessor)}\\b`).test(publicSource),
);

return [
...(!abstractDeclaration(backingSource, entry.backingType)
? [`${entry.name} backing ${entry.backingFile} must remain an abstract type.`]
: []),
...(!aliasExists ? [`${entry.name} must retain alias ${entry.publicAlias}.`] : []),
...missingAccessors.map((accessor) => `${entry.name} is missing accessor ${accessor}.`),
];
});

if (errors.length > 0) {
console.error(errors.join("\n"));
process.exitCode = 1;
} else {
console.log(
`Validated ${inventory.convertedNativeInstances.length} abstract native identities from the partial inventory.`,
);
}
4 changes: 2 additions & 2 deletions scripts/consumer-concrete-types.res
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,6 @@ let worklet: WebAPI.Worklet.t => WebAPI.Worklet.t = value => value

let makePublicUrl = () => {
let url: WebAPI.URL.t = WebAPI.URL.make(~url="https://example.invalid")
url.href = "https://example.com"
url.href
WebAPI.URL.setHref(url, "https://example.com")
WebAPI.URL.getHref(url)
}
2 changes: 1 addition & 1 deletion scripts/consumer-crypto.res
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@scope("globalThis")
external crypto: WebAPI.Crypto.t = "crypto"

let subtle = crypto.subtle
let subtle = WebAPI.Crypto.subtle(crypto)
let data =
WebAPI.SubtleCrypto.binaryDataFromUint8Array(Uint8Array.fromArray([65, 66, 67]))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
external crypto: WebAPI.Crypto.t = "crypto"

let _pair = WebAPI.SubtleCrypto.generateEcdsaKeyPair(
crypto.subtle,
WebAPI.Crypto.subtle(crypto),
~algorithm=WebAPI.SubtleCrypto.algorithmObject({
name: "HMAC",
hash: {name: "SHA-256"},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
external crypto: WebAPI.Crypto.t = "crypto"

let _key = WebAPI.SubtleCrypto.generateKeySymmetric(
crypto.subtle,
WebAPI.Crypto.subtle(crypto),
~algorithm=WebAPI.SubtleCrypto.algorithmObject({name: "ECDSA", namedCurve: "P-256"}),
~hash=WebAPI.SubtleCrypto.algorithmObject({name: "SHA-256"}),
~length=256,
Expand Down
Loading
Loading