@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 +21 -0
- package/README.md +109 -2
- package/dist/builds.d.ts +64 -0
- package/dist/builds.js +123 -0
- package/dist/builds.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +36 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/moduleMap.d.ts +17 -0
- package/dist/moduleMap.js +79 -0
- package/dist/moduleMap.js.map +1 -0
- package/dist/modules.d.ts +14 -0
- package/dist/modules.js +58 -0
- package/dist/modules.js.map +1 -0
- package/package.json +55 -3
- package/src/builds.ts +163 -0
- package/src/cli.ts +33 -0
- package/src/index.ts +18 -0
- package/src/moduleMap.ts +96 -0
- package/src/modules.ts +66 -0
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
|
-
#
|
|
1
|
+
# @backtickjs/builds
|
|
2
2
|
|
|
3
|
-
|
|
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
|
package/dist/builds.d.ts
ADDED
|
@@ -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
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
|
package/dist/cli.js.map
ADDED
|
@@ -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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/modules.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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";
|
package/src/moduleMap.ts
ADDED
|
@@ -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
|
+
}
|