diff --git a/lib/MakeswiftPreview.js b/lib/MakeswiftPreview.js
index b4545205..45231b90 100644
--- a/lib/MakeswiftPreview.js
+++ b/lib/MakeswiftPreview.js
@@ -4,6 +4,7 @@ import tunnelManagerModule from './TunnelManager.js';
import previewSessionClientModule from './PreviewSessionClient.js';
import PreviewSessionHeartbeat from './PreviewSessionHeartbeat.js';
import { createPreviewProxy } from './preview-proxy.js';
+import { createStorefrontLinkRewriteRule } from './storefront-link-rewrite.js';
const PREVIEW_LISTEN_HOST = 'localhost';
// Ctrl-C must not be slow. A session that is not revoked expires with its TTL.
@@ -147,6 +148,7 @@ class MakeswiftPreview {
logger: this._logger,
}),
],
+ rewriteRules: [createStorefrontLinkRewriteRule(this._session.storeUrl)],
tunnel: false,
};
}
diff --git a/lib/MakeswiftPreview.spec.js b/lib/MakeswiftPreview.spec.js
index 0f2a9e9a..972d0896 100644
--- a/lib/MakeswiftPreview.spec.js
+++ b/lib/MakeswiftPreview.spec.js
@@ -168,12 +168,21 @@ describe('MakeswiftPreview', () => {
describe('getBrowserSyncOptions', () => {
it('should serve a signing proxy to the storefront on loopback only', async () => {
const { preview, previewProxyFactory, logger } = await startPreview();
- expect(preview.getBrowserSyncOptions(localPort)).toEqual({
+ const { rewriteRules, ...options } = preview.getBrowserSyncOptions(localPort);
+ expect(options).toEqual({
listen: 'localhost',
server: { baseDir: [] },
middleware: [previewProxyMiddleware],
tunnel: false,
});
+ // The rule itself is tested in storefront-link-rewrite.spec.js.
+ expect(rewriteRules).toHaveLength(1);
+ const [{ match, fn }] = rewriteRules;
+ const html = '';
+ const rewritten = html.replace(match, (...args) =>
+ fn({ headers: { host: 'localhost:3000' } }, {}, ...args),
+ );
+ expect(rewritten).toBe('');
expect(previewProxyFactory).toHaveBeenCalledWith({
previewSession: preview.session,
localPort,
diff --git a/lib/storefront-link-rewrite.js b/lib/storefront-link-rewrite.js
new file mode 100644
index 00000000..2f20a90b
--- /dev/null
+++ b/lib/storefront-link-rewrite.js
@@ -0,0 +1,46 @@
+import { escapeRegExp } from 'lodash-es';
+
+// The link must start here. This stops a match on the "//host" part of "ftp://host" or
+// "https://other.test//host".
+const LINK_START = String.raw`(? {
+ const localSlashes = slashes.includes('\\') ? '\\/\\/' : '//';
+ return `http:${localSlashes}${req.headers.host}`;
+ },
+ };
+}
+
+export { buildStorefrontLinkPattern, createStorefrontLinkRewriteRule };
diff --git a/lib/storefront-link-rewrite.spec.js b/lib/storefront-link-rewrite.spec.js
new file mode 100644
index 00000000..a3e86ffb
--- /dev/null
+++ b/lib/storefront-link-rewrite.spec.js
@@ -0,0 +1,163 @@
+import {
+ buildStorefrontLinkPattern,
+ createStorefrontLinkRewriteRule,
+} from './storefront-link-rewrite.js';
+
+const LOCAL_HOST = 'localhost:3000';
+
+// Applies the rule the same way BrowserSync (resp-modifier) does.
+const rewrite = (html, storeUrl = 'https://www.example.com') => {
+ const rule = createStorefrontLinkRewriteRule(storeUrl);
+ return html.replace(rule.match, (...args) =>
+ rule.fn({ headers: { host: LOCAL_HOST } }, {}, ...args),
+ );
+};
+
+describe('createStorefrontLinkRewriteRule', () => {
+ describe('rewrites links to the storefront host', () => {
+ it.each([
+ ['https link', 'https://www.example.com/products', 'http://localhost:3000/products'],
+ ['http link', 'http://www.example.com/products', 'http://localhost:3000/products'],
+ [
+ 'protocol-relative link',
+ '//www.example.com/products',
+ 'http://localhost:3000/products',
+ ],
+ [
+ 'uppercase link',
+ 'HTTPS://WWW.EXAMPLE.COM/products',
+ 'http://localhost:3000/products',
+ ],
+ ['host with no path', 'https://www.example.com', 'http://localhost:3000'],
+ ['host then query', 'https://www.example.com?a=1', 'http://localhost:3000?a=1'],
+ ['host then hash', 'https://www.example.com#top', 'http://localhost:3000#top'],
+ ['host in double quotes', '"https://www.example.com"', '"http://localhost:3000"'],
+ ['host in single quotes', "'https://www.example.com'", "'http://localhost:3000'"],
+ ['host then <', 'https://www.example.com', 'http://localhost:3000'],
+ [
+ 'host at end of sentence',
+ 'Visit https://www.example.com.',
+ 'Visit http://localhost:3000.',
+ ],
+ [
+ 'JSON-escaped link',
+ String.raw`"https:\/\/www.example.com\/products"`,
+ String.raw`"http:\/\/localhost:3000\/products"`,
+ ],
+ [
+ 'JSON-escaped protocol-relative link',
+ String.raw`"\/\/www.example.com\/products"`,
+ String.raw`"http:\/\/localhost:3000\/products"`,
+ ],
+ [
+ 'link in a query param of another link',
+ 'https://other.test/?r=https://www.example.com/x',
+ 'https://other.test/?r=http://localhost:3000/x',
+ ],
+ ])('%s', (_name, input, expected) => {
+ expect(rewrite(input)).toBe(expected);
+ });
+
+ it('rewrites all links in a document', () => {
+ const html = [
+ 'A',
+ '
',
+ String.raw``,
+ ].join('\n');
+
+ expect(rewrite(html)).toBe(
+ [
+ 'A',
+ '
',
+ String.raw``,
+ ].join('\n'),
+ );
+ });
+
+ it('gives the same result when the rule is used more than once', () => {
+ // A global RegExp keeps lastIndex; String.prototype.replace must reset it.
+ const rule = createStorefrontLinkRewriteRule('https://www.example.com');
+ const apply = (html) =>
+ html.replace(rule.match, (...args) =>
+ rule.fn({ headers: { host: LOCAL_HOST } }, {}, ...args),
+ );
+
+ expect(apply('https://www.example.com/a')).toBe('http://localhost:3000/a');
+ expect(apply('https://www.example.com/a')).toBe('http://localhost:3000/a');
+ });
+
+ it('uses the Host header of the request', () => {
+ const rule = createStorefrontLinkRewriteRule('https://www.example.com');
+ const result = 'https://www.example.com/a'.replace(rule.match, (...args) =>
+ rule.fn({ headers: { host: '127.0.0.1:4000' } }, {}, ...args),
+ );
+
+ expect(result).toBe('http://127.0.0.1:4000/a');
+ });
+ });
+
+ describe('does not change links to other hosts', () => {
+ it.each([
+ ['longer host (subdomain suffix)', 'https://www.example.com.evil.test/'],
+ ['longer host (hyphen)', 'https://www.example.com-evil.test/'],
+ ['longer host (more letters)', 'https://www.example.community/'],
+ ['subdomain of the host', 'https://shop.www.example.com/'],
+ ['parent domain', 'https://example.com/'],
+ ['same host, other port', 'https://www.example.com:8443/'],
+ ['host with trailing dot (FQDN)', 'https://www.example.com./x'],
+ ['host with user info', 'https://user@www.example.com/'],
+ ['host with no slashes', 'www.example.com/products'],
+ ['host as a path segment', '/www.example.com/products'],
+ ['other scheme', 'ftp://www.example.com/'],
+ ['host after // in a path', 'https://other.test//www.example.com/'],
+ ['dot in host is not a wildcard', 'https://wwwXexampleYcom/'],
+ ])('%s', (_name, input) => {
+ expect(rewrite(input)).toBe(input);
+ });
+ });
+
+ describe('known limits (not rewritten)', () => {
+ it.each([
+ ['URL-encoded link', 'https%3A%2F%2Fwww.example.com%2Fx'],
+ ['double JSON-escaped link', String.raw`https:\\\/\\\/www.example.com`],
+ ])('%s', (_name, input) => {
+ expect(rewrite(input)).toBe(input);
+ });
+
+ it('does not rewrite the bare domain when the store URL has www', () => {
+ expect(rewrite('https://example.com/x')).toBe('https://example.com/x');
+ });
+ });
+
+ describe('store URL with a port', () => {
+ const storeUrl = 'https://store.test:8443';
+
+ it('rewrites links with the same port', () => {
+ expect(rewrite('https://store.test:8443/x', storeUrl)).toBe('http://localhost:3000/x');
+ });
+
+ it.each([
+ ['no port', 'https://store.test/x'],
+ ['other port', 'https://store.test:9443/x'],
+ ['longer port', 'https://store.test:84430/x'],
+ ])('does not rewrite links with %s', (_name, input) => {
+ expect(rewrite(input, storeUrl)).toBe(input);
+ });
+ });
+
+ it('ignores a default port in the store URL', () => {
+ // `new URL('https://host:443').host` is `host`.
+ expect(rewrite('https://www.example.com/x', 'https://www.example.com:443')).toBe(
+ 'http://localhost:3000/x',
+ );
+ });
+});
+
+describe('buildStorefrontLinkPattern', () => {
+ it('escapes regex characters in the host', () => {
+ const pattern = buildStorefrontLinkPattern('a.b');
+
+ expect('https://a.b/'.match(pattern)).toEqual(['https://a.b']);
+ expect('https://aXb/'.match(pattern)).toBeNull();
+ });
+});