Fix app.json vendoring and schema bounds

This commit is contained in:
Chris Tate
2026-08-18 01:00:44 -05:00
parent 8a013c5dd6
commit 55f28ec663
6 changed files with 127 additions and 19 deletions
+3 -3
View File
@@ -408,9 +408,9 @@
"properties": {
"volume_name": { "type": "string" },
"background": { "type": "string" },
"window_width": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 660 },
"window_height": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 400 },
"icon_size": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 128 },
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
"app_position": { "$ref": "#/$defs/position" },
"applications_position": { "$ref": "#/$defs/position" },
"applications_link": { "type": "boolean", "default": true },
+3 -3
View File
@@ -408,9 +408,9 @@
"properties": {
"volume_name": { "type": "string" },
"background": { "type": "string" },
"window_width": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 660 },
"window_height": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 400 },
"icon_size": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 128 },
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
"app_position": { "$ref": "#/$defs/position" },
"applications_position": { "$ref": "#/$defs/position" },
"applications_link": { "type": "boolean", "default": true },
+6
View File
@@ -18,6 +18,12 @@ assert.equal(schema.$defs.persist.properties.debounce_ms.minimum, 0);
assert.equal(schema.$defs.persist.properties.debounce_ms.maximum, 60_000);
assert.equal(schema.$defs.frontend.properties.dev.properties.timeout_ms.minimum, 1);
assert.equal(schema.$defs.frontend.properties.dev.properties.timeout_ms.maximum, 4_294_967_295);
assert.equal(schema.$defs.dmg.properties.window_width.minimum, 320);
assert.equal(schema.$defs.dmg.properties.window_width.maximum, 2_000);
assert.equal(schema.$defs.dmg.properties.window_height.minimum, 240);
assert.equal(schema.$defs.dmg.properties.window_height.maximum, 1_400);
assert.equal(schema.$defs.dmg.properties.icon_size.minimum, 32);
assert.equal(schema.$defs.dmg.properties.icon_size.maximum, 256);
assert.deepEqual(fs.readFileSync(legacyPath), publishedBytes, "legacy docs schema differs from canonical v1");
assert.deepEqual(fs.readFileSync(packagePath), publishedBytes, "published and npm-packaged app schemas differ");
assert.equal(deployment.outputDirectory, "public");
@@ -86,6 +86,89 @@ function parseJsonManifest(source) {
return manifest;
}
function skipJsonWhitespace(source, start) {
let at = start;
while (/\s/.test(source[at] ?? "")) at++;
return at;
}
function scanJsonString(source, start) {
let escaped = false;
for (let at = start + 1; at < source.length; at++) {
const char = source[at];
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') return at + 1;
}
throw new Error("app.json contains an unterminated string");
}
function scanJsonValue(source, start) {
const first = source[start];
if (first === '"') return scanJsonString(source, start);
if (first === "{" || first === "[") {
const close = first === "{" ? "}" : "]";
let depth = 1;
let string = false;
let escaped = false;
for (let at = start + 1; at < source.length; at++) {
const char = source[at];
if (string) {
if (escaped) escaped = false;
else if (char === "\\") escaped = true;
else if (char === '"') string = false;
continue;
}
if (char === '"') string = true;
else if (char === first) depth++;
else if (char === close && --depth === 0) return at + 1;
}
throw new Error("app.json contains an unterminated value");
}
let at = start;
while (at < source.length && !/[\s,}\]]/.test(source[at])) at++;
return at;
}
function jsonObjectField(source, fieldName) {
let at = skipJsonWhitespace(source, 0);
if (source[at] !== "{") throw new Error("app.json must contain one object");
at = skipJsonWhitespace(source, at + 1);
let memberCount = 0;
let match = null;
while (source[at] !== "}") {
if (source[at] !== '"') throw new Error("app.json contains an invalid object key");
const keyStart = at;
const keyEnd = scanJsonString(source, keyStart);
const key = JSON.parse(source.slice(keyStart, keyEnd));
at = skipJsonWhitespace(source, keyEnd);
if (source[at] !== ":") throw new Error("app.json contains an invalid object field");
const valueStart = skipJsonWhitespace(source, at + 1);
const valueEnd = scanJsonValue(source, valueStart);
memberCount++;
if (key === fieldName) {
if (match !== null) throw new Error(`app.json contains duplicate ${fieldName} fields`);
match = { valueStart, valueEnd };
}
at = skipJsonWhitespace(source, valueEnd);
if (source[at] === ",") at = skipJsonWhitespace(source, at + 1);
else if (source[at] !== "}") throw new Error("app.json contains an invalid object separator");
}
return { match, close: at, memberCount };
}
function jsonTopLevelIndent(source, close) {
const firstKey = source.indexOf('"', source.indexOf("{") + 1);
const key = firstKey >= 0 && firstKey < close ? firstKey : close;
const lineStart = source.lastIndexOf("\n", key - 1) + 1;
const indent = source.slice(lineStart, key);
return /^[ \t]+$/.test(indent) ? indent : " ";
}
function renderJsonValue(value, indent) {
return JSON.stringify(value, null, 2).replaceAll("\n", `\n${indent}`);
}
export function readServicePackages(source, format = "zon") {
if (format === "json") {
const entries = parseJsonManifest(source).service_packages ?? [];
@@ -128,9 +211,15 @@ export function mergePackageSpecs(source, requested, format = "zon") {
export function replaceServicePackages(source, entries, format = "zon") {
if (format === "json") {
const manifest = parseJsonManifest(source);
manifest.service_packages = entries;
return `${JSON.stringify(manifest, null, 2)}\n`;
parseJsonManifest(source);
const field = jsonObjectField(source, "service_packages");
const indent = jsonTopLevelIndent(source, field.close);
const rendered = renderJsonValue(entries, indent);
if (field.match) {
return source.slice(0, field.match.valueStart) + rendered + source.slice(field.match.valueEnd);
}
const comma = field.memberCount === 0 ? "" : ",";
return `${source.slice(0, field.close).trimEnd()}${comma}\n${indent}"service_packages": ${rendered}\n${source.slice(field.close)}`;
}
const rendered = [
" .service_packages = .{",
+20 -7
View File
@@ -592,18 +592,31 @@ test("incremental vendoring preserves prior package facts and lets explicit upda
});
test("app.json vendoring preserves schema metadata and package facts", () => {
const source = JSON.stringify({
$schema: "https://schema.native-sdk.dev/app/v1.json",
id: "dev.example.fixture",
name: "fixture",
version: "1.0.0",
service_packages: [{ name: "alpha", version: "1.2.3", content_hash: "a".repeat(64) }],
});
const source = `{
"$schema": "https://schema.native-sdk.dev/app/v1.json",
"id": "dev.example.fixture",
"name": "fixture",
"version": "1.0.0",
"assets": { "images": [{ "id": 9007199254740993, "path": "assets/cover.png" }] },
"frontend": { "dev": { "url": "http://127.0.0.1:5173/", "timeout_ms": 1e3 } },
"service_packages": [{ "name": "alpha", "version": "1.2.3", "content_hash": "${"a".repeat(64)}" }]
}\n`;
assert.deepEqual(mergePackageSpecs(source, ["beta@2.0.0"], "json"), ["alpha@1.2.3", "beta@2.0.0"]);
const replaced = replaceServicePackages(source, [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }], "json");
const parsed = JSON.parse(replaced);
assert.equal(parsed.$schema, "https://schema.native-sdk.dev/app/v1.json");
assert.deepEqual(readServicePackages(replaced, "json"), [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }]);
assert.match(replaced, /"id": 9007199254740993/);
assert.match(replaced, /"timeout_ms": 1e3/);
const inserted = replaceServicePackages(`{
"id": "dev.example.fixture",
"name": "fixture",
"version": "1.0.0",
"assets": { "images": [{ "id": 9007199254740993, "path": "assets/cover.png" }] }
}\n`, [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }], "json");
assert.match(inserted, /"id": 9007199254740993/);
assert.deepEqual(readServicePackages(inserted, "json"), [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }]);
});
test("service staging lowers tagged throws across the service-host graph", () => {
+3 -3
View File
@@ -408,9 +408,9 @@
"properties": {
"volume_name": { "type": "string" },
"background": { "type": "string" },
"window_width": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 660 },
"window_height": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 400 },
"icon_size": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 128 },
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
"app_position": { "$ref": "#/$defs/position" },
"applications_position": { "$ref": "#/$defs/position" },
"applications_link": { "type": "boolean", "default": true },