diff --git a/.changeset/eleven-carrots-battle.md b/.changeset/eleven-carrots-battle.md new file mode 100644 index 0000000..b05994c --- /dev/null +++ b/.changeset/eleven-carrots-battle.md @@ -0,0 +1,6 @@ +--- +"@kopflos-cms/core": patch +"@kopflos-cms/express": patch +--- + +Changed static method `Kopflos.fromGraphs` to `Kopflos#loadApiGraphs` diff --git a/.changeset/friendly-pandas-fry.md b/.changeset/friendly-pandas-fry.md new file mode 100644 index 0000000..a41eae0 --- /dev/null +++ b/.changeset/friendly-pandas-fry.md @@ -0,0 +1,5 @@ +--- +"@kopflos-cms/core": patch +--- + +New plugin hooks: `onStop` and `apiTriples` diff --git a/.changeset/wise-tomatoes-breathe.md b/.changeset/wise-tomatoes-breathe.md new file mode 100644 index 0000000..f8119d0 --- /dev/null +++ b/.changeset/wise-tomatoes-breathe.md @@ -0,0 +1,6 @@ +--- +"@kopflos-cms/plugin-deploy-resources": patch +"kopflos": patch +--- + +Watch mode diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8b5cd29..4b0fc5b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -2,6 +2,9 @@ name: tests on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + jobs: unit-tests: runs-on: [ ubuntu-latest ] diff --git a/example/kopflos.config.ts b/example/kopflos.config.ts index 71d7658..b196bc6 100644 --- a/example/kopflos.config.ts +++ b/example/kopflos.config.ts @@ -10,6 +10,7 @@ export default { updateUrl: 'http://localhost:7878/update', }, }, + watch: ['lib'], plugins: { '@kopflos-cms/plugin-deploy-resources': { paths: ['resources', 'resources.dev'], diff --git a/package-lock.json b/package-lock.json index fcb8dcc..9ba8044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,13 +35,13 @@ } }, "example": { - "version": "0.0.1-beta.3", + "version": "0.0.2", "dependencies": { - "@kopflos-cms/serve-file": "0.1.0-beta.1", - "@kopflos-cms/vite": "0.0.1-beta.2", - "@kopflos-labs/handlebars": "0.1.0-beta.1", - "@kopflos-labs/html-template": "0.1.0-beta.2", - "@kopflos-labs/lit": "0.1.0-beta.1", + "@kopflos-cms/serve-file": "0.1.0", + "@kopflos-cms/vite": "0.0.1", + "@kopflos-labs/handlebars": "0.1.0", + "@kopflos-labs/html-template": "0.1.0", + "@kopflos-labs/lit": "0.1.0", "@openlayers-elements/core": "^0.3.0", "@openlayers-elements/maps": "^0.3.0", "@shoelace-style/shoelace": "^2.17.1", @@ -49,7 +49,7 @@ "compression": "^1.7.4", "cors": "^2.8.5", "express": "^5.0.1", - "kopflos": "0.1.0-beta.2", + "kopflos": "0.1.1", "lit-element": "^4.1.1" }, "devDependencies": { @@ -58,14 +58,14 @@ }, "labs/handlebars": { "name": "@kopflos-labs/handlebars", - "version": "0.1.0-beta.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@zazuko/env": "^2.3.0", "@zazuko/prefixes": "^2.2.0", "clownface-shacl-path": "^2.4.0", "handlebars": "^4.7.8", - "sparql-path-parser": "^0.1.0-beta.1" + "sparql-path-parser": "^0.1.0" }, "devDependencies": { "chai": "^5.1.1", @@ -74,17 +74,17 @@ }, "labs/html-template": { "name": "@kopflos-labs/html-template", - "version": "0.1.0-beta.2", + "version": "0.1.0", "license": "MIT", "dependencies": { - "@kopflos-cms/logger": "^0.1.0-beta.1", + "@kopflos-cms/logger": "^0.1.0", "@zazuko/env": "^2.3.0", "@zazuko/prefixes": "^2.2.0", "cheerio": "^1.0.0", "htmlparser2": "^9.1.0" }, "devDependencies": { - "@kopflos-cms/core": "^0.3.0-beta.11", + "@kopflos-cms/core": "^0.3.0", "@types/chai-html": "^3.0.0", "@types/sinon": "^17.0.3", "@zazuko/env-node": "^2.1.4", @@ -129,7 +129,7 @@ }, "labs/lit": { "name": "@kopflos-labs/lit", - "version": "0.1.0-beta.1", + "version": "0.1.0", "dependencies": { "@lit-labs/ssr": "^3.2.2", "@lit-labs/ssr-client": "^1.1.7", @@ -10532,6 +10532,35 @@ "node": ">= 8" } }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -18966,6 +18995,61 @@ "node": ">=6" } }, + "node_modules/temp-dir": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", + "integrity": "sha512-nHc6S/bwIilKHNRgK/3jlhDoIHcp45YgyiwcAk46Tr0LfEqGBVpmiAyuiuxeVE44m3mXnEeVhaipLOEWmH+Njw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/tempy": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tempy/-/tempy-3.1.0.tgz", + "integrity": "sha512-7jDLIdD2Zp0bDe5r3D2qtkd1QOCacylBuL7oa4udvN6v2pqr4+LcCr67C8DR1zkpaZ8XosF5m1yQSabKAW6f2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-stream": "^3.0.0", + "temp-dir": "^3.0.0", + "type-fest": "^2.12.2", + "unique-string": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tempy/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/term-size": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/term-size/-/term-size-2.2.1.tgz", @@ -19414,6 +19498,22 @@ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "license": "MIT" }, + "node_modules/unique-string": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/universalify": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", @@ -20187,12 +20287,13 @@ }, "packages/cli": { "name": "kopflos", - "version": "0.1.0-beta.2", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@kopflos-cms/express": "^0.1.0-beta.6", - "@kopflos-cms/logger": "^0.1.0-beta.1", - "@kopflos-cms/plugin-deploy-resources": "^0.1.0-beta.1", + "@kopflos-cms/express": "^0.1.0", + "@kopflos-cms/logger": "^0.1.0", + "@kopflos-cms/plugin-deploy-resources": "^0.1.0", + "chokidar": "^4.0.1", "commander": "^12.0.0", "cosmiconfig": "^9.0.0", "express": "^5.0.1", @@ -20205,12 +20306,40 @@ "chai": "^5.1.1" } }, + "packages/cli/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/cli/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "packages/core": { "name": "@kopflos-cms/core", - "version": "0.3.0-beta.11", + "version": "0.3.0", "license": "MIT", "dependencies": { - "@kopflos-cms/logger": "^0.1.0-beta.1", + "@kopflos-cms/logger": "^0.1.0", "@rdfjs/types": "^1.1.0", "@tpluscode/sparql-builder": "^3.0.0", "@types/clownface": "^2.0.8", @@ -20257,11 +20386,11 @@ }, "packages/express": { "name": "@kopflos-cms/express", - "version": "0.1.0-beta.6", + "version": "0.1.0", "license": "MIT", "dependencies": { - "@kopflos-cms/core": "^0.3.0-beta.10", - "@kopflos-cms/logger": "^0.1.0-beta.1", + "@kopflos-cms/core": "^0.3.0", + "@kopflos-cms/logger": "^0.1.0", "@rdfjs/express-handler": "^2.0.2", "@zazuko/env-node": "^2.1.3", "absolute-url": "^2.0.0", @@ -20303,31 +20432,61 @@ }, "packages/logger": { "name": "@kopflos-cms/logger", - "version": "0.1.0-beta.1", + "version": "0.1.0", "dependencies": { "anylogger": "^1.0.11" } }, "packages/plugin-deploy-resources": { "name": "@kopflos-cms/plugin-deploy-resources", - "version": "0.1.0-beta.1", + "version": "0.1.0", "license": "MIT", "dependencies": { "@hydrofoil/resource-store": "^0.2.2", "@hydrofoil/talos-core": "^0.3.0", - "@kopflos-cms/logger": "^0.1.0-beta.1", - "anylogger": "^1.0.11" + "@kopflos-cms/logger": "^0.1.0", + "anylogger": "^1.0.11", + "chokidar": "^4.0.1" }, "devDependencies": { - "@kopflos-cms/core": "^0.3.0-beta.10", + "@kopflos-cms/core": "^0.3.0", "@zazuko/env-node": "^2.1.3", "chai": "^5.1.1", - "mocha-chai-rdf": "^0.1.4" + "mocha-chai-rdf": "^0.1.4", + "tempy": "^3.1.0" + } + }, + "packages/plugin-deploy-resources/node_modules/chokidar": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "packages/plugin-deploy-resources/node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "packages/serve-file": { "name": "@kopflos-cms/serve-file", - "version": "0.1.0-beta.1", + "version": "0.1.0", "dependencies": { "mime": "^4.0.4" }, @@ -20350,7 +20509,7 @@ } }, "packages/sparql-path-parser": { - "version": "0.1.0-beta.1", + "version": "0.1.0", "dependencies": { "@tpluscode/rdf-ns-builders": "^4", "@types/sparqljs": "^3.1.11", @@ -20378,10 +20537,10 @@ }, "packages/vite": { "name": "@kopflos-cms/vite", - "version": "0.0.1-beta.2", + "version": "0.0.1", "license": "MIT", "dependencies": { - "@kopflos-cms/logger": "^0.1.0-beta.1", + "@kopflos-cms/logger": "^0.1.0", "express": "^5.0.1", "glob": "^11.0.0", "onetime": "^7.0.0", diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 9da0891..40a3ddb 100755 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -1,9 +1,10 @@ import 'ulog' +import { fork } from 'node:child_process' import { program } from 'commander' +import log from '@kopflos-cms/logger' import { variable } from './lib/options.js' import deploy from './lib/command/deploy.js' import build from './lib/command/build.js' -import serve from './lib/command/serve.js' program.name('kopflos') @@ -15,7 +16,26 @@ program.command('serve') .option('-h, --host ', 'Host to bind to (default: "0.0.0.0")') .addOption(variable) .option('--trust-proxy [proxy]', 'Trust the X-Forwarded-Host header') - .action(serve) + .option('--watch', 'Enable watching for changes') + .option('--no-watch', 'Disable watching for changes') + .action((options) => { + (function serve() { + // running the server in a forked process to be able to restart it + // child process is necessary to bypass node module caching + const proc = fork(new URL('./lib/command/serve.js', import.meta.url)) + + proc.send(options) + + proc.on('message', (message) => { + if (message === 'restart') { + proc.kill() + serve() + } else { + log.error(`Unknown message: ${message}`) + } + }) + })() + }) program.command('build') .option('-c, --config ', 'Path to config file') diff --git a/packages/cli/lib/command/build.ts b/packages/cli/lib/command/build.ts index dd8f0e5..5c4a2f6 100644 --- a/packages/cli/lib/command/build.ts +++ b/packages/cli/lib/command/build.ts @@ -7,7 +7,7 @@ interface BuildArgs { } export default async function (args: BuildArgs) { - const config = await loadConfig({ + const { config } = await loadConfig({ path: args.config, }) const plugins = await loadPlugins(config.plugins) diff --git a/packages/cli/lib/command/deploy.ts b/packages/cli/lib/command/deploy.ts index b6d3d37..9f5c036 100644 --- a/packages/cli/lib/command/deploy.ts +++ b/packages/cli/lib/command/deploy.ts @@ -8,7 +8,7 @@ interface DeployArgs { } export default async function (args: DeployArgs) { - const config = await loadConfig({ + const { config } = await loadConfig({ path: args.config, }) diff --git a/packages/cli/lib/command/serve.ts b/packages/cli/lib/command/serve.ts index 80149f0..4908f7a 100644 --- a/packages/cli/lib/command/serve.ts +++ b/packages/cli/lib/command/serve.ts @@ -1,24 +1,27 @@ +import 'ulog' import log from '@kopflos-cms/logger' import express from 'express' +import * as chokidar from 'chokidar' import kopflos from '@kopflos-cms/express' -import { loadConfig } from '../config.js' +import { prepareConfig } from '../config.js' -interface ServeArgs { +export interface ServeArgs { mode?: 'development' | 'production' | unknown config?: string port?: number host?: string trustProxy?: boolean variable: Record + watch?: boolean } -export default async function ({ +async function run({ mode: _mode = 'production', - config, + watch = _mode === 'development', port = 1429, host = '0.0.0.0', trustProxy, - variable, + ...rest }: ServeArgs) { let mode: 'development' | 'production' if (_mode !== 'development' && _mode !== 'production') { @@ -28,19 +31,8 @@ export default async function ({ mode = _mode } - const loadedConfig = await loadConfig({ - path: config, - }) - - const finalOptions = { - port, - host, - mode, - ...loadedConfig, - variables: { - ...loadedConfig.variables, - ...variable, - }, + if (mode === 'development' && watch === false) { + log.warn('Watch disabled in development mode') } const app = express() @@ -49,12 +41,37 @@ export default async function ({ app.set('trust proxy', trustProxy) } - const { instance, middleware } = await kopflos(finalOptions) + const config = await prepareConfig({ mode, watch, ...rest }) + const { instance, middleware } = await kopflos(config) app.use(middleware) await instance.start() - app.listen(port, host, () => { - log.info(`Server running on ${port}. API URL: ${finalOptions.baseIri}`) + const server = app.listen(port, host, () => { + log.info(`Server running on ${port}. API URL: ${config.baseIri}`) }) + + if (config.watch) { + log.info(`Watch mode. Watching for changes in: ${config.watch.join(', ')}`) + async function restartServer(path: string) { + log.info('Changes detected, restarting server') + log.debug(`Changed file: ${path}`) + + await instance.stop() + server.close(() => { + process.send?.('restart') + }) + } + + chokidar.watch(config.watch, { + ignoreInitial: true, + }) + .on('change', restartServer) + .on('add', restartServer) + .on('unlink', restartServer) + } + + process.send?.('ready') } + +process.on('message', run) diff --git a/packages/cli/lib/config.ts b/packages/cli/lib/config.ts index 0ac60e5..b0ad9c8 100644 --- a/packages/cli/lib/config.ts +++ b/packages/cli/lib/config.ts @@ -9,7 +9,13 @@ interface LoadConfig { path: string | undefined } -export async function loadConfig({ path, root }: LoadConfig): Promise { +declare module '@kopflos-cms/core' { + interface KopflosConfig { + watch?: string[] + } +} + +export async function loadConfig({ path, root }: LoadConfig): Promise<{ config: KopflosConfig; filepath: string }> { let ccResult: CosmiconfigResult if (path) { ccResult = await explorer.load(path) @@ -21,5 +27,30 @@ export async function loadConfig({ path, root }: LoadConfig): Promise +} + +export async function prepareConfig({ mode, config, watch, variable }: PrepareConfigArgs): Promise { + const { config: loadedConfig, filepath: configPath } = await loadConfig({ + path: config, + }) + + const watchedPaths = loadedConfig.watch || [] + + return { + mode, + ...loadedConfig, + watch: watch ? [...watchedPaths, configPath] : undefined, + variables: { + ...(loadedConfig.variables || {}), + ...variable, + }, + } } diff --git a/packages/cli/package.json b/packages/cli/package.json index cc7fb9e..b374c7e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,13 +33,16 @@ "@kopflos-cms/logger": "^0.1.0", "@kopflos-cms/express": "^0.1.0", "@kopflos-cms/plugin-deploy-resources": "^0.1.0", + "chokidar": "^4.0.1", "commander": "^12.0.0", "cosmiconfig": "^9.0.0", "express": "^5.0.1", "ulog": "^2.0.0-beta.19" }, "devDependencies": { - "chai": "^5.1.1" + "chai": "^5.1.1", + "mocha-chai-rdf": "^0.1.5", + "tempy": "^3.1.0" }, "mocha": { "extension": [ diff --git a/packages/cli/test/fixtures/config.with-watch.json b/packages/cli/test/fixtures/config.with-watch.json new file mode 100644 index 0000000..572b90b --- /dev/null +++ b/packages/cli/test/fixtures/config.with-watch.json @@ -0,0 +1,4 @@ +{ + "baseIri": "https://example.com/", + "watch": ["lib"] +} diff --git a/packages/cli/test/kopflos.config.ts b/packages/cli/test/kopflos.config.ts index 1a05cb2..6be6466 100644 --- a/packages/cli/test/kopflos.config.ts +++ b/packages/cli/test/kopflos.config.ts @@ -1,5 +1,12 @@ +import url from 'node:url' import type { KopflosConfig } from '@kopflos-cms/core' export default { baseIri: 'https://example.com/', + sparql: { + default: 'https://example.com/query', + }, + watch: [ + url.fileURLToPath(new URL('fixtures', import.meta.url)), + ], } diff --git a/packages/cli/test/lib/command/serve.test.ts b/packages/cli/test/lib/command/serve.test.ts new file mode 100644 index 0000000..5b9c426 --- /dev/null +++ b/packages/cli/test/lib/command/serve.test.ts @@ -0,0 +1,114 @@ +import { fork } from 'node:child_process' +import * as fs from 'node:fs' +import url from 'node:url' +import { createEmpty } from 'mocha-chai-rdf/store.js' +import type { ServeArgs } from '../../../lib/command/serve.js' + +const serve = new URL('../../../lib/command/serve.js', import.meta.url) +const fixturesDir = new URL('../../fixtures/temp/', import.meta.url) + +describe('kopflos/lib/command/serve', function () { + this.timeout(10000) + + let process: ReturnType + + beforeEach(createEmpty) + beforeEach(function () { + process = fork(serve) + fs.mkdirSync(fixturesDir) + }) + + afterEach(function () { + process.kill() + fs.rmSync(fixturesDir, { recursive: true, force: true }) + }) + + context('development mode', function () { + context('watch enabled by default', function () { + it('sends message to parent process when watched files change', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'development', + }, function (done) { + process.on('message', (message) => { + if (message === 'restart') { + done() + } else { + done(new Error(`Unexpected message: ${message}`)) + } + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + })) + }) + + context('watch disabled', function () { + it('ignores filesystem changes', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'development', + watch: false, + }, function (done) { + process.on('message', (message) => { + done(new Error(`Unexpected message: ${message}`)) + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + setTimeout(done, 1000) + })) + }) + }) + + context('production mode', function () { + context('watch enabled', function () { + it('sends message to parent process when watched files change', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'production', + watch: true, + }, function (done) { + process.on('message', (message) => { + if (message === 'restart') { + done() + } else { + done(new Error(`Unexpected message: ${message}`)) + } + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + })) + }) + + context('watch disabled by default', function () { + it('ignores filesystem changes', runTest({ + config: url.fileURLToPath(new URL('../../kopflos.config.ts', import.meta.url)), + variable: {}, + mode: 'production', + }, function (done) { + process.on('message', (message) => { + done(new Error(`Unexpected message: ${message}`)) + }) + + fs.writeFileSync(new URL('./file.txt', fixturesDir), '') + setTimeout(done, 1000) + })) + }) + }) + + function runTest(args: ServeArgs, action: (done: Mocha.Done) => void): Mocha.Func { + return function (done) { + process.once('message', payload => { + if (payload === 'ready') { + action(() => { + done() + process.kill() + }) + } else { + done(new Error(`Unexpected message: ${payload}`)) + } + }) + + process.send(args) + } + } +}) diff --git a/packages/cli/test/lib/config.test.ts b/packages/cli/test/lib/config.test.ts index 86f3077..4e11d45 100644 --- a/packages/cli/test/lib/config.test.ts +++ b/packages/cli/test/lib/config.test.ts @@ -1,18 +1,18 @@ import url from 'node:url' import { expect } from 'chai' -import { loadConfig } from '../../lib/config.js' +import { loadConfig, prepareConfig } from '../../lib/config.js' describe('kopflos/lib/config.js', function () { this.timeout(10000) describe('loadConfig', () => { it('should discover the config file', async () => { - const config = await loadConfig({ + const { config } = await loadConfig({ path: undefined, root: url.fileURLToPath(new URL('..', import.meta.url)), }) - expect(config).to.be.deep.equal({ + expect(config).to.be.deep.include({ baseIri: 'https://example.com/', }) }) @@ -22,7 +22,7 @@ describe('kopflos/lib/config.js', function () { const configPath = url.fileURLToPath(new URL('../fixtures/config.json', import.meta.url)) // when - const config = await loadConfig({ + const { config } = await loadConfig({ path: configPath, }) @@ -32,4 +32,38 @@ describe('kopflos/lib/config.js', function () { }) }) }) + + describe('prepareConfig', () => { + it('sets config itself as watched paths', async () => { + // given + const configPath = url.fileURLToPath(new URL('../fixtures/config.json', import.meta.url)) + + // when + const config = await prepareConfig({ + config: configPath, + mode: 'development', + watch: true, + variable: {}, + }) + + // then + expect(config.watch).to.deep.eq([configPath]) + }) + + it('adds config itself to watched paths', async () => { + // given + const configPath = url.fileURLToPath(new URL('../fixtures/config.with-watch.json', import.meta.url)) + + // when + const config = await prepareConfig({ + config: configPath, + mode: 'development', + watch: true, + variable: {}, + }) + + // then + expect(config.watch).to.contain.all.members([configPath, 'lib']) + }) + }) }) diff --git a/packages/core/lib/Kopflos.ts b/packages/core/lib/Kopflos.ts index d07706c..980f489 100644 --- a/packages/core/lib/Kopflos.ts +++ b/packages/core/lib/Kopflos.ts @@ -1,7 +1,7 @@ import type { IncomingHttpHeaders, IncomingMessage, OutgoingHttpHeaders } from 'node:http' import type { parse } from 'node:querystring' import type { ReadableStream } from 'node:stream/web' -import type { DatasetCore, NamedNode, Stream, Term } from '@rdfjs/types' +import type { DatasetCore, NamedNode, Quad, Stream, Term } from '@rdfjs/types' import type { GraphPointer, MultiPointer } from 'clownface' import type { Options as EndpointOptions, StreamClient } from 'sparql-http-client/StreamClient.js' import type { ParsingClient } from 'sparql-http-client/ParsingClient.js' @@ -21,6 +21,10 @@ import { loadHandlers } from './handler.js' import type { HttpMethod } from './httpMethods.js' import log from './log.js' +declare module '@rdfjs/types' { + interface Stream extends AsyncIterable {} +} + type Dataset = ReturnType export interface Body { @@ -60,11 +64,14 @@ export interface Kopflos { get plugins(): Array get start(): () => Promise handleRequest(req: KopflosRequest): Promise + loadApiGraphs(): Promise } export interface KopflosPlugin { build?: () => Promise | void onStart?(instance: Kopflos): Promise | void + onStop?(instance: Kopflos): Promise | void + apiTriples?(instance: Kopflos): Promise | DatasetCore | Stream } interface Clients { @@ -79,6 +86,7 @@ export interface PluginConfig { } export interface KopflosConfig { + [key: string]: unknown mode?: 'development' | 'production' baseIri: string sparql: Record & { default: Endpoint } @@ -310,8 +318,16 @@ export default class Impl implements Kopflos { } } - static async fromGraphs(kopflos: Impl, ...graphs: Array): Promise { - const graphsIris = graphs.map(graph => typeof graph === 'string' ? kopflos.env.namedNode(graph) : graph) + async loadApiGraphs(): Promise { + const graphs = this.env.kopflos.config.apiGraphs + + if (!graphs) { + throw new Error('No API graphs configured. In a future release it will be possible to select graphs dynamically.') + } + + this.dataset.deleteMatches() + + const graphsIris = graphs.map(graph => typeof graph === 'string' ? this.env.namedNode(graph) : graph) log.info('Loading graphs', graphsIris.map(g => g.value)) const quads = CONSTRUCT`?s ?p ?o ` @@ -321,12 +337,29 @@ export default class Impl implements Kopflos { } FILTER (?g ${IN(...graphsIris)}) - `.execute(kopflos.env.sparql.default.stream) + `.execute(this.env.sparql.default.stream) for await (const quad of quads) { - kopflos.dataset.add(quad) + this.dataset.add(quad) } - log.info(`Graphs loaded. Dataset now contains ${kopflos.dataset.size} quads`) + const apiTriples = this.plugins.map(async plugin => { + if (!plugin.apiTriples) { + return + } + + const triples = await plugin.apiTriples(this) + for await (const quad of triples) { + this.dataset.add(quad) + } + log.debug('API triples loaded from plugin', plugin) + }) + + await Promise.all(apiTriples) + log.info(`Graphs loaded. Dataset now contains ${this.dataset.size} quads`) + } + + async stop() { + await Promise.all(this.plugins.map(async plugin => { plugin.onStop?.(this) })) } } diff --git a/packages/core/plugin/shorthandTerms.ts b/packages/core/plugin/shorthandTerms.ts index 40a5feb..1b5dbd0 100644 --- a/packages/core/plugin/shorthandTerms.ts +++ b/packages/core/plugin/shorthandTerms.ts @@ -1,12 +1,11 @@ +import type { Stream } from '@rdfjs/types' import type { KopflosPlugin } from '../lib/Kopflos.js' export default function (): KopflosPlugin { return { - async onStart(kopflos): Promise { + apiTriples(kopflos): Stream { const { env } = kopflos - const shorthands = env.fromFile(new URL('../graphs/shorthands.ttl', import.meta.url)) - - await kopflos.dataset.import(shorthands) + return env.fromFile(new URL('../graphs/shorthands.ttl', import.meta.url)) }, } } diff --git a/packages/core/test/lib/Kopflos.test.ts b/packages/core/test/lib/Kopflos.test.ts index 1d359c6..3886ec7 100644 --- a/packages/core/test/lib/Kopflos.test.ts +++ b/packages/core/test/lib/Kopflos.test.ts @@ -592,13 +592,14 @@ describe('lib/Kopflos', () => { beforeEach(async function () { instance = new Kopflos({ ...config, + apiGraphs: [ex.PublicApi, ex.PrivateApi], sparql: { default: inMemoryClients(this.rdf), }, }, { plugins: await loadPlugins({}), }) - await instance.start() + await instance.loadApiGraphs() }) context(`inserts ${shorthand.value} shorthand`, () => { @@ -609,9 +610,6 @@ describe('lib/Kopflos', () => { }) it('which can be loaded', async function () { - // when - await Kopflos.fromGraphs(instance, ex.PublicApi, ex.PrivateApi) - // then const loadedFunc = await instance.env.load(instance.graph.node(shorthand)) expect(loadedFunc).to.eq(implementation) @@ -640,6 +638,45 @@ describe('lib/Kopflos', () => { expect(plugin.onStart).to.have.been.calledOnce }) }) + + describe('stop', () => { + it('calls onStop on plugins', async function () { + // given + const plugin = { + onStop: sinon.spy(), + } + const instance = new Kopflos({ + ...config, + sparql: { + default: inMemoryClients(this.rdf), + }, + }, { + plugins: [plugin], + }) + + // when + await instance.stop() + + // then + expect(plugin.onStop).to.have.been.called + }) + + it('ignores plugins without onStop', async function () { + // given + const plugin = {} + const instance = new Kopflos({ + ...config, + sparql: { + default: inMemoryClients(this.rdf), + }, + }, { + plugins: [plugin], + }) + + // when + await instance.stop() + }) + }) }) const testHandler: Handler = ({ subject, property, object }) => ({ diff --git a/packages/core/test/lib/loadApi.test.ts b/packages/core/test/lib/loadApi.test.ts index 6492084..41eca72 100644 --- a/packages/core/test/lib/loadApi.test.ts +++ b/packages/core/test/lib/loadApi.test.ts @@ -18,27 +18,33 @@ describe('loadApi', () => { } }) - describe('fromGraphs', () => { + describe('loadGraphs', () => { it('fetches combined graph contents', async () => { // given - const kopfos = new Kopflos(config) + const kopflos = new Kopflos({ + ...config, + apiGraphs: [ex.PublicApi, ex.PrivateApi], + }) // when - await Kopflos.fromGraphs(kopfos, ex.PublicApi, ex.PrivateApi) + await kopflos.loadApiGraphs() // then - expect(kopfos.dataset).to.have.property('size', 11) + expect(kopflos.dataset).to.have.property('size', 11) }) it('fetches combined graph contents (string names)', async () => { // given - const kopfos = new Kopflos(config) + const kopflos = new Kopflos({ + ...config, + apiGraphs: ['http://example.org/PublicApi', 'http://example.org/PrivateApi'], + }) // when - await Kopflos.fromGraphs(kopfos, 'http://example.org/PublicApi', 'http://example.org/PrivateApi') + await kopflos.loadApiGraphs() // then - expect(kopfos.dataset).to.have.property('size', 11) + expect(kopflos.dataset).to.have.property('size', 11) }) }) }) diff --git a/packages/express/index.ts b/packages/express/index.ts index b8b79f1..211e637 100644 --- a/packages/express/index.ts +++ b/packages/express/index.ts @@ -32,9 +32,7 @@ export default async (options: KopflosConfig): Promise<{ middleware: RequestHand plugins: await loadPlugins(options.plugins), }) - const loadApiGraphs = onetime(async (graphs: Required['apiGraphs']) => { - await Kopflos.fromGraphs(kopflos, ...graphs) - }) + const loadApiGraphs = onetime(() => kopflos.loadApiGraphs()) const router = Router() @@ -42,11 +40,7 @@ export default async (options: KopflosConfig): Promise<{ middleware: RequestHand router .use((req, res, next) => { - if (!options.apiGraphs) { - return next(new Error('No API graphs configured. In future release it will be possible to select graphs dynamically.')) - } - - loadApiGraphs(options.apiGraphs).then(next).catch(next) + loadApiGraphs().then(next).catch(next) }) .use((req, res, next) => { const fullUrl = absolutUrl(req) diff --git a/packages/plugin-deploy-resources/index.ts b/packages/plugin-deploy-resources/index.ts index b0d1b76..2f88804 100644 --- a/packages/plugin-deploy-resources/index.ts +++ b/packages/plugin-deploy-resources/index.ts @@ -3,10 +3,12 @@ import { bootstrap } from '@hydrofoil/talos-core/bootstrap.js' import { fromDirectories } from '@hydrofoil/talos-core' import { ResourcePerGraphStore } from '@hydrofoil/resource-store' import { createLogger } from '@kopflos-cms/logger' +import * as chokidar from 'chokidar' interface Options { enabled?: boolean paths?: string[] + watch?: boolean } declare module '@kopflos-cms/core' { @@ -24,9 +26,11 @@ export async function deploy(paths: string[], env: KopflosEnvironment) { }) } -export default function kopflosPlugin({ paths = [], enabled = true }: Options = {}) { +export default function kopflosPlugin({ paths = [], enabled = true, watch = true }: Options = {}) { + const instances = new WeakMap() + return { - onStart({ env }: Kopflos) { + onStart(instance: Kopflos) { if (!enabled) { log.info('Auto deploy disabled. Skipping deployment') return @@ -39,7 +43,27 @@ export default function kopflosPlugin({ paths = [], enabled = true }: Options = log.info(`Auto deploy enabled. Deploying from: ${paths}`) - return deploy(paths, env) + if (watch && instance.env.kopflos.config.watch) { + async function redeploy(changedFile: string) { + log.info('Resources changed, redeploying') + log.debug(`Changed path: ${changedFile}`) + await deploy(paths, instance.env) + await instance.loadApiGraphs() + } + + const watcher = chokidar.watch(paths, { ignoreInitial: true }) + .on('change', redeploy) + .on('add', redeploy) + .on('unlink', redeploy) + + instances.set(instance, watcher) + } + + return deploy(paths, instance.env) + }, + async onStop(instance: Kopflos) { + const watcher = instances.get(instance) + await watcher?.close() }, } } diff --git a/packages/plugin-deploy-resources/package.json b/packages/plugin-deploy-resources/package.json index 33325db..c70ca14 100644 --- a/packages/plugin-deploy-resources/package.json +++ b/packages/plugin-deploy-resources/package.json @@ -18,13 +18,15 @@ "@hydrofoil/resource-store": "^0.2.2", "@hydrofoil/talos-core": "^0.3.0", "@kopflos-cms/logger": "^0.1.0", - "anylogger": "^1.0.11" + "anylogger": "^1.0.11", + "chokidar": "^4.0.1" }, "devDependencies": { "@kopflos-cms/core": "^0.3.0", "@zazuko/env-node": "^2.1.3", "chai": "^5.1.1", - "mocha-chai-rdf": "^0.1.4" + "mocha-chai-rdf": "^0.1.4", + "tempy": "^3.1.0" }, "mocha": { "extension": [ diff --git a/packages/plugin-deploy-resources/test/__snapshots__/index.test.ts.snap b/packages/plugin-deploy-resources/test/__snapshots__/index.test.ts.snap index f99ef00..007b381 100644 --- a/packages/plugin-deploy-resources/test/__snapshots__/index.test.ts.snap +++ b/packages/plugin-deploy-resources/test/__snapshots__/index.test.ts.snap @@ -10,3 +10,25 @@ exports[`@kopflos-cms/plugin-deploy-resources onStart enabled deploys trig 1`] = . " `; + +exports[`@kopflos-cms/plugin-deploy-resources watch disabled does not react to any changes 1`] = ` +" . + . + . + \\"bar\\" . + . + . + . + . +" +`; + +exports[`@kopflos-cms/plugin-deploy-resources watch enabled redeploys when file changes 1`] = ` +" . + . +" +`; + +exports[`@kopflos-cms/plugin-deploy-resources watch enabled redeploys when file is created 1`] = ` +" . +"`; diff --git a/packages/plugin-deploy-resources/test/index.test.ts b/packages/plugin-deploy-resources/test/index.test.ts index b2a932e..65b4507 100644 --- a/packages/plugin-deploy-resources/test/index.test.ts +++ b/packages/plugin-deploy-resources/test/index.test.ts @@ -1,9 +1,13 @@ import url from 'node:url' +import * as fs from 'node:fs/promises' +import { promisify } from 'node:util' +import * as path from 'node:path' import { createEmpty } from 'mocha-chai-rdf/store.js' import rdf from '@zazuko/env-node' import { expect, use } from 'chai' import snapshots from 'mocha-chai-rdf/snapshots.js' import Kopflos from '@kopflos-cms/core' +import { temporaryDirectory } from 'tempy' import configure from '../index.js' import inMemoryClients from '../../testing-helpers/in-memory-clients.js' @@ -17,16 +21,16 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { beforeEach(createEmpty) - beforeEach(function () { - env = new Kopflos({ - baseIri, - sparql: { - default: inMemoryClients(this.rdf), - }, + describe('onStart', () => { + beforeEach(function () { + env = new Kopflos({ + baseIri, + sparql: { + default: inMemoryClients(this.rdf), + }, + }) }) - }) - describe('onStart', () => { context('disabled', () => { beforeEach(async () => { const plugin = configure({ @@ -85,13 +89,100 @@ describe('@kopflos-cms/plugin-deploy-resources', () => { it('deploys trig', async function () { const fooGraph = this.rdf.dataset.match(null, null, null, ex('foo')) - expect(rdf.dataset.toCanonical(fooGraph)).to.matchSnapshot() + expect(rdf.dataset.toCanonical(fooGraph)).toMatchSnapshot() }) it('applies base', async function () { const barGraph = this.rdf.dataset.match(null, null, null, ex('bar')) - expect(rdf.dataset.toCanonical(barGraph)).to.matchSnapshot() + expect(rdf.dataset.toCanonical(barGraph)).toMatchSnapshot() + }) + }) + }) + + context('watch', () => { + let plugin: ReturnType + let tempDir: string + + beforeEach(async function () { + env = new Kopflos({ + baseIri, + sparql: { + default: inMemoryClients(this.rdf), + }, + watch: [], + }) + + tempDir = temporaryDirectory() + await fs.cp(url.fileURLToPath(new URL('resources', import.meta.url)), tempDir, { recursive: true }) + }) + + afterEach(async () => { + await plugin?.onStop(env) + await fs.rm(tempDir, { recursive: true, force: true }) + }) + + context('enabled', () => { + beforeEach(async () => { + plugin = configure({ + paths: [tempDir], + }) + await plugin.onStart(env) + }) + + it('redeploys when file changes', async function () { + const fileToModify = path.resolve(tempDir, 'bar.ttl') + + // when + await fs.appendFile(fileToModify, '<> a ex:Baz .') + await promisify(setTimeout)(1000) + + // then + const barGraph = this.rdf.dataset.match(null, null, null, ex('bar')) + expect(rdf.dataset.toCanonical(barGraph)).toMatchSnapshot() + }) + + it('redeploys when file is created', async function () { + // given + const fileToCreate = path.resolve(tempDir, 'baz.ttl') + + // when + await fs.writeFile(fileToCreate, 'PREFIX ex: \n<> a ex:Baz .') + await promisify(setTimeout)(1000) + + // then + const bazGraph = this.rdf.dataset.match(null, null, null, ex('baz')) + expect(rdf.dataset.toCanonical(bazGraph)).toMatchSnapshot() + }) + }) + + context('disabled', () => { + beforeEach(async () => { + plugin = configure({ + paths: [tempDir], + watch: false, + }) + await plugin.onStart(env) + }) + + it('does not react to any changes', async function () { + // given + const fileToModify = path.resolve(tempDir, 'bar.ttl') + const fileToCreate = path.resolve(tempDir, 'baz.ttl') + const fileToDelete = path.resolve(tempDir, 'index.trig') + + await plugin.onStart(env) + + // when + await Promise.all([ + fs.appendFile(fileToModify, '<> a ex:Baz .'), + fs.writeFile(fileToCreate, 'PREFIX ex: \n<> a ex:Baz .'), + fs.unlink(fileToDelete), + ]) + await promisify(setTimeout)(1000) + + // then + expect(rdf.dataset.toCanonical(this.rdf.dataset)).toMatchSnapshot() }) }) })