@backtickjs/bundler 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,26 @@
1
+ import type { JsxElement, Spliceable } from "@backtickjs/core";
2
+
3
+ // The in-flight promise, so two references to one element run its component
4
+ // once. Per bundle, like a function's expansion: an element held at module
5
+ // level is the same object in every bundle, and what its component drew for
6
+ // one request (the request's user) is not the next one's.
7
+ export type ElementExpansions = WeakMap<JsxElement, Promise<Spliceable>>;
8
+
9
+ // Runs a host component and answers with what it drew. The component itself
10
+ // never leaves the host: a server component expands away here, and only the
11
+ // client component it bottoms out in reaches the bundle.
12
+ export function expandJsxElement(
13
+ jsx: JsxElement,
14
+ component: (props: never) => unknown,
15
+ expansions: ElementExpansions,
16
+ ): Promise<Spliceable> {
17
+ const shared = expansions.get(jsx);
18
+ if (shared) {
19
+ return shared;
20
+ }
21
+ const expansion = Promise.resolve(
22
+ component(jsx.props as never) as Spliceable | Promise<Spliceable>,
23
+ );
24
+ expansions.set(jsx, expansion);
25
+ return expansion;
26
+ }
@@ -0,0 +1,15 @@
1
+ import type { ClientScript, Param } from "@backtickjs/core";
2
+
3
+ // What a script's module says about its parameters, the same for every run.
4
+
5
+ // The bindings a hole hands over. A capture hands over none.
6
+ export function bindingsOf(param: Param | undefined): readonly string[] {
7
+ return param?.kind === "splice" ? param.bindings : [];
8
+ }
9
+
10
+ // The binding keys a script captures, in parameter order.
11
+ export function capturesOf(script: ClientScript): readonly string[] {
12
+ return script.module.params.flatMap((param) =>
13
+ param.kind === "capture" ? [param.key] : [],
14
+ );
15
+ }
package/src/bundler.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { Spliceable } from "@backtickjs/core";
2
+ import { buildBundle } from "./bundle/buildBundle.js";
3
+ import { printBundle } from "./print/printBundle.js";
4
+
5
+ export interface BuildOptions {
6
+ // What to bundle.
7
+ readonly input: Spliceable;
8
+ // The packages the client provides, each at its exact version: left out of
9
+ // the bundle, which requires them. A script's import from any other, or
10
+ // needing a version it doesn't have, throws.
11
+ readonly packageVersions: Readonly<Record<string, string>>;
12
+ }
13
+
14
+ export interface OutputOptions {
15
+ // `"es"`, an ES module, for a page; `"cjs"`, CommonJS, for a client that
16
+ // hands the bundle `require` itself, as a React Native app does.
17
+ readonly format: "es" | "cjs";
18
+ // A map into the host files, as Rollup's: `"inline"` appends the map to the
19
+ // code as a `data:` URL, and `"hidden"` answers it alone. None by default.
20
+ // It carries host files' names and lines, not their content, which stays
21
+ // the server's.
22
+ readonly sourcemap?: false | "inline" | "hidden";
23
+ }
24
+
25
+ /** What `generate` writes: the code, and its map where one was asked for. */
26
+ export interface OutputChunk {
27
+ readonly code: string;
28
+ readonly map: string | null;
29
+ }
30
+
31
+ /** A built bundle, written out by `generate`. */
32
+ export interface Bundle {
33
+ generate(options: OutputOptions): OutputChunk;
34
+ }
35
+
36
+ /**
37
+ * What runs your components and hands back what they drew, shaped as Rollup
38
+ * is: a build, then output from it.
39
+ *
40
+ * const bundle = await bundler.build({ input: <Home />, packageVersions });
41
+ * const { code, map } = bundle.generate({ format: "es", sourcemap: "hidden" });
42
+ *
43
+ * The module's default export is the value (CommonJS: `module.exports`). A
44
+ * namespace rather than a bare function, so `bundle` stays a name a caller can
45
+ * give what comes back.
46
+ */
47
+ export const bundler = {
48
+ async build({ input, packageVersions }: BuildOptions): Promise<Bundle> {
49
+ // Scripts come compiled for their framework when their host was built, so
50
+ // the bundle is only put together: nothing is compiled or parsed here.
51
+ const tree = await buildBundle(input, packageVersions);
52
+ return {
53
+ generate: ({ format, sourcemap = false }) => {
54
+ const { code, map } = printBundle(tree, format);
55
+ if (sourcemap === false) {
56
+ return { code, map: null };
57
+ }
58
+ if (sourcemap === "hidden") {
59
+ return { code, map };
60
+ }
61
+ const url = `data:application/json;charset=utf-8,${encodeURIComponent(map)}`;
62
+ return { code: `${code}\n//# sourceMappingURL=${url}`, map };
63
+ },
64
+ };
65
+ },
66
+ };
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ export {
2
+ type Bundle,
3
+ type BuildOptions,
4
+ bundler,
5
+ type OutputChunk,
6
+ type OutputOptions,
7
+ } from "./bundler.js";
@@ -0,0 +1,144 @@
1
+ import type { ClientImport } from "@backtickjs/core";
2
+ import semver from "semver";
3
+
4
+ // What a bundle is written as, and the one place its conventions live: how a
5
+ // name is written, how a literal escapes, and how an element is written as JSX
6
+ // for the framework's compiler. Everything is code, as a string.
7
+
8
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
9
+
10
+ /**
11
+ * What a bundle imports, keyed by specifier and export, each bound once; and
12
+ * the packages it may import them from, which the client provides, by version.
13
+ */
14
+ export interface Names {
15
+ readonly imports: Map<string, { from: string; name: string; local: string }>;
16
+ readonly packageVersions: Readonly<Record<string, string>>;
17
+ }
18
+
19
+ export function createNames(
20
+ packageVersions: Readonly<Record<string, string>>,
21
+ ): Names {
22
+ return { imports: new Map(), packageVersions };
23
+ }
24
+
25
+ // A string, as a literal: `<` escaped, so no `</script>` or `<!--` appears
26
+ // when the bundle is inlined in a page, and the line and paragraph separators,
27
+ // which JSON leaves bare and not every engine reads inside a string.
28
+ export function string(value: string): string {
29
+ return JSON.stringify(value).replace(
30
+ /[<\u2028\u2029]/g,
31
+ (unit) => `\\u${unit.charCodeAt(0).toString(16).padStart(4, "0")}`,
32
+ );
33
+ }
34
+
35
+ export function literal(value: string | number | boolean | null): string {
36
+ if (typeof value === "string") {
37
+ return string(value);
38
+ }
39
+ if (typeof value === "number") {
40
+ if (!Number.isFinite(value)) {
41
+ throw new Error(`${value} has no literal`);
42
+ }
43
+ return Object.is(value, -0) ? "-0" : String(value);
44
+ }
45
+ return String(value);
46
+ }
47
+
48
+ export const undefinedValue = "void 0";
49
+
50
+ export function call(callee: string, args: readonly string[]): string {
51
+ return `${callee}(${args.join(", ")})`;
52
+ }
53
+
54
+ // The body in parentheses, so one that is an object literal is not read as a
55
+ // block.
56
+ export function arrow(params: readonly string[], body: string): string {
57
+ return `(${params.join(", ")}) => (${body})`;
58
+ }
59
+
60
+ export function thunk(body: string): string {
61
+ return arrow([], body);
62
+ }
63
+
64
+ export function array(elements: readonly string[]): string {
65
+ return `[${elements.join(", ")}]`;
66
+ }
67
+
68
+ /** An object literal, each key bare where it can be and a string where not. */
69
+ export function object(
70
+ entries: readonly (readonly [string, string])[],
71
+ ): string {
72
+ const members = entries.map(([key, value]) => `${member(key)}: ${value}`);
73
+ return `{ ${members.join(", ")} }`;
74
+ }
75
+
76
+ // A key as an object literal writes it. `__proto__`, bare or quoted, would set
77
+ // the object's prototype instead, so a key a user may have chosen is computed,
78
+ // which makes it the object's own.
79
+ function member(key: string): string {
80
+ if (key === "__proto__") {
81
+ return `[${string(key)}]`;
82
+ }
83
+ return IDENTIFIER.test(key) ? key : string(key);
84
+ }
85
+
86
+ // The package a module is in: its specifier's first segment, or first two
87
+ // when scoped (`solid-js/web` → `solid-js`, `@scope/pkg/x` → `@scope/pkg`).
88
+ function packageOf(from: string): string {
89
+ const segments = from.split("/");
90
+ return segments.slice(0, from.startsWith("@") ? 2 : 1).join("/");
91
+ }
92
+
93
+ /** An export of a module the client provides, imported by the bundle. */
94
+ export function imported(
95
+ names: Names,
96
+ { from, name, version }: ClientImport<unknown>,
97
+ ): string {
98
+ // Where a script's import is written: a plugin's own (a framework's compile
99
+ // step) are the plugin's, trusted as it is.
100
+ const pkg = packageOf(from);
101
+ const provided = names.packageVersions[pkg];
102
+ if (provided === undefined) {
103
+ const packages = Object.entries(names.packageVersions).map(
104
+ ([provided, at]) => `${provided}@${at}`,
105
+ );
106
+ throw new Error(
107
+ `Can't import \`${name}\` from "${from}": the client provides ` +
108
+ `${packages.join(", ") || "no packages"}.`,
109
+ );
110
+ }
111
+ if (!semver.satisfies(provided, version)) {
112
+ throw new Error(
113
+ `Can't import \`${name}\` from "${from}": it needs ${pkg}@${version}, ` +
114
+ `and the client provides ${pkg}@${provided}.`,
115
+ );
116
+ }
117
+ const key = `${from}\0${name}`;
118
+ let entry = names.imports.get(key);
119
+ if (entry === undefined) {
120
+ entry = { from, name, local: `$i${names.imports.size}` };
121
+ names.imports.set(key, entry);
122
+ }
123
+ return entry.local;
124
+ }
125
+
126
+ /** An import declaration of one export under its local name. */
127
+ export function importDeclaration(
128
+ from: string,
129
+ name: string,
130
+ local: string,
131
+ ): string {
132
+ const exported = IDENTIFIER.test(name) ? name : string(name);
133
+ return `import { ${exported} as ${local} } from ${string(from)};`;
134
+ }
135
+
136
+ // The same import as CommonJS writes it: `const { name: local } = require(…);`.
137
+ export function requireDeclaration(
138
+ from: string,
139
+ name: string,
140
+ local: string,
141
+ ): string {
142
+ const exported = IDENTIFIER.test(name) ? name : string(name);
143
+ return `const { ${exported}: ${local} } = require(${string(from)});`;
144
+ }
@@ -0,0 +1,126 @@
1
+ import type { BundleTree } from "../bundle/buildBundle.js";
2
+ import type { ClientModule } from "@backtickjs/core";
3
+ import { importDeclaration, requireDeclaration, string } from "./code.js";
4
+
5
+ const MODULE_ID = "bundle.js";
6
+
7
+ // A module table's `require`: a module's exports, its entry run the first time
8
+ // it is asked for.
9
+ const RUNTIME = `const $require = (id) => {
10
+ if (!(id in $exports)) {
11
+ const module = { exports: {} };
12
+ $exports[id] = module.exports;
13
+ $modules[id](module, module.exports, $require);
14
+ $exports[id] = module.exports;
15
+ }
16
+ return $exports[id];
17
+ };`;
18
+
19
+ /**
20
+ * A bundle tree as a module whose default export is the tree's root: the value
21
+ * bundled, as its scripts wrote it.
22
+ *
23
+ * The module is its imports, a module table of its scripts, each compiled for
24
+ * its framework when its host was built, and the root. Its imports are the
25
+ * client's to resolve. As an ES module (`"es"`), a page resolves them through
26
+ * an import map; as CommonJS (`"cjs"`), whatever runs it hands it `require`,
27
+ * as a React Native app does for the packages it was built with.
28
+ *
29
+ * Its map leads into the host files its scripts were written in: an index
30
+ * map, each script's own map a section where the script stands. What the
31
+ * bundler wrote around the scripts maps to nothing, since no source wrote it.
32
+ */
33
+ export function printBundle(
34
+ tree: BundleTree,
35
+ format: "es" | "cjs",
36
+ ): { code: string; map: string } {
37
+ const { names } = tree;
38
+ const module = new ModuleWriter();
39
+ if (format === "cjs") {
40
+ // An ES module is strict code, and so are the scripts; CommonJS isn't.
41
+ module.line('"use strict";');
42
+ }
43
+ const declaration = format === "es" ? importDeclaration : requireDeclaration;
44
+ for (const { from, name, local } of names.imports.values()) {
45
+ module.line(declaration(from, name, local));
46
+ }
47
+ // Each script a module table's entry, as webpack's and Metro's are: its body
48
+ // under its id, run once by `$require`. The modules they require are the
49
+ // client's, each imported whole, once.
50
+ const modules = new Map(tree.modules.map(([, entry]) => [entry.id, entry]));
51
+ const dependencies = [
52
+ ...new Set([...modules.values()].flatMap((entry) => entry.dependencies)),
53
+ ];
54
+ dependencies.forEach((specifier, index) =>
55
+ module.line(
56
+ format === "es"
57
+ ? `import * as $module${index} from ${string(specifier)};`
58
+ : `const $module${index} = require(${string(specifier)});`,
59
+ ),
60
+ );
61
+ module.line("const $modules = {");
62
+ for (const [id, entry] of modules) {
63
+ module.write(`${string(id)}: `);
64
+ module.script(entry);
65
+ module.line(",");
66
+ }
67
+ module.line("};");
68
+ module.line("const $exports = {");
69
+ dependencies.forEach((specifier, index) =>
70
+ module.line(`${string(specifier)}: $module${index},`),
71
+ );
72
+ module.line("};");
73
+ module.line(RUNTIME);
74
+ for (const [label, entry] of tree.modules) {
75
+ module.line(`const ${label} = $require(${string(entry.id)}).default;`);
76
+ }
77
+ // Parenthesized, so a root that is a function isn't a declaration.
78
+ module.write(
79
+ format === "es"
80
+ ? `export default (${tree.root});`
81
+ : `module.exports = (${tree.root});`,
82
+ );
83
+
84
+ return { code: module.code, map: module.map() };
85
+ }
86
+
87
+ // The module's code as it is written, and a map of the scripts in it.
88
+ class ModuleWriter {
89
+ code = "";
90
+ #line = 0;
91
+ #column = 0;
92
+ #sections: { offset: { line: number; column: number }; map: object }[] = [];
93
+
94
+ write(text: string): void {
95
+ this.code += text;
96
+ const lines = text.split("\n");
97
+ if (lines.length > 1) {
98
+ this.#line += lines.length - 1;
99
+ this.#column = lines[lines.length - 1]!.length;
100
+ } else {
101
+ this.#column += text.length;
102
+ }
103
+ }
104
+
105
+ line(text: string): void {
106
+ this.write(`${text}\n`);
107
+ }
108
+
109
+ // A script's code, its map a section of the module's where it starts.
110
+ script({ code, map }: ClientModule): void {
111
+ this.#sections.push({
112
+ offset: { line: this.#line, column: this.#column },
113
+ map: JSON.parse(map) as object,
114
+ });
115
+ this.write(code);
116
+ }
117
+
118
+ // An index map: each script's own map, at its offset, decoded by no one here.
119
+ map(): string {
120
+ return JSON.stringify({
121
+ version: 3,
122
+ file: MODULE_ID,
123
+ sections: this.#sections,
124
+ });
125
+ }
126
+ }