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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion packages/scramjet/packages/controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
ScramjetFetchHandler,
ScramjetHeaders,
setWasm,
splitFragment,
Tap,
type CookieSyncOptions,
type FetchHooks,
Expand All @@ -20,6 +21,7 @@ import {
type ScramjetInterface,
type TrackedHistoryState,
Plugin,
unrewriteUrl,
} from "@mercuryworkshop/scramjet";
import { CONTROLLERFRAME } from "./symbols";
import type {
Expand Down Expand Up @@ -877,6 +879,43 @@ export class Frame {
//@ts-expect-error
base: new URL(location.href),
});
this.element.src = encoded;
this.element.src = this.sameDocument(url) ?? encoded;
}

/**
* The real URL of the frame's document with `url`'s fragment, when `url`
* is a fragment of that same document - or null.
*
* Going to `page#b` from `page#a` is a fragment navigation: it scrolls,
* and fires `hashchange`, without reloading anything. The browser only
* sees one when the frame's new URL equals its document's real URL but for
* the fragment, and a rewritten URL does not - it lacks the query the
* document was loaded with.
*/
private sameDocument(url: string): string | null {
let raw: string | undefined;
try {
raw = this.element.contentWindow?.location.href;
} catch {
return null;
}
if (!raw) return null;

let target: URL;
let current: URL;
try {
target = new URL(url);
current = new URL(unrewriteUrl(raw, this.context));
} catch {
return null;
}

const [withoutFragment, fragment] = splitFragment(target.href);
// without a fragment it loads a new document, which it has to request
// afresh
if (fragment === null) return null;
if (splitFragment(current.href)[0] !== withoutFragment) return null;

return splitFragment(raw)[0] + (fragment ?? "");
}
}
24 changes: 24 additions & 0 deletions packages/scramjet/packages/core/rewriter/js/src/cfg.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,33 @@ impl FromStr for IncumbencyMode {
}
}

/// `s` escaped to go between the quotes of a JavaScript string literal of
/// either kind: the characters that would end it, start an escape, or end
/// the line.
pub fn escape_js_string(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\'' => out.push_str("\\'"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
c => out.push(c),
}
}
out
}

#[derive(Debug)]
pub struct Flags {
pub base: String,
/// `base`, escaped with [`escape_js_string`] for the string literals the
/// rewrite puts it in. Fragments are not percent-encoded for `\` or `'`,
/// and the service worker sees the fragment a module was requested with
pub base_literal: String,
pub sourcetag: String,
/// the private ID a `pst` script registers itself under, fresh per
/// rewrite. Generated by the caller, not here
Expand Down
4 changes: 2 additions & 2 deletions packages/scramjet/packages/core/rewriter/js/src/changes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,8 @@ impl<'alloc: 'data, 'data> Transform<'data> for JsChange<'alloc, 'data> {
&flags.sourcetag,
"*/"
]),
Ty::ImportFn => LL::replace(transforms![&cfg.importfn, "(\"", &flags.base, "\","]),
Ty::MetaFn => LL::replace(transforms![&cfg.metafn, "(import.meta,\"", &flags.base, "\")"]),
Ty::ImportFn => LL::replace(transforms![&cfg.importfn, "(\"", &flags.base_literal, "\","]),
Ty::MetaFn => LL::replace(transforms![&cfg.metafn, "(import.meta,\"", &flags.base_literal, "\")"]),
Ty::CallFnPrelude => LL::replace(transforms!["(", &cfg.tempreceiverid, "="]),
Ty::CallFnLeft { computed, optional, optional_call, throws } => {
let access: &str = if computed { "[" } else { "." };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ impl NativeRewriter {
},
Flags {
base: cfg.base.clone(),
base_literal: js::cfg::escape_js_string(&cfg.base),
sourcetag: cfg.sourcetag.clone(),
script_id: String::from("0"),
is_module: cfg.is_module,
Expand Down
21 changes: 18 additions & 3 deletions packages/scramjet/packages/core/rewriter/wasm/src/jsr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,13 +54,23 @@ impl UrlRewriter for WasmUrlRewriter {
builder: &mut StringBuilder,
module: bool,
) -> std::result::Result<(), Box<dyn Error + Sync + Send>> {
let url = Url::new_with_base(url, &flags.base)
let url: std::string::String = Url::new_with_base(url, &flags.base)
.map_err(RewriterError::from)?
.to_string();
.href();

// the fragment goes after the query, as it was written, the same way
// `rewriteUrl` puts it - so `import "./m.js#a"` and `import("./m.js#a")`
// load the one module, as they do natively. A module's identity is its
// URL fragment and all. The serializer percent-encodes every other `#`,
// so the first is the delimiter
let (without_fragment, fragment) = match url.find('#') {
Some(index) => url.split_at(index),
None => (url.as_str(), ""),
};

let mut rewritten = self
.0
.call1(&JsValue::NULL, &url.into())
.call1(&JsValue::NULL, &without_fragment.into())
.map_err(RewriterError::from)?
.as_string()
.ok_or_else(|| RewriterError::not_str("url rewriter output"))?;
Expand All @@ -73,6 +83,11 @@ impl UrlRewriter for WasmUrlRewriter {
rewritten.push_str(&encoded_origin);
}

// back inside the string literal it came out of. The fragment
// percent-encode set covers `"`, line terminators and everything
// outside ASCII, but leaves `\` and `'`
rewritten.push_str(&js::cfg::escape_js_string(fragment));

builder.push_str(&rewritten);

Ok(())
Expand Down
1 change: 1 addition & 0 deletions packages/scramjet/packages/core/rewriter/wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ fn get_js_config(config: &Object) -> Result<Config> {

fn get_js_flags(obj: &Object, base: String, is_module: bool) -> Result<Flags> {
Ok(Flags {
base_literal: js::cfg::escape_js_string(&base),
base,
sourcetag: scramtag(),
script_id: get_str(obj, "scriptId")?,
Expand Down
113 changes: 82 additions & 31 deletions packages/scramjet/packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createLocationProxy } from "@client/location";
import { createWrapFn } from "@client/shared/wrap";
import { LifecycleHooks } from "@client/events";
import {
frozenBaseUrl,
rewriteUrl,
RewriteUrlOptions,
unrewriteUrl,
Expand All @@ -24,7 +25,7 @@ import {
} from "@/shared";
import { iswindow } from "./entry";
import { SingletonBox } from "./singletonbox";
import { AttributeLayer } from "./attributes";
import { AttributeLayer, HTML_NAMESPACE } from "./attributes";
import { TextLayer } from "./text";
import { ScramjetConfig } from "@/types";
import { Tap } from "@/Tap";
Expand Down Expand Up @@ -358,12 +359,33 @@ function findBox(global: Window, seen: Window[]): SingletonBox | null {
*/
export const GlobalScope = class {} as unknown as typeof Window;

/**
* https://html.spec.whatwg.org/multipage/urls-and-fetching.html#matches-about:blank -
* a fragment is allowed on either, and a query on about:blank but not on
* about:srcdoc.
*/
function isAboutBlankOrSrcdoc(url: _URL): boolean {
const href = String_split(url.href, "#")[0];

return (
href === "about:blank" ||
String_startsWith(href, "about:blank?") ||
href === "about:srcdoc"
);
}

export class ScramjetClient {
locationProxy: any;
indirectEval: any;
private readonly creatorOrigin: string | null;
/** the creator's {@link originKey}, for a document that inherits it */
private readonly creatorOriginKey: string | null;
/**
* The document that created this one - see {@link captureCreator} - for
* its base URL, which an about:blank or srcdoc document resolves against
* in place of its own URL.
*/
private readonly creator: ScramjetClient | null;
/** whether this document's frame sandbox forces it into an opaque origin */
private readonly sandboxedOrigin: boolean;
serviceWorker: ServiceWorkerContainer;
Expand Down Expand Up @@ -544,6 +566,7 @@ export class ScramjetClient {
const creator = this.captureCreator();
this.creatorOrigin = creator ? creator.siteOrigin : null;
this.creatorOriginKey = creator ? creator.originKey : null;
this.creator = creator;
this.sandboxedOrigin = this.captureSandboxedOrigin();

this.bare = new BareCompatibleClient(init.transport);
Expand All @@ -562,23 +585,25 @@ export class ScramjetClient {
get origin() {
return client.url;
},
/**
* https://html.spec.whatwg.org/multipage/urls-and-fetching.html#document-base-url -
* the frozen base URL of the first base element with an href: that
* href parsed against the fallback base URL. One the browser ignores
* leaves the fallback in place (see `frozenBaseUrl`).
*/
get base() {
const fallback = client.fallbackBaseUrl();
if (iswindow) {
const base = new client.native.Document(
client.global.document
).querySelector("base");
if (base) {
let url = base.getAttribute("href");
if (!url) return client.url;
const frag = url.indexOf("#");
url = url.substring(0, frag === -1 ? undefined : frag);
if (!url) return client.url;

return new _URL(url, client.url.origin);
}
const base = client.baseElement(client.global.document);
const href = base ? client.attributes.get(base, "href") : null;
const frozen = href === null ? null : frozenBaseUrl(href, fallback);
if (frozen) return frozen;
}

return client.url;
return fallback;
},
get rawUrl() {
return iswindow ? client.global.location.href : undefined;
},
get topFrameName() {
if (!iswindow)
Expand Down Expand Up @@ -765,6 +790,46 @@ export class ScramjetClient {
});
}

/**
* https://html.spec.whatwg.org/multipage/urls-and-fetching.html#document-base-url -
* the first base element with an href, in tree order, that sets
* `document`'s base URL. Only an HTML one does: `base[href]` also matches
* an SVG element that happens to be called `base`.
*/
baseElement(document: Document): Element | null {
const found = new this.native.Document(document).querySelectorAll(
"base[href]"
);
for (let i = 0; i < found.length; i++) {
const element = found[i];
if (new this.native.Element(element).namespaceURI === HTML_NAMESPACE) {
return element;
}
}

return null;
}

/**
* https://html.spec.whatwg.org/multipage/urls-and-fetching.html#fallback-base-url
*
* The document's URL - except for an about:blank or srcdoc document, which
* has no URL worth resolving against and takes its creator's base URL
* instead. So `#x` in a frame the page made with `createElement` is the
* *parent's* URL with that fragment, as it is natively.
*
* The spec keeps a copy of an about:blank document's creator's base URL
* from when it was created; this reads it now, which only differs once the
* creator has changed its own base since.
*/
fallbackBaseUrl(): _URL {
const url = this.url;
if (this.creator && isAboutBlankOrSrcdoc(url))
return this.creator.meta.base;

return url;
}

/**
* The security origin of this client
*
Expand All @@ -782,17 +847,8 @@ export class ScramjetClient {
*/
get siteOrigin(): string | null {
const url = this.url;
// Fragments preserve the document's inherited origin. Queries are also
// allowed for about:blank, but not for about:srcdoc.
// https://html.spec.whatwg.org/multipage/urls-and-fetching.html#matches-about:blank
const href = String_split(url.href, "#")[0];
if (
href === "about:blank" ||
String_startsWith(href, "about:blank?") ||
href === "about:srcdoc"
) {
return this.creatorOrigin;
}
// fragments preserve the document's inherited origin
if (isAboutBlankOrSrcdoc(url)) return this.creatorOrigin;

return url.origin;
}
Expand Down Expand Up @@ -958,12 +1014,7 @@ export class ScramjetClient {
if (this.sandboxedOrigin) return this.opaqueScope;

const url = this.url;
const href = String_split(url.href, "#")[0];
if (
href === "about:blank" ||
String_startsWith(href, "about:blank?") ||
href === "about:srcdoc"
) {
if (isAboutBlankOrSrcdoc(url)) {
return this.creatorOriginKey ?? this.opaqueScope;
}

Expand Down
Loading
Loading