Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .changeset/26242-serve-dev-compiles-at-boot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@objectstack/cli': patch
---

fix(cli): a direct `os serve --dev` compiles the config before its dev artifact door serves it, so the served connector `baseUrl` is the one dispatch calls

Clause-②: no
Metadata-protocol: none

- **Before.** `os serve --dev` over an existing `dist/objectstack.json` served that file through the dev artifact door as found, while dispatch read the config module it evaluated. A config value read from the environment, such as `examples/app-showcase`'s self URL built from `OS_PORT`, then differed between them. With `OS_PORT=41302` over an artifact built without it, `GET /api/v1/meta/connector/showcase_status_api` served `http://127.0.0.1:3000` while the connector called port 41302. `os dev` had the same split until it began compiling at boot.
- **Now.** Before it composes that door, a direct dev boot compiles the config into the artifact in its own environment, using the same decision `os dev` uses (`devCompilesAtBoot`), so both copies come from one evaluation. A failed compile refuses the boot, as under `os dev`. This costs about 3 s per boot on the showcase.
- Under `os dev`, the `serve` child keeps its parent's decision and never compiles again, so `os dev --no-compile` still boots the artifact as built. A dev boot with no artifact on disk composes no door and compiles nothing, as before. A pinned artifact (`OS_ARTIFACT_URL`) is never rebuilt.
- Production `os serve` (no `--dev`, `NODE_ENV` not `development`) and `os start` are unchanged.
43 changes: 1 addition & 42 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type DevelopmentUpgradeResult,
} from '../utils/dev-organization-ownership-upgrade.js';
import { childEnvWithResolvedArtifact } from '../utils/internal-artifact-channel.js';
import { devCompilesAtBoot } from '../utils/dev-compile-at-boot.js';
import { makeDirectorySync } from '../utils/make-directory.js';
// THE artifact precedence, written once (#21501) — shared with `start`, and
// with the `serve` child this command spawns. ⛔ No rung of it is restated here.
Expand Down Expand Up @@ -246,48 +247,6 @@ export function forwardSeedSettledToParent(msg: unknown): boolean {
return true;
}

/**
* Whether this `os dev` boot compiles the cwd config into the artifact it is
* about to serve, before the server starts (#26130).
*
* Yes by default whenever the boot serves an artifact this command builds and
* there is a config to build it from. The reason is the dev boot's two copies
* of one stack. A host config's `os dev` boot serves the stack from TWO places:
* the config MODULE, evaluated in the `serve` child (the AppPlugin wrap, whose
* registry copy is what a provider-bound connector is materialized from, so
* what dispatch calls), and the compiled artifact (the dev artifact door, whose
* copy is what `/api/v1/meta/<type>` serves). A config that reads the
* environment at module evaluation, as `examples/app-showcase`'s self URL reads
* `OS_PORT`, gives the module copy THIS boot's value and the artifact copy the
* value of whichever environment last compiled it. Measured on the showcase
* with `OS_PORT=41302` over an artifact built without it:
* `GET /api/v1/meta/connector/showcase_status_api` served
* `http://127.0.0.1:3000` while the materialized connector called 41302.
*
* Compiling here, in this process's environment (the `serve` child inherits
* the same one), makes both copies one evaluation's answer, so nothing has to
* restate which variables a config reads or in what order. The watch loop's
* recompiles already run in that environment, so the two stay one answer for
* the whole session.
*
* `--no-compile` keeps the old shortcut, booting the artifact as built (and
* the startup staleness warning says when it is older than the sources); a
* missing artifact is still compiled, since there is nothing else to serve. A
* pinned artifact (`--artifact`, `OS_ARTIFACT_URL`, a remote
* `OS_ARTIFACT_PATH`) is never rebuilt. With no config and a missing artifact
* the answer is yes, so the caller refuses there with the one remedy line.
*/
export function devCompilesAtBoot(opts: {
compile: boolean;
pinned: boolean;
configExists: boolean;
artifactExists: boolean;
}): boolean {
if (opts.pinned) return false;
if (!opts.artifactExists) return true;
return opts.compile && opts.configExists;
}

/**
* Whether this `os dev` boot runs the watch-recompile loop (#20681).
*
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { mergeBootConfig } from '../utils/merge-boot-config.js';
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
import { isHostConfig, shouldBootWithLibrary } from '../utils/plugin-detection.js';
import { readInternalArtifactPath, readInternalConfigOutputPath } from '../utils/internal-artifact-channel.js';
import { compileForDevBoot, devCompilesAtBoot } from '../utils/dev-compile-at-boot.js';
import { makeDirectorySync } from '../utils/make-directory.js';
// The precedence's last rung — whether the cwd config takes part — decided by
// the SAME predicate the supervisors print their `Config:` row by (#21501).
Expand Down Expand Up @@ -2173,6 +2174,9 @@ export default class Serve extends Command {
// An operator who never exported NODE_ENV is booting a real deployment,
// not asking to be treated as development — this is `os serve` agreeing
// with `os start` on that, one line, for the whole gate family at once.
// #26242 — the value before this command sets its own, for the one child a
// dev boot may start (`compileForDevBoot`), which must not inherit the write.
const launchNodeEnv = process.env.NODE_ENV;
if (flags.dev) {
if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development';
} else if (!process.env.NODE_ENV) {
Expand Down Expand Up @@ -3503,6 +3507,41 @@ export default class Serve extends Command {
+ '\n boot without a door.',
));
} else {
// #26242 — the door is about to serve THIS file's copy of the
// stack while the wrap below serves the config module's, so a
// config that reads the environment at module evaluation (the
// showcase's `OS_PORT` self URL) gives `/meta` the value of
// whichever environment last compiled the file. `os dev` closed
// that by compiling before it boots (#26130); a direct dev boot
// reads the same decision, `devCompilesAtBoot`. Under a
// supervising `os dev` (its channel answered) the parent already
// made it, `--no-compile` included, so the child never compiles
// again. Only an artifact on disk reaches this branch: with none,
// no door composes and the wrap is the one copy.
//
// The compile runs in THIS process's environment, the one the
// module above was evaluated in, with the launch `NODE_ENV` put
// back (`compileForDevBoot` says why), so both copies are one
// evaluation's answer exactly as under `os dev`. A failed compile
// refuses the boot, as it does there.
if (!supervisorArtifact && devCompilesAtBoot({
compile: true,
pinned: Boolean(pinnedArtifact),
configExists,
artifactExists: true,
})) {
printDiagnostic(chalk.dim(` Compiling ${relativeConfig} → ${path.relative(process.cwd(), hmrArtifactPath) || hmrArtifactPath}...`));
const compiled = compileForDevBoot({ configPath: absolutePath, outputPath: hmrArtifactPath, launchNodeEnv });
if (compiled.status !== 0) {
console.error(
`${compiled.stdout ?? ''}${compiled.stderr ?? ''}\n`
+ chalk.red(` ✗ Compile failed — fix errors above before starting the dev server.\n`)
+ chalk.dim(` A dev boot over ${hmrArtifactPath} compiles ${relativeConfig} into it first, so the`)
+ chalk.dim('\n metadata it serves is the config this boot evaluates.'),
);
process.exit(1);
}
}
const { MetadataPlugin } = await import('@objectstack/metadata');
// Mirror the standalone stack's dev config exactly
// (packages/runtime/src/standalone-stack.ts): declarative
Expand Down
81 changes: 81 additions & 0 deletions packages/cli/src/utils/dev-compile-at-boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { spawnSync, type SpawnSyncReturns } from 'child_process';

/**
* Whether a development boot compiles the config into the artifact it is about
* to serve, before the server starts (#26130). Both dev doors read this one
* decision: `os dev` before it spawns its `serve` child, and a direct
* `os serve --dev` before it composes the dev artifact door (#26242). Lifted
* here from `commands/dev.ts` so the second door reads it instead of restating
* it (`dev.ts` imports `serve.ts`, so `serve.ts` cannot import `dev.ts`).
*
* Yes by default whenever the boot serves an artifact it builds and there is a
* config to build it from. The reason is the dev boot's two copies of one
* stack. A host config's dev boot serves the stack from TWO places: the config
* MODULE, evaluated in the serving process (the AppPlugin wrap, whose registry
* copy is what a provider-bound connector is materialized from, so what
* dispatch calls), and the compiled artifact (the dev artifact door, whose copy
* is what `/api/v1/meta/<type>` serves). A config that reads the environment at
* module evaluation, as `examples/app-showcase`'s self URL reads `OS_PORT`,
* gives the module copy THIS boot's value and the artifact copy the value of
* whichever environment last compiled it. Measured on the showcase with
* `OS_PORT=41302` over an artifact built without it, on `os dev` and on
* `os serve --dev` alike: `GET /api/v1/meta/connector/showcase_status_api`
* served `http://127.0.0.1:3000` while the materialized connector called 41302.
*
* Compiling in the boot's own environment (`os dev`'s `serve` child inherits
* the same one) makes both copies one evaluation's answer, so nothing has to
* restate which variables a config reads or in what order. `os dev`'s watch
* loop recompiles in that environment too, so the two stay one answer for the
* whole session.
*
* `os dev --no-compile` keeps the old shortcut, booting the artifact as built
* (and the startup staleness warning says when it is older than the sources);
* a missing artifact is still compiled, since there is nothing else to serve.
* A pinned artifact (`--artifact`, `OS_ARTIFACT_URL`, a remote
* `OS_ARTIFACT_PATH`) is never rebuilt. With no config and a missing artifact
* the answer is yes, so the caller refuses there with the one remedy line.
*/
export function devCompilesAtBoot(opts: {
compile: boolean;
pinned: boolean;
configExists: boolean;
artifactExists: boolean;
}): boolean {
if (opts.pinned) return false;
if (!opts.artifactExists) return true;
return opts.compile && opts.configExists;
}

/**
* Compile `configPath` into `outputPath` in a child `os compile`, for a direct
* dev boot about to serve that artifact (#26242). The output is captured and
* returned, so a boot whose banner is still being assembled stays quiet unless
* the compile fails.
*
* The child gets the serving process's environment (the project `.env` files
* it loaded included) with ONE value put back: `NODE_ENV` as the process was
* launched with it. `serve --dev` sets `NODE_ENV=development` on ITSELF after
* its own modules have loaded; inherited, that write would put the child under
* oclif's TypeScript source loader, the failure
* `commands/child-env-source-loader.pin.test.ts` holds every command to (and
* the reason `os dev`'s own compile spawn writes no `NODE_ENV`). So the child
* sees what `os dev`'s compile child sees: the operator's `NODE_ENV`, or none.
*/
export function compileForDevBoot(opts: {
configPath: string;
outputPath: string;
launchNodeEnv: string | undefined;
}): SpawnSyncReturns<string> {
return spawnSync(
process.execPath,
[process.argv[1], 'compile', opts.configPath, '--output', opts.outputPath],
{
// `spawn` drops an `undefined` entry, so an unset launch value stays unset.
env: { ...process.env, NODE_ENV: opts.launchNodeEnv },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
},
);
}
3 changes: 2 additions & 1 deletion packages/cli/test/dev-compile-at-boot.pin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

import { describe, it, expect } from 'vitest';
import { Parser } from '@oclif/core';
import Dev, { devCompilesAtBoot } from '../src/commands/dev.js';
import Dev from '../src/commands/dev.js';
import { devCompilesAtBoot } from '../src/utils/dev-compile-at-boot.js';

const parseDev = (argv: string[]) =>
Parser.parse(argv, { flags: Dev.flags, args: Dev.args, strict: true });
Expand Down
Loading
Loading