@multiplatform.one/platform 6.7.0 → 7.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/README.md +7 -3
- package/dist/cjs/config/config.cjs +14 -8
- package/dist/cjs/config/index.cjs +1 -0
- package/dist/cjs/config/runtimeConfig.cjs +100 -0
- package/dist/cjs/index.cjs +2 -0
- package/dist/cjs/platform/index.gnome.cjs +59 -0
- package/dist/cjs/platform/platformBase.cjs +8 -1
- package/dist/esm/config/config.mjs +14 -7
- package/dist/esm/config/config.mjs.map +1 -1
- package/dist/esm/config/index.mjs +1 -0
- package/dist/esm/config/index.mjs.map +1 -1
- package/dist/esm/config/runtimeConfig.mjs +67 -0
- package/dist/esm/config/runtimeConfig.mjs.map +1 -0
- package/dist/esm/index.mjs +2 -1
- package/dist/esm/index.mjs.map +1 -1
- package/dist/esm/platform/index.gnome.mjs +34 -0
- package/dist/esm/platform/index.gnome.mjs.map +1 -0
- package/dist/esm/platform/platformBase.mjs +8 -1
- package/dist/esm/platform/platformBase.mjs.map +1 -1
- package/package.json +8 -4
- package/src/config/config.spec.ts +62 -1
- package/src/config/config.ts +17 -8
- package/src/config/index.ts +1 -0
- package/src/config/runtimeConfig.native.ts +84 -0
- package/src/config/runtimeConfig.spec.ts +128 -0
- package/src/config/runtimeConfig.ts +159 -0
- package/src/index.ts +1 -0
- package/src/platform/index.gnome.spec.ts +42 -0
- package/src/platform/index.gnome.ts +51 -0
- package/src/platform/platformBase.spec.ts +7 -0
- package/src/platform/platformBase.ts +8 -0
- package/types/config/config.d.ts.map +1 -1
- package/types/config/index.d.ts +1 -0
- package/types/config/index.d.ts.map +1 -1
- package/types/config/runtimeConfig.d.ts +74 -0
- package/types/config/runtimeConfig.d.ts.map +1 -0
- package/types/config/runtimeConfig.native.d.ts +21 -0
- package/types/config/runtimeConfig.native.d.ts.map +1 -0
- package/types/index.d.ts +1 -1
- package/types/index.d.ts.map +1 -1
- package/types/platform/index.gnome.d.ts +5 -0
- package/types/platform/index.gnome.d.ts.map +1 -0
- package/types/platform/platformBase.d.ts +3 -1
- package/types/platform/platformBase.d.ts.map +1 -1
package/src/config/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bakedPublicConfig, readRuntimePublicConfig } from "./runtimeConfig";
|
|
1
2
|
import type { IConfig } from "./types";
|
|
2
3
|
|
|
3
4
|
export class Config implements IConfig {
|
|
@@ -7,7 +8,11 @@ export class Config implements IConfig {
|
|
|
7
8
|
|
|
8
9
|
private _lookupEnv(key: string): string | undefined {
|
|
9
10
|
if (this._blacklist.has(key)) return undefined;
|
|
10
|
-
|
|
11
|
+
// `process` is UNDECLARED in the browser, where `process?.env` throws a
|
|
12
|
+
// ReferenceError rather than short-circuiting — this is why env lookup
|
|
13
|
+
// could never be the browser's only path to config.
|
|
14
|
+
if (typeof process === "undefined") return undefined;
|
|
15
|
+
return process.env?.[key];
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
private _resolveConfig() {
|
|
@@ -29,14 +34,18 @@ export class Config implements IConfig {
|
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
constructor(config: Record<string, string | undefined> = {}) {
|
|
32
|
-
let viteConfig: Record<string, string | undefined> = {};
|
|
33
|
-
try {
|
|
34
|
-
// Read public config injected by Vite at build time via VITE_MP_CONFIG
|
|
35
|
-
const raw = typeof import.meta !== "undefined" && import.meta.env?.VITE_MP_CONFIG;
|
|
36
|
-
viteConfig = JSON.parse(raw || "{}");
|
|
37
|
-
} catch {}
|
|
38
37
|
this._config = {
|
|
39
|
-
|
|
38
|
+
// 1. BUILD time: public config baked into the bundle via VITE_MP_CONFIG.
|
|
39
|
+
// Kept first (and kept working) so apps that bake their config in —
|
|
40
|
+
// native, GNOME, static web — are unchanged.
|
|
41
|
+
...this._reduceConfig(bakedPublicConfig()),
|
|
42
|
+
// 2. RUNTIME: the public payload the SSR document published into the
|
|
43
|
+
// browser (see runtimeConfig.ts). Wins over the bake so a redeployed
|
|
44
|
+
// container overrides a stale build-time value, and so an image built
|
|
45
|
+
// with an EMPTY build environment can still be configured per
|
|
46
|
+
// deployment. `process.env` is not reachable here.
|
|
47
|
+
...this._reduceConfig(readRuntimePublicConfig()),
|
|
48
|
+
// 3. Explicit values handed to the constructor always win.
|
|
40
49
|
...this._reduceConfig(config),
|
|
41
50
|
};
|
|
42
51
|
}
|
package/src/config/index.ts
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native twin of `runtimeConfig.ts`.
|
|
3
|
+
*
|
|
4
|
+
* Identical API, minus `import.meta`: the vxrn/rolldown native bundle is a
|
|
5
|
+
* CLASSIC script for Hermes, where `import.meta` is a syntax error (the same
|
|
6
|
+
* reason `config.native.ts` exists as a hand-written twin of `config.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Native has no SSR document, so `readRuntimePublicConfig` normally returns
|
|
9
|
+
* undefined and native keeps resolving through `expo-constants` +
|
|
10
|
+
* `process.env` exactly as before. The exports are kept so that shared code
|
|
11
|
+
* importing from `@multiplatform.one/platform` compiles on every target.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const runtimePublicConfigKey = "__mp_public_config__";
|
|
15
|
+
export const publicConfigKeysEnvKey = "VITE_MP_PUBLIC_CONFIG_KEYS";
|
|
16
|
+
export const bakedConfigEnvKey = "VITE_MP_CONFIG";
|
|
17
|
+
|
|
18
|
+
function processEnv(): Record<string, string | undefined> {
|
|
19
|
+
try {
|
|
20
|
+
if (typeof process === "undefined") return {};
|
|
21
|
+
return (process.env || {}) as Record<string, string | undefined>;
|
|
22
|
+
} catch {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseJson(raw: string | undefined): unknown {
|
|
28
|
+
if (!raw) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function stringifyValues(source: Record<string, unknown>): Record<string, string> {
|
|
37
|
+
return Object.entries(source).reduce<Record<string, string>>((acc, [key, value]) => {
|
|
38
|
+
if (typeof value !== "undefined" && value !== null) acc[key] = String(value);
|
|
39
|
+
return acc;
|
|
40
|
+
}, {});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function publicConfigKeys(): string[] {
|
|
44
|
+
const parsed = parseJson(processEnv()[publicConfigKeysEnvKey]);
|
|
45
|
+
if (!Array.isArray(parsed)) return [];
|
|
46
|
+
return parsed.filter((key): key is string => typeof key === "string" && key.length > 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function bakedPublicConfig(): Record<string, string> {
|
|
50
|
+
const parsed = parseJson(processEnv()[bakedConfigEnvKey]);
|
|
51
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
52
|
+
return stringifyValues(parsed as Record<string, unknown>);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function resolvePublicConfig(
|
|
56
|
+
env: Record<string, string | undefined> = processEnv(),
|
|
57
|
+
): Record<string, string> {
|
|
58
|
+
const baked = bakedPublicConfig();
|
|
59
|
+
return publicConfigKeys().reduce<Record<string, string>>((acc, key) => {
|
|
60
|
+
const value = env[key] ?? baked[key];
|
|
61
|
+
if (typeof value === "string" && value.length > 0) acc[key] = value;
|
|
62
|
+
return acc;
|
|
63
|
+
}, {});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function readRuntimePublicConfig(): Record<string, string> | undefined {
|
|
67
|
+
const raw = (globalThis as unknown as Record<string, unknown>)[runtimePublicConfigKey];
|
|
68
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
69
|
+
return stringifyValues(raw as Record<string, unknown>);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function safeJsonStringify(value: unknown): string {
|
|
73
|
+
return JSON.stringify(value ?? {})
|
|
74
|
+
.replace(/</g, "\\u003c")
|
|
75
|
+
.replace(/>/g, "\\u003e")
|
|
76
|
+
.replace(/\u2028/g, "\\u2028")
|
|
77
|
+
.replace(/\u2029/g, "\\u2029");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function serializeRuntimePublicConfig(
|
|
81
|
+
config: Record<string, string> = resolvePublicConfig(),
|
|
82
|
+
): string {
|
|
83
|
+
return `globalThis[${JSON.stringify(runtimePublicConfigKey)}]=${safeJsonStringify(config)};`;
|
|
84
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
bakedConfigEnvKey,
|
|
4
|
+
publicConfigKeysEnvKey,
|
|
5
|
+
readRuntimePublicConfig,
|
|
6
|
+
resolvePublicConfig,
|
|
7
|
+
runtimePublicConfigKey,
|
|
8
|
+
serializeRuntimePublicConfig,
|
|
9
|
+
} from "./runtimeConfig";
|
|
10
|
+
|
|
11
|
+
function stubAllowlist(keys: string[]) {
|
|
12
|
+
vi.stubEnv(publicConfigKeysEnvKey, JSON.stringify(keys));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function stubBaked(config: Record<string, string>) {
|
|
16
|
+
vi.stubEnv(bakedConfigEnvKey, JSON.stringify(config));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clearRuntimePayload() {
|
|
20
|
+
delete (globalThis as unknown as Record<string, unknown>)[runtimePublicConfigKey];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
vi.unstubAllEnvs();
|
|
25
|
+
clearRuntimePayload();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolvePublicConfig", () => {
|
|
29
|
+
it("resolves a public key from the RUNTIME environment, not the build", () => {
|
|
30
|
+
stubAllowlist(["MARKETPLACE_API_URL"]);
|
|
31
|
+
expect(
|
|
32
|
+
resolvePublicConfig({ MARKETPLACE_API_URL: "https://api.marketplace.example.org" }),
|
|
33
|
+
).toEqual({
|
|
34
|
+
MARKETPLACE_API_URL: "https://api.marketplace.example.org",
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("lets the runtime environment override a stale baked value", () => {
|
|
39
|
+
stubAllowlist(["MARKETPLACE_API_URL"]);
|
|
40
|
+
stubBaked({ MARKETPLACE_API_URL: "https://stale.example.org" });
|
|
41
|
+
expect(resolvePublicConfig({ MARKETPLACE_API_URL: "https://fresh.example.org" })).toEqual({
|
|
42
|
+
MARKETPLACE_API_URL: "https://fresh.example.org",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("falls back to the baked value when the runtime environment is empty", () => {
|
|
47
|
+
stubAllowlist(["MARKETPLACE_API_URL"]);
|
|
48
|
+
stubBaked({ MARKETPLACE_API_URL: "https://baked.example.org" });
|
|
49
|
+
expect(resolvePublicConfig({})).toEqual({
|
|
50
|
+
MARKETPLACE_API_URL: "https://baked.example.org",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("resolves nothing when the build baked no public allowlist", () => {
|
|
55
|
+
expect(resolvePublicConfig({ MARKETPLACE_API_URL: "https://api.example.org" })).toEqual({});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("public allowlist enforcement", () => {
|
|
60
|
+
it("never resolves a key that is absent from the build-time public allowlist", () => {
|
|
61
|
+
stubAllowlist(["MARKETPLACE_API_URL"]);
|
|
62
|
+
const resolved = resolvePublicConfig({
|
|
63
|
+
MARKETPLACE_API_URL: "https://api.example.org",
|
|
64
|
+
SECRET: "hunter2",
|
|
65
|
+
DATABASE_PASSWORD: "hunter2",
|
|
66
|
+
});
|
|
67
|
+
expect(resolved).toEqual({ MARKETPLACE_API_URL: "https://api.example.org" });
|
|
68
|
+
expect(resolved).not.toHaveProperty("SECRET");
|
|
69
|
+
expect(resolved).not.toHaveProperty("DATABASE_PASSWORD");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("never serializes a private key into the document payload", () => {
|
|
73
|
+
stubAllowlist(["MARKETPLACE_API_URL", "FRAPPE_ENABLED"]);
|
|
74
|
+
const payload = serializeRuntimePublicConfig(
|
|
75
|
+
resolvePublicConfig({
|
|
76
|
+
MARKETPLACE_API_URL: "https://api.example.org",
|
|
77
|
+
FRAPPE_ENABLED: "1",
|
|
78
|
+
SECRET: "hunter2",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
expect(payload).toContain("MARKETPLACE_API_URL");
|
|
82
|
+
expect(payload).toContain("FRAPPE_ENABLED");
|
|
83
|
+
expect(payload).not.toContain("SECRET");
|
|
84
|
+
expect(payload).not.toContain("hunter2");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("serializeRuntimePublicConfig", () => {
|
|
89
|
+
it("assigns the payload to the runtime global key", () => {
|
|
90
|
+
stubAllowlist(["FRAPPE_ENABLED"]);
|
|
91
|
+
expect(serializeRuntimePublicConfig(resolvePublicConfig({ FRAPPE_ENABLED: "1" }))).toBe(
|
|
92
|
+
`globalThis["${runtimePublicConfigKey}"]={"FRAPPE_ENABLED":"1"};`,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("escapes characters that could break out of the inline script", () => {
|
|
97
|
+
stubAllowlist(["BASE_URL"]);
|
|
98
|
+
const payload = serializeRuntimePublicConfig(
|
|
99
|
+
resolvePublicConfig({ BASE_URL: "</script><script>alert(1)</script>" }),
|
|
100
|
+
);
|
|
101
|
+
expect(payload).not.toContain("</script>");
|
|
102
|
+
expect(payload).toContain("\\u003c");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("emits an empty object rather than throwing when nothing resolves", () => {
|
|
106
|
+
expect(serializeRuntimePublicConfig({})).toBe(`globalThis["${runtimePublicConfigKey}"]={};`);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("readRuntimePublicConfig", () => {
|
|
111
|
+
it("reads the payload the SSR document published", () => {
|
|
112
|
+
(globalThis as unknown as Record<string, unknown>)[runtimePublicConfigKey] = {
|
|
113
|
+
MARKETPLACE_API_URL: "https://api.example.org",
|
|
114
|
+
};
|
|
115
|
+
expect(readRuntimePublicConfig()).toEqual({
|
|
116
|
+
MARKETPLACE_API_URL: "https://api.example.org",
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns undefined when no document published a payload", () => {
|
|
121
|
+
expect(readRuntimePublicConfig()).toBeUndefined();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("ignores a payload that is not a plain object", () => {
|
|
125
|
+
(globalThis as unknown as Record<string, unknown>)[runtimePublicConfigKey] = ["nope"];
|
|
126
|
+
expect(readRuntimePublicConfig()).toBeUndefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUNTIME public-config seam: SSR document -> browser.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `VITE_MP_CONFIG` is resolved by `@multiplatform.one/config`
|
|
5
|
+
* at BUILD time (createViteConfig sets `process.env.VITE_MP_CONFIG` from the
|
|
6
|
+
* build environment, and vite bakes it into `import.meta.env`). A container
|
|
7
|
+
* image built by CI therefore ships whatever the CI job happened to export —
|
|
8
|
+
* usually nothing — and no amount of runtime container env reaches the
|
|
9
|
+
* browser, because `Config._lookupEnv` reads `process.env`, which does not
|
|
10
|
+
* exist there. The measured failure: the marketplace storefront had
|
|
11
|
+
* MARKETPLACE_API_URL on its container and still rendered "MARKETPLACE_API_URL
|
|
12
|
+
* is not configured", issuing ZERO catalog requests.
|
|
13
|
+
*
|
|
14
|
+
* The fix keeps the image environment-agnostic: the BUILD bakes only the
|
|
15
|
+
* NAMES of the public keys (`VITE_MP_PUBLIC_CONFIG_KEYS`), and the SSR node
|
|
16
|
+
* process resolves their VALUES from its own `process.env` at request time and
|
|
17
|
+
* publishes them into the served document.
|
|
18
|
+
*
|
|
19
|
+
* Transport: one inline `<script>` in `<head>` assigning a `globalThis` key —
|
|
20
|
+
* the SAME mechanism one uses for `__one_server_context__` (see one's
|
|
21
|
+
* `server/ServerContextScript`), deliberately sitting ALONGSIDE it rather
|
|
22
|
+
* than being a second, differently-shaped invention.
|
|
23
|
+
*
|
|
24
|
+
* Safety: `resolvePublicConfig` walks the BUILD-TIME PUBLIC ALLOWLIST, never
|
|
25
|
+
* the environment. A key that is not on `config.json`'s `public` list can not
|
|
26
|
+
* be serialized even if it is present in the SSR process env, so server-only
|
|
27
|
+
* secrets never reach the document.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Global key the SSR document assigns the resolved public config to.
|
|
32
|
+
* Sibling of one's `__one_server_context__`.
|
|
33
|
+
*/
|
|
34
|
+
export const runtimePublicConfigKey = "__mp_public_config__";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build-time env key carrying the JSON array of PUBLIC config key names
|
|
38
|
+
* (written by `createViteConfig` in `@multiplatform.one/config/vite`).
|
|
39
|
+
* Only the NAMES are baked — values stay environment-agnostic.
|
|
40
|
+
*/
|
|
41
|
+
export const publicConfigKeysEnvKey = "VITE_MP_PUBLIC_CONFIG_KEYS";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build-time env key carrying the JSON object of public config values
|
|
45
|
+
* resolved from the BUILD environment. Still honored so apps that bake their
|
|
46
|
+
* config in (native, GNOME, static web) keep working unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export const bakedConfigEnvKey = "VITE_MP_CONFIG";
|
|
49
|
+
|
|
50
|
+
function importMetaEnv(): Record<string, string | undefined> {
|
|
51
|
+
try {
|
|
52
|
+
if (typeof import.meta === "undefined") return {};
|
|
53
|
+
return (import.meta.env || {}) as Record<string, string | undefined>;
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function processEnv(): Record<string, string | undefined> {
|
|
60
|
+
try {
|
|
61
|
+
// `process` is undefined in the browser; a bare `process?.env` would
|
|
62
|
+
// still throw ReferenceError on an undeclared identifier.
|
|
63
|
+
if (typeof process === "undefined") return {};
|
|
64
|
+
return (process.env || {}) as Record<string, string | undefined>;
|
|
65
|
+
} catch {
|
|
66
|
+
return {};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readBuildEnv(key: string): string | undefined {
|
|
71
|
+
return importMetaEnv()[key] ?? processEnv()[key];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseJson(raw: string | undefined): unknown {
|
|
75
|
+
if (!raw) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
return JSON.parse(raw);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function stringifyValues(source: Record<string, unknown>): Record<string, string> {
|
|
84
|
+
return Object.entries(source).reduce<Record<string, string>>((acc, [key, value]) => {
|
|
85
|
+
if (typeof value !== "undefined" && value !== null) acc[key] = String(value);
|
|
86
|
+
return acc;
|
|
87
|
+
}, {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The PUBLIC key allowlist baked at build time. Empty when the app was not
|
|
92
|
+
* built through `createViteConfig` (tests, storybook, plain node) — in which
|
|
93
|
+
* case nothing is publishable and the payload stays empty, which is the safe
|
|
94
|
+
* direction to fail.
|
|
95
|
+
*/
|
|
96
|
+
export function publicConfigKeys(): string[] {
|
|
97
|
+
const parsed = parseJson(readBuildEnv(publicConfigKeysEnvKey));
|
|
98
|
+
if (!Array.isArray(parsed)) return [];
|
|
99
|
+
return parsed.filter((key): key is string => typeof key === "string" && key.length > 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Public config values baked into the bundle at BUILD time. */
|
|
103
|
+
export function bakedPublicConfig(): Record<string, string> {
|
|
104
|
+
const parsed = parseJson(readBuildEnv(bakedConfigEnvKey));
|
|
105
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
|
106
|
+
return stringifyValues(parsed as Record<string, unknown>);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the public config the SSR document should publish.
|
|
111
|
+
*
|
|
112
|
+
* Iterates the ALLOWLIST — never the environment — so a private key present
|
|
113
|
+
* in `env` can not reach the returned object. Runtime env wins over the
|
|
114
|
+
* build-time bake, so a redeployed container overrides a stale baked value.
|
|
115
|
+
*/
|
|
116
|
+
export function resolvePublicConfig(
|
|
117
|
+
env: Record<string, string | undefined> = processEnv(),
|
|
118
|
+
): Record<string, string> {
|
|
119
|
+
const baked = bakedPublicConfig();
|
|
120
|
+
return publicConfigKeys().reduce<Record<string, string>>((acc, key) => {
|
|
121
|
+
const value = env[key] ?? baked[key];
|
|
122
|
+
if (typeof value === "string" && value.length > 0) acc[key] = value;
|
|
123
|
+
return acc;
|
|
124
|
+
}, {});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Read the payload the SSR document published. Returns undefined anywhere the
|
|
129
|
+
* document never ran (native, SSR itself, tests).
|
|
130
|
+
*/
|
|
131
|
+
export function readRuntimePublicConfig(): Record<string, string> | undefined {
|
|
132
|
+
const raw = (globalThis as unknown as Record<string, unknown>)[runtimePublicConfigKey];
|
|
133
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
134
|
+
return stringifyValues(raw as Record<string, unknown>);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* HTML-safe JSON: an inline `<script>` body must not be able to close its own
|
|
139
|
+
* tag, and U+2028/U+2029 are line terminators to a JS parser but legal inside
|
|
140
|
+
* a JSON string.
|
|
141
|
+
*/
|
|
142
|
+
function safeJsonStringify(value: unknown): string {
|
|
143
|
+
return JSON.stringify(value ?? {})
|
|
144
|
+
.replace(/</g, "\\u003c")
|
|
145
|
+
.replace(/>/g, "\\u003e")
|
|
146
|
+
.replace(/\u2028/g, "\\u2028")
|
|
147
|
+
.replace(/\u2029/g, "\\u2029");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The inline `<script>` body that publishes the public config to the browser.
|
|
152
|
+
* Runs during document parse, so it lands before the deferred app bundle
|
|
153
|
+
* constructs its `Config`.
|
|
154
|
+
*/
|
|
155
|
+
export function serializeRuntimePublicConfig(
|
|
156
|
+
config: Record<string, string> = resolvePublicConfig(),
|
|
157
|
+
): string {
|
|
158
|
+
return `globalThis[${JSON.stringify(runtimePublicConfigKey)}]=${safeJsonStringify(config)};`;
|
|
159
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { platform } from "./index.gnome";
|
|
3
|
+
import { platform as webPlatform } from "./index";
|
|
4
|
+
|
|
5
|
+
describe("the gnome platform entry", () => {
|
|
6
|
+
it("sets isGnome, which is the whole point of the file existing", () => {
|
|
7
|
+
expect(platform.isGnome).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('resolves preciseName to "gnome" rather than "native"', () => {
|
|
11
|
+
expect(platform.preciseName).toBe("gnome");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('resolves broadName to "client"', () => {
|
|
15
|
+
expect(platform.broadName).toBe("client");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("reports a desktop that is not a browser", () => {
|
|
19
|
+
expect(platform.isDesktop).toBe(true);
|
|
20
|
+
expect(platform.isNative).toBe(true);
|
|
21
|
+
expect(platform.isWeb).toBe(false);
|
|
22
|
+
expect(platform.isBrowser).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("does not claim a usable window, since GJS's is a polyfill stub", () => {
|
|
26
|
+
expect(platform.isWindowDefined).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("claims no other platform", () => {
|
|
30
|
+
expect(platform.isIos).toBe(false);
|
|
31
|
+
expect(platform.isAndroid).toBe(false);
|
|
32
|
+
expect(platform.isExpo).toBe(false);
|
|
33
|
+
expect(platform.isNext).toBe(false);
|
|
34
|
+
expect(platform.isServer).toBe(false);
|
|
35
|
+
expect(platform.isWebExtension).toBe(false);
|
|
36
|
+
expect(platform.isStorybook).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("answers every key the default entry does, so the flag set cannot drift", () => {
|
|
40
|
+
expect(Object.keys(platform).sort()).toEqual(Object.keys(webPlatform).sort());
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Platform flags for the GNOME (GTK4 / GJS / libadwaita) target.
|
|
2
|
+
//
|
|
3
|
+
// Resolved the same way `index.ios.ts` and `index.android.ts` are: the
|
|
4
|
+
// bundler picks this file over `index.ts` by extension. Metro does that for
|
|
5
|
+
// `.ios`/`.android` out of the box; on GNOME it is Vite, and the `.gnome.*`
|
|
6
|
+
// entries come ahead of the `.native.*` ones in `resolve.extensions` —
|
|
7
|
+
// `gnomePlatformExtensions` from @multiplatform.one/vite-plugin-gnome, wired
|
|
8
|
+
// into apps/one/vite.config.gnome.ts. Without that registration this file is
|
|
9
|
+
// inert and every consumer sees the base `isGnome: false`.
|
|
10
|
+
//
|
|
11
|
+
// Every value is stated rather than inherited from @tamagui/constants,
|
|
12
|
+
// because those are inferred from `document`/`window` and GJS's window is a
|
|
13
|
+
// polyfill stub — inference would answer questions about a fake DOM instead
|
|
14
|
+
// of about GTK.
|
|
15
|
+
|
|
16
|
+
import { type Platform, getBroadName, getPreciseName, platformBase } from "./platformBase";
|
|
17
|
+
|
|
18
|
+
export const platform: Platform = {
|
|
19
|
+
...platformBase,
|
|
20
|
+
isGnome: true,
|
|
21
|
+
// GTK is a desktop, and it is not a browser — so `isNative` too, which is
|
|
22
|
+
// exactly why Gnome sits ahead of Native in the precision order.
|
|
23
|
+
isDesktop: true,
|
|
24
|
+
isNative: true,
|
|
25
|
+
isClient: true,
|
|
26
|
+
isServer: false,
|
|
27
|
+
isWeb: false,
|
|
28
|
+
isBrowser: false,
|
|
29
|
+
isChrome: false,
|
|
30
|
+
isFirefox: false,
|
|
31
|
+
isIframe: false,
|
|
32
|
+
isStorybook: false,
|
|
33
|
+
isWebExtension: false,
|
|
34
|
+
isChromeExtension: false,
|
|
35
|
+
isFirefoxExtension: false,
|
|
36
|
+
isExpo: false,
|
|
37
|
+
isNext: false,
|
|
38
|
+
// A GTK pointer desktop. Touch exists on some hardware but is not the
|
|
39
|
+
// interaction model the layout should assume.
|
|
40
|
+
isTouchable: false,
|
|
41
|
+
isWebTouchable: false,
|
|
42
|
+
// There is a `window` under the react-gnome polyfills, but it is a stub
|
|
43
|
+
// with no layout, no events and no document. Callers branching on this are
|
|
44
|
+
// asking "can I touch the DOM", and the answer is no.
|
|
45
|
+
isWindowDefined: false,
|
|
46
|
+
};
|
|
47
|
+
platform.preciseName = getPreciseName(platform);
|
|
48
|
+
platform.broadName = getBroadName(platform);
|
|
49
|
+
|
|
50
|
+
export type { Platform };
|
|
51
|
+
export type { PlatformName } from "./platformBase";
|
|
@@ -12,6 +12,7 @@ function makePlatform(overrides: Partial<Platform> = {}): Platform {
|
|
|
12
12
|
isExpo: false,
|
|
13
13
|
isFirefox: false,
|
|
14
14
|
isFirefoxExtension: false,
|
|
15
|
+
isGnome: false,
|
|
15
16
|
isIframe: false,
|
|
16
17
|
isIos: false,
|
|
17
18
|
isNative: false,
|
|
@@ -45,6 +46,12 @@ describe("getPreciseName", () => {
|
|
|
45
46
|
expect(getPreciseName(makePlatform({ isAndroid: true, isNative: true }))).toBe("android");
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
it('returns "gnome" for the GTK desktop target', () => {
|
|
50
|
+
expect(getPreciseName(makePlatform({ isGnome: true, isDesktop: true, isNative: true }))).toBe(
|
|
51
|
+
"gnome",
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
48
55
|
it('returns "tauri" for desktop Tauri apps', () => {
|
|
49
56
|
expect(getPreciseName(makePlatform({ isTauri: true, isDesktop: true, isWeb: true }))).toBe(
|
|
50
57
|
"tauri",
|
|
@@ -17,7 +17,12 @@ declare global {
|
|
|
17
17
|
const platformOrder = [
|
|
18
18
|
"Ios",
|
|
19
19
|
"Android",
|
|
20
|
+
// Gnome outranks Native: the GTK target reports isNative (it is not a
|
|
21
|
+
// browser) but "gnome" is the precise answer callers want.
|
|
22
|
+
"Gnome",
|
|
20
23
|
"Native",
|
|
24
|
+
// Tauri outranks Desktop/Web the same way: the webview host reports isWeb
|
|
25
|
+
// and isDesktop, but "tauri" is the precise answer.
|
|
21
26
|
"Tauri",
|
|
22
27
|
"Desktop",
|
|
23
28
|
"ChromeExtension",
|
|
@@ -38,6 +43,7 @@ const platformOrder = [
|
|
|
38
43
|
export type PlatformName =
|
|
39
44
|
| "ios"
|
|
40
45
|
| "android"
|
|
46
|
+
| "gnome"
|
|
41
47
|
| "native"
|
|
42
48
|
| "tauri"
|
|
43
49
|
| "desktop"
|
|
@@ -66,6 +72,7 @@ export const platformBase = {
|
|
|
66
72
|
isExpo: false,
|
|
67
73
|
isFirefox: false,
|
|
68
74
|
isFirefoxExtension: false,
|
|
75
|
+
isGnome: false,
|
|
69
76
|
isIframe: false,
|
|
70
77
|
isIos: false,
|
|
71
78
|
isNative: false,
|
|
@@ -112,6 +119,7 @@ export interface Platform {
|
|
|
112
119
|
isExpo: boolean;
|
|
113
120
|
isFirefox: boolean;
|
|
114
121
|
isFirefoxExtension: boolean;
|
|
122
|
+
isGnome: boolean;
|
|
115
123
|
isIframe: boolean;
|
|
116
124
|
isIos: boolean;
|
|
117
125
|
isNative: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/config/config.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,qBAAa,MAAO,YAAW,OAAO;IACpC,OAAO,CAAC,OAAO,CAA8B;IAE7C,OAAO,CAAC,UAAU,CAA4B;IAE9C,OAAO,CAAC,UAAU;IASlB,OAAO,CAAC,cAAc;IAQtB,OAAO,CAAC,aAAa;gBAUT,MAAM,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAM;IAiB3D,GAAG,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAC7B,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;IACpC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,MAAM;IAO9C,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM;IAK9B,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC;IAQjC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS;CAKxC"}
|
package/types/config/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,QAAA,MAAM,MAAM,QAAe,CAAC;AAE5B,OAAO,EAAE,MAAM,EAAE,CAAC;AAClB,cAAc,UAAU,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/config/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAElC,QAAA,MAAM,MAAM,QAAe,CAAC;AAE5B,OAAO,EAAE,MAAM,EAAE,CAAC;AAClB,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RUNTIME public-config seam: SSR document -> browser.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: `VITE_MP_CONFIG` is resolved by `@multiplatform.one/config`
|
|
5
|
+
* at BUILD time (createViteConfig sets `process.env.VITE_MP_CONFIG` from the
|
|
6
|
+
* build environment, and vite bakes it into `import.meta.env`). A container
|
|
7
|
+
* image built by CI therefore ships whatever the CI job happened to export —
|
|
8
|
+
* usually nothing — and no amount of runtime container env reaches the
|
|
9
|
+
* browser, because `Config._lookupEnv` reads `process.env`, which does not
|
|
10
|
+
* exist there. The measured failure: the marketplace storefront had
|
|
11
|
+
* MARKETPLACE_API_URL on its container and still rendered "MARKETPLACE_API_URL
|
|
12
|
+
* is not configured", issuing ZERO catalog requests.
|
|
13
|
+
*
|
|
14
|
+
* The fix keeps the image environment-agnostic: the BUILD bakes only the
|
|
15
|
+
* NAMES of the public keys (`VITE_MP_PUBLIC_CONFIG_KEYS`), and the SSR node
|
|
16
|
+
* process resolves their VALUES from its own `process.env` at request time and
|
|
17
|
+
* publishes them into the served document.
|
|
18
|
+
*
|
|
19
|
+
* Transport: one inline `<script>` in `<head>` assigning a `globalThis` key —
|
|
20
|
+
* the SAME mechanism one uses for `__one_server_context__` (see one's
|
|
21
|
+
* `server/ServerContextScript`), deliberately sitting ALONGSIDE it rather
|
|
22
|
+
* than being a second, differently-shaped invention.
|
|
23
|
+
*
|
|
24
|
+
* Safety: `resolvePublicConfig` walks the BUILD-TIME PUBLIC ALLOWLIST, never
|
|
25
|
+
* the environment. A key that is not on `config.json`'s `public` list can not
|
|
26
|
+
* be serialized even if it is present in the SSR process env, so server-only
|
|
27
|
+
* secrets never reach the document.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Global key the SSR document assigns the resolved public config to.
|
|
31
|
+
* Sibling of one's `__one_server_context__`.
|
|
32
|
+
*/
|
|
33
|
+
export declare const runtimePublicConfigKey = "__mp_public_config__";
|
|
34
|
+
/**
|
|
35
|
+
* Build-time env key carrying the JSON array of PUBLIC config key names
|
|
36
|
+
* (written by `createViteConfig` in `@multiplatform.one/config/vite`).
|
|
37
|
+
* Only the NAMES are baked — values stay environment-agnostic.
|
|
38
|
+
*/
|
|
39
|
+
export declare const publicConfigKeysEnvKey = "VITE_MP_PUBLIC_CONFIG_KEYS";
|
|
40
|
+
/**
|
|
41
|
+
* Build-time env key carrying the JSON object of public config values
|
|
42
|
+
* resolved from the BUILD environment. Still honored so apps that bake their
|
|
43
|
+
* config in (native, GNOME, static web) keep working unchanged.
|
|
44
|
+
*/
|
|
45
|
+
export declare const bakedConfigEnvKey = "VITE_MP_CONFIG";
|
|
46
|
+
/**
|
|
47
|
+
* The PUBLIC key allowlist baked at build time. Empty when the app was not
|
|
48
|
+
* built through `createViteConfig` (tests, storybook, plain node) — in which
|
|
49
|
+
* case nothing is publishable and the payload stays empty, which is the safe
|
|
50
|
+
* direction to fail.
|
|
51
|
+
*/
|
|
52
|
+
export declare function publicConfigKeys(): string[];
|
|
53
|
+
/** Public config values baked into the bundle at BUILD time. */
|
|
54
|
+
export declare function bakedPublicConfig(): Record<string, string>;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the public config the SSR document should publish.
|
|
57
|
+
*
|
|
58
|
+
* Iterates the ALLOWLIST — never the environment — so a private key present
|
|
59
|
+
* in `env` can not reach the returned object. Runtime env wins over the
|
|
60
|
+
* build-time bake, so a redeployed container overrides a stale baked value.
|
|
61
|
+
*/
|
|
62
|
+
export declare function resolvePublicConfig(env?: Record<string, string | undefined>): Record<string, string>;
|
|
63
|
+
/**
|
|
64
|
+
* Read the payload the SSR document published. Returns undefined anywhere the
|
|
65
|
+
* document never ran (native, SSR itself, tests).
|
|
66
|
+
*/
|
|
67
|
+
export declare function readRuntimePublicConfig(): Record<string, string> | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* The inline `<script>` body that publishes the public config to the browser.
|
|
70
|
+
* Runs during document parse, so it lands before the deferred app bundle
|
|
71
|
+
* constructs its `Config`.
|
|
72
|
+
*/
|
|
73
|
+
export declare function serializeRuntimePublicConfig(config?: Record<string, string>): string;
|
|
74
|
+
//# sourceMappingURL=runtimeConfig.d.ts.map
|