Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
66e5f29
refactor(project): Document and trim discardIncrementalState resets
RandomByte Jul 30, 2026
475d099
refactor(project): Add shared @parcel/watcher helpers
RandomByte Jul 30, 2026
b8a676c
refactor(project): Add ProjectDefinitionWatcher and projectGraphSettl…
RandomByte Jul 30, 2026
5637141
refactor(project): Keep the source watcher alive across a branch switch
RandomByte Jul 30, 2026
a92110b
refactor(project): Add reader-suspend gate and settle-based restart t…
RandomByte Jul 30, 2026
5d42fd1
refactor(server): Re-create the serving stack behind a stable HTTP se…
RandomByte Jul 30, 2026
99c4cd5
refactor(server): Add serveMiddleware API for embedding in external H…
RandomByte Jul 30, 2026
420b39b
refactor(cli): Re-resolve the graph and re-create the serving stack o…
RandomByte Jul 30, 2026
1170d03
refactor(logger): Reflect project re-resolution and degraded server s…
RandomByte Jul 30, 2026
fde0571
docs(project): Document project-definition change handling in the bui…
RandomByte Jul 30, 2026
f7c06b2
refactor(project): Consolidate live re-resolution helpers into one in…
RandomByte Jul 31, 2026
d725522
refactor(server): Tear down the serving stack on a post-bind startup …
RandomByte Jul 31, 2026
b7b339f
refactor(project): Add suspendBuilds() to stop the build loop on a de…
RandomByte Aug 3, 2026
0396cab
refactor(server): Stop the outgoing BuildServer's build loop on defin…
RandomByte Aug 3, 2026
ed9f235
docs(project): Document the build-loop suspend in the build skill
RandomByte Aug 3, 2026
b55fc60
test(server): Add failing test for recovery from a late-installed dep…
RandomByte Aug 3, 2026
c0db78d
refactor(server): Recover a degraded serve from a late-installed depe…
RandomByte Aug 3, 2026
464cf7e
refactor(server): Drop redundant Cache import from serve stack
RandomByte Aug 4, 2026
055f7b5
refactor(server): Inject the definition watcher instead of importing it
RandomByte Aug 4, 2026
2e36412
refactor(project): Reuse the workspace config default filename
RandomByte Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 54 additions & 4 deletions .claude/skills/incremental-build/architecture.md

Large diffs are not rendered by default.

59 changes: 41 additions & 18 deletions packages/cli/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -147,23 +147,36 @@ serve.handler = async function(argv) {

const {graphFromStaticFile, graphFromPackageDependencies} = await import("@ui5/project/graph");

let graph;
if (argv.dependencyDefinition) {
graph = await graphFromStaticFile({
filePath: argv.dependencyDefinition,
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
});
} else {
graph = await graphFromPackageDependencies({
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
workspaceConfigPath: argv.workspaceConfig,
workspaceName: argv.workspace === false ? null : argv.workspace,
});
}
// Workspace resolution is active unless static-graph mode is used or --workspace is disabled.
// Single source for both the graph resolve (below) and the watcher config, so the two cannot
// drift on whether workspace resolution runs.
const workspaceActive = !argv.dependencyDefinition && argv.workspace !== false;

// One graph-construction site, reused for the initial graph and every re-init the server
// performs on a project-definition change. Captures argv so the server re-resolves with the
// same parameters.
const buildGraph = async () => {
if (argv.dependencyDefinition) {
return graphFromStaticFile({
filePath: argv.dependencyDefinition,
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
});
} else {
return graphFromPackageDependencies({
rootConfigPath: argv.config,
versionOverride: argv.frameworkVersion,
snapshotCache: argv.snapshotCache ?? argv.cacheMode ?? "Default", // Use cacheMode as fallback
workspaceConfigPath: argv.workspaceConfig,
workspaceName: workspaceActive ? argv.workspace : null,
});
}
};

// Build the initial graph up front: its root server settings resolve the port and live-reload
// defaults below, before the server binds.
const graph = await buildGraph();

let port = argv.port;
let changePortIfInUse = false;
Expand Down Expand Up @@ -210,6 +223,11 @@ serve.handler = async function(argv) {
cache: argv.cache,
includedTasks: argv["include-task"],
excludedTasks: argv["exclude-task"],
// Threaded to the definition watcher so it watches the same files buildGraph resolves from.
// null selects the watcher's default ui5-workspace.yaml, undefined disables workspace watching.
rootConfigPath: argv.config,
workspaceConfigPath: workspaceActive ? (argv.workspaceConfig ?? null) : undefined,
dependencyDefinitionPath: argv.dependencyDefinition,
};

if (serverConfig.h2) {
Expand All @@ -221,9 +239,14 @@ serve.handler = async function(argv) {

const {promise: pOnError, reject} = Promise.withResolvers();
const {serve: serverServe} = await import("@ui5/server");
// The server does not depend on @ui5/project; inject the definition-watcher namespace it needs for
// re-resolution. The CLI owns @ui5/project.
const projectWatcher = await import("@ui5/project/internal/graph/ProjectDefinitionWatcher");
// Pass buildGraph as the graphFactory so the server can re-resolve the graph and re-create
// the serving stack when its definition watcher observes a project-definition change.
const {h2, port: actualPort} = await serverServe(graph, serverConfig, function(err) {
reject(err);
});
}, buildGraph, projectWatcher);

if (argv.open !== undefined) {
const protocol = h2 ? "https" : "http";
Expand Down
140 changes: 81 additions & 59 deletions packages/cli/test/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ test.beforeEach(async (t) => {
t.context.handlerReady = t.context.handlerReadyResolvers.promise;

t.context.server = {
serve: sinon.stub().callsFake((graph, config, errorCallback) => {
serve: sinon.stub().callsFake((graph, config, errorCallback, graphFactory) => {
t.context.serverErrorCallback = errorCallback;
t.context.handlerReadyResolvers.resolve();
return {
Expand All @@ -69,6 +69,9 @@ test.beforeEach(async (t) => {
graphFromPackageDependencies: sinon.stub().resolves(t.context.fakeGraph)
};

// Definition-watcher namespace the handler injects into server.serve().
t.context.projectWatcher = {default: {create: sinon.stub()}};

// Capture stray writes to stderr/stdout so failing assertions surface the
// actual output instead of ava's timeout diagnostics.
t.context.consoleOutput = "";
Expand All @@ -85,6 +88,7 @@ test.beforeEach(async (t) => {
"@ui5/server": t.context.server,
"@ui5/server/internal/sslUtil": t.context.sslUtil,
"@ui5/project/graph": t.context.graph,
"@ui5/project/internal/graph/ProjectDefinitionWatcher": t.context.projectWatcher,
"open": t.context.open
});
});
Expand All @@ -109,24 +113,40 @@ test.serial("ui5 serve: default", async (t) => {
}]);

t.is(server.serve.callCount, 1);
t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [
fakeGraph,
{
acceptRemoteConnections: false,
cache: undefined,
cert: undefined,
changePortIfInUse: true,
h2: false,
key: undefined,
port: 8080,
sendSAPTargetCSP: false,
serveCSPReports: false,
simpleIndex: false,
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
}
]);
t.is(server.serve.getCall(0).args[0], fakeGraph);

// The last argument carries a graphFactory the server can call to re-resolve the graph on a
// project-definition change. It must produce the same graph via the same builder + args.
const graphFactory = server.serve.getCall(0).args[3];
t.is(typeof graphFactory, "function");
await graphFactory();
t.is(graph.graphFromPackageDependencies.callCount, 2, "graphFactory re-invokes the same builder");
t.deepEqual(graph.graphFromPackageDependencies.getCall(1).args, graph.graphFromPackageDependencies.getCall(0).args,
"graphFactory re-resolves with identical parameters");

// esmock merges the partial mock over the real module, so assert on the threaded default rather than
// object identity.
t.is(server.serve.getCall(0).args[4].default, t.context.projectWatcher.default,
"the ProjectDefinitionWatcher module namespace is injected into server.serve()");

t.deepEqual(server.serve.getCall(0).args[1], {
acceptRemoteConnections: false,
cache: undefined,
cert: undefined,
changePortIfInUse: true,
h2: false,
key: undefined,
port: 8080,
sendSAPTargetCSP: false,
serveCSPReports: false,
simpleIndex: false,
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
rootConfigPath: undefined,
workspaceConfigPath: null,
dependencyDefinitionPath: undefined,
});
t.is(typeof server.serve.getCall(0).args[2], "function");
});

Expand All @@ -138,7 +158,7 @@ test.serial("ui5 serve --h2", async (t) => {
cert: "random-cert"
});

server.serve.callsFake((graph, config, errorCallback) => {
server.serve.callsFake((graph, config, errorCallback, graphFactory) => {
t.context.serverErrorCallback = errorCallback;
t.context.handlerReadyResolvers.resolve();
return {h2: true, port: 8443};
Expand All @@ -153,24 +173,25 @@ test.serial("ui5 serve --h2", async (t) => {
t.is(graph.graphFromPackageDependencies.callCount, 1);

t.is(server.serve.callCount, 1);
t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [
fakeGraph,
{
acceptRemoteConnections: false,
cache: undefined,
changePortIfInUse: true,
h2: true,
key: "random-key",
cert: "random-cert",
port: 8443,
sendSAPTargetCSP: false,
serveCSPReports: false,
simpleIndex: false,
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
}
]);
t.is(server.serve.getCall(0).args[0], fakeGraph);
t.deepEqual(server.serve.getCall(0).args[1], {
acceptRemoteConnections: false,
cache: undefined,
changePortIfInUse: true,
h2: true,
key: "random-key",
cert: "random-cert",
port: 8443,
sendSAPTargetCSP: false,
serveCSPReports: false,
simpleIndex: false,
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
rootConfigPath: undefined,
workspaceConfigPath: null,
dependencyDefinitionPath: undefined,
});

t.is(sslUtil.getSslCertificate.callCount, 1);
t.deepEqual(sslUtil.getSslCertificate.getCall(0).args, [
Expand All @@ -188,24 +209,25 @@ test.serial("ui5 serve --accept-remote-connections", async (t) => {
await t.context.handlerReady;

t.is(server.serve.callCount, 1);
t.deepEqual(server.serve.getCall(0).args.slice(0, 2), [
fakeGraph,
{
acceptRemoteConnections: true,
cache: undefined,
cert: undefined,
changePortIfInUse: true,
h2: false,
key: undefined,
port: 8080,
sendSAPTargetCSP: false,
serveCSPReports: false,
simpleIndex: false,
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
}
]);
t.is(server.serve.getCall(0).args[0], fakeGraph);
t.deepEqual(server.serve.getCall(0).args[1], {
acceptRemoteConnections: true,
cache: undefined,
cert: undefined,
changePortIfInUse: true,
h2: false,
key: undefined,
port: 8080,
sendSAPTargetCSP: false,
serveCSPReports: false,
simpleIndex: false,
liveReload: true,
includedTasks: undefined,
excludedTasks: undefined,
rootConfigPath: undefined,
workspaceConfigPath: null,
dependencyDefinitionPath: undefined,
});
});

test.serial("ui5 serve --open", async (t) => {
Expand Down Expand Up @@ -480,7 +502,7 @@ test.serial("ui5 serve with ui5.yaml port setting", async (t) => {
httpPort: 3333
});

server.serve.callsFake((graph, config, errorCallback) => {
server.serve.callsFake((graph, config, errorCallback, graphFactory) => {
t.context.serverErrorCallback = errorCallback;
t.context.handlerReadyResolvers.resolve();
return {h2: false, port: 3333};
Expand All @@ -506,7 +528,7 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting", async (t) => {
httpsPort: 4444
});

server.serve.callsFake((graph, config, errorCallback) => {
server.serve.callsFake((graph, config, errorCallback, graphFactory) => {
t.context.serverErrorCallback = errorCallback;
t.context.handlerReadyResolvers.resolve();
return {h2: true, port: 4444};
Expand Down Expand Up @@ -535,7 +557,7 @@ test.serial("ui5 serve --h2 with ui5.yaml port setting and port CLI argument", a
httpsPort: 4444
});

server.serve.callsFake((graph, config, errorCallback) => {
server.serve.callsFake((graph, config, errorCallback, graphFactory) => {
t.context.serverErrorCallback = errorCallback;
t.context.handlerReadyResolvers.resolve();
return {h2: true, port: 5555};
Expand Down
4 changes: 2 additions & 2 deletions packages/logger/lib/writers/Console.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ class Console {
process.on("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
process.on("ui5.build-status", this._handleBuildStatusEvent);
process.on("ui5.project-build-status", this._handleProjectBuildStatusEvent);
process.on("ui5.project-resolved", this._handleProjectResolvedEvent);
process.on("ui5.project-resolve-succeeded", this._handleProjectResolvedEvent);
process.on("ui5.server-listening", this._handleServerListeningEvent);
process.on("ui5.log.stop-console", this._handleStop);
}
Expand All @@ -129,7 +129,7 @@ class Console {
process.off("ui5.project-build-metadata", this._handleProjectBuildMetadataEvent);
process.off("ui5.build-status", this._handleBuildStatusEvent);
process.off("ui5.project-build-status", this._handleProjectBuildStatusEvent);
process.off("ui5.project-resolved", this._handleProjectResolvedEvent);
process.off("ui5.project-resolve-succeeded", this._handleProjectResolvedEvent);
process.off("ui5.server-listening", this._handleServerListeningEvent);
process.off("ui5.log.stop-console", this._handleStop);
if (this.#progressBarContainer) {
Expand Down
Loading
Loading