Skip to content
Closed
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
54 changes: 54 additions & 0 deletions .github/workflows/bun.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: bun

# Runs the crate's own test suite against the `bun` branch. Upstream's
# `ci.yml` runs on every pull request, but on a push only for upstream's
# default branch; this file is additive so rebasing the fork onto a new
# upstream tag does not have to re-edit the triggers in `ci.yml`.
#
# The oven-sh organization rejects an action that is not pinned to a
# full-length commit SHA: the job fails at "Set up job". So every `uses:` in
# this directory is pinned, upstream's workflows included. After a rebase that
# brings in new `uses:` lines from upstream, pin those too.
# For dtolnay/rust-toolchain, pin a commit from its `master` branch. Its
# README says other commits get garbage-collected, and the commit behind
# `@stable` is one of those. `master` has no default toolchain, so pass
# `toolchain:`.
on:
push:
branches:
- bun
pull_request:
branches:
- bun

env:
RUST_BACKTRACE: 1
CARGO_INCREMENTAL: 0

jobs:
test:
name: cargo test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: stable
- name: Cache cargo registry
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
key: index-${{ hashFiles('Cargo.toml') }}
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
- name: Fetch dependencies
run: cargo fetch
- name: Cache target directory
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
key: target-bun-${{ hashFiles('Cargo.lock') }}
path: target
- name: Run tests
run: cargo test --features=integration_test --target-dir=target
23 changes: 13 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,17 @@ jobs:
name: Clippy and format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
submodules: 'recursive'
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: stable
components: clippy, rustfmt
- name: Check format
run: cargo fmt -- --check
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
key: index-${{ hashFiles('Cargo.toml') }}
path: |
Expand All @@ -35,7 +36,7 @@ jobs:
- name: Fetch dependencies
run: cargo fetch
- name: Cache target directory
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
key: clippy-${{ hashFiles('Cargo.lock') }}
path: target
Expand All @@ -50,7 +51,7 @@ jobs:
- name: Check docs
run: cargo doc --no-deps --all --target-dir=target
- name: Check semver
uses: obi1kenobi/cargo-semver-checks-action@v2
uses: obi1kenobi/cargo-semver-checks-action@6b69fcf40e9b5fb17adeb57e4b6ecd020649a239 # v2.9
with:
package: lol_html
feature-group: default-features
Expand All @@ -59,12 +60,14 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
submodules: 'recursive'
- uses: dtolnay/rust-toolchain@stable
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # v1
with:
toolchain: stable
- name: Cache cargo registry
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
key: index-${{ hashFiles('Cargo.toml') }}
path: |
Expand All @@ -73,13 +76,13 @@ jobs:
- name: Fetch dependencies
run: cargo fetch
- name: Cache target directory
uses: actions/cache@v4
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
key: target-${{ hashFiles('Cargo.lock') }}
path: target
- name: Run Rust tests
run: scripts/test.sh
- uses: actions/setup-node@v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
- name: wasm-pack install
run: npm i -g wasm-pack
- name: Run JS tests
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/semgrep.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ jobs:
container:
image: semgrep/semgrep
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- run: semgrep ci
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# LOL HTML

> [!NOTE]
> **This is Bun's fork of [`cloudflare/lol-html`](https://github.com/cloudflare/lol-html).** The
> [`bun` branch](https://github.com/oven-sh/lol-html/tree/bun) adds **content-handler suspension**:
> a handler can return `Err(SuspensionRequest)` to deep-copy the in-flight rewritable unit onto the
> heap and exit `write()`/`end()` with the non-poisoning `RewritingError::Suspended`;
> `HtmlRewriter::resume()` continues from a state-machine bookmark. Bun's `HTMLRewriter` uses this
> so an `async` JS content handler can `await` and then keep mutating the element, without running
> the parser on a side stack or spinning a nested event loop inside `write()`. See
> [oven-sh/bun#36733](https://github.com/oven-sh/bun/pull/36733). The `main` branch tracks
> upstream unchanged.

<p align="center">
<a href="https://github.com/cloudflare/lol-html">
<img src="media/logo.png" alt="The logo is generated from https://openmoji.org/data/color/svg/1F602.svg by Emily Jäger which is licensed under CC BY-SA 4.0 (https://creativecommons.org/licenses/by-sa/4.0/)" style="width:472px; height: 375px" />
Expand Down
12 changes: 11 additions & 1 deletion src/base/bytes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ impl<'b> BytesCow<'b> {
BytesCow(Cow::Owned(self.0.into_owned()))
}

/// Detaches the bytes from the input buffer they may borrow, leaving an
/// empty value behind. Used when a token has to outlive the parser's
/// input buffer (handler suspension).
#[inline]
pub(crate) fn take_owned(&mut self) -> BytesCow<'static> {
BytesCow(Cow::Owned(
std::mem::replace(&mut self.0, Cow::Borrowed(&[])).into_owned(),
))
}

#[inline]
pub(crate) fn as_ref(&self) -> Bytes<'_> {
Bytes(&self.0)
Expand All @@ -75,7 +85,7 @@ impl<'b> BytesCow<'b> {

impl<'b> Bytes<'b> {
#[inline]
pub(crate) fn new(bytes: &'b [u8]) -> Self {
pub(crate) const fn new(bytes: &'b [u8]) -> Self {
Self(bytes)
}

Expand Down
32 changes: 27 additions & 5 deletions src/base/spanned.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,24 @@ pub(crate) struct Spanned<B> {
source_location_byte_start: usize,
}

#[derive(Debug, Copy, Clone)]
#[derive(Debug, Clone)]
pub(crate) enum RawBytes<'input> {
Original(&'input [u8]),
/// Raw bytes copied out of the input buffer so that they can outlive it.
/// Produced by [`SpannedRawBytes::take_owned`] when a token is detached
/// from the parser (handler suspension).
Owned(Box<[u8]>),
/// Keeps the len of the original
Modified(usize),
}

impl<'input> SpannedRawBytes<'input> {
#[inline]
pub fn len(&self) -> usize {
match self.bytes {
match &self.bytes {
RawBytes::Original(s) => s.len(),
RawBytes::Modified(l) => l,
RawBytes::Owned(s) => s.len(),
RawBytes::Modified(l) => *l,
}
}

Expand All @@ -71,13 +76,30 @@ impl<'input> SpannedRawBytes<'input> {
}

#[inline]
pub fn original(&self) -> Option<&'input [u8]> {
match self.bytes {
pub fn original(&self) -> Option<&[u8]> {
match &self.bytes {
RawBytes::Original(s) => Some(s),
RawBytes::Owned(s) => Some(s),
RawBytes::Modified(_) => None,
}
}

/// Detaches the raw bytes from the input buffer, leaving `self` behind as
/// `Modified(len)` (the source is abandoned after a `take_owned`, so the
/// leftover only has to keep `len()`/`source_location()` correct).
pub(crate) fn take_owned(&mut self) -> SpannedRawBytes<'static> {
let len = self.len();
let bytes = match std::mem::replace(&mut self.bytes, RawBytes::Modified(len)) {
RawBytes::Original(s) => RawBytes::Owned(s.into()),
RawBytes::Owned(s) => RawBytes::Owned(s),
RawBytes::Modified(l) => RawBytes::Modified(l),
};
Spanned {
bytes,
source_location_byte_start: self.source_location_byte_start,
}
}

#[inline]
pub fn source_location(&self) -> SourceLocation {
SourceLocation::from_start_len(self.source_location_byte_start, self.len())
Expand Down
25 changes: 24 additions & 1 deletion src/html/local_name.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,12 +134,24 @@ impl PartialEq<Tag> for LocalNameHash {
/// `LocalName` is used for the comparison of tag names.
/// In the majority of cases it will be represented as a hash, however for long
/// non-standard tag names it fallsback to the Name representation.
#[derive(Clone, Debug, Eq, Hash)]
#[derive(Clone, Debug, Eq)]
pub enum LocalName<'i> {
Hash(LocalNameHash),
Bytes(BytesCow<'i>),
}

// `PartialEq` compares `Bytes` case-insensitively, so `Hash` must case-fold too.
impl std::hash::Hash for LocalName<'_> {
#[inline]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
core::mem::discriminant(self).hash(state);
match self {
LocalName::Hash(h) => h.hash(state),
LocalName::Bytes(b) => b.iter().for_each(|c| c.to_ascii_lowercase().hash(state)),
}
}
}

impl<'i> LocalName<'i> {
#[inline]
#[must_use]
Expand Down Expand Up @@ -219,4 +231,15 @@ mod tests {
fn hash_invalidation_for_long_values() {
assert!(LocalNameHash::from("aaaaaaaaaaaaaa").is_empty());
}

#[test]
fn bytes_variant_hash_matches_case_insensitive_eq() {
use std::hash::{BuildHasher, RandomState};
let s = RandomState::new();
let a = LocalName::from_str_without_replacements("My-Widget", encoding_rs::UTF_8).unwrap();
let b = LocalName::from_str_without_replacements("my-widget", encoding_rs::UTF_8).unwrap();
assert!(matches!(a, LocalName::Bytes(_)));
assert_eq!(a, b);
assert_eq!(s.hash_one(&a), s.hash_one(&b));
}
}
6 changes: 3 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ use cfg_if::cfg_if;
pub use self::rewriter::{
AsciiCompatibleEncoding, CommentHandler, DoctypeHandler, DocumentContentHandlers,
ElementContentHandlers, ElementHandler, EndHandler, EndTagHandler, HandlerResult, HandlerTypes,
HtmlRewriter, LocalHandlerTypes, MemorySettings, RewriteStrSettings, Settings, TextHandler,
rewrite_str,
HtmlRewriter, LocalHandlerTypes, MemorySettings, RewriteStrSettings, Settings,
SuspensionRequest, TextHandler, rewrite_str,
};
pub use self::selectors_vm::Selector;
pub use self::transform_stream::OutputSink;
Expand Down Expand Up @@ -88,7 +88,7 @@ pub mod errors {
pub use super::rewritable_units::{
AttributeNameError, CommentTextError, TagNameError, Utf8Error,
};
pub use super::rewriter::RewritingError;
pub use super::rewriter::{RewritingError, SuspensionRequest};
pub use super::selectors_vm::SelectorError;
}

Expand Down
52 changes: 48 additions & 4 deletions src/memory/arena.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ use super::{MemoryLimitExceededError, SharedMemoryLimiter};
pub(crate) struct Arena {
limiter: SharedMemoryLimiter,
data: Vec<u8>,
/// Offset of the live region within `data`. `shift` only advances this;
/// the dead prefix is reclaimed lazily by the next `append`.
start: usize,
}

impl Arena {
Expand All @@ -23,10 +26,28 @@ impl Arena {
"Total preallocated memory size should be less than `MemorySettings::max_allowed_memory_usage`."
);

Self { limiter, data }
Self {
limiter,
data,
start: 0,
}
}

/// Reclaim the dead prefix left behind by `shift`, in one memmove.
fn compact(&mut self) {
if self.start > 0 {
self.data.copy_within(self.start.., 0);
let live = self.data.len() - self.start;
self.data.truncate(live);
self.start = 0;
}
}

pub fn append(&mut self, slice: &[u8]) -> Result<(), MemoryLimitExceededError> {
// Only the multi-write streaming path appends after a `shift`, and it
// is the only place the dead prefix has to go away.
self.compact();

// this specific form of capacity check optimizes out redundant resizing in extend_from_slice
if self.data.capacity() - self.data.len() < slice.len() {
let additional = slice.len() + self.data.len() - self.data.capacity();
Expand All @@ -51,16 +72,20 @@ impl Arena {

pub fn init_with(&mut self, slice: &[u8]) -> Result<(), MemoryLimitExceededError> {
self.data.clear();
self.start = 0;
self.append(slice)
}

/// O(1): advances the live-region start instead of memmoving the tail.
/// A suspended rewrite resumes by re-feeding the unconsumed tail, so this
/// runs once per suspension — memmoving there is O(suspensions × tail).
pub fn shift(&mut self, byte_count: usize) {
self.data.copy_within(byte_count.., 0);
self.data.truncate(self.data.len() - byte_count);
debug_assert!(byte_count <= self.data.len() - self.start);
self.start += byte_count;
}

pub fn bytes(&self) -> &[u8] {
&self.data
&self.data[self.start..]
}
}

Expand Down Expand Up @@ -140,4 +165,23 @@ mod tests {
assert_eq!(arena.bytes(), &[2, 3, 4, 5]);
assert_eq!(limiter.current_usage(), 5);
}

// A suspended rewrite shifts once per resume with no append in between;
// the dead prefix must accumulate and then be reclaimed by the next append.
#[test]
fn consecutive_shifts_without_append() {
let limiter = SharedMemoryLimiter::new(10);
let mut arena = Arena::new(limiter.clone(), 0);

arena.append(&[0, 1, 2, 3, 4]).unwrap();
arena.shift(1);
arena.shift(2);
assert_eq!(arena.bytes(), &[3, 4]);

arena.shift(2);
assert!(arena.bytes().is_empty());

arena.append(&[9]).unwrap();
assert_eq!(arena.bytes(), &[9]);
}
}
Loading
Loading