@tinkerlandai/indiana-iam 0.2.0 → 0.3.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 +74 -5
- package/assets/viewer.js +55 -2
- package/package.json +2 -1
- package/src/analyze.mjs +106 -22
- package/src/anchors.mjs +5 -0
- package/src/build-options.d.ts +5 -0
- package/src/build.mjs +10 -4
- package/src/cli.mjs +10 -4
- package/src/dom-payload-browser.mjs +28 -0
- package/src/dom-payload-json.mjs +12 -0
- package/src/dom-payload.mjs +50 -0
- package/src/framework-routes.mjs +41 -88
- package/src/generate.mjs +17 -6
- package/src/index.d.ts +11 -1
- package/src/index.mjs +1 -1
- package/src/integrity.mjs +4 -2
- package/src/library-entry.mjs +1 -0
- package/src/next.mjs +4 -3
- package/src/node-hashes.mjs +5 -2
- package/src/render-index.mjs +6 -4
- package/src/routing.mjs +1 -1
- package/src/schema-definition.mjs +8 -1
- package/src/source-entry.mjs +50 -0
- package/src/source-resolution.mjs +268 -0
- package/src/tanstack-routes.mjs +143 -0
- package/src/transform.mjs +2 -2
- package/src/upload.mjs +28 -6
- package/src/validate.mjs +14 -4
- package/src/vite.d.ts +4 -1
- package/src/vite.mjs +25 -10
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { checkedDomPayload, parsedDomPayload } from "./dom-payload-json.mjs";
|
|
2
|
+
|
|
3
|
+
/** The viewer decodes locally; it never fetches content or executes merchant code. */
|
|
4
|
+
export async function documentDomBrowser(document) {
|
|
5
|
+
const payload = checkedDomPayload(document);
|
|
6
|
+
if (!payload) return { html: document.html, nodes: document.nodes };
|
|
7
|
+
if (typeof DecompressionStream !== "function") throw Error("Compressed IAM requires DecompressionStream support");
|
|
8
|
+
const binary = atob(payload.data);
|
|
9
|
+
if (btoa(binary) !== payload.data) throw Error("Noncanonical compressed DOM base64");
|
|
10
|
+
const compressed = Uint8Array.from(binary, character => character.charCodeAt(0));
|
|
11
|
+
const stream = new ReadableStream({ start(controller) { controller.enqueue(compressed); controller.close(); } }).pipeThrough(new DecompressionStream("gzip"));
|
|
12
|
+
const reader = stream.getReader(), chunks = [];
|
|
13
|
+
let total = 0;
|
|
14
|
+
try {
|
|
15
|
+
for (;;) {
|
|
16
|
+
const next = await reader.read();
|
|
17
|
+
if (next.done) break;
|
|
18
|
+
total += next.value.byteLength;
|
|
19
|
+
if (total > payload.bytes) throw Error("Expanded DOM byte budget exceeded");
|
|
20
|
+
chunks.push(next.value);
|
|
21
|
+
}
|
|
22
|
+
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
23
|
+
if (total !== payload.bytes) throw Error("Expanded DOM byte length mismatch");
|
|
24
|
+
const decoded = new Uint8Array(total);
|
|
25
|
+
let offset = 0;
|
|
26
|
+
for (const chunk of chunks) { decoded.set(chunk, offset); offset += chunk.byteLength; }
|
|
27
|
+
return parsedDomPayload(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Shared with the offline viewer. Encodings describe storage, never executable code. */
|
|
2
|
+
export function checkedDomPayload(document) {
|
|
3
|
+
const payload = document.domPayload;
|
|
4
|
+
if (!payload) return null;
|
|
5
|
+
if (document.html !== "" || !Array.isArray(document.nodes) || document.nodes.length || Object.keys(payload).sort().join(",") !== "bytes,data,encoding" || payload.encoding !== "gzip-json-v1" || !Number.isSafeInteger(payload.bytes) || payload.bytes < 1 || payload.bytes > 8_388_608 || typeof payload.data !== "string" || !payload.data.length || payload.data.length > 11_184_812 || payload.data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(payload.data)) throw Error("Invalid compressed DOM payload");
|
|
6
|
+
return payload;
|
|
7
|
+
}
|
|
8
|
+
export function parsedDomPayload(text) {
|
|
9
|
+
const value = JSON.parse(text);
|
|
10
|
+
if (!value || Object.keys(value).sort().join(",") !== "html,nodes" || typeof value.html !== "string" || value.html.length > 2_000_000 || !value.html.isWellFormed() || !Array.isArray(value.nodes) || value.nodes.length > 12000 || JSON.stringify(value) !== text) throw Error("Invalid expanded DOM payload");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { gzip, Inflate } from "pako";
|
|
2
|
+
import { checkedDomPayload, parsedDomPayload } from "./dom-payload-json.mjs";
|
|
3
|
+
|
|
4
|
+
export const MAX_EXPANDED_DOM_BYTES = 33_554_432;
|
|
5
|
+
|
|
6
|
+
/** Lossless, local decoding with a declared and enforced output bound. */
|
|
7
|
+
export function documentDom(document) {
|
|
8
|
+
const payload = checkedDomPayload(document);
|
|
9
|
+
if (!payload) return { html: document.html, nodes: document.nodes };
|
|
10
|
+
const compressed = Buffer.from(payload.data, "base64");
|
|
11
|
+
if (compressed.toString("base64") !== payload.data) throw Error("Noncanonical compressed DOM base64");
|
|
12
|
+
const inflater = new Inflate({ windowBits: 31, chunkSize: 32_768 });
|
|
13
|
+
const chunks = [];
|
|
14
|
+
let total = 0;
|
|
15
|
+
inflater.onData = chunk => {
|
|
16
|
+
total += chunk.byteLength;
|
|
17
|
+
if (total > payload.bytes) throw Error("Expanded DOM byte budget exceeded");
|
|
18
|
+
chunks.push(chunk);
|
|
19
|
+
};
|
|
20
|
+
if (!inflater.push(compressed, true) || inflater.err || !inflater.ended) throw Error("Invalid compressed DOM stream: " + inflater.msg);
|
|
21
|
+
if (inflater.strm.avail_in !== 0) throw Error("Trailing compressed DOM data");
|
|
22
|
+
if (total !== payload.bytes) throw Error("Expanded DOM byte length mismatch");
|
|
23
|
+
const decoded = new Uint8Array(total);
|
|
24
|
+
let offset = 0;
|
|
25
|
+
for (const chunk of chunks) { decoded.set(chunk, offset); offset += chunk.byteLength; }
|
|
26
|
+
return parsedDomPayload(new TextDecoder("utf-8", { fatal: true }).decode(decoded));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function assertDomBudget(documents) {
|
|
30
|
+
let total = 0;
|
|
31
|
+
for (const document of documents) {
|
|
32
|
+
const payload = checkedDomPayload(document);
|
|
33
|
+
total += payload ? payload.bytes : Buffer.byteLength(JSON.stringify({ html: document.html, nodes: document.nodes }));
|
|
34
|
+
if (total > MAX_EXPANDED_DOM_BYTES) throw Error("Expanded DOM aggregate byte budget exceeded");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Only sizeable, beneficial payloads are compressed; the original IDs and values survive byte-for-byte. */
|
|
39
|
+
export function packDocumentDom(document) {
|
|
40
|
+
if (document.domPayload) return false;
|
|
41
|
+
const raw = Buffer.from(JSON.stringify({ html: document.html, nodes: document.nodes }));
|
|
42
|
+
if (raw.length < 16_384 || raw.length > 8_388_608) return false;
|
|
43
|
+
const zipped = gzip(raw, { level: 9, header: { time: 0, os: 255 } });
|
|
44
|
+
const payload = { encoding: "gzip-json-v1", bytes: raw.length, data: Buffer.from(zipped).toString("base64") };
|
|
45
|
+
if (Buffer.byteLength(JSON.stringify(payload)) + 1024 >= raw.length) return false;
|
|
46
|
+
document.html = "";
|
|
47
|
+
document.nodes = [];
|
|
48
|
+
document.domPayload = payload;
|
|
49
|
+
return true;
|
|
50
|
+
}
|
package/src/framework-routes.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tanstackRoutes } from "./tanstack-routes.mjs";
|
|
1
2
|
import { pagesRoute } from "./routing.mjs";
|
|
2
3
|
import { routeCompositions } from "./route-composition.mjs";
|
|
3
4
|
import { compositionIdentity, flattenComposition } from "./render-index.mjs";
|
|
@@ -16,11 +17,15 @@ export function externalImport(module, name) {
|
|
|
16
17
|
/** Source-only adapters. Merchant modules, loaders and configuration are never executed. */
|
|
17
18
|
export function frameworkRoutes({
|
|
18
19
|
modules,
|
|
20
|
+
discoveryModules = modules,
|
|
21
|
+
entry,
|
|
19
22
|
resolveModule,
|
|
20
23
|
ts,
|
|
21
24
|
sourceLocation,
|
|
22
25
|
navigationPaths,
|
|
23
26
|
gap,
|
|
27
|
+
resolution,
|
|
28
|
+
inlineComponent,
|
|
24
29
|
}) {
|
|
25
30
|
const files = [...modules.keys()];
|
|
26
31
|
const app = routeCompositions(files, navigationPaths);
|
|
@@ -48,6 +53,7 @@ export function frameworkRoutes({
|
|
|
48
53
|
app.entries.length ||
|
|
49
54
|
(pages.length && (explicitNextPages || (!reactRouting && !reactBootstrap)))
|
|
50
55
|
) {
|
|
56
|
+
if (entry) throw Error("entry is for browser applications; scope Next.js by its app root");
|
|
51
57
|
const entries = [...app.entries];
|
|
52
58
|
const hardRoutes = new Set(
|
|
53
59
|
entries.filter((e) => e.entryMode === "hard").map((e) => e.route),
|
|
@@ -118,87 +124,17 @@ export function frameworkRoutes({
|
|
|
118
124
|
return node;
|
|
119
125
|
};
|
|
120
126
|
const external = externalImport;
|
|
121
|
-
|
|
122
|
-
node = unwrap(node);
|
|
123
|
-
if (!node || seen.size > 16) return undefined;
|
|
124
|
-
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node))
|
|
125
|
-
return node.text;
|
|
126
|
-
if (ts.isIdentifier(node)) {
|
|
127
|
-
if (seen.has(node.text)) return undefined;
|
|
128
|
-
return literal(
|
|
129
|
-
module.bindings.get(node.text),
|
|
130
|
-
module,
|
|
131
|
-
new Set([...seen, node.text]),
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
if (
|
|
135
|
-
ts.isPropertyAccessExpression(node) &&
|
|
136
|
-
ts.isIdentifier(node.expression)
|
|
137
|
-
) {
|
|
138
|
-
const declaration = module.source.statements.find(
|
|
139
|
-
(s) => ts.isEnumDeclaration(s) && s.name.text === node.expression.text,
|
|
140
|
-
);
|
|
141
|
-
const member = declaration?.members.find(
|
|
142
|
-
(m) =>
|
|
143
|
-
m.name.getText(module.source).replace(/^['"]|['"]$/g, "") ===
|
|
144
|
-
node.name.text,
|
|
145
|
-
);
|
|
146
|
-
if (member) return literal(member.initializer, module, seen);
|
|
147
|
-
const record = unwrap(module.bindings.get(node.expression.text));
|
|
148
|
-
if (record && ts.isObjectLiteralExpression(record))
|
|
149
|
-
return literal(
|
|
150
|
-
record.properties.find((p) => p.name?.text === node.name.text)
|
|
151
|
-
?.initializer,
|
|
152
|
-
module,
|
|
153
|
-
seen,
|
|
154
|
-
);
|
|
155
|
-
}
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
127
|
+
const literal = (node, module, seen = new Set()) => resolution.string(node, module, seen);
|
|
158
128
|
function component(tag, module) {
|
|
159
|
-
const
|
|
160
|
-
if (
|
|
161
|
-
|
|
162
|
-
return target
|
|
163
|
-
? {
|
|
164
|
-
file: target.file,
|
|
165
|
-
symbol:
|
|
166
|
-
imported.name === "default"
|
|
167
|
-
? target.defaultName
|
|
168
|
-
: (target.exports.get(imported.name) ?? imported.name),
|
|
169
|
-
}
|
|
170
|
-
: null;
|
|
171
|
-
}
|
|
172
|
-
const local = unwrap(module.bindings.get(tag));
|
|
173
|
-
// React.lazy(() => import("literal")) is inspected, never invoked.
|
|
174
|
-
if (
|
|
175
|
-
local &&
|
|
176
|
-
ts.isCallExpression(local) &&
|
|
177
|
-
external(module, local.expression.getText(module.source))?.specifier ===
|
|
178
|
-
"react" &&
|
|
179
|
-
external(module, local.expression.getText(module.source))?.name === "lazy"
|
|
180
|
-
) {
|
|
181
|
-
const fn = local.arguments[0],
|
|
182
|
-
body = fn && ts.isArrowFunction(fn) ? unwrap(fn.body) : null;
|
|
183
|
-
if (
|
|
184
|
-
body &&
|
|
185
|
-
ts.isCallExpression(body) &&
|
|
186
|
-
body.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
187
|
-
body.arguments.length === 1
|
|
188
|
-
) {
|
|
189
|
-
const specifier = literal(body.arguments[0], module),
|
|
190
|
-
target =
|
|
191
|
-
typeof specifier === "string" &&
|
|
192
|
-
resolveModule(module.file, specifier);
|
|
193
|
-
if (target) return { file: target.file, symbol: target.defaultName };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
return local ? { file: module.file, symbol: tag } : null;
|
|
129
|
+
const ref = resolution.component(module, tag);
|
|
130
|
+
if (ref?.wrapped) gap("LAZY_WRAPPER_UNOBSERVED", module.file, tag + ": only a statically forwarded successful module is projected; retry/reload behavior is not executed");
|
|
131
|
+
return ref ? { file: ref.module.file, symbol: ref.name } : null;
|
|
197
132
|
}
|
|
198
|
-
function jsxComponent(node, module) {
|
|
133
|
+
function jsxComponent(node, module, route) {
|
|
199
134
|
node = unwrap(node);
|
|
200
135
|
if (!node) return null;
|
|
201
|
-
if (ts.isJsxExpression(node)) return jsxComponent(node.expression, module);
|
|
136
|
+
if (ts.isJsxExpression(node)) return jsxComponent(node.expression, module, route);
|
|
137
|
+
if (inlineComponent && route !== undefined && (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isMethodDeclaration(node))) return inlineComponent(module, node, route);
|
|
202
138
|
if (ts.isJsxElement(node)) {
|
|
203
139
|
const name = node.openingElement.tagName.getText(module.source),
|
|
204
140
|
imported = external(module, name);
|
|
@@ -209,13 +145,14 @@ export function frameworkRoutes({
|
|
|
209
145
|
const children = node.children.filter(
|
|
210
146
|
(n) => !ts.isJsxText(n) || n.text.trim(),
|
|
211
147
|
);
|
|
212
|
-
return children.length === 1 ? jsxComponent(children[0], module) : null;
|
|
148
|
+
return children.length === 1 ? jsxComponent(children[0], module, route) : null;
|
|
213
149
|
}
|
|
214
150
|
return component(name, module);
|
|
215
151
|
}
|
|
216
152
|
if (ts.isJsxSelfClosingElement(node))
|
|
217
153
|
return component(node.tagName.getText(module.source), module);
|
|
218
154
|
if (ts.isIdentifier(node)) return component(node.text, module);
|
|
155
|
+
if (ts.isPropertyAccessExpression(node)) return component(node.getText(module.source), module);
|
|
219
156
|
return null;
|
|
220
157
|
}
|
|
221
158
|
function join(parent, child) {
|
|
@@ -321,7 +258,8 @@ export function frameworkRoutes({
|
|
|
321
258
|
node = unwrap(node);
|
|
322
259
|
if (
|
|
323
260
|
!ts.isObjectLiteralExpression(node) ||
|
|
324
|
-
node.properties.some((p) => !ts.isPropertyAssignment(p))
|
|
261
|
+
node.properties.some((p) => !(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) || ts.isMethodDeclaration(p)) || ts.isComputedPropertyName(p.name)) ||
|
|
262
|
+
new Set(node.properties.map(p => p.name.text)).size !== node.properties.length
|
|
325
263
|
) {
|
|
326
264
|
gap(
|
|
327
265
|
"ROUTES_UNRESOLVED",
|
|
@@ -331,7 +269,7 @@ export function frameworkRoutes({
|
|
|
331
269
|
continue;
|
|
332
270
|
}
|
|
333
271
|
const properties = new Map(
|
|
334
|
-
node.properties.map((p) => [p.name.text, p.initializer]),
|
|
272
|
+
node.properties.map((p) => [p.name.text, ts.isMethodDeclaration(p) ? p : ts.isShorthandPropertyAssignment(p) ? p.name : p.initializer]),
|
|
335
273
|
);
|
|
336
274
|
const rawPath = properties.has("path")
|
|
337
275
|
? literal(properties.get("path"), module)
|
|
@@ -345,10 +283,17 @@ export function frameworkRoutes({
|
|
|
345
283
|
continue;
|
|
346
284
|
}
|
|
347
285
|
const route = join(parent, rawPath);
|
|
348
|
-
if (route === null)
|
|
286
|
+
if (route === null) {
|
|
287
|
+
if (typeof rawPath === "string" && /\?/.test(rawPath)) {
|
|
288
|
+
gap("ROUTE_PATTERN_UNSUPPORTED", module.file, rawPath + ": optional route parameters require an explicit expansion adapter");
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
throw Error("Invalid nested React route");
|
|
292
|
+
}
|
|
349
293
|
const target = jsxComponent(
|
|
350
294
|
properties.get("element") ?? properties.get("Component"),
|
|
351
295
|
module,
|
|
296
|
+
route,
|
|
352
297
|
);
|
|
353
298
|
const index = properties.get("index")?.kind === ts.SyntaxKind.TrueKeyword;
|
|
354
299
|
if (
|
|
@@ -455,8 +400,15 @@ export function frameworkRoutes({
|
|
|
455
400
|
target = jsxComponent(
|
|
456
401
|
props.get("element") ?? props.get("Component"),
|
|
457
402
|
module,
|
|
403
|
+
route,
|
|
458
404
|
);
|
|
459
|
-
if (route === null)
|
|
405
|
+
if (route === null) {
|
|
406
|
+
if (typeof raw === "string" && /\?/.test(raw)) {
|
|
407
|
+
gap("ROUTE_PATTERN_UNSUPPORTED", module.file, raw + ": optional route parameters require an explicit expansion adapter");
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
throw Error("Invalid nested React route");
|
|
411
|
+
}
|
|
460
412
|
const nested =
|
|
461
413
|
ts.isJsxElement(child) &&
|
|
462
414
|
child.children.some(
|
|
@@ -500,7 +452,8 @@ export function frameworkRoutes({
|
|
|
500
452
|
add(module, child, route, target, ancestors);
|
|
501
453
|
}
|
|
502
454
|
}
|
|
503
|
-
|
|
455
|
+
const tanstack = tanstackRoutes({ discoveryModules, ts, resolution, gap, component: jsxComponent, add, externalImport });
|
|
456
|
+
for (const module of discoveryModules.values()) {
|
|
504
457
|
if (
|
|
505
458
|
/(^|\/)(__tests__|tests?|stories)(\/|$)|\.(test|spec|stories)\./.test(
|
|
506
459
|
module.file,
|
|
@@ -517,7 +470,7 @@ export function frameworkRoutes({
|
|
|
517
470
|
["react-router", "react-router-dom"].includes(imported?.specifier) &&
|
|
518
471
|
["createBrowserRouter", "createHashRouter"].includes(imported.name)
|
|
519
472
|
) {
|
|
520
|
-
if (++routers > 1)
|
|
473
|
+
if (++routers > 1 || tanstack)
|
|
521
474
|
throw Error(
|
|
522
475
|
"Multiple React route authorities require an explicit adapter",
|
|
523
476
|
);
|
|
@@ -559,7 +512,7 @@ export function frameworkRoutes({
|
|
|
559
512
|
["react-router", "react-router-dom"].includes(imported?.specifier) &&
|
|
560
513
|
imported.name === "Routes"
|
|
561
514
|
) {
|
|
562
|
-
if (++routers > 1)
|
|
515
|
+
if (++routers > 1 || tanstack)
|
|
563
516
|
throw Error(
|
|
564
517
|
"Multiple React route authorities require an explicit adapter",
|
|
565
518
|
);
|
|
@@ -571,9 +524,9 @@ export function frameworkRoutes({
|
|
|
571
524
|
}
|
|
572
525
|
visit(module.source);
|
|
573
526
|
}
|
|
574
|
-
if (!routers) {
|
|
527
|
+
if (!routers && !tanstack) {
|
|
575
528
|
let roots = 0;
|
|
576
|
-
for (const module of
|
|
529
|
+
for (const module of discoveryModules.values()) {
|
|
577
530
|
if (
|
|
578
531
|
/(^|\/)(__tests__|tests?|stories)(\/|$)|\.(test|spec|stories)\./.test(
|
|
579
532
|
module.file,
|
|
@@ -609,7 +562,7 @@ export function frameworkRoutes({
|
|
|
609
562
|
}
|
|
610
563
|
}
|
|
611
564
|
return {
|
|
612
|
-
framework: routers ? "react-router" : "react-spa",
|
|
565
|
+
framework: tanstack ? "tanstack-router" : routers ? "react-router" : "react-spa",
|
|
613
566
|
entries,
|
|
614
567
|
pageFiles: [...new Set(entries.map((e) => e.sourceFile))],
|
|
615
568
|
unsupported: [],
|
package/src/generate.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { analyze } from "./analyze.mjs";
|
|
4
4
|
import { validate } from "./validate.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { documentDom, packDocumentDom, assertDomBudget } from "./dom-payload.mjs";
|
|
6
|
+
import { sha256, sealDocument } from "./integrity.mjs";
|
|
6
7
|
import { attachBuildStyles, shareDocumentStyles } from "./build-styles.mjs";
|
|
7
8
|
import { releasePath } from "./release-metadata.mjs";
|
|
8
9
|
import { shareDocumentScripts, documentScript } from "./shared-scripts.mjs";
|
|
@@ -11,9 +12,9 @@ import { refreshRenderHashes } from "./render-index.mjs";
|
|
|
11
12
|
export const IAM_MAX_BYTES = 8_388_608;
|
|
12
13
|
|
|
13
14
|
/** Scans the source checkout. The result is the input of {@link generateAppManifest}. */
|
|
14
|
-
export async function analyzeSource({ root, name, repository = "", commit = "", navigationPaths = [], automaticAnchors = false }) {
|
|
15
|
+
export async function analyzeSource({ root, name, repository = "", commit = "", navigationPaths = [], entry, automaticAnchors = false }) {
|
|
15
16
|
root = await fs.realpath(root);
|
|
16
|
-
return analyze({ root, name: name || path.basename(root), repository, commit, navigationPaths, automaticAnchors });
|
|
17
|
+
return analyze({ root, name: name || path.basename(root), repository, commit, navigationPaths, entry, automaticAnchors });
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
/**
|
|
@@ -27,6 +28,7 @@ export async function generateAppManifest({
|
|
|
27
28
|
repository,
|
|
28
29
|
commit,
|
|
29
30
|
navigationPaths,
|
|
31
|
+
entry,
|
|
30
32
|
automaticAnchors = false,
|
|
31
33
|
releaseId,
|
|
32
34
|
buildCss = [],
|
|
@@ -39,7 +41,12 @@ export async function generateAppManifest({
|
|
|
39
41
|
root = await fs.realpath(root);
|
|
40
42
|
const manifest = analysis
|
|
41
43
|
? structuredClone(analysis)
|
|
42
|
-
: await analyzeSource({ root, name, repository, commit, navigationPaths, automaticAnchors });
|
|
44
|
+
: await analyzeSource({ root, name, repository, commit, navigationPaths, entry, automaticAnchors });
|
|
45
|
+
for (const document of manifest.documents) if (document.domPayload) {
|
|
46
|
+
Object.assign(document, documentDom(document));
|
|
47
|
+
delete document.domPayload;
|
|
48
|
+
sealDocument(document);
|
|
49
|
+
}
|
|
43
50
|
if (releaseId) {
|
|
44
51
|
releasePath(releaseId);
|
|
45
52
|
manifest.release.runtimeBinding = { schemaVersion: 1, releaseId };
|
|
@@ -57,6 +64,9 @@ export async function generateAppManifest({
|
|
|
57
64
|
else if (rootFont) throw new Error("rootFont requires build CSS");
|
|
58
65
|
shareDocumentStyles(manifest);
|
|
59
66
|
shareDocumentScripts(manifest);
|
|
67
|
+
assertDomBudget(manifest.documents);
|
|
68
|
+
if (Buffer.byteLength(JSON.stringify(manifest)) > 4_194_304)
|
|
69
|
+
for (const document of manifest.documents) if (packDocumentDom(document)) sealDocument(document);
|
|
60
70
|
refreshRenderHashes(manifest);
|
|
61
71
|
const validation = await validate(manifest);
|
|
62
72
|
const serialized = JSON.stringify(manifest) + "\n",
|
|
@@ -83,15 +93,16 @@ export async function writeAppManifestOutput({ root, out, result }) {
|
|
|
83
93
|
await fs.writeFile(path.join(out, "styles", style.styleKey + ".css"), style.css);
|
|
84
94
|
}
|
|
85
95
|
for (const document of manifest.documents) {
|
|
96
|
+
const decoded = documentDom(document);
|
|
86
97
|
const folder = path.join(out, "documents", document.documentKey);
|
|
87
98
|
await fs.mkdir(folder, { recursive: true });
|
|
88
99
|
await Promise.all([
|
|
89
|
-
fs.writeFile(path.join(folder, "page.html"),
|
|
100
|
+
fs.writeFile(path.join(folder, "page.html"), decoded.html),
|
|
90
101
|
fs.writeFile(path.join(folder, "page.css"), document.css),
|
|
91
102
|
fs.writeFile(path.join(folder, "page.js"), documentScript(manifest, document)),
|
|
92
103
|
fs.writeFile(
|
|
93
104
|
path.join(folder, "dom-map.json"),
|
|
94
|
-
JSON.stringify({ rootNodeId: document.rootNodeId, nodes:
|
|
105
|
+
JSON.stringify({ rootNodeId: document.rootNodeId, nodes: decoded.nodes, hashes: document.hashes }, null, 2) + "\n",
|
|
95
106
|
),
|
|
96
107
|
]);
|
|
97
108
|
}
|
package/src/index.d.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type ValidationCounts = Record<string, unknown>;
|
|
|
20
20
|
|
|
21
21
|
export type GenerateOptions = {
|
|
22
22
|
root: string;
|
|
23
|
+
/** Browser entry relative to root. Defaults to the single module script in index.html. */
|
|
24
|
+
entry?: string;
|
|
23
25
|
automaticAnchors?: boolean;
|
|
24
26
|
/** A previous {@link analyzeSource} result; scanned again when omitted. */
|
|
25
27
|
analysis?: AppManifest;
|
|
@@ -47,6 +49,8 @@ export type GeneratedAppManifest = {
|
|
|
47
49
|
export declare const IAM_MAX_BYTES: 8388608;
|
|
48
50
|
export declare function analyzeSource(options: {
|
|
49
51
|
root: string;
|
|
52
|
+
/** Browser entry relative to root. Defaults to the single module script in index.html. */
|
|
53
|
+
entry?: string;
|
|
50
54
|
automaticAnchors?: boolean;
|
|
51
55
|
name?: string;
|
|
52
56
|
repository?: string;
|
|
@@ -82,6 +86,8 @@ export type BuildLogger = Pick<Console, "log" | "warn">;
|
|
|
82
86
|
export type BuildReleaseOptions = {
|
|
83
87
|
strict?: boolean;
|
|
84
88
|
logger?: BuildLogger;
|
|
89
|
+
/** Browser entry relative to root. Defaults to the single module script in index.html. */
|
|
90
|
+
entry?: string;
|
|
85
91
|
automaticAnchors?: boolean;
|
|
86
92
|
};
|
|
87
93
|
export declare function prepareBuildRelease(
|
|
@@ -114,10 +120,13 @@ export declare function finishBuildRelease(
|
|
|
114
120
|
} | null>;
|
|
115
121
|
|
|
116
122
|
export declare const DEFAULT_BASE_URL: "https://tinker.cash";
|
|
123
|
+
export declare const DEFAULT_CLIENT: "web";
|
|
117
124
|
export declare function uploadAppManifest(options: {
|
|
118
125
|
serialized: string;
|
|
119
126
|
digest: string;
|
|
120
127
|
label: string;
|
|
128
|
+
/** Defaults to `web`; each client of an App environment has its own current version. */
|
|
129
|
+
client?: string;
|
|
121
130
|
apiKey: string;
|
|
122
131
|
baseUrl?: string;
|
|
123
132
|
fetch?: typeof globalThis.fetch;
|
|
@@ -133,9 +142,10 @@ export declare function uploadSettings(
|
|
|
133
142
|
apiKey?: string;
|
|
134
143
|
baseUrl?: string;
|
|
135
144
|
upload?: "production" | "always" | false | true;
|
|
145
|
+
client?: string;
|
|
136
146
|
},
|
|
137
147
|
env?: Record<string, string | undefined>,
|
|
138
|
-
): { enabled: boolean; apiKey: string; baseUrl: string; missingKey: boolean };
|
|
148
|
+
): { enabled: boolean; apiKey: string; baseUrl: string; client: string; missingKey: boolean };
|
|
139
149
|
export declare function buildLabel(
|
|
140
150
|
root: string,
|
|
141
151
|
env?: Record<string, string | undefined>,
|
package/src/index.mjs
CHANGED
|
@@ -6,4 +6,4 @@ export { nextStaticMounts } from "./build-styles.mjs";
|
|
|
6
6
|
export { transformSourceForIam } from "./transform.mjs";
|
|
7
7
|
export { rebindIamSource } from "./rebind.mjs";
|
|
8
8
|
export { planAnchors, applyAnchors } from "./instrument.mjs";
|
|
9
|
-
export { uploadAppManifest, uploadSettings, buildLabel, DEFAULT_BASE_URL } from "./upload.mjs";
|
|
9
|
+
export { uploadAppManifest, uploadSettings, buildLabel, DEFAULT_BASE_URL, DEFAULT_CLIENT } from "./upload.mjs";
|
package/src/integrity.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { documentDom } from "./dom-payload.mjs";
|
|
2
3
|
|
|
3
4
|
export const sha256 = (bytes) =>
|
|
4
5
|
createHash("sha256").update(bytes).digest("hex");
|
|
@@ -70,13 +71,14 @@ export function sealNodes(nodes, rootId) {
|
|
|
70
71
|
}
|
|
71
72
|
export function sealDocument(document) {
|
|
72
73
|
const { hashes: _hashes, ...body } = document;
|
|
74
|
+
const dom = documentDom(document);
|
|
73
75
|
document.hashes = {
|
|
74
|
-
html: sha256(
|
|
76
|
+
html: sha256(dom.html),
|
|
75
77
|
css: sha256(document.css),
|
|
76
78
|
js: sha256(document.js),
|
|
77
79
|
dom: hashObject("iam/dom-map/v1", {
|
|
78
80
|
rootNodeId: document.rootNodeId,
|
|
79
|
-
nodes:
|
|
81
|
+
nodes: dom.nodes,
|
|
80
82
|
...(document.sourceMappingsHash?{sourceMappingsHash:document.sourceMappingsHash}:{}),
|
|
81
83
|
}),
|
|
82
84
|
document: hashObject("iam/document/v1", body),
|
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 { documentDom as iamDocumentDom } from './dom-payload.mjs';
|
|
11
12
|
export { rebindIamSource } from './rebind.mjs';
|
|
12
13
|
export const parseIamJson = parseManifest;
|
|
13
14
|
export async function isReviewedIamScript(value) {
|
package/src/next.mjs
CHANGED
|
@@ -17,12 +17,12 @@ const sourceLoader = fileURLToPath(new URL("./next-loader.cjs", import.meta.url)
|
|
|
17
17
|
* for production deployments). Development servers and other phases get the config unchanged.
|
|
18
18
|
*/
|
|
19
19
|
export function withIndianaIam(nextConfig = {}, options = {}) {
|
|
20
|
-
const { enabled = true, root, name, automaticAnchors = true, strict = false, rootFont, omitSvgResources, logger = console, apiKey, baseUrl, upload, fetch } = options;
|
|
20
|
+
const { enabled = true, root, name, entry, automaticAnchors = true, strict = false, rootFont, omitSvgResources, logger = console, apiKey, baseUrl, upload, client, fetch } = options;
|
|
21
21
|
return async (phase, context) => {
|
|
22
22
|
const config = typeof nextConfig === "function" ? await nextConfig(phase, context) : nextConfig;
|
|
23
23
|
if (phase !== PRODUCTION_BUILD || !enabled) return config;
|
|
24
24
|
const directory = path.resolve(root ?? process.cwd());
|
|
25
|
-
const prepared = await prepareBuildRelease({ root: directory, name, automaticAnchors, strict, logger });
|
|
25
|
+
const prepared = await prepareBuildRelease({ root: directory, name, entry, automaticAnchors, strict, logger });
|
|
26
26
|
if (!prepared) return config;
|
|
27
27
|
const { releaseId } = prepared;
|
|
28
28
|
const previous = config.compiler?.runAfterProductionCompile;
|
|
@@ -56,13 +56,14 @@ export function withIndianaIam(nextConfig = {}, options = {}) {
|
|
|
56
56
|
root: build.projectDir,
|
|
57
57
|
releaseId,
|
|
58
58
|
name,
|
|
59
|
+
entry,
|
|
59
60
|
automaticAnchors,
|
|
60
61
|
cssDirectory: distDir + "/static",
|
|
61
62
|
mounts: nextStaticMounts(distDir, config.basePath ?? ""),
|
|
62
63
|
publicDirectory: "public",
|
|
63
64
|
rootFont,
|
|
64
65
|
omitSvgResources,
|
|
65
|
-
upload: { apiKey, baseUrl, upload, fetch },
|
|
66
|
+
upload: { apiKey, baseUrl, upload, client, fetch },
|
|
66
67
|
strict,
|
|
67
68
|
logger,
|
|
68
69
|
});
|
package/src/node-hashes.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { documentDom } from "./dom-payload.mjs";
|
|
1
2
|
import { canonical, sealNodes, sealDocument } from './integrity.mjs';
|
|
2
3
|
import { dependency } from './dependencies.mjs';
|
|
3
4
|
import { sourceMappingsHash } from './compact-dom.mjs';
|
|
@@ -20,8 +21,9 @@ export function expandDomNodes(document, body) {
|
|
|
20
21
|
return result;
|
|
21
22
|
}
|
|
22
23
|
function domNodes(document){
|
|
23
|
-
const {
|
|
24
|
-
|
|
24
|
+
const decoded = {...document, ...documentDom(document)};
|
|
25
|
+
const {JSDOM}=dependency('jsdom').value,dom=new JSDOM('<!doctype html><html><body>'+decoded.html+'</body></html>');
|
|
26
|
+
try{return expandDomNodes(decoded,dom.window.document.body);}finally{dom.window.close();}
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
/** Node hashes are pure derived values. IDs, source mappings and algorithms never change. */
|
|
@@ -32,6 +34,7 @@ export function documentNodes(document) {
|
|
|
32
34
|
}
|
|
33
35
|
/** Serialize the producer's in-memory DOM into the single admitted wire representation. */
|
|
34
36
|
export function storeDocumentNodes(document, nodes, elements) {
|
|
37
|
+
delete document.domPayload;
|
|
35
38
|
document.sourceMappingsHash = sourceMappingsHash(nodes, elements);
|
|
36
39
|
document.nodeEncoding = 'html-preorder-v1';
|
|
37
40
|
document.nodeHashMode = 'derived-v1';
|
package/src/render-index.mjs
CHANGED
|
@@ -5,7 +5,7 @@ export function flattenComposition(root) {
|
|
|
5
5
|
const entries = [];
|
|
6
6
|
function visit(branch) {
|
|
7
7
|
if (!branch) return;
|
|
8
|
-
entries.push({ lane: branch.lane, file: branch.file, layers: branch.layers.map(layer => ({ file: layer.file, slots: layer.slots.map(slot => ({ name: slot.name, lane: slot.branch?.lane ?? null })) })) });
|
|
8
|
+
entries.push({ lane: branch.lane, file: branch.file, ...(branch.symbol ? { symbol: branch.symbol } : {}), layers: branch.layers.map(layer => ({ file: layer.file, ...(layer.symbol ? { symbol: layer.symbol } : {}), slots: layer.slots.map(slot => ({ name: slot.name, lane: slot.branch?.lane ?? null })) })) });
|
|
9
9
|
for (const layer of branch.layers) for (const slot of layer.slots) visit(slot.branch);
|
|
10
10
|
}
|
|
11
11
|
visit(root);
|
|
@@ -36,6 +36,7 @@ export function renderTreeSchema(base) {
|
|
|
36
36
|
p.renderNavigation=array(object({fromRenderKey:str,toRenderKey:str}),512);
|
|
37
37
|
p.guards = array(object({ routeKey: str, selectorKeys: array({type:"integer",minimum:0,maximum:511}, 512) }), 200);
|
|
38
38
|
p.compositions = array(object({ compositionKey: str, entries: array(object({ lane: str, file: str, layers: array(object({ file: str, slots: array(object({ name: str, lane: nullable(str) }), 32) }), 32) }), 128) }), 1000);
|
|
39
|
+
for (const shape of [p.compositions.items.properties.entries.items, p.compositions.items.properties.entries.items.properties.layers.items]) shape.properties.symbol = str;
|
|
39
40
|
schema.required.push("renders", "guards", "compositions", "selectorPool", "renderNavigation");
|
|
40
41
|
return schema;
|
|
41
42
|
}
|
|
@@ -85,7 +86,7 @@ export function attachRenderIndex(graph, manifest, definitions, navigation=[]) {
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export function validateRenderIndex(graph, manifest) {
|
|
88
|
-
const contained = (file, lane) => lane === "." && ["react-router", "react-spa"].includes(manifest.routing.framework)
|
|
89
|
+
const contained = (file, lane) => lane === "." && ["react-router", "react-spa", "tanstack-router"].includes(manifest.routing.framework)
|
|
89
90
|
? !file.startsWith("/") && !file.split("/").includes("..")
|
|
90
91
|
: file.startsWith(lane + "/");
|
|
91
92
|
const unique = (rows, key) => {
|
|
@@ -95,14 +96,15 @@ export function validateRenderIndex(graph, manifest) {
|
|
|
95
96
|
};
|
|
96
97
|
const renders = unique(graph.renders, "renderKey"), compositions = unique(graph.compositions, "compositionKey"), guards = unique(graph.guards, "routeKey");
|
|
97
98
|
const files = new Set(manifest.sourceFiles.map(file => file.file));
|
|
99
|
+
const knownSymbol = entry => !entry.symbol || manifest.components.some(component => component.file === entry.file && component.name === entry.symbol);
|
|
98
100
|
for (const composition of compositions.values()) {
|
|
99
101
|
if (composition.compositionKey !== compositionIdentity(composition.entries)) throw new Error("Composition identity mismatch");
|
|
100
102
|
const lanes = new Set();
|
|
101
103
|
for (const entry of composition.entries) {
|
|
102
|
-
if (lanes.has(entry.lane) || !files.has(entry.file) || !contained(entry.file, entry.lane)) throw new Error("Invalid composition entry");
|
|
104
|
+
if (lanes.has(entry.lane) || !files.has(entry.file) || !contained(entry.file, entry.lane) || !knownSymbol(entry)) throw new Error("Invalid composition entry");
|
|
103
105
|
lanes.add(entry.lane);
|
|
104
106
|
for (const layer of entry.layers) {
|
|
105
|
-
if (!files.has(layer.file) || !contained(layer.file, entry.lane)) throw new Error("Invalid composition layout");
|
|
107
|
+
if (!files.has(layer.file) || !contained(layer.file, entry.lane) || !knownSymbol(layer)) throw new Error("Invalid composition layout");
|
|
106
108
|
const folder = layer.file.slice(0, layer.file.lastIndexOf("/"));
|
|
107
109
|
for (const slot of layer.slots) if (slot.lane !== null && slot.lane !== folder + "/@" + slot.name) throw new Error("Invalid composition slot");
|
|
108
110
|
}
|
package/src/routing.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function pagesRoute(file) {
|
|
|
26
26
|
|
|
27
27
|
/** Router configuration provenance is explicit for source-declared React routes. */
|
|
28
28
|
export function sourcePageRoute(page, routing) {
|
|
29
|
-
if (["react-router", "react-spa"].includes(routing.framework)) {
|
|
29
|
+
if (["react-router", "react-spa", "tanstack-router"].includes(routing.framework)) {
|
|
30
30
|
const rows = routing.routeSources?.filter(
|
|
31
31
|
(row) =>
|
|
32
32
|
row.path === page.route &&
|
|
@@ -39,7 +39,7 @@ export const schema = {
|
|
|
39
39
|
platforms: array({ enum: ["web", "ios", "android"] }, 3),
|
|
40
40
|
}),
|
|
41
41
|
routing: object({
|
|
42
|
-
framework: { enum: ["next-app-router", "next-pages-router", "react-router", "react-spa"] },
|
|
42
|
+
framework: { enum: ["next-app-router", "next-pages-router", "react-router", "react-spa", "tanstack-router"] },
|
|
43
43
|
basePath: str,
|
|
44
44
|
trailingSlash: { type: "boolean" },
|
|
45
45
|
routeSources: array(object({ path: str, source, componentFile: str, componentSymbol: str }), 200),
|
|
@@ -176,3 +176,10 @@ schema.properties.styles=array(object({styleKey:{type:'string',pattern:'^style_[
|
|
|
176
176
|
schema.properties.documents.items.properties.styleKeys=array({type:'string',pattern:'^style_[a-f0-9]{64}$'},16);
|
|
177
177
|
schema.properties.scripts=array(object({scriptKey:{type:'string',pattern:'^script_[a-f0-9]{64}$'},sha256:hash,js:{type:'string',maxLength:100000}}),8);
|
|
178
178
|
schema.properties.documents.items.properties.scriptKey={type:'string',pattern:'^script_[a-f0-9]{64}$'};
|
|
179
|
+
|
|
180
|
+
// HTML and preorder mappings may use bounded lossless storage compression.
|
|
181
|
+
schema.properties.documents.items.properties.domPayload = object({
|
|
182
|
+
encoding: { const: "gzip-json-v1" },
|
|
183
|
+
bytes: { type: "integer", minimum: 1, maximum: 8_388_608 },
|
|
184
|
+
data: { type: "string", minLength: 1, maxLength: 11_184_812 },
|
|
185
|
+
});
|