@akanjs/devkit 3.0.0-beta.26 → 3.0.0-beta.28
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.ts +22 -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/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrPageStack.e2e.test.ts +65 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/executors.test.ts +43 -0
- package/executors.ts +17 -2
- package/frontendBuild/csrDevArtifactWriter.test.ts +8 -0
- package/frontendBuild/csrDevArtifactWriter.ts +4 -0
- package/frontendBuild/csrDevBundler.ts +1 -1
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +210 -6
- package/frontendBuild/csrDevModuleCompiler.ts +133 -19
- package/frontendBuild/csrDevPaths.ts +1 -0
- package/frontendBuild/csrDevResolver.ts +89 -9
- package/frontendBuild/ssrBaseArtifactBuilder.ts +7 -2
- package/frontendBuild/ssrDevBundler.ts +1 -1
- 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 +129 -0
- package/mobile/desktopServerStage.ts +120 -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 +37 -1
- package/mobile/nativeApp.ts +176 -27
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -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/package.json +2 -2
- package/transforms/serverEnvPlugin.test.ts +100 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/types.ts +1 -0
- package/workspaceLayout.test.ts +5 -0
- package/workspaceLayout.ts +2 -0
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -8,9 +8,11 @@ import type { AkanImageConfig } from "akanjs/server";
|
|
|
8
8
|
import type { App, Lib } from "../commandDecorators";
|
|
9
9
|
import { LibExecutor, WorkspaceExecutor } from "../executors";
|
|
10
10
|
import type { BaseDevEnv, PackageJson } from "../types";
|
|
11
|
+
import { AkanBin } from "./akanBin";
|
|
11
12
|
import {
|
|
12
13
|
type AkanApiConfig,
|
|
13
14
|
type AkanAssetsConfig,
|
|
15
|
+
type AkanBinConfig,
|
|
14
16
|
type AkanDatabaseConfig,
|
|
15
17
|
type AkanMobileConfig,
|
|
16
18
|
type AkanMobileTargetConfig,
|
|
@@ -107,13 +109,19 @@ const normalizeDeepLinks = (deepLinks: DeepPartial<AkanMobileTargetConfig["deepL
|
|
|
107
109
|
} satisfies AkanMobileTargetConfig["deepLinks"];
|
|
108
110
|
};
|
|
109
111
|
|
|
110
|
-
type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web"> & {
|
|
112
|
+
type AppConfigDeclaration = Omit<DeepPartial<AppConfigResult>, "docker" | "web" | "bin"> & {
|
|
111
113
|
docker?: DockerOption;
|
|
112
114
|
web?: AkanWebOption;
|
|
115
|
+
bin?: AkanBinConfig;
|
|
113
116
|
};
|
|
114
117
|
|
|
118
|
+
type LibConfigDeclaration = Omit<DeepPartial<LibConfigResult>, "bin"> & { bin?: AkanBinConfig };
|
|
119
|
+
|
|
115
120
|
export interface LibContributions {
|
|
116
121
|
externalLibs: string[];
|
|
122
|
+
trustedDependencies?: string[];
|
|
123
|
+
/** Per lib, since an app carries only the entries of the libs it depends on. */
|
|
124
|
+
bin?: { lib: string; bin: AkanBinConfig }[];
|
|
117
125
|
docker: LibDockerConfig;
|
|
118
126
|
/** Keep globs rewritten to the app's own `public/`, where `akan sync` mounts each lib's assets. */
|
|
119
127
|
keepFonts?: string[];
|
|
@@ -121,10 +129,19 @@ export interface LibContributions {
|
|
|
121
129
|
|
|
122
130
|
const emptyLibContributions = (): LibContributions => ({
|
|
123
131
|
externalLibs: [],
|
|
132
|
+
trustedDependencies: [],
|
|
133
|
+
bin: [],
|
|
124
134
|
docker: { preRuns: [], postRuns: [] },
|
|
125
135
|
keepFonts: [],
|
|
126
136
|
});
|
|
127
137
|
|
|
138
|
+
const normalizePackageNames = (names: unknown, owner: string): string[] => {
|
|
139
|
+
if (names === undefined) return [];
|
|
140
|
+
if (!Array.isArray(names) || names.some((name) => typeof name !== "string" || !name.trim()))
|
|
141
|
+
throw new Error(`${owner}: trustedDependencies lists package names`);
|
|
142
|
+
return [...new Set(names.map((name: string) => name.trim()))];
|
|
143
|
+
};
|
|
144
|
+
|
|
128
145
|
const normalizeKeepFonts = (keepFonts: string[] | undefined) => [
|
|
129
146
|
...new Set((keepFonts ?? []).map((glob) => glob.trim().replace(/^\/+/, "")).filter(Boolean)),
|
|
130
147
|
];
|
|
@@ -145,6 +162,10 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
145
162
|
database: AkanDatabaseConfig;
|
|
146
163
|
web: AkanWebConfig;
|
|
147
164
|
externalLibs: string[];
|
|
165
|
+
trustedDependencies: string[];
|
|
166
|
+
/** The app's own entries; the libs' are in `libBins`. */
|
|
167
|
+
bin: AkanBinConfig;
|
|
168
|
+
libBins: { lib: string; bin: AkanBinConfig }[];
|
|
148
169
|
barrelImports: string[];
|
|
149
170
|
optimizeImports: string[];
|
|
150
171
|
images: AkanImageConfig;
|
|
@@ -183,6 +204,15 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
183
204
|
this.#applyRoutes(config?.routes);
|
|
184
205
|
this.database = AkanAppConfig.#database(app, config);
|
|
185
206
|
this.externalLibs = [...new Set([...(config?.externalLibs ?? []), ...libContributions.externalLibs])];
|
|
207
|
+
const owner = `apps/${app.name}/akan.config.ts`;
|
|
208
|
+
this.trustedDependencies = [
|
|
209
|
+
...new Set([
|
|
210
|
+
...normalizePackageNames(config?.trustedDependencies, owner),
|
|
211
|
+
...(libContributions.trustedDependencies ?? []),
|
|
212
|
+
]),
|
|
213
|
+
];
|
|
214
|
+
this.bin = AkanBin.parse(config?.bin, owner, app.cwdPath);
|
|
215
|
+
this.libBins = libContributions.bin ?? [];
|
|
186
216
|
this.barrelImports = [
|
|
187
217
|
...DEFAULT_BARREL_IMPORTS,
|
|
188
218
|
...WORKSPACE_BARREL_FACETS.map((facet) => `@apps/${app.name}/${facet}`),
|
|
@@ -239,6 +269,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
239
269
|
const buildNum = rawMobile.buildNum ?? 1;
|
|
240
270
|
const files = AkanAppConfig.#resolveMobileFiles(rawMobile.files as AkanMobileConfig["files"], "mobile", configPath);
|
|
241
271
|
const native = rawMobile.native as AkanMobileConfig["native"];
|
|
272
|
+
const updates = rawMobile.updates as AkanMobileConfig["updates"];
|
|
242
273
|
const defaultTargetName = this.#defaultMobileTargetName(rawTargets);
|
|
243
274
|
const targetEntries = Object.entries(
|
|
244
275
|
rawTargets ?? {
|
|
@@ -263,6 +294,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
263
294
|
configPath,
|
|
264
295
|
);
|
|
265
296
|
const fileName = (target.fileName ?? rawMobile.fileName) as string | undefined;
|
|
297
|
+
const targetUpdates = updates || target.updates ? { ...updates, ...target.updates } : undefined;
|
|
298
|
+
if (targetUpdates && (!targetUpdates.url || !targetUpdates.publicKey))
|
|
299
|
+
throw new Error(
|
|
300
|
+
`${where}.updates in ${configPath} has no url or publicKey; give them in mobile.updates or the target's own.`,
|
|
301
|
+
);
|
|
266
302
|
const resolved = {
|
|
267
303
|
name,
|
|
268
304
|
basePath,
|
|
@@ -279,6 +315,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
279
315
|
...(native || target.native
|
|
280
316
|
? { native: AkanAppConfig.#mergeNative(native, target.native as AkanMobileTargetConfig["native"]) }
|
|
281
317
|
: {}),
|
|
318
|
+
...(targetUpdates ? { updates: targetUpdates } : {}),
|
|
282
319
|
} satisfies AkanMobileTargetConfig;
|
|
283
320
|
return [name, resolved];
|
|
284
321
|
}),
|
|
@@ -291,10 +328,11 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
291
328
|
buildNum,
|
|
292
329
|
...(files ? { files } : {}),
|
|
293
330
|
...(native ? { native } : {}),
|
|
331
|
+
...(updates ? { updates } : {}),
|
|
294
332
|
targets,
|
|
295
333
|
};
|
|
296
334
|
}
|
|
297
|
-
static readonly #mobileKeys = ["appName", "appId", "fileName", "version", "buildNum", "files", "native"];
|
|
335
|
+
static readonly #mobileKeys = ["appName", "appId", "fileName", "version", "buildNum", "files", "native", "updates"];
|
|
298
336
|
static readonly #targetKeys = [
|
|
299
337
|
"basePath",
|
|
300
338
|
"indexPath",
|
|
@@ -308,6 +346,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
308
346
|
"deepLinks",
|
|
309
347
|
"files",
|
|
310
348
|
"native",
|
|
349
|
+
"updates",
|
|
311
350
|
];
|
|
312
351
|
//* The Capacitor keys used to pass straight through; the native runtime takes typed ones, so a leftover is refused.
|
|
313
352
|
static #assertMobileKeys(section: object, known: string[], where: string, configPath: string) {
|
|
@@ -350,10 +389,21 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
350
389
|
if (merged.length) android[key] = merged;
|
|
351
390
|
else delete android[key];
|
|
352
391
|
}
|
|
392
|
+
const pushAndroid = { ...base?.push?.android, ...override?.push?.android };
|
|
393
|
+
const privacy = { ...base?.privacy, ...override?.privacy };
|
|
394
|
+
const window = { ...base?.desktop?.window, ...override?.desktop?.window };
|
|
395
|
+
const desktop = {
|
|
396
|
+
...base?.desktop,
|
|
397
|
+
...override?.desktop,
|
|
398
|
+
...(Object.keys(window).length ? { window } : {}),
|
|
399
|
+
};
|
|
353
400
|
return {
|
|
354
401
|
...(plugins.length ? { plugins } : {}),
|
|
355
402
|
...(Object.keys(ios).length ? { ios } : {}),
|
|
356
403
|
...(Object.keys(android).length ? { android } : {}),
|
|
404
|
+
...(Object.keys(pushAndroid).length ? { push: { android: pushAndroid } } : {}),
|
|
405
|
+
...(Object.keys(privacy).length ? { privacy } : {}),
|
|
406
|
+
...(Object.keys(desktop).length ? { desktop } : {}),
|
|
357
407
|
};
|
|
358
408
|
}
|
|
359
409
|
#defaultMobileTargetName(rawTargets: DeepPartial<AkanMobileConfig>["targets"] | undefined) {
|
|
@@ -405,18 +455,39 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
405
455
|
command: docker?.command ?? ["bun", "main.js"],
|
|
406
456
|
};
|
|
407
457
|
}
|
|
408
|
-
|
|
458
|
+
/** What the built server runs with in its image; a desktop app's carried server starts from the same values. */
|
|
459
|
+
getProductionEnv(environment: string = this.baseDevEnv.env): Record<string, string> {
|
|
460
|
+
return {
|
|
461
|
+
PORT: "8282",
|
|
462
|
+
NODE_ENV: "production",
|
|
463
|
+
AKAN_PUBLIC_REPO_NAME: this.baseDevEnv.repoName,
|
|
464
|
+
AKAN_PUBLIC_SERVE_DOMAIN: this.baseDevEnv.serveDomain,
|
|
465
|
+
AKAN_PUBLIC_APP_NAME: this.app.name,
|
|
466
|
+
AKAN_PUBLIC_ENV: environment,
|
|
467
|
+
...(this.basePaths.size ? { AKAN_PUBLIC_BASE_PATHS: [...this.basePaths].join(",") } : {}),
|
|
468
|
+
AKAN_PUBLIC_DEFAULT_LOCALE: this.i18n.defaultLocale,
|
|
469
|
+
AKAN_PUBLIC_LOCALES: this.i18n.locales.join(","),
|
|
470
|
+
AKAN_PUBLIC_API_PREFIX: this.api.prefix,
|
|
471
|
+
AKAN_PUBLIC_WS_PREFIX: this.api.websocketPrefix,
|
|
472
|
+
AKAN_PUBLIC_OPERATION_MODE: "cloud",
|
|
473
|
+
AKAN_DATABASE_MODES: this.database.modes.join(","),
|
|
474
|
+
// File logging is off: a container's writable layer is ephemeral and stdout is the collection path.
|
|
475
|
+
AKAN_LOG_TO_FILE: "0",
|
|
476
|
+
// The web env matches what was built (a deployment can only narrow it).
|
|
477
|
+
...(this.web.ssr ? {} : { AKAN_SSR: "false" }),
|
|
478
|
+
...(this.web.csr ? {} : { AKAN_CSR: "false" }),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
dockerfileFor(environment: string): string {
|
|
482
|
+
return this.#makeDockerfile(environment);
|
|
483
|
+
}
|
|
484
|
+
#makeDockerfile(environment?: string): string {
|
|
409
485
|
if (typeof this.docker === "string") return this.docker;
|
|
410
486
|
const { image, preRuns, postRuns, command } = this.docker;
|
|
411
487
|
const preRunScripts = this.#getDockerRunScripts(preRuns);
|
|
412
488
|
const postRunScripts = this.#getDockerRunScripts(postRuns);
|
|
413
489
|
const imageScript = this.#getDockerImageScript(image, DEFAULT_DOCKER_IMAGE);
|
|
414
|
-
|
|
415
|
-
// writable layer is ephemeral and stdout is the collection path.
|
|
416
|
-
const webEnvLines = [
|
|
417
|
-
...(this.web.ssr ? [] : ["ENV AKAN_SSR=false"]),
|
|
418
|
-
...(this.web.csr ? [] : ["ENV AKAN_CSR=false"]),
|
|
419
|
-
].join("\n");
|
|
490
|
+
const envLines = Object.entries(this.getProductionEnv(environment)).map(([key, value]) => `ENV ${key}=${value}`);
|
|
420
491
|
return `${imageScript}
|
|
421
492
|
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*
|
|
422
493
|
RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime
|
|
@@ -428,21 +499,7 @@ COPY ./package.json ./package.json
|
|
|
428
499
|
RUN bun install --production
|
|
429
500
|
${postRunScripts.join("\n")}
|
|
430
501
|
COPY . .
|
|
431
|
-
|
|
432
|
-
ENV NODE_ENV=production
|
|
433
|
-
ENV AKAN_PUBLIC_REPO_NAME=${this.baseDevEnv.repoName}
|
|
434
|
-
ENV AKAN_PUBLIC_SERVE_DOMAIN=${this.baseDevEnv.serveDomain}
|
|
435
|
-
ENV AKAN_PUBLIC_APP_NAME=${this.app.name}
|
|
436
|
-
ENV AKAN_PUBLIC_ENV=${this.baseDevEnv.env}
|
|
437
|
-
${this.basePaths.size ? `ENV AKAN_PUBLIC_BASE_PATHS=${[...this.basePaths].join(",")}` : ""}
|
|
438
|
-
ENV AKAN_PUBLIC_DEFAULT_LOCALE=${this.i18n.defaultLocale}
|
|
439
|
-
ENV AKAN_PUBLIC_LOCALES=${this.i18n.locales.join(",")}
|
|
440
|
-
ENV AKAN_PUBLIC_API_PREFIX=${this.api.prefix}
|
|
441
|
-
ENV AKAN_PUBLIC_WS_PREFIX=${this.api.websocketPrefix}
|
|
442
|
-
ENV AKAN_PUBLIC_OPERATION_MODE=cloud
|
|
443
|
-
ENV AKAN_DATABASE_MODES=${this.database.modes.join(",")}
|
|
444
|
-
ENV AKAN_LOG_TO_FILE=0
|
|
445
|
-
${webEnvLines}
|
|
502
|
+
${envLines.join("\n")}
|
|
446
503
|
CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
447
504
|
}
|
|
448
505
|
static #importGeneration = 0;
|
|
@@ -485,6 +542,10 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
485
542
|
);
|
|
486
543
|
return {
|
|
487
544
|
externalLibs: libConfigs.flatMap((libConfig) => libConfig?.externalLibs ?? []),
|
|
545
|
+
trustedDependencies: libConfigs.flatMap((libConfig) => libConfig?.trustedDependencies ?? []),
|
|
546
|
+
bin: libConfigs.flatMap((libConfig) =>
|
|
547
|
+
libConfig && Object.keys(libConfig.bin).length ? [{ lib: libConfig.lib.name, bin: libConfig.bin }] : [],
|
|
548
|
+
),
|
|
488
549
|
docker: {
|
|
489
550
|
preRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.preRuns ?? []),
|
|
490
551
|
postRuns: libConfigs.flatMap((libConfig) => libConfig?.docker.postRuns ?? []),
|
|
@@ -554,6 +615,7 @@ CMD [${command.map((c) => `"${c}"`).join(",")}]`;
|
|
|
554
615
|
this.#resolveProductionDependencyVersion(lib),
|
|
555
616
|
]),
|
|
556
617
|
),
|
|
618
|
+
...(this.trustedDependencies.length ? { trustedDependencies: this.trustedDependencies } : {}),
|
|
557
619
|
...data,
|
|
558
620
|
};
|
|
559
621
|
}
|
|
@@ -607,13 +669,18 @@ function mergeImageConfig(config: Partial<AkanImageConfig> = {}): AkanImageConfi
|
|
|
607
669
|
export class AkanLibConfig implements LibConfigResult {
|
|
608
670
|
lib: Lib;
|
|
609
671
|
externalLibs: string[];
|
|
672
|
+
trustedDependencies: string[];
|
|
673
|
+
bin: AkanBinConfig;
|
|
610
674
|
docker: LibDockerConfig;
|
|
611
675
|
assets: LibAssetsConfig;
|
|
612
676
|
/** Live-only: plugins declared in this lib's `akan.config.ts` (never serialized). */
|
|
613
677
|
plugins: AkanPlugin[];
|
|
614
|
-
constructor(lib: Lib, config:
|
|
678
|
+
constructor(lib: Lib, config: LibConfigDeclaration, plugins: AkanPlugin[] = []) {
|
|
615
679
|
this.lib = lib;
|
|
616
680
|
this.externalLibs = config?.externalLibs ?? [];
|
|
681
|
+
const owner = `libs/${lib.name}/akan.config.ts`;
|
|
682
|
+
this.trustedDependencies = normalizePackageNames(config?.trustedDependencies, owner);
|
|
683
|
+
this.bin = AkanBin.parse(config?.bin, owner, lib.cwdPath);
|
|
617
684
|
this.docker = { preRuns: config?.docker?.preRuns ?? [], postRuns: config?.docker?.postRuns ?? [] };
|
|
618
685
|
this.assets = { keepFonts: normalizeKeepFonts(config?.assets?.keepFonts as string[] | undefined) };
|
|
619
686
|
this.plugins = plugins;
|
|
@@ -621,7 +688,7 @@ export class AkanLibConfig implements LibConfigResult {
|
|
|
621
688
|
static async from(lib: Lib, { bustImportCache = false }: { bustImportCache?: boolean } = {}) {
|
|
622
689
|
const configImp = await AkanAppConfig.importConfigModule(lib.cwdPath, { bustImportCache });
|
|
623
690
|
const resolved = typeof configImp === "function" ? configImp(lib) : configImp;
|
|
624
|
-
const { plugins, ...config } = (resolved ?? {}) as
|
|
691
|
+
const { plugins, ...config } = (resolved ?? {}) as LibConfigDeclaration & { plugins?: AkanPlugin[] };
|
|
625
692
|
return new AkanLibConfig(lib, config, plugins ?? []);
|
|
626
693
|
}
|
|
627
694
|
}
|
package/akanConfig/index.ts
CHANGED
package/akanConfig/types.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
export type {
|
|
2
2
|
AkanApiConfig,
|
|
3
3
|
AkanAssetsConfig,
|
|
4
|
+
AkanBinConfig,
|
|
5
|
+
AkanBinPathSource,
|
|
6
|
+
AkanBinSource,
|
|
7
|
+
AkanBinUrlSource,
|
|
4
8
|
AkanConfigFile,
|
|
5
9
|
AkanDatabaseConfig,
|
|
6
10
|
AkanExecutor,
|
|
7
11
|
AkanMobileConfig,
|
|
8
12
|
AkanMobileNativeConfig,
|
|
9
13
|
AkanMobileTargetConfig,
|
|
14
|
+
AkanMobileUpdatesConfig,
|
|
10
15
|
AkanNativeValue,
|
|
11
16
|
AkanPlugin,
|
|
12
17
|
AkanPluginNativeConfig,
|
|
@@ -20,6 +25,7 @@ export type {
|
|
|
20
25
|
AppConfigResult,
|
|
21
26
|
AppScanResult,
|
|
22
27
|
Arch,
|
|
28
|
+
BinPlatform,
|
|
23
29
|
DatabaseMode,
|
|
24
30
|
DeepPartial,
|
|
25
31
|
DockerConfig,
|
|
@@ -39,4 +45,4 @@ export type {
|
|
|
39
45
|
ScanResult,
|
|
40
46
|
WorkspaceScanResult,
|
|
41
47
|
} from "akanjs";
|
|
42
|
-
export { archs } from "akanjs";
|
|
48
|
+
export { archs, binPlatforms } from "akanjs";
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { rm } from "node:fs/promises";
|
|
2
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { AkanAppConfig } from "./akanConfig";
|
|
6
|
+
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
7
|
+
import type { App } from "./commandDecorators";
|
|
8
|
+
import { Executor } from "./executors";
|
|
6
9
|
|
|
7
10
|
describe("ApplicationBuildRunner", () => {
|
|
8
11
|
test("externalizes Akan optional backend dependencies", () => {
|
|
@@ -44,4 +47,35 @@ console.info(new SampleService().name);
|
|
|
44
47
|
await rm(dir, { recursive: true, force: true });
|
|
45
48
|
}
|
|
46
49
|
});
|
|
50
|
+
|
|
51
|
+
test("an image of a --env build boots the env its server carries, not the root .env's", async () => {
|
|
52
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "akan-build-meta-"));
|
|
53
|
+
const baseDevEnv = {
|
|
54
|
+
repoName: "akanjs",
|
|
55
|
+
serveDomain: "akanjs.com",
|
|
56
|
+
env: "local" as const,
|
|
57
|
+
portOffset: 0,
|
|
58
|
+
workspaceRoot: root,
|
|
59
|
+
};
|
|
60
|
+
const config = new AkanAppConfig(
|
|
61
|
+
{ name: "portal" } as never,
|
|
62
|
+
[],
|
|
63
|
+
{ name: "repo", version: "1.0.0", description: "repo" },
|
|
64
|
+
{},
|
|
65
|
+
baseDevEnv,
|
|
66
|
+
);
|
|
67
|
+
const app = {
|
|
68
|
+
getConfig: async () => config,
|
|
69
|
+
dist: new Executor("dist", root),
|
|
70
|
+
workspace: { workspaceRoot: root },
|
|
71
|
+
} as unknown as App;
|
|
72
|
+
try {
|
|
73
|
+
await new ApplicationBuildRunner(app, { environment: "debug" }).buildAppMeta();
|
|
74
|
+
expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("ENV AKAN_PUBLIC_ENV=debug\n");
|
|
75
|
+
await new ApplicationBuildRunner(app).buildAppMeta();
|
|
76
|
+
expect(await readFile(path.join(root, "Dockerfile"), "utf8")).toContain("ENV AKAN_PUBLIC_ENV=local\n");
|
|
77
|
+
} finally {
|
|
78
|
+
await rm(root, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
47
81
|
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
SsrBaseArtifactBuilder,
|
|
16
16
|
} from "./frontendBuild";
|
|
17
17
|
import { Spinner } from "./spinner";
|
|
18
|
+
import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
|
|
18
19
|
|
|
19
20
|
export interface TypecheckOptions {
|
|
20
21
|
clean?: boolean;
|
|
@@ -29,6 +30,8 @@ export type BuildProgressReporter = ApplicationBuildProgressReporter;
|
|
|
29
30
|
export interface ApplicationBuildRunnerOptions {
|
|
30
31
|
fast?: boolean;
|
|
31
32
|
reporter?: BuildProgressReporter;
|
|
33
|
+
/** The backend env the build is for, when it is not the workspace's own (a mobile or desktop build's `--env`). */
|
|
34
|
+
environment?: string;
|
|
32
35
|
}
|
|
33
36
|
export interface BuildOptions {
|
|
34
37
|
spinner?: boolean;
|
|
@@ -76,14 +79,16 @@ export class ApplicationBuildRunner {
|
|
|
76
79
|
#app: App;
|
|
77
80
|
#fast: boolean;
|
|
78
81
|
#reporter?: BuildProgressReporter;
|
|
82
|
+
#environment?: string;
|
|
79
83
|
#spinner?: boolean;
|
|
80
84
|
#startedAt = Date.now();
|
|
81
85
|
#phases: BuildPhaseResult[] = [];
|
|
82
86
|
|
|
83
|
-
constructor(app: App, { fast = false, reporter }: ApplicationBuildRunnerOptions = {}) {
|
|
87
|
+
constructor(app: App, { fast = false, reporter, environment }: ApplicationBuildRunnerOptions = {}) {
|
|
84
88
|
this.#app = app;
|
|
85
89
|
this.#fast = fast;
|
|
86
90
|
this.#reporter = reporter;
|
|
91
|
+
this.#environment = environment;
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
async build({ spinner = false }: BuildOptions = {}): Promise<BuildResult> {
|
|
@@ -133,7 +138,7 @@ export class ApplicationBuildRunner {
|
|
|
133
138
|
? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> gzip ${ApplicationBuildRunner.formatBytes(result.outputBytes)} / br ${ApplicationBuildRunner.formatBytes(result.brotliBytes)}`
|
|
134
139
|
: "no files",
|
|
135
140
|
);
|
|
136
|
-
await this.#runPhase("metadata", "Writing production metadata", () => this
|
|
141
|
+
await this.#runPhase("metadata", "Writing production metadata", () => this.buildAppMeta());
|
|
137
142
|
return {
|
|
138
143
|
phases: this.#phases,
|
|
139
144
|
durationMs: Date.now() - this.#startedAt,
|
|
@@ -175,11 +180,12 @@ export class ApplicationBuildRunner {
|
|
|
175
180
|
}
|
|
176
181
|
}
|
|
177
182
|
|
|
178
|
-
async
|
|
183
|
+
async buildAppMeta() {
|
|
179
184
|
const akanConfig = await this.#app.getConfig();
|
|
185
|
+
const dockerfile = akanConfig.dockerfileFor(this.#environment ?? akanConfig.baseDevEnv.env);
|
|
180
186
|
await Promise.all([
|
|
181
187
|
this.#app.dist.writeJson("package.json", akanConfig.getProductionPackageJson()),
|
|
182
|
-
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`,
|
|
188
|
+
this.#app.dist.writeFile(`${this.#app.dist.cwdPath}/Dockerfile`, dockerfile),
|
|
183
189
|
this.#app.dist.writeJson("akan.build.json", {
|
|
184
190
|
buildId: await this.#resolveBuildId(),
|
|
185
191
|
akanVersion: akanConfig.akanVersion,
|
|
@@ -204,7 +210,7 @@ export class ApplicationBuildRunner {
|
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
async #buildBackend() {
|
|
207
|
-
const { externalLibs, web } = await this.#app.getConfig();
|
|
213
|
+
const { externalLibs, web, baseDevEnv, branches } = await this.#app.getConfig();
|
|
208
214
|
const backendEntryPoints = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
209
215
|
for (const entrypoint of backendEntryPoints) {
|
|
210
216
|
if (!(await Bun.file(entrypoint).exists())) throw new Error(`Backend entrypoint not found: ${entrypoint}`);
|
|
@@ -217,7 +223,16 @@ export class ApplicationBuildRunner {
|
|
|
217
223
|
// `akan build` must embed production react-server-dom regardless of the shell's NODE_ENV.
|
|
218
224
|
define: { "process.env.NODE_ENV": JSON.stringify("production") },
|
|
219
225
|
} satisfies Omit<Bun.BuildConfig, "entrypoints">;
|
|
220
|
-
const
|
|
226
|
+
const serverEnvPlugin = createServerEnvPlugin({
|
|
227
|
+
envDir: path.join(this.#app.cwdPath, "env"),
|
|
228
|
+
//? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
|
|
229
|
+
environment: this.#environment ?? baseDevEnv.env,
|
|
230
|
+
environments: ["local", "testing", ...branches],
|
|
231
|
+
});
|
|
232
|
+
const backendConfig = {
|
|
233
|
+
...sharedConfig,
|
|
234
|
+
plugins: [this.#createExternalSpecifiersPlugin(externalLibs), serverEnvPlugin],
|
|
235
|
+
};
|
|
221
236
|
//* Built apart so main.js keeps its own module copies; splitting moves lazy vendor `import()`s out of the boot parse.
|
|
222
237
|
const [mainResult, serverResult] = [
|
|
223
238
|
await this.#buildOrThrow("backend", { ...backendConfig, entrypoints: [backendEntryPoints[0]] }),
|
package/biome.base.json
CHANGED
|
@@ -137,7 +137,8 @@
|
|
|
137
137
|
"!**/*.spec.tsx",
|
|
138
138
|
"!**/*.constant.ts",
|
|
139
139
|
"!**/common/**",
|
|
140
|
-
"!**/env/**"
|
|
140
|
+
"!**/env/**",
|
|
141
|
+
"!{apps,libs}/*/native/**"
|
|
141
142
|
],
|
|
142
143
|
"plugins": ["./node_modules/@akanjs/devkit/lint/no-throw-raw-error.grit"]
|
|
143
144
|
},
|
|
@@ -225,6 +226,7 @@
|
|
|
225
226
|
"{apps,libs}/**/*.ts",
|
|
226
227
|
"{apps,libs}/**/*.tsx",
|
|
227
228
|
"!{apps,libs}/**/webkit/**",
|
|
229
|
+
"!{apps,libs}/*/native/**",
|
|
228
230
|
"!**/*.test.ts",
|
|
229
231
|
"!**/*.test.tsx",
|
|
230
232
|
"!**/*.spec.ts",
|
|
@@ -54,6 +54,7 @@ export class CsrE2eHarness {
|
|
|
54
54
|
readonly #lang: string;
|
|
55
55
|
readonly #mobileTarget: string;
|
|
56
56
|
readonly #edited = new Map<string, string>();
|
|
57
|
+
readonly #console: string[];
|
|
57
58
|
#marker = "";
|
|
58
59
|
#csr: boolean = true;
|
|
59
60
|
|
|
@@ -63,14 +64,39 @@ export class CsrE2eHarness {
|
|
|
63
64
|
server: CsrE2eServer | null;
|
|
64
65
|
lang: string;
|
|
65
66
|
mobileTarget: string;
|
|
67
|
+
console: string[];
|
|
66
68
|
}) {
|
|
67
69
|
this.origin = options.origin;
|
|
68
70
|
this.#view = options.view;
|
|
71
|
+
this.#console = options.console;
|
|
69
72
|
this.#server = options.server;
|
|
70
73
|
this.#lang = options.lang;
|
|
71
74
|
this.#mobileTarget = options.mobileTarget;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** What keeps this host from driving a page, or null when a `Bun.WebView` opens one and answers. */
|
|
78
|
+
static async preflight(backend = CsrE2eHarness.#defaultBackend()): Promise<string | null> {
|
|
79
|
+
if (typeof (Bun as { WebView?: unknown }).WebView !== "function") return `Bun ${Bun.version} has no Bun.WebView`;
|
|
80
|
+
let view: Bun.WebView | null = null;
|
|
81
|
+
try {
|
|
82
|
+
const opened = new Bun.WebView({
|
|
83
|
+
width: 320,
|
|
84
|
+
height: 240,
|
|
85
|
+
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
86
|
+
});
|
|
87
|
+
view = opened;
|
|
88
|
+
const answer = await Promise.race([
|
|
89
|
+
opened.navigate("about:blank").then(async () => await opened.evaluate<number>("1 + 1")),
|
|
90
|
+
Bun.sleep(30_000).then(() => null),
|
|
91
|
+
]);
|
|
92
|
+
return answer === 2 ? null : `a ${backend} Bun.WebView did not answer within 30s`;
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return `a ${backend} Bun.WebView cannot start: ${error instanceof Error ? error.message : String(error)}`;
|
|
95
|
+
} finally {
|
|
96
|
+
view?.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
74
100
|
/** E2E suites run only with `AKAN_CSR_E2E=1`: they boot a dev server and a browser. */
|
|
75
101
|
static get enabled() {
|
|
76
102
|
return process.env.AKAN_CSR_E2E === "1";
|
|
@@ -91,13 +117,19 @@ export class CsrE2eHarness {
|
|
|
91
117
|
await CsrE2eHarness.#waitForHealth(origin, server, options.startTimeoutMs ?? 180_000);
|
|
92
118
|
const { width, height } = { width: 390, height: 844, ...options.viewport };
|
|
93
119
|
const backend = options.backend ?? CsrE2eHarness.#defaultBackend();
|
|
120
|
+
const messages: string[] = [];
|
|
121
|
+
const print = process.env.AKAN_CSR_E2E_CONSOLE === "1";
|
|
94
122
|
//? `url: false` spawns a fresh Chrome instead of attaching to one the developer runs with remote debugging on.
|
|
95
123
|
const view = new Bun.WebView({
|
|
96
124
|
width,
|
|
97
125
|
height,
|
|
98
126
|
backend: backend === "chrome" ? { type: "chrome", url: false } : backend,
|
|
99
|
-
//? AKAN_CSR_E2E_CONSOLE=1 prints
|
|
100
|
-
|
|
127
|
+
//? Kept for `consoleMessages()`; AKAN_CSR_E2E_CONSOLE=1 also prints it, the only view of a hydration warning or
|
|
128
|
+
//? a failed patch. An uncaught error is not a console call and never arrives here.
|
|
129
|
+
console: (type: string, ...args: unknown[]) => {
|
|
130
|
+
messages.push(`[${type}] ${args.map(CsrE2eHarness.#formatArg).join(" ")}`);
|
|
131
|
+
if (print) globalThis.console.info(`[page ${type}]`, ...args);
|
|
132
|
+
},
|
|
101
133
|
});
|
|
102
134
|
return new CsrE2eHarness({
|
|
103
135
|
origin,
|
|
@@ -105,6 +137,7 @@ export class CsrE2eHarness {
|
|
|
105
137
|
server,
|
|
106
138
|
lang: options.lang ?? "en",
|
|
107
139
|
mobileTarget: options.mobileTarget ?? "e2e",
|
|
140
|
+
console: messages,
|
|
108
141
|
});
|
|
109
142
|
} catch (error) {
|
|
110
143
|
await CsrE2eHarness.#stopServer(server);
|
|
@@ -120,6 +153,7 @@ export class CsrE2eHarness {
|
|
|
120
153
|
url.searchParams.set("akanMobileTarget", this.#mobileTarget);
|
|
121
154
|
}
|
|
122
155
|
this.#csr = csr;
|
|
156
|
+
this.#console.length = 0;
|
|
123
157
|
await this.#view.navigate(url.toString());
|
|
124
158
|
await this.#markBoot();
|
|
125
159
|
}
|
|
@@ -346,6 +380,11 @@ export class CsrE2eHarness {
|
|
|
346
380
|
return [await read(logPath), await read(logPath.replace(/\.log$/, ".err.log"))];
|
|
347
381
|
}
|
|
348
382
|
|
|
383
|
+
/** The page's console calls since `open()`, as `[type] text`. */
|
|
384
|
+
consoleMessages(): string[] {
|
|
385
|
+
return [...this.#console];
|
|
386
|
+
}
|
|
387
|
+
|
|
349
388
|
/** Whether the page reloaded since `open()`: the boot marker lives only in the page `open()` loaded. */
|
|
350
389
|
async reloaded() {
|
|
351
390
|
const marker = await this.evaluate(
|
|
@@ -378,7 +417,17 @@ export class CsrE2eHarness {
|
|
|
378
417
|
await CsrE2eHarness.#stopServer(this.#server);
|
|
379
418
|
}
|
|
380
419
|
|
|
381
|
-
static #
|
|
420
|
+
static #formatArg(arg: unknown): string {
|
|
421
|
+
if (typeof arg === "string") return arg;
|
|
422
|
+
try {
|
|
423
|
+
return JSON.stringify(arg) ?? String(arg);
|
|
424
|
+
} catch {
|
|
425
|
+
// A circular descriptor still names itself.
|
|
426
|
+
return String(arg);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
static #defaultBackend(): "webkit" | "chrome" {
|
|
382
431
|
const requested = process.env.AKAN_CSR_E2E_BACKEND;
|
|
383
432
|
if (requested === "webkit" || requested === "chrome") return requested;
|
|
384
433
|
return process.platform === "darwin" ? "webkit" : "chrome";
|
|
@@ -411,8 +460,9 @@ export class CsrE2eHarness {
|
|
|
411
460
|
const errPath = logPath.replace(/\.log$/, ".err.log");
|
|
412
461
|
//? A `Bun.file` sink writes from offset 0 without truncating, so the last run's tail would outlive this one's start.
|
|
413
462
|
await Promise.all([Bun.write(logPath, ""), Bun.write(errPath, "")]);
|
|
414
|
-
//? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned.
|
|
415
|
-
|
|
463
|
+
//? Its own process group, so stopping it reaches the gateway, the builder and every replica it spawned. That group
|
|
464
|
+
//? outlives a run killed before it could stop it, so `--kill` takes the suite's own port back from it first.
|
|
465
|
+
const proc = Bun.spawn(["bun", cli, "start", app, "--plain", "--kill"], {
|
|
416
466
|
cwd: workspaceRoot,
|
|
417
467
|
env: { ...process.env, AKAN_DEV_PORT: String(port), ...env },
|
|
418
468
|
stdout: Bun.file(logPath),
|
|
@@ -176,6 +176,71 @@ describe.skipIf(!CsrE2eHarness.enabled)("CSR page stack (minimal, /e2e/stack)",
|
|
|
176
176
|
expect(await csr.ticksOver("tab-a")).toBeGreaterThan(0);
|
|
177
177
|
}, 60_000);
|
|
178
178
|
|
|
179
|
+
test("a page that redirects after an await lands on the target, inside the target's own async layout", async () => {
|
|
180
|
+
await csr.open(TAB_B);
|
|
181
|
+
await csr.evaluate((next: string) => {
|
|
182
|
+
window.dispatchEvent(new CustomEvent("akan:sync-navigation", { detail: { href: next, kind: "push" } }));
|
|
183
|
+
}, "/e2e/stack/redirect?delay=120");
|
|
184
|
+
await csr.waitFor((pathname: string) => location.pathname.endsWith(pathname), {
|
|
185
|
+
args: ["/e2e/tabbed/home"],
|
|
186
|
+
timeout: 10_000,
|
|
187
|
+
});
|
|
188
|
+
await csr.waitFor(() => document.querySelectorAll('[data-e2e="tabbar"]').length === 1, { timeout: 10_000 });
|
|
189
|
+
await csr.waitFor(
|
|
190
|
+
() =>
|
|
191
|
+
(window as unknown as { __akanE2eProbes?: Record<string, { mounted?: boolean }> }).__akanE2eProbes?.home
|
|
192
|
+
?.mounted,
|
|
193
|
+
{ timeout: 10_000 },
|
|
194
|
+
);
|
|
195
|
+
expect(await csr.probe("home")).toMatchObject({ activity: "current", mounted: true });
|
|
196
|
+
expect(await csr.reloaded()).toBe(false);
|
|
197
|
+
}, 60_000);
|
|
198
|
+
|
|
199
|
+
test("an async render that throws is logged with its route instead of leaving a blank page in silence", async () => {
|
|
200
|
+
await csr.open("/e2e/stack/broken");
|
|
201
|
+
await csr.waitFor(() => true);
|
|
202
|
+
await Bun.sleep(500);
|
|
203
|
+
expect(
|
|
204
|
+
csr.consoleMessages().some((line) => /render of page \d+ of \S*\/e2e\/stack\/broken failed/.test(line)),
|
|
205
|
+
).toBe(true);
|
|
206
|
+
}, 60_000);
|
|
207
|
+
|
|
208
|
+
test("the memory frame trace records navigation without writing it to the console", async () => {
|
|
209
|
+
await csr.open(TAB_A);
|
|
210
|
+
await csr.evaluate(() => localStorage.setItem("akan:debug:frame", "memory"));
|
|
211
|
+
try {
|
|
212
|
+
await csr.open(TAB_A);
|
|
213
|
+
await csr.navigate(item("71"));
|
|
214
|
+
const events = await csr.evaluate(() =>
|
|
215
|
+
((window as { __AKAN_FRAME_TRACE__?: { event: string }[] }).__AKAN_FRAME_TRACE__ ?? []).map(
|
|
216
|
+
(entry) => entry.event,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
expect(events).toContain("router.push");
|
|
220
|
+
expect(events).toContain("navigation.commit");
|
|
221
|
+
expect(csr.consoleMessages().some((line) => line.includes("[akan:frame:"))).toBe(false);
|
|
222
|
+
} finally {
|
|
223
|
+
await csr.evaluate(() => localStorage.removeItem("akan:debug:frame"));
|
|
224
|
+
}
|
|
225
|
+
}, 60_000);
|
|
226
|
+
|
|
227
|
+
test("a cached page whose layout redirected renders that layout again when it is back on screen", async () => {
|
|
228
|
+
await csr.evaluate(() => {
|
|
229
|
+
(globalThis as { __e2eSignedIn?: boolean }).__e2eSignedIn = false;
|
|
230
|
+
});
|
|
231
|
+
await csr.open("/e2e/gate/home");
|
|
232
|
+
await csr.waitFor((pathname: string) => location.pathname.endsWith(pathname), {
|
|
233
|
+
args: [TAB_B],
|
|
234
|
+
timeout: 10_000,
|
|
235
|
+
});
|
|
236
|
+
await csr.evaluate(() => {
|
|
237
|
+
(globalThis as { __e2eSignedIn?: boolean }).__e2eSignedIn = true;
|
|
238
|
+
});
|
|
239
|
+
await csr.navigate("/e2e/gate/home", "replace");
|
|
240
|
+
await csr.waitFor(() => document.querySelectorAll('[data-e2e="gate-tabbar"]').length === 1, { timeout: 10_000 });
|
|
241
|
+
expect(await csr.probe("gate-home")).toMatchObject({ activity: "current", mounted: true });
|
|
242
|
+
}, 60_000);
|
|
243
|
+
|
|
179
244
|
test("back returns to the previous entry without a reload", async () => {
|
|
180
245
|
await csr.open(TAB_A);
|
|
181
246
|
await csr.navigate(item("6"));
|