diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a961a500..5f8856cd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - +## [Unreleased] + +- Added `"service": true` setting, which is well suited for long-running + processes like servers. A service is started either when it is invoked directly, + or when another script that depends on it is ready to run. A service is stopped + when all scripts that depend on it have finished, or when Wireit is exited. ## [0.7.2] - 2022-09-25 diff --git a/README.md b/README.md index 8d26e1bc3..5eb25a60e 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - [GitHub Actions caching](#github-actions-caching) - [Cleaning output](#cleaning-output) - [Watch mode](#watch-mode) +- [Services](#services) - [Failures and errors](#failures-and-errors) - [Package locks](#package-locks) - [Recipes](#recipes) @@ -388,6 +389,46 @@ The benefit of Wireit's watch mode over built-in watch modes are: simultaneously, such as build steps being triggered before all preceding steps have finished. +## Services + +By default, Wireit assumes that your scripts will eventually exit by themselves. +This is well suited for build and test scripts, but not for long-running +processes like servers. To tell Wireit that a process is long-running and not +expected to exit by itself, set `"service": true`. + +```json +{ + "scripts": { + "serve": "wireit", + "build:server": "wireit", + "build:assets": "wireit" + }, + "wireit": { + "serve": { + "command": "node my-server.js", + "service": true, + "files": ["server-config.json"], + "dependencies": ["build:server", "build:assets"] + } + } +} +``` + +If a service is run _directly_ (e.g. `npm run serve`), then it will stay running +until the user kills Wireit (e.g. `Ctrl-C`). + +If a service is a _dependency_ of one or more other scripts, then it will start +up before any depending script runs, and will shut down after all depending +scripts finish. + +In watch mode, a service will be restarted whenever one of its input files or +dependencies change. + +Services cannot have `output` files, because there is no way for Wireit to know +when a service has finished writing its output. If you have a service that +produces output, you should define a non-service script that depends on it, and +which exits when the service's output is complete. + ## Failures and errors By default, when a script fails (meaning it returned with a non-zero exit code), diff --git a/package.json b/package.json index 92d40b415..ee3fca9a1 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "test:json-schema": "wireit", "test:optimize-mkdirs": "wireit", "test:parallelism": "wireit", + "test:service": "wireit", "test:watch": "wireit" }, "wireit": { @@ -88,6 +89,7 @@ "test:json-schema", "test:optimize-mkdirs", "test:parallelism", + "test:service", "test:watch" ] }, @@ -251,6 +253,14 @@ "files": [], "output": [] }, + "test:service": { + "command": "cross-env NODE_OPTIONS=--enable-source-maps uvu lib/test \"service\\.test\\.js$\"", + "dependencies": [ + "build" + ], + "files": [], + "output": [] + }, "test:watch": { "command": "cross-env NODE_OPTIONS=--enable-source-maps uvu lib/test \"watch\\.test\\.js$\"", "dependencies": [ diff --git a/schema.json b/schema.json index 6848f6e39..d0fa29ca7 100644 --- a/schema.json +++ b/schema.json @@ -43,6 +43,10 @@ "type": "string" }, "type": "array" + }, + "service": { + "markdownDescription": "If true, treat this script as a long-running process.\nServices are automatically brought up and down as they are depended upon by other scripts. If invoked directly, services continue running until Wireit is killed with Ctrl-C.\nFor more info, see: https://github.com/google/wireit#services", + "type": "boolean" } }, "type": "object" diff --git a/src/analyzer.ts b/src/analyzer.ts index 2f8cb0a66..ab779d35f 100644 --- a/src/analyzer.ts +++ b/src/analyzer.ts @@ -462,6 +462,13 @@ export class Analyzer { command ); const clean = this._processClean(placeholder, packageJson, syntaxInfo); + const service = this._processService( + placeholder, + packageJson, + syntaxInfo, + command, + output + ); this._processPackageLocks(placeholder, packageJson, syntaxInfo, files); // It's important to in-place update the placeholder object, instead of @@ -476,7 +483,8 @@ export class Analyzer { dependencies, files, output, - clean: clean ?? true, + clean, + service, scriptAstNode: scriptCommand, configAstNode: wireitConfig, declaringFile: packageJson.jsonFile, @@ -741,9 +749,10 @@ export class Analyzer { placeholder: UnvalidatedConfig, packageJson: PackageJson, syntaxInfo: ScriptSyntaxInfo - ): undefined | boolean | 'if-file-deleted' { + ): boolean | 'if-file-deleted' { + const defaultValue = true; if (syntaxInfo.wireitConfigNode == null) { - return; + return defaultValue; } const clean = findNodeAtLocation(syntaxInfo.wireitConfigNode, ['clean']) as | undefined @@ -767,11 +776,86 @@ export class Analyzer { }, }, }); - // We shouldn't execute if there's failures, but just in case, this is - // likely the safest option. - return false; + return defaultValue; + } + return clean?.value ?? defaultValue; + } + + private _processService( + placeholder: UnvalidatedConfig, + packageJson: PackageJson, + syntaxInfo: ScriptSyntaxInfo, + command: JsonAstNode | undefined, + output: ArrayNode | undefined + ): boolean { + const defaultValue = false; + if (syntaxInfo.wireitConfigNode == null) { + return defaultValue; + } + const node = findNodeAtLocation(syntaxInfo.wireitConfigNode, [ + 'service', + ]) as undefined | JsonAstNode; + if (node == null) { + return defaultValue; + } + if (node.value !== true && node.value !== false) { + placeholder.failures.push({ + type: 'failure', + reason: 'invalid-config-syntax', + script: placeholder, + diagnostic: { + severity: 'error', + message: `The "service" property must be either true or false.`, + location: { + file: packageJson.jsonFile, + range: {length: node.length, offset: node.offset}, + }, + }, + }); + return defaultValue; + } + + const value = node?.value ?? defaultValue; + + if (value === true && command == null) { + placeholder.failures.push({ + type: 'failure', + reason: 'invalid-config-syntax', + script: placeholder, + diagnostic: { + severity: 'error', + message: `A "service" script must have a "command".`, + location: { + file: packageJson.jsonFile, + range: { + length: node.length, + offset: node.offset, + }, + }, + }, + }); } - return clean?.value; + + if (value === true && output != null) { + placeholder.failures.push({ + type: 'failure', + reason: 'invalid-config-syntax', + script: placeholder, + diagnostic: { + severity: 'error', + message: `A "service" script cannot have an "output".`, + location: { + file: packageJson.jsonFile, + range: { + length: output.node.length, + offset: output.node.offset, + }, + }, + }, + }); + } + + return value; } private _processPackageLocks( diff --git a/src/config.ts b/src/config.ts index 8fe978e9f..f2eae08a7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -99,6 +99,11 @@ interface BaseScriptConfig extends ScriptReference { */ clean: boolean | 'if-file-deleted'; + /** + * Whether the script should run in service mode. + */ + service: boolean; + /** * The command string in the scripts section. i.e.: * diff --git a/src/test/errors-analysis.test.ts b/src/test/errors-analysis.test.ts index 767032ab9..0a69a5552 100644 --- a/src/test/errors-analysis.test.ts +++ b/src/test/errors-analysis.test.ts @@ -1737,4 +1737,100 @@ test( }) ); +test( + 'service is not a boolean', + timeout(async ({rig}) => { + await rig.write({ + 'package.json': { + scripts: { + a: 'wireit', + }, + wireit: { + a: { + command: 'true', + service: 1, + }, + }, + }, + }); + const result = rig.exec('npm run a'); + const done = await result.exit; + assert.equal(done.code, 1); + checkScriptOutput( + done.stderr, + ` +❌ package.json:8:18 The "service" property must be either true or false. + "service": 1 + ~` + ); + }) +); + +test( + 'service does not have command', + timeout(async ({rig}) => { + await rig.write({ + 'package.json': { + scripts: { + a: 'wireit', + b: 'wireit', + }, + wireit: { + a: { + service: true, + dependencies: ['b'], + }, + b: { + command: 'true', + }, + }, + }, + }); + const result = rig.exec('npm run a'); + const done = await result.exit; + assert.equal(done.code, 1); + checkScriptOutput( + done.stderr, + ` +❌ package.json:8:18 A "service" script must have a "command". + "service": true, + ~~~~` + ); + }) +); + +test( + 'service cannot have output', + timeout(async ({rig}) => { + await rig.write({ + 'package.json': { + scripts: { + a: 'wireit', + }, + wireit: { + a: { + command: 'true', + service: true, + output: ['foo'], + }, + }, + }, + }); + const result = rig.exec('npm run a'); + const done = await result.exit; + assert.equal(done.code, 1); + checkScriptOutput( + done.stderr, + ` +❌ package.json:9:17 A "service" script cannot have an "output". + "output": [ + ~ + "foo" + ~~~~~~~~~~~~~ + ] + ~~~~~~~` + ); + }) +); + test.run(); diff --git a/src/test/service.test.ts b/src/test/service.test.ts new file mode 100644 index 000000000..fb42dd613 --- /dev/null +++ b/src/test/service.test.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2022 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {suite} from 'uvu'; +import {WireitTestRig} from './util/test-rig.js'; + +const test = suite<{rig: WireitTestRig}>(); + +test.before.each(async (ctx) => { + try { + ctx.rig = new WireitTestRig(); + await ctx.rig.setup(); + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu before error', error); + process.exit(1); + } +}); + +test.after.each(async (ctx) => { + try { + await ctx.rig.cleanup(); + } catch (error) { + // Uvu has a bug where it silently ignores failures in before and after, + // see https://github.com/lukeed/uvu/issues/191. + console.error('uvu after error', error); + process.exit(1); + } +}); + +test.run();