@backtickjs/builds 0.0.0-stage → 0.1.9

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jean-François Bisson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,110 @@
1
- # Temporary Holding Version
1
+ # @backtickjs/builds
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keeps a [Backtick](https://backtickjs.com) server drawing for every build of its React Native app that's still in users' hands. A store build carries the modules it was built with, at their versions, for as long as it's installed. Your server moves on. Each build sends an id that names its modules, and the server bundles its screens for exactly those modules, or refuses a build it can't serve.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install @backtickjs/builds
9
+ ```
10
+
11
+ ## How it works
12
+
13
+ 1. **The app declares what it offers to screens** in a module map, one per platform as Metro picks files: `moduleMap.ts` for iOS and Android, and `moduleMap.web.ts` for the web. A module map is a table and nothing else, so it can be read without running it:
14
+
15
+ ```ts
16
+ // moduleMap.ts
17
+ import type { ModuleMap } from "@backtickjs/react-native-client";
18
+
19
+ export const moduleMap = {
20
+ react: () => require("react"),
21
+ "react/jsx-runtime": () => require("react/jsx-runtime"),
22
+ "react-native": () => require("react-native"),
23
+ "react-native-fbsdk-next": () => require("react-native-fbsdk-next"),
24
+ } satisfies ModuleMap;
25
+ ```
26
+
27
+ An entry that decides at runtime, like `native() ? require("…") : {}`, is an error. What a platform lacks, its map leaves out.
28
+
29
+ 2. **Each build gets an id:** a hash of the modules its platform's map offers, each at the installed version of its package. `app.config.ts` puts the ids in the app config:
30
+
31
+ ```ts
32
+ // app.config.ts
33
+ import { buildIds } from "@backtickjs/builds";
34
+ import type { ConfigContext, ExpoConfig } from "expo/config";
35
+
36
+ export default ({ config, projectRoot }: ConfigContext): ExpoConfig =>
37
+ ({
38
+ ...config,
39
+ extra: { ...config.extra, buildIds: buildIds(projectRoot) },
40
+ }) as ExpoConfig;
41
+ ```
42
+
43
+ 3. **The app sends its id** however it reaches its server: a header, a GraphQL variable. `buildIdFrom` from [`@backtickjs/react-native-client`](https://github.com/backtickjs/backtick/tree/main/packages/react-native-client) reads it:
44
+
45
+ ```ts
46
+ const buildId = buildIdFrom(Constants.expoConfig?.extra, Platform.OS);
47
+ ```
48
+
49
+ 4. **The server bundles for that build's modules:**
50
+
51
+ ```ts
52
+ import { createBuilds } from "@backtickjs/builds";
53
+ import { bundler } from "@backtickjs/bundler";
54
+
55
+ const builds = createBuilds({ projectRoot });
56
+
57
+ const modules = builds.modulesOf(buildId);
58
+ const bundle = await bundler.build({ input, modules });
59
+ ```
60
+
61
+ To draw differently for builds that lack a module, ask with `supports` from [`@backtickjs/bundler`](https://github.com/backtickjs/backtick/tree/main/packages/bundler), the same check the bundler makes: `supports(modules, "react-native-fbsdk-next", "^13")`.
62
+
63
+ 5. **Every build is registered before it ships,** in `builds/`, one file per id, committed with the code:
64
+
65
+ ```sh
66
+ npx backtick-builds register
67
+ ```
68
+
69
+ ```json
70
+ {
71
+ "createdAt": "2026-10-12T00:00:00.000Z",
72
+ "deprecated": false,
73
+ "modules": {
74
+ "react": "19.2.3",
75
+ "react/jsx-runtime": "19.2.3",
76
+ "react-native": "0.86.3"
77
+ }
78
+ }
79
+ ```
80
+
81
+ Run `backtick-builds check` in your tests, or let EAS run it: as `eas-build-post-install` in `package.json`, it fails a build that isn't registered, checking the platform being built and letting development builds through.
82
+
83
+ ```json
84
+ "scripts": {
85
+ "eas-build-post-install": "backtick-builds check"
86
+ }
87
+ ```
88
+
89
+ ## Unknown and deprecated builds
90
+
91
+ `modulesOf` throws for a build its server can't draw for:
92
+
93
+ - **`UnknownBuild`**: no id, or one that isn't registered. Every build is registered before it ships, so this means the server or the build was deployed wrong, as when a server is rolled back past a build in users' hands. Answer it as a server error.
94
+ - **`DeprecatedBuild`**: a build you retired by setting `"deprecated": true` in its file. Answer it with something your app shell recognises and turns into "update the app", such as HTTP 426. The shell has to know that answer before the build is retired, so ship it in your first build.
95
+
96
+ ## API
97
+
98
+ - `buildIds(projectRoot)`: the id of the build the app at `projectRoot` now makes, on `ios`, `android` and `web`.
99
+ - `createBuilds({ projectRoot })`: the app's builds for its server. `modulesOf(buildId)` resolves a build, `installed(platform)` is what this code's build carries, and `unregistered(platform)` says why this code's build couldn't ship, or null. Registered builds are read once. Module maps are only read for an id that isn't registered, as in development after a package change.
100
+ - `register(projectRoot)`: what `backtick-builds register` runs. A build already registered keeps its `createdAt` and `deprecated`.
101
+ - `readModuleMap(file)`, `moduleMapFile(projectRoot, platform)`, `installedModules(projectRoot, platform)`, `buildIdOf(modules)`, `readBuilds(directory)`: the steps, for tools of your own.
102
+
103
+ ## Related
104
+
105
+ - [`@backtickjs/react-native-client`](https://github.com/backtickjs/backtick/tree/main/packages/react-native-client): `evaluate` and `buildIdFrom`, in the app
106
+ - [`@backtickjs/bundler`](https://github.com/backtickjs/backtick/tree/main/packages/bundler): bundles a screen for a build's modules, and `supports`
107
+
108
+ ## License
109
+
110
+ MIT
@@ -0,0 +1,64 @@
1
+ import { type Platform } from "./moduleMap.js";
2
+ import type { Modules } from "@backtickjs/bundler";
3
+ /** A registered build: the modules it carries, and what's known of it. */
4
+ export type Build = {
5
+ createdAt: string;
6
+ deprecated: boolean;
7
+ modules: Modules;
8
+ };
9
+ /**
10
+ * A build its server can't draw for: it doesn't know the modules it
11
+ * carries. Fatal: every build is registered before it ships, so one a server
12
+ * doesn't know means it, or the build, was deployed wrong.
13
+ */
14
+ export declare class UnknownBuild extends Error {
15
+ readonly buildId: string | null;
16
+ constructor(buildId: string | null);
17
+ }
18
+ /** A build its server no longer draws for, on purpose: its users are asked to update. */
19
+ export declare class DeprecatedBuild extends Error {
20
+ readonly buildId: string;
21
+ constructor(buildId: string);
22
+ }
23
+ /** Where an app keeps its registered builds, one file each: `builds/`. */
24
+ export declare function buildsDirectory(projectRoot: string): string;
25
+ /** The builds registered in `directory`, by id. */
26
+ export declare function readBuilds(directory: string): Map<string, Build>;
27
+ /** The id of the build the app at `projectRoot` now makes, on each platform. */
28
+ export declare function buildIds(projectRoot: string): Record<Platform, string>;
29
+ /** The builds of the app at `projectRoot`, as its server resolves them. */
30
+ export type Builds = {
31
+ /**
32
+ * The modules of the build with id `buildId`, however it was sent: a
33
+ * registered build's, or one this code makes. Throws `DeprecatedBuild` for
34
+ * one marked deprecated, and `UnknownBuild` for one this server doesn't
35
+ * know, or none.
36
+ */
37
+ modulesOf(buildId: string | null): Modules;
38
+ /** The modules the build this code makes for `platform` carries. */
39
+ installed(platform: Platform): Modules;
40
+ /**
41
+ * Why the build this code makes for `platform` can't ship, or null: its
42
+ * server wouldn't know it once the code moves on, or it's deprecated.
43
+ */
44
+ unregistered(platform: Platform): string | null;
45
+ };
46
+ /**
47
+ * The builds of the app at `projectRoot`, for its server: the registered
48
+ * ones, read once, and the ones this code makes, worked out only when an id
49
+ * isn't registered, as in development after a package change.
50
+ */
51
+ export declare function createBuilds({ projectRoot }: {
52
+ projectRoot: string;
53
+ }): Builds;
54
+ /**
55
+ * Registers the builds the app at `projectRoot` now makes, one file per id
56
+ * in its `builds/`, for its server to keep drawing for them once the code
57
+ * moves on. A build already registered keeps when it was, and whether it's
58
+ * deprecated. Answers each platform's id and file.
59
+ */
60
+ export declare function register(projectRoot: string): {
61
+ platform: Platform;
62
+ id: string;
63
+ file: string;
64
+ }[];
package/dist/builds.js ADDED
@@ -0,0 +1,123 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { isDeepStrictEqual } from "node:util";
4
+ import { platforms } from "./moduleMap.js";
5
+ import { buildIdOf, installedModules } from "./modules.js";
6
+ /**
7
+ * A build its server can't draw for: it doesn't know the modules it
8
+ * carries. Fatal: every build is registered before it ships, so one a server
9
+ * doesn't know means it, or the build, was deployed wrong.
10
+ */
11
+ export class UnknownBuild extends Error {
12
+ buildId;
13
+ constructor(buildId) {
14
+ super(buildId === null
15
+ ? "No build id was sent: the app's code predates build ids, or doesn't send its own."
16
+ : `No modules for build ${JSON.stringify(buildId)}: it isn't registered here.`);
17
+ this.buildId = buildId;
18
+ }
19
+ }
20
+ /** A build its server no longer draws for, on purpose: its users are asked to update. */
21
+ export class DeprecatedBuild extends Error {
22
+ buildId;
23
+ constructor(buildId) {
24
+ super(`Build ${JSON.stringify(buildId)} is deprecated.`);
25
+ this.buildId = buildId;
26
+ }
27
+ }
28
+ /** Where an app keeps its registered builds, one file each: `builds/`. */
29
+ export function buildsDirectory(projectRoot) {
30
+ return join(projectRoot, "builds");
31
+ }
32
+ /** The builds registered in `directory`, by id. */
33
+ export function readBuilds(directory) {
34
+ if (!existsSync(directory))
35
+ return new Map();
36
+ return new Map(readdirSync(directory)
37
+ .filter((file) => file.endsWith(".json"))
38
+ .map((file) => [
39
+ file.slice(0, -".json".length),
40
+ JSON.parse(readFileSync(join(directory, file), "utf8")),
41
+ ]));
42
+ }
43
+ /** The id of the build the app at `projectRoot` now makes, on each platform. */
44
+ export function buildIds(projectRoot) {
45
+ return Object.fromEntries(platforms.map((platform) => [
46
+ platform,
47
+ buildIdOf(installedModules(projectRoot, platform)),
48
+ ]));
49
+ }
50
+ /**
51
+ * The builds of the app at `projectRoot`, for its server: the registered
52
+ * ones, read once, and the ones this code makes, worked out only when an id
53
+ * isn't registered, as in development after a package change.
54
+ */
55
+ export function createBuilds({ projectRoot }) {
56
+ const registered = readBuilds(buildsDirectory(projectRoot));
57
+ const installed = new Map();
58
+ const installedOn = (platform) => {
59
+ let modules = installed.get(platform);
60
+ if (modules === undefined) {
61
+ modules = installedModules(projectRoot, platform);
62
+ installed.set(platform, modules);
63
+ }
64
+ return modules;
65
+ };
66
+ return {
67
+ modulesOf(buildId) {
68
+ const build = buildId === null ? undefined : registered.get(buildId);
69
+ if (build?.deprecated)
70
+ throw new DeprecatedBuild(buildId);
71
+ if (build !== undefined)
72
+ return build.modules;
73
+ const current = platforms
74
+ .map(installedOn)
75
+ .find((modules) => buildIdOf(modules) === buildId);
76
+ if (current === undefined)
77
+ throw new UnknownBuild(buildId);
78
+ return current;
79
+ },
80
+ installed: installedOn,
81
+ unregistered(platform) {
82
+ const modules = installedOn(platform);
83
+ const id = buildIdOf(modules);
84
+ const build = registered.get(id);
85
+ if (build === undefined) {
86
+ return `No builds/${id}.json for ${platform}: run backtick-builds register and commit what it writes.`;
87
+ }
88
+ if (build.deprecated) {
89
+ return `builds/${id}.json, ${platform}'s build, is deprecated: its users would only be asked to update.`;
90
+ }
91
+ if (!isDeepStrictEqual(build.modules, modules)) {
92
+ return `builds/${id}.json isn't ${platform}'s modules: run backtick-builds register.`;
93
+ }
94
+ return null;
95
+ },
96
+ };
97
+ }
98
+ /**
99
+ * Registers the builds the app at `projectRoot` now makes, one file per id
100
+ * in its `builds/`, for its server to keep drawing for them once the code
101
+ * moves on. A build already registered keeps when it was, and whether it's
102
+ * deprecated. Answers each platform's id and file.
103
+ */
104
+ export function register(projectRoot) {
105
+ const directory = buildsDirectory(projectRoot);
106
+ mkdirSync(directory, { recursive: true });
107
+ return platforms.map((platform) => {
108
+ const modules = installedModules(projectRoot, platform);
109
+ const id = buildIdOf(modules);
110
+ const file = join(directory, `${id}.json`);
111
+ const recorded = existsSync(file)
112
+ ? JSON.parse(readFileSync(file, "utf8"))
113
+ : {};
114
+ const build = {
115
+ createdAt: recorded.createdAt ?? new Date().toISOString(),
116
+ deprecated: recorded.deprecated ?? false,
117
+ modules,
118
+ };
119
+ writeFileSync(file, JSON.stringify(build, null, 2) + "\n");
120
+ return { platform, id, file };
121
+ });
122
+ }
123
+ //# sourceMappingURL=builds.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"builds.js","sourceRoot":"","sources":["../src/builds.ts"],"names":[],"mappings":"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"}
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { relative } from "node:path";
3
+ import { createBuilds, register } from "./builds.js";
4
+ import { platforms } from "./moduleMap.js";
5
+ // `backtick-builds register`: records the builds the app in this directory
6
+ // now makes, to commit. `backtick-builds check`: fails unless they're all
7
+ // registered, as an EAS build's `eas-build-post-install` runs it, checking
8
+ // the platform it builds and letting a development build through.
9
+ const [command] = process.argv.slice(2);
10
+ const projectRoot = process.cwd();
11
+ if (command === "register") {
12
+ for (const { platform, file } of register(projectRoot)) {
13
+ console.log(`Registered ${platform}: ${relative(projectRoot, file)}`);
14
+ }
15
+ }
16
+ else if (command === "check") {
17
+ if (process.env.EAS_BUILD_PROFILE === "development") {
18
+ console.log("A development build: its server is this code's.");
19
+ process.exit(0);
20
+ }
21
+ const building = process.env.EAS_BUILD_PLATFORM;
22
+ const builds = createBuilds({ projectRoot });
23
+ const problems = (building ? [building] : platforms)
24
+ .map((platform) => builds.unregistered(platform))
25
+ .filter((problem) => problem !== null);
26
+ for (const problem of problems)
27
+ console.error(problem);
28
+ if (problems.length > 0)
29
+ process.exit(1);
30
+ console.log("Every build this code makes is registered.");
31
+ }
32
+ else {
33
+ console.error("Usage: backtick-builds register | check");
34
+ process.exit(1);
35
+ }
36
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,YAAY,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACrD,OAAO,EAAiB,SAAS,EAAE,MAAM,gBAAgB,CAAC;AAE1D,2EAA2E;AAC3E,0EAA0E;AAC1E,2EAA2E;AAC3E,kEAAkE;AAClE,MAAM,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;AACxC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;AAElC,IAAI,OAAO,KAAK,UAAU,EAAE,CAAC;IAC3B,KAAK,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,QAAQ,CAAC,WAAW,CAAC,EAAE,CAAC;QACvD,OAAO,CAAC,GAAG,CAAC,cAAc,QAAQ,KAAK,QAAQ,CAAC,WAAW,EAAE,IAAI,CAAC,EAAE,CAAC,CAAC;IACxE,CAAC;AACH,CAAC;KAAM,IAAI,OAAO,KAAK,OAAO,EAAE,CAAC;IAC/B,IAAI,OAAO,CAAC,GAAG,CAAC,iBAAiB,KAAK,aAAa,EAAE,CAAC;QACpD,OAAO,CAAC,GAAG,CAAC,iDAAiD,CAAC,CAAC;QAC/D,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;IACD,MAAM,QAAQ,GAAG,OAAO,CAAC,GAAG,CAAC,kBAA0C,CAAC;IACxE,MAAM,MAAM,GAAG,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC;IAC7C,MAAM,QAAQ,GAAG,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC;SACjD,GAAG,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;SAChD,MAAM,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,OAAO,KAAK,IAAI,CAAC,CAAC;IACzC,KAAK,MAAM,OAAO,IAAI,QAAQ;QAAE,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;IACvD,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;QAAE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACzC,OAAO,CAAC,GAAG,CAAC,4CAA4C,CAAC,CAAC;AAC5D,CAAC;KAAM,CAAC;IACN,OAAO,CAAC,KAAK,CAAC,yCAAyC,CAAC,CAAC;IACzD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC"}
@@ -0,0 +1,3 @@
1
+ export { type Build, type Builds, buildIds, buildsDirectory, createBuilds, DeprecatedBuild, readBuilds, register, UnknownBuild, } from "./builds.js";
2
+ export { moduleMapFile, type Platform, platforms, readModuleMap, } from "./moduleMap.js";
3
+ export { buildIdOf, installedModules } from "./modules.js";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { buildIds, buildsDirectory, createBuilds, DeprecatedBuild, readBuilds, register, UnknownBuild, } from "./builds.js";
2
+ export { moduleMapFile, platforms, readModuleMap, } from "./moduleMap.js";
3
+ export { buildIdOf, installedModules } from "./modules.js";
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAGL,QAAQ,EACR,eAAe,EACf,YAAY,EACZ,eAAe,EACf,UAAU,EACV,QAAQ,EACR,YAAY,GACb,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,aAAa,EAEb,SAAS,EACT,aAAa,GACd,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,SAAS,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC"}
@@ -0,0 +1,17 @@
1
+ /** The platforms an app is built for, each with its own module map. */
2
+ export declare const platforms: readonly ["ios", "android", "web"];
3
+ export type Platform = (typeof platforms)[number];
4
+ /**
5
+ * The specifiers a module map offers, read from its source without running
6
+ * it: `export const moduleMap = { … }`, each entry `() => require("…")`, and
7
+ * nothing else but type imports. Anything more, as a check of the platform,
8
+ * would make what a build offers something only the device knows, so it
9
+ * throws, naming the line.
10
+ */
11
+ export declare function readModuleMap(file: string): string[];
12
+ /**
13
+ * The module map Metro picks for `platform` in the app at `projectRoot`, as
14
+ * it picks any file: `moduleMap.ios.ts` (or `.android`, `.web`), then
15
+ * `moduleMap.native.ts` off the web, then `moduleMap.ts`.
16
+ */
17
+ export declare function moduleMapFile(projectRoot: string, platform: Platform): string;
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+ /** The platforms an app is built for, each with its own module map. */
5
+ export const platforms = ["ios", "android", "web"];
6
+ // TypeScript, loaded only to read a module map: a server reading registered
7
+ // builds never needs it.
8
+ const require = createRequire(import.meta.url);
9
+ /**
10
+ * The specifiers a module map offers, read from its source without running
11
+ * it: `export const moduleMap = { … }`, each entry `() => require("…")`, and
12
+ * nothing else but type imports. Anything more, as a check of the platform,
13
+ * would make what a build offers something only the device knows, so it
14
+ * throws, naming the line.
15
+ */
16
+ export function readModuleMap(file) {
17
+ const ts = require("typescript");
18
+ const source = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest);
19
+ const wrong = (node, what) => {
20
+ const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
21
+ return new Error(`${file}:${line + 1}: ${what}`);
22
+ };
23
+ let map;
24
+ for (const statement of source.statements) {
25
+ if (ts.isImportDeclaration(statement) &&
26
+ statement.importClause?.isTypeOnly) {
27
+ continue;
28
+ }
29
+ const declaration = ts.isVariableStatement(statement)
30
+ ? statement.declarationList.declarations[0]
31
+ : undefined;
32
+ let value = declaration?.initializer;
33
+ if (value !== undefined && ts.isSatisfiesExpression(value)) {
34
+ value = value.expression;
35
+ }
36
+ if (map === undefined &&
37
+ declaration?.name.getText(source) === "moduleMap" &&
38
+ value !== undefined &&
39
+ ts.isObjectLiteralExpression(value)) {
40
+ map = value;
41
+ continue;
42
+ }
43
+ throw wrong(statement, "a module map holds `export const moduleMap = { … }` and type imports only");
44
+ }
45
+ if (map === undefined) {
46
+ throw new Error(`${file}: no \`export const moduleMap = { … }\``);
47
+ }
48
+ return map.properties.map((property) => {
49
+ const entry = ts.isPropertyAssignment(property) &&
50
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
51
+ ts.isArrowFunction(property.initializer) &&
52
+ property.initializer.parameters.length === 0 &&
53
+ ts.isCallExpression(property.initializer.body) &&
54
+ property.initializer.body.expression.getText(source) === "require" &&
55
+ property.initializer.body.arguments.length === 1 &&
56
+ ts.isStringLiteral(property.initializer.body.arguments[0])
57
+ ? property.name
58
+ : undefined;
59
+ if (entry === undefined) {
60
+ throw wrong(property, 'each entry is `"specifier": () => require("…")`');
61
+ }
62
+ return entry.text;
63
+ });
64
+ }
65
+ /**
66
+ * The module map Metro picks for `platform` in the app at `projectRoot`, as
67
+ * it picks any file: `moduleMap.ios.ts` (or `.android`, `.web`), then
68
+ * `moduleMap.native.ts` off the web, then `moduleMap.ts`.
69
+ */
70
+ export function moduleMapFile(projectRoot, platform) {
71
+ const extensions = [platform, ...(platform === "web" ? [] : ["native"])];
72
+ for (const extension of [...extensions.map((each) => `.${each}`), ""]) {
73
+ const file = join(projectRoot, `moduleMap${extension}.ts`);
74
+ if (existsSync(file))
75
+ return file;
76
+ }
77
+ throw new Error(`${projectRoot}: no moduleMap.ts`);
78
+ }
79
+ //# sourceMappingURL=moduleMap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"moduleMap.js","sourceRoot":"","sources":["../src/moduleMap.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { Modules } from "@backtickjs/bundler";
2
+ import { type Platform } from "./moduleMap.js";
3
+ /**
4
+ * The modules the module map for `platform` offers, each at the installed
5
+ * version of the package it comes from, in the app at `projectRoot`: what a
6
+ * build made from it now carries there.
7
+ */
8
+ export declare function installedModules(projectRoot: string, platform: Platform): Modules;
9
+ /**
10
+ * The id of a build that carries `modules`: a hash of them, so two builds
11
+ * that carry the same share it, and a build always names exactly what it
12
+ * has.
13
+ */
14
+ export declare function buildIdOf(modules: Modules): string;
@@ -0,0 +1,58 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { moduleMapFile, readModuleMap } from "./moduleMap.js";
5
+ // The package a module specifier is in: `react` for `react/jsx-runtime`.
6
+ function packageOf(specifier) {
7
+ const segments = specifier.split("/");
8
+ return segments.slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
9
+ }
10
+ // Where a package is installed for the app at `projectRoot`, as Node finds
11
+ // it: in its node_modules, or the nearest above, as a workspace hoists it.
12
+ function installed(projectRoot, name) {
13
+ for (let directory = projectRoot;; directory = dirname(directory)) {
14
+ const file = join(directory, "node_modules", name, "package.json");
15
+ if (existsSync(file))
16
+ return file;
17
+ if (dirname(directory) === directory) {
18
+ throw new Error(`${projectRoot}: ${name} isn't installed`);
19
+ }
20
+ }
21
+ }
22
+ /**
23
+ * The modules the module map for `platform` offers, each at the installed
24
+ * version of the package it comes from, in the app at `projectRoot`: what a
25
+ * build made from it now carries there.
26
+ */
27
+ export function installedModules(projectRoot, platform) {
28
+ const specifiers = readModuleMap(moduleMapFile(projectRoot, platform));
29
+ const versions = new Map();
30
+ const versionOf = (name) => {
31
+ let version = versions.get(name);
32
+ if (version === undefined) {
33
+ const file = installed(projectRoot, name);
34
+ version = JSON.parse(readFileSync(file, "utf8"))
35
+ .version;
36
+ versions.set(name, version);
37
+ }
38
+ return version;
39
+ };
40
+ return Object.fromEntries([...specifiers]
41
+ .sort()
42
+ .map((specifier) => [specifier, versionOf(packageOf(specifier))]));
43
+ }
44
+ /**
45
+ * The id of a build that carries `modules`: a hash of them, so two builds
46
+ * that carry the same share it, and a build always names exactly what it
47
+ * has.
48
+ */
49
+ export function buildIdOf(modules) {
50
+ const sorted = Object.keys(modules)
51
+ .sort()
52
+ .map((specifier) => [specifier, modules[specifier]]);
53
+ return createHash("sha256")
54
+ .update(JSON.stringify(sorted))
55
+ .digest("hex")
56
+ .slice(0, 16);
57
+ }
58
+ //# sourceMappingURL=modules.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"modules.js","sourceRoot":"","sources":["../src/modules.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACnD,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C,OAAO,EAAE,aAAa,EAAiB,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAE7E,yEAAyE;AACzE,SAAS,SAAS,CAAC,SAAiB;IAClC,MAAM,QAAQ,GAAG,SAAS,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACtC,OAAO,QAAQ,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AACxE,CAAC;AAED,2EAA2E;AAC3E,2EAA2E;AAC3E,SAAS,SAAS,CAAC,WAAmB,EAAE,IAAY;IAClD,KAAK,IAAI,SAAS,GAAG,WAAW,GAAI,SAAS,GAAG,OAAO,CAAC,SAAS,CAAC,EAAE,CAAC;QACnE,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,EAAE,cAAc,EAAE,IAAI,EAAE,cAAc,CAAC,CAAC;QACnE,IAAI,UAAU,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QAClC,IAAI,OAAO,CAAC,SAAS,CAAC,KAAK,SAAS,EAAE,CAAC;YACrC,MAAM,IAAI,KAAK,CAAC,GAAG,WAAW,KAAK,IAAI,kBAAkB,CAAC,CAAC;QAC7D,CAAC;IACH,CAAC;AACH,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAC9B,WAAmB,EACnB,QAAkB;IAElB,MAAM,UAAU,GAAG,aAAa,CAAC,aAAa,CAAC,WAAW,EAAE,QAAQ,CAAC,CAAC,CAAC;IACvE,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAkB,CAAC;IAC3C,MAAM,SAAS,GAAG,CAAC,IAAY,EAAE,EAAE;QACjC,IAAI,OAAO,GAAG,QAAQ,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QACjC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;YAC1B,MAAM,IAAI,GAAG,SAAS,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;YAC1C,OAAO,GAAI,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,CAAyB;iBACtE,OAAO,CAAC;YACX,QAAQ,CAAC,GAAG,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;QAC9B,CAAC;QACD,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IACF,OAAO,MAAM,CAAC,WAAW,CACvB,CAAC,GAAG,UAAU,CAAC;SACZ,IAAI,EAAE;SACN,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,SAAS,EAAE,SAAS,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CACpE,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,SAAS,CAAC,OAAgB;IACxC,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC;SAChC,IAAI,EAAE;SACN,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,CAAC,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC;IACvD,OAAO,UAAU,CAAC,QAAQ,CAAC;SACxB,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC;SAC9B,MAAM,CAAC,KAAK,CAAC;SACb,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AAClB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@backtickjs/builds",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.9",
4
+ "description": "Keeps a Backtick server drawing for every build of its app in users' hands: build ids, a registry of what each build carries, and a check for EAS builds",
5
+ "keywords": [
6
+ "backtick",
7
+ "react-native",
8
+ "expo",
9
+ "server-driven-ui",
10
+ "builds",
11
+ "versioning"
12
+ ],
13
+ "homepage": "https://backtickjs.com",
14
+ "bugs": {
15
+ "url": "https://github.com/backtickjs/backtick/issues"
16
+ },
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/backtickjs/backtick.git",
20
+ "directory": "packages/builds"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Jean-François Bisson <jf@backtickjs.com>",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "default": "./dist/index.js"
29
+ }
30
+ },
31
+ "bin": {
32
+ "backtick-builds": "./dist/cli.js"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "!dist/._cache",
37
+ "src"
38
+ ],
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "dependencies": {
43
+ "@backtickjs/bundler": "^0.1.9",
44
+ "typescript": "^6.0.3"
45
+ },
46
+ "devDependencies": {
47
+ "@types/node": "^25.9.1",
48
+ "prettier": "^3.6.2"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -b",
52
+ "dev": "tsc -b --watch",
53
+ "format": "prettier --write --ignore-unknown .",
54
+ "format:check": "prettier --check --ignore-unknown .",
55
+ "test": "node --test \"test/**/*.test.ts\"",
56
+ "clean": "rm -rf node_modules dist .turbo"
57
+ }
6
58
  }
package/src/builds.ts ADDED
@@ -0,0 +1,163 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { isDeepStrictEqual } from "node:util";
10
+ import { type Platform, platforms } from "./moduleMap.js";
11
+ import type { Modules } from "@backtickjs/bundler";
12
+ import { buildIdOf, installedModules } from "./modules.js";
13
+
14
+ /** A registered build: the modules it carries, and what's known of it. */
15
+ export type Build = {
16
+ // when it was first registered, as an ISO date
17
+ createdAt: string;
18
+ // set to true by hand to stop drawing for it: its users are asked to update
19
+ deprecated: boolean;
20
+ modules: Modules;
21
+ };
22
+
23
+ /**
24
+ * A build its server can't draw for: it doesn't know the modules it
25
+ * carries. Fatal: every build is registered before it ships, so one a server
26
+ * doesn't know means it, or the build, was deployed wrong.
27
+ */
28
+ export class UnknownBuild extends Error {
29
+ constructor(readonly buildId: string | null) {
30
+ super(
31
+ buildId === null
32
+ ? "No build id was sent: the app's code predates build ids, or doesn't send its own."
33
+ : `No modules for build ${JSON.stringify(buildId)}: it isn't registered here.`,
34
+ );
35
+ }
36
+ }
37
+
38
+ /** A build its server no longer draws for, on purpose: its users are asked to update. */
39
+ export class DeprecatedBuild extends Error {
40
+ constructor(readonly buildId: string) {
41
+ super(`Build ${JSON.stringify(buildId)} is deprecated.`);
42
+ }
43
+ }
44
+
45
+ /** Where an app keeps its registered builds, one file each: `builds/`. */
46
+ export function buildsDirectory(projectRoot: string): string {
47
+ return join(projectRoot, "builds");
48
+ }
49
+
50
+ /** The builds registered in `directory`, by id. */
51
+ export function readBuilds(directory: string): Map<string, Build> {
52
+ if (!existsSync(directory)) return new Map();
53
+ return new Map(
54
+ readdirSync(directory)
55
+ .filter((file) => file.endsWith(".json"))
56
+ .map((file) => [
57
+ file.slice(0, -".json".length),
58
+ JSON.parse(readFileSync(join(directory, file), "utf8")) as Build,
59
+ ]),
60
+ );
61
+ }
62
+
63
+ /** The id of the build the app at `projectRoot` now makes, on each platform. */
64
+ export function buildIds(projectRoot: string): Record<Platform, string> {
65
+ return Object.fromEntries(
66
+ platforms.map((platform) => [
67
+ platform,
68
+ buildIdOf(installedModules(projectRoot, platform)),
69
+ ]),
70
+ ) as Record<Platform, string>;
71
+ }
72
+
73
+ /** The builds of the app at `projectRoot`, as its server resolves them. */
74
+ export type Builds = {
75
+ /**
76
+ * The modules of the build with id `buildId`, however it was sent: a
77
+ * registered build's, or one this code makes. Throws `DeprecatedBuild` for
78
+ * one marked deprecated, and `UnknownBuild` for one this server doesn't
79
+ * know, or none.
80
+ */
81
+ modulesOf(buildId: string | null): Modules;
82
+ /** The modules the build this code makes for `platform` carries. */
83
+ installed(platform: Platform): Modules;
84
+ /**
85
+ * Why the build this code makes for `platform` can't ship, or null: its
86
+ * server wouldn't know it once the code moves on, or it's deprecated.
87
+ */
88
+ unregistered(platform: Platform): string | null;
89
+ };
90
+
91
+ /**
92
+ * The builds of the app at `projectRoot`, for its server: the registered
93
+ * ones, read once, and the ones this code makes, worked out only when an id
94
+ * isn't registered, as in development after a package change.
95
+ */
96
+ export function createBuilds({ projectRoot }: { projectRoot: string }): Builds {
97
+ const registered = readBuilds(buildsDirectory(projectRoot));
98
+ const installed = new Map<Platform, Modules>();
99
+ const installedOn = (platform: Platform) => {
100
+ let modules = installed.get(platform);
101
+ if (modules === undefined) {
102
+ modules = installedModules(projectRoot, platform);
103
+ installed.set(platform, modules);
104
+ }
105
+ return modules;
106
+ };
107
+ return {
108
+ modulesOf(buildId) {
109
+ const build = buildId === null ? undefined : registered.get(buildId);
110
+ if (build?.deprecated) throw new DeprecatedBuild(buildId!);
111
+ if (build !== undefined) return build.modules;
112
+ const current = platforms
113
+ .map(installedOn)
114
+ .find((modules) => buildIdOf(modules) === buildId);
115
+ if (current === undefined) throw new UnknownBuild(buildId);
116
+ return current;
117
+ },
118
+ installed: installedOn,
119
+ unregistered(platform) {
120
+ const modules = installedOn(platform);
121
+ const id = buildIdOf(modules);
122
+ const build = registered.get(id);
123
+ if (build === undefined) {
124
+ return `No builds/${id}.json for ${platform}: run backtick-builds register and commit what it writes.`;
125
+ }
126
+ if (build.deprecated) {
127
+ return `builds/${id}.json, ${platform}'s build, is deprecated: its users would only be asked to update.`;
128
+ }
129
+ if (!isDeepStrictEqual(build.modules, modules)) {
130
+ return `builds/${id}.json isn't ${platform}'s modules: run backtick-builds register.`;
131
+ }
132
+ return null;
133
+ },
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Registers the builds the app at `projectRoot` now makes, one file per id
139
+ * in its `builds/`, for its server to keep drawing for them once the code
140
+ * moves on. A build already registered keeps when it was, and whether it's
141
+ * deprecated. Answers each platform's id and file.
142
+ */
143
+ export function register(
144
+ projectRoot: string,
145
+ ): { platform: Platform; id: string; file: string }[] {
146
+ const directory = buildsDirectory(projectRoot);
147
+ mkdirSync(directory, { recursive: true });
148
+ return platforms.map((platform) => {
149
+ const modules = installedModules(projectRoot, platform);
150
+ const id = buildIdOf(modules);
151
+ const file = join(directory, `${id}.json`);
152
+ const recorded: Partial<Build> = existsSync(file)
153
+ ? JSON.parse(readFileSync(file, "utf8"))
154
+ : {};
155
+ const build: Build = {
156
+ createdAt: recorded.createdAt ?? new Date().toISOString(),
157
+ deprecated: recorded.deprecated ?? false,
158
+ modules,
159
+ };
160
+ writeFileSync(file, JSON.stringify(build, null, 2) + "\n");
161
+ return { platform, id, file };
162
+ });
163
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ import { relative } from "node:path";
3
+ import { createBuilds, register } from "./builds.js";
4
+ import { type Platform, platforms } from "./moduleMap.js";
5
+
6
+ // `backtick-builds register`: records the builds the app in this directory
7
+ // now makes, to commit. `backtick-builds check`: fails unless they're all
8
+ // registered, as an EAS build's `eas-build-post-install` runs it, checking
9
+ // the platform it builds and letting a development build through.
10
+ const [command] = process.argv.slice(2);
11
+ const projectRoot = process.cwd();
12
+
13
+ if (command === "register") {
14
+ for (const { platform, file } of register(projectRoot)) {
15
+ console.log(`Registered ${platform}: ${relative(projectRoot, file)}`);
16
+ }
17
+ } else if (command === "check") {
18
+ if (process.env.EAS_BUILD_PROFILE === "development") {
19
+ console.log("A development build: its server is this code's.");
20
+ process.exit(0);
21
+ }
22
+ const building = process.env.EAS_BUILD_PLATFORM as Platform | undefined;
23
+ const builds = createBuilds({ projectRoot });
24
+ const problems = (building ? [building] : platforms)
25
+ .map((platform) => builds.unregistered(platform))
26
+ .filter((problem) => problem !== null);
27
+ for (const problem of problems) console.error(problem);
28
+ if (problems.length > 0) process.exit(1);
29
+ console.log("Every build this code makes is registered.");
30
+ } else {
31
+ console.error("Usage: backtick-builds register | check");
32
+ process.exit(1);
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ export {
2
+ type Build,
3
+ type Builds,
4
+ buildIds,
5
+ buildsDirectory,
6
+ createBuilds,
7
+ DeprecatedBuild,
8
+ readBuilds,
9
+ register,
10
+ UnknownBuild,
11
+ } from "./builds.js";
12
+ export {
13
+ moduleMapFile,
14
+ type Platform,
15
+ platforms,
16
+ readModuleMap,
17
+ } from "./moduleMap.js";
18
+ export { buildIdOf, installedModules } from "./modules.js";
@@ -0,0 +1,96 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join } from "node:path";
4
+
5
+ /** The platforms an app is built for, each with its own module map. */
6
+ export const platforms = ["ios", "android", "web"] as const;
7
+ export type Platform = (typeof platforms)[number];
8
+
9
+ // TypeScript, loaded only to read a module map: a server reading registered
10
+ // builds never needs it.
11
+ const require = createRequire(import.meta.url);
12
+
13
+ /**
14
+ * The specifiers a module map offers, read from its source without running
15
+ * it: `export const moduleMap = { … }`, each entry `() => require("…")`, and
16
+ * nothing else but type imports. Anything more, as a check of the platform,
17
+ * would make what a build offers something only the device knows, so it
18
+ * throws, naming the line.
19
+ */
20
+ export function readModuleMap(file: string): string[] {
21
+ const ts = require("typescript") as typeof import("typescript");
22
+ const source = ts.createSourceFile(
23
+ file,
24
+ readFileSync(file, "utf8"),
25
+ ts.ScriptTarget.Latest,
26
+ );
27
+ const wrong = (node: import("typescript").Node, what: string) => {
28
+ const { line } = source.getLineAndCharacterOfPosition(
29
+ node.getStart(source),
30
+ );
31
+ return new Error(`${file}:${line + 1}: ${what}`);
32
+ };
33
+ let map: import("typescript").ObjectLiteralExpression | undefined;
34
+ for (const statement of source.statements) {
35
+ if (
36
+ ts.isImportDeclaration(statement) &&
37
+ statement.importClause?.isTypeOnly
38
+ ) {
39
+ continue;
40
+ }
41
+ const declaration = ts.isVariableStatement(statement)
42
+ ? statement.declarationList.declarations[0]
43
+ : undefined;
44
+ let value = declaration?.initializer;
45
+ if (value !== undefined && ts.isSatisfiesExpression(value)) {
46
+ value = value.expression;
47
+ }
48
+ if (
49
+ map === undefined &&
50
+ declaration?.name.getText(source) === "moduleMap" &&
51
+ value !== undefined &&
52
+ ts.isObjectLiteralExpression(value)
53
+ ) {
54
+ map = value;
55
+ continue;
56
+ }
57
+ throw wrong(
58
+ statement,
59
+ "a module map holds `export const moduleMap = { … }` and type imports only",
60
+ );
61
+ }
62
+ if (map === undefined) {
63
+ throw new Error(`${file}: no \`export const moduleMap = { … }\``);
64
+ }
65
+ return map.properties.map((property) => {
66
+ const entry =
67
+ ts.isPropertyAssignment(property) &&
68
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
69
+ ts.isArrowFunction(property.initializer) &&
70
+ property.initializer.parameters.length === 0 &&
71
+ ts.isCallExpression(property.initializer.body) &&
72
+ property.initializer.body.expression.getText(source) === "require" &&
73
+ property.initializer.body.arguments.length === 1 &&
74
+ ts.isStringLiteral(property.initializer.body.arguments[0]!)
75
+ ? property.name
76
+ : undefined;
77
+ if (entry === undefined) {
78
+ throw wrong(property, 'each entry is `"specifier": () => require("…")`');
79
+ }
80
+ return entry.text;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * The module map Metro picks for `platform` in the app at `projectRoot`, as
86
+ * it picks any file: `moduleMap.ios.ts` (or `.android`, `.web`), then
87
+ * `moduleMap.native.ts` off the web, then `moduleMap.ts`.
88
+ */
89
+ export function moduleMapFile(projectRoot: string, platform: Platform): string {
90
+ const extensions = [platform, ...(platform === "web" ? [] : ["native"])];
91
+ for (const extension of [...extensions.map((each) => `.${each}`), ""]) {
92
+ const file = join(projectRoot, `moduleMap${extension}.ts`);
93
+ if (existsSync(file)) return file;
94
+ }
95
+ throw new Error(`${projectRoot}: no moduleMap.ts`);
96
+ }
package/src/modules.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import type { Modules } from "@backtickjs/bundler";
5
+ import { moduleMapFile, type Platform, readModuleMap } from "./moduleMap.js";
6
+
7
+ // The package a module specifier is in: `react` for `react/jsx-runtime`.
8
+ function packageOf(specifier: string): string {
9
+ const segments = specifier.split("/");
10
+ return segments.slice(0, specifier.startsWith("@") ? 2 : 1).join("/");
11
+ }
12
+
13
+ // Where a package is installed for the app at `projectRoot`, as Node finds
14
+ // it: in its node_modules, or the nearest above, as a workspace hoists it.
15
+ function installed(projectRoot: string, name: string): string {
16
+ for (let directory = projectRoot; ; directory = dirname(directory)) {
17
+ const file = join(directory, "node_modules", name, "package.json");
18
+ if (existsSync(file)) return file;
19
+ if (dirname(directory) === directory) {
20
+ throw new Error(`${projectRoot}: ${name} isn't installed`);
21
+ }
22
+ }
23
+ }
24
+
25
+ /**
26
+ * The modules the module map for `platform` offers, each at the installed
27
+ * version of the package it comes from, in the app at `projectRoot`: what a
28
+ * build made from it now carries there.
29
+ */
30
+ export function installedModules(
31
+ projectRoot: string,
32
+ platform: Platform,
33
+ ): Modules {
34
+ const specifiers = readModuleMap(moduleMapFile(projectRoot, platform));
35
+ const versions = new Map<string, string>();
36
+ const versionOf = (name: string) => {
37
+ let version = versions.get(name);
38
+ if (version === undefined) {
39
+ const file = installed(projectRoot, name);
40
+ version = (JSON.parse(readFileSync(file, "utf8")) as { version: string })
41
+ .version;
42
+ versions.set(name, version);
43
+ }
44
+ return version;
45
+ };
46
+ return Object.fromEntries(
47
+ [...specifiers]
48
+ .sort()
49
+ .map((specifier) => [specifier, versionOf(packageOf(specifier))]),
50
+ );
51
+ }
52
+
53
+ /**
54
+ * The id of a build that carries `modules`: a hash of them, so two builds
55
+ * that carry the same share it, and a build always names exactly what it
56
+ * has.
57
+ */
58
+ export function buildIdOf(modules: Modules): string {
59
+ const sorted = Object.keys(modules)
60
+ .sort()
61
+ .map((specifier) => [specifier, modules[specifier]]);
62
+ return createHash("sha256")
63
+ .update(JSON.stringify(sorted))
64
+ .digest("hex")
65
+ .slice(0, 16);
66
+ }