@maravilla-labs/adapter-core 3.4.0 → 4.1.0
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/dist/build-failure.d.ts +31 -0
- package/dist/build-failure.d.ts.map +1 -0
- package/dist/build-failure.js +37 -0
- package/dist/build-failure.js.map +1 -0
- package/dist/config-loader.d.ts +19 -1
- package/dist/config-loader.d.ts.map +1 -1
- package/dist/config-loader.js +418 -0
- package/dist/config-loader.js.map +1 -1
- package/dist/events.d.ts +10 -8
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +23 -17
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +4 -1
- package/dist/manifest.js.map +1 -1
- package/dist/types.d.ts +247 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/workflows.d.ts +9 -6
- package/dist/workflows.d.ts.map +1 -1
- package/dist/workflows.js +22 -15
- package/dist/workflows.js.map +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared failure reporting for the workflows / events adapter steps.
|
|
3
|
+
*
|
|
4
|
+
* Both steps used to catch build errors, call `log?.error(...)`, and return —
|
|
5
|
+
* so the adapter build finished green and the deployment shipped without the
|
|
6
|
+
* bundle. Worse, `log` is optional and three of the four adapters pass nothing,
|
|
7
|
+
* so on those frameworks the failure produced no output at all (issue #94).
|
|
8
|
+
*
|
|
9
|
+
* A build error here is always fatal: the caller only gets this far after
|
|
10
|
+
* confirming the project actually has sources to build.
|
|
11
|
+
*/
|
|
12
|
+
export type AdapterLog = {
|
|
13
|
+
minor: (msg: string) => void;
|
|
14
|
+
error: (msg: string) => void;
|
|
15
|
+
warn: (msg: string) => void;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Report a fatal build failure and throw.
|
|
19
|
+
*
|
|
20
|
+
* Writes to `console.error` unconditionally — not just through the optional
|
|
21
|
+
* `log` — so the message survives on adapters that pass no logger.
|
|
22
|
+
*/
|
|
23
|
+
export declare function failBuild(what: 'workflows' | 'events', error: unknown, log?: AdapterLog): never;
|
|
24
|
+
/**
|
|
25
|
+
* Error thrown when the build API itself can't be resolved. That means a
|
|
26
|
+
* broken or partial install of `@maravilla-labs/functions`, which is very
|
|
27
|
+
* different from "this project has no workflows" — the old code returned
|
|
28
|
+
* silent no-op stubs and the two were indistinguishable.
|
|
29
|
+
*/
|
|
30
|
+
export declare function missingBuildApi(what: 'workflows' | 'events', attempted: string[]): Error;
|
|
31
|
+
//# sourceMappingURL=build-failure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-failure.d.ts","sourceRoot":"","sources":["../src/build-failure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,MAAM,UAAU,GAAG;IACvB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7B,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,UAAU,GAAG,KAAK,CAQ/F;AAED;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,WAAW,GAAG,QAAQ,EAAE,SAAS,EAAE,MAAM,EAAE,GAAG,KAAK,CAOxF"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared failure reporting for the workflows / events adapter steps.
|
|
3
|
+
*
|
|
4
|
+
* Both steps used to catch build errors, call `log?.error(...)`, and return —
|
|
5
|
+
* so the adapter build finished green and the deployment shipped without the
|
|
6
|
+
* bundle. Worse, `log` is optional and three of the four adapters pass nothing,
|
|
7
|
+
* so on those frameworks the failure produced no output at all (issue #94).
|
|
8
|
+
*
|
|
9
|
+
* A build error here is always fatal: the caller only gets this far after
|
|
10
|
+
* confirming the project actually has sources to build.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Report a fatal build failure and throw.
|
|
14
|
+
*
|
|
15
|
+
* Writes to `console.error` unconditionally — not just through the optional
|
|
16
|
+
* `log` — so the message survives on adapters that pass no logger.
|
|
17
|
+
*/
|
|
18
|
+
export function failBuild(what, error, log) {
|
|
19
|
+
const message = error?.message || String(error);
|
|
20
|
+
const banner = `Failed to build ${what} — aborting build so this is not silently deployed.`;
|
|
21
|
+
log?.error(banner);
|
|
22
|
+
console.error(`\n${banner}\n${message}\n`);
|
|
23
|
+
throw error instanceof Error ? error : new Error(message);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Error thrown when the build API itself can't be resolved. That means a
|
|
27
|
+
* broken or partial install of `@maravilla-labs/functions`, which is very
|
|
28
|
+
* different from "this project has no workflows" — the old code returned
|
|
29
|
+
* silent no-op stubs and the two were indistinguishable.
|
|
30
|
+
*/
|
|
31
|
+
export function missingBuildApi(what, attempted) {
|
|
32
|
+
return new Error(`Cannot load the ${what} build API — @maravilla-labs/functions did not resolve.\n` +
|
|
33
|
+
`Tried: ${attempted.join(', ')}\n\n` +
|
|
34
|
+
`This project has ${what} sources, so they cannot be skipped.\n` +
|
|
35
|
+
'Install the package (npm i -D @maravilla-labs/functions) and rebuild.');
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=build-failure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-failure.js","sourceRoot":"","sources":["../src/build-failure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAQH;;;;;GAKG;AACH,MAAM,UAAU,SAAS,CAAC,IAA4B,EAAE,KAAc,EAAE,GAAgB;IACtF,MAAM,OAAO,GAAI,KAAe,EAAE,OAAO,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC;IAC3D,MAAM,MAAM,GAAG,mBAAmB,IAAI,qDAAqD,CAAC;IAE5F,GAAG,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IACnB,OAAO,CAAC,KAAK,CAAC,KAAK,MAAM,KAAK,OAAO,IAAI,CAAC,CAAC;IAE3C,MAAM,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,CAAC;AAC5D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,eAAe,CAAC,IAA4B,EAAE,SAAmB;IAC/E,OAAO,IAAI,KAAK,CACd,mBAAmB,IAAI,2DAA2D;QAChF,UAAU,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM;QACpC,oBAAoB,IAAI,wCAAwC;QAChE,uEAAuE,CAC1E,CAAC;AACJ,CAAC"}
|
package/dist/config-loader.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AuthConfigBlock, DatabaseConfigBlock, DocumentIndexDeclaration, StudioConfigBlock } from './types.js';
|
|
1
|
+
import type { AuthConfigBlock, DatabaseConfigBlock, DocumentIndexDeclaration, StudioConfigBlock, RealtimeConfigBlock, SecurityConfigBlock, ProxyConfigBlock } from './types.js';
|
|
2
2
|
import type { TransformsConfig } from './transforms/index.js';
|
|
3
3
|
export interface MaravillaConfig {
|
|
4
4
|
auth?: AuthConfigBlock;
|
|
@@ -17,6 +17,23 @@ export interface MaravillaConfig {
|
|
|
17
17
|
* `crates/dev-server/src/config_loader.rs`'s `StudioConfig`.
|
|
18
18
|
*/
|
|
19
19
|
studio?: StudioConfigBlock;
|
|
20
|
+
/**
|
|
21
|
+
* Realtime settings — today which channels keep a replayable message history.
|
|
22
|
+
* Mirrors `RealtimeConfigBlock` in `packages/platform/src/config.ts`.
|
|
23
|
+
*/
|
|
24
|
+
realtime?: RealtimeConfigBlock;
|
|
25
|
+
/**
|
|
26
|
+
* Security settings — today the extra origins allowed to frame this site.
|
|
27
|
+
* Mirrors `SecurityConfigBlock` in `packages/adapter-core/src/types.ts`.
|
|
28
|
+
*/
|
|
29
|
+
security?: SecurityConfigBlock;
|
|
30
|
+
/**
|
|
31
|
+
* Reverse-proxy mounts — paths served by forwarding to an external origin.
|
|
32
|
+
* Mirrors `ProxyConfigBlock` in `packages/adapter-core/src/types.ts` and the
|
|
33
|
+
* Rust `ProxyManifest` in
|
|
34
|
+
* `crates/server-integration/src/manifest/proxy.rs`.
|
|
35
|
+
*/
|
|
36
|
+
proxy?: ProxyConfigBlock;
|
|
20
37
|
}
|
|
21
38
|
export interface OAuthProviderDefinition {
|
|
22
39
|
enabled: boolean;
|
|
@@ -47,4 +64,5 @@ export declare class ConfigError extends Error {
|
|
|
47
64
|
export declare function loadMaravillaConfig(projectRoot: string): Promise<LoadResult>;
|
|
48
65
|
export declare function normalizeDocumentIndex(raw: unknown, file: string, path: string): DocumentIndexDeclaration;
|
|
49
66
|
export type { AuthConfigBlock, DatabaseConfigBlock, DocumentIndexDeclaration, VectorIndexDeclaration, } from './types.js';
|
|
67
|
+
export declare function normalizeProxy(raw: unknown, file: string, field: string): ProxyConfigBlock;
|
|
50
68
|
//# sourceMappingURL=config-loader.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config-loader.d.ts","sourceRoot":"","sources":["../src/config-loader.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,eAAe,EACf,mBAAmB,EACnB,wBAAwB,EACxB,iBAAiB,
|
|
1
|
+
{"version":3,"file":"config-loader.d.ts","sourceRoot":"","sources":["../src/config-loader.ts"],"names":[],"mappings":"AAmBA,OAAO,KAAK,EACV,eAAe,EACf,mBAAmB,EACnB,wBAAwB,EACxB,iBAAiB,EACjB,mBAAmB,EACnB,mBAAmB,EACnB,gBAAgB,EAKjB,MAAM,YAAY,CAAC;AACpB,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAe9D,MAAM,WAAW,eAAe;IAC9B,IAAI,CAAC,EAAE,eAAe,CAAC;IACvB,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B;;;;;;OAMG;IACH,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B;;;;OAIG;IACH,MAAM,CAAC,EAAE,iBAAiB,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,EAAE,mBAAmB,CAAC;IAC/B;;;;;OAKG;IACH,KAAK,CAAC,EAAE,gBAAgB,CAAC;CAC1B;AACD,MAAM,WAAW,uBAAuB;IACtC,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,SAAS,CAAC;IACzB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AACD,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG;IAAE,GAAG,EAAE,MAAM,CAAA;CAAE,CAAC;AAWjD,MAAM,WAAW,UAAU;IACzB,0EAA0E;IAC1E,MAAM,EAAE,eAAe,CAAC;IACxB,8EAA8E;IAC9E,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;CACrB;AAED,qBAAa,WAAY,SAAQ,KAAK;aAGlB,IAAI,EAAE,MAAM,GAAG,IAAI;aACnB,KAAK,CAAC,EAAE,MAAM;gBAF9B,OAAO,EAAE,MAAM,EACC,IAAI,GAAE,MAAM,GAAG,IAAW,EAC1B,KAAK,CAAC,EAAE,MAAM,YAAA;CASjC;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAkBlF;AAoQD,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,OAAO,EACZ,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX,wBAAwB,CAkC1B;AA8ND,YAAY,EACV,eAAe,EACf,mBAAmB,EACnB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,YAAY,CAAC;AA8WpB,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,gBAAgB,CAqB1F"}
|
package/dist/config-loader.js
CHANGED
|
@@ -162,6 +162,94 @@ function normalize(raw, file) {
|
|
|
162
162
|
if (src.studio !== undefined) {
|
|
163
163
|
out.studio = normalizeStudio(src.studio, file, 'studio');
|
|
164
164
|
}
|
|
165
|
+
if (src.realtime !== undefined) {
|
|
166
|
+
out.realtime = normalizeRealtime(src.realtime, file, 'realtime');
|
|
167
|
+
}
|
|
168
|
+
if (src.security !== undefined) {
|
|
169
|
+
out.security = normalizeSecurity(src.security, file, 'security');
|
|
170
|
+
}
|
|
171
|
+
if (src.proxy !== undefined) {
|
|
172
|
+
out.proxy = normalizeProxy(src.proxy, file, 'proxy');
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
// ── Security normalization ──
|
|
177
|
+
//
|
|
178
|
+
// Validated here rather than passed through, because a malformed source is
|
|
179
|
+
// silent at every later layer: the manifest carries it, delivery unions it into
|
|
180
|
+
// the CSP, and the browser drops the whole `frame-ancestors` directive it
|
|
181
|
+
// cannot parse — which reads as "the platform ignored my setting", not as a
|
|
182
|
+
// typo. A path, a port-less scheme mismatch or a bare `*` are the common ones.
|
|
183
|
+
/**
|
|
184
|
+
* A CSP `frame-ancestors` source we accept: `'self'`, `'none'`, or an
|
|
185
|
+
* http(s) origin with an optional single leading `*.` label and no path.
|
|
186
|
+
* A bare `*` is rejected on purpose — it is almost never what the author
|
|
187
|
+
* means, and it cannot be narrowed later without breaking whoever relied on it.
|
|
188
|
+
*/
|
|
189
|
+
function validateFrameAncestor(value, file, field) {
|
|
190
|
+
if (value === "'self'" || value === "'none'")
|
|
191
|
+
return value;
|
|
192
|
+
if (!/^https?:\/\//.test(value)) {
|
|
193
|
+
throw new ConfigError(`${field} must be 'self', 'none', or an https:// origin (got ${JSON.stringify(value)})`, file);
|
|
194
|
+
}
|
|
195
|
+
const rest = value.replace(/^https?:\/\//, '');
|
|
196
|
+
if (rest.includes('/')) {
|
|
197
|
+
throw new ConfigError(`${field} must be an origin with no path (got ${JSON.stringify(value)})`, file);
|
|
198
|
+
}
|
|
199
|
+
const host = rest.split(':')[0];
|
|
200
|
+
if (host === '*' || host.length === 0) {
|
|
201
|
+
throw new ConfigError(`${field} must name a host, not a bare wildcard`, file);
|
|
202
|
+
}
|
|
203
|
+
const labels = host.startsWith('*.') ? host.slice(2) : host;
|
|
204
|
+
if (labels.includes('*')) {
|
|
205
|
+
throw new ConfigError(`${field} allows at most one leading '*.' label (got ${JSON.stringify(value)})`, file);
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}
|
|
209
|
+
function normalizeSecurity(raw, file, field) {
|
|
210
|
+
const obj = expectObject(raw, file, field);
|
|
211
|
+
const out = {};
|
|
212
|
+
if (obj.frameAncestors !== undefined) {
|
|
213
|
+
if (!Array.isArray(obj.frameAncestors)) {
|
|
214
|
+
throw new ConfigError(`${field}.frameAncestors must be an array of origins`, file);
|
|
215
|
+
}
|
|
216
|
+
out.frameAncestors = obj.frameAncestors.map((v, i) => {
|
|
217
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
218
|
+
throw new ConfigError(`${field}.frameAncestors[${i}] must be a non-empty string`, file);
|
|
219
|
+
}
|
|
220
|
+
return validateFrameAncestor(v, file, `${field}.frameAncestors[${i}]`);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
// ── Realtime normalization ──
|
|
226
|
+
//
|
|
227
|
+
// Validated here rather than passed through, because a typo in `channels` is
|
|
228
|
+
// silent at every later layer: the manifest carries it, registration accepts it,
|
|
229
|
+
// and the only symptom is a replay that returns nothing.
|
|
230
|
+
function normalizeRealtime(raw, file, field) {
|
|
231
|
+
const obj = expectObject(raw, file, field);
|
|
232
|
+
const out = {};
|
|
233
|
+
if (obj.history !== undefined) {
|
|
234
|
+
const h = expectObject(obj.history, file, `${field}.history`);
|
|
235
|
+
if (!Array.isArray(h.channels)) {
|
|
236
|
+
throw new ConfigError(`${field}.history.channels must be an array of channel patterns`, file);
|
|
237
|
+
}
|
|
238
|
+
const channels = h.channels.map((c, i) => {
|
|
239
|
+
if (typeof c !== 'string' || c.length === 0) {
|
|
240
|
+
throw new ConfigError(`${field}.history.channels[${i}] must be a non-empty string`, file);
|
|
241
|
+
}
|
|
242
|
+
return c;
|
|
243
|
+
});
|
|
244
|
+
const history = { channels };
|
|
245
|
+
if (h.maxMessages !== undefined) {
|
|
246
|
+
if (typeof h.maxMessages !== 'number' || !Number.isFinite(h.maxMessages) || h.maxMessages < 0) {
|
|
247
|
+
throw new ConfigError(`${field}.history.maxMessages must be a non-negative number`, file);
|
|
248
|
+
}
|
|
249
|
+
history.maxMessages = h.maxMessages;
|
|
250
|
+
}
|
|
251
|
+
out.history = history;
|
|
252
|
+
}
|
|
165
253
|
return out;
|
|
166
254
|
}
|
|
167
255
|
// ── Studio normalization ──
|
|
@@ -416,4 +504,334 @@ function errMsg(e) {
|
|
|
416
504
|
return e.message;
|
|
417
505
|
return String(e);
|
|
418
506
|
}
|
|
507
|
+
// ── Proxy normalization ──
|
|
508
|
+
//
|
|
509
|
+
// A proxy mount is the one config block that can reach OUT of the platform, so
|
|
510
|
+
// it is validated hardest and at build time: a bad target here is not a broken
|
|
511
|
+
// page, it is a request the platform makes on the author's behalf. Two classes
|
|
512
|
+
// of mistake are caught rather than deployed:
|
|
513
|
+
//
|
|
514
|
+
// * a target naming a private or loopback address, which would turn a
|
|
515
|
+
// tenant-authored config into a request-forgery primitive against the
|
|
516
|
+
// platform's own internal APIs (delivery's `/_internal/*` listens on
|
|
517
|
+
// loopback). Delivery re-checks this at request time — including what the
|
|
518
|
+
// hostname actually resolves to — but a config that can never work should
|
|
519
|
+
// fail the build, not each request.
|
|
520
|
+
// * a mount that shadows a platform path (`/_auth/*`, `/_assets/*`, …), which
|
|
521
|
+
// would silently break login, assets or realtime for the whole site.
|
|
522
|
+
/**
|
|
523
|
+
* Path prefixes delivery owns. A mount may not claim any of them: the site's
|
|
524
|
+
* own login pages, asset serving, realtime sockets and health endpoints all
|
|
525
|
+
* live here, and a mount that swallowed one would break the site in a way whose
|
|
526
|
+
* cause is invisible from the app's own code.
|
|
527
|
+
*/
|
|
528
|
+
const RESERVED_PROXY_PREFIXES = [
|
|
529
|
+
'/_assets', '/_auth', '/_rt', '/_push', '/_internal', '/_mcp', '/_queue',
|
|
530
|
+
'/health', '/stats', '/metrics', '/.well-known',
|
|
531
|
+
];
|
|
532
|
+
/** Methods we will forward. Anything else is a config error, not a 405 at runtime. */
|
|
533
|
+
const PROXY_METHODS = new Set([
|
|
534
|
+
'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS',
|
|
535
|
+
]);
|
|
536
|
+
/**
|
|
537
|
+
* Headers a mount may not set on the upstream request or on our response.
|
|
538
|
+
* These are either connection-scoped (the HTTP client owns them) or the
|
|
539
|
+
* proxy's own bookkeeping, and letting config forge them makes the hop
|
|
540
|
+
* unreadable — an upstream trusting `X-Forwarded-For` would trust whatever the
|
|
541
|
+
* config said instead of the real client.
|
|
542
|
+
*/
|
|
543
|
+
const PROXY_FORBIDDEN_HEADERS = new Set([
|
|
544
|
+
'host', 'connection', 'keep-alive', 'transfer-encoding', 'upgrade', 'te',
|
|
545
|
+
'trailer', 'proxy-authorization', 'proxy-authenticate', 'content-length',
|
|
546
|
+
'x-forwarded-for', 'x-forwarded-proto', 'x-forwarded-host',
|
|
547
|
+
]);
|
|
548
|
+
/**
|
|
549
|
+
* Reject a target that names the platform's own network. Literal-IP checks
|
|
550
|
+
* cover the direct attempt; the hostname list covers the obvious names. A
|
|
551
|
+
* hostname that *resolves* into private space is caught by delivery at request
|
|
552
|
+
* time — that check cannot be done honestly here, since the build machine's
|
|
553
|
+
* DNS is not the server's.
|
|
554
|
+
*/
|
|
555
|
+
function isPrivateHost(host) {
|
|
556
|
+
const h = host.toLowerCase();
|
|
557
|
+
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) {
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
// IPv6 literal, as it appears in a URL: [::1]
|
|
561
|
+
if (h.startsWith('[')) {
|
|
562
|
+
const inner = h.slice(1, h.endsWith(']') ? -1 : undefined);
|
|
563
|
+
if (inner === '::1' || inner === '::')
|
|
564
|
+
return true;
|
|
565
|
+
// fc00::/7 (unique-local) and fe80::/10 (link-local)
|
|
566
|
+
if (/^f[cd][0-9a-f]{2}:/.test(inner) || /^fe[89ab][0-9a-f]:/.test(inner))
|
|
567
|
+
return true;
|
|
568
|
+
return false;
|
|
569
|
+
}
|
|
570
|
+
const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
|
|
571
|
+
if (!v4)
|
|
572
|
+
return false;
|
|
573
|
+
const [a, b] = [Number(v4[1]), Number(v4[2])];
|
|
574
|
+
if (a === 127 || a === 0 || a === 10)
|
|
575
|
+
return true;
|
|
576
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
577
|
+
return true;
|
|
578
|
+
if (a === 192 && b === 168)
|
|
579
|
+
return true;
|
|
580
|
+
if (a === 169 && b === 254)
|
|
581
|
+
return true; // link-local, incl. cloud metadata
|
|
582
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
583
|
+
return true; // CGNAT
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
function normalizeProxyTarget(raw, file, field) {
|
|
587
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
588
|
+
throw new ConfigError(`${field} must be a non-empty http(s) URL`, file);
|
|
589
|
+
}
|
|
590
|
+
let url;
|
|
591
|
+
try {
|
|
592
|
+
url = new URL(raw);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
throw new ConfigError(`${field} must be an absolute URL (got ${JSON.stringify(raw)})`, file);
|
|
596
|
+
}
|
|
597
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
598
|
+
throw new ConfigError(`${field} must use http:// or https:// (got ${url.protocol})`, file);
|
|
599
|
+
}
|
|
600
|
+
if (url.search || url.hash) {
|
|
601
|
+
throw new ConfigError(`${field} must not carry a query string or fragment — the visitor's own query is forwarded`, file);
|
|
602
|
+
}
|
|
603
|
+
if (url.username || url.password) {
|
|
604
|
+
throw new ConfigError(`${field} must not embed credentials — use requestHeaders for an upstream secret`, file);
|
|
605
|
+
}
|
|
606
|
+
if (isPrivateHost(url.hostname)) {
|
|
607
|
+
throw new ConfigError(`${field} names a private or loopback address (${url.hostname}); a proxy target must be a public origin`, file);
|
|
608
|
+
}
|
|
609
|
+
// Normalize: keep an explicit base path, drop the trailing slash so joining
|
|
610
|
+
// is unambiguous later (`/site` + `/cart`, never `/site//cart`).
|
|
611
|
+
const basePath = url.pathname.replace(/\/+$/, '');
|
|
612
|
+
return `${url.protocol}//${url.host}${basePath}`;
|
|
613
|
+
}
|
|
614
|
+
function normalizeProxyPath(raw, file, field) {
|
|
615
|
+
if (typeof raw !== 'string' || raw.length === 0) {
|
|
616
|
+
throw new ConfigError(`${field} must be a non-empty path`, file);
|
|
617
|
+
}
|
|
618
|
+
if (!raw.startsWith('/')) {
|
|
619
|
+
throw new ConfigError(`${field} must start with '/' (got ${JSON.stringify(raw)})`, file);
|
|
620
|
+
}
|
|
621
|
+
if (raw.includes('?') || raw.includes('#')) {
|
|
622
|
+
throw new ConfigError(`${field} must be a path with no query or fragment`, file);
|
|
623
|
+
}
|
|
624
|
+
const star = raw.indexOf('*');
|
|
625
|
+
if (star !== -1 && !raw.endsWith('/*')) {
|
|
626
|
+
throw new ConfigError(`${field} may only use a trailing '/*' wildcard (got ${JSON.stringify(raw)})`, file);
|
|
627
|
+
}
|
|
628
|
+
const mount = raw.endsWith('/*') ? raw.slice(0, -2) : raw.replace(/\/+$/, '');
|
|
629
|
+
if (mount === '') {
|
|
630
|
+
// '/*' — the whole site. Legal, but then nothing of ours is left to serve.
|
|
631
|
+
throw new ConfigError(`${field} may not mount the whole site ('/*') — proxy the sections that live elsewhere`, file);
|
|
632
|
+
}
|
|
633
|
+
const lower = mount.toLowerCase();
|
|
634
|
+
for (const reserved of RESERVED_PROXY_PREFIXES) {
|
|
635
|
+
if (lower === reserved || lower.startsWith(`${reserved}/`)) {
|
|
636
|
+
throw new ConfigError(`${field} may not claim ${reserved} — that path belongs to the platform`, file);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
return raw;
|
|
640
|
+
}
|
|
641
|
+
function normalizeProxyHeaders(raw, file, field) {
|
|
642
|
+
const obj = expectObject(raw, file, field);
|
|
643
|
+
const out = {};
|
|
644
|
+
for (const [name, value] of Object.entries(obj)) {
|
|
645
|
+
const lower = name.toLowerCase();
|
|
646
|
+
if (!/^[!#$%&'*+\-.^_`|~0-9a-z]+$/.test(lower)) {
|
|
647
|
+
throw new ConfigError(`${field}: ${JSON.stringify(name)} is not a valid header name`, file);
|
|
648
|
+
}
|
|
649
|
+
if (PROXY_FORBIDDEN_HEADERS.has(lower)) {
|
|
650
|
+
throw new ConfigError(`${field}: ${name} is set by the proxy itself and cannot be overridden`, file);
|
|
651
|
+
}
|
|
652
|
+
if (typeof value !== 'string') {
|
|
653
|
+
throw new ConfigError(`${field}.${name} must be a string`, file);
|
|
654
|
+
}
|
|
655
|
+
if (/[\r\n]/.test(value)) {
|
|
656
|
+
throw new ConfigError(`${field}.${name} must not contain a newline`, file);
|
|
657
|
+
}
|
|
658
|
+
out[lower] = value;
|
|
659
|
+
}
|
|
660
|
+
return out;
|
|
661
|
+
}
|
|
662
|
+
function expectPositiveInt(raw, file, field, max) {
|
|
663
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0 || !Number.isInteger(raw)) {
|
|
664
|
+
throw new ConfigError(`${field} must be a non-negative whole number`, file);
|
|
665
|
+
}
|
|
666
|
+
if (max !== undefined && raw > max) {
|
|
667
|
+
throw new ConfigError(`${field} must be at most ${max}`, file);
|
|
668
|
+
}
|
|
669
|
+
return raw;
|
|
670
|
+
}
|
|
671
|
+
function normalizeStringArray(raw, file, field) {
|
|
672
|
+
const arr = expectArray(raw, file, field);
|
|
673
|
+
return arr.map((v, i) => {
|
|
674
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
675
|
+
throw new ConfigError(`${field}[${i}] must be a non-empty string`, file);
|
|
676
|
+
}
|
|
677
|
+
return v;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Validate a list of path patterns — the grammar shared by `blockPaths` and
|
|
682
|
+
* `cache.bypassPaths`. Kept as one function so the two can never drift apart:
|
|
683
|
+
* an author who learns the grammar in one field should not find it means
|
|
684
|
+
* something else in the other.
|
|
685
|
+
*/
|
|
686
|
+
function normalizePathPatterns(raw, file, field) {
|
|
687
|
+
return normalizeStringArray(raw, file, field).map((p, i) => {
|
|
688
|
+
if (!p.startsWith('/')) {
|
|
689
|
+
throw new ConfigError(`${field}[${i}] must start with '/' (got ${JSON.stringify(p)})`, file);
|
|
690
|
+
}
|
|
691
|
+
const star = p.indexOf('*');
|
|
692
|
+
if (star !== -1 && star !== p.length - 1) {
|
|
693
|
+
throw new ConfigError(`${field}[${i}] may only use a trailing '*' (got ${JSON.stringify(p)})`, file);
|
|
694
|
+
}
|
|
695
|
+
return p;
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
function normalizeProxyCache(raw, file, field) {
|
|
699
|
+
const obj = expectObject(raw, file, field);
|
|
700
|
+
const out = {};
|
|
701
|
+
if (obj.ttl !== undefined)
|
|
702
|
+
out.ttl = expectPositiveInt(obj.ttl, file, `${field}.ttl`, 31536000);
|
|
703
|
+
if (obj.staleWhileRevalidate !== undefined) {
|
|
704
|
+
out.staleWhileRevalidate = expectPositiveInt(obj.staleWhileRevalidate, file, `${field}.staleWhileRevalidate`, 31536000);
|
|
705
|
+
}
|
|
706
|
+
if (obj.methods !== undefined) {
|
|
707
|
+
out.methods = normalizeStringArray(obj.methods, file, `${field}.methods`).map((m) => {
|
|
708
|
+
const upper = m.toUpperCase();
|
|
709
|
+
if (upper !== 'GET' && upper !== 'HEAD') {
|
|
710
|
+
throw new ConfigError(`${field}.methods may only contain GET or HEAD — caching a ${upper} would serve one visitor's write to the next`, file);
|
|
711
|
+
}
|
|
712
|
+
return upper;
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
if (obj.ignoreUpstreamCacheControl !== undefined) {
|
|
716
|
+
if (typeof obj.ignoreUpstreamCacheControl !== 'boolean') {
|
|
717
|
+
throw new ConfigError(`${field}.ignoreUpstreamCacheControl must be a boolean`, file);
|
|
718
|
+
}
|
|
719
|
+
out.ignoreUpstreamCacheControl = obj.ignoreUpstreamCacheControl;
|
|
720
|
+
}
|
|
721
|
+
if (obj.varyHeaders !== undefined) {
|
|
722
|
+
out.varyHeaders = normalizeStringArray(obj.varyHeaders, file, `${field}.varyHeaders`)
|
|
723
|
+
.map((h) => h.toLowerCase());
|
|
724
|
+
}
|
|
725
|
+
if (obj.bypassPaths !== undefined) {
|
|
726
|
+
out.bypassPaths = normalizePathPatterns(obj.bypassPaths, file, `${field}.bypassPaths`);
|
|
727
|
+
}
|
|
728
|
+
if (obj.bypassCookies !== undefined) {
|
|
729
|
+
if (typeof obj.bypassCookies !== 'boolean') {
|
|
730
|
+
throw new ConfigError(`${field}.bypassCookies must be a boolean`, file);
|
|
731
|
+
}
|
|
732
|
+
out.bypassCookies = obj.bypassCookies;
|
|
733
|
+
}
|
|
734
|
+
if (obj.maxBodyBytes !== undefined) {
|
|
735
|
+
out.maxBodyBytes = expectPositiveInt(obj.maxBodyBytes, file, `${field}.maxBodyBytes`, 33554432);
|
|
736
|
+
}
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
function normalizeProxyRewrite(raw, file, field) {
|
|
740
|
+
const obj = expectObject(raw, file, field);
|
|
741
|
+
const out = {};
|
|
742
|
+
if (obj.links !== undefined) {
|
|
743
|
+
if (typeof obj.links !== 'boolean') {
|
|
744
|
+
throw new ConfigError(`${field}.links must be a boolean`, file);
|
|
745
|
+
}
|
|
746
|
+
out.links = obj.links;
|
|
747
|
+
}
|
|
748
|
+
if (obj.hosts !== undefined) {
|
|
749
|
+
out.hosts = normalizeStringArray(obj.hosts, file, `${field}.hosts`).map((h, i) => {
|
|
750
|
+
const host = h.toLowerCase();
|
|
751
|
+
if (host.includes('/') || host.includes(':')) {
|
|
752
|
+
throw new ConfigError(`${field}.hosts[${i}] must be a bare hostname, no scheme, port or path (got ${JSON.stringify(h)})`, file);
|
|
753
|
+
}
|
|
754
|
+
return host;
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
if (obj.contentTypes !== undefined) {
|
|
758
|
+
out.contentTypes = normalizeStringArray(obj.contentTypes, file, `${field}.contentTypes`)
|
|
759
|
+
.map((t) => t.toLowerCase());
|
|
760
|
+
}
|
|
761
|
+
if (obj.setCookieDomain !== undefined) {
|
|
762
|
+
if (typeof obj.setCookieDomain !== 'boolean') {
|
|
763
|
+
throw new ConfigError(`${field}.setCookieDomain must be a boolean`, file);
|
|
764
|
+
}
|
|
765
|
+
out.setCookieDomain = obj.setCookieDomain;
|
|
766
|
+
}
|
|
767
|
+
if (obj.maxBodyBytes !== undefined) {
|
|
768
|
+
out.maxBodyBytes = expectPositiveInt(obj.maxBodyBytes, file, `${field}.maxBodyBytes`, 67108864);
|
|
769
|
+
}
|
|
770
|
+
return out;
|
|
771
|
+
}
|
|
772
|
+
function normalizeProxyRoute(raw, file, field) {
|
|
773
|
+
const obj = expectObject(raw, file, field);
|
|
774
|
+
const out = {
|
|
775
|
+
path: normalizeProxyPath(obj.path, file, `${field}.path`),
|
|
776
|
+
target: normalizeProxyTarget(obj.target, file, `${field}.target`),
|
|
777
|
+
};
|
|
778
|
+
if (obj.stripPrefix !== undefined) {
|
|
779
|
+
if (typeof obj.stripPrefix !== 'boolean') {
|
|
780
|
+
throw new ConfigError(`${field}.stripPrefix must be a boolean`, file);
|
|
781
|
+
}
|
|
782
|
+
out.stripPrefix = obj.stripPrefix;
|
|
783
|
+
}
|
|
784
|
+
if (obj.preserveHostHeader !== undefined) {
|
|
785
|
+
if (typeof obj.preserveHostHeader !== 'boolean') {
|
|
786
|
+
throw new ConfigError(`${field}.preserveHostHeader must be a boolean`, file);
|
|
787
|
+
}
|
|
788
|
+
out.preserveHostHeader = obj.preserveHostHeader;
|
|
789
|
+
}
|
|
790
|
+
if (obj.methods !== undefined) {
|
|
791
|
+
out.methods = normalizeStringArray(obj.methods, file, `${field}.methods`).map((m) => {
|
|
792
|
+
const upper = m.toUpperCase();
|
|
793
|
+
if (!PROXY_METHODS.has(upper)) {
|
|
794
|
+
throw new ConfigError(`${field}.methods: ${JSON.stringify(m)} is not a forwardable method`, file);
|
|
795
|
+
}
|
|
796
|
+
return upper;
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
if (obj.blockPaths !== undefined) {
|
|
800
|
+
out.blockPaths = normalizePathPatterns(obj.blockPaths, file, `${field}.blockPaths`);
|
|
801
|
+
}
|
|
802
|
+
if (obj.timeoutMs !== undefined) {
|
|
803
|
+
out.timeoutMs = expectPositiveInt(obj.timeoutMs, file, `${field}.timeoutMs`, 120000);
|
|
804
|
+
}
|
|
805
|
+
if (obj.cache !== undefined)
|
|
806
|
+
out.cache = normalizeProxyCache(obj.cache, file, `${field}.cache`);
|
|
807
|
+
if (obj.rewrite !== undefined) {
|
|
808
|
+
out.rewrite = normalizeProxyRewrite(obj.rewrite, file, `${field}.rewrite`);
|
|
809
|
+
}
|
|
810
|
+
if (obj.requestHeaders !== undefined) {
|
|
811
|
+
out.requestHeaders = normalizeProxyHeaders(obj.requestHeaders, file, `${field}.requestHeaders`);
|
|
812
|
+
}
|
|
813
|
+
if (obj.responseHeaders !== undefined) {
|
|
814
|
+
out.responseHeaders = normalizeProxyHeaders(obj.responseHeaders, file, `${field}.responseHeaders`);
|
|
815
|
+
}
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
export function normalizeProxy(raw, file, field) {
|
|
819
|
+
const obj = expectObject(raw, file, field);
|
|
820
|
+
const out = {};
|
|
821
|
+
if (obj.routes !== undefined) {
|
|
822
|
+
const arr = expectArray(obj.routes, file, `${field}.routes`);
|
|
823
|
+
const routes = arr.map((r, i) => normalizeProxyRoute(r, file, `${field}.routes[${i}]`));
|
|
824
|
+
// Two mounts on the same path is not a precedence question, it is a typo:
|
|
825
|
+
// the second is unreachable and its author will never see why.
|
|
826
|
+
const seen = new Set();
|
|
827
|
+
for (const route of routes) {
|
|
828
|
+
if (seen.has(route.path)) {
|
|
829
|
+
throw new ConfigError(`${field}.routes: ${route.path} is mounted twice — the second one can never match`, file);
|
|
830
|
+
}
|
|
831
|
+
seen.add(route.path);
|
|
832
|
+
}
|
|
833
|
+
out.routes = routes;
|
|
834
|
+
}
|
|
835
|
+
return out;
|
|
836
|
+
}
|
|
419
837
|
//# sourceMappingURL=config-loader.js.map
|