From c5e200f2792f1bda6c834b822e7bbe9ebdf1298c Mon Sep 17 00:00:00 2001 From: Andrew Gallant Date: Mon, 29 Jul 2024 17:10:06 -0400 Subject: [PATCH] jiff: add robust WASM support This PR basically makes the `wasm{32,64}-unknown-unknown` targets work _almost_ out of the box. All you need to do is enable Jiff's new `js` crate feature. This will cause Jiff to depend on `js-sys` and `wasm-bindgen`. Jiff will then use Javascript APIs to determine the current time and time zone. This PR also includes a new long form guide, `PLATFORM.md`, which describes Jiff's platform support in one central location. (Most information is already in Jiff's API docs, but it's scattered in a variety of places.) Finally, this adds a `wasm32-unknown-unknown` test to CI courtesy of `wasm-pack`. It just does a basic sanity check that the current time and time zone can be retrieved. Fixes #56 --- .github/workflows/ci.yml | 163 ++++++++++------- CHANGELOG.md | 12 ++ Cargo.toml | 33 +++- Cross.toml | 6 + PLATFORM.md | 319 ++++++++++++++++++++++++++++++++++ README.md | 1 + jiff-wasm/.gitignore | 3 + jiff-wasm/Cargo.toml | 26 +++ jiff-wasm/README.md | 7 + jiff-wasm/lib.rs | 25 +++ src/lib.rs | 17 ++ src/now.rs | 76 ++++++++ src/timestamp.rs | 10 +- src/tz/db/bundled/mod.rs | 10 +- src/tz/db/mod.rs | 6 +- src/tz/db/zoneinfo/enabled.rs | 2 +- src/tz/system/mod.rs | 20 ++- src/tz/system/unix.rs | 11 +- src/tz/system/wasm_js.rs | 47 +++++ src/util/cache.rs | 9 +- src/zoned.rs | 10 +- test-wasm | 2 +- 22 files changed, 737 insertions(+), 78 deletions(-) create mode 100644 Cross.toml create mode 100644 PLATFORM.md create mode 100644 jiff-wasm/.gitignore create mode 100644 jiff-wasm/Cargo.toml create mode 100644 jiff-wasm/README.md create mode 100644 jiff-wasm/lib.rs create mode 100644 src/now.rs create mode 100644 src/tz/system/wasm_js.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8df1058..d9b9031 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,18 +29,6 @@ permissions: jobs: test: - env: - # For some builds, we use cross to test on 32-bit and big-endian - # systems. - CARGO: cargo - # When CARGO is set to CROSS, TARGET is set to `--target matrix.target`. - # Note that we only use cross on Linux, so setting a target on a - # different OS will just use normal cargo. - TARGET: - # Bump this as appropriate. We pin to a version to make sure CI - # continues to work as cross releases in the past have broken things - # in subtle ways. - CROSS_VERSION: v0.2.5 runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -49,22 +37,6 @@ jobs: - build: stable os: ubuntu-latest rust: stable - - build: stable-x86 - os: ubuntu-latest - rust: stable - target: i686-unknown-linux-gnu - - build: stable-aarch64 - os: ubuntu-latest - rust: stable - target: aarch64-unknown-linux-gnu - - build: stable-powerpc64 - os: ubuntu-latest - rust: stable - target: powerpc64-unknown-linux-gnu - - build: stable-s390x - os: ubuntu-latest - rust: stable - target: s390x-unknown-linux-gnu - build: beta os: ubuntu-latest rust: beta @@ -87,38 +59,12 @@ jobs: uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust }} - - name: Install and configure Cross - if: matrix.os == 'ubuntu-latest' && matrix.target != '' - run: | - # In the past, new releases of 'cross' have broken CI. So for now, we - # pin it. We also use their pre-compiled binary releases because cross - # has over 100 dependencies and takes a bit to compile. - dir="$RUNNER_TEMP/cross-download" - mkdir "$dir" - echo "$dir" >> $GITHUB_PATH - cd "$dir" - curl -LO "https://github.com/cross-rs/cross/releases/download/$CROSS_VERSION/cross-x86_64-unknown-linux-musl.tar.gz" - tar xf cross-x86_64-unknown-linux-musl.tar.gz - - # We used to install 'cross' from master, but it kept failing. So now - # we build from a known-good version until 'cross' becomes more stable - # or we find an alternative. Notably, between v0.2.1 and current - # master (2022-06-14), the number of Cross's dependencies has doubled. - echo "CARGO=cross" >> $GITHUB_ENV - echo "TARGET=--target ${{ matrix.target }}" >> $GITHUB_ENV - - name: Show command used for Cargo - run: | - echo "cargo command is: ${{ env.CARGO }}" - echo "target flag is: ${{ env.TARGET }}" - - run: ${{ env.CARGO }} build --verbose $TARGET - - run: ${{ env.CARGO }} doc --verbose $TARGET - - if: matrix.build == 'pinned' - run: ${{ env.CARGO }} test - - if: matrix.build != 'pinned' - run: ${{ env.CARGO }} test --all --verbose $TARGET - - if: matrix.build != 'pinned' - run: ${{ env.CARGO }} test -p jiff-cli --verbose $TARGET - - if: matrix.target == '' + - run: cargo build --verbose + - run: cargo doc --verbose + - run: cargo test --verbose --all + - run: cargo test --verbose -p jiff-cli + # Skip on Windows because it takes freaking forever. + - if: matrix.build != 'win-msvc' && matrix.build != 'win-gnu' run: ./test # This job runs a stripped down version of CI to test the MSRV. The specific @@ -150,8 +96,44 @@ jobs: - name: Run integration tests run: cargo test --test integration - # Setup and run tests on the wasm32-wasi target via wasmtime. - wasm: + # Generic testing for most cross targets. Some get special treatment in + # other jobs. + cross: + env: + # Bump this as appropriate. We pin to a version to make sure CI + # continues to work as cross releases in the past have broken things + # in subtle ways. + CROSS_VERSION: v0.2.5 + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: + - i686-unknown-linux-gnu + - aarch64-unknown-linux-gnu + - powerpc-unknown-linux-gnu + - powerpc64-unknown-linux-gnu + - s390x-unknown-linux-gnu + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install and configure Cross + run: | + # In the past, new releases of 'cross' have broken CI. So for now, we + # pin it. We also use their pre-compiled binary releases because cross + # has over 100 dependencies and takes a bit to compile. + dir="$RUNNER_TEMP/cross-download" + mkdir "$dir" + echo "$dir" >> $GITHUB_PATH + cd "$dir" + curl -LO "https://github.com/cross-rs/cross/releases/download/$CROSS_VERSION/cross-x86_64-unknown-linux-musl.tar.gz" + tar xf cross-x86_64-unknown-linux-musl.tar.gz + - run: cross build --verbose --target ${{ matrix.target }} + - run: cross test --verbose --target ${{ matrix.target }} --all + - run: cross test --verbose --target ${{ matrix.target }} -p jiff-cli + + # Test the wasm32-wasip1 target via wasmtime. + wasm32-wasip1: runs-on: ubuntu-latest env: # The version of wasmtime to download and install. @@ -185,6 +167,62 @@ jobs: - name: Run integration tests run: cargo test --test integration -- --nocapture + # Test the wasm32-unknown-emscripten target. + # + # Regretably, `insta` doesn't work on emscripten, so we just do a basic + # sanity check here. + wasm32-unknown-emscripten: + runs-on: ubuntu-latest + env: + # Bump this as appropriate. We pin to a version to make sure CI + # continues to work as cross releases in the past have broken things + # in subtle ways. + CROSS_VERSION: v0.2.5 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + - name: Install Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + - name: Install and configure Cross + run: | + dir="$RUNNER_TEMP/cross-download" + mkdir "$dir" + echo "$dir" >> $GITHUB_PATH + cd "$dir" + curl -LO "https://github.com/cross-rs/cross/releases/download/$CROSS_VERSION/cross-x86_64-unknown-linux-musl.tar.gz" + tar xf cross-x86_64-unknown-linux-musl.tar.gz + - name: Build jiff + run: cross build --verbose --target wasm32-unknown-emscripten -p jiff + - name: Build jiff-tzdb + run: cargo build --verbose --target wasm32-unknown-emscripten -p jiff-tzdb + - name: Build jiff-tzdb-platform + run: cargo build --verbose --target wasm32-unknown-emscripten -p jiff-tzdb-platform + - name: Run library tests + run: cross test --verbose --target wasm32-unknown-emscripten --features logging --lib now_works -- --nocapture + + # Tests wasm32-unknown-unknown integration via wasm-pack. + wasm32-unknown-uknown: + runs-on: ubuntu-latest + env: + # Set the time zone to something so that there's some kind of interesting + # output to scrutinize. The test below doesn't actually assert anything + # about the time zone, but we can at least visually inspect it in the CI + # logs. + TZ: America/New_York + steps: + - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v4 + - name: Install wasm-pack + run: | + curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh + - name: Test wasm-pack project + run: | + cd jiff-wasm + wasm-pack test --node + + # Run benchmarks as tests. testbench: runs-on: ubuntu-latest steps: @@ -198,6 +236,7 @@ jobs: run: | cargo bench --manifest-path bench/Cargo.toml -- --test + # Check that all files are formatted properly. rustfmt: runs-on: ubuntu-latest steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 533fe47..9b1ec59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +0.1.3 (2024-07-30) +================== +This release features support for `wasm32-unknown-unknown`. That is, when +Jiff's new `js` crate feature is enabled, Jiff will automatically use +JavaScript APIs to determine the current time and time zone. + +Enhancements: + +* [#58](https://github.com/BurntSushi/jiff/pull/58): +Add WASM support and a new `PLATFORM.md` guide. + + 0.1.2 (2024-07-28) ================== This release features a few new APIs that a need for arose while experimenting diff --git a/Cargo.toml b/Cargo.toml index 4758a6d..2e654d3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,26 @@ tzdb-bundle-always = ["dep:jiff-tzdb", "alloc"] # database that is typically found at /usr/share/zoneinfo on macOS and Linux. tzdb-zoneinfo = ["std"] +# This enables bindings to web browser APIs for retrieving the current time +# and configured time zone. This ONLY applies on wasm32-unknown-unknown and +# wasm64-unknown-unknown targets. Specifically, *not* on wasm32-wasi or +# wasm32-unknown-emscripten targets. +# +# This is an "ecosystem" compromise due to the fact that there is no general +# way to determine at compile time whether a wasm target is intended for use +# on the "web." In practice, only wasm{32,64}-unknown-unknown targets are used +# on the web, but wasm{32,64}-unknown-unknown targets can be used in non-web +# contexts as well. Thus, the `js` feature should be enabled only by binaries, +# tests or benchmarks when it is *known* that the application will be used in a +# web context. +# +# Libraries that depend on Jiff should not need to define their own `js` +# feature just to forward it to Jiff. Instead, application authors can depend +# on Jiff directly and enable the `js` feature themselves. +# +# (This is the same dependency setup that the `getrandom` crate uses.) +js = ["wasm-bindgen", "js-sys"] + [dependencies] jiff-tzdb = { version = "0.1.0", path = "jiff-tzdb", optional = true } log = { version = "0.4.21", optional = true } @@ -62,8 +82,8 @@ serde = { version = "1.0.203", optional = true } # Note that the `cfg` gate for the `tzdb-bundle-platform` must repeat the # target gate on this dependency. The intent is that `tzdb-bundle-platform` # is enabled by default, but that the `tzdb-bundle-platform` crate is only -# actually used on platforms without a system tzdb (i.e., Windows). -[target.'cfg(windows)'.dependencies] +# actually used on platforms without a system tzdb (i.e., Windows and wasm). +[target.'cfg(any(windows, target_family = "wasm"))'.dependencies] jiff-tzdb-platform = { version = "0.1.0", path = "jiff-tzdb-platform", optional = true } [target.'cfg(windows)'.dependencies.windows-sys] @@ -72,6 +92,10 @@ default-features = false features = ["Win32_Foundation", "Win32_System_Time"] optional = true +[target.'cfg(all(any(target_arch = "wasm32", target_arch = "wasm64"), target_os = "unknown"))'.dependencies] +js-sys = { version = "0.3.50", optional = true } +wasm-bindgen = { version = "0.2.70", optional = true } + [dev-dependencies] anyhow = "1.0.81" chrono = { version = "0.4.38", features = ["serde"] } @@ -88,9 +112,8 @@ time = { version = "0.3.36", features = ["local-offset", "macros", "parsing"] } tzfile = "0.1.3" walkdir = "2.5.0" -# hifitime doesn't build on wasm32-wasip1 for some reason, so only -# depend on it on Unix or Windows. -[target.'cfg(any(unix, windows))'.dev-dependencies.hifitime] +# hifitime doesn't build on wasm for some reason, so exclude it there. +[target.'cfg(not(target_family = "wasm"))'.dev-dependencies.hifitime] version = "3.9.0" [[test]] diff --git a/Cross.toml b/Cross.toml new file mode 100644 index 0000000..2e063f0 --- /dev/null +++ b/Cross.toml @@ -0,0 +1,6 @@ +[build.env] +passthrough = [ + "TZ", + "RUST_LOG", + "RUST_BACKTRACE", +] diff --git a/PLATFORM.md b/PLATFORM.md new file mode 100644 index 0000000..f4bf9cf --- /dev/null +++ b/PLATFORM.md @@ -0,0 +1,319 @@ +# Platform support + +This document describes Jiff's platform support. That is, it describes the +interaction points between this library and its environment. Most of the +details in this document are written down elsewhere on individual APIs, but +this document serves to centralize everything in one place. + +As a general rule, interaction with the environment requires that Jiff's +`std` feature is enabled. The `std` feature is what allows Jiff to read +environment variables and files, for example. + +Before starting, let's cover some vocabulary first. + +## Vocabulary + +This section defines the key terms used below when describing platform support. +We also try to contextualize the concepts to make their meaning concrete in a +way that hopefully relates to your lived experience. + +* [Civil time]: The time you see on your clock. And in general, the time that +the humans in your approximate geographic vicinity also see. That is, civil +time is a human coordinated agreement for communicating time in a particular +geographic region. Civil time is also known as: local time, plain time, naive +time, clock time and others. +* [Time zone]: A set of rules for determining the civil (or "local") time, +via an offset from UTC, in a particular geographic region. In many cases, the +offset in a particular time zone can vary over the course of a year through +transitions into and out of [daylight saving time]. A time zone is necessary +to convert civil time into a precise unambiguous instant in time. +* [IANA Time Zone Database]: A directory on your system containing a store of +files, one per time zone, which encode the time at which transitions between +UTC offsets occur in a specific geographic region. In effect, each time zone +file provides a mapping between civil (or "local") time and UTC. The format +of each file is called TZif and is specified by [RFC 8536]. This database is +typically found at `/usr/share/zoneinfo` and only on Unix systems (including +macOS). Other environments, like Windows and WASM, do not have a standard copy +of the Time Zone Database. (Jiff will instead embed it into your program by +default on these platforms.) +* [IANA time zone identifier]: A short ASCII human readable string identifying +a time zone in the IANA Time Zone Database. The time zone for where I live, +`America/New_York`, has an entry at `/usr/share/zoneinfo/America/New_York` +on my system. IANA time zone identifiers are used by Jiff's `Zoned` type +to losslessly roundtrip datetimes via an interchange format specified by +[Temporal] that draws inspiration from [RFC 3339], [RFC 9557] and [ISO 8601]. + +## Environment variables + +Jiff reads exactly two environment variables. These variables are read on +all platforms that support environment variables. So for example, Jiff +will respect `TZ` on Windows. Note though that some environments, like +`wasm32-wasip1` or `wasm32-unknown-emscripten`, are sandboxed by default. A +sandboxed environment typically makes reading environment variables set outside +the sandbox impossible (or require opt-in support, such as [wasmtime]'s `-S +inherit-env` or `--env` flags). + +### `TZDIR` + +The `TZDIR` environment variable tells Jiff where to look for the +[IANA Time Zone Database]. When it isn't set, Jiff will check a few standard +locations for the database. It's usually found at `/usr/share/zoneinfo`. + +It can be useful to set this for non-standard environments or when you +specifically want Jiff to prefer using a non-system copy of the database. +(If you want Jiff to _only_ use a non-system copy of the database, then you'll +need to use `TimeZoneDatabase::from_dir` and use the resulting handle +explicitly.) + +If a IANA Time Zone Database could not be found a `TZDIR`, then Jiff will +still attempt to look for a database at the standard locations (like +`/usr/share/zoneinfo`). + +### `TZ` + +The `TZ` environment variable overrides and sets the default system time zone. +It is [specified by POSIX][POSIX TZ]. Jiff implements the POSIX specification +(even on non-POSIX platforms like Windows) with some common extensions. + +It is useful to set `TZ` when Jiff could not detect (or had a problem +detecting) the system time zone, or if the system time zone is wrong in a +specific circumstance. + +Summarizing POSIX (and common extensions supported by GNU libc and musl), the +`TZ` environment variable accepts these kinds of values: + +* `America/New_York` sets the time zone via a IANA time zone identifier. +* `/usr/share/zoneinfo/America/New_York` sets the time zone by providing a path +to a TZif formatted file. +* `EST5EDT,M3.2.0,M11.1.0` sets the time zone using a POSIX daylight saving +time rule. The rule shown here is for `US/Eastern` at time of writing (2024). +This is useful for specifying a custom time zone with generating TZif data, +but is rarely used in practice. + +When `TZ` isn't set, then Jiff uses heuristics to detect the system's +configured time zone. If this automatic detection fails, please first check +for an [existing issue for your platform][issue-platform], and if one doesn't +exist, please [file a new issue][issue-new]. Otherwise, setting `TZ` should be +considered as a work-around. + +## Platforms + +This section lists the platforms that Jiff has explicit support for. Support +may not be perfect, so if something isn't working as it should, check the +list of [existing platform related issues][issue-platform]. If you can't find +one that matches your specific problem, [create a new issue][issue-new]. + +For each platform, there are generally three things to consider: + +1. Whether getting the current time is supported. +2. How Jiff finds the IANA Time Zone Database. +3. How Jiff finds the system configured time zone. + +We answer these questions for each platform. + +### Unix + +#### Current time + +All Unix platforms should be supported in terms of getting the current time. +This support comes from Rust's standard library. + +#### IANA Time Zone Database + +The vast majority of Unix systems, including macOS, store a copy of the IANA +time zone database at `/usr/share/zoneinfo`, which Jiff will automatically +detect. If your Unix system uses a different directory, you may try to submit +a PR adding support for it in Jiff proper, or just set the `TZDIR` environment +variable. + +The existence of `/usr/share/zoneinfo` is not guaranteed in all Unix environments. +For example, stripped down Docker containers might omit a full copy of the +time zone database. Jiff will still work in such environments, but all IANA +time zone identifier lookups will fail. To fix this, you can either install the +IANA Time Zone Database into your environment, or you can enable the Jiff +crate feature `tzdb-bundle-always`. This compile time setting will cause Jiff +to depend on `jiff-tzdb`, which includes a complete copy of the IANA Time Zone +Database embedded into the compiled artifact. + +Bundling the IANA Time Zone Database should only be done as a last resort. +Especially on Unix systems, it is greatly preferred to use the system copy of +the database, as the database is typically updated a few times each year. By +using the system copy, Jiff will automatically pick up updates without needing +to be recompiled. + +But if bundling is needed, it is a fine solution. It just means that Jiff will +need to be re-compiled after `jiff-tzdb` is updated when a new IANA Time Zone +Database release is published. + +#### System time zone + +On most Unix systems, the system configured time zone manifests as a symbolic +link at `/etc/localtime`. The symbolic link usually points to a file in +your system copy of the IANA Time Zone Database. For example, on my Linux +system: + +```text +$ ls -l /etc/localtime +lrwxrwxrwx 1 root root 36 Jul 15 20:26 /etc/localtime -> /usr/share/zoneinfo/America/New_York +``` + +And my macOS system: + +```text +$ ls -l /etc/localtime +lrwxr-xr-x 1 root wheel 42 Jun 20 07:13 /etc/localtime -> /var/db/timezone/zoneinfo/America/New_York +``` + +Jiff examines the symbolic link metadata to extract the IANA time zone +identifier from the file path. In the above two examples, that would be +`America/New_York`. The identifier is then used to do a lookup in the system +copy of the IANA Time Zone Database. + +If `/etc/localtime` is not a symbolic link, then Jiff reads it directly as a +TZif file. When this happens, Jiff cannot feasibly know the IANA time zone +identifier. While arithmetic on the resulting `Zoned` value will still be DST +safe, one cannot losslessly serialize and deserialize it since Jiff won't be +able to include the IANA time zone identifier in the serialized format. When +such a `Zoned` value is serialized, the offset of the datetime will be used +in lieu of the IANA time zone identifier. + +(NOTE: Not all Unix systems follow this pattern. If your system uses a +different way to configure the system time zone, please check [available +platform issues][issue-platform] for a related issue. If one doesn't exist, +please [create a new issue][issue-new].) + +### Windows + +#### Current time + +All Windows platforms should be supported in terms of getting the current time. +This support comes from Rust's standard library. + +#### IANA Time Zone Database + +Windows does not have a canonical installation of the IANA Time Zone Database +like Unix. Because of this, and because of the importance of time zone support +to Jiff's design, Jiff will automatically embed an entire copy of the IANA Time +Zone Database into your binary on Windows. + +The automatic bundling is done via the Jiff crate feature +`tzdb-bundle-platform`. This is a _target activated feature_. Namely, it is +enabled by default, but only results in a bundled database on an enumerated set +of platforms (where Windows is one of them). If you want to opt out of bundling +the database on Windows, you'll need to disable this feature. + +Bundling the IANA Time Zone Database is not ideal, since after a new release of +the database, you'll need to wait for the `jiff-tzdb` crate to be updated. Then +you'll need to update your dependency version and re-compile your software to +get the database updates. + +One alternative is to point Jiff to a copy of the IANA Time Zone Database via +the `TZDIR` environment variable. Even on Windows, Jiff will attempt to read +the directory specified as a time zone database. But you'll likely need to +manage the database yourself. + +#### System time zone + +Jiff currently uses [`GetDynamicTimeZoneInformation`] from the Windows C API +to query the current time zone information. This provides a value of type +[`DYNAMIC_TIME_ZONE_INFORMATION`]. Jiff uses the `TimeZoneKeyName` member +of that type to do a lookup in Unicode's [CLDR XML data] that maps Windows +time zone names to IANA time zone identifiers. The resulting IANA time zone +identifier is then used as a key to find a time zone in the configured IANA +Time Zone Database. + +### WASM + +There are a variety of WASM targets available for Rust that service different +use cases. Here is a possibly incomplete list of those targets and a short +imprecise blurb about them: + +* `wasm32-unknown-emscripten`: Sandboxed and emulates Unix as much as possible. +* `wasm32-wasi` and `wasm32-wasip1`: Provides a sandbox with capability-based +security. This is not typically used in web browsers. [wasmtime] is an example +of a runtime that can run programs compiled for these targets. +* `wasm{32,64}-unknown-unknown`: Typically used for web deployments to run in +a browser via `wasm-pack`. But, crucially, not exclusively so. + +Jiff supports all of these targets, but the nature of that support varies. Each +target is discussed in the sections below. + +#### The `js` crate feature + +Jiff comes with a `js` crate feature that is disabled by default. It is a +_target activated feature_ that enables dependencies on the `js-sys` and +`wasm-bindgen` crates. This feature is intended to be enabled only in binaries, +tests and benchmarks when it is known that the code will be running in a +web context. Consequently, this feature only activates this support for the +`wasm{32,64}-unknown-unknown` targets. It has no effect on any other target, +including other WASM targets. + +Library crates should generally never enable Jiff's `js` feature or even +forward it. Applications using your library can depend on Jiff directly and +enable the feature. + +#### Current time + +* `wasm32-unknown-emscripten`: Supported via Rust's standard library. +* `wasm32-wasi*`: Supported via Rust's standard library. +* `wasm{32,64}-unknown-unknown`: `std::time::SystemTime::now()`, and thus +`Zoned::now()`, panics in Jiff's default configuration. Enabling Jiff's `js` +feature will cause Jiff to assume a web context and use JavaScript's +[`Date.now`] API to determine the current time. + +#### IANA Time Zone Database + +None of the WASM targets have a canonical installation of the IANA Time Zone +Database. Because of this, and because of the important of time zone support +to Jiff's design, Jiff will automatically embed an entire copy of the IANA +Time Zone Database into your binary on all WASM targets. + +The automatic bundling is done via the Jiff crate feature +`tzdb-bundle-platform`. This is a _target activated feature_. Namely, it is +enabled by default, but only results in a bundled database on an enumerated set +of platforms (where WASM is one of them). If you want to opt out of bundling +the database on WASM targets, you'll need to disable this feature. + +Bundling the IANA Time Zone Database is not ideal, since after a new release of +the database, you'll need to wait for the `jiff-tzdb` crate to be updated. Then +you'll need to update your dependency version and re-compile your software to +get the database updates. + +Some WASM targets, like `wasm32-wasip1`, can actually read the host's +IANA Time Zone Database (e.g., on Unix), but this requires relaxing its +sandbox restrictions so that the code can read system directories like +`/usr/share/zoneinfo`. That is, it won't work out of the box. The same applies +to the `wasm32-unknown-emscripten` target. (Although this author could not +figure out how to relax emscripten's sandbox.) + +#### System time zone + +* `wasm32-unknown-emscripten`: Unsupported. +* `wasm32-wasi*`: Unsupported. But you may set the `TZ` environment variable +via your WASM runtime, and Jiff will respect it. For example, with [wasmtime], +that's `--env TZ=America/New_York`. +* `wasm{32,64}-unknown-unknown`: Unsupported in Jiff's default configuration. +Enabling Jiff's `js` feature will cause Jiff to assume a web context and use +JavaScript's [`Intl.DateTimeFormat`] API to determine the system configured +IANA time zone identifier. This time zone identifier is then used to look up +the time zone in Jiff's configured IANA Time Zone Database. + +[Civil time]: https://en.wikipedia.org/wiki/Civil_time +[Time zone]: https://en.wikipedia.org/wiki/Time_zone +[daylight saving time]: https://en.wikipedia.org/wiki/Daylight_saving_time +[IANA Time Zone Database]: https://en.wikipedia.org/wiki/Tz_database +[IANA time zone identifier]: https://data.iana.org/time-zones/theory.html#naming +[RFC 8536]: https://datatracker.ietf.org/doc/html/rfc8536 +[wasmtime]: https://wasmtime.dev/ +[POSIX TZ]: https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap08.html +[RFC 3339]: https://www.rfc-editor.org/rfc/rfc3339 +[RFC 9557]: https://www.rfc-editor.org/rfc/rfc9557.html +[ISO 8601]: https://www.iso.org/iso-8601-date-and-time-format.html +[Temporal]: https://tc39.es/proposal-temporal +[issue-platform]: https://github.com/BurntSushi/jiff/labels/platform +[issue-new]: https://github.com/BurntSushi/jiff/issues/new +[`GetDynamicTimeZoneInformation`]: https://learn.microsoft.com/en-us/windows/win32/api/timezoneapi/nf-timezoneapi-getdynamictimezoneinformation +[`DYNAMIC_TIME_ZONE_INFORMATION`]: https://learn.microsoft.com/en-us/windows/win32/api/timezoneapi/ns-timezoneapi-dynamic_time_zone_information +[CLDR XML data]: https://github.com/unicode-org/cldr/raw/main/common/supplemental/windowsZones.xml +[`Intl.DateTimeFormat`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions#timezone +[`Date.now`]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/now diff --git a/README.md b/README.md index 072e156..5dba96d 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ Dual-licensed under MIT or the [UNLICENSE](https://unlicense.org/). * [API documentation on docs.rs](https://docs.rs/jiff) * [Comparison with `chrono`, `time`, `hifitime` and `icu`](COMPARE.md) * [The API design rationale for Jiff](DESIGN.md) +* [Platform support][PLATFORM.md) ### Example diff --git a/jiff-wasm/.gitignore b/jiff-wasm/.gitignore new file mode 100644 index 0000000..6dc3845 --- /dev/null +++ b/jiff-wasm/.gitignore @@ -0,0 +1,3 @@ +/Cargo.lock +/target +/tests diff --git a/jiff-wasm/Cargo.toml b/jiff-wasm/Cargo.toml new file mode 100644 index 0000000..8544ba5 --- /dev/null +++ b/jiff-wasm/Cargo.toml @@ -0,0 +1,26 @@ +[package] +publish = false +name = "jiff-wasm" +version = "0.1.0" +authors = ["Andrew Gallant "] +license = "Unlicense OR MIT" +edition = "2021" + +[workspace] + +[lib] +path = "lib.rs" +crate-type = ["cdylib", "rlib"] + +[dependencies] +jiff = { path = "..", features = ["js"] } +wasm-bindgen = "0.2.84" +wasm-bindgen-test = "0.3.34" +web-sys = { version = "0.3.69", features = ["console"] } + +# The `console_error_panic_hook` crate provides better debugging of panics +# by logging them with `console.error`. +console_error_panic_hook = "0.1.7" + +[profile.release] +opt-level = "s" diff --git a/jiff-wasm/README.md b/jiff-wasm/README.md new file mode 100644 index 0000000..761f0ae --- /dev/null +++ b/jiff-wasm/README.md @@ -0,0 +1,7 @@ +jiff-wasm +========= +This is a small dummy test project for doing a sanity check that getting the +current time and time zone on the `wasm32-unknown-unknown` target with the `js` +Jiff crate feature enabled works as expected. + +This is not a published crate. It is only used in CI testing. diff --git a/jiff-wasm/lib.rs b/jiff-wasm/lib.rs new file mode 100644 index 0000000..b84e3db --- /dev/null +++ b/jiff-wasm/lib.rs @@ -0,0 +1,25 @@ +use wasm_bindgen_test::*; + +macro_rules! eprintln { + ($($tt:tt)*) => {{ + let s = std::format!($($tt)*); + web_sys::console::log_1(&s.into()); + }} +} + +#[wasm_bindgen_test] +fn zoned_now_no_panic() { + let zdt = jiff::Zoned::now(); + eprintln!("{zdt}"); +} + +// Not sure if this is needed? ---AG +pub fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + console_error_panic_hook::set_once(); +} diff --git a/src/lib.rs b/src/lib.rs index 6726d33..d83d367 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,6 +65,7 @@ documentation: * [Comparison with other Rust datetime crates](crate::_documentation::comparison) * [The API design rationale for Jiff](crate::_documentation::design) +* [Platform support](crate::_documentation::platform) # Features @@ -533,6 +534,18 @@ specifiers and other APIs. Temporal, but it's a mix of the "best" parts of RFC 3339, RFC 9557 and ISO 8601. See the [`fmt::temporal`] module for more details on the format used. +* **js** - + On _only_ the `wasm32-unknown-unknown` and `wasm64-unknown-unknown` targets, + the `js` feature will add dependencies on `js-sys` and `wasm-bindgen`. + These dependencies are used to determine the current datetime and time + zone from the web browser. On these targets without the `js` feature + enabled, getting the current datetime will panic (because that's what + `std::time::SystemTime::now()` does), and it won't be possible to determine + the time zone. This feature is disabled by default because not all uses + of `wasm{32,64}-unknown-unknown` are in a web context, although _many_ are + (for example, when using `wasm-pack`). Only binary, tests and benchmarks + should enable this feature. See + [Platform support](crate::_documentation::platform) for more details. ### Time zone features @@ -631,6 +644,8 @@ mod logging; pub mod civil; mod error; pub mod fmt; +#[cfg(feature = "std")] +mod now; mod span; mod timestamp; pub mod tz; @@ -643,6 +658,8 @@ pub mod _documentation { pub mod comparison {} #[doc = include_str!("../DESIGN.md")] pub mod design {} + #[doc = include_str!("../PLATFORM.md")] + pub mod platform {} } #[cfg(test)] diff --git a/src/now.rs b/src/now.rs new file mode 100644 index 0000000..68eb21c --- /dev/null +++ b/src/now.rs @@ -0,0 +1,76 @@ +/*! +Provides a centralized helper for getting the current system time. + +We generally rely on the standard library for this, but the standard library +(by design) does not provide this for the `wasm{32,64}-unknown-unknown` +targets. Instead, Jiff provides an opt-in `js` feature that only applies on the +aforementioned target. Specifically, when enabled, it assumes a web context and +runs JavaScript code to get the current time. + +This also exposes a "fallible" API for querying monotonic time. Since we only +use monotonic time for managing expiration for caches, in the case where we +can't get monotonic time (easily), we just consider the cache to always be +expired. ¯\_(ツ)_/¯ +*/ + +pub(crate) use self::sys::*; + +#[cfg(not(all( + feature = "js", + any(target_arch = "wasm32", target_arch = "wasm64"), + target_os = "unknown" +)))] +mod sys { + use std::time::{Instant, SystemTime}; + + pub(crate) fn system_time() -> SystemTime { + SystemTime::now() + } + + pub(crate) fn monotonic_time() -> Option { + Some(Instant::now()) + } +} + +#[cfg(all( + feature = "js", + any(target_arch = "wasm32", target_arch = "wasm64"), + target_os = "unknown" +))] +mod sys { + use std::time::{Instant, SystemTime}; + + pub(crate) fn system_time() -> SystemTime { + use std::time::Duration; + + #[cfg(not(feature = "std"))] + use crate::util::libm::F64; + + let millis = js_sys::Date::new_0().get_time(); + let sign = millis.signum(); + let millis = millis.abs() as u64; + let duration = Duration::from_millis(millis); + let result = if sign >= 0.0 { + SystemTime::UNIX_EPOCH.checked_add(duration) + } else { + SystemTime::UNIX_EPOCH.checked_sub(duration) + }; + // It's a little sad that we have to panic here, but the standard + // SystemTime::now() API is infallible, so we kind of have to match it. + // With that said, a panic here would be highly unusual. It would imply + // that the system time is set to some extreme timestamp very far in the + // future or the past. + let Some(timestamp) = result else { + panic!( + "failed to get current time: \ + subtracting {duration:?} from Unix epoch overflowed" + ) + }; + timestamp + } + + pub(crate) fn monotonic_time() -> Option { + // :-( + None + } +} diff --git a/src/timestamp.rs b/src/timestamp.rs index e2f4e18..3c6412f 100644 --- a/src/timestamp.rs +++ b/src/timestamp.rs @@ -380,6 +380,14 @@ impl Timestamp { /// If you want to get the current Unix time fallibly, use /// [`Timestamp::try_from`] with a `std::time::SystemTime` as input. /// + /// This may also panic when `SystemTime::now()` itself panics. The most + /// common context in which this happens is on the `wasm32-unknown-unknown` + /// target. If you're using that target in the context of the web (for + /// example, via `wasm-pack`), and you're an application, then you should + /// enable Jiff's `js` feature. This will automatically instruct Jiff in + /// this very specific circumstance to execute JavaScript code to determine + /// the current time from the web browser. + /// /// # Example /// /// ``` @@ -389,7 +397,7 @@ impl Timestamp { /// ``` #[cfg(feature = "std")] pub fn now() -> Timestamp { - Timestamp::try_from(std::time::SystemTime::now()) + Timestamp::try_from(crate::now::system_time()) .expect("system time is valid") } diff --git a/src/tz/db/bundled/mod.rs b/src/tz/db/bundled/mod.rs index 0241176..1c8c618 100644 --- a/src/tz/db/bundled/mod.rs +++ b/src/tz/db/bundled/mod.rs @@ -2,13 +2,19 @@ pub(crate) use self::inner::*; #[cfg(not(any( feature = "tzdb-bundle-always", - all(feature = "tzdb-bundle-platform", windows), + all( + feature = "tzdb-bundle-platform", + any(windows, target_family = "wasm"), + ), )))] #[path = "disabled.rs"] mod inner; #[cfg(any( feature = "tzdb-bundle-always", - all(feature = "tzdb-bundle-platform", windows), + all( + feature = "tzdb-bundle-platform", + any(windows, target_family = "wasm"), + ), ))] #[path = "enabled.rs"] mod inner; diff --git a/src/tz/db/mod.rs b/src/tz/db/mod.rs index 7586605..be904cd 100644 --- a/src/tz/db/mod.rs +++ b/src/tz/db/mod.rs @@ -38,7 +38,11 @@ pub fn db() -> &'static TimeZoneDatabase { use std::sync::OnceLock; static DB: OnceLock = OnceLock::new(); - DB.get_or_init(|| TimeZoneDatabase::from_env()) + DB.get_or_init(|| { + let db = TimeZoneDatabase::from_env(); + debug!("initialized global time zone database: {db:?}"); + db + }) } } diff --git a/src/tz/db/zoneinfo/enabled.rs b/src/tz/db/zoneinfo/enabled.rs index 82eccc2..525c924 100644 --- a/src/tz/db/zoneinfo/enabled.rs +++ b/src/tz/db/zoneinfo/enabled.rs @@ -51,7 +51,7 @@ impl ZoneInfo { match ZoneInfo::from_dir(&tzdir) { Ok(db) => return db, Err(_err) => { - debug!("failed opening TZDIR={}: {_err}", tzdir.display()); + debug!("failed opening {}: {_err}", tzdir.display()); } } } diff --git a/src/tz/system/mod.rs b/src/tz/system/mod.rs index 28a9f0c..2ea19a3 100644 --- a/src/tz/system/mod.rs +++ b/src/tz/system/mod.rs @@ -13,10 +13,28 @@ use crate::{ #[cfg(unix)] #[path = "unix.rs"] mod sys; + #[cfg(windows)] #[path = "windows/mod.rs"] mod sys; -#[cfg(not(any(unix, windows)))] + +#[cfg(all( + feature = "js", + any(target_arch = "wasm32", target_arch = "wasm64"), + target_os = "unknown" +))] +#[path = "wasm_js.rs"] +mod sys; + +#[cfg(not(any( + unix, + windows, + all( + feature = "js", + any(target_arch = "wasm32", target_arch = "wasm64"), + target_os = "unknown" + ) +)))] mod sys { use crate::tz::{TimeZone, TimeZoneDatabase}; diff --git a/src/tz/system/unix.rs b/src/tz/system/unix.rs index 531284a..8ddb08e 100644 --- a/src/tz/system/unix.rs +++ b/src/tz/system/unix.rs @@ -23,7 +23,16 @@ pub(super) fn get(db: &TimeZoneDatabase) -> Option { "failed to find time zone name using Unix-specific heuristics, \ attempting to read {UNIX_LOCALTIME_PATH} as unnamed time zone", ); - super::read_unnamed_tzif_file(UNIX_LOCALTIME_PATH).ok() + match super::read_unnamed_tzif_file(UNIX_LOCALTIME_PATH) { + Ok(tz) => Some(tz), + Err(_err) => { + trace!( + "failed to read {UNIX_LOCALTIME_PATH} \ + as unnamed time zone: {_err}" + ); + None + } + } } /// Attempt to determine the time zone name from the symlink path given. diff --git a/src/tz/system/wasm_js.rs b/src/tz/system/wasm_js.rs new file mode 100644 index 0000000..a736894 --- /dev/null +++ b/src/tz/system/wasm_js.rs @@ -0,0 +1,47 @@ +use alloc::string::String; + +use crate::tz::{TimeZone, TimeZoneDatabase}; + +pub(super) fn get(db: &TimeZoneDatabase) -> Option { + let fmt = js_sys::Intl::DateTimeFormat::new( + &js_sys::Array::new(), + &js_sys::Object::new(), + ); + let options = fmt.resolved_options(); + // Documented to be an IANA tz ID: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat/resolvedOptions#timezone + let key = wasm_bindgen::JsValue::from("timeZone"); + let val = match js_sys::Reflect::get(&options, &key) { + Ok(val) => val, + Err(_err) => { + trace!( + "failed to get `timeZone` key on \ + Intl.DateTimeFormat options: {_err:?}" + ); + return None; + } + }; + trace!("got `timeZone` value from Intl.DateTimeFormat options: {val:?}"); + let name = match String::try_from(val) { + Ok(string) => string, + Err(_err) => { + trace!( + "failed to convert `timeZone` on \ + Intl.DateTimeFormat to string" + ); + return None; + } + }; + let tz = match db.get(&name) { + Ok(tz) => tz, + Err(_err) => { + trace!( + "got {name:?} as time zone name, \ + but failed to find time zone with that name in \ + zoneinfo database {db:?}", + ); + return None; + } + }; + Some(tz) +} diff --git a/src/util/cache.rs b/src/util/cache.rs index b567904..d20c774 100644 --- a/src/util/cache.rs +++ b/src/util/cache.rs @@ -15,7 +15,9 @@ impl Expiration { /// Returns an expiration time for which `is_expired` returns true after /// the given duration has elapsed from this instant. pub(crate) fn after(ttl: Duration) -> Expiration { - Expiration(MonotonicInstant::now().checked_add(ttl)) + Expiration( + crate::now::monotonic_time().and_then(|now| now.checked_add(ttl)), + ) } /// Returns an expiration time for which `is_expired` always returns true. @@ -25,6 +27,9 @@ impl Expiration { /// Whether expiration has occurred or not. pub(crate) fn is_expired(self) -> bool { - self.0.map_or(true, |t| MonotonicInstant::now() > t) + self.0.map_or(true, |t| { + let Some(now) = crate::now::monotonic_time() else { return true }; + now > t + }) } } diff --git a/src/zoned.rs b/src/zoned.rs index 51026fc..d71a26f 100644 --- a/src/zoned.rs +++ b/src/zoned.rs @@ -391,6 +391,14 @@ impl Zoned { /// If you want to get the current Unix time fallibly, use /// [`Zoned::try_from`] with a `std::time::SystemTime` as input. /// + /// This may also panic when `SystemTime::now()` itself panics. The most + /// common context in which this happens is on the `wasm32-unknown-unknown` + /// target. If you're using that target in the context of the web (for + /// example, via `wasm-pack`), and you're an application, then you should + /// enable Jiff's `js` feature. This will automatically instruct Jiff in + /// this very specific circumstance to execute JavaScript code to determine + /// the current time from the web browser. + /// /// # Example /// /// ``` @@ -401,7 +409,7 @@ impl Zoned { #[cfg(feature = "std")] #[inline] pub fn now() -> Zoned { - Zoned::try_from(std::time::SystemTime::now()) + Zoned::try_from(crate::now::system_time()) .expect("system time is valid") } diff --git a/test-wasm b/test-wasm index 2803343..32d9e3f 100755 --- a/test-wasm +++ b/test-wasm @@ -19,5 +19,5 @@ export CARGO_BUILD_TARGET=wasm32-wasip1 # Not sure exactly why this is needed, but without it, # `insta` tries to run `cargo`, and wasip1 doesn't like that. export INSTA_WORKSPACE_ROOT="$PWD" -export CARGO_TARGET_WASM32_WASIP1_RUNNER="wasmtime run --dir / -S inherit-env" +export CARGO_TARGET_WASM32_WASIP1_RUNNER="wasmtime run -S inherit-env" "$@"