@akanjs/devkit 3.0.0-beta.27 → 3.0.0-beta.29
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/akanApp/akanApp.host.test.ts +55 -0
- package/akanApp/akanApp.host.ts +24 -2
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +212 -2
- package/akanConfig/akanConfig.ts +93 -26
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +7 -1
- package/applicationBuildRunner.test.ts +36 -2
- package/applicationBuildRunner.ts +21 -6
- package/biome.base.json +3 -1
- package/csrE2e/csrPageStack.e2e.test.ts +65 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevModuleCompiler.test.ts +32 -0
- package/frontendBuild/csrDevModuleCompiler.ts +45 -2
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- package/integration/devStability.integration.test.ts +35 -0
- package/mobile/desktopBin.test.ts +177 -0
- package/mobile/desktopBin.ts +143 -0
- package/mobile/desktopServerStage.test.ts +204 -0
- package/mobile/desktopServerStage.ts +138 -0
- package/mobile/index.ts +3 -0
- package/mobile/mobileTarget.test.ts +50 -1
- package/mobile/mobileTarget.ts +20 -4
- package/mobile/nativeApp.test.ts +52 -1
- package/mobile/nativeApp.ts +175 -20
- package/mobile/nativeAppLine.test.ts +7 -0
- package/mobile/nativeAppLine.ts +8 -0
- package/mobile/nativeConfig.test.ts +215 -4
- package/mobile/nativeConfig.ts +117 -8
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.ts +6 -2
- package/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- package/workspaceLayout.ts +2 -0
package/mobile/nativeConfig.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import type { AkanNativeConfig } from "@akanjs/native/config";
|
|
3
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
AkanMobileTargetConfig,
|
|
6
|
+
AkanNativeValue,
|
|
7
|
+
AkanPluginNativeConfig,
|
|
8
|
+
MobileEnv,
|
|
9
|
+
MobilePermission,
|
|
10
|
+
} from "akanjs";
|
|
11
|
+
import type { DesktopServerBundle } from "./desktopServerStage";
|
|
12
|
+
import { type NativePlatform, resolveAppId } from "./mobileTarget";
|
|
13
|
+
import type { NativePluginFolder } from "./nativePluginFolders";
|
|
4
14
|
import { toIosInfoPlistUsageDescriptions } from "./usageDescriptions";
|
|
5
15
|
|
|
6
16
|
export interface NativeConfigInput {
|
|
@@ -12,6 +22,15 @@ export interface NativeConfigInput {
|
|
|
12
22
|
/** What the app's and its libs' plugins declare. */
|
|
13
23
|
contributions: AkanPluginNativeConfig[];
|
|
14
24
|
locales: readonly string[];
|
|
25
|
+
/** The backend the binary talks to; an updates channel left unnamed follows it. */
|
|
26
|
+
env?: MobileEnv;
|
|
27
|
+
platform: NativePlatform;
|
|
28
|
+
/** A desktop build that carries its server (the target's `native.desktop.server`). */
|
|
29
|
+
desktopServer?: DesktopServerBundle;
|
|
30
|
+
/** The plugins in the app's and its libs' `native/` folders. */
|
|
31
|
+
nativePlugins?: NativePluginFolder[];
|
|
32
|
+
/** A desktop build's staged `bin` executables (DesktopBin). */
|
|
33
|
+
desktopBin?: string;
|
|
15
34
|
}
|
|
16
35
|
|
|
17
36
|
export interface NativeConfigResult {
|
|
@@ -68,7 +87,18 @@ export class NativeConfig {
|
|
|
68
87
|
speech: { permission: "speech" },
|
|
69
88
|
};
|
|
70
89
|
|
|
71
|
-
static build({
|
|
90
|
+
static build({
|
|
91
|
+
appPath,
|
|
92
|
+
target,
|
|
93
|
+
webDir,
|
|
94
|
+
contributions,
|
|
95
|
+
locales,
|
|
96
|
+
env,
|
|
97
|
+
platform,
|
|
98
|
+
desktopServer,
|
|
99
|
+
nativePlugins = [],
|
|
100
|
+
desktopBin,
|
|
101
|
+
}: NativeConfigInput): NativeConfigResult {
|
|
72
102
|
const warnings: string[] = [];
|
|
73
103
|
const applied = (target.permissions ?? []).flatMap((permission) => {
|
|
74
104
|
const claimed = contributions.filter((contribution) => contribution.permission === permission);
|
|
@@ -78,13 +108,30 @@ export class NativeConfig {
|
|
|
78
108
|
return used;
|
|
79
109
|
});
|
|
80
110
|
const abs = (relative: string) => path.resolve(appPath, relative);
|
|
111
|
+
const updates = NativeConfig.#updates(target, env);
|
|
112
|
+
const folders = new Set(nativePlugins.map((plugin) => path.resolve(plugin.dir)));
|
|
113
|
+
const folderIds = new Set(nativePlugins.map((plugin) => plugin.id));
|
|
114
|
+
//? A `native/<id>` folder ships by itself; listed again, by path or by id, it would reach the runtime twice.
|
|
115
|
+
const listed = (target.native?.plugins ?? []).filter((spec) =>
|
|
116
|
+
NativeConfig.#isId(spec) ? !folderIds.has(spec) : !folders.has(path.resolve(appPath, spec)),
|
|
117
|
+
);
|
|
81
118
|
const plugins = [
|
|
82
119
|
...new Set([
|
|
83
120
|
...NativeConfig.basePlugins,
|
|
121
|
+
//? Windows and Linux open a link by starting the app again; without the hand-over it runs as a second app and
|
|
122
|
+
//? the instance whose auth-session start() waits for the callback never hears it.
|
|
123
|
+
...(target.deepLinks?.schemes?.length ? ["single-instance"] : []),
|
|
84
124
|
...applied.flatMap((contribution) => contribution.plugins ?? []),
|
|
85
|
-
|
|
125
|
+
//? A second copy of the app would start a second server on the same data.
|
|
126
|
+
...(desktopServer ? ["single-instance"] : []),
|
|
127
|
+
...(updates ? ["updates"] : []),
|
|
128
|
+
...listed,
|
|
129
|
+
...nativePlugins.map((plugin) => plugin.dir),
|
|
86
130
|
]),
|
|
87
131
|
];
|
|
132
|
+
const pluginIds = plugins.map(
|
|
133
|
+
(spec) => nativePlugins.find((plugin) => plugin.dir === spec)?.id ?? NativeConfig.#pluginId(appPath, spec),
|
|
134
|
+
);
|
|
88
135
|
const usageDescriptions = Object.fromEntries(
|
|
89
136
|
Object.entries(
|
|
90
137
|
toIosInfoPlistUsageDescriptions(
|
|
@@ -119,9 +166,17 @@ export class NativeConfig {
|
|
|
119
166
|
const resources = Object.entries(target.files ?? {}).map(([to, from]) => ({ from: abs(from), to }));
|
|
120
167
|
const native = NativeConfig.#compact({ ios, android, resources });
|
|
121
168
|
const googleServices = target.native?.android?.googleServices;
|
|
169
|
+
const pushAndroid = target.native?.push?.android;
|
|
170
|
+
const desktop = NativeConfig.#compact<NonNullable<AkanNativeConfig["desktop"]>>({
|
|
171
|
+
recovery: target.native?.desktop?.recovery,
|
|
172
|
+
screenCapture: target.native?.desktop?.screenCapture,
|
|
173
|
+
window: NativeConfig.#compact({ ...target.native?.desktop?.window }),
|
|
174
|
+
server: desktopServer,
|
|
175
|
+
bin: desktopBin,
|
|
176
|
+
});
|
|
122
177
|
const config: AkanNativeConfig = {
|
|
123
178
|
app: {
|
|
124
|
-
id: target.appId,
|
|
179
|
+
id: resolveAppId(target.appId, platform),
|
|
125
180
|
name: target.appName,
|
|
126
181
|
fileName: target.fileName ?? NativeConfig.fileNameOf(path.basename(appPath)),
|
|
127
182
|
version: target.version,
|
|
@@ -135,7 +190,7 @@ export class NativeConfig {
|
|
|
135
190
|
{
|
|
136
191
|
identifier: "app",
|
|
137
192
|
description: `The plugins ${target.appName} ships, each with its default permissions`,
|
|
138
|
-
permissions:
|
|
193
|
+
permissions: pluginIds.map((id) => `${id}:default`),
|
|
139
194
|
},
|
|
140
195
|
],
|
|
141
196
|
...(Object.keys(usageDescriptions).length ? { usageDescriptions } : {}),
|
|
@@ -143,20 +198,74 @@ export class NativeConfig {
|
|
|
143
198
|
? { deepLinks: NativeConfig.#deepLinks(target, locales) }
|
|
144
199
|
: {}),
|
|
145
200
|
...(native ? { native } : {}),
|
|
201
|
+
...(updates ? { updates } : {}),
|
|
202
|
+
...(pushAndroid
|
|
203
|
+
? {
|
|
204
|
+
push: {
|
|
205
|
+
android: { ...pushAndroid, ...(pushAndroid.smallIcon ? { smallIcon: abs(pushAndroid.smallIcon) } : {}) },
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
: {}),
|
|
209
|
+
...(target.native?.privacy ? { privacy: target.native.privacy } : {}),
|
|
146
210
|
//? assetlinks.json vouches for `<appId>.debug` outside main, the suffix a debug build installs under.
|
|
147
|
-
android: {
|
|
211
|
+
android: {
|
|
212
|
+
debugAppIdSuffix: ".debug",
|
|
213
|
+
...(googleServices ? { googleServices: abs(googleServices) } : {}),
|
|
214
|
+
...(target.native?.android?.autoplay ? { autoplay: true } : {}),
|
|
215
|
+
},
|
|
148
216
|
keyboard: { resize: "none" },
|
|
149
|
-
...(
|
|
150
|
-
...(target.assets?.
|
|
217
|
+
...(desktop ? { desktop } : {}),
|
|
218
|
+
...(target.assets?.icon ? { icon: NativeConfig.#icon(target.assets.icon, abs) } : {}),
|
|
219
|
+
...(target.assets?.splash ? { splash: NativeConfig.#splash(target.assets.splash, abs) } : {}),
|
|
151
220
|
};
|
|
152
221
|
return { config, warnings };
|
|
153
222
|
}
|
|
154
223
|
|
|
224
|
+
static #icon(
|
|
225
|
+
icon: NonNullable<NonNullable<AkanMobileTargetConfig["assets"]>["icon"]>,
|
|
226
|
+
abs: (relative: string) => string,
|
|
227
|
+
): NonNullable<AkanNativeConfig["icon"]> {
|
|
228
|
+
return typeof icon === "string" ? abs(icon) : { ...icon, image: abs(icon.image) };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
static #splash(
|
|
232
|
+
splash: NonNullable<NonNullable<AkanMobileTargetConfig["assets"]>["splash"]>,
|
|
233
|
+
abs: (relative: string) => string,
|
|
234
|
+
): NonNullable<AkanNativeConfig["splash"]> {
|
|
235
|
+
if (typeof splash === "string") return { image: abs(splash) };
|
|
236
|
+
return { ...splash, ...(splash.image ? { image: abs(splash.image) } : {}) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
//? akanConfig refuses a target whose merged updates lack url or publicKey; the type still has them optional.
|
|
240
|
+
static #updates(
|
|
241
|
+
target: AkanMobileTargetConfig,
|
|
242
|
+
env?: MobileEnv,
|
|
243
|
+
): NonNullable<AkanNativeConfig["updates"]> | undefined {
|
|
244
|
+
const { url, publicKey, channel = env, ...rest } = target.updates ?? {};
|
|
245
|
+
return url && publicKey ? { ...rest, url, publicKey, ...(channel ? { channel } : {}) } : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
155
248
|
/** Letters, digits, `.`, `_` and `-` only; the folder name of an akan app already is one almost always. */
|
|
156
249
|
static fileNameOf(name: string) {
|
|
157
250
|
return name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[-.]+|[-.]+$/g, "") || "app";
|
|
158
251
|
}
|
|
159
252
|
|
|
253
|
+
//? A permission names a plugin by its id, and a plugin given by folder is named in its manifest; a spec the runtime
|
|
254
|
+
//? cannot read is left as it is for the runtime to report.
|
|
255
|
+
static #pluginId(appPath: string, spec: string) {
|
|
256
|
+
if (NativeConfig.#isId(spec)) return spec;
|
|
257
|
+
try {
|
|
258
|
+
const { id } = JSON.parse(readFileSync(path.join(path.resolve(appPath, spec), "native-plugin.json"), "utf8"));
|
|
259
|
+
return typeof id === "string" ? id : spec;
|
|
260
|
+
} catch {
|
|
261
|
+
return spec;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
static #isId(spec: string) {
|
|
266
|
+
return !spec.startsWith(".") && !path.isAbsolute(spec);
|
|
267
|
+
}
|
|
268
|
+
|
|
160
269
|
/** Drops empty arrays and objects, and answers undefined when nothing is left. */
|
|
161
270
|
static #compact<T extends object>(value: { [key: string]: unknown }): T | undefined {
|
|
162
271
|
const kept = Object.entries(value).filter(([, entry]) =>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { tempDirs } from "../testHelpers";
|
|
6
|
+
import { NativePluginFolders } from "./nativePluginFolders";
|
|
7
|
+
|
|
8
|
+
const makeTempRoot = tempDirs("akan-native-plugin-folders-");
|
|
9
|
+
|
|
10
|
+
const writePlugin = async (root: string, folder: string, id: unknown = folder) => {
|
|
11
|
+
const dir = path.join(root, "native", folder);
|
|
12
|
+
await mkdir(dir, { recursive: true });
|
|
13
|
+
await writeFile(path.join(dir, "native-plugin.json"), JSON.stringify({ id, apiVersion: 1, methods: [] }));
|
|
14
|
+
return dir;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const fakeApp = (root: string, libs: string[]) =>
|
|
18
|
+
({
|
|
19
|
+
name: "board",
|
|
20
|
+
cwdPath: path.join(root, "apps", "board"),
|
|
21
|
+
workspace: { workspaceRoot: root },
|
|
22
|
+
getScanInfo: () => ({ getLibs: () => libs }),
|
|
23
|
+
}) as unknown as App;
|
|
24
|
+
|
|
25
|
+
describe("NativePluginFolders", () => {
|
|
26
|
+
test("finds each plugin folder in native/, by name", async () => {
|
|
27
|
+
const root = await makeTempRoot();
|
|
28
|
+
const kiosk = await writePlugin(root, "kiosk");
|
|
29
|
+
const audio = await writePlugin(root, "audio-output");
|
|
30
|
+
await writeFile(path.join(root, "native", "README.md"), "notes");
|
|
31
|
+
|
|
32
|
+
expect(await NativePluginFolders.in(root, "apps/board")).toEqual([
|
|
33
|
+
{ id: "audio-output", dir: audio, owner: "apps/board" },
|
|
34
|
+
{ id: "kiosk", dir: kiosk, owner: "apps/board" },
|
|
35
|
+
]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("answers nothing for an app with no native folder", async () => {
|
|
39
|
+
expect(await NativePluginFolders.in(await makeTempRoot(), "apps/board")).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("refuses a folder without a manifest, or one named apart from its id", async () => {
|
|
43
|
+
const root = await makeTempRoot();
|
|
44
|
+
await mkdir(path.join(root, "native", "helpers"), { recursive: true });
|
|
45
|
+
await expect(NativePluginFolders.in(root, "apps/board")).rejects.toThrow(
|
|
46
|
+
"apps/board/native/helpers has no native-plugin.json",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const other = await makeTempRoot();
|
|
50
|
+
await writePlugin(other, "kiosk-tools", "kiosk");
|
|
51
|
+
await expect(NativePluginFolders.in(other, "libs/kiosk")).rejects.toThrow(
|
|
52
|
+
'libs/kiosk/native/kiosk-tools/native-plugin.json has id "kiosk"; name the folder after its id.',
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("the app's own plugin wins an id, and two libs may not both hold one", () => {
|
|
57
|
+
const app = { id: "kiosk", dir: "/w/apps/board/native/kiosk", owner: "apps/board" };
|
|
58
|
+
const libKiosk = { id: "kiosk", dir: "/w/libs/signage/native/kiosk", owner: "libs/signage" };
|
|
59
|
+
const libAudio = { id: "audio", dir: "/w/libs/signage/native/audio", owner: "libs/signage" };
|
|
60
|
+
|
|
61
|
+
expect(NativePluginFolders.select([app], [libKiosk, libAudio])).toEqual([app, libAudio]);
|
|
62
|
+
expect(() =>
|
|
63
|
+
NativePluginFolders.select(
|
|
64
|
+
[],
|
|
65
|
+
[libKiosk, { ...libKiosk, dir: "/w/libs/venue/native/kiosk", owner: "libs/venue" }],
|
|
66
|
+
),
|
|
67
|
+
).toThrow('libs/signage and libs/venue both hold native plugin "kiosk"; give the app its own native/kiosk');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("an app ships its own plugins and those of the libs it depends on, not of other libs", async () => {
|
|
71
|
+
const root = await makeTempRoot();
|
|
72
|
+
const own = await writePlugin(path.join(root, "apps", "board"), "kiosk");
|
|
73
|
+
const used = await writePlugin(path.join(root, "libs", "signage"), "audio");
|
|
74
|
+
await writePlugin(path.join(root, "libs", "unused"), "camera-rig");
|
|
75
|
+
|
|
76
|
+
expect(await NativePluginFolders.of(fakeApp(root, ["signage"]))).toEqual([
|
|
77
|
+
{ id: "kiosk", dir: own, owner: "apps/board" },
|
|
78
|
+
{ id: "audio", dir: used, owner: "libs/signage" },
|
|
79
|
+
]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { App } from "../commandDecorators";
|
|
4
|
+
|
|
5
|
+
export interface NativePluginFolder {
|
|
6
|
+
id: string;
|
|
7
|
+
/** Absolute: the native build takes a plugin that is not builtin by its folder. */
|
|
8
|
+
dir: string;
|
|
9
|
+
/** Whose `native/` holds it: `apps/<app>` or `libs/<lib>`. */
|
|
10
|
+
owner: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
//* An app's and its libs' own native plugins, each in `native/<id>/` beside its `native-plugin.json`. Every mobile and
|
|
14
|
+
//* desktop target of the app ships them (the manifest says per platform what runs there); a lib's reach only the apps
|
|
15
|
+
//* that depend on it.
|
|
16
|
+
export class NativePluginFolders {
|
|
17
|
+
static readonly folder = "native";
|
|
18
|
+
|
|
19
|
+
static async in(root: string, owner: string): Promise<NativePluginFolder[]> {
|
|
20
|
+
const base = path.join(root, NativePluginFolders.folder);
|
|
21
|
+
const entries = await readdir(base, { withFileTypes: true }).catch(() => []);
|
|
22
|
+
const folders = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
23
|
+
return await Promise.all(
|
|
24
|
+
folders.sort().map(async (name) => {
|
|
25
|
+
const where = `${owner}/${NativePluginFolders.folder}/${name}`;
|
|
26
|
+
const manifest = Bun.file(path.join(base, name, "native-plugin.json"));
|
|
27
|
+
if (!(await manifest.exists()))
|
|
28
|
+
throw new Error(`${where} has no native-plugin.json; a folder in native/ is one native plugin.`);
|
|
29
|
+
const { id } = (await manifest.json()) as { id?: unknown };
|
|
30
|
+
if (id !== name)
|
|
31
|
+
throw new Error(`${where}/native-plugin.json has id ${JSON.stringify(id)}; name the folder after its id.`);
|
|
32
|
+
return { id, dir: path.join(base, name), owner };
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The app's own plugin wins an id; two libs may not both claim one. */
|
|
38
|
+
static select(own: NativePluginFolder[], fromLibs: NativePluginFolder[]): NativePluginFolder[] {
|
|
39
|
+
const chosen = new Map(own.map((plugin) => [plugin.id, plugin]));
|
|
40
|
+
for (const plugin of fromLibs) {
|
|
41
|
+
const held = chosen.get(plugin.id);
|
|
42
|
+
if (!held) chosen.set(plugin.id, plugin);
|
|
43
|
+
else if (held.owner.startsWith("libs/"))
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${held.owner} and ${plugin.owner} both hold native plugin "${plugin.id}"; give the app its own native/${plugin.id} to pick one.`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return [...chosen.values()];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static async of(app: App): Promise<NativePluginFolder[]> {
|
|
52
|
+
const scanInfo = app.getScanInfo({ allowEmpty: true }) ?? (await app.scan({ write: false }));
|
|
53
|
+
const root = app.workspace.workspaceRoot;
|
|
54
|
+
const [own, ...fromLibs] = await Promise.all([
|
|
55
|
+
NativePluginFolders.in(app.cwdPath, `apps/${app.name}`),
|
|
56
|
+
...scanInfo.getLibs().map((lib) => NativePluginFolders.in(path.join(root, "libs", lib), `libs/${lib}`)),
|
|
57
|
+
]);
|
|
58
|
+
return NativePluginFolders.select(own ?? [], fromLibs.flat());
|
|
59
|
+
}
|
|
60
|
+
}
|
package/mobile/nativeWebDir.ts
CHANGED
|
@@ -34,12 +34,16 @@ export class NativeWebDir {
|
|
|
34
34
|
}
|
|
35
35
|
static readonly #targetTag = "<script>window.__AKAN_MOBILE_TARGET__=";
|
|
36
36
|
|
|
37
|
+
async clear() {
|
|
38
|
+
await rm(this.dir, { recursive: true, force: true });
|
|
39
|
+
await mkdir(this.dir, { recursive: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
/** Rebuilds the folder from scratch and answers its files as web paths: relative, `/`-separated and sorted. */
|
|
38
43
|
async assemble(target: AkanMobileTargetConfig, { html, publicDir, fontsDir }: NativeWebDirSources) {
|
|
39
44
|
if (!(await Bun.file(html).exists()))
|
|
40
45
|
throw new Error(`CSR html for mobile target '${target.name}' not found: ${html}`);
|
|
41
|
-
await
|
|
42
|
-
await mkdir(this.dir, { recursive: true });
|
|
46
|
+
await this.clear();
|
|
43
47
|
if (publicDir && (await NativeWebDir.#isDir(publicDir))) await cp(publicDir, this.dir, { recursive: true });
|
|
44
48
|
if (fontsDir && (await NativeWebDir.#isDir(fontsDir)))
|
|
45
49
|
await cp(fontsDir, path.join(this.dir, "_akan", "fonts"), { recursive: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.29",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"@earendil-works/pi-tui": "0.86.1",
|
|
48
48
|
"@inquirer/prompts": "^8.4.3",
|
|
49
49
|
"@tailwindcss/node": "^4.3.0",
|
|
50
|
-
"akanjs": "3.0.0-beta.
|
|
50
|
+
"akanjs": "3.0.0-beta.29",
|
|
51
51
|
"chalk": "^5.6.2",
|
|
52
52
|
"commander": "^14.0.3",
|
|
53
53
|
"dayjs": "^1.11.20",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { tempDirs, writeText as write } from "../testHelpers";
|
|
4
|
+
import { createServerEnvPlugin } from "./serverEnvPlugin";
|
|
5
|
+
|
|
6
|
+
const makeTempRoot = tempDirs("akan-devkit-server-env-");
|
|
7
|
+
const environments = ["local", "testing", "debug", "develop", "main", "qa"];
|
|
8
|
+
|
|
9
|
+
const writeApp = async (root: string) => {
|
|
10
|
+
const envDir = path.join(root, "apps/demo/env");
|
|
11
|
+
await write(
|
|
12
|
+
path.join(envDir, "env.server.ts"),
|
|
13
|
+
[
|
|
14
|
+
...environments.map((name) => `import { env as ${name} } from "./env.server.${name}";`),
|
|
15
|
+
`const envConfigs = { ${environments.join(", ")} };`,
|
|
16
|
+
'const currentEnv = process.env.AKAN_PUBLIC_ENV ?? "local";',
|
|
17
|
+
"export const env = envConfigs[currentEnv as keyof typeof envConfigs];",
|
|
18
|
+
"",
|
|
19
|
+
].join("\n"),
|
|
20
|
+
);
|
|
21
|
+
await write(path.join(envDir, "env.server.type.ts"), 'export const libEnv = { shared: "shared-value" };\n');
|
|
22
|
+
for (const name of environments)
|
|
23
|
+
await write(
|
|
24
|
+
path.join(envDir, `env.server.${name}.ts`),
|
|
25
|
+
[
|
|
26
|
+
'import { libEnv } from "./env.server.type";',
|
|
27
|
+
`export const env = { ...libEnv, secret: "${name}-secret" };`,
|
|
28
|
+
`export default "${name}-default";`,
|
|
29
|
+
"",
|
|
30
|
+
].join("\n"),
|
|
31
|
+
);
|
|
32
|
+
const entry = path.join(root, "apps/demo/server.ts");
|
|
33
|
+
await write(entry, 'import { env } from "./env/env.server";\nconsole.info(JSON.stringify({ ...env }));\n');
|
|
34
|
+
return { envDir, entry };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const build = async (root: string, environment: string) => {
|
|
38
|
+
const { envDir, entry } = await writeApp(root);
|
|
39
|
+
const result = await Bun.build({
|
|
40
|
+
entrypoints: [entry],
|
|
41
|
+
outdir: path.join(root, "out"),
|
|
42
|
+
target: "bun",
|
|
43
|
+
plugins: [createServerEnvPlugin({ envDir, environment, environments })],
|
|
44
|
+
});
|
|
45
|
+
expect(result.success).toBe(true);
|
|
46
|
+
const output = result.outputs.find((artifact) => artifact.kind === "entry-point");
|
|
47
|
+
if (!output) throw new Error("no entry output");
|
|
48
|
+
return { file: output.path, text: await output.text() };
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const run = (file: string, environment: string) =>
|
|
52
|
+
Bun.spawnSync([process.execPath, file], { env: { ...process.env, AKAN_PUBLIC_ENV: environment } });
|
|
53
|
+
|
|
54
|
+
describe("createServerEnvPlugin", () => {
|
|
55
|
+
test("bundles only the environment the image runs", async () => {
|
|
56
|
+
const { text } = await build(await makeTempRoot(), "main");
|
|
57
|
+
expect(text).toContain("main-secret");
|
|
58
|
+
expect(text).toContain("shared-value");
|
|
59
|
+
for (const name of environments.filter((name) => name !== "main")) {
|
|
60
|
+
expect(text).not.toContain(`${name}-secret`);
|
|
61
|
+
expect(text).not.toContain(`${name}-default`);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the built environment boots with its own values", async () => {
|
|
66
|
+
const { file } = await build(await makeTempRoot(), "main");
|
|
67
|
+
const proc = run(file, "main");
|
|
68
|
+
expect(proc.exitCode).toBe(0);
|
|
69
|
+
expect(JSON.parse(proc.stdout.toString())).toEqual({ shared: "shared-value", secret: "main-secret" });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("another environment refuses to boot and names the build to make", async () => {
|
|
73
|
+
const { file } = await build(await makeTempRoot(), "main");
|
|
74
|
+
const proc = run(file, "develop");
|
|
75
|
+
expect(proc.exitCode).not.toBe(0);
|
|
76
|
+
expect(proc.stderr.toString()).toContain(
|
|
77
|
+
"env/env.server.develop.ts is not in this build: `akan build` bundles the server env of AKAN_PUBLIC_ENV=main alone",
|
|
78
|
+
);
|
|
79
|
+
expect(proc.stderr.toString()).toContain(
|
|
80
|
+
"To run develop, build for it: AKAN_PUBLIC_ENV=develop in the workspace's .env, which outranks the shell, or --env develop on a desktop or mobile build.",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("a custom branch is its own environment", async () => {
|
|
85
|
+
const { text } = await build(await makeTempRoot(), "qa");
|
|
86
|
+
expect(text).toContain("qa-secret");
|
|
87
|
+
expect(text).not.toContain("main-secret");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("leaves env files outside the app's env directory alone", async () => {
|
|
91
|
+
const root = await makeTempRoot();
|
|
92
|
+
const other = path.join(root, "libs/shared/env/env.server.testing.ts");
|
|
93
|
+
await write(other, 'export const env = { secret: "lib-testing-secret" };\n');
|
|
94
|
+
const { envDir, entry } = await writeApp(root);
|
|
95
|
+
await write(entry, `import { env } from ${JSON.stringify(other)};\nconsole.info(env.secret);\n`);
|
|
96
|
+
const result = await Bun.build({
|
|
97
|
+
entrypoints: [entry],
|
|
98
|
+
target: "bun",
|
|
99
|
+
plugins: [createServerEnvPlugin({ envDir, environment: "main", environments })],
|
|
100
|
+
});
|
|
101
|
+
expect(await result.outputs[0]?.text()).toContain("lib-testing-secret");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { BunPlugin } from "bun";
|
|
4
|
+
|
|
5
|
+
export interface ServerEnvPluginOptions {
|
|
6
|
+
envDir: string;
|
|
7
|
+
/** The environment the image runs: its Dockerfile fixes `AKAN_PUBLIC_ENV` to it. */
|
|
8
|
+
environment: string;
|
|
9
|
+
environments: Iterable<string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
13
|
+
|
|
14
|
+
//* `env.server.ts` imports every environment's file, so an image would hand out the secrets of each environment it
|
|
15
|
+
//* never runs. Every file but the image's own is swapped for exports that refuse to be read.
|
|
16
|
+
export const createServerEnvPlugin = ({ envDir, environment, environments }: ServerEnvPluginOptions): BunPlugin => {
|
|
17
|
+
const others = [...new Set(environments)].filter((name) => name !== environment);
|
|
18
|
+
//? Bun hands onLoad the real path, which differs from the given one under a symlink (macOS's /var -> /private/var).
|
|
19
|
+
const dirs = new Set([path.resolve(envDir), existsSync(envDir) ? realpathSync.native(envDir) : envDir]);
|
|
20
|
+
const names = others.map(escapeRegExp).join("|");
|
|
21
|
+
const filter = new RegExp(`^(?:${[...dirs].map(escapeRegExp).join("|")})[\\\\/]env\\.server\\.(?:${names})\\.ts$`);
|
|
22
|
+
return {
|
|
23
|
+
name: "akan-server-env",
|
|
24
|
+
setup(build) {
|
|
25
|
+
if (!others.length) return;
|
|
26
|
+
build.onLoad({ filter }, async (args) => {
|
|
27
|
+
const name = path.basename(args.path, ".ts").slice("env.server.".length);
|
|
28
|
+
const { exports } = new Bun.Transpiler({ loader: "ts" }).scan(await Bun.file(args.path).text());
|
|
29
|
+
const message = `env/env.server.${name}.ts is not in this build: \`akan build\` bundles the server env of AKAN_PUBLIC_ENV=${environment} alone, the environment its image runs. To run ${name}, build for it: AKAN_PUBLIC_ENV=${name} in the workspace's .env, which outranks the shell, or --env ${name} on a desktop or mobile build.`;
|
|
30
|
+
return {
|
|
31
|
+
loader: "js",
|
|
32
|
+
contents: [
|
|
33
|
+
`const refuse = () => { throw new Error(${JSON.stringify(message)}); };`,
|
|
34
|
+
"const unbuilt = new Proxy({}, { get: refuse, has: refuse, ownKeys: refuse, getOwnPropertyDescriptor: refuse });",
|
|
35
|
+
`export { ${exports.map((exported) => `unbuilt as ${JSON.stringify(exported)}`).join(", ")} };`,
|
|
36
|
+
].join("\n"),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
};
|
package/types.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface PackageJson {
|
|
|
10
10
|
peerDependencies?: Record<string, string>;
|
|
11
11
|
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
|
12
12
|
optionalDependencies?: Record<string, string>;
|
|
13
|
+
trustedDependencies?: string[];
|
|
13
14
|
overrides?: Record<string, string>;
|
|
14
15
|
engines?: Record<string, string>;
|
|
15
16
|
exports?: Record<string, string | Record<string, string>>;
|
package/workspaceLayout.test.ts
CHANGED
|
@@ -21,6 +21,11 @@ describe("app root layout allowlist", () => {
|
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
test("admits the native plugins an app owns, and a lib too", () => {
|
|
25
|
+
expect(appRootAllowedDirs.has("native")).toBe(true);
|
|
26
|
+
expect(libRootAllowedDirs.has("native")).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
|
|
24
29
|
test("rejects an app root entry no facet owns", () => {
|
|
25
30
|
expect(appRootAllowedFiles.has("helper.ts")).toBe(false);
|
|
26
31
|
expect(appRootAllowedDirs.has("base")).toBe(false);
|
package/workspaceLayout.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const appRootAllowedDirs = new Set([
|
|
|
21
21
|
"common",
|
|
22
22
|
"env",
|
|
23
23
|
"lib",
|
|
24
|
+
"native",
|
|
24
25
|
"page",
|
|
25
26
|
"plugin",
|
|
26
27
|
"private",
|
|
@@ -52,6 +53,7 @@ export const libRootAllowedDirs = new Set([
|
|
|
52
53
|
"common",
|
|
53
54
|
"env",
|
|
54
55
|
"lib",
|
|
56
|
+
"native",
|
|
55
57
|
"page",
|
|
56
58
|
"plugin",
|
|
57
59
|
"private",
|