@speedkit/cli 4.22.1 → 4.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +37 -1
- package/dist/commands/origin-request.d.ts +21 -0
- package/dist/commands/origin-request.js +49 -0
- package/dist/hooks/init/dns-result-order.d.ts +12 -0
- package/dist/hooks/init/dns-result-order.js +14 -0
- package/dist/hooks/init/dns-result-order.spec.d.ts +1 -0
- package/dist/hooks/init/dns-result-order.spec.js +15 -0
- package/dist/services/document-handler-runtime/context/document-handler-runtime-context.d.ts +1 -0
- package/dist/services/document-handler-runtime/context/document-handler-runtime-context.js +4 -0
- package/dist/services/document-handler-runtime/server/request.d.ts +1 -0
- package/dist/services/document-handler-runtime/server/request.js +7 -1
- package/dist/services/document-handler-runtime/templates/execute-document-handler.js +2 -0
- package/dist/services/document-handler-runtime/templates/orestes-mock.js +2 -0
- package/dist/services/document-handler-runtime/templates/test.js +3 -0
- package/dist/services/onboarding/virtual-orestes-app/crawler.d.ts +12 -43
- package/dist/services/onboarding/virtual-orestes-app/crawler.js +42 -133
- package/dist/services/origin-request/asset-variations.d.ts +47 -0
- package/dist/services/origin-request/asset-variations.js +170 -0
- package/dist/services/origin-request/asset-variations.spec.d.ts +1 -0
- package/dist/services/origin-request/asset-variations.spec.js +74 -0
- package/dist/services/origin-request/index.d.ts +4 -0
- package/dist/services/origin-request/index.js +4 -0
- package/dist/services/origin-request/origin-request-context.d.ts +13 -0
- package/dist/services/origin-request/origin-request-context.js +23 -0
- package/dist/services/origin-request/origin-request-service-factory.d.ts +13 -0
- package/dist/services/origin-request/origin-request-service-factory.js +32 -0
- package/dist/services/origin-request/origin-request-service.d.ts +33 -0
- package/dist/services/origin-request/origin-request-service.js +135 -0
- package/oclif.manifest.json +90 -1
- package/package.json +6 -1
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Port of the server's variation resolution, so a request can be reproduced locally with the exact
|
|
3
|
+
* headers Speed Kit would send.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors two pieces of server behaviour:
|
|
6
|
+
* - `AssetVariations.getVariations()` — which variation configs apply and in which order
|
|
7
|
+
* (orestes: `orestes-common/.../typesystem/AssetVariations.kt`)
|
|
8
|
+
* - `AssetClient.newRequest()` / `createRequest()` — base headers, last-writer-wins merging,
|
|
9
|
+
* urlPrefix and queryParams (orestes: `orestes-server/.../asset/AssetClient.kt`)
|
|
10
|
+
*
|
|
11
|
+
* Keep this file free of I/O so the resolution stays unit-testable.
|
|
12
|
+
*/
|
|
13
|
+
import { javaRegexToJs } from "../../helpers/java-regex.js";
|
|
14
|
+
export const DEFAULT_TYPE = "default";
|
|
15
|
+
const WILD_CARD = "*";
|
|
16
|
+
/** User agents the server falls back to. Keep in sync with AssetVariations.kt. */
|
|
17
|
+
export const DEFAULT_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Safari/605.1.15 (compatible; SpeedKit/1.0)";
|
|
18
|
+
const MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_6_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1 (compatible; SpeedKit/1.0)";
|
|
19
|
+
const TABLET_USER_AGENT = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36 (compatible; SpeedKit/1.0)";
|
|
20
|
+
const DEVICE_DEFAULTS = {
|
|
21
|
+
[DEFAULT_TYPE]: { headers: { "user-agent": DEFAULT_USER_AGENT } },
|
|
22
|
+
mobile: { headers: { "user-agent": MOBILE_USER_AGENT } },
|
|
23
|
+
tablet: { headers: { "user-agent": TABLET_USER_AGENT } },
|
|
24
|
+
};
|
|
25
|
+
/** Group names a template captures, e.g. `storeId` from `(?<storeId>.*)`. */
|
|
26
|
+
const CAPTURING_TEMPLATES = /<(\w+)>/g;
|
|
27
|
+
/** Thrown where the server would throw UndefinedVariationException. */
|
|
28
|
+
export class UndefinedVariationError extends Error {
|
|
29
|
+
constructor(variationType) {
|
|
30
|
+
super(`Variation ${variationType} not found — the server would reject this request with UndefinedVariationException.`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Matches a template pattern against a variation. The server uses Java's `matches()`, which requires
|
|
35
|
+
* the pattern to cover the whole input, so a partial match is not a match here either.
|
|
36
|
+
*/
|
|
37
|
+
function matchWholeVariation(pattern, variationType) {
|
|
38
|
+
const regex = javaRegexToJs(pattern);
|
|
39
|
+
// An unsupported Java construct leaves the entry unmatched rather than crashing the caller.
|
|
40
|
+
if (!regex)
|
|
41
|
+
return undefined;
|
|
42
|
+
const match = regex.exec(variationType);
|
|
43
|
+
return match?.[0] === variationType ? match : undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Substitutes `${group}` in every header, urlPrefix and queryParam of a template entry. */
|
|
46
|
+
function applyTemplate(entry, variationType) {
|
|
47
|
+
const pattern = entry.regex;
|
|
48
|
+
if (!pattern)
|
|
49
|
+
return undefined;
|
|
50
|
+
const match = matchWholeVariation(pattern, variationType);
|
|
51
|
+
if (!match)
|
|
52
|
+
return undefined;
|
|
53
|
+
const replacements = new Map();
|
|
54
|
+
for (const [, groupName] of pattern.matchAll(CAPTURING_TEMPLATES)) {
|
|
55
|
+
const value = match.groups?.[groupName];
|
|
56
|
+
if (value !== undefined)
|
|
57
|
+
replacements.set(groupName, value);
|
|
58
|
+
}
|
|
59
|
+
const substitute = (text) => {
|
|
60
|
+
let out = text;
|
|
61
|
+
for (const [name, value] of replacements)
|
|
62
|
+
out = out.split(`\${${name}}`).join(value);
|
|
63
|
+
return out;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
matchedKey: entry.matchedKey,
|
|
67
|
+
urlPrefix: entry.urlPrefix ? substitute(entry.urlPrefix) : entry.urlPrefix,
|
|
68
|
+
encodePrefixedUrl: entry.encodePrefixedUrl,
|
|
69
|
+
queryParams: (entry.queryParams ?? []).map(substitute),
|
|
70
|
+
headers: Object.fromEntries(Object.entries(entry.headers ?? {}).map(([key, value]) => [
|
|
71
|
+
key,
|
|
72
|
+
value === null ? null : substitute(String(value)),
|
|
73
|
+
])),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolves which variation configs apply, in server order. The template path returns
|
|
78
|
+
* `[wildcard, template]` and skips the device defaults; otherwise `[default, wildcard, named]`.
|
|
79
|
+
*/
|
|
80
|
+
export function getVariations(custom, variationType) {
|
|
81
|
+
const entries = Object.entries(custom ?? {}).map(([key, entry]) => ({
|
|
82
|
+
...entry,
|
|
83
|
+
matchedKey: key,
|
|
84
|
+
}));
|
|
85
|
+
const wildcard = entries.find((entry) => entry.matchedKey === WILD_CARD);
|
|
86
|
+
const type = variationType ?? DEFAULT_TYPE;
|
|
87
|
+
if (variationType &&
|
|
88
|
+
!custom[variationType] &&
|
|
89
|
+
!custom[variationType.toUpperCase()]) {
|
|
90
|
+
const template = entries
|
|
91
|
+
.filter((entry) => entry.regex)
|
|
92
|
+
.map((entry) => applyTemplate(entry, variationType))
|
|
93
|
+
.find(Boolean);
|
|
94
|
+
if (template)
|
|
95
|
+
return [wildcard, template].filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
const deviceDefault = DEVICE_DEFAULTS[type] ?? DEVICE_DEFAULTS[type.toLowerCase()];
|
|
98
|
+
const named = custom[type] ?? custom[type.toUpperCase()];
|
|
99
|
+
if (!deviceDefault && !named)
|
|
100
|
+
throw new UndefinedVariationError(type);
|
|
101
|
+
return [
|
|
102
|
+
deviceDefault && { ...deviceDefault, matchedKey: `device-default:${type}` },
|
|
103
|
+
wildcard,
|
|
104
|
+
named && { ...named, matchedKey: type },
|
|
105
|
+
].filter(Boolean);
|
|
106
|
+
}
|
|
107
|
+
/** Applies each config's urlPrefix and queryParams in order, as AssetClient.createRequest does. */
|
|
108
|
+
function buildUrl(url, chain) {
|
|
109
|
+
let result = url;
|
|
110
|
+
for (const config of chain) {
|
|
111
|
+
if (config.urlPrefix) {
|
|
112
|
+
result =
|
|
113
|
+
config.urlPrefix +
|
|
114
|
+
(config.encodePrefixedUrl ? encodeURIComponent(result) : result);
|
|
115
|
+
}
|
|
116
|
+
if (config.queryParams?.length) {
|
|
117
|
+
result +=
|
|
118
|
+
(result.includes("?") ? "&" : "?") + config.queryParams.join("&");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Base headers from AssetClient.newRequest, then each config's headers with the last writer
|
|
125
|
+
* winning. A `null` value removes the header, which is how a config drops Accept-Language.
|
|
126
|
+
*/
|
|
127
|
+
function buildHeaders(chain, hostOverride) {
|
|
128
|
+
const headers = new Map([
|
|
129
|
+
[
|
|
130
|
+
"accept",
|
|
131
|
+
"text/html, application/xhtml+xml, application/xml;q=0.9, image/avif, image/webp, */*;q=0.8",
|
|
132
|
+
],
|
|
133
|
+
["accept-encoding", "gzip"],
|
|
134
|
+
["accept-language", "*"],
|
|
135
|
+
["user-agent", DEFAULT_USER_AGENT],
|
|
136
|
+
]);
|
|
137
|
+
for (const config of chain) {
|
|
138
|
+
for (const [key, value] of Object.entries(config.headers ?? {})) {
|
|
139
|
+
if (value === null)
|
|
140
|
+
headers.delete(key.toLowerCase());
|
|
141
|
+
else
|
|
142
|
+
headers.set(key.toLowerCase(), String(value));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (hostOverride)
|
|
146
|
+
headers.set("host", hostOverride);
|
|
147
|
+
return Object.fromEntries(headers);
|
|
148
|
+
}
|
|
149
|
+
/** Resolves a variation into the concrete request the server would send. */
|
|
150
|
+
export function resolveRequest(custom, url, variationType, hostOverride) {
|
|
151
|
+
const chain = getVariations(custom, variationType);
|
|
152
|
+
const isTemplate = chain.some((config) => config.matchedKey !== WILD_CARD &&
|
|
153
|
+
!DEVICE_DEFAULTS[config.matchedKey ?? ""]) &&
|
|
154
|
+
!chain.some((config) => config.matchedKey?.startsWith("device-default:"));
|
|
155
|
+
return {
|
|
156
|
+
appliedKeys: chain.map((config) => config.matchedKey ?? "?"),
|
|
157
|
+
resolutionPath: isTemplate ? "template" : "named",
|
|
158
|
+
url: buildUrl(url, chain),
|
|
159
|
+
headers: buildHeaders(chain, hostOverride),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
/** Renders the resolved request as a curl command, for pasting into a bug report. */
|
|
163
|
+
export function toCurl(url, headers) {
|
|
164
|
+
const parts = ["curl -sSD - -o /dev/null --compressed"];
|
|
165
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
166
|
+
parts.push(` -H ${JSON.stringify(`${key}: ${value}`)}`);
|
|
167
|
+
}
|
|
168
|
+
parts.push(` ${JSON.stringify(url)}`);
|
|
169
|
+
return parts.join(" \\\n");
|
|
170
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { expect } from "chai";
|
|
2
|
+
import { describe, it } from "mocha";
|
|
3
|
+
import { getVariations, resolveRequest, UndefinedVariationError, } from "./asset-variations.js";
|
|
4
|
+
/**
|
|
5
|
+
* These cases mirror the server's own expectations, so a change here means the simulation and the
|
|
6
|
+
* server have drifted apart. The reference tests live in orestes:
|
|
7
|
+
* `orestes-common/src/test/kotlin/info/orestes/common/typesystem/WebBotAuthSignatureMismatchTest.kt`.
|
|
8
|
+
*/
|
|
9
|
+
describe("asset variations", () => {
|
|
10
|
+
const storeTemplate = {
|
|
11
|
+
"*": { headers: { cookie: "fmarktcookie=e_2879130" } },
|
|
12
|
+
"desktop-fmarkt-variant": {
|
|
13
|
+
headers: { cookie: "fmarktcookie=${storeId}" },
|
|
14
|
+
regex: "(?i)desktop-fmarkt-variant-(?<storeId>.*)",
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
it("substitutes a captured group into the headers", () => {
|
|
18
|
+
const { headers } = resolveRequest(storeTemplate, "https://www.example.com/plp", "desktop-fmarkt-variant-e_1907090");
|
|
19
|
+
expect(headers.cookie).to.equal("fmarktcookie=e_1907090");
|
|
20
|
+
});
|
|
21
|
+
it("lets the template override the wildcard, because the server applies it last", () => {
|
|
22
|
+
const { appliedKeys, resolutionPath } = resolveRequest(storeTemplate, "https://www.example.com/plp", "desktop-fmarkt-variant-e_1907090");
|
|
23
|
+
expect(resolutionPath).to.equal("template");
|
|
24
|
+
expect(appliedKeys).to.deep.equal(["*", "desktop-fmarkt-variant"]);
|
|
25
|
+
});
|
|
26
|
+
it("matches a template only against the whole variation, and rejects what matches nothing", () => {
|
|
27
|
+
// The desktop template cannot match a mobile variation, and the server does not fall back to
|
|
28
|
+
// the "mobile" device default by substring — that is the pre-renderer's rule, not the server's.
|
|
29
|
+
expect(() => getVariations(storeTemplate, "mobile-fmarkt-variant-e_1907090")).to.throw(UndefinedVariationError);
|
|
30
|
+
});
|
|
31
|
+
it("resolves a mobile variation through its own template", () => {
|
|
32
|
+
const custom = {
|
|
33
|
+
...storeTemplate,
|
|
34
|
+
"mobile-fmarkt-variant": {
|
|
35
|
+
headers: { cookie: "fmarktcookie=${storeId}" },
|
|
36
|
+
regex: "(?i)mobile-fmarkt-variant-(?<storeId>.*)",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
const { headers, appliedKeys } = resolveRequest(custom, "https://www.example.com/", "mobile-fmarkt-variant-e_1");
|
|
40
|
+
expect(headers.cookie).to.equal("fmarktcookie=e_1");
|
|
41
|
+
expect(appliedKeys).to.deep.equal(["*", "mobile-fmarkt-variant"]);
|
|
42
|
+
});
|
|
43
|
+
it("prefers an exact key over a template", () => {
|
|
44
|
+
const custom = {
|
|
45
|
+
...storeTemplate,
|
|
46
|
+
"desktop-fmarkt-variant-e_1": { headers: { cookie: "exact" } },
|
|
47
|
+
};
|
|
48
|
+
const { headers } = resolveRequest(custom, "https://www.example.com/", "desktop-fmarkt-variant-e_1");
|
|
49
|
+
expect(headers.cookie).to.equal("exact");
|
|
50
|
+
});
|
|
51
|
+
it("keeps the base headers a request always carries", () => {
|
|
52
|
+
const { headers } = resolveRequest({}, "https://www.example.com/");
|
|
53
|
+
expect(headers["accept-encoding"]).to.equal("gzip");
|
|
54
|
+
expect(headers["accept-language"]).to.equal("*");
|
|
55
|
+
expect(headers["user-agent"]).to.contain("SpeedKit/1.0");
|
|
56
|
+
});
|
|
57
|
+
it("drops a header a variation sets to null", () => {
|
|
58
|
+
const { headers } = resolveRequest({ "*": { headers: { "accept-language": null } } }, "https://www.example.com/");
|
|
59
|
+
expect(headers).to.not.have.property("accept-language");
|
|
60
|
+
});
|
|
61
|
+
it("applies urlPrefix and queryParams like the server", () => {
|
|
62
|
+
const { url } = resolveRequest({
|
|
63
|
+
"*": {
|
|
64
|
+
urlPrefix: "https://proxy.example.com/?target=",
|
|
65
|
+
encodePrefixedUrl: true,
|
|
66
|
+
queryParams: ["bq=1"],
|
|
67
|
+
},
|
|
68
|
+
}, "https://www.example.com/plp?page=2");
|
|
69
|
+
expect(url).to.equal("https://proxy.example.com/?target=https%3A%2F%2Fwww.example.com%2Fplp%3Fpage%3D2&bq=1");
|
|
70
|
+
});
|
|
71
|
+
it("reports an unknown variation instead of guessing", () => {
|
|
72
|
+
expect(() => getVariations({}, "b2b-unknown")).to.throw(UndefinedVariationError);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Inputs for `sk origin-request`. No logic. */
|
|
2
|
+
export declare class OriginRequestContext {
|
|
3
|
+
readonly customerPath: string;
|
|
4
|
+
readonly configName: string;
|
|
5
|
+
readonly pageUrl: string;
|
|
6
|
+
readonly variation?: string;
|
|
7
|
+
readonly host?: string;
|
|
8
|
+
readonly bodyFile?: string;
|
|
9
|
+
readonly grep?: string;
|
|
10
|
+
readonly json?: boolean;
|
|
11
|
+
readonly variationsFile?: string;
|
|
12
|
+
constructor(customerPath: string, configName: string, pageUrl: string, variation?: string, host?: string, bodyFile?: string, grep?: string, json?: boolean, variationsFile?: string);
|
|
13
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Inputs for `sk origin-request`. No logic. */
|
|
2
|
+
export class OriginRequestContext {
|
|
3
|
+
customerPath;
|
|
4
|
+
configName;
|
|
5
|
+
pageUrl;
|
|
6
|
+
variation;
|
|
7
|
+
host;
|
|
8
|
+
bodyFile;
|
|
9
|
+
grep;
|
|
10
|
+
json;
|
|
11
|
+
variationsFile;
|
|
12
|
+
constructor(customerPath, configName, pageUrl, variation, host, bodyFile, grep, json, variationsFile) {
|
|
13
|
+
this.customerPath = customerPath;
|
|
14
|
+
this.configName = configName;
|
|
15
|
+
this.pageUrl = pageUrl;
|
|
16
|
+
this.variation = variation;
|
|
17
|
+
this.host = host;
|
|
18
|
+
this.bodyFile = bodyFile;
|
|
19
|
+
this.grep = grep;
|
|
20
|
+
this.json = json;
|
|
21
|
+
this.variationsFile = variationsFile;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { UserCliConfig } from "../../helpers/cli-config.js";
|
|
2
|
+
import { OriginRequestContext } from "./origin-request-context.js";
|
|
3
|
+
import { OriginRequestService } from "./origin-request-service.js";
|
|
4
|
+
export declare class OriginRequestServiceFactory {
|
|
5
|
+
private readonly context;
|
|
6
|
+
private readonly userConfig;
|
|
7
|
+
private service?;
|
|
8
|
+
constructor(context: OriginRequestContext, userConfig: UserCliConfig);
|
|
9
|
+
buildService(): Promise<OriginRequestService>;
|
|
10
|
+
private build;
|
|
11
|
+
private getIntegrationFiles;
|
|
12
|
+
private getConfigApi;
|
|
13
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { CliServiceFactory } from "../cli/index.js";
|
|
2
|
+
import { ConfigApiContext, ConfigApiServiceFactory, } from "../config-api/index.js";
|
|
3
|
+
import { IntegrationApiContext, IntegrationApiFactory, } from "../integration-api/index.js";
|
|
4
|
+
import { OriginRequestService } from "./origin-request-service.js";
|
|
5
|
+
export class OriginRequestServiceFactory {
|
|
6
|
+
context;
|
|
7
|
+
userConfig;
|
|
8
|
+
service = undefined;
|
|
9
|
+
constructor(context, userConfig) {
|
|
10
|
+
this.context = context;
|
|
11
|
+
this.userConfig = userConfig;
|
|
12
|
+
}
|
|
13
|
+
async buildService() {
|
|
14
|
+
if (!(this.service instanceof OriginRequestService)) {
|
|
15
|
+
await this.build();
|
|
16
|
+
}
|
|
17
|
+
return this.service;
|
|
18
|
+
}
|
|
19
|
+
async build() {
|
|
20
|
+
const files = await this.getIntegrationFiles();
|
|
21
|
+
const customer = files.getCustomerConfig().config;
|
|
22
|
+
this.service = new OriginRequestService(this.context, new CliServiceFactory().getService(), this.getConfigApi(customer.app));
|
|
23
|
+
}
|
|
24
|
+
async getIntegrationFiles() {
|
|
25
|
+
const integrationApiContext = new IntegrationApiContext(this.context.customerPath, this.context.configName, []);
|
|
26
|
+
const integrationApi = new IntegrationApiFactory(integrationApiContext, this.userConfig).buildService();
|
|
27
|
+
return await integrationApi.run();
|
|
28
|
+
}
|
|
29
|
+
getConfigApi(app) {
|
|
30
|
+
return new ConfigApiServiceFactory(new ConfigApiContext(app)).getService();
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CliService } from "../cli/index.js";
|
|
2
|
+
import { ConfigApiService } from "../config-api/index.js";
|
|
3
|
+
import { OriginRequestContext } from "./origin-request-context.js";
|
|
4
|
+
/**
|
|
5
|
+
* Fetches a page from origin with exactly the headers Speed Kit's server would send for a cache
|
|
6
|
+
* variation, then reports what the origin answered.
|
|
7
|
+
*
|
|
8
|
+
* Use it to check whether the origin resolves a variation's identity — a store, a locale, a segment
|
|
9
|
+
* — without waiting for a render or reading backend logs. The variations come from the app's
|
|
10
|
+
* deployed runtime config, so the simulation follows production rather than a local file.
|
|
11
|
+
*
|
|
12
|
+
* Known deviations from the server, all deliberate:
|
|
13
|
+
* - The origin map (origin rewriting plus its extra headers) lives outside the runtime config;
|
|
14
|
+
* use `--host` for the Host part.
|
|
15
|
+
* - Revalidation headers (If-None-Match, If-Modified-Since) apply only when refreshing a cached
|
|
16
|
+
* entry, so they are out of scope.
|
|
17
|
+
* - Header order and the HTTP version come from undici, not Jetty.
|
|
18
|
+
*/
|
|
19
|
+
export declare class OriginRequestService {
|
|
20
|
+
private readonly context;
|
|
21
|
+
private readonly cli;
|
|
22
|
+
private readonly configApi;
|
|
23
|
+
constructor(context: OriginRequestContext, cli: CliService, configApi: ConfigApiService);
|
|
24
|
+
run(): Promise<void>;
|
|
25
|
+
/**
|
|
26
|
+
* Reads `speedKit.variations` from the app's deployed runtime config, or from a local file when
|
|
27
|
+
* `--variations` is given. Returns undefined when neither source is usable.
|
|
28
|
+
*/
|
|
29
|
+
private loadVariations;
|
|
30
|
+
private fetchRuntimeConfig;
|
|
31
|
+
private readVariationsFile;
|
|
32
|
+
private report;
|
|
33
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { safe } from "../../helpers/safe.js";
|
|
3
|
+
import { DEFAULT_TYPE, resolveRequest, toCurl, UndefinedVariationError, } from "./asset-variations.js";
|
|
4
|
+
/**
|
|
5
|
+
* Fetches a page from origin with exactly the headers Speed Kit's server would send for a cache
|
|
6
|
+
* variation, then reports what the origin answered.
|
|
7
|
+
*
|
|
8
|
+
* Use it to check whether the origin resolves a variation's identity — a store, a locale, a segment
|
|
9
|
+
* — without waiting for a render or reading backend logs. The variations come from the app's
|
|
10
|
+
* deployed runtime config, so the simulation follows production rather than a local file.
|
|
11
|
+
*
|
|
12
|
+
* Known deviations from the server, all deliberate:
|
|
13
|
+
* - The origin map (origin rewriting plus its extra headers) lives outside the runtime config;
|
|
14
|
+
* use `--host` for the Host part.
|
|
15
|
+
* - Revalidation headers (If-None-Match, If-Modified-Since) apply only when refreshing a cached
|
|
16
|
+
* entry, so they are out of scope.
|
|
17
|
+
* - Header order and the HTTP version come from undici, not Jetty.
|
|
18
|
+
*/
|
|
19
|
+
export class OriginRequestService {
|
|
20
|
+
context;
|
|
21
|
+
cli;
|
|
22
|
+
configApi;
|
|
23
|
+
constructor(context, cli, configApi) {
|
|
24
|
+
this.context = context;
|
|
25
|
+
this.cli = cli;
|
|
26
|
+
this.configApi = configApi;
|
|
27
|
+
}
|
|
28
|
+
async run() {
|
|
29
|
+
const variations = await this.loadVariations();
|
|
30
|
+
// Without variations every request would resolve to the bare defaults, so the report would
|
|
31
|
+
// describe a request the server never sends. Stop instead of simulating something wrong.
|
|
32
|
+
if (!variations)
|
|
33
|
+
return;
|
|
34
|
+
const variation = this.context.variation ?? DEFAULT_TYPE;
|
|
35
|
+
let resolved;
|
|
36
|
+
try {
|
|
37
|
+
resolved = resolveRequest(variations, this.context.pageUrl, this.context.variation, this.context.host);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (error instanceof UndefinedVariationError) {
|
|
41
|
+
this.cli.writeError(error.message);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
const response = await fetch(resolved.url, {
|
|
47
|
+
headers: resolved.headers,
|
|
48
|
+
redirect: "manual",
|
|
49
|
+
});
|
|
50
|
+
const body = await response.text();
|
|
51
|
+
const setCookies = response.headers.getSetCookie?.() ?? [];
|
|
52
|
+
if (this.context.bodyFile)
|
|
53
|
+
await writeFile(this.context.bodyFile, body);
|
|
54
|
+
if (this.context.json) {
|
|
55
|
+
this.cli.write(JSON.stringify({
|
|
56
|
+
variation,
|
|
57
|
+
resolutionPath: resolved.resolutionPath,
|
|
58
|
+
appliedConfigs: resolved.appliedKeys,
|
|
59
|
+
requestUrl: resolved.url,
|
|
60
|
+
requestHeaders: resolved.headers,
|
|
61
|
+
status: response.status,
|
|
62
|
+
responseHeaders: Object.fromEntries(response.headers),
|
|
63
|
+
setCookie: setCookies,
|
|
64
|
+
bodyLength: body.length,
|
|
65
|
+
}, null, 2));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.report(variation, resolved, response, setCookies, body);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reads `speedKit.variations` from the app's deployed runtime config, or from a local file when
|
|
72
|
+
* `--variations` is given. Returns undefined when neither source is usable.
|
|
73
|
+
*/
|
|
74
|
+
async loadVariations() {
|
|
75
|
+
const raw = this.context.variationsFile
|
|
76
|
+
? await this.readVariationsFile(this.context.variationsFile)
|
|
77
|
+
: await this.fetchRuntimeConfig();
|
|
78
|
+
if (!raw)
|
|
79
|
+
return undefined;
|
|
80
|
+
const parsed = JSON.parse(raw);
|
|
81
|
+
const variations = parsed.speedKit?.variations ?? parsed.variations;
|
|
82
|
+
if (!variations) {
|
|
83
|
+
this.cli.writeError("the config carries no speedKit.variations — nothing to simulate");
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
this.cli.writeSuccess(`variations: ${Object.keys(variations).join(", ")}`);
|
|
87
|
+
return variations;
|
|
88
|
+
}
|
|
89
|
+
async fetchRuntimeConfig() {
|
|
90
|
+
this.cli.startAction("ORIGIN_REQUEST:FETCH:RUNTIME_CONFIG", "load variations from the app");
|
|
91
|
+
const serverConfig = await safe(this.configApi.getServerConfig());
|
|
92
|
+
if (serverConfig.success === false) {
|
|
93
|
+
this.cli.failAction("ORIGIN_REQUEST:FETCH:RUNTIME_CONFIG");
|
|
94
|
+
this.cli.writeError(`unable to load the runtime config: ${serverConfig.error}. Run "sk login", or pass --variations <file> to simulate against a config you already have.`);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
this.cli.successAction("ORIGIN_REQUEST:FETCH:RUNTIME_CONFIG");
|
|
98
|
+
return serverConfig.data;
|
|
99
|
+
}
|
|
100
|
+
async readVariationsFile(file) {
|
|
101
|
+
const content = await safe(readFile(file, "utf8"));
|
|
102
|
+
if (content.success === false) {
|
|
103
|
+
this.cli.writeError(`unable to read ${file}: ${content.error}`);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
this.cli.writeSuccess(`variations from ${file}`);
|
|
107
|
+
return content.data;
|
|
108
|
+
}
|
|
109
|
+
report(variation, resolved, response, setCookies, body) {
|
|
110
|
+
this.cli.write(`variation ${variation}`);
|
|
111
|
+
this.cli.write(`resolution ${resolved.resolutionPath} → applied: ${resolved.appliedKeys.join(" then ")}`);
|
|
112
|
+
this.cli.write(`request url ${resolved.url}`);
|
|
113
|
+
this.cli.write("request headers");
|
|
114
|
+
for (const [key, value] of Object.entries(resolved.headers))
|
|
115
|
+
this.cli.write(` ${key}: ${value}`);
|
|
116
|
+
this.cli.write(`\nstatus ${response.status} ${response.statusText}`);
|
|
117
|
+
const location = response.headers.get("location");
|
|
118
|
+
if (location)
|
|
119
|
+
this.cli.write(`location ${location}`);
|
|
120
|
+
if (setCookies.length) {
|
|
121
|
+
this.cli.write("set-cookie");
|
|
122
|
+
for (const cookie of setCookies)
|
|
123
|
+
this.cli.write(` ${cookie}`);
|
|
124
|
+
}
|
|
125
|
+
this.cli.write(`body ${body.length} bytes${this.context.bodyFile ? ` → ${this.context.bodyFile}` : ""}`);
|
|
126
|
+
this.cli.write(`\nequivalent curl:\n${toCurl(resolved.url, resolved.headers)}`);
|
|
127
|
+
if (this.context.grep) {
|
|
128
|
+
const pattern = new RegExp(this.context.grep, "i");
|
|
129
|
+
const hits = body.split("\n").filter((line) => pattern.test(line));
|
|
130
|
+
this.cli.write(`\ngrep "${this.context.grep}" → ${hits.length} line(s)`);
|
|
131
|
+
for (const line of hits.slice(0, 10))
|
|
132
|
+
this.cli.write(` ${line.trim().slice(0, 300)}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
package/oclif.manifest.json
CHANGED
|
@@ -651,6 +651,95 @@
|
|
|
651
651
|
"onboarding.js"
|
|
652
652
|
]
|
|
653
653
|
},
|
|
654
|
+
"origin-request": {
|
|
655
|
+
"aliases": [],
|
|
656
|
+
"args": {
|
|
657
|
+
"customerPath": {
|
|
658
|
+
"description": "The customer config path",
|
|
659
|
+
"name": "customerPath",
|
|
660
|
+
"required": true
|
|
661
|
+
},
|
|
662
|
+
"pageUrl": {
|
|
663
|
+
"description": "Page to request from origin",
|
|
664
|
+
"name": "pageUrl",
|
|
665
|
+
"required": true
|
|
666
|
+
}
|
|
667
|
+
},
|
|
668
|
+
"description": "Request a page from origin with the exact headers Speed Kit's server sends for a cache variation, using the app's deployed runtime config",
|
|
669
|
+
"examples": [
|
|
670
|
+
"$ sk origin-request <customerPath> <pageUrl>",
|
|
671
|
+
"$ sk origin-request customers/decathlon.de https://www.decathlon.de/ -v mobile",
|
|
672
|
+
"$ sk origin-request customers/decathlon.de https://www.decathlon.de/ --grep \"storeId\" --body /tmp/origin.html"
|
|
673
|
+
],
|
|
674
|
+
"flags": {
|
|
675
|
+
"configName": {
|
|
676
|
+
"char": "c",
|
|
677
|
+
"description": "The costumer config name",
|
|
678
|
+
"name": "configName",
|
|
679
|
+
"required": false,
|
|
680
|
+
"default": "production",
|
|
681
|
+
"hasDynamicHelp": false,
|
|
682
|
+
"multiple": false,
|
|
683
|
+
"type": "option"
|
|
684
|
+
},
|
|
685
|
+
"variation": {
|
|
686
|
+
"char": "v",
|
|
687
|
+
"description": "Cache variation to simulate, e.g. desktop-fmarkt-variant-e_1907090. Omitted means the default variation",
|
|
688
|
+
"name": "variation",
|
|
689
|
+
"hasDynamicHelp": false,
|
|
690
|
+
"multiple": false,
|
|
691
|
+
"type": "option"
|
|
692
|
+
},
|
|
693
|
+
"host": {
|
|
694
|
+
"description": "Send this Host header, emulating an origin-map host override",
|
|
695
|
+
"name": "host",
|
|
696
|
+
"hasDynamicHelp": false,
|
|
697
|
+
"multiple": false,
|
|
698
|
+
"type": "option"
|
|
699
|
+
},
|
|
700
|
+
"body": {
|
|
701
|
+
"description": "Write the response body to this file",
|
|
702
|
+
"name": "body",
|
|
703
|
+
"hasDynamicHelp": false,
|
|
704
|
+
"multiple": false,
|
|
705
|
+
"type": "option"
|
|
706
|
+
},
|
|
707
|
+
"grep": {
|
|
708
|
+
"description": "Print body lines matching this regex, case-insensitive",
|
|
709
|
+
"name": "grep",
|
|
710
|
+
"hasDynamicHelp": false,
|
|
711
|
+
"multiple": false,
|
|
712
|
+
"type": "option"
|
|
713
|
+
},
|
|
714
|
+
"json": {
|
|
715
|
+
"description": "Machine-readable output",
|
|
716
|
+
"name": "json",
|
|
717
|
+
"allowNo": false,
|
|
718
|
+
"type": "boolean"
|
|
719
|
+
},
|
|
720
|
+
"variations": {
|
|
721
|
+
"description": "Simulate against a runtime config in a local file instead of the app's deployed one",
|
|
722
|
+
"name": "variations",
|
|
723
|
+
"hasDynamicHelp": false,
|
|
724
|
+
"multiple": false,
|
|
725
|
+
"type": "option"
|
|
726
|
+
}
|
|
727
|
+
},
|
|
728
|
+
"hasDynamicHelp": false,
|
|
729
|
+
"hiddenAliases": [],
|
|
730
|
+
"id": "origin-request",
|
|
731
|
+
"pluginAlias": "@speedkit/cli",
|
|
732
|
+
"pluginName": "@speedkit/cli",
|
|
733
|
+
"pluginType": "core",
|
|
734
|
+
"strict": true,
|
|
735
|
+
"enableJsonFlag": false,
|
|
736
|
+
"isESM": true,
|
|
737
|
+
"relativePath": [
|
|
738
|
+
"dist",
|
|
739
|
+
"commands",
|
|
740
|
+
"origin-request.js"
|
|
741
|
+
]
|
|
742
|
+
},
|
|
654
743
|
"prewarm": {
|
|
655
744
|
"aliases": [],
|
|
656
745
|
"args": {
|
|
@@ -1131,5 +1220,5 @@
|
|
|
1131
1220
|
]
|
|
1132
1221
|
}
|
|
1133
1222
|
},
|
|
1134
|
-
"version": "4.
|
|
1223
|
+
"version": "4.23.1"
|
|
1135
1224
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@speedkit/cli",
|
|
3
3
|
"description": "Speed Kit CLI",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.23.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Baqend.com",
|
|
7
7
|
"email": "info@baqend.com"
|
|
@@ -64,6 +64,11 @@
|
|
|
64
64
|
],
|
|
65
65
|
"oclif": {
|
|
66
66
|
"commands": "./dist/commands",
|
|
67
|
+
"hooks": {
|
|
68
|
+
"init": [
|
|
69
|
+
"./dist/hooks/init/dns-result-order.js"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
67
72
|
"bin": "sk",
|
|
68
73
|
"dirname": "speed-kit-cli",
|
|
69
74
|
"plugins": [
|