Files
road-compiler/workbench/server.js
que01 62795a97b9 fix: install the recompile hook on UI import
`/api/import` set `session.area` but never `session.context.compileFresh`, which
is only wired when the server starts with an area on the command line. Every
session imported through the browser therefore answered "请先导入 OSM 文件" to
`/api/compile`, even though the import had just succeeded.

This predates direct editing — it broke "保存并重新生成" for UI-imported
workspaces — but it surfaced now, because saving a direct edit recompiles and so
reported a failed regeneration after a save that had in fact succeeded.

Covered by a new HTTP-level test: import the fixture, then recompile twice. The
bug lived in the wiring between two handlers rather than in either one, so
nothing below the HTTP boundary could catch it. Verified by reverting the fix and
watching the test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:37:27 +08:00

640 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
const {
compileRoadModel,
compileGeometry,
loadOverrides,
validateOverrides,
writeJsonAtomic,
} = require('../src/compile/native-road');
const { generate, validateDocument, runtime } = require('../src/native-traffic-signals');
const { convertGeoJson } = require('../src/reference/gaode');
const { exportNativeRoadPackage } = require('../src/export/native-road-package');
const { compileInput } = require('../src/compile/compiler');
const {
createCheckpoint,
ensureRevisionStore,
readRevision,
setActiveAreaConfig,
} = require('../src/compile/road-revisions');
const { GEOMETRY_VERSION, resolveDirectEditConstraints } = require('../src/compile/direct-edit-solver');
const { loadEditDocument, saveEditDocument, validateEditDocument } = require('../src/compile/native-road-edits');
function startWorkbench({
area = null,
input = null,
inputFile = null,
repoRoot = process.cwd(),
dataRoot = path.join(repoRoot, 'workbench-data'),
configPath = null,
compileFresh = null,
readAreaConfig = null,
junctionReference = null,
debug = false,
port = 8787,
}) {
if (typeof junctionReference === 'string') junctionReference = readJunctionReference(junctionReference);
// `--debug` surfaces advisory compiler findings that have no geometry layer of
// their own — currently the complex-junction candidates. Off by default so the
// normal editing view stays uncluttered.
if (!Number.isInteger(port) || port < 1024 || port > 65535)
throw new Error('--port must be an integer in [1024, 65535].');
const session = {
area,
context: { repoRoot, configPath, compileFresh, readAreaConfig },
junctionReference,
debug,
dataRoot,
};
if (input) {
session.context.compileFresh = () => {
const compiled = compileWorkbenchInput(input);
session.area = compiled.area;
return compiled;
};
session.context.compileFresh();
}
const server = http.createServer((request, response) => handle(request, response, session));
server.on('error', (error) => {
console.error(`Road Workbench failed to listen: ${error.message}`);
process.exitCode = 1;
});
server.listen(port, '127.0.0.1', () =>
console.log(`Road Workbench: http://127.0.0.1:${port}/${debug ? ' (debug: 复杂路口候选已开启)' : ''}`),
);
return server;
}
function handle(request, response, session) {
const area = session.area;
const context = session.context;
const junctionReference = session.junctionReference;
const debug = session.debug;
const url = new URL(request.url, 'http://127.0.0.1');
if (request.method === 'GET' && url.pathname === '/') return sendWorkbenchApp(response);
if (request.method === 'GET' && url.pathname.startsWith('/vendor/'))
return sendVendorFile(response, url.pathname, context.repoRoot);
if (request.method === 'GET' && url.pathname === '/api/state')
return area
? sendJson(response, 200, state(area, junctionReference, debug))
: sendJson(response, 200, { active: false });
if (request.method === 'GET' && url.pathname === '/api/edit-state')
return Promise.resolve()
.then(() => (area ? editState(area) : { active: false }))
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/edit-preview')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
return editPreview(area, body);
})
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/edits')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
return saveEdits(area, body);
})
.then((value) => sendJson(response, 200, value))
.catch((error) =>
sendJson(response, error.statusCode || 400, {
ok: false,
error: error.message,
...(error.current ? { current: error.current } : {}),
}),
);
if (request.method === 'POST' && url.pathname === '/api/revisions')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const revision = createCheckpoint(path.dirname(area.input), body.label);
return { ok: true, revision: revision.manifest, ...editState(area) };
})
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
const rebase = /^\/api\/revisions\/(rev-\d{4})\/rebase$/.exec(url.pathname);
if (request.method === 'POST' && rebase)
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
return rebaseEdits(area, rebase[1]);
})
.then((value) => sendJson(response, 200, value))
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'GET' && url.pathname === '/api/session')
return sendJson(response, 200, {
active: Boolean(session.area),
areaId: session.area?.id || null,
...(session.area ? activeRevisionFields(session.area) : {}),
});
if (request.method === 'POST' && url.pathname === '/api/import')
return readUpload(request, session)
.then((result) =>
sendJson(response, 200, { ok: true, areaId: result.area.id, ...state(result.area, junctionReference, debug) }),
)
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'GET' && url.pathname === '/api/export.zip')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const exported = exportNativeRoadPackage(area.outputs.nativeRoadDir);
response.writeHead(200, {
'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${area.id}.native-road.zip"`,
'Content-Length': exported.bytes.length,
'Cache-Control': 'no-store',
});
response.end(Buffer.from(exported.bytes));
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const document = validateDocument(body, fs.readFileSync(area.input, 'utf8'));
writeJsonAtomic(area.outputs.nativeTrafficSignals, document);
sendJson(response, 200, { ok: true, trafficSignals: document, runtime: runtime(document) });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/traffic-signals/generate')
return Promise.resolve()
.then(() => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const generated = generate(
fs.readFileSync(area.input, 'utf8'),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'vehicle_stop_lines.geojson')),
readLayer(path.join(area.outputs.nativeRoadDir, 'layers', 'intersection_surface.geojson')),
);
const current = validateDocument(
readJson(area.outputs.nativeTrafficSignals),
fs.readFileSync(area.input, 'utf8'),
);
const present = new Set(current.assemblies.features.map((feature) => feature.properties.signal_uid));
current.assemblies.features.push(
...generated.assemblies.features.filter((feature) => !present.has(feature.properties.signal_uid)),
);
writeJsonAtomic(area.outputs.nativeTrafficSignals, current);
sendJson(response, 200, {
ok: true,
trafficSignals: current,
runtime: runtime(current),
generated: generated.assemblies.features.length,
compiled: Boolean(compiled),
});
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/overrides')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
const compiled = readCompiled(area);
const overrides = validateOverrides(body, { roads: compiled.model.roads, endpoints: compiled.model.endpoints });
writeJsonAtomic(area.outputs.nativeRoadOverrides, overrides);
sendJson(response, 200, { ok: true, overrides });
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/junction-clusters')
return readBody(request)
.then((body) => {
if (!area) throw new Error('请先导入 OSM 文件。');
if (!debug) throw new Error('该接口仅在 --debug 模式下可用。');
const added = addJunctionCluster(body, readCompiled(area), area.nativeRoad);
setActiveAreaConfig(path.dirname(area.input), added.options);
context.compileFresh();
sendJson(response, 200, {
ok: true,
added: added.cluster,
configSnippet: { nativeRoad: { junctionTemplates: added.options.junctionTemplates } },
...state(session.area, junctionReference, debug),
});
})
.catch((error) => sendJson(response, 400, { ok: false, error: error.message }));
if (request.method === 'POST' && url.pathname === '/api/compile')
return Promise.resolve()
.then(() => {
if (!area || typeof context.compileFresh !== 'function') throw new Error('请先导入 OSM 文件。');
context.compileFresh();
sendJson(response, 200, state(session.area, junctionReference, debug));
})
.catch((error) => sendJson(response, 500, { ok: false, error: error.message }));
if (request.method === 'GET' && !url.pathname.startsWith('/api/')) return sendWorkbenchAsset(response, url.pathname);
sendJson(response, 404, { error: 'Not found' });
}
function state(area, junctionReference = null, debug = false) {
// This is the read entry point for both a fresh import and a reopened legacy
// workspace. Initialization only adds the v2 layout; it never rewrites v1 files.
const revisionStore = ensureRevisionStore(path.dirname(area.input), area.nativeRoad);
const nativeDir = area.outputs.nativeRoadDir;
const osm2streetsRoadSurface = area.outputs.geojsonDir
? path.join(area.outputs.geojsonDir, 'road_surface.geojson')
: null;
const trafficSignals = fs.existsSync(area.outputs.nativeTrafficSignals)
? validateDocument(readJson(area.outputs.nativeTrafficSignals), fs.readFileSync(area.input, 'utf8'))
: {
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
};
const trafficRuntime = runtime(trafficSignals);
const compiled = readCompiled(area);
return {
areaId: area.id,
activeRevisionId: revisionStore.active.activeRevisionId,
documentVersion: revisionStore.active.documentVersion,
debug: debug ? { junctionCandidates: junctionCandidates(compiled) } : null,
compiled,
overrides: loadOverrides(area.outputs.nativeRoadOverrides),
trafficSignals,
trafficRuntime,
comparison: readJson(path.join(nativeDir, 'comparison.json')),
junctionReference,
layers: {
nativeRoadSurface: readLayer(path.join(nativeDir, 'layers', 'road_surface.geojson')),
edgeLines: readLayer(path.join(nativeDir, 'layers', 'edge_lines.geojson')),
nativeSidewalkSurface: readLayer(path.join(nativeDir, 'layers', 'sidewalk_surface.geojson')),
nativeIntersectionSurface: readLayer(path.join(nativeDir, 'layers', 'intersection_surface.geojson')),
laneCenterlines: readLayer(path.join(nativeDir, 'layers', 'lane_centerlines.geojson')),
laneSeparators: readLayer(path.join(nativeDir, 'layers', 'lane_separators.geojson')),
centerLines: readLayer(path.join(nativeDir, 'layers', 'center_lines.geojson')),
directionArrows: readLayer(path.join(nativeDir, 'layers', 'direction_arrows.geojson')),
turnArrows: readLayer(path.join(nativeDir, 'layers', 'turn_arrows.geojson')),
crosswalks: readLayer(path.join(nativeDir, 'layers', 'crosswalks.geojson')),
vehicleStopLines: readLayer(path.join(nativeDir, 'layers', 'vehicle_stop_lines.geojson')),
connectors: readLayer(path.join(nativeDir, 'layers', 'connectors.geojson')),
osm2streetsRoadSurface:
osm2streetsRoadSurface && fs.existsSync(osm2streetsRoadSurface) ? readLayer(osm2streetsRoadSurface) : null,
},
};
}
function activeRevisionFields(area) {
const store = ensureRevisionStore(path.dirname(area.input), area.nativeRoad);
return { activeRevisionId: store.active.activeRevisionId, documentVersion: store.active.documentVersion };
}
function editState(area) {
const workspace = path.dirname(area.input);
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
const directEdits = loadEditDocument(initialized.paths.activeEdits);
const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides));
const resolution = resolveDirectEditConstraints(model, directEdits, {
revisionId: initialized.active.activeRevisionId,
compilerGeometryVersion: GEOMETRY_VERSION,
});
return {
active: true,
areaId: area.id,
activeRevisionId: initialized.active.activeRevisionId,
documentVersion: directEdits.documentVersion,
document: directEdits,
constraintStates: resolution.constraintStates,
diagnostics: resolution.diagnostics,
handles: resolution.handles,
};
}
function editPreview(area, body = {}) {
const startedAt = Date.now();
const workspace = path.dirname(area.input);
const activeEditsFile = path.join(workspace, 'active', 'native-road-edits.json');
const activeStateFile = path.join(workspace, 'active', 'state.json');
if (!fs.existsSync(activeEditsFile) || !fs.existsSync(activeStateFile))
throw new Error('编辑工作区尚未初始化,请先重新导入 OSM 文件。');
const activeEdits = loadEditDocument(activeEditsFile);
const draft = body.document
? validateEditDocument(body.document)
: validateEditDocument({
...activeEdits,
...(Array.isArray(body.constraints) ? { constraints: body.constraints } : {}),
...(Array.isArray(body.operations) ? { operations: body.operations } : {}),
});
const activeState = readJson(activeStateFile);
const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides));
const previewSeq = Number.isInteger(body.previewSeq) ? body.previewSeq : 0;
const resolution = resolveDirectEditConstraints(model, draft, {
revisionId: activeState.activeRevisionId,
previewSeq,
compilerGeometryVersion: GEOMETRY_VERSION,
});
const compiled = compileGeometry(model, loadOverrides(area.outputs.nativeRoadOverrides), {
edgeLines: area.nativeRoad.edgeLines,
junctionTemplates: area.nativeRoad.junctionTemplates,
directEdit: resolution,
});
const diagnostics = [...compiled.diagnostics, ...resolution.diagnostics];
const degraded = Date.now() - startedAt > 300;
return {
ok: true,
previewSeq,
degraded,
revisionId: activeState.activeRevisionId,
documentVersion: activeEdits.documentVersion,
constraintStates: resolution.constraintStates,
diagnostics,
handles: resolution.handles,
layers: {
roadSurface: compiled.roadSurface,
edgeLines: compiled.edgeLines,
sidewalkSurface: compiled.sidewalkSurface,
intersectionSurface: compiled.intersectionSurface,
laneCenterlines: compiled.laneCenterlines,
laneSeparators: compiled.laneSeparators,
centerLines: compiled.centerLines,
directionArrows: compiled.directionArrows,
turnArrows: compiled.turnArrows,
crosswalks: compiled.crosswalks,
vehicleStopLines: compiled.vehicleStopLines,
connectors: compiled.connectors,
},
};
}
function saveEdits(area, body = {}) {
const workspace = path.dirname(area.input);
const initialized = ensureRevisionStore(workspace, area.nativeRoad);
const current = loadEditDocument(initialized.paths.activeEdits);
if (!Number.isInteger(body.expectedDocumentVersion)) throw new Error('expectedDocumentVersion must be an integer.');
if (body.expectedDocumentVersion !== current.documentVersion) {
const error = new Error('编辑文档版本已变化,请刷新后重试。');
error.statusCode = 409;
error.current = { documentVersion: current.documentVersion };
throw error;
}
const candidate = body.document
? { ...body.document, documentVersion: current.documentVersion }
: {
...current,
...(Array.isArray(body.constraints) ? { constraints: body.constraints } : {}),
...(Array.isArray(body.operations) ? { operations: body.operations } : {}),
documentVersion: current.documentVersion,
};
const saved = saveEditDocument(initialized.paths.activeEdits, candidate);
writeJsonAtomic(initialized.paths.activeState, {
...initialized.active,
documentVersion: saved.documentVersion,
});
return {
ok: true,
document: saved,
documentVersion: saved.documentVersion,
activeRevisionId: initialized.active.activeRevisionId,
};
}
function rebaseEdits(area, revisionId) {
const workspace = path.dirname(area.input);
const revision = readRevision(workspace, revisionId);
const model = compileRoadModel(fs.readFileSync(area.input, 'utf8'), loadOverrides(area.outputs.nativeRoadOverrides));
const resolution = resolveDirectEditConstraints(model, revision.directEdits, {
revisionId,
compilerGeometryVersion: GEOMETRY_VERSION,
});
const counts = Object.fromEntries(
['exact', 'recheck', 'pending', 'conflicted', 'stale'].map((status) => [status, 0]),
);
for (const state of resolution.constraintStates) counts[state.status] = (counts[state.status] || 0) + 1;
return {
ok: true,
revisionId,
counts,
constraints: resolution.constraintStates,
diagnostics: resolution.diagnostics,
};
}
// The compiler reports candidates as advisory diagnostics. Lift them into their
// own payload with a stable index so the map can label them "#1, #2, ..." and
// the inspector can offer a ready-to-paste cluster配置.
// Append one detected cluster to the active area-config snapshot. The candidate
// must still be present in the latest compile, so a stale browser tab cannot
// write a cluster that no longer exists. The external config remains untouched;
// the response carries a copyable snippet while future compiles use the snapshot.
function addJunctionCluster(body, compiled, options) {
const index = Number(body?.index);
if (!Number.isInteger(index)) throw new Error('请求缺少候选编号 index。');
const candidate = junctionCandidates(compiled).find((item) => item.index === index);
if (!candidate) throw new Error(`候选 #${index} 不在最新一次编译结果里,请刷新页面后重试。`);
const templates = options?.junctionTemplates;
if (!templates) throw new Error('区域配置缺少 nativeRoad.junctionTemplates请先手工建立该节点。');
const clusters = Array.isArray(templates.clusters) ? templates.clusters : [];
const taken = new Set(clusters.flatMap((cluster) => (cluster.nodeIds || []).map(String)));
const clash = candidate.nodeIds.filter((nodeId) => taken.has(String(nodeId)));
if (clash.length) throw new Error(`节点 ${clash.join('、')} 已属于其他复杂路口配置。`);
const id = uniqueClusterId(`cluster-${candidate.nodeIds[0]}`, new Set(clusters.map((cluster) => cluster.id)));
const cluster = {
id,
template: candidate.template,
coreRadiusMeters: candidate.coreRadiusMeters,
cornerRadiusMeters: 12,
outerRadiusExtraMeters: 18,
nodeIds: candidate.nodeIds.map(String),
};
const nextOptions = {
...options,
junctionTemplates: { ...templates, enabled: true, clusters: [...clusters, cluster] },
};
return { cluster, options: nextOptions };
}
function uniqueClusterId(base, taken) {
if (!taken.has(base)) return base;
for (let suffix = 2; suffix < 100; suffix += 1) if (!taken.has(`${base}-${suffix}`)) return `${base}-${suffix}`;
throw new Error('无法生成唯一的 cluster id。');
}
function junctionCandidates(compiled) {
return (compiled?.diagnostics || [])
.filter((item) => item.rule === 'complex-junction-candidate' && item.suggestedCluster)
.sort(
(first, second) =>
second.suggestedCluster.nodeCount - first.suggestedCluster.nodeCount ||
first.suggestedCluster.diameterMeters - second.suggestedCluster.diameterMeters,
)
.map((item, index) => ({
index: index + 1,
id: item.id,
message: item.message,
coordinate: item.geometry?.coordinates || null,
...item.suggestedCluster,
}));
}
function readJunctionReference(file) {
if (!fs.existsSync(file)) throw new Error(`Junction reference not found: ${file}`);
const converted = convertGeoJson(JSON.parse(fs.readFileSync(file, 'utf8')));
return { source: file, coordinateSystem: 'GCJ-02', converted };
}
function readUpload(request, session) {
return readMultipart(request, 20 * 1024 * 1024).then(({ filename, data }) => {
if (!filename || !/\.osm$/i.test(filename)) throw new Error('请选择 .osm 文件。');
if (!data.length) throw new Error('OSM 文件不能为空。');
const base =
path
.basename(filename, path.extname(filename))
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.replace(/^-+|-+$/g, '')
.toLowerCase() || 'osm-import';
fs.mkdirSync(session.dataRoot, { recursive: true });
const root = fs.mkdtempSync(path.join(session.dataRoot, 'import-'));
const areaId = `${base}-${path.basename(root).slice(-6)}`;
const outRoot = path.join(root, 'outputs');
const input = {
areaId,
osmFile: path.join(root, 'source.osm'),
outDir: path.join(outRoot, 'native-road'),
stagingDir: path.join(outRoot, '_pipeline'),
overridesFile: path.join(root, 'native-road-overrides.json'),
trafficSignalsFile: path.join(root, 'native-traffic-signals.json'),
options: { edgeLines: false, junctionTemplates: { enabled: false, references: [] } },
};
fs.writeFileSync(input.osmFile, data);
fs.writeFileSync(
input.overridesFile,
JSON.stringify({ schema: 'native-road-overrides/v1', overrides: [] }, null, 2),
);
fs.writeFileSync(
input.trafficSignalsFile,
JSON.stringify(
{
schema: 'native-traffic-signals/v1',
provenance: 'empty',
assemblies: { type: 'FeatureCollection', features: [] },
},
null,
2,
),
);
try {
const compiled = compileWorkbenchInput(input);
session.area = compiled.area;
// Install the recompile hook too. Without it `/api/compile` answers "请先导入
// OSM 文件" for the whole life of a UI-imported session, because compileFresh
// is only wired when the server is started with an area on the command line.
// That broke "保存并重新生成" and, once direct edits recompiled on save, made a
// successful save report a failed regeneration.
session.context.compileFresh = () => {
const recompiled = compileWorkbenchInput(input);
session.area = recompiled.area;
return recompiled;
};
return compiled;
} catch (error) {
fs.rmSync(root, { recursive: true, force: true });
throw error;
}
});
}
function compileWorkbenchInput(input) {
const workspace = path.dirname(input.osmFile);
const initialized = ensureRevisionStore(workspace, input.options);
return compileInput({
...input,
areaConfigSnapshotFile: initialized.paths.activeAreaConfig,
editsFile: initialized.paths.activeEdits,
});
}
function readMultipart(request, limit) {
return new Promise((resolve, reject) => {
const type = request.headers['content-type'] || '';
const match = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(type);
if (!match) return reject(new Error('请使用 multipart/form-data 上传 OSM 文件。'));
const boundary = `--${match[1] || match[2]}`;
const chunks = [];
let size = 0;
request.on('data', (chunk) => {
size += chunk.length;
if (size > limit) {
reject(new Error('上传文件超过 20 MB 限制。'));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on('error', reject);
request.on('end', () => {
const body = Buffer.concat(chunks);
const start = body.indexOf(Buffer.from('\r\n\r\n'));
const end = body.lastIndexOf(Buffer.from(`\r\n${boundary}--`));
if (start < 0 || end < start) return reject(new Error('上传内容格式无效。'));
const header = body.slice(0, start).toString();
const name = /filename="([^"]*)"/i.exec(header)?.[1] || 'upload.osm';
resolve({ filename: name, data: body.slice(start + 4, end) });
});
});
}
function readCompiled(area) {
return readJson(path.join(area.outputs.nativeRoadDir, 'compiled.json'));
}
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function readLayer(file) {
return fs.existsSync(file) ? readJson(file) : { type: 'FeatureCollection', features: [] };
}
function readBody(request) {
return new Promise((resolve, reject) => {
let body = '';
request.setEncoding('utf8');
request.on('data', (part) => {
body += part;
if (body.length > 1024 * 1024) request.destroy();
});
request.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (_) {
reject(new Error('Request body must be JSON.'));
}
});
request.on('error', reject);
});
}
function sendWorkbenchApp(response) {
const built = path.join(__dirname, 'client', 'dist', 'index.html');
return sendFile(
response,
fs.existsSync(built) ? built : path.join(__dirname, 'client', 'index.html'),
'text/html; charset=utf-8',
);
}
function sendWorkbenchAsset(response, pathname) {
const root = path.join(__dirname, 'client', 'dist');
const file = path.resolve(root, `.${pathname}`);
if (file.startsWith(`${root}${path.sep}`) && fs.existsSync(file) && fs.statSync(file).isFile()) {
const extension = path.extname(file);
const types = {
'.js': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.woff2': 'font/woff2',
};
return sendFile(response, file, types[extension] || 'application/octet-stream');
}
return sendWorkbenchApp(response);
}
function sendFile(response, file, type) {
response.writeHead(200, { 'Content-Type': type, 'Cache-Control': 'no-store' });
fs.createReadStream(file).pipe(response);
}
function sendVendorFile(response, pathname, repoRoot) {
const match = /^\/vendor\/(ol|rbush|quickselect)\/(.+)$/.exec(pathname);
if (!match) return sendJson(response, 404, { error: 'Not found' });
const root = path.join(repoRoot, 'node_modules', match[1]);
const file = path.resolve(root, match[2]);
if (!file.startsWith(`${root}${path.sep}`) || !fs.existsSync(file) || !fs.statSync(file).isFile())
return sendJson(response, 404, { error: 'Not found' });
return sendFile(response, file, file.endsWith('.css') ? 'text/css; charset=utf-8' : 'text/javascript; charset=utf-8');
}
function sendJson(response, status, value) {
response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`${JSON.stringify(value)}\n`);
}
module.exports = { startWorkbench, readMultipart, editState, editPreview, saveEdits, rebaseEdits };