@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.3
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/akanConfig/akanConfig.test.ts +51 -5
- package/akanConfig/akanConfig.ts +31 -12
- package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
- package/akanConfig/akanWorkspaceConfig.ts +1 -1
- package/applicationBuildRunner.test.ts +33 -1
- package/applicationBuildRunner.ts +4 -5
- package/cloud/cloudApi.ts +22 -15
- package/cloud/cloudHttpError.test.ts +32 -0
- package/cloud/cloudHttpError.ts +54 -0
- package/cloud/cloudUpdates.ts +138 -0
- package/cloud/globalConfig.test.ts +17 -0
- package/cloud/globalConfig.ts +8 -2
- package/cloud/index.ts +3 -0
- package/cloud/pipelineSecrets.ts +114 -0
- package/cloud/updatePackUpload.test.ts +100 -45
- package/cloud/updatePackUpload.ts +38 -18
- package/commandDecorators/argMeta.ts +11 -2
- package/commandDecorators/command.ts +40 -6
- package/commandDecorators/commandBuilder.ts +6 -1
- package/commandDecorators/commandDecorators.test.ts +205 -4
- package/commandDecorators/exitCodeError.ts +15 -0
- package/commandDecorators/index.ts +1 -0
- package/executors.test.ts +69 -0
- package/executors.ts +30 -27
- package/frontendBuild/csrArtifactBuilder.ts +18 -1
- package/incrementalBuilder/buildBatch.proc.ts +12 -7
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
- package/mobile/desktopServerStage.test.ts +13 -0
- package/mobile/desktopServerStage.ts +5 -7
- package/mobile/index.ts +2 -0
- package/mobile/nativeApi.test.ts +26 -0
- package/mobile/nativeApi.ts +5 -2
- package/mobile/nativeApp.test.ts +135 -33
- package/mobile/nativeApp.ts +109 -73
- package/mobile/nativeConfig.ts +2 -1
- package/mobile/nativePublicEnv.test.ts +131 -0
- package/mobile/nativePublicEnv.ts +116 -0
- package/mobile/serverBundleSecrets.test.ts +79 -0
- package/mobile/serverBundleSecrets.ts +60 -16
- package/mobile/updatePackFolder.test.ts +98 -0
- package/mobile/updatePackFolder.ts +131 -0
- package/mobile/updateUploader.test.ts +114 -22
- package/mobile/updateUploader.ts +75 -8
- package/package.json +2 -2
- package/pipeline/PipelineCompiler.test.ts +157 -1
- package/pipeline/PipelineCompiler.ts +80 -46
- package/pipeline/PipelineDefinition.test.ts +9 -0
- package/pipeline/PipelineDefinition.ts +4 -3
- package/pipeline/PipelineDryRun.test.ts +27 -0
- package/pipeline/PipelineDryRun.ts +2 -2
- package/pipeline/PipelineInputReader.ts +157 -253
- package/pipeline/PipelineRules.ts +92 -0
- package/pipeline/index.ts +1 -0
- package/qualityScanner.test.ts +5 -3
- package/slicePlanner.ts +5 -3
- package/subspace.test.ts +90 -19
- package/subspace.ts +52 -500
- package/subspaceClone.ts +361 -0
- package/subspaceConfig.ts +1 -2
- package/subspaceReport.ts +162 -0
- package/transforms/serverEnvPlugin.test.ts +76 -0
- package/transforms/serverEnvPlugin.ts +9 -25
- package/transforms/serverEnvProfile.test.ts +189 -0
- package/transforms/serverEnvProfile.ts +324 -60
- package/typecheck/projectTypecheck.ts +6 -5
package/executors.ts
CHANGED
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isRouteSourceFile,
|
|
29
29
|
Logger,
|
|
30
30
|
parseRouteModuleKey,
|
|
31
|
+
toError,
|
|
31
32
|
validatePageSourceFile,
|
|
32
33
|
validateSubRoutePageKey,
|
|
33
34
|
} from "akanjs/common";
|
|
@@ -52,6 +53,7 @@ import { AppInfo, isAkanFrameworkDependency, LibInfo, PkgInfo, WorkspaceInfo } f
|
|
|
52
53
|
import { Spinner } from "./spinner";
|
|
53
54
|
// Type-only: `getTypeChecker` loads it on demand to keep `typescript` out of the resident module graph.
|
|
54
55
|
import type { TypeChecker } from "./typeChecker";
|
|
56
|
+
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
55
57
|
import type { FileContent, PackageJson, TsConfigJson } from "./types";
|
|
56
58
|
import { barrelFacetDirs } from "./workspaceLayout";
|
|
57
59
|
|
|
@@ -64,6 +66,10 @@ export interface PageRoot {
|
|
|
64
66
|
keyPrefix: string;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
export interface WrittenFileContent extends FileContent {
|
|
70
|
+
written: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
67
73
|
const staticTemplateFileExtensions = new Set(
|
|
68
74
|
".avif .bmp .cjs .css .eot .gif .html .ico .jpeg .jpg .js .json .map .md .mjs .mp3 .mp4 .ogg .otf .pdf .png .svg"
|
|
69
75
|
.split(" ")
|
|
@@ -265,20 +271,20 @@ export class Executor {
|
|
|
265
271
|
const readPath = this.getPath(dirPath);
|
|
266
272
|
try {
|
|
267
273
|
const glob = new Bun.Glob("*");
|
|
268
|
-
return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false }));
|
|
274
|
+
return Array.from(glob.scanSync({ cwd: readPath, onlyFiles: false })).sort();
|
|
269
275
|
} catch {
|
|
270
276
|
return [];
|
|
271
277
|
}
|
|
272
278
|
}
|
|
273
279
|
async getAllFiles(pattern = "**/*", { cwd }: { cwd?: string } = {}): Promise<string[]> {
|
|
274
280
|
const glob = new Bun.Glob(pattern);
|
|
275
|
-
return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true }));
|
|
281
|
+
return Array.from(glob.scanSync({ cwd: cwd ?? this.cwdPath, onlyFiles: true })).sort();
|
|
276
282
|
}
|
|
277
283
|
async getFilesAndDirs(dirPath: string): Promise<{ files: string[]; dirs: string[] }> {
|
|
278
284
|
const fullDirPath = this.getPath(dirPath);
|
|
279
285
|
const glob = new Bun.Glob("*");
|
|
280
|
-
const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true }));
|
|
281
|
-
const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false }));
|
|
286
|
+
const files = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: true })).sort();
|
|
287
|
+
const allEntries = Array.from(glob.scanSync({ cwd: fullDirPath, onlyFiles: false })).sort();
|
|
282
288
|
const dirs = allEntries.filter((entry) => !files.includes(entry));
|
|
283
289
|
return { files, dirs };
|
|
284
290
|
}
|
|
@@ -302,17 +308,19 @@ export class Executor {
|
|
|
302
308
|
filePath: string,
|
|
303
309
|
content: string | object,
|
|
304
310
|
{ overwrite = true, silent = false }: { overwrite?: boolean; silent?: boolean } = {},
|
|
305
|
-
): Promise<
|
|
311
|
+
): Promise<WrittenFileContent> {
|
|
306
312
|
const writePath = this.getPath(filePath);
|
|
307
313
|
const dir = path.dirname(writePath);
|
|
308
314
|
if (!(await FileSys.dirExists(dir))) await mkdir(dir, { recursive: true });
|
|
309
315
|
let contentStr = typeof content === "string" ? content : JsonFormatter.stringify(content, writePath);
|
|
316
|
+
let written = true;
|
|
310
317
|
|
|
311
318
|
if (await FileSys.fileExists(writePath)) {
|
|
312
319
|
const currentContent = await FileSys.readText(writePath);
|
|
313
320
|
if (currentContent === contentStr || !overwrite) {
|
|
314
321
|
this.logger.verbose(`File ${writePath} is unchanged`);
|
|
315
322
|
contentStr = currentContent;
|
|
323
|
+
written = false;
|
|
316
324
|
} else {
|
|
317
325
|
await FileSys.writeText(writePath, contentStr);
|
|
318
326
|
if (Logger.isVerbose()) this.logger.rawLog(chalk.yellow(`File Update: ${filePath}`));
|
|
@@ -321,7 +329,7 @@ export class Executor {
|
|
|
321
329
|
await FileSys.writeText(writePath, contentStr);
|
|
322
330
|
if (!silent) this.logger.rawLog(chalk.green(`File Create: ${filePath}`));
|
|
323
331
|
}
|
|
324
|
-
return { filePath: writePath, content: contentStr };
|
|
332
|
+
return { filePath: writePath, content: contentStr, written };
|
|
325
333
|
}
|
|
326
334
|
async writeJson(filePath: string, content: object) {
|
|
327
335
|
await this.writeFile(filePath, content);
|
|
@@ -418,7 +426,7 @@ export class Executor {
|
|
|
418
426
|
},
|
|
419
427
|
dict: { [key: string]: string } = {},
|
|
420
428
|
options: { [key: string]: unknown } = {},
|
|
421
|
-
): Promise<
|
|
429
|
+
): Promise<WrittenFileContent | null> {
|
|
422
430
|
const convertPath = (target: string) =>
|
|
423
431
|
Object.entries(dict).reduce(
|
|
424
432
|
(converted, [key, value]) => converted.replace(new RegExp(`__${key}__`, "g"), value),
|
|
@@ -451,11 +459,13 @@ export class Executor {
|
|
|
451
459
|
} else if (staticTemplateFileExtensions.has(path.extname(targetPath).toLowerCase())) {
|
|
452
460
|
const convertedTargetPath = convertPath(targetPath);
|
|
453
461
|
const writePath = this.getPath(convertedTargetPath);
|
|
462
|
+
if (!overwrite && (await FileSys.fileExists(writePath)))
|
|
463
|
+
return { filePath: writePath, content: "", written: false };
|
|
454
464
|
const dirname = path.dirname(writePath);
|
|
455
465
|
if (!(await FileSys.dirExists(dirname))) await mkdir(dirname, { recursive: true });
|
|
456
466
|
await copyFile(templatePath, writePath);
|
|
457
467
|
this.logger.verbose(`Apply template ${templatePath} to ${convertedTargetPath}`);
|
|
458
|
-
return { filePath: writePath, content: "" };
|
|
468
|
+
return { filePath: writePath, content: "", written: true };
|
|
459
469
|
} else return null;
|
|
460
470
|
}
|
|
461
471
|
async _applyTemplate({
|
|
@@ -472,7 +482,7 @@ export class Executor {
|
|
|
472
482
|
dict?: { [key: string]: string };
|
|
473
483
|
options?: { [key: string]: unknown };
|
|
474
484
|
overwrite?: boolean;
|
|
475
|
-
}): Promise<
|
|
485
|
+
}): Promise<WrittenFileContent[]> {
|
|
476
486
|
const templateRoot = await this.#resolveTemplateRoot();
|
|
477
487
|
const templatePath = `${templateRoot}${template ? `/${template}` : ""}`;
|
|
478
488
|
if ((await stat(templatePath)).isFile()) {
|
|
@@ -486,7 +496,7 @@ export class Executor {
|
|
|
486
496
|
dict,
|
|
487
497
|
options,
|
|
488
498
|
);
|
|
489
|
-
return fileContent ? [fileContent] : ([] as
|
|
499
|
+
return fileContent ? [fileContent] : ([] as WrittenFileContent[]);
|
|
490
500
|
} else {
|
|
491
501
|
const subdirs = await readDirEntries(templatePath);
|
|
492
502
|
const fileContents = (
|
|
@@ -504,7 +514,7 @@ export class Executor {
|
|
|
504
514
|
dict,
|
|
505
515
|
options,
|
|
506
516
|
);
|
|
507
|
-
return fileContent ? [fileContent] : ([] as
|
|
517
|
+
return fileContent ? [fileContent] : ([] as WrittenFileContent[]);
|
|
508
518
|
} else
|
|
509
519
|
return await this._applyTemplate({
|
|
510
520
|
basePath: path.join(basePath, subdir),
|
|
@@ -539,7 +549,7 @@ export class Executor {
|
|
|
539
549
|
overwrite?: boolean;
|
|
540
550
|
}): Promise<FileContent[]> {
|
|
541
551
|
const fileContents = await this._applyTemplate({ ...options, dict: withCapitalizedDict(options.dict) });
|
|
542
|
-
await this.#formatAppliedTemplate(fileContents);
|
|
552
|
+
await this.#formatAppliedTemplate(fileContents.filter((fileContent) => fileContent.written));
|
|
543
553
|
return fileContents;
|
|
544
554
|
}
|
|
545
555
|
|
|
@@ -554,7 +564,7 @@ export class Executor {
|
|
|
554
564
|
const { fixed } = await this.getLinter().fixFiles(filePaths);
|
|
555
565
|
if (fixed.length > 0) this.logger.verbose(`Formatted ${fixed.length} scaffolded file(s)`);
|
|
556
566
|
} catch (err) {
|
|
557
|
-
this.logger.verbose(`Skipped formatting scaffolded files: ${err
|
|
567
|
+
this.logger.verbose(`Skipped formatting scaffolded files: ${toError(err).message}`);
|
|
558
568
|
}
|
|
559
569
|
}
|
|
560
570
|
// Async so `typescript` (~65MB resident) loads only in the commands that typecheck.
|
|
@@ -572,7 +582,7 @@ export class Executor {
|
|
|
572
582
|
}
|
|
573
583
|
async typeCheckAsync(filePath: string) {
|
|
574
584
|
const path = this.getPath(filePath);
|
|
575
|
-
const entry = await
|
|
585
|
+
const entry = await ProjectTypecheck.workerEntry(process.cwd());
|
|
576
586
|
const proc = Bun.spawn([process.execPath, entry], {
|
|
577
587
|
cwd: this.cwdPath,
|
|
578
588
|
env: {
|
|
@@ -603,18 +613,6 @@ export class Executor {
|
|
|
603
613
|
message: result.message,
|
|
604
614
|
};
|
|
605
615
|
}
|
|
606
|
-
async #resolveTypecheckWorkerEntry() {
|
|
607
|
-
const dirname = getDirname(import.meta.url);
|
|
608
|
-
const candidates = [
|
|
609
|
-
path.join(process.cwd(), "pkgs/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
610
|
-
path.join(process.cwd(), "node_modules/@akanjs/devkit/typecheck/typecheck.proc.ts"),
|
|
611
|
-
path.join(dirname, "typecheck/typecheck.proc.ts"),
|
|
612
|
-
path.join(dirname, "typecheck.proc.js"),
|
|
613
|
-
path.join(dirname, "typecheck.proc.ts"),
|
|
614
|
-
];
|
|
615
|
-
for (const candidate of candidates) if (await Bun.file(candidate).exists()) return candidate;
|
|
616
|
-
throw new Error(`[devkit] typecheck worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
617
|
-
}
|
|
618
616
|
getLinter() {
|
|
619
617
|
this.linter ??= new Linter(this.cwdPath);
|
|
620
618
|
return this.linter;
|
|
@@ -662,9 +660,14 @@ export class WorkspaceExecutor extends Executor {
|
|
|
662
660
|
} = {}) {
|
|
663
661
|
return new WorkspaceExecutor({ workspaceRoot, repoName });
|
|
664
662
|
}
|
|
663
|
+
/** The public env a native build was told to reproduce (`--public-env`): it outranks `.env`, which outranks the rest. */
|
|
664
|
+
static publicEnvPin: Record<string, string> | null = null;
|
|
665
665
|
static getBaseDevEnv(envPath?: string) {
|
|
666
666
|
// Bun auto-loads .env, so we use process.env directly
|
|
667
|
-
const sourceEnv =
|
|
667
|
+
const sourceEnv = {
|
|
668
|
+
...(envPath ? { ...process.env, ...parseEnvFile(envPath) } : process.env),
|
|
669
|
+
...WorkspaceExecutor.publicEnvPin,
|
|
670
|
+
};
|
|
668
671
|
|
|
669
672
|
const appName = sourceEnv.AKAN_PUBLIC_APP_NAME;
|
|
670
673
|
const workspaceRoot = sourceEnv.AKAN_WORKSPACE_ROOT;
|
|
@@ -61,8 +61,9 @@ export class CsrArtifactBuilder {
|
|
|
61
61
|
).flat(),
|
|
62
62
|
);
|
|
63
63
|
|
|
64
|
+
const options = CsrArtifactBuilder.bundleOptions(this.#app, this.#command, akanConfig.optimizeImports);
|
|
64
65
|
const result = await Bun.build({
|
|
65
|
-
...
|
|
66
|
+
...options,
|
|
66
67
|
entrypoints: htmlBasePaths.map((basePath) => this.#generatedPath(CsrArtifactBuilder.htmlFilename(basePath))),
|
|
67
68
|
files: generatedFiles,
|
|
68
69
|
root: this.#generatedDir,
|
|
@@ -75,6 +76,7 @@ export class CsrArtifactBuilder {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
await this.#inlineCsrArtifacts(cssAssets);
|
|
79
|
+
if (this.#command === "build") await CsrArtifactBuilder.writePublicEnv(this.#app, options.define);
|
|
78
80
|
this.#app.verbose(`[csr-build] output -> ${this.#outputDir}`);
|
|
79
81
|
return { outputDir: this.#outputDir };
|
|
80
82
|
}
|
|
@@ -92,6 +94,21 @@ export class CsrArtifactBuilder {
|
|
|
92
94
|
};
|
|
93
95
|
}
|
|
94
96
|
|
|
97
|
+
/** Where a build records the `AKAN_PUBLIC_*` it inlined, which nothing in the minified bundle names. */
|
|
98
|
+
static publicEnvPath(app: App) {
|
|
99
|
+
return path.join(app.dist.cwdPath, ".akan", "artifact", "csr-public-env.json");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static async writePublicEnv(app: App, define: Record<string, string>) {
|
|
103
|
+
const prefix = "process.env.";
|
|
104
|
+
const publicEnv = Object.fromEntries(
|
|
105
|
+
Object.entries(define)
|
|
106
|
+
.filter(([key]) => key.startsWith(`${prefix}AKAN_PUBLIC_`))
|
|
107
|
+
.map(([key, value]) => [key.slice(prefix.length), JSON.parse(value) as unknown]),
|
|
108
|
+
);
|
|
109
|
+
await Bun.write(CsrArtifactBuilder.publicEnvPath(app), `${JSON.stringify(publicEnv, null, 2)}\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
95
112
|
/** The routes one basePath's HTML boots: its own plus every route outside any basePath, matching `bootCsr`. */
|
|
96
113
|
static pageEntriesForBasePath(pageEntries: PageEntry[], basePath: string, basePaths: string[]): PageEntry[] {
|
|
97
114
|
return CsrEntryFiles.pageEntriesForBasePath(pageEntries, basePath, basePaths);
|
|
@@ -33,13 +33,17 @@ class BuildBatch {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
async run(): Promise<BuildBatchResult> {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
36
|
+
const { needs } = this.#request;
|
|
37
|
+
//? A CSR artifact inlines the stylesheets, so it waits for this batch's compile of them; the registry reads none.
|
|
38
|
+
const cssFirst = needs.includes("css") && needs.includes("csr") && resolveDevCsrMode() === "artifact";
|
|
39
|
+
if (needs.includes("base")) await this.#buildBase();
|
|
40
|
+
if (cssFirst) await this.#buildCss();
|
|
41
|
+
if (needs.includes("csr")) await this.#buildCsr();
|
|
42
|
+
if (needs.includes("shell")) await this.#buildShell();
|
|
43
|
+
if (needs.includes("ssr")) await this.#buildSsrRegistry();
|
|
40
44
|
// Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
|
|
41
|
-
if (
|
|
42
|
-
if (
|
|
45
|
+
if (needs.includes("css") && !cssFirst) await this.#buildCss();
|
|
46
|
+
if (needs.includes("pages")) await this.#buildPages();
|
|
43
47
|
return this.#result;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -112,7 +116,8 @@ class BuildBatch {
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
async #rebuildCsrArtifact(started: number): Promise<void> {
|
|
115
|
-
|
|
119
|
+
const cssAssets = this.#result.cssAssets ?? this.#request.cssAssets;
|
|
120
|
+
await new CsrArtifactBuilder(this.#app, "start", "en", { cssAssets }).build();
|
|
116
121
|
this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
|
|
117
122
|
// A CSR tab takes none of the SSR refresh messages, so a rebuilt artifact reaches it only as this reload.
|
|
118
123
|
if (this.#request.changedFiles.length === 0) return;
|
|
@@ -178,6 +178,69 @@ process.send({ type: "build-batch-result", data: { generation, errors: {}, artif
|
|
|
178
178
|
expect(batches.filter((needs) => needs === "shell")).toHaveLength(1);
|
|
179
179
|
}, 30_000);
|
|
180
180
|
|
|
181
|
+
test("builds the shell again once the css it inlined is replaced, though the generation has not moved", async () => {
|
|
182
|
+
const { root } = track(await createTempApp("demo"));
|
|
183
|
+
const appDir = path.join(root, "apps/demo");
|
|
184
|
+
const ledger = path.join(root, "batches.log");
|
|
185
|
+
const gate = path.join(root, "css-lands");
|
|
186
|
+
const landed = { "": { cssUrl: "/_akan/styles/root-next.css", cssRelPath: "styles/root-next.css" } };
|
|
187
|
+
// The registry's boot build stands in for a save's css compile, held until the first shell is built.
|
|
188
|
+
await writeText(
|
|
189
|
+
path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
190
|
+
`import fs from "node:fs";
|
|
191
|
+
const { generation, needs, cssAssets } = JSON.parse(process.argv[2]);
|
|
192
|
+
fs.appendFileSync(${JSON.stringify(ledger)}, needs.join(",") + " " + JSON.stringify(cssAssets) + "\\n");
|
|
193
|
+
const orphaned = () => process.exit(1);
|
|
194
|
+
process.on("disconnect", orphaned);
|
|
195
|
+
const deadline = Date.now() + 15_000;
|
|
196
|
+
if (needs.includes("ssr")) while (!fs.existsSync(${JSON.stringify(gate)}) && Date.now() < deadline) await Bun.sleep(20);
|
|
197
|
+
const css = needs.includes("ssr") ? { cssAssets: ${JSON.stringify(landed)} } : {};
|
|
198
|
+
process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] }, ...css } });
|
|
199
|
+
process.off("disconnect", orphaned);
|
|
200
|
+
`,
|
|
201
|
+
);
|
|
202
|
+
await writeText(path.join(appDir, "page/_index.tsx"), "export default () => null;\n");
|
|
203
|
+
const messages: BuilderMessage[] = [];
|
|
204
|
+
const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
|
|
205
|
+
cwd: appDir,
|
|
206
|
+
env: {
|
|
207
|
+
...process.env,
|
|
208
|
+
AKAN_WORKSPACE_ROOT: root,
|
|
209
|
+
AKAN_PUBLIC_APP_NAME: "demo",
|
|
210
|
+
AKAN_PUBLIC_REPO_NAME: "repo",
|
|
211
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
212
|
+
AKAN_PUBLIC_ENV: "local",
|
|
213
|
+
AKAN_WATCH: "0",
|
|
214
|
+
AKAN_DEV_CSR_PATCHER: "off",
|
|
215
|
+
},
|
|
216
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
217
|
+
serialization: "advanced",
|
|
218
|
+
ipc: (message: BuilderMessage) => {
|
|
219
|
+
messages.push(message);
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
const answers = () => messages.filter((message) => message.type === "build-csr-res");
|
|
223
|
+
try {
|
|
224
|
+
await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
|
|
225
|
+
proc.send({ type: "build-csr", id: 7, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
226
|
+
await until(proc, "the first build-csr-res", () => answers().length === 1);
|
|
227
|
+
await writeText(gate, "");
|
|
228
|
+
await until(proc, "boot-armed", () => messages.some((message) => message.type === "boot-armed"));
|
|
229
|
+
proc.send({ type: "build-csr", id: 8, reason: "mcp app view", shell: true } satisfies BuilderMessage);
|
|
230
|
+
await until(proc, "the second build-csr-res", () => answers().length === 2);
|
|
231
|
+
} finally {
|
|
232
|
+
await writeText(gate, "");
|
|
233
|
+
proc.kill();
|
|
234
|
+
await proc.exited;
|
|
235
|
+
}
|
|
236
|
+
expect(answers()).toEqual([
|
|
237
|
+
{ type: "build-csr-res", id: 7, ok: true },
|
|
238
|
+
{ type: "build-csr-res", id: 8, ok: true },
|
|
239
|
+
]);
|
|
240
|
+
const shells = (await Bun.file(ledger).text()).split("\n").filter((line) => line.startsWith("shell "));
|
|
241
|
+
expect(shells).toEqual(["shell null", `shell ${JSON.stringify(landed)}`]);
|
|
242
|
+
}, 30_000);
|
|
243
|
+
|
|
181
244
|
test("a killed boot build is tried again after the crash window, before boot-armed; a route build's check waits likewise", async () => {
|
|
182
245
|
// Inside the checkout so the route's client bundle resolves the framework from its source.
|
|
183
246
|
const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
|
|
@@ -63,7 +63,7 @@ class IncrementalBuilder {
|
|
|
63
63
|
#autoImportSync: AutoImportSync;
|
|
64
64
|
#watcher: HmrWatcher | null = null;
|
|
65
65
|
#generation = 0;
|
|
66
|
-
#
|
|
66
|
+
#shellKey: string | null = null;
|
|
67
67
|
#discoveredThrough = 0;
|
|
68
68
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
69
69
|
#csrBundler: CsrDevBundler;
|
|
@@ -908,19 +908,21 @@ class IncrementalBuilder {
|
|
|
908
908
|
await armed;
|
|
909
909
|
}
|
|
910
910
|
|
|
911
|
-
// Asked on every view read and rebuilt only when a save moved the generation, instead of armed
|
|
912
|
-
// builder has no last build to trust, and so rebuilds once on the next read.
|
|
911
|
+
// Asked on every view read and rebuilt only when a save moved the generation or the css it inlines, instead of armed
|
|
912
|
+
// per save: a restarted builder has no last build to trust, and so rebuilds once on the next read.
|
|
913
913
|
async #buildShell(msg: BuilderCsrReq): Promise<void> {
|
|
914
914
|
await this.#workQueue.enqueue("build-shell", async (): Promise<void> => {
|
|
915
915
|
const generation = this.#generation;
|
|
916
|
+
//? A save moves the generation before its css compile lands, so a shell built in between holds the old css.
|
|
917
|
+
const shellKey = `${generation}:${JSON.stringify(this.#artifact.cssAssets)}`;
|
|
916
918
|
let error: string | undefined;
|
|
917
|
-
if (this.#
|
|
919
|
+
if (this.#shellKey !== shellKey)
|
|
918
920
|
try {
|
|
919
921
|
const result = await this.#batchRunner.run(
|
|
920
922
|
await this.#batchRequest({ generation, needs: ["shell"], changedFiles: [] }),
|
|
921
923
|
);
|
|
922
924
|
error = result.errors.shell;
|
|
923
|
-
if (!error) this.#
|
|
925
|
+
if (!error) this.#shellKey = shellKey;
|
|
924
926
|
} catch (err) {
|
|
925
927
|
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
926
928
|
}
|
|
@@ -54,6 +54,19 @@ describe("DesktopServerStage", () => {
|
|
|
54
54
|
expect(withEnv).toMatchObject({ CLOUD_ORIGIN: "https://cloud.example", AKAN_PUBLIC_OPERATION_MODE: "edge" });
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test("every key the stage sets over desktop.server.env is one the config refuses there, never silently replaced", () => {
|
|
58
|
+
const config = appConfig();
|
|
59
|
+
const run = { modules: ["@edge"] };
|
|
60
|
+
const owned = Object.keys(DesktopServerStage.env(config, "main", run)).filter(
|
|
61
|
+
(key) => DesktopServerStage.env(config, "main", { ...run, env: { [key]: "mine" } })[key] !== "mine",
|
|
62
|
+
);
|
|
63
|
+
expect(owned).toEqual(expect.arrayContaining(["AKAN_MCP", "AKAN_SHUTDOWN_TIMEOUT_MS", "AKAN_MODULES"]));
|
|
64
|
+
for (const key of owned)
|
|
65
|
+
expect(() => appConfig({ native: { desktop: { server: { env: { [key]: "mine" } } } } })).toThrow(
|
|
66
|
+
`sets ${key}, which the carried server's stage owns`,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
57
70
|
test("installs what the image does, minus the RSC renderer and the other modes' drivers", () => {
|
|
58
71
|
const config = appConfig({ database: { modes: ["single", "cluster"] }, externalLibs: ["@external/runtime"] });
|
|
59
72
|
const built = config.getProductionPackageJson();
|
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
import { cp, mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import type { ServerPolicy } from "@akanjs/native/api";
|
|
4
|
+
import { toError } from "akanjs/common";
|
|
3
5
|
import type { AkanAppConfig, NativeEnv } from "../akanConfig";
|
|
4
6
|
import type { App } from "../commandDecorators";
|
|
5
7
|
import type { PackageJson } from "../types";
|
|
6
8
|
import { DesktopBin } from "./desktopBin";
|
|
7
9
|
|
|
8
10
|
/** What a desktop build hands the native runtime as `desktop.server`. */
|
|
9
|
-
export interface DesktopServerBundle {
|
|
11
|
+
export interface DesktopServerBundle extends ServerPolicy {
|
|
10
12
|
dir: string;
|
|
11
13
|
entry: string;
|
|
12
14
|
env: Record<string, string>;
|
|
13
|
-
restart?: "forever" | number;
|
|
14
|
-
watchdog?: { intervalMs: number; failures: number };
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/** How the targets that carry one build's server have it run (`desktop.server`, normalized). */
|
|
18
|
-
export interface DesktopServerRun {
|
|
18
|
+
export interface DesktopServerRun extends ServerPolicy {
|
|
19
19
|
modules?: string[];
|
|
20
20
|
env?: Record<string, string>;
|
|
21
|
-
restart?: "forever" | number;
|
|
22
|
-
watchdog?: { intervalMs: number; failures: number };
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
//* A desktop app's carried server is the backend `akan build` wrote into dist, installed on its own as the image's
|
|
@@ -153,7 +151,7 @@ export class DesktopServerStage {
|
|
|
153
151
|
await this.app.spawn(process.execPath, DesktopServerStage.installArgs(arch), { cwd: this.dir });
|
|
154
152
|
} catch (error) {
|
|
155
153
|
throw new Error(
|
|
156
|
-
`The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${error
|
|
154
|
+
`The desktop app's server could not install its packages in ${this.dir}: it needs the npm registry, or a Bun cache that already holds every one of them.\n${toError(error).message}`,
|
|
157
155
|
{ cause: error },
|
|
158
156
|
);
|
|
159
157
|
}
|
package/mobile/index.ts
CHANGED
|
@@ -5,7 +5,9 @@ export * from "./nativeApi";
|
|
|
5
5
|
export * from "./nativeApp";
|
|
6
6
|
export * from "./nativeConfig";
|
|
7
7
|
export * from "./nativePluginFolders";
|
|
8
|
+
export * from "./nativePublicEnv";
|
|
8
9
|
export * from "./nativeWebDir";
|
|
9
10
|
export * from "./placeholderAppId";
|
|
11
|
+
export * from "./updatePackFolder";
|
|
10
12
|
export * from "./updateUploader";
|
|
11
13
|
export * from "./usageDescriptions";
|
package/mobile/nativeApi.test.ts
CHANGED
|
@@ -7,6 +7,19 @@ import { NativeApi } from "./nativeApi";
|
|
|
7
7
|
const makeTempRoot = tempDirs("akan-native-api-");
|
|
8
8
|
const repoApp = path.resolve(import.meta.dir, "../../../../apps/minimal");
|
|
9
9
|
|
|
10
|
+
const installVendoredApi = async (version: string) => {
|
|
11
|
+
const root = await realpath(await makeTempRoot());
|
|
12
|
+
await mkdir(path.join(root, "node_modules/akanjs"), { recursive: true });
|
|
13
|
+
await writeFile(
|
|
14
|
+
path.join(root, "node_modules/akanjs/package.json"),
|
|
15
|
+
JSON.stringify({ name: "akanjs", exports: { "./package.json": "./package.json" } }),
|
|
16
|
+
);
|
|
17
|
+
const entry = NativeApi.entryOf(root);
|
|
18
|
+
await mkdir(path.dirname(entry), { recursive: true });
|
|
19
|
+
await writeFile(entry, `export const API_VERSION = ${JSON.stringify(version)};\n`);
|
|
20
|
+
return root;
|
|
21
|
+
};
|
|
22
|
+
|
|
10
23
|
describe("NativeApi", () => {
|
|
11
24
|
test("inside this repo, loads the workspace package and checks its major", async () => {
|
|
12
25
|
expect(NativeApi.entryOf(repoApp)).toBe(path.resolve(import.meta.dir, "../../native/packages/cli/src/api.ts"));
|
|
@@ -27,4 +40,17 @@ describe("NativeApi", () => {
|
|
|
27
40
|
);
|
|
28
41
|
await expect(NativeApi.load(root)).rejects.toThrow("The installed akanjs has no native build");
|
|
29
42
|
});
|
|
43
|
+
|
|
44
|
+
test("refuses an API older than the one the build calls into, and another major, before anything builds", async () => {
|
|
45
|
+
await expect(NativeApi.load(await installVendoredApi("0.10.0"))).rejects.toThrow(
|
|
46
|
+
"install matching versions of akanjs and @akanjs/cli",
|
|
47
|
+
);
|
|
48
|
+
await expect(NativeApi.load(await installVendoredApi("1.0.0"))).rejects.toThrow(
|
|
49
|
+
"install matching versions of akanjs and @akanjs/cli",
|
|
50
|
+
);
|
|
51
|
+
expect((await NativeApi.load(await installVendoredApi(NativeApi.minimumVersion))).API_VERSION).toBe(
|
|
52
|
+
NativeApi.minimumVersion,
|
|
53
|
+
);
|
|
54
|
+
expect((await NativeApi.load(await installVendoredApi("0.12.0"))).API_VERSION).toBe("0.12.0");
|
|
55
|
+
});
|
|
30
56
|
});
|
package/mobile/nativeApi.ts
CHANGED
|
@@ -6,6 +6,9 @@ export type NativeBuildApiModule = typeof NativeBuildApi;
|
|
|
6
6
|
export class NativeApi {
|
|
7
7
|
//* The build API versions itself; a major this devkit was not written against is refused before anything builds.
|
|
8
8
|
static readonly supportedMajor = 0;
|
|
9
|
+
//? An update release's `revoke` and `resetSequence` arrived in 0.12.0, and an older API would publish without them;
|
|
10
|
+
//? `requestedDistribution` in 0.11.0; akanjs 3.0.x vendors 0.10.0.
|
|
11
|
+
static readonly minimumVersion = "0.12.0";
|
|
9
12
|
|
|
10
13
|
/** The API of the akanjs the app installed: the workspace package inside this repo, akanjs's vendored copy elsewhere. */
|
|
11
14
|
static entryOf(appDir: string): string {
|
|
@@ -22,9 +25,9 @@ export class NativeApi {
|
|
|
22
25
|
throw new Error(`The installed akanjs has no native build at ${entry}; update akanjs.`);
|
|
23
26
|
const api = (await import(entry)) as NativeBuildApiModule;
|
|
24
27
|
const major = Number(api.API_VERSION.split(".")[0]);
|
|
25
|
-
if (major !== NativeApi.supportedMajor)
|
|
28
|
+
if (major !== NativeApi.supportedMajor || Bun.semver.order(api.API_VERSION, NativeApi.minimumVersion) < 0)
|
|
26
29
|
throw new Error(
|
|
27
|
-
`The native build API is ${api.API_VERSION}, and this akan CLI speaks ${NativeApi.supportedMajor}.x; install matching versions of akanjs and @akanjs/cli.`,
|
|
30
|
+
`The native build API is ${api.API_VERSION}, and this akan CLI speaks ${NativeApi.minimumVersion} or a later ${NativeApi.supportedMajor}.x; install matching versions of akanjs and @akanjs/cli.`,
|
|
28
31
|
);
|
|
29
32
|
return api;
|
|
30
33
|
}
|