@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
|
@@ -6,7 +6,7 @@ import type { App } from "../commandDecorators";
|
|
|
6
6
|
import { CsrDevModuleCompiler } from "./csrDevModuleCompiler";
|
|
7
7
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
8
8
|
import { CsrDevResolver } from "./csrDevResolver";
|
|
9
|
-
import type { CsrDevContext } from "./csrDevTypes";
|
|
9
|
+
import type { CsrDevCompiledModule, CsrDevContext } from "./csrDevTypes";
|
|
10
10
|
|
|
11
11
|
const context: CsrDevContext = {
|
|
12
12
|
pageEntries: [],
|
|
@@ -41,20 +41,63 @@ var wrapped = __toESM({ b: 2 });
|
|
|
41
41
|
export { parts, wrapped };
|
|
42
42
|
`;
|
|
43
43
|
|
|
44
|
+
//? The development checks jotai's and zustand's ESM builds ship, as written in jotai/esm/vanilla.mjs.
|
|
45
|
+
const jotai = `let keyCount = 0;
|
|
46
|
+
export function atom(read) {
|
|
47
|
+
const key = \`atom\${++keyCount}\`;
|
|
48
|
+
return {
|
|
49
|
+
read,
|
|
50
|
+
toString() {
|
|
51
|
+
return (import.meta.env ? import.meta.env.MODE : void 0) !== "production" && this.debugLabel ? key + ":" + this.debugLabel : key;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
`;
|
|
56
|
+
const zustand = `export const devtools = (fn) => (import.meta.env ? import.meta.env.MODE : void 0) !== "production" ? "dev" : fn;
|
|
57
|
+
`;
|
|
58
|
+
|
|
44
59
|
describe("CsrDevModuleCompiler", () => {
|
|
45
60
|
let root: string;
|
|
61
|
+
const origin = "http://localhost:8283";
|
|
46
62
|
const file = (relative: string) => path.join(root, relative);
|
|
47
|
-
const
|
|
63
|
+
const logged: string[] = [];
|
|
64
|
+
const compile = async (relative: string | string[], { prepass = false }: { prepass?: boolean } = {}) => {
|
|
65
|
+
const files = (Array.isArray(relative) ? relative : [relative]).map(file);
|
|
48
66
|
const paths = new CsrDevPaths(root);
|
|
67
|
+
const resolver = new CsrDevResolver({ paths, context, entryFiles: prepass ? files : [] });
|
|
68
|
+
if (prepass) await resolver.prepass();
|
|
49
69
|
const compiler = new CsrDevModuleCompiler({
|
|
50
|
-
app: { verbose: () => undefined } as unknown as App,
|
|
70
|
+
app: { verbose: () => undefined, logger: { error: (message: string) => logged.push(message) } } as unknown as App,
|
|
51
71
|
paths,
|
|
52
|
-
resolver
|
|
72
|
+
resolver,
|
|
53
73
|
context,
|
|
54
74
|
outDir: file("out"),
|
|
55
75
|
routePrefix: "/_akan/registry/",
|
|
56
76
|
});
|
|
57
|
-
return await compiler.compile(
|
|
77
|
+
return { resolver, ...(await compiler.compile(files, new Set())) };
|
|
78
|
+
};
|
|
79
|
+
//? As the page runs a factory: a classic script whose only globals are the runtime and the window.
|
|
80
|
+
const run = (module: CsrDevCompiledModule | undefined) => {
|
|
81
|
+
const helpers = new Map<string, Record<string, unknown>>();
|
|
82
|
+
const akan = {
|
|
83
|
+
defineHelpers: (hash: string, define: () => Record<string, unknown>) => helpers.set(hash, define()),
|
|
84
|
+
helpers: (hash: string) => helpers.get(hash),
|
|
85
|
+
};
|
|
86
|
+
if (module?.helpers) new Function("__akan", module.helpers.definition)(akan);
|
|
87
|
+
const factory = new Function("__akan", "self", `return (${module?.factory});`)(akan, {
|
|
88
|
+
location: { href: `${origin}/en/dashboard` },
|
|
89
|
+
});
|
|
90
|
+
const record = { exports: {} as Record<string, unknown> };
|
|
91
|
+
factory.call(
|
|
92
|
+
record.exports,
|
|
93
|
+
() => ({}),
|
|
94
|
+
record,
|
|
95
|
+
record.exports,
|
|
96
|
+
() => undefined,
|
|
97
|
+
() => (type: unknown) => type,
|
|
98
|
+
async () => ({}),
|
|
99
|
+
);
|
|
100
|
+
return record.exports;
|
|
58
101
|
};
|
|
59
102
|
|
|
60
103
|
beforeAll(async () => {
|
|
@@ -62,6 +105,84 @@ describe("CsrDevModuleCompiler", () => {
|
|
|
62
105
|
await Bun.write(file("node_modules/prebundled/chunk.mjs"), prebundled);
|
|
63
106
|
await Bun.write(file("node_modules/bare-reexport/index.mjs"), "export const parts = __reExport({}, { a: 1 });\n");
|
|
64
107
|
await Bun.write(file("node_modules/bare-toesm/index.mjs"), "export const wrapped = __toESM({ b: 2 });\n");
|
|
108
|
+
await Bun.write(file("node_modules/jotai/esm/vanilla.mjs"), jotai);
|
|
109
|
+
await Bun.write(file("node_modules/zustand/esm/middleware.mjs"), zustand);
|
|
110
|
+
await Bun.write(
|
|
111
|
+
file("apps/demo/ui/meta.ts"),
|
|
112
|
+
[
|
|
113
|
+
"export const url = import.meta.url;",
|
|
114
|
+
"export const env = import.meta.env;",
|
|
115
|
+
'export const asset = new URL("./x.wasm", import.meta.url).href;',
|
|
116
|
+
'export const label = "import.meta.env";',
|
|
117
|
+
"",
|
|
118
|
+
].join("\n"),
|
|
119
|
+
);
|
|
120
|
+
//? The shape of an app's useClient.ts: a macro whose module graph reaches server code that reads import.meta.
|
|
121
|
+
await Bun.write(
|
|
122
|
+
file("apps/demo/lib/macroDep.ts"),
|
|
123
|
+
'import { createRequire } from "node:module";\nconst req = createRequire(import.meta.url);\nexport const depUrl = import.meta.url;\nexport const hasRequire = typeof req;\n',
|
|
124
|
+
);
|
|
125
|
+
await Bun.write(
|
|
126
|
+
file("apps/demo/lib/macro.ts"),
|
|
127
|
+
'import { depUrl, hasRequire } from "./macroDep";\nexport const serialized = () => ({ depUrl, hasRequire });\n',
|
|
128
|
+
);
|
|
129
|
+
await Bun.write(
|
|
130
|
+
file("apps/demo/lib/useClient.ts"),
|
|
131
|
+
[
|
|
132
|
+
'import { used } from "barrel";',
|
|
133
|
+
'import { serialized } from "./macro" with { type: "macro" };',
|
|
134
|
+
"export const signal = serialized();",
|
|
135
|
+
"export const own = import.meta.url;",
|
|
136
|
+
'export const label = "import.meta.url";',
|
|
137
|
+
"export const barrel = used;",
|
|
138
|
+
"",
|
|
139
|
+
].join("\n"),
|
|
140
|
+
);
|
|
141
|
+
await Bun.write(file("node_modules/with-bin/index.mjs"), "#!/usr/bin/env node\nexport const a = 1;\n");
|
|
142
|
+
await Bun.write(file("node_modules/awaits/index.mjs"), "export const a = await Promise.resolve(1);\n");
|
|
143
|
+
await Bun.write(file("apps/demo/ui/awaits.ts"), "export const a = await Promise.resolve(1);\n");
|
|
144
|
+
await Bun.write(
|
|
145
|
+
file("node_modules/barrel/package.json"),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
name: "barrel",
|
|
148
|
+
type: "module",
|
|
149
|
+
main: "index.js",
|
|
150
|
+
sideEffects: false,
|
|
151
|
+
imports: {
|
|
152
|
+
"#where": { node: "./where.node.js", default: "./where.browser.js" },
|
|
153
|
+
"#other": { node: "./other.node.js", default: "./other.browser.js" },
|
|
154
|
+
},
|
|
155
|
+
}),
|
|
156
|
+
);
|
|
157
|
+
await Bun.write(
|
|
158
|
+
file("node_modules/barrel/index.js"),
|
|
159
|
+
'export { used } from "./used.js";\nexport { unused } from "./unused.js";\nexport { other } from "#other";\n',
|
|
160
|
+
);
|
|
161
|
+
await Bun.write(file("node_modules/barrel/used.js"), "export const used = 1;\n");
|
|
162
|
+
await Bun.write(
|
|
163
|
+
file("node_modules/barrel/unused.js"),
|
|
164
|
+
'import { where } from "#where";\nexport const unused = where;\n',
|
|
165
|
+
);
|
|
166
|
+
await Bun.write(
|
|
167
|
+
file("node_modules/barrel/where.node.js"),
|
|
168
|
+
'import path from "node:path";\nexport const where = path.sep;\n',
|
|
169
|
+
);
|
|
170
|
+
await Bun.write(file("node_modules/barrel/where.browser.js"), 'export const where = "browser";\n');
|
|
171
|
+
await Bun.write(
|
|
172
|
+
file("node_modules/barrel/other.node.js"),
|
|
173
|
+
'import path from "node:path";\nexport const other = path.sep;\n',
|
|
174
|
+
);
|
|
175
|
+
await Bun.write(file("node_modules/barrel/other.browser.js"), 'export const other = "browser";\n');
|
|
176
|
+
await Bun.write(file("apps/demo/entry.ts"), 'import { used } from "barrel";\nexport const run = used;\n');
|
|
177
|
+
await Bun.write(
|
|
178
|
+
file("node_modules/inspectish/package.json"),
|
|
179
|
+
JSON.stringify({ name: "inspectish", main: "index.js", browser: { "./util.inspect.js": false, fs: false } }),
|
|
180
|
+
);
|
|
181
|
+
await Bun.write(
|
|
182
|
+
file("node_modules/inspectish/index.js"),
|
|
183
|
+
'var inspect = require("./util.inspect");\nvar fs = require("fs");\nmodule.exports = { inspect, fs };\n',
|
|
184
|
+
);
|
|
185
|
+
await Bun.write(file("node_modules/inspectish/util.inspect.js"), 'module.exports = require("util").inspect;\n');
|
|
65
186
|
});
|
|
66
187
|
|
|
67
188
|
afterAll(async () => {
|
|
@@ -77,7 +198,8 @@ describe("CsrDevModuleCompiler", () => {
|
|
|
77
198
|
expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
|
|
78
199
|
expect(factory).not.toContain("__akan.reExport");
|
|
79
200
|
expect(factory).not.toContain("__akan.toESM");
|
|
80
|
-
expect(
|
|
201
|
+
expect(factory).not.toContain("__akanMeta");
|
|
202
|
+
expect(run(module)).toMatchObject({ parts: { a: 1 }, wrapped: { b: 2 } });
|
|
81
203
|
});
|
|
82
204
|
|
|
83
205
|
test.each([
|
|
@@ -86,4 +208,86 @@ describe("CsrDevModuleCompiler", () => {
|
|
|
86
208
|
])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
|
|
87
209
|
await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
|
|
88
210
|
});
|
|
211
|
+
|
|
212
|
+
test("jotai's and zustand's import.meta.env checks run as a classic script and take the ESM bundle's branch", async () => {
|
|
213
|
+
const [{ modules: jotaiModules }, { modules: zustandModules }] = await Promise.all([
|
|
214
|
+
compile("node_modules/jotai/esm/vanilla.mjs"),
|
|
215
|
+
compile("node_modules/zustand/esm/middleware.mjs"),
|
|
216
|
+
]);
|
|
217
|
+
const [jotaiModule] = jotaiModules;
|
|
218
|
+
const [zustandModule] = zustandModules;
|
|
219
|
+
expect(jotaiModule?.factory).not.toContain("import.meta");
|
|
220
|
+
expect(zustandModule?.factory).not.toContain("import.meta");
|
|
221
|
+
const labeled = Object.assign((run(jotaiModule).atom as (read: unknown) => object)(null), { debugLabel: "count" });
|
|
222
|
+
expect(String(labeled)).toBe("atom1:count");
|
|
223
|
+
expect((run(zustandModule).devtools as (fn: unknown) => unknown)("prod")).toBe("dev");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("an app module reads import.meta as a browser module does, on its own path and with no env", async () => {
|
|
227
|
+
const [module] = (await compile("apps/demo/ui/meta.ts")).modules;
|
|
228
|
+
expect(module?.factory).not.toContain("file://");
|
|
229
|
+
expect(run(module)).toEqual({
|
|
230
|
+
url: `${origin}/apps/demo/ui/meta.ts`,
|
|
231
|
+
env: undefined,
|
|
232
|
+
asset: `${origin}/apps/demo/ui/x.wasm`,
|
|
233
|
+
label: "import.meta.env",
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a macro runs with its modules' own import.meta, and the file importing it still reads the browser's", async () => {
|
|
238
|
+
const { modules } = await compile("apps/demo/lib/useClient.ts", { prepass: true });
|
|
239
|
+
const module = modules.find((compiled) => compiled.id === "apps/demo/lib/useClient.ts");
|
|
240
|
+
expect(module?.deps.some((dep) => dep.includes(`${path.join("node_modules", "barrel")}${path.sep}`))).toBe(true);
|
|
241
|
+
expect(run(module)).toMatchObject({
|
|
242
|
+
signal: { depUrl: Bun.pathToFileURL(file("apps/demo/lib/macroDep.ts")).href, hasRequire: "function" },
|
|
243
|
+
own: `${origin}/apps/demo/lib/useClient.ts`,
|
|
244
|
+
label: "import.meta.url",
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("a file or re-export the browser build tree-shook away resolves with the browser condition", async () => {
|
|
249
|
+
const { modules, resolver } = await compile("apps/demo/entry.ts", { prepass: true });
|
|
250
|
+
const ids = modules.map((module) => module.id);
|
|
251
|
+
expect(ids).toContain("node_modules/barrel/unused.js");
|
|
252
|
+
expect(ids).toContain("node_modules/barrel/where.browser.js");
|
|
253
|
+
expect(ids).not.toContain("node_modules/barrel/where.node.js");
|
|
254
|
+
expect(ids).toContain("node_modules/barrel/other.browser.js");
|
|
255
|
+
expect(ids).not.toContain("node_modules/barrel/other.node.js");
|
|
256
|
+
expect([...resolver.fallbacks]).toEqual([]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("what a package's browser field maps to false is an empty stub, not the file or the Node built-in", async () => {
|
|
260
|
+
const { modules } = await compile("node_modules/inspectish/index.js");
|
|
261
|
+
expect(modules.map((module) => module.id)).toEqual(["node_modules/inspectish/index.js"]);
|
|
262
|
+
expect(modules[0]?.deps.sort()).toEqual(["stub:empty:fs", "stub:empty:node_modules/inspectish/util.inspect.js"]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a package's #! line is a comment in its factory, so the package loads", async () => {
|
|
266
|
+
const [module] = (await compile("node_modules/with-bin/index.mjs")).modules;
|
|
267
|
+
expect(module?.factory).not.toContain("#!");
|
|
268
|
+
expect(run(module)).toEqual({ a: 1 });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("a package file Bun cannot build as CommonJS is left out, named in the log, and the rest still builds", async () => {
|
|
272
|
+
logged.length = 0;
|
|
273
|
+
const { modules } = await compile(["node_modules/awaits/index.mjs", "node_modules/zustand/esm/middleware.mjs"]);
|
|
274
|
+
const left = modules.find((module) => module.id === "node_modules/awaits/index.mjs");
|
|
275
|
+
expect(modules.map((module) => module.id)).toContain("node_modules/zustand/esm/middleware.mjs");
|
|
276
|
+
expect(left?.deps).toEqual([]);
|
|
277
|
+
expect(() => run(left)).toThrow(
|
|
278
|
+
"[akan-csr] node_modules/awaits/index.mjs was left out of the dev registry: a top-level await",
|
|
279
|
+
);
|
|
280
|
+
expect(logged).toEqual([
|
|
281
|
+
expect.stringContaining(
|
|
282
|
+
"[csr-dev] node_modules/awaits/index.mjs is left out of the dev registry: a top-level await",
|
|
283
|
+
),
|
|
284
|
+
]);
|
|
285
|
+
expect(logged[0]).toContain("(node_modules/awaits/index.mjs:1)");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("app code Bun cannot build as CommonJS still fails the build with Bun's messages", async () => {
|
|
289
|
+
const failure = await compile("apps/demo/ui/awaits.ts").catch((error: unknown) => error);
|
|
290
|
+
expect(failure).toBeInstanceOf(AggregateError);
|
|
291
|
+
expect(String((failure as AggregateError).errors[0])).toContain('"await" can only be used');
|
|
292
|
+
});
|
|
89
293
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { BunPlugin } from "bun";
|
|
3
3
|
import type { App } from "../commandDecorators";
|
|
4
|
+
import { CsrDevFactoryCheck } from "./csrDevFactoryCheck";
|
|
4
5
|
import { CsrDevPaths } from "./csrDevPaths";
|
|
5
6
|
import { CsrDevResolver } from "./csrDevResolver";
|
|
6
7
|
import type { CsrDevCompiledModule, CsrDevContext, CsrDevSharedHelpers } from "./csrDevTypes";
|
|
@@ -43,6 +44,7 @@ export interface CsrDevCompileResult {
|
|
|
43
44
|
//* modules it changed instead of reloading one scope-hoisted file.
|
|
44
45
|
export class CsrDevModuleCompiler {
|
|
45
46
|
static readonly #storeRoot = /(?:^|\/)lib\/st\.ts$/;
|
|
47
|
+
static readonly #macroImport = /\b(?:with|assert)\s*\{\s*type\s*:\s*["']macro["']/;
|
|
46
48
|
static readonly #factoryArgument =
|
|
47
49
|
/(?<![\w$.])(?:require|module|exports|__akanImport|\$RefreshReg\$|\$RefreshSig\$)(?![\w$]|\s*:)/;
|
|
48
50
|
static readonly #helperPatches = [
|
|
@@ -82,6 +84,7 @@ export class CsrDevModuleCompiler {
|
|
|
82
84
|
while (frontier.length > 0) {
|
|
83
85
|
const refusedVendors = refuseNewVendor ? frontier.filter((file) => CsrDevPaths.isVendorFile(file)) : [];
|
|
84
86
|
if (refusedVendors.length > 0) return { modules: [...compiled.values()], refusedVendors, unresolved: [] };
|
|
87
|
+
await this.#resolver.cover(frontier);
|
|
85
88
|
const round = await this.#compileRound(frontier);
|
|
86
89
|
if (round.misses.length > 0 && !this.#resolver.prepassDone && refusePrepass)
|
|
87
90
|
return { modules: [...compiled.values()], refusedVendors: [], unresolved: round.misses };
|
|
@@ -138,21 +141,30 @@ export class CsrDevModuleCompiler {
|
|
|
138
141
|
),
|
|
139
142
|
);
|
|
140
143
|
const misses: string[] = [];
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
144
|
+
const macroOutputs = vendor ? new Map<string, string>() : await this.#evaluateMacros(files);
|
|
145
|
+
let result: Awaited<ReturnType<typeof Bun.build>>;
|
|
146
|
+
try {
|
|
147
|
+
result = await Bun.build({
|
|
148
|
+
entrypoints: files,
|
|
149
|
+
root: this.#paths.root,
|
|
150
|
+
target: "browser",
|
|
151
|
+
format: "cjs",
|
|
152
|
+
reactFastRefresh: !vendor,
|
|
153
|
+
sourcemap: vendor ? "none" : "external",
|
|
154
|
+
naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
|
|
155
|
+
publicPath: this.#routePrefix,
|
|
156
|
+
metafile: true,
|
|
157
|
+
env: "AKAN_PUBLIC_*",
|
|
158
|
+
//? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
|
|
159
|
+
//? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
|
|
160
|
+
define: { ...this.#context.define, "import.meta": "__akanMeta" },
|
|
161
|
+
optimizeImports: this.#context.optimizeImports,
|
|
162
|
+
plugins: [this.#registryPlugin(vendor, misses, macroOutputs)],
|
|
163
|
+
});
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (vendor && error instanceof AggregateError) return await this.#withoutFailed(files, error);
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
156
168
|
const { outputs } = result.metafile as unknown as OutputMetafile;
|
|
157
169
|
const sourceMaps = new Map(
|
|
158
170
|
result.outputs.filter((artifact) => artifact.kind === "sourcemap").map((artifact) => [artifact.path, artifact]),
|
|
@@ -177,12 +189,20 @@ export class CsrDevModuleCompiler {
|
|
|
177
189
|
}
|
|
178
190
|
const code = await artifact.text();
|
|
179
191
|
const id = this.#paths.idOf(file);
|
|
192
|
+
const built = CsrDevModuleCompiler.#factory(id, code);
|
|
193
|
+
const problem = CsrDevFactoryCheck.problemOf(built.factory, built.helpers?.definition);
|
|
194
|
+
if (problem && !vendor)
|
|
195
|
+
throw new Error(`[csr-dev] ${id}: its factory does not parse as a classic script: ${problem}`);
|
|
196
|
+
if (problem) {
|
|
197
|
+
modules.push(this.#leftOut(file, problem));
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
180
200
|
const sourceMap = sourceMaps.get(`${artifact.path}.map`);
|
|
181
201
|
modules.push({
|
|
182
202
|
id,
|
|
183
203
|
file,
|
|
184
204
|
vendor,
|
|
185
|
-
...
|
|
205
|
+
...built,
|
|
186
206
|
sourceMap: sourceMap ? this.#rebaseSourceMap(await sourceMap.text()) : undefined,
|
|
187
207
|
deps: this.#emittedDeps(code),
|
|
188
208
|
mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
|
|
@@ -192,11 +212,96 @@ export class CsrDevModuleCompiler {
|
|
|
192
212
|
return { modules, misses, tangled };
|
|
193
213
|
}
|
|
194
214
|
|
|
195
|
-
|
|
215
|
+
//? A package file that cannot be a CommonJS factory would fail every module built beside it, and no save fixes a
|
|
216
|
+
//? package: it is left out and named in the log, and the module requiring it gets the reason. App code still fails.
|
|
217
|
+
async #withoutFailed(files: string[], error: AggregateError): Promise<BuildRound> {
|
|
218
|
+
const reasons = new Map<string, string[]>();
|
|
219
|
+
for (const message of error.errors as {
|
|
220
|
+
message?: unknown;
|
|
221
|
+
position?: { file?: unknown; line?: number } | null;
|
|
222
|
+
}[]) {
|
|
223
|
+
const file = message.position?.file;
|
|
224
|
+
if (typeof file !== "string" || !path.isAbsolute(file)) continue;
|
|
225
|
+
const real = CsrDevPaths.realpath(file);
|
|
226
|
+
const at = message.position?.line ? `:${message.position.line}` : "";
|
|
227
|
+
reasons.set(real, [...(reasons.get(real) ?? []), `${String(message.message)} (${this.#paths.idOf(real)}${at})`]);
|
|
228
|
+
}
|
|
229
|
+
const failed = new Set(files.filter((file) => reasons.has(CsrDevPaths.realpath(file))));
|
|
230
|
+
if (failed.size === 0) throw error;
|
|
231
|
+
const rest = files.filter((file) => !failed.has(file));
|
|
232
|
+
const round = rest.length > 0 ? await this.#bunBuild(rest, true) : { modules: [], misses: [], tangled: [] };
|
|
233
|
+
for (const file of failed) {
|
|
234
|
+
const text = (reasons.get(CsrDevPaths.realpath(file)) ?? []).join("; ");
|
|
235
|
+
const awaits = text.includes('"await" can only be used');
|
|
236
|
+
round.modules.push(
|
|
237
|
+
this.#leftOut(file, awaits ? `a top-level await, which a CommonJS factory cannot wait on: ${text}` : text),
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
return round;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#leftOut(file: string, problem: string): CsrDevCompiledModule {
|
|
244
|
+
const real = CsrDevPaths.realpath(file);
|
|
245
|
+
const id = this.#paths.idOf(real);
|
|
246
|
+
this.#app.logger.error(`[csr-dev] ${id} is left out of the dev registry: ${problem}`);
|
|
247
|
+
return {
|
|
248
|
+
id,
|
|
249
|
+
file: real,
|
|
250
|
+
vendor: true,
|
|
251
|
+
factory: CsrDevFactoryCheck.thrower(id, problem),
|
|
252
|
+
helpers: null,
|
|
253
|
+
deps: [],
|
|
254
|
+
mtimeMs: CsrDevPaths.mtimeOf(real),
|
|
255
|
+
hash: "",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
//? Bun applies `define` to the modules a macro imports too, and runs them in this process, where the `import.meta`
|
|
260
|
+
//? rewrite leaves a free `__akanMeta` that no factory header declares. So a file importing a macro has its macros run
|
|
261
|
+
//? first by a build of its own, with every other import left as written, and the module build reads that output.
|
|
262
|
+
async #evaluateMacros(files: string[]): Promise<Map<string, string>> {
|
|
263
|
+
const sources = await Promise.all(files.map(async (file) => [file, await Bun.file(file).text()] as const));
|
|
264
|
+
const importers = sources.filter(([, source]) => CsrDevModuleCompiler.#macroImport.test(source));
|
|
265
|
+
const outputs = await Promise.all(
|
|
266
|
+
importers.map(async ([file]) => {
|
|
267
|
+
const result = await Bun.build({
|
|
268
|
+
entrypoints: [file],
|
|
269
|
+
target: "browser",
|
|
270
|
+
format: "esm",
|
|
271
|
+
plugins: [
|
|
272
|
+
{
|
|
273
|
+
name: "akan-csr-macro-pass",
|
|
274
|
+
setup: (build) =>
|
|
275
|
+
void build.onResolve({ filter: /.*/ }, (args) =>
|
|
276
|
+
args.importer ? { path: args.path, external: true } : undefined,
|
|
277
|
+
),
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
});
|
|
281
|
+
const [output] = result.outputs;
|
|
282
|
+
if (!output) throw new Error(`[csr-dev] ${this.#paths.idOf(file)}: its macro pass wrote no output`);
|
|
283
|
+
const text = await output.text();
|
|
284
|
+
return [
|
|
285
|
+
[file, text],
|
|
286
|
+
[CsrDevPaths.realpath(file), text],
|
|
287
|
+
] as const;
|
|
288
|
+
}),
|
|
289
|
+
);
|
|
290
|
+
return new Map(outputs.flat());
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#registryPlugin(vendor: boolean, misses: string[], macroOutputs: Map<string, string>): BunPlugin {
|
|
196
294
|
return {
|
|
197
295
|
name: "akan-csr-registry",
|
|
198
296
|
setup: (build) => {
|
|
199
297
|
build.onLoad({ filter: /\.css$/ }, () => ({ contents: "", loader: "js" }));
|
|
298
|
+
if (macroOutputs.size > 0) {
|
|
299
|
+
const exact = [...macroOutputs.keys()].map((file) => file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
300
|
+
build.onLoad({ filter: new RegExp(`^(?:${exact.join("|")})$`) }, (args) => {
|
|
301
|
+
const contents = macroOutputs.get(args.path);
|
|
302
|
+
return contents === undefined ? undefined : { contents, loader: "js" };
|
|
303
|
+
});
|
|
304
|
+
}
|
|
200
305
|
if (!vendor)
|
|
201
306
|
build.onLoad({ filter: /\.tsx$/ }, async (args) => {
|
|
202
307
|
if (CsrDevPaths.isVendorFile(args.path)) return undefined;
|
|
@@ -261,7 +366,11 @@ export class CsrDevModuleCompiler {
|
|
|
261
366
|
// Every rewrite keeps the line count, so a module's source map still lines up inside the factory.
|
|
262
367
|
static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
|
|
263
368
|
const prefix = CsrDevPaths.modulePrefix;
|
|
264
|
-
|
|
369
|
+
//? Bun keeps a package's `#!` line atop its output, where a script inside a function cannot have one.
|
|
370
|
+
let patched = code
|
|
371
|
+
.replace(/^#!/, "//")
|
|
372
|
+
.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`)
|
|
373
|
+
.replace(/^\/\/# debugId=.*$/m, "");
|
|
265
374
|
//? A file esbuild already bundled (mermaid's parser chunks) defines its own helper under the same name, below Bun's
|
|
266
375
|
//? preamble, and Bun then emits none. Only a copy there is exempt: one in the preamble is Bun's own, changed.
|
|
267
376
|
const marker = patched.search(/^\/\/ /m);
|
|
@@ -279,8 +388,13 @@ export class CsrDevModuleCompiler {
|
|
|
279
388
|
// re-running the store root is the whole update, and bubbling past it would only reach the page modules.
|
|
280
389
|
if (CsrDevModuleCompiler.#storeRoot.test(id)) patched += "\nmodule.hot.accept();";
|
|
281
390
|
const shared = CsrDevModuleCompiler.#shareHelpers(patched);
|
|
391
|
+
//? What `import.meta` is in a browser module: a `url` and no `env`, so `import.meta.env` checks (jotai, zustand)
|
|
392
|
+
//? branch as they do in the ESM bundle. On the header line, outside the preamble that modules share.
|
|
393
|
+
const meta = /(?<![\w$.])__akanMeta(?![\w$])/.test(patched)
|
|
394
|
+
? ` var __akanMeta = { url: new URL(${JSON.stringify(id.startsWith("/") ? id : `/${id}`)}, self.location.href).href };`
|
|
395
|
+
: "";
|
|
282
396
|
return {
|
|
283
|
-
factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${shared?.code ?? patched}\n}`,
|
|
397
|
+
factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {${meta}\n${shared?.code ?? patched}\n}`,
|
|
284
398
|
helpers: shared?.helpers ?? null,
|
|
285
399
|
};
|
|
286
400
|
}
|
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
export class CsrDevPaths {
|
|
5
5
|
static readonly modulePrefix = "akan-module:";
|
|
6
6
|
static readonly stubPrefix = "stub:";
|
|
7
|
+
static readonly emptyStubPrefix = "stub:empty:";
|
|
7
8
|
static readonly vendorPrefix = "vendor:";
|
|
8
9
|
readonly root: string;
|
|
9
10
|
|
|
@@ -15,6 +15,12 @@ interface PrepassMetafile {
|
|
|
15
15
|
inputs: Record<string, { imports: MetafileImport[] }>;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
interface BrowserMap {
|
|
19
|
+
/** What the `browser` object maps to `false`: an empty module in a browser build. */
|
|
20
|
+
offFiles: Set<string>;
|
|
21
|
+
offNames: Set<string>;
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
export interface CsrDevResolverOptions {
|
|
19
25
|
paths: CsrDevPaths;
|
|
20
26
|
context: CsrDevContext;
|
|
@@ -36,7 +42,9 @@ export class CsrDevResolver {
|
|
|
36
42
|
readonly #resolution = new Map<string, Map<string, string>>();
|
|
37
43
|
readonly #fallbacks = new Set<string>();
|
|
38
44
|
readonly #runtimeResolved = new Map<string, Set<string>>();
|
|
39
|
-
readonly #browserMaps = new Map<string,
|
|
45
|
+
readonly #browserMaps = new Map<string, BrowserMap | null>();
|
|
46
|
+
/** Files a browser build loaded, so every import they make has its browser resolution recorded. */
|
|
47
|
+
readonly #loaded = new Set<string>();
|
|
40
48
|
readonly #startedAt: number;
|
|
41
49
|
#prepassDone = false;
|
|
42
50
|
|
|
@@ -85,9 +93,52 @@ export class CsrDevResolver {
|
|
|
85
93
|
optimizeImports: this.#context.optimizeImports,
|
|
86
94
|
plugins: [PagesBundleBuilder.createCssStubPlugin()],
|
|
87
95
|
});
|
|
88
|
-
|
|
96
|
+
this.#record((result.metafile as unknown as PrepassMetafile).inputs);
|
|
97
|
+
this.#prepassDone = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//? The browser build drops a side-effect-free barrel's unused re-exports without loading them, yet the registry
|
|
101
|
+
//? compiles every file an import names. Such a file's own imports are resolved by a browser build of its own, one
|
|
102
|
+
//? level deep, not by the runtime resolver's Node conditions (vfile's `#minpath` would reach `node:path`).
|
|
103
|
+
async cover(files: string[]): Promise<void> {
|
|
104
|
+
if (!this.#prepassDone) return;
|
|
105
|
+
const pending = files.filter((file) => CsrDevPaths.isScript(file) && !this.#loaded.has(file));
|
|
106
|
+
if (pending.length === 0) return;
|
|
107
|
+
for (const file of pending) this.#loaded.add(file);
|
|
108
|
+
const entries = new Set(pending);
|
|
109
|
+
const result = await Bun.build({
|
|
110
|
+
entrypoints: pending,
|
|
111
|
+
target: "browser",
|
|
112
|
+
metafile: true,
|
|
113
|
+
throw: false,
|
|
114
|
+
external: [...(this.#context.externals ?? [])],
|
|
115
|
+
env: "AKAN_PUBLIC_*",
|
|
116
|
+
define: this.#context.define,
|
|
117
|
+
plugins: [
|
|
118
|
+
{
|
|
119
|
+
name: "akan-csr-cover",
|
|
120
|
+
//? What an entry imports is resolved, never parsed: a CommonJS stub, since an empty ESM module fails a named
|
|
121
|
+
//? import ("No matching export") and a failed build hands back no metafile at all.
|
|
122
|
+
setup: (build) => {
|
|
123
|
+
build.onLoad({ filter: /.*/ }, (args) =>
|
|
124
|
+
entries.has(CsrDevPaths.realpath(args.path))
|
|
125
|
+
? undefined
|
|
126
|
+
: { contents: "module.exports = {};", loader: "js" },
|
|
127
|
+
);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
//? Left unrecorded when the build fails: those imports keep the runtime resolver's answer, as before.
|
|
133
|
+
if (result.metafile) this.#record((result.metafile as unknown as PrepassMetafile).inputs, entries);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
#record(inputs: PrepassMetafile["inputs"], only?: Set<string>): void {
|
|
89
137
|
for (const [input, { imports }] of Object.entries(inputs)) {
|
|
90
138
|
const importer = CsrDevPaths.realpath(path.resolve(input));
|
|
139
|
+
if (only && !only.has(importer)) continue;
|
|
140
|
+
//? A re-export the build tree-shook keeps no specifier, so its file is resolved again as an entry of its own.
|
|
141
|
+
if (imports.every((record) => record.original)) this.#loaded.add(importer);
|
|
91
142
|
for (const record of imports) {
|
|
92
143
|
if (!record.original) continue;
|
|
93
144
|
if (record.external) {
|
|
@@ -101,11 +152,12 @@ export class CsrDevResolver {
|
|
|
101
152
|
this.#runtimeResolved.get(importer)?.delete(record.original);
|
|
102
153
|
}
|
|
103
154
|
}
|
|
104
|
-
this.#prepassDone = true;
|
|
105
155
|
}
|
|
106
156
|
|
|
107
157
|
resolve(importer: string, specifier: string, kind: "import" | "require" = "import"): string | null {
|
|
108
158
|
if (this.#isInlineSpecifier(specifier)) return CsrDevResolver.inline;
|
|
159
|
+
const disabled = this.#disabledInBrowser(importer, specifier, kind);
|
|
160
|
+
if (disabled) return disabled;
|
|
109
161
|
const known = this.#resolution.get(importer)?.get(specifier);
|
|
110
162
|
const usable = known && this.#stillAnswers(importer, specifier, known, kind) ? known : null;
|
|
111
163
|
const target = usable ?? this.#resolveUnknown(importer, specifier, kind);
|
|
@@ -139,23 +191,51 @@ export class CsrDevResolver {
|
|
|
139
191
|
});
|
|
140
192
|
}
|
|
141
193
|
|
|
194
|
+
//? `false` in a package's `browser` object is an empty module to a browser build (object-inspect's
|
|
195
|
+
//? `./util.inspect.js`, which requires `util`). Its record carries no specifier, so the field is read here.
|
|
196
|
+
#disabledInBrowser(importer: string, specifier: string, kind: "import" | "require"): string | null {
|
|
197
|
+
const map = this.#browserMapOf(path.dirname(importer));
|
|
198
|
+
if (!map || (map.offFiles.size === 0 && map.offNames.size === 0)) return null;
|
|
199
|
+
if (!specifier.startsWith(".") && !path.isAbsolute(specifier))
|
|
200
|
+
return map.offNames.has(specifier) ? `${CsrDevPaths.emptyStubPrefix}${specifier}` : null;
|
|
201
|
+
const target = CsrDevPaths.resolveRelative(path.dirname(importer), specifier, kind);
|
|
202
|
+
return target && map.offFiles.has(CsrDevPaths.realpath(target))
|
|
203
|
+
? `${CsrDevPaths.emptyStubPrefix}${this.#paths.idOf(target)}`
|
|
204
|
+
: null;
|
|
205
|
+
}
|
|
206
|
+
|
|
142
207
|
#hasBrowserMap(dir: string): boolean {
|
|
208
|
+
return this.#browserMapOf(dir) !== null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#browserMapOf(dir: string): BrowserMap | null {
|
|
143
212
|
const cached = this.#browserMaps.get(dir);
|
|
144
213
|
if (cached !== undefined) return cached;
|
|
145
214
|
const pkgFile = path.join(dir, "package.json");
|
|
146
215
|
const parent = path.dirname(dir);
|
|
147
|
-
let
|
|
216
|
+
let map: BrowserMap | null = null;
|
|
148
217
|
if (fs.existsSync(pkgFile)) {
|
|
149
218
|
try {
|
|
150
219
|
const browser = (JSON.parse(fs.readFileSync(pkgFile, "utf8")) as { browser?: unknown }).browser;
|
|
151
|
-
|
|
220
|
+
if (typeof browser === "object" && browser !== null) {
|
|
221
|
+
const off = Object.entries(browser)
|
|
222
|
+
.filter(([, target]) => target === false)
|
|
223
|
+
.map(([key]) => key);
|
|
224
|
+
const files = off
|
|
225
|
+
.filter((key) => key.startsWith("."))
|
|
226
|
+
.map((key) => CsrDevPaths.resolveRelative(dir, key))
|
|
227
|
+
.filter((file): file is string => !!file);
|
|
228
|
+
map = {
|
|
229
|
+
offFiles: new Set(files.map((file) => CsrDevPaths.realpath(file))),
|
|
230
|
+
offNames: new Set(off.filter((key) => !key.startsWith("."))),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
152
233
|
} catch {
|
|
153
234
|
// An unreadable package.json maps nothing; Bun's build fails on it anyway.
|
|
154
|
-
mapped = false;
|
|
155
235
|
}
|
|
156
|
-
} else
|
|
157
|
-
this.#browserMaps.set(dir,
|
|
158
|
-
return
|
|
236
|
+
} else if (parent !== dir) map = this.#browserMapOf(parent);
|
|
237
|
+
this.#browserMaps.set(dir, map);
|
|
238
|
+
return map;
|
|
159
239
|
}
|
|
160
240
|
|
|
161
241
|
/** A module that left the graph takes its records with it. */
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { optimize } from "@tailwindcss/node";
|
|
3
|
-
import type { AkanWebConfig } from "akanjs";
|
|
3
|
+
import type { AkanMobileAppId, AkanWebConfig } from "akanjs";
|
|
4
4
|
import type { BaseBuildArtifact } from "akanjs/server";
|
|
5
5
|
import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
|
|
6
6
|
import { computeRouteSeedIndex, type RouteSeedIndex, saveRouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
@@ -89,7 +89,8 @@ export class SsrBaseArtifactBuilder {
|
|
|
89
89
|
.filter((target) => (target.deepLinks?.domains?.length ?? 0) > 0)
|
|
90
90
|
.map((target) => ({
|
|
91
91
|
targetName: target.name,
|
|
92
|
-
|
|
92
|
+
iosAppId: SsrBaseArtifactBuilder.#appIdOn(target.appId, "ios"),
|
|
93
|
+
androidAppId: SsrBaseArtifactBuilder.#appIdOn(target.appId, "android"),
|
|
93
94
|
domains: target.deepLinks?.domains ?? [],
|
|
94
95
|
iosTeamId: target.deepLinks?.ios?.teamId,
|
|
95
96
|
androidSha256CertFingerprints: target.deepLinks?.android?.sha256CertFingerprints,
|
|
@@ -223,6 +224,10 @@ export class SsrBaseArtifactBuilder {
|
|
|
223
224
|
}
|
|
224
225
|
|
|
225
226
|
// Checked against the written asset, not the compiled text: it is the only stylesheet an SSR render serves.
|
|
227
|
+
static #appIdOn(appId: AkanMobileAppId, platform: "ios" | "android"): string | null {
|
|
228
|
+
return typeof appId === "string" ? appId : (appId[platform] ?? appId.default ?? null);
|
|
229
|
+
}
|
|
230
|
+
|
|
226
231
|
static #warnDroppedImports(app: App, cssRelPath: string, css: string, imported: ImportedStylesheet[]) {
|
|
227
232
|
for (const { cssPath, declaredNames } of imported) {
|
|
228
233
|
if (declaredNames.length === 0 || declaredNames.some((name) => css.includes(`${name}:`))) continue;
|