@akanjs/devkit 3.0.0-beta.26 → 3.0.0-beta.27
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/csrE2e/csrE2eHarness.fixture.ts +55 -5
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- 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 +178 -6
- package/frontendBuild/csrDevModuleCompiler.ts +89 -18
- package/frontendBuild/csrDevPaths.ts +1 -0
- package/frontendBuild/csrDevResolver.ts +89 -9
- package/frontendBuild/ssrDevBundler.ts +1 -1
- package/mobile/nativeApp.test.ts +15 -0
- package/mobile/nativeApp.ts +54 -14
- package/mobile/nativeAppLine.test.ts +62 -0
- package/mobile/nativeAppLine.ts +63 -0
- package/package.json +2 -2
|
@@ -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,63 @@ 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
|
+
await Bun.write(file("node_modules/with-bin/index.mjs"), "#!/usr/bin/env node\nexport const a = 1;\n");
|
|
121
|
+
await Bun.write(file("node_modules/awaits/index.mjs"), "export const a = await Promise.resolve(1);\n");
|
|
122
|
+
await Bun.write(file("apps/demo/ui/awaits.ts"), "export const a = await Promise.resolve(1);\n");
|
|
123
|
+
await Bun.write(
|
|
124
|
+
file("node_modules/barrel/package.json"),
|
|
125
|
+
JSON.stringify({
|
|
126
|
+
name: "barrel",
|
|
127
|
+
type: "module",
|
|
128
|
+
main: "index.js",
|
|
129
|
+
sideEffects: false,
|
|
130
|
+
imports: {
|
|
131
|
+
"#where": { node: "./where.node.js", default: "./where.browser.js" },
|
|
132
|
+
"#other": { node: "./other.node.js", default: "./other.browser.js" },
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
await Bun.write(
|
|
137
|
+
file("node_modules/barrel/index.js"),
|
|
138
|
+
'export { used } from "./used.js";\nexport { unused } from "./unused.js";\nexport { other } from "#other";\n',
|
|
139
|
+
);
|
|
140
|
+
await Bun.write(file("node_modules/barrel/used.js"), "export const used = 1;\n");
|
|
141
|
+
await Bun.write(
|
|
142
|
+
file("node_modules/barrel/unused.js"),
|
|
143
|
+
'import { where } from "#where";\nexport const unused = where;\n',
|
|
144
|
+
);
|
|
145
|
+
await Bun.write(
|
|
146
|
+
file("node_modules/barrel/where.node.js"),
|
|
147
|
+
'import path from "node:path";\nexport const where = path.sep;\n',
|
|
148
|
+
);
|
|
149
|
+
await Bun.write(file("node_modules/barrel/where.browser.js"), 'export const where = "browser";\n');
|
|
150
|
+
await Bun.write(
|
|
151
|
+
file("node_modules/barrel/other.node.js"),
|
|
152
|
+
'import path from "node:path";\nexport const other = path.sep;\n',
|
|
153
|
+
);
|
|
154
|
+
await Bun.write(file("node_modules/barrel/other.browser.js"), 'export const other = "browser";\n');
|
|
155
|
+
await Bun.write(file("apps/demo/entry.ts"), 'import { used } from "barrel";\nexport const run = used;\n');
|
|
156
|
+
await Bun.write(
|
|
157
|
+
file("node_modules/inspectish/package.json"),
|
|
158
|
+
JSON.stringify({ name: "inspectish", main: "index.js", browser: { "./util.inspect.js": false, fs: false } }),
|
|
159
|
+
);
|
|
160
|
+
await Bun.write(
|
|
161
|
+
file("node_modules/inspectish/index.js"),
|
|
162
|
+
'var inspect = require("./util.inspect");\nvar fs = require("fs");\nmodule.exports = { inspect, fs };\n',
|
|
163
|
+
);
|
|
164
|
+
await Bun.write(file("node_modules/inspectish/util.inspect.js"), 'module.exports = require("util").inspect;\n');
|
|
65
165
|
});
|
|
66
166
|
|
|
67
167
|
afterAll(async () => {
|
|
@@ -77,7 +177,8 @@ describe("CsrDevModuleCompiler", () => {
|
|
|
77
177
|
expect(factory).toContain("var __toESM = (mod, isNodeMode, target) => (");
|
|
78
178
|
expect(factory).not.toContain("__akan.reExport");
|
|
79
179
|
expect(factory).not.toContain("__akan.toESM");
|
|
80
|
-
expect(
|
|
180
|
+
expect(factory).not.toContain("__akanMeta");
|
|
181
|
+
expect(run(module)).toMatchObject({ parts: { a: 1 }, wrapped: { b: 2 } });
|
|
81
182
|
});
|
|
82
183
|
|
|
83
184
|
test.each([
|
|
@@ -86,4 +187,75 @@ describe("CsrDevModuleCompiler", () => {
|
|
|
86
187
|
])("a %s call with no definition anywhere still stops the build", async (name, relative) => {
|
|
87
188
|
await expect(compile(relative)).rejects.toThrow(`Bun's ${name} helper no longer reads`);
|
|
88
189
|
});
|
|
190
|
+
|
|
191
|
+
test("jotai's and zustand's import.meta.env checks run as a classic script and take the ESM bundle's branch", async () => {
|
|
192
|
+
const [{ modules: jotaiModules }, { modules: zustandModules }] = await Promise.all([
|
|
193
|
+
compile("node_modules/jotai/esm/vanilla.mjs"),
|
|
194
|
+
compile("node_modules/zustand/esm/middleware.mjs"),
|
|
195
|
+
]);
|
|
196
|
+
const [jotaiModule] = jotaiModules;
|
|
197
|
+
const [zustandModule] = zustandModules;
|
|
198
|
+
expect(jotaiModule?.factory).not.toContain("import.meta");
|
|
199
|
+
expect(zustandModule?.factory).not.toContain("import.meta");
|
|
200
|
+
const labeled = Object.assign((run(jotaiModule).atom as (read: unknown) => object)(null), { debugLabel: "count" });
|
|
201
|
+
expect(String(labeled)).toBe("atom1:count");
|
|
202
|
+
expect((run(zustandModule).devtools as (fn: unknown) => unknown)("prod")).toBe("dev");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("an app module reads import.meta as a browser module does, on its own path and with no env", async () => {
|
|
206
|
+
const [module] = (await compile("apps/demo/ui/meta.ts")).modules;
|
|
207
|
+
expect(module?.factory).not.toContain("file://");
|
|
208
|
+
expect(run(module)).toEqual({
|
|
209
|
+
url: `${origin}/apps/demo/ui/meta.ts`,
|
|
210
|
+
env: undefined,
|
|
211
|
+
asset: `${origin}/apps/demo/ui/x.wasm`,
|
|
212
|
+
label: "import.meta.env",
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("a file or re-export the browser build tree-shook away resolves with the browser condition", async () => {
|
|
217
|
+
const { modules, resolver } = await compile("apps/demo/entry.ts", { prepass: true });
|
|
218
|
+
const ids = modules.map((module) => module.id);
|
|
219
|
+
expect(ids).toContain("node_modules/barrel/unused.js");
|
|
220
|
+
expect(ids).toContain("node_modules/barrel/where.browser.js");
|
|
221
|
+
expect(ids).not.toContain("node_modules/barrel/where.node.js");
|
|
222
|
+
expect(ids).toContain("node_modules/barrel/other.browser.js");
|
|
223
|
+
expect(ids).not.toContain("node_modules/barrel/other.node.js");
|
|
224
|
+
expect([...resolver.fallbacks]).toEqual([]);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("what a package's browser field maps to false is an empty stub, not the file or the Node built-in", async () => {
|
|
228
|
+
const { modules } = await compile("node_modules/inspectish/index.js");
|
|
229
|
+
expect(modules.map((module) => module.id)).toEqual(["node_modules/inspectish/index.js"]);
|
|
230
|
+
expect(modules[0]?.deps.sort()).toEqual(["stub:empty:fs", "stub:empty:node_modules/inspectish/util.inspect.js"]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a package's #! line is a comment in its factory, so the package loads", async () => {
|
|
234
|
+
const [module] = (await compile("node_modules/with-bin/index.mjs")).modules;
|
|
235
|
+
expect(module?.factory).not.toContain("#!");
|
|
236
|
+
expect(run(module)).toEqual({ a: 1 });
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("a package file Bun cannot build as CommonJS is left out, named in the log, and the rest still builds", async () => {
|
|
240
|
+
logged.length = 0;
|
|
241
|
+
const { modules } = await compile(["node_modules/awaits/index.mjs", "node_modules/zustand/esm/middleware.mjs"]);
|
|
242
|
+
const left = modules.find((module) => module.id === "node_modules/awaits/index.mjs");
|
|
243
|
+
expect(modules.map((module) => module.id)).toContain("node_modules/zustand/esm/middleware.mjs");
|
|
244
|
+
expect(left?.deps).toEqual([]);
|
|
245
|
+
expect(() => run(left)).toThrow(
|
|
246
|
+
"[akan-csr] node_modules/awaits/index.mjs was left out of the dev registry: a top-level await",
|
|
247
|
+
);
|
|
248
|
+
expect(logged).toEqual([
|
|
249
|
+
expect.stringContaining(
|
|
250
|
+
"[csr-dev] node_modules/awaits/index.mjs is left out of the dev registry: a top-level await",
|
|
251
|
+
),
|
|
252
|
+
]);
|
|
253
|
+
expect(logged[0]).toContain("(node_modules/awaits/index.mjs:1)");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("app code Bun cannot build as CommonJS still fails the build with Bun's messages", async () => {
|
|
257
|
+
const failure = await compile("apps/demo/ui/awaits.ts").catch((error: unknown) => error);
|
|
258
|
+
expect(failure).toBeInstanceOf(AggregateError);
|
|
259
|
+
expect(String((failure as AggregateError).errors[0])).toContain('"await" can only be used');
|
|
260
|
+
});
|
|
89
261
|
});
|
|
@@ -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";
|
|
@@ -82,6 +83,7 @@ export class CsrDevModuleCompiler {
|
|
|
82
83
|
while (frontier.length > 0) {
|
|
83
84
|
const refusedVendors = refuseNewVendor ? frontier.filter((file) => CsrDevPaths.isVendorFile(file)) : [];
|
|
84
85
|
if (refusedVendors.length > 0) return { modules: [...compiled.values()], refusedVendors, unresolved: [] };
|
|
86
|
+
await this.#resolver.cover(frontier);
|
|
85
87
|
const round = await this.#compileRound(frontier);
|
|
86
88
|
if (round.misses.length > 0 && !this.#resolver.prepassDone && refusePrepass)
|
|
87
89
|
return { modules: [...compiled.values()], refusedVendors: [], unresolved: round.misses };
|
|
@@ -138,21 +140,29 @@ export class CsrDevModuleCompiler {
|
|
|
138
140
|
),
|
|
139
141
|
);
|
|
140
142
|
const misses: string[] = [];
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
143
|
+
let result: Awaited<ReturnType<typeof Bun.build>>;
|
|
144
|
+
try {
|
|
145
|
+
result = await Bun.build({
|
|
146
|
+
entrypoints: files,
|
|
147
|
+
root: this.#paths.root,
|
|
148
|
+
target: "browser",
|
|
149
|
+
format: "cjs",
|
|
150
|
+
reactFastRefresh: !vendor,
|
|
151
|
+
sourcemap: vendor ? "none" : "external",
|
|
152
|
+
naming: { entry: "[dir]/[name].[ext]", asset: "assets/[name]-[hash].[ext]" },
|
|
153
|
+
publicPath: this.#routePrefix,
|
|
154
|
+
metafile: true,
|
|
155
|
+
env: "AKAN_PUBLIC_*",
|
|
156
|
+
//? A factory runs as a classic script, where `import.meta` is a SyntaxError that takes the whole vendor file
|
|
157
|
+
//? down, and Bun's cjs output keeps `import.meta.env` and spells `import.meta.url` as this disk's `file://` path.
|
|
158
|
+
define: { ...this.#context.define, "import.meta": "__akanMeta" },
|
|
159
|
+
optimizeImports: this.#context.optimizeImports,
|
|
160
|
+
plugins: [this.#registryPlugin(vendor, misses)],
|
|
161
|
+
});
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (vendor && error instanceof AggregateError) return await this.#withoutFailed(files, error);
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
156
166
|
const { outputs } = result.metafile as unknown as OutputMetafile;
|
|
157
167
|
const sourceMaps = new Map(
|
|
158
168
|
result.outputs.filter((artifact) => artifact.kind === "sourcemap").map((artifact) => [artifact.path, artifact]),
|
|
@@ -177,12 +187,20 @@ export class CsrDevModuleCompiler {
|
|
|
177
187
|
}
|
|
178
188
|
const code = await artifact.text();
|
|
179
189
|
const id = this.#paths.idOf(file);
|
|
190
|
+
const built = CsrDevModuleCompiler.#factory(id, code);
|
|
191
|
+
const problem = CsrDevFactoryCheck.problemOf(built.factory, built.helpers?.definition);
|
|
192
|
+
if (problem && !vendor)
|
|
193
|
+
throw new Error(`[csr-dev] ${id}: its factory does not parse as a classic script: ${problem}`);
|
|
194
|
+
if (problem) {
|
|
195
|
+
modules.push(this.#leftOut(file, problem));
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
180
198
|
const sourceMap = sourceMaps.get(`${artifact.path}.map`);
|
|
181
199
|
modules.push({
|
|
182
200
|
id,
|
|
183
201
|
file,
|
|
184
202
|
vendor,
|
|
185
|
-
...
|
|
203
|
+
...built,
|
|
186
204
|
sourceMap: sourceMap ? this.#rebaseSourceMap(await sourceMap.text()) : undefined,
|
|
187
205
|
deps: this.#emittedDeps(code),
|
|
188
206
|
mtimeMs: mtimes.get(file) ?? CsrDevPaths.mtimeOf(file),
|
|
@@ -192,6 +210,50 @@ export class CsrDevModuleCompiler {
|
|
|
192
210
|
return { modules, misses, tangled };
|
|
193
211
|
}
|
|
194
212
|
|
|
213
|
+
//? A package file that cannot be a CommonJS factory would fail every module built beside it, and no save fixes a
|
|
214
|
+
//? package: it is left out and named in the log, and the module requiring it gets the reason. App code still fails.
|
|
215
|
+
async #withoutFailed(files: string[], error: AggregateError): Promise<BuildRound> {
|
|
216
|
+
const reasons = new Map<string, string[]>();
|
|
217
|
+
for (const message of error.errors as {
|
|
218
|
+
message?: unknown;
|
|
219
|
+
position?: { file?: unknown; line?: number } | null;
|
|
220
|
+
}[]) {
|
|
221
|
+
const file = message.position?.file;
|
|
222
|
+
if (typeof file !== "string" || !path.isAbsolute(file)) continue;
|
|
223
|
+
const real = CsrDevPaths.realpath(file);
|
|
224
|
+
const at = message.position?.line ? `:${message.position.line}` : "";
|
|
225
|
+
reasons.set(real, [...(reasons.get(real) ?? []), `${String(message.message)} (${this.#paths.idOf(real)}${at})`]);
|
|
226
|
+
}
|
|
227
|
+
const failed = new Set(files.filter((file) => reasons.has(CsrDevPaths.realpath(file))));
|
|
228
|
+
if (failed.size === 0) throw error;
|
|
229
|
+
const rest = files.filter((file) => !failed.has(file));
|
|
230
|
+
const round = rest.length > 0 ? await this.#bunBuild(rest, true) : { modules: [], misses: [], tangled: [] };
|
|
231
|
+
for (const file of failed) {
|
|
232
|
+
const text = (reasons.get(CsrDevPaths.realpath(file)) ?? []).join("; ");
|
|
233
|
+
const awaits = text.includes('"await" can only be used');
|
|
234
|
+
round.modules.push(
|
|
235
|
+
this.#leftOut(file, awaits ? `a top-level await, which a CommonJS factory cannot wait on: ${text}` : text),
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return round;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
#leftOut(file: string, problem: string): CsrDevCompiledModule {
|
|
242
|
+
const real = CsrDevPaths.realpath(file);
|
|
243
|
+
const id = this.#paths.idOf(real);
|
|
244
|
+
this.#app.logger.error(`[csr-dev] ${id} is left out of the dev registry: ${problem}`);
|
|
245
|
+
return {
|
|
246
|
+
id,
|
|
247
|
+
file: real,
|
|
248
|
+
vendor: true,
|
|
249
|
+
factory: CsrDevFactoryCheck.thrower(id, problem),
|
|
250
|
+
helpers: null,
|
|
251
|
+
deps: [],
|
|
252
|
+
mtimeMs: CsrDevPaths.mtimeOf(real),
|
|
253
|
+
hash: "",
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
195
257
|
#registryPlugin(vendor: boolean, misses: string[]): BunPlugin {
|
|
196
258
|
return {
|
|
197
259
|
name: "akan-csr-registry",
|
|
@@ -261,7 +323,11 @@ export class CsrDevModuleCompiler {
|
|
|
261
323
|
// Every rewrite keeps the line count, so a module's source map still lines up inside the factory.
|
|
262
324
|
static #factory(id: string, code: string): { factory: string; helpers: CsrDevSharedHelpers | null } {
|
|
263
325
|
const prefix = CsrDevPaths.modulePrefix;
|
|
264
|
-
|
|
326
|
+
//? Bun keeps a package's `#!` line atop its output, where a script inside a function cannot have one.
|
|
327
|
+
let patched = code
|
|
328
|
+
.replace(/^#!/, "//")
|
|
329
|
+
.replaceAll(`import("${prefix}`, `__akanImport("${prefix}`)
|
|
330
|
+
.replace(/^\/\/# debugId=.*$/m, "");
|
|
265
331
|
//? A file esbuild already bundled (mermaid's parser chunks) defines its own helper under the same name, below Bun's
|
|
266
332
|
//? preamble, and Bun then emits none. Only a copy there is exempt: one in the preamble is Bun's own, changed.
|
|
267
333
|
const marker = patched.search(/^\/\/ /m);
|
|
@@ -279,8 +345,13 @@ export class CsrDevModuleCompiler {
|
|
|
279
345
|
// re-running the store root is the whole update, and bubbling past it would only reach the page modules.
|
|
280
346
|
if (CsrDevModuleCompiler.#storeRoot.test(id)) patched += "\nmodule.hot.accept();";
|
|
281
347
|
const shared = CsrDevModuleCompiler.#shareHelpers(patched);
|
|
348
|
+
//? What `import.meta` is in a browser module: a `url` and no `env`, so `import.meta.env` checks (jotai, zustand)
|
|
349
|
+
//? branch as they do in the ESM bundle. On the header line, outside the preamble that modules share.
|
|
350
|
+
const meta = /(?<![\w$.])__akanMeta(?![\w$])/.test(patched)
|
|
351
|
+
? ` var __akanMeta = { url: new URL(${JSON.stringify(id.startsWith("/") ? id : `/${id}`)}, self.location.href).href };`
|
|
352
|
+
: "";
|
|
282
353
|
return {
|
|
283
|
-
factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {\n${shared?.code ?? patched}\n}`,
|
|
354
|
+
factory: `function (require, module, exports, $RefreshReg$, $RefreshSig$, __akanImport) {${meta}\n${shared?.code ?? patched}\n}`,
|
|
284
355
|
helpers: shared?.helpers ?? null,
|
|
285
356
|
};
|
|
286
357
|
}
|
|
@@ -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. */
|
|
@@ -16,7 +16,7 @@ import { VENDOR_SPECIFIERS } from "./vendorSpecifiers";
|
|
|
16
16
|
//* (the app's client runtime) and every `"use client"` entry; React and the akanjs vendor facets stay the import map's,
|
|
17
17
|
//* so the page keeps one React and one store registry.
|
|
18
18
|
export class SsrDevBundler extends DevRegistryBundler {
|
|
19
|
-
static readonly #formatVersion =
|
|
19
|
+
static readonly #formatVersion = 2;
|
|
20
20
|
static readonly bootstrapKey = "";
|
|
21
21
|
readonly reloadsOnEntryChange = false;
|
|
22
22
|
#discovery: ClientEntryDiscovery | null = null;
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -38,6 +38,21 @@ describe("NativeApp", () => {
|
|
|
38
38
|
expect(admin.outDir("android")).toBe(path.join(appDir, ".akan/mobile/admin/native/android"));
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
+
test("a dev boot that came up says so in one line: platform, device, dev server, time", () => {
|
|
42
|
+
const app = { ...fakeApp(), name: "portal" } as unknown as App;
|
|
43
|
+
const startedAt = performance.now() - 12_400;
|
|
44
|
+
expect(new NativeApp(app, target()).readyLine("macos", { upstream: "http://localhost:8283", startedAt })).toMatch(
|
|
45
|
+
/^portal desktop ready · http:\/\/localhost:8283 · 12\.\ds$/,
|
|
46
|
+
);
|
|
47
|
+
expect(
|
|
48
|
+
new NativeApp(app, target({ name: "admin" })).readyLine("ios", {
|
|
49
|
+
upstream: "http://localhost:8283",
|
|
50
|
+
device: { name: "iPhone 16" },
|
|
51
|
+
startedAt,
|
|
52
|
+
}),
|
|
53
|
+
).toMatch(/^portal\/admin ios ready · iPhone 16 · http:\/\/localhost:8283 · 12\.\ds$/);
|
|
54
|
+
});
|
|
55
|
+
|
|
41
56
|
test("opens a dev build on its target's home, the CSR shell under the locale", () => {
|
|
42
57
|
expect(new NativeApp(fakeApp(), target({ indexPath: "/explore" })).startPath("en")).toBe(
|
|
43
58
|
"/en/explore?csr=true&akanMobileTarget=default&akanMobileIndexPath=%2Fexplore",
|