@tinkerlandai/indiana-iam 0.1.1 → 0.2.0
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/README.md +30 -3
- package/package.json +2 -1
- package/src/analyze.mjs +65 -71
- package/src/anchors.mjs +122 -0
- package/src/build-options.d.ts +2 -0
- package/src/build.mjs +6 -18
- package/src/generate.mjs +4 -3
- package/src/git-metadata.mjs +61 -0
- package/src/index.d.ts +103 -11
- package/src/index.mjs +2 -0
- package/src/instrument.mjs +2 -2
- package/src/library-entry.mjs +1 -0
- package/src/next-loader.cjs +30 -0
- package/src/next.mjs +24 -2
- package/src/rebind.mjs +116 -0
- package/src/release-metadata.mjs +1 -1
- package/src/render-index.mjs +26 -7
- package/src/route-tree.mjs +5 -12
- package/src/source-files.mjs +53 -0
- package/src/transform.mjs +32 -0
- package/src/upload.mjs +2 -10
- package/src/vite.d.ts +12 -2
- package/src/vite.mjs +15 -4
package/src/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export type ValidationCounts = Record<string, unknown>;
|
|
|
20
20
|
|
|
21
21
|
export type GenerateOptions = {
|
|
22
22
|
root: string;
|
|
23
|
+
automaticAnchors?: boolean;
|
|
23
24
|
/** A previous {@link analyzeSource} result; scanned again when omitted. */
|
|
24
25
|
analysis?: AppManifest;
|
|
25
26
|
name?: string;
|
|
@@ -46,18 +47,23 @@ export type GeneratedAppManifest = {
|
|
|
46
47
|
export declare const IAM_MAX_BYTES: 8388608;
|
|
47
48
|
export declare function analyzeSource(options: {
|
|
48
49
|
root: string;
|
|
50
|
+
automaticAnchors?: boolean;
|
|
49
51
|
name?: string;
|
|
50
52
|
repository?: string;
|
|
51
53
|
commit?: string;
|
|
52
54
|
navigationPaths?: string[][];
|
|
53
55
|
}): Promise<AppManifest>;
|
|
54
|
-
export declare function generateAppManifest(
|
|
56
|
+
export declare function generateAppManifest(
|
|
57
|
+
options: GenerateOptions,
|
|
58
|
+
): Promise<GeneratedAppManifest>;
|
|
55
59
|
export declare function writeAppManifestOutput(options: {
|
|
56
60
|
root: string;
|
|
57
61
|
out: string;
|
|
58
62
|
result: GeneratedAppManifest;
|
|
59
63
|
}): Promise<{ out: string; file: string; receipt: Record<string, unknown> }>;
|
|
60
|
-
export declare function validateAppManifest(
|
|
64
|
+
export declare function validateAppManifest(
|
|
65
|
+
manifest: AppManifest,
|
|
66
|
+
): Promise<ValidationCounts>;
|
|
61
67
|
export declare function parseAppManifest(text: string): AppManifest;
|
|
62
68
|
export declare function deriveReleaseId(manifest: AppManifest): string;
|
|
63
69
|
export declare function releasePath(releaseId: string): string;
|
|
@@ -67,10 +73,17 @@ export declare function writePublicRelease(
|
|
|
67
73
|
releaseId: string,
|
|
68
74
|
publicDirectory?: string,
|
|
69
75
|
): Promise<{ path: string; bytes: number }>;
|
|
70
|
-
export declare function nextStaticMounts(
|
|
76
|
+
export declare function nextStaticMounts(
|
|
77
|
+
distDir?: string,
|
|
78
|
+
basePath?: string,
|
|
79
|
+
): StaticMount[];
|
|
71
80
|
|
|
72
81
|
export type BuildLogger = Pick<Console, "log" | "warn">;
|
|
73
|
-
export type BuildReleaseOptions = {
|
|
82
|
+
export type BuildReleaseOptions = {
|
|
83
|
+
strict?: boolean;
|
|
84
|
+
logger?: BuildLogger;
|
|
85
|
+
automaticAnchors?: boolean;
|
|
86
|
+
};
|
|
74
87
|
export declare function prepareBuildRelease(
|
|
75
88
|
options: { root: string; name?: string } & BuildReleaseOptions,
|
|
76
89
|
): Promise<{ releaseId: string } | null>;
|
|
@@ -84,7 +97,12 @@ export declare function finishBuildRelease(
|
|
|
84
97
|
publicDirectory: string;
|
|
85
98
|
rootFont?: string;
|
|
86
99
|
omitSvgResources?: boolean;
|
|
87
|
-
upload?: {
|
|
100
|
+
upload?: {
|
|
101
|
+
apiKey?: string;
|
|
102
|
+
baseUrl?: string;
|
|
103
|
+
upload?: "production" | "always" | false;
|
|
104
|
+
fetch?: typeof globalThis.fetch;
|
|
105
|
+
};
|
|
88
106
|
} & BuildReleaseOptions,
|
|
89
107
|
): Promise<{
|
|
90
108
|
releaseId: string;
|
|
@@ -103,14 +121,88 @@ export declare function uploadAppManifest(options: {
|
|
|
103
121
|
apiKey: string;
|
|
104
122
|
baseUrl?: string;
|
|
105
123
|
fetch?: typeof globalThis.fetch;
|
|
106
|
-
}): Promise<{
|
|
124
|
+
}): Promise<{
|
|
125
|
+
version: { id: string; state: string; isCurrent: boolean } & Record<
|
|
126
|
+
string,
|
|
127
|
+
unknown
|
|
128
|
+
>;
|
|
129
|
+
uploaded: boolean;
|
|
130
|
+
}>;
|
|
107
131
|
export declare function uploadSettings(
|
|
108
|
-
options?: {
|
|
132
|
+
options?: {
|
|
133
|
+
apiKey?: string;
|
|
134
|
+
baseUrl?: string;
|
|
135
|
+
upload?: "production" | "always" | false | true;
|
|
136
|
+
},
|
|
109
137
|
env?: Record<string, string | undefined>,
|
|
110
138
|
): { enabled: boolean; apiKey: string; baseUrl: string; missingKey: boolean };
|
|
111
|
-
export declare function buildLabel(
|
|
139
|
+
export declare function buildLabel(
|
|
140
|
+
root: string,
|
|
141
|
+
env?: Record<string, string | undefined>,
|
|
142
|
+
): string;
|
|
112
143
|
export declare function planAnchors(
|
|
113
144
|
root: string,
|
|
114
|
-
options?: {
|
|
115
|
-
|
|
116
|
-
|
|
145
|
+
options?: {
|
|
146
|
+
primitives?: { module: string; exports: string[] }[];
|
|
147
|
+
publicLabels?: boolean;
|
|
148
|
+
forwardAnchors?: boolean;
|
|
149
|
+
},
|
|
150
|
+
): Promise<
|
|
151
|
+
{ root: string; changes: { file: string; anchors: number }[] } & Record<
|
|
152
|
+
string,
|
|
153
|
+
unknown
|
|
154
|
+
>
|
|
155
|
+
>;
|
|
156
|
+
export declare function applyAnchors(
|
|
157
|
+
plan: Awaited<ReturnType<typeof planAnchors>>,
|
|
158
|
+
): Promise<{ files: number; anchors: number }>;
|
|
159
|
+
export type IamSourceTransform = {
|
|
160
|
+
code: string;
|
|
161
|
+
map: {
|
|
162
|
+
version: 3;
|
|
163
|
+
file?: string;
|
|
164
|
+
sources: string[];
|
|
165
|
+
sourcesContent: string[];
|
|
166
|
+
names: string[];
|
|
167
|
+
mappings: string;
|
|
168
|
+
toString(): string;
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
export declare function transformSourceForIam(
|
|
172
|
+
code: string,
|
|
173
|
+
file: string,
|
|
174
|
+
): IamSourceTransform | null;
|
|
175
|
+
|
|
176
|
+
export type IamBindingSource = {
|
|
177
|
+
versionId: string;
|
|
178
|
+
manifestDigest: string;
|
|
179
|
+
sourceGraphDigest: string;
|
|
180
|
+
pageKey: string;
|
|
181
|
+
stateKey: string;
|
|
182
|
+
documentKey: string;
|
|
183
|
+
documentHash: string;
|
|
184
|
+
renderKey: string;
|
|
185
|
+
patches: {
|
|
186
|
+
nodeId: string;
|
|
187
|
+
elementKey: string;
|
|
188
|
+
expectedContentHash: string;
|
|
189
|
+
property: "text" | "fontSize" | "color";
|
|
190
|
+
value: string | number;
|
|
191
|
+
}[];
|
|
192
|
+
};
|
|
193
|
+
/** Facts must come from fully validated immutable IAM artifacts in the same App/environment. */
|
|
194
|
+
export declare function rebindIamSource(
|
|
195
|
+
previous: {
|
|
196
|
+
id: string;
|
|
197
|
+
sha256: string;
|
|
198
|
+
sourceGraphDigest: string;
|
|
199
|
+
routeTree: unknown;
|
|
200
|
+
},
|
|
201
|
+
next: {
|
|
202
|
+
id: string;
|
|
203
|
+
sha256: string;
|
|
204
|
+
sourceGraphDigest: string;
|
|
205
|
+
routeTree: unknown;
|
|
206
|
+
},
|
|
207
|
+
source: IamBindingSource,
|
|
208
|
+
): { ok: true; source: IamBindingSource } | { ok: false; reason: string };
|
package/src/index.mjs
CHANGED
|
@@ -3,5 +3,7 @@ export { validate as validateAppManifest, parseManifest as parseAppManifest } fr
|
|
|
3
3
|
export { deriveReleaseId, releasePath, writePublicRelease } from "./release-metadata.mjs";
|
|
4
4
|
export { finishBuildRelease, prepareBuildRelease } from "./build.mjs";
|
|
5
5
|
export { nextStaticMounts } from "./build-styles.mjs";
|
|
6
|
+
export { transformSourceForIam } from "./transform.mjs";
|
|
7
|
+
export { rebindIamSource } from "./rebind.mjs";
|
|
6
8
|
export { planAnchors, applyAnchors } from "./instrument.mjs";
|
|
7
9
|
export { uploadAppManifest, uploadSettings, buildLabel, DEFAULT_BASE_URL } from "./upload.mjs";
|
package/src/instrument.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { sourceFiles } from "./source-files.mjs";
|
|
4
4
|
import { dependency } from "./dependencies.mjs";
|
|
5
5
|
import { sha256 } from "./integrity.mjs";
|
|
6
6
|
import{redactLiteral}from'./privacy.mjs';
|
|
@@ -10,7 +10,7 @@ export async function planAnchors(root,options={}) {
|
|
|
10
10
|
const primitives=options.primitives??[];
|
|
11
11
|
if(!Array.isArray(primitives)||primitives.length>32||primitives.some(p=>typeof p.module!=='string'||p.module.length>256||!Array.isArray(p.exports)||p.exports.length>64||p.exports.some(e=>typeof e!=='string'||!/^default$|^[A-Z][\w]*$/.test(e))))throw Error('Invalid reviewed primitive map');
|
|
12
12
|
root = await fs.realpath(root);
|
|
13
|
-
const files =
|
|
13
|
+
const files = await sourceFiles(root, { maxFiles: 3000, include: file => /\.[jt]sx$/.test(file) });
|
|
14
14
|
if (files.length > 3000) throw new Error("Instrumentation file budget exceeded");
|
|
15
15
|
const changes = [];
|
|
16
16
|
for (const file of files.sort()) {
|
package/src/library-entry.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import{renderGuardFor}from'./render-index.mjs';
|
|
|
8
8
|
import { documentScript } from "./shared-scripts.mjs";
|
|
9
9
|
import { documentNodes } from './node-hashes.mjs';
|
|
10
10
|
export { documentNodes as iamDocumentNodes } from './node-hashes.mjs';
|
|
11
|
+
export { rebindIamSource } from './rebind.mjs';
|
|
11
12
|
export const parseIamJson = parseManifest;
|
|
12
13
|
export async function isReviewedIamScript(value) {
|
|
13
14
|
return typeof value === "string" && (await loadTrustedRuntimes()).includes(value);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const fs = require("node:fs/promises");
|
|
3
|
+
|
|
4
|
+
module.exports = function indianaIamLoader(source, inputMap) {
|
|
5
|
+
this.cacheable?.();
|
|
6
|
+
const done = this.async();
|
|
7
|
+
const { root } = this.getOptions();
|
|
8
|
+
Promise.all([
|
|
9
|
+
import("./transform.mjs"),
|
|
10
|
+
fs.realpath(this.resourcePath),
|
|
11
|
+
fs.realpath(root),
|
|
12
|
+
])
|
|
13
|
+
.then(([{ transformSourceForIam }, resource, directory]) => {
|
|
14
|
+
const file = path.relative(directory, resource).split(path.sep).join("/");
|
|
15
|
+
if (file.startsWith("../") || path.isAbsolute(file))
|
|
16
|
+
return done(null, source, inputMap);
|
|
17
|
+
// A pre-loader sees original source; refusing an upstream map prevents silently corrupting diagnostics.
|
|
18
|
+
if (inputMap)
|
|
19
|
+
throw new Error(
|
|
20
|
+
"Indiana IAM source loader must run before source transforms",
|
|
21
|
+
);
|
|
22
|
+
const result = transformSourceForIam(source, file);
|
|
23
|
+
done(
|
|
24
|
+
null,
|
|
25
|
+
result?.code ?? source,
|
|
26
|
+
result ? JSON.parse(result.map.toString()) : inputMap,
|
|
27
|
+
);
|
|
28
|
+
})
|
|
29
|
+
.catch(done);
|
|
30
|
+
};
|
package/src/next.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
2
3
|
import { finishBuildRelease, prepareBuildRelease } from "./build.mjs";
|
|
3
4
|
import { nextStaticMounts } from "./build-styles.mjs";
|
|
4
5
|
import { releasePath } from "./release-metadata.mjs";
|
|
5
6
|
|
|
6
7
|
const PRODUCTION_BUILD = "phase-production-build";
|
|
8
|
+
const sourceLoader = fileURLToPath(new URL("./next-loader.cjs", import.meta.url));
|
|
7
9
|
|
|
8
10
|
/**
|
|
9
11
|
* Wraps a Next.js config so each production build generates the App's IAM.
|
|
@@ -15,16 +17,35 @@ const PRODUCTION_BUILD = "phase-production-build";
|
|
|
15
17
|
* for production deployments). Development servers and other phases get the config unchanged.
|
|
16
18
|
*/
|
|
17
19
|
export function withIndianaIam(nextConfig = {}, options = {}) {
|
|
18
|
-
const { enabled = true, root, name, strict = false, rootFont, omitSvgResources, logger = console, apiKey, baseUrl, upload, fetch } = options;
|
|
20
|
+
const { enabled = true, root, name, automaticAnchors = true, strict = false, rootFont, omitSvgResources, logger = console, apiKey, baseUrl, upload, fetch } = options;
|
|
19
21
|
return async (phase, context) => {
|
|
20
22
|
const config = typeof nextConfig === "function" ? await nextConfig(phase, context) : nextConfig;
|
|
21
23
|
if (phase !== PRODUCTION_BUILD || !enabled) return config;
|
|
22
|
-
const
|
|
24
|
+
const directory = path.resolve(root ?? process.cwd());
|
|
25
|
+
const prepared = await prepareBuildRelease({ root: directory, name, automaticAnchors, strict, logger });
|
|
23
26
|
if (!prepared) return config;
|
|
24
27
|
const { releaseId } = prepared;
|
|
25
28
|
const previous = config.compiler?.runAfterProductionCompile;
|
|
29
|
+
const pattern = "*.{js,jsx,ts,tsx,mjs,cjs,mts,cts}";
|
|
30
|
+
const existingRules = config.turbopack?.rules ?? {};
|
|
31
|
+
const rule = { loaders: [{ loader: sourceLoader, options: { root: directory } }] };
|
|
32
|
+
const matching = existingRules[pattern];
|
|
26
33
|
return {
|
|
27
34
|
...config,
|
|
35
|
+
...(automaticAnchors ? {
|
|
36
|
+
turbopack: { ...config.turbopack, rules: {
|
|
37
|
+
[pattern]: matching ? [rule, ...(Array.isArray(matching) ? matching : [matching])] : rule,
|
|
38
|
+
...Object.fromEntries(Object.entries(existingRules).filter(([key]) => key !== pattern)),
|
|
39
|
+
} },
|
|
40
|
+
webpack(webpackConfig, context) {
|
|
41
|
+
const result = config.webpack ? config.webpack(webpackConfig, context) : webpackConfig;
|
|
42
|
+
if (result?.then) throw new Error("Next.js webpack hooks must return a synchronous config");
|
|
43
|
+
result.module ??= {};
|
|
44
|
+
result.module.rules ??= [];
|
|
45
|
+
result.module.rules.unshift({ test: /\.[cm]?[jt]sx?$/, include: directory, exclude: /node_modules/, enforce: "pre", use: [{ loader: sourceLoader, options: { root: directory } }] });
|
|
46
|
+
return result;
|
|
47
|
+
},
|
|
48
|
+
} : {}),
|
|
28
49
|
env: { ...config.env, INDIANA_IAM_RELEASE_URL: releasePath(releaseId) },
|
|
29
50
|
compiler: {
|
|
30
51
|
...config.compiler,
|
|
@@ -35,6 +56,7 @@ export function withIndianaIam(nextConfig = {}, options = {}) {
|
|
|
35
56
|
root: build.projectDir,
|
|
36
57
|
releaseId,
|
|
37
58
|
name,
|
|
59
|
+
automaticAnchors,
|
|
38
60
|
cssDirectory: distDir + "/static",
|
|
39
61
|
mounts: nextStaticMounts(distDir, config.basePath ?? ""),
|
|
40
62
|
publicDirectory: "public",
|
package/src/rebind.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Match admitted immutable route indexes. Neither line numbers nor DOM order prove identity. */
|
|
2
|
+
export function rebindIamSource(previous, next, source) {
|
|
3
|
+
const fail = (reason) => ({ ok: false, reason });
|
|
4
|
+
const a = previous.routeTree,
|
|
5
|
+
b = next.routeTree;
|
|
6
|
+
if (
|
|
7
|
+
source.versionId !== previous.id ||
|
|
8
|
+
source.manifestDigest !== previous.sha256 ||
|
|
9
|
+
source.sourceGraphDigest !== previous.sourceGraphDigest ||
|
|
10
|
+
a?.schemaVersion !== 3 ||
|
|
11
|
+
b?.schemaVersion !== 3
|
|
12
|
+
)
|
|
13
|
+
return fail("source_mismatch");
|
|
14
|
+
if (
|
|
15
|
+
!Array.isArray(source.patches) ||
|
|
16
|
+
source.patches.length < 1 ||
|
|
17
|
+
source.patches.length > 200
|
|
18
|
+
)
|
|
19
|
+
return fail("invalid_patches");
|
|
20
|
+
const route = a.routes.find((row) => row.routeKey === source.pageKey);
|
|
21
|
+
const render = a.renders.find(
|
|
22
|
+
(row) =>
|
|
23
|
+
row.routeKey === source.pageKey && row.renderKey === source.renderKey,
|
|
24
|
+
);
|
|
25
|
+
if (
|
|
26
|
+
!route ||
|
|
27
|
+
!render ||
|
|
28
|
+
render.documentKey !== source.documentKey ||
|
|
29
|
+
render.documentHash !== source.documentHash ||
|
|
30
|
+
render.stateKey !== source.stateKey
|
|
31
|
+
)
|
|
32
|
+
return fail("source_mismatch");
|
|
33
|
+
const routes = b.routes.filter((row) => row.path === route.path);
|
|
34
|
+
if (routes.length !== 1) return fail("route_changed");
|
|
35
|
+
const seen = new Set(),
|
|
36
|
+
targets = [];
|
|
37
|
+
for (const patch of source.patches) {
|
|
38
|
+
const key = patch.nodeId + "\0" + patch.property;
|
|
39
|
+
const old = a.targets.filter(
|
|
40
|
+
(row) =>
|
|
41
|
+
row.routeKey === source.pageKey &&
|
|
42
|
+
row.renderKey === render.renderKey &&
|
|
43
|
+
row.nodeId === patch.nodeId &&
|
|
44
|
+
row.elementKey === patch.elementKey &&
|
|
45
|
+
row.contentHash === patch.expectedContentHash,
|
|
46
|
+
);
|
|
47
|
+
if (seen.has(key) || old.length !== 1) return fail("source_mismatch");
|
|
48
|
+
seen.add(key);
|
|
49
|
+
targets.push(old[0]);
|
|
50
|
+
}
|
|
51
|
+
const candidates = [];
|
|
52
|
+
for (const candidate of b.renders) {
|
|
53
|
+
if (
|
|
54
|
+
candidate.routeKey !== routes[0].routeKey ||
|
|
55
|
+
candidate.entryMode !== render.entryMode ||
|
|
56
|
+
candidate.compositionKey !== render.compositionKey
|
|
57
|
+
)
|
|
58
|
+
continue;
|
|
59
|
+
if (
|
|
60
|
+
!candidate.match.present.length ||
|
|
61
|
+
b.renders.filter(
|
|
62
|
+
(other) =>
|
|
63
|
+
other.routeKey === candidate.routeKey &&
|
|
64
|
+
JSON.stringify(other.match) === JSON.stringify(candidate.match),
|
|
65
|
+
).length !== 1
|
|
66
|
+
)
|
|
67
|
+
continue;
|
|
68
|
+
const patches = [];
|
|
69
|
+
for (const [index, patch] of source.patches.entries()) {
|
|
70
|
+
const old = targets[index];
|
|
71
|
+
const matches = b.targets.filter(
|
|
72
|
+
(row) =>
|
|
73
|
+
row.routeKey === candidate.routeKey &&
|
|
74
|
+
row.renderKey === candidate.renderKey &&
|
|
75
|
+
row.selector === old.selector &&
|
|
76
|
+
row.tag === old.tag,
|
|
77
|
+
);
|
|
78
|
+
if (matches.length !== 1) break;
|
|
79
|
+
const target = matches[0];
|
|
80
|
+
if (
|
|
81
|
+
target.contentHash !== old.contentHash ||
|
|
82
|
+
(patch.property === "text" &&
|
|
83
|
+
(!old.leaf ||
|
|
84
|
+
!target.leaf ||
|
|
85
|
+
!old.subtreeHash ||
|
|
86
|
+
target.subtreeHash !== old.subtreeHash))
|
|
87
|
+
)
|
|
88
|
+
break;
|
|
89
|
+
patches.push({
|
|
90
|
+
...patch,
|
|
91
|
+
nodeId: target.nodeId,
|
|
92
|
+
elementKey: target.elementKey,
|
|
93
|
+
expectedContentHash: target.contentHash,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (patches.length === source.patches.length)
|
|
97
|
+
candidates.push({ candidate, patches });
|
|
98
|
+
}
|
|
99
|
+
if (candidates.length !== 1)
|
|
100
|
+
return fail(candidates.length ? "render_ambiguous" : "target_changed");
|
|
101
|
+
const { candidate, patches } = candidates[0];
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
source: {
|
|
105
|
+
versionId: next.id,
|
|
106
|
+
manifestDigest: next.sha256,
|
|
107
|
+
sourceGraphDigest: next.sourceGraphDigest,
|
|
108
|
+
pageKey: candidate.routeKey,
|
|
109
|
+
stateKey: candidate.stateKey,
|
|
110
|
+
documentKey: candidate.documentKey,
|
|
111
|
+
documentHash: candidate.documentHash,
|
|
112
|
+
renderKey: candidate.renderKey,
|
|
113
|
+
patches,
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/release-metadata.mjs
CHANGED
|
@@ -7,6 +7,6 @@ async function directory(root,parts){let folder=root;for(const part of parts){fo
|
|
|
7
7
|
/** Before the native build: embed this immutable URL in the installed SDK. */
|
|
8
8
|
export async function prepareRelease(root){root=await fs.realpath(root);const folder=await directory(root,['.indiana']),target=path.join(folder,'iam-release-input.json'),releaseId=randomUUID(),value={releaseId,manifestUrl:releasePath(releaseId)};try{if((await fs.lstat(target)).isSymbolicLink())throw Error('Release input cannot be a symlink');}catch(error){if(error.code!=='ENOENT')throw error;}const temporary=target+'.'+randomUUID();await fs.writeFile(temporary,JSON.stringify(value)+'\n',{flag:'wx'});await fs.rename(temporary,target);return value;}
|
|
9
9
|
/** Build plugins: the same source and producer always get the same release ID, so an unchanged rebuild reproduces the same IAM bytes. */
|
|
10
|
-
export function deriveReleaseId(manifest){const digest=manifest?.release?.sourceGraphDigest,version=manifest?.producer?.version;if(typeof digest!=='string'||!digest||typeof version!=='string'||!version)throw Error('Release identity needs an analyzed source graph');const h=createHash('sha256').update('indiana-iam-release/v1\0'+version+'\0'+digest).digest('hex');return h.slice(0,8)+'-'+h.slice(8,12)+'-4'+h.slice(13,16)+'-'+(8|(parseInt(h[16],16)&3)).toString(16)+h.slice(17,20)+'-'+h.slice(20,32);}
|
|
10
|
+
export function deriveReleaseId(manifest){const digest=manifest?.release?.sourceGraphDigest,version=manifest?.producer?.version;if(typeof digest!=='string'||!digest||typeof version!=='string'||!version)throw Error('Release identity needs an analyzed source graph');const h=createHash('sha256').update('indiana-iam-release/v1\0'+version+'\0'+digest+'\0'+JSON.stringify({app:manifest.app,routing:manifest.routing,routeTreeDigest:manifest.integrity?.routeTreeDigest})).digest('hex');return h.slice(0,8)+'-'+h.slice(8,12)+'-4'+h.slice(13,16)+'-'+(8|(parseInt(h[16],16)&3)).toString(16)+h.slice(17,20)+'-'+h.slice(20,32);}
|
|
11
11
|
/** Explicit post-build write into a served folder under root. Reusing an ID for different bytes fails closed. */
|
|
12
12
|
export async function writePublicRelease(root,manifestDigest,releaseId,publicDirectory='public'){root=await fs.realpath(root);const url=releasePath(releaseId);if(!/^[a-f0-9]{64}$/.test(manifestDigest))throw Error('Invalid release digest');const parts=publicDirectory.split('/');if(parts.some(part=>!/^[A-Za-z0-9_.-]+$/.test(part)||part==='.'||part==='..'))throw Error('Invalid release directory');const folder=await directory(root,[...parts,'.well-known','indiana','releases']),target=path.join(folder,releaseId+'.json'),bytes=JSON.stringify({format:'indiana-app-manifest-release',schemaVersion:2,releaseId,manifestDigest})+'\n';try{await fs.writeFile(target,bytes,{flag:'wx'});}catch(error){if(error.code!=='EEXIST')throw error;if((await fs.lstat(target)).isSymbolicLink())throw Error('Release metadata cannot be a symlink');if(await fs.readFile(target,'utf8')!==bytes)throw Error('Immutable release ID is occupied');}return{path:url,bytes:Buffer.byteLength(bytes)};}
|
package/src/render-index.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { anchorSelector, anchorSelectorPattern } from "./anchors.mjs";
|
|
1
2
|
import { canonical, hashObject, identity } from "./integrity.mjs";
|
|
2
3
|
|
|
3
4
|
export function flattenComposition(root) {
|
|
@@ -70,10 +71,9 @@ export function attachRenderIndex(graph, manifest, definitions, navigation=[]) {
|
|
|
70
71
|
if (field === "transitions") row.fromStateKey = row.toStateKey = renders.find(render => render.renderKey === row.renderKey).stateKey;
|
|
71
72
|
}
|
|
72
73
|
const sets = new Map(renders.map(render => [render.renderKey, stableSelectors(graph, manifest.documents.find(doc => doc.documentKey === render.documentKey), render.renderKey)]));
|
|
73
|
-
const selectorPool = [...new Set([...sets.values()].flatMap(set => [...set]))].sort();
|
|
74
|
-
if (selectorPool.length > 512) throw new Error("Render discriminator budget exceeded");
|
|
75
74
|
const rawSets = new Map(renders.map(render => [render.renderKey, rawSelectors(manifest.documents.find(doc => doc.documentKey === render.documentKey))]));
|
|
76
75
|
const negativeProofs=routerSelectors({...graph,compositions:[...compositions.values()]},manifest);
|
|
76
|
+
const selectorPool = discriminatorPool(graph.routes, renders, sets, rawSets, negativeProofs);
|
|
77
77
|
const guards = graph.routes.map(route => ({ routeKey: route.routeKey, selectorKeys: guardKeys(renders.filter(render => render.routeKey === route.routeKey), selectorPool, sets, rawSets,negativeProofs) }));
|
|
78
78
|
for (const render of renders) guards.find(guard => guard.routeKey === render.routeKey).selectorKeys.forEach((poolKey, key) => render.match[sets.get(render.renderKey).has(selectorPool[poolKey]) ? "present" : "absent"].push(key));
|
|
79
79
|
const renderNavigation=navigation.map(edge=>{
|
|
@@ -130,10 +130,10 @@ export function validateRenderIndex(graph, manifest) {
|
|
|
130
130
|
}
|
|
131
131
|
for (const variable of graph.variables) if (renders.get(variable.renderKey)?.routeKey !== variable.routeKey) throw new Error("Cross-render variable");
|
|
132
132
|
const sets = new Map(graph.renders.map(render => [render.renderKey, stableSelectors(graph, documents.get(render.documentKey), render.renderKey)]));
|
|
133
|
-
const expectedPool = [...new Set([...sets.values()].flatMap(set => [...set]))].sort();
|
|
134
|
-
if (canonical(expectedPool) !== canonical(graph.selectorPool)) throw new Error("Render discriminator mismatch");
|
|
135
133
|
const rawSets = new Map(graph.renders.map(render => [render.renderKey, rawSelectors(documents.get(render.documentKey))]));
|
|
136
134
|
const negativeProofs=routerSelectors(graph,manifest);
|
|
135
|
+
const expectedPool = discriminatorPool(graph.routes, graph.renders, sets, rawSets, negativeProofs);
|
|
136
|
+
if (canonical(expectedPool) !== canonical(graph.selectorPool)) throw new Error("Render discriminator mismatch");
|
|
137
137
|
for (const route of graph.routes) {
|
|
138
138
|
const candidates = graph.renders.filter(row => row.routeKey === route.routeKey);
|
|
139
139
|
const expectedKeys = guardKeys(candidates, expectedPool, sets, rawSets,negativeProofs);
|
|
@@ -152,13 +152,32 @@ export function validateRenderIndex(graph, manifest) {
|
|
|
152
152
|
|
|
153
153
|
function rawSelectors(document) {
|
|
154
154
|
return new Set(document.nodes.flatMap(node => {
|
|
155
|
-
const
|
|
156
|
-
return
|
|
155
|
+
const selector = anchorSelector(node);
|
|
156
|
+
return selector && anchorSelectorPattern.test(selector) ? [selector] : [];
|
|
157
157
|
}));
|
|
158
158
|
}
|
|
159
159
|
function guardKeys(candidates, pool, stable, raw,negativeProofs) {
|
|
160
160
|
return pool.flatMap((selector, key) => (!negativeProofs.has(selector)&&!candidates.some(render=>stable.get(render.renderKey).has(selector)))||candidates.some(render => raw.get(render.renderKey).has(selector) && !stable.get(render.renderKey).has(selector)) ? [] : [key]);
|
|
161
161
|
}
|
|
162
|
+
/** Editable targets do not all need to be render discriminators. Retain positive proof and every distinguishable pair. */
|
|
163
|
+
function discriminatorPool(routes, renders, stable, raw, negative) {
|
|
164
|
+
const all = [...new Set([...stable.values()].flatMap(set => [...set]))].sort();
|
|
165
|
+
if (all.length <= 512) return all;
|
|
166
|
+
const selected = new Set();
|
|
167
|
+
const add = selector => { if (selector) selected.add(selector); if (selected.size > 512) throw new Error("Render discriminator budget exceeded"); };
|
|
168
|
+
for (const route of routes) {
|
|
169
|
+
const candidates = renders.filter(render => render.routeKey === route.routeKey);
|
|
170
|
+
const eligible = guardKeys(candidates, all, stable, raw, negative).map(key => all[key]);
|
|
171
|
+
const allowed = new Set(eligible);
|
|
172
|
+
for (const render of candidates) add(eligible.find(selector => stable.get(render.renderKey).has(selector)));
|
|
173
|
+
for (let i = 0; i < candidates.length; i++) for (let j = i + 1; j < candidates.length; j++) {
|
|
174
|
+
const a = stable.get(candidates[i].renderKey), b = stable.get(candidates[j].renderKey);
|
|
175
|
+
if ([...selected].some(selector => allowed.has(selector) && a.has(selector) !== b.has(selector))) continue;
|
|
176
|
+
add(eligible.find(selector => a.has(selector) !== b.has(selector)));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return [...selected].sort();
|
|
180
|
+
}
|
|
162
181
|
/** Shared UI may appear in unresolved lists elsewhere: it can prove presence locally, never absence globally. */
|
|
163
182
|
function routerSelectors(graph,manifest){
|
|
164
183
|
const files=new Set(graph.compositions.flatMap(composition=>composition.entries.flatMap(entry=>[entry.file,...entry.layers.map(layer=>layer.file)])));
|
|
@@ -182,7 +201,7 @@ function stableSelectors(graph, document, renderKey) {
|
|
|
182
201
|
}
|
|
183
202
|
return true;
|
|
184
203
|
}
|
|
185
|
-
return new Set(graph.targets.filter(target => target.renderKey === renderKey &&
|
|
204
|
+
return new Set(graph.targets.filter(target => target.renderKey === renderKey && anchorSelectorPattern.test(target.selector) && stable(nodes.get(target.nodeId))).map(target => target.selector));
|
|
186
205
|
}
|
|
187
206
|
|
|
188
207
|
export function refreshRenderHashes(manifest) {
|
package/src/route-tree.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { anchorSelector } from "./anchors.mjs";
|
|
2
|
+
export { anchorSelector } from "./anchors.mjs";
|
|
1
3
|
import { dependency } from "./dependencies.mjs";
|
|
2
4
|
import { canonical } from "./integrity.mjs";
|
|
3
5
|
import { documentNodes } from './node-hashes.mjs';
|
|
@@ -32,6 +34,7 @@ const routeTreeShape = obj({
|
|
|
32
34
|
targets: arr(obj({ routeKey: str, nodeId: str, elementKey: str, contentHash: { type: "string", pattern: "^[a-f0-9]{64}$" }, selector: str, tag: str, leaf: { type: "boolean" }, source })),
|
|
33
35
|
});
|
|
34
36
|
routeTreeShape.properties.targets.items.required=routeTreeShape.properties.targets.items.required.filter(key=>key!=='source');
|
|
37
|
+
routeTreeShape.properties.targets.items.properties.subtreeHash = { type: "string", pattern: "^[a-f0-9]{64}$" };
|
|
35
38
|
export const routeTreeSchema = renderTreeSchema(routeTreeShape);
|
|
36
39
|
const Ajv = dependency("ajv").value;
|
|
37
40
|
const check = new Ajv({ allErrors: true }).compile({ ...routeTreeSchema, definitions: { uiExpression } });
|
|
@@ -110,7 +113,7 @@ export function validateRouteTree(graph, manifest, verifiedSelectors, concreteDo
|
|
|
110
113
|
try {
|
|
111
114
|
for (const target of graph.targets) {
|
|
112
115
|
const node = mapped(target);
|
|
113
|
-
if (targetIds.has(target.nodeId) || node.synthetic || target.contentHash !== node.contentHash || target.tag !== node.tag || target.selector !== anchorSelector(node) || target.leaf !== !node.children.some(id => nodes.get(id).kind === "element")) throw new Error("Invalid SDK target binding");
|
|
116
|
+
if (targetIds.has(target.nodeId) || node.synthetic || target.contentHash !== node.contentHash || target.subtreeHash !== undefined && target.subtreeHash !== node.subtreeHash || target.tag !== node.tag || target.selector !== anchorSelector(node) || target.leaf !== !node.children.some(id => nodes.get(id).kind === "element")) throw new Error("Invalid SDK target binding");
|
|
114
117
|
targetIds.add(target.nodeId);
|
|
115
118
|
let count = verifiedSelectors?.get(node.documentKey)?.get(target.selector);
|
|
116
119
|
if (count === undefined) {
|
|
@@ -137,7 +140,7 @@ export function assembleRouteTree(manifest, extracted, renderDefinitions, render
|
|
|
137
140
|
try { for (const node of doc.nodes) {
|
|
138
141
|
const selector = anchorSelector(node);
|
|
139
142
|
if (!selector || node.synthetic || dom.window.document.querySelectorAll(selector).length !== 1) continue;
|
|
140
|
-
targets.push({ routeKey: doc.pageKey, nodeId: node.nodeId, elementKey: node.elementKey, contentHash: node.contentHash, selector, tag: node.tag, leaf: !node.children.some(id => doc.nodes.find(n => n.nodeId === id).kind === "element"), source: node.source });
|
|
143
|
+
targets.push({ routeKey: doc.pageKey, nodeId: node.nodeId, elementKey: node.elementKey, contentHash: node.contentHash, subtreeHash: node.subtreeHash, selector, tag: node.tag, leaf: !node.children.some(id => doc.nodes.find(n => n.nodeId === id).kind === "element"), source: node.source });
|
|
141
144
|
} } finally { dom.window.close(); }
|
|
142
145
|
}
|
|
143
146
|
const graph = { format: "indiana-route-state-index", schemaVersion: 3, profile: "bounded-web-preview", roots: routes.filter(r => r.parentKey === null).map(r => r.routeKey), routes,
|
|
@@ -149,16 +152,6 @@ export function assembleRouteTree(manifest, extracted, renderDefinitions, render
|
|
|
149
152
|
return attachRenderIndex(graph, manifest, renderDefinitions, renderNavigation);
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
export function anchorSelector(node) {
|
|
153
|
-
if (node.kind !== "element") return null;
|
|
154
|
-
for (const key of ["data-indiana-id", "id", "data-testid", "name"]) {
|
|
155
|
-
const value = node.attributes[key];
|
|
156
|
-
if (!value || value.length > 256 || Array.from(value).some(char => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127)) continue;
|
|
157
|
-
return node.tag + '[' + key + '="' + value.replaceAll("\\", "\\\\").replaceAll('"', '\\"') + '"]';
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
155
|
export function readVariables(expression, result = new Set()) {
|
|
163
156
|
if (expression.op === "var") result.add(expression.key);
|
|
164
157
|
for (const child of [expression.arg, expression.left, expression.right, ...(expression.args || []),...(expression.parts||[]).map(part=>part.when)]) if (child) readVariables(child, result);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function sourcePath(file) {
|
|
5
|
+
return (
|
|
6
|
+
/\.(?:[cm]?[jt]sx?|css|mdx|json)$/.test(file) &&
|
|
7
|
+
!/(?:^|\/)(?:api|server|secrets?)(?:\/|$)/.test(file) &&
|
|
8
|
+
!/(?:^|\/)(?:\.?env|credentials|secrets)(?:\.|\/)/i.test(file) &&
|
|
9
|
+
!/(?:lock|package-lock)\.json$/.test(file)
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Source enumeration needs no Git executable, lifecycle script or merchant tooling. */
|
|
14
|
+
export async function sourceFiles(
|
|
15
|
+
root,
|
|
16
|
+
{ maxFiles = 5000, include = sourcePath } = {},
|
|
17
|
+
) {
|
|
18
|
+
const files = [],
|
|
19
|
+
pending = [root];
|
|
20
|
+
let directories = 0;
|
|
21
|
+
while (pending.length) {
|
|
22
|
+
if (++directories > 20_000)
|
|
23
|
+
throw new Error("Source directory budget exceeded");
|
|
24
|
+
const directory = pending.pop();
|
|
25
|
+
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
|
26
|
+
if (
|
|
27
|
+
entry.name.startsWith(".") ||
|
|
28
|
+
[
|
|
29
|
+
"node_modules",
|
|
30
|
+
"dist",
|
|
31
|
+
"build",
|
|
32
|
+
"coverage",
|
|
33
|
+
"api",
|
|
34
|
+
"server",
|
|
35
|
+
"secret",
|
|
36
|
+
"secrets",
|
|
37
|
+
].includes(entry.name)
|
|
38
|
+
)
|
|
39
|
+
continue;
|
|
40
|
+
const absolute = path.join(directory, entry.name);
|
|
41
|
+
if (entry.isDirectory()) pending.push(absolute);
|
|
42
|
+
else if (entry.isFile()) {
|
|
43
|
+
const file = path.relative(root, absolute).split(path.sep).join("/");
|
|
44
|
+
if (!sourcePath(file) || !include(file)) continue;
|
|
45
|
+
files.push(file);
|
|
46
|
+
if (files.length > maxFiles)
|
|
47
|
+
throw new Error("Source file budget exceeded");
|
|
48
|
+
}
|
|
49
|
+
// Symlinks never expand the authorized source root.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return files.sort();
|
|
53
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import MagicString from "magic-string";
|
|
2
|
+
import { dependency } from "./dependencies.mjs";
|
|
3
|
+
import { automaticSourceAnchors, instrumentableSource } from "./anchors.mjs";
|
|
4
|
+
|
|
5
|
+
/** Adds build-only anchors without editing merchant source or evaluating any expression. */
|
|
6
|
+
export function transformSourceForIam(code, file) {
|
|
7
|
+
file = file.replaceAll("\\", "/");
|
|
8
|
+
if (!instrumentableSource(file, code)) return null;
|
|
9
|
+
const ts = dependency("typescript").value;
|
|
10
|
+
const source = ts.createSourceFile(
|
|
11
|
+
file,
|
|
12
|
+
code,
|
|
13
|
+
ts.ScriptTarget.Latest,
|
|
14
|
+
true,
|
|
15
|
+
/\.jsx?$/.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX,
|
|
16
|
+
);
|
|
17
|
+
if (source.parseDiagnostics.length)
|
|
18
|
+
throw new Error("IAM cannot instrument invalid source: " + file);
|
|
19
|
+
const anchors = automaticSourceAnchors(file, source);
|
|
20
|
+
if (!anchors.size) return null;
|
|
21
|
+
const output = new MagicString(code);
|
|
22
|
+
for (const [opening, anchor] of anchors)
|
|
23
|
+
output.appendLeft(opening.tagName.end, ` data-indiana-id="${anchor}"`);
|
|
24
|
+
return {
|
|
25
|
+
code: output.toString(),
|
|
26
|
+
map: output.generateMap({
|
|
27
|
+
source: file,
|
|
28
|
+
includeContent: true,
|
|
29
|
+
hires: true,
|
|
30
|
+
}),
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/upload.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { repositoryCommit } from "./git-metadata.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
export const DEFAULT_BASE_URL = "https://tinker.cash";
|
|
@@ -18,19 +18,11 @@ export function uploadSettings({ apiKey, baseUrl, upload = "production" } = {},
|
|
|
18
18
|
|
|
19
19
|
/** A short version label: the commit being built when the CI or git exposes it. */
|
|
20
20
|
export function buildLabel(root, env = process.env) {
|
|
21
|
-
const commit = env.VERCEL_GIT_COMMIT_SHA || env.GITHUB_SHA || env.CI_COMMIT_SHA ||
|
|
21
|
+
const commit = env.VERCEL_GIT_COMMIT_SHA || env.GITHUB_SHA || env.CI_COMMIT_SHA || repositoryCommit(root);
|
|
22
22
|
if (commit && /^[0-9a-f]{7,64}$/i.test(commit)) return commit.slice(0, 12).toLowerCase();
|
|
23
23
|
return "build " + new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function gitHead(root) {
|
|
27
|
-
try {
|
|
28
|
-
return execFileSync("git", ["-C", root, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
29
|
-
} catch {
|
|
30
|
-
return "";
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
26
|
async function failure(response, step) {
|
|
35
27
|
let message = response.statusText;
|
|
36
28
|
try {
|