@nanobpm/urban 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -16
- package/package.json +16 -11
- package/src/cli.ts +6 -5
- package/src/index.ts +46 -0
- package/src/runtime/adapters/deno.ts +124 -0
- package/src/runtime/adapters/detect.ts +22 -0
- package/src/runtime/adapters/node.ts +119 -0
- package/src/runtime/core/context.ts +35 -0
- package/src/runtime/core/glob.test.ts +45 -0
- package/src/runtime/core/glob.ts +63 -0
- package/src/runtime/core/host.ts +130 -0
- package/src/runtime/core/manifest.test.ts +31 -0
- package/src/runtime/core/manifest.ts +112 -0
- package/src/runtime/core/modules/datasource.ts +220 -0
- package/src/runtime/core/modules/deploy.ts +41 -0
- package/src/runtime/core/modules/security.ts +53 -0
- package/src/runtime/core/modules/surfaces.test.ts +42 -0
- package/src/runtime/core/modules/surfaces.ts +103 -0
- package/src/runtime/core/modules/triggers.ts +143 -0
- package/src/runtime/core/modules/units.test.ts +22 -0
- package/src/runtime/core/modules/workers.ts +96 -0
- package/src/runtime/core/purity.test.ts +31 -0
- package/src/runtime/core/router.test.ts +16 -0
- package/src/runtime/core/router.ts +55 -0
- package/src/runtime/core/runtime.test.ts +258 -0
- package/src/runtime/core/runtime.ts +213 -0
- package/src/runtime/core/validate.test.ts +73 -0
- package/src/runtime/core/validate.ts +129 -0
- package/src/runtime/engine/nanosdk.test.ts +124 -0
- package/src/runtime/engine/nanosdk.ts +123 -0
- package/src/runtime/engine/rest.ts +243 -0
- package/src/runtime/index.ts +68 -0
- package/src/runtime/run.ts +91 -0
- package/src/runtime/schema/nano-app.schema.json +471 -0
- package/src/toolkit/artifact.ts +29 -0
- package/src/toolkit/derivers/migrations.test.ts +50 -0
- package/src/toolkit/derivers/migrations.ts +99 -0
- package/src/toolkit/derivers/worker-io.test.ts +75 -0
- package/src/toolkit/derivers/worker-io.ts +176 -0
- package/src/toolkit/fsio.test.ts +21 -0
- package/src/toolkit/fsio.ts +36 -0
- package/src/toolkit/gen.test.ts +85 -0
- package/src/toolkit/gen.ts +109 -0
- package/src/toolkit/index.ts +37 -0
package/README.md
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# `@nanobpm/urban`
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Build and run **code-first apps on Nano** — the runtime, the derivation toolkit,
|
|
4
|
+
and the `urban` CLI in one package, on **Node or Deno**.
|
|
5
|
+
|
|
6
|
+
An Urban app is a directory with a `nano.app.json` manifest that declares its
|
|
7
|
+
processes, forms, datasources, workers, HTTP surfaces and triggers. This package
|
|
8
|
+
brings it to life and gives you a library API to embed or extend it. Author your
|
|
9
|
+
durable processes in code with [`@nanobpm/workflow`](../workflow)
|
|
10
|
+
(`defineFlow`) — re-exported from here for convenience.
|
|
5
11
|
|
|
6
12
|
## Install
|
|
7
13
|
|
|
@@ -9,30 +15,26 @@ from your terminal — on **Node or Deno**.
|
|
|
9
15
|
npm i -g @nanobpm/urban # install the `urban` command
|
|
10
16
|
# or run without installing:
|
|
11
17
|
npx @nanobpm/urban new my-app
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
Requires Node ≥ 22.6 or Deno. On Node < 23.6 the `urban` command re-executes with
|
|
15
|
-
`--experimental-strip-types` so its TypeScript source runs directly.
|
|
16
|
-
|
|
17
|
-
Under Deno you can run it without installing:
|
|
18
|
-
|
|
19
|
-
```bash
|
|
18
|
+
# or on Deno:
|
|
20
19
|
deno run -A npm:@nanobpm/urban run
|
|
21
20
|
```
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
Requires Node ≥ 22.6 or Deno. It ships as TypeScript source with no build step;
|
|
23
|
+
on Node < 23.6 the `urban` command re-executes with `--experimental-strip-types`.
|
|
24
|
+
|
|
25
|
+
## The `urban` CLI
|
|
24
26
|
|
|
25
27
|
| Command | What it does |
|
|
26
28
|
|---|---|
|
|
27
29
|
| `urban new <name>` | scaffold a new app in a new directory |
|
|
28
30
|
| `urban check` | validate the app's `nano.app.json` manifest |
|
|
29
|
-
| `urban gen` | generate the `nano-generated/` artifacts (migrations, worker I/O
|
|
31
|
+
| `urban gen` | generate the `nano-generated/` artifacts (migrations, worker I/O) |
|
|
30
32
|
| `urban gen --check` | fail if the generated artifacts are out of date (a CI drift gate) |
|
|
31
33
|
| `urban run` | generate, then run the app — starts its workers and serves its surfaces |
|
|
32
34
|
| `urban dev` | run the app (hot-reload is not yet implemented) |
|
|
33
35
|
| `urban deploy` | deploy the app's models to the engine, then exit |
|
|
34
36
|
|
|
35
|
-
|
|
37
|
+
### Options
|
|
36
38
|
|
|
37
39
|
| Flag | Purpose | Default |
|
|
38
40
|
|---|---|---|
|
|
@@ -46,7 +48,7 @@ The engine address comes from `$CAMUNDA_REST_ADDRESS` (default
|
|
|
46
48
|
`http://localhost:8080/v2`). Set `$CAMUNDA_TRANSPORT` to a non-`rest` value to use
|
|
47
49
|
the `@nanobpm/nano-sdk` transport when it is installed.
|
|
48
50
|
|
|
49
|
-
|
|
51
|
+
### A typical session
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
54
|
urban new invoices && cd invoices
|
|
@@ -55,8 +57,83 @@ urban check # validate the manifest
|
|
|
55
57
|
urban run # start workers and serve surfaces
|
|
56
58
|
```
|
|
57
59
|
|
|
60
|
+
## Library API
|
|
61
|
+
|
|
62
|
+
Everything the CLI does is available programmatically. Import the whole surface
|
|
63
|
+
from `@nanobpm/urban`, or the focused subpaths `@nanobpm/urban/runtime` and
|
|
64
|
+
`@nanobpm/urban/toolkit`.
|
|
65
|
+
|
|
66
|
+
### Runtime — run an app
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { runFromEnv } from "@nanobpm/urban";
|
|
70
|
+
|
|
71
|
+
const app = await runFromEnv(); // reads ./nano.app.json and the environment
|
|
72
|
+
console.log(app.inspect()); // { app, name, httpPort, ... }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`runFromEnv` reads the engine address and transport from the environment, starts
|
|
76
|
+
the app (validate → deploy → provision datasources → start workers → serve
|
|
77
|
+
surfaces and webhook triggers), and installs SIGINT/SIGTERM handlers for a
|
|
78
|
+
graceful shutdown. For full control, assemble the pieces yourself:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { createUrbanApp, selectHost, RestEngineClient } from "@nanobpm/urban";
|
|
82
|
+
|
|
83
|
+
const host = selectHost(); // picks the Node or Deno adapter
|
|
84
|
+
const engine = new RestEngineClient({ baseUrl: process.env.CAMUNDA_REST_ADDRESS! });
|
|
85
|
+
const app = await createUrbanApp({ host, engine, root: "." });
|
|
86
|
+
await app.start();
|
|
87
|
+
// ... later:
|
|
88
|
+
await app.stop(); // releases workers, server, datasources
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Two engine transports are built in: `RestEngineClient` (default, no extra
|
|
92
|
+
dependencies) and `createNanoSdkEngineClient` (used when `CAMUNDA_TRANSPORT` is
|
|
93
|
+
anything other than `rest`; needs the optional `@nanobpm/nano-sdk`).
|
|
94
|
+
|
|
95
|
+
### Toolkit — derive artifacts (`urban gen`)
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
import { runGen, createNodeGenIO } from "@nanobpm/urban";
|
|
99
|
+
|
|
100
|
+
const io = createNodeGenIO();
|
|
101
|
+
await runGen({ root: ".", io }); // writes nano-generated/
|
|
102
|
+
const { drift } = await runGen({ root: ".", io, check: true }); // CI drift gate
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Each deriver is a pure `(input) → artifacts` function you can also call directly:
|
|
106
|
+
|
|
107
|
+
| Deriver | Input | Output |
|
|
108
|
+
|---|---|---|
|
|
109
|
+
| `deriveMigrations` | the manifest's datasource types | `nano-generated/<source>.schema.sql` (`CREATE TABLE` per type) |
|
|
110
|
+
| `deriveWorkerBindings` | BPMN service tasks + their data-envelope I/O | `nano-generated/worker-io.d.ts` (typed worker input/output) |
|
|
111
|
+
|
|
112
|
+
Derivers are deterministic — the same input produces byte-identical output — so
|
|
113
|
+
generated files are safe to commit and to gate in CI.
|
|
114
|
+
|
|
115
|
+
### Code-first processes
|
|
116
|
+
|
|
117
|
+
Author durable processes in code with `defineFlow`, re-exported from
|
|
118
|
+
[`@nanobpm/workflow`](../workflow):
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { defineFlow, WorkflowClient, Worker } from "@nanobpm/urban";
|
|
122
|
+
|
|
123
|
+
const flow = defineFlow("pr-review", (w) => {
|
|
124
|
+
w.run("fetchDiff", async (job) => ({ files: 3 }));
|
|
125
|
+
w.signal("humanApproval", { correlationKey: "prId" }); // durable human wait
|
|
126
|
+
w.run("merge", async (job) => ({ merged: true }));
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
The SDK derives the executable BPMN, the job types, and the message/correlation
|
|
131
|
+
wiring; `WorkflowClient` deploys and starts, `Worker` hosts your `run` steps.
|
|
132
|
+
`deploy` emits an auto-generated diagram (DI) so the deployed model is
|
|
133
|
+
inspectable in a modeller/Operate — `@nanobpm/urban` bundles `bpmn-auto-layout`
|
|
134
|
+
so this works out of the box.
|
|
135
|
+
|
|
58
136
|
## Related packages
|
|
59
137
|
|
|
138
|
+
- [`@nanobpm/workflow`](../workflow) — the code-first process surface (`defineFlow`).
|
|
60
139
|
- [`create-urban-app`](../create-urban-app) — the scaffolder behind `urban new`.
|
|
61
|
-
- [`@nanobpm/urban-toolkit`](../urban-toolkit) — the generation behind `urban gen`.
|
|
62
|
-
- [`@nanobpm/urban-runtime`](../urban-runtime) — the runtime behind `urban run`.
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanobpm/urban",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"nanobpm",
|
|
9
9
|
"urban",
|
|
10
|
-
"urban-cli",
|
|
11
10
|
"cli",
|
|
11
|
+
"runtime",
|
|
12
|
+
"codegen",
|
|
13
|
+
"derivation",
|
|
12
14
|
"nano-app",
|
|
13
15
|
"rad"
|
|
14
16
|
],
|
|
@@ -18,7 +20,7 @@
|
|
|
18
20
|
"repository": {
|
|
19
21
|
"type": "git",
|
|
20
22
|
"url": "https://github.com/jwulf/nano-ide.git",
|
|
21
|
-
"directory": "packages/urban
|
|
23
|
+
"directory": "packages/urban"
|
|
22
24
|
},
|
|
23
25
|
"engines": {
|
|
24
26
|
"node": ">=22.6"
|
|
@@ -26,10 +28,12 @@
|
|
|
26
28
|
"bin": {
|
|
27
29
|
"urban": "./src/bin.mjs"
|
|
28
30
|
},
|
|
29
|
-
"main": "./src/
|
|
30
|
-
"types": "./src/
|
|
31
|
+
"main": "./src/index.ts",
|
|
32
|
+
"types": "./src/index.ts",
|
|
31
33
|
"exports": {
|
|
32
|
-
".": "./src/
|
|
34
|
+
".": "./src/index.ts",
|
|
35
|
+
"./runtime": "./src/runtime/index.ts",
|
|
36
|
+
"./toolkit": "./src/toolkit/index.ts"
|
|
33
37
|
},
|
|
34
38
|
"files": [
|
|
35
39
|
"src",
|
|
@@ -37,12 +41,13 @@
|
|
|
37
41
|
],
|
|
38
42
|
"scripts": {
|
|
39
43
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
40
|
-
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\""
|
|
44
|
+
"test": "node --test --experimental-strip-types \"src/**/*.test.ts\"",
|
|
45
|
+
"test:deno": "deno test -A --no-check src/"
|
|
41
46
|
},
|
|
42
47
|
"dependencies": {
|
|
43
|
-
"@nanobpm/
|
|
44
|
-
"
|
|
45
|
-
"create-urban-app": "^0.
|
|
48
|
+
"@nanobpm/workflow": "^0.5.0",
|
|
49
|
+
"bpmn-auto-layout": "^2.0.0-alpha.2",
|
|
50
|
+
"create-urban-app": "^0.2.0"
|
|
46
51
|
},
|
|
47
52
|
"devDependencies": {
|
|
48
53
|
"@types/node": "^22.20.1",
|
package/src/cli.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
// The `urban` CLI — a thin command surface over
|
|
1
|
+
// The `urban` CLI — a thin command surface over the Urban runtime + toolkit.
|
|
2
2
|
//
|
|
3
3
|
// urban new <name> scaffold a new app (delegates to create-urban-app)
|
|
4
4
|
// urban check load + validate the manifest, report issues
|
|
5
|
+
// urban gen derive generated artifacts (migrations, worker I/O)
|
|
5
6
|
// urban run materialize + serve the app (deploy, data, workers, surfaces, triggers)
|
|
6
7
|
// urban dev like run (hot-reload is not yet implemented; documented below)
|
|
7
8
|
// urban deploy deploy models only, then exit
|
|
@@ -14,12 +15,12 @@ import {
|
|
|
14
15
|
loadManifest,
|
|
15
16
|
runFromEnv,
|
|
16
17
|
selectHost,
|
|
17
|
-
} from "
|
|
18
|
+
} from "./runtime/index.ts";
|
|
18
19
|
import { scaffold, slugify } from "create-urban-app";
|
|
19
|
-
import { createNodeGenIO, runGen } from "
|
|
20
|
+
import { createNodeGenIO, runGen } from "./toolkit/index.ts";
|
|
20
21
|
import { pathToFileURL } from "node:url";
|
|
21
22
|
|
|
22
|
-
const VERSION = "0.
|
|
23
|
+
const VERSION = "0.2.0";
|
|
23
24
|
|
|
24
25
|
interface Flags {
|
|
25
26
|
root: string;
|
|
@@ -63,7 +64,7 @@ const USAGE = `urban — build and run Urban apps (nano.app.json)
|
|
|
63
64
|
Usage:
|
|
64
65
|
urban new <name> [--root <path>] scaffold a new Urban app
|
|
65
66
|
urban check validate the manifest
|
|
66
|
-
urban gen [--check] derive artifacts (migrations, worker-io
|
|
67
|
+
urban gen [--check] derive artifacts (migrations, worker-io)
|
|
67
68
|
urban run materialize + serve the app
|
|
68
69
|
urban dev run (hot-reload not yet implemented)
|
|
69
70
|
urban deploy deploy models only, then exit
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @nanobpm/urban — the code-first Urban stack in one package (ADR 0054):
|
|
2
|
+
//
|
|
3
|
+
// • runtime — materialize and serve an app from a nano.app.json manifest
|
|
4
|
+
// (datasource, workers, surfaces, triggers) on Node or Deno.
|
|
5
|
+
// • toolkit — pure derivers (`urban gen`) that emit generated artifacts
|
|
6
|
+
// (SQL migrations, worker I/O bindings) from the model.
|
|
7
|
+
// • workflow — the code-first process surface, re-exported from
|
|
8
|
+
// `@nanobpm/workflow`: author durable flows with `defineFlow` and the SDK
|
|
9
|
+
// derives the executable BPMN, job types, and correlation wiring.
|
|
10
|
+
//
|
|
11
|
+
// The CLI (`urban`) is a separate entrypoint (bin) over this same API.
|
|
12
|
+
// Subpath imports are also available: `@nanobpm/urban/runtime` and
|
|
13
|
+
// `@nanobpm/urban/toolkit`.
|
|
14
|
+
|
|
15
|
+
// Runtime — app materialization + hosting
|
|
16
|
+
export * from "./runtime/index.ts";
|
|
17
|
+
|
|
18
|
+
// Toolkit — derivation library (`urban gen`)
|
|
19
|
+
export * from "./toolkit/index.ts";
|
|
20
|
+
|
|
21
|
+
// Code-first process surface (re-exported from @nanobpm/workflow)
|
|
22
|
+
export {
|
|
23
|
+
defineFlow,
|
|
24
|
+
declarativeToBpmn,
|
|
25
|
+
declarativeToLayoutedBpmn,
|
|
26
|
+
externalJobTypes,
|
|
27
|
+
envelope,
|
|
28
|
+
layoutBpmn,
|
|
29
|
+
toBpmn,
|
|
30
|
+
toDeployableBpmn,
|
|
31
|
+
Worker,
|
|
32
|
+
WorkflowClient,
|
|
33
|
+
WorkflowError,
|
|
34
|
+
} from "@nanobpm/workflow";
|
|
35
|
+
export type {
|
|
36
|
+
FlowBuilder,
|
|
37
|
+
Envelope,
|
|
38
|
+
EnvelopeType,
|
|
39
|
+
EnvelopeField,
|
|
40
|
+
FieldSpec,
|
|
41
|
+
ScalarType,
|
|
42
|
+
WorkflowClientOptions,
|
|
43
|
+
ActivateOptions,
|
|
44
|
+
WorkerOptions,
|
|
45
|
+
ActivityEvent,
|
|
46
|
+
} from "@nanobpm/workflow";
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Deno adapter — implements HostContext against the `Deno` global (plus `node:*` compat
|
|
2
|
+
// modules Deno supports). This and node.ts are the only files that touch a concrete runtime.
|
|
3
|
+
// A minimal ambient `Deno` declaration lets this compile under Node's tsc; at runtime the
|
|
4
|
+
// adapter is only ever selected when the real `Deno` global is present (see detect.ts).
|
|
5
|
+
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import type {
|
|
10
|
+
HostContext,
|
|
11
|
+
HttpHandler,
|
|
12
|
+
HttpRequest,
|
|
13
|
+
HttpServer,
|
|
14
|
+
SqliteDb,
|
|
15
|
+
} from "../core/host.ts";
|
|
16
|
+
|
|
17
|
+
interface DenoHttpServer {
|
|
18
|
+
finished: Promise<void>;
|
|
19
|
+
shutdown(): Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
interface DenoGlobal {
|
|
22
|
+
env: { get(name: string): string | undefined };
|
|
23
|
+
cwd(): string;
|
|
24
|
+
readTextFile(path: string): Promise<string>;
|
|
25
|
+
readDir(path: string): AsyncIterable<{ name: string; isFile: boolean }>;
|
|
26
|
+
stat(path: string): Promise<unknown>;
|
|
27
|
+
serve(
|
|
28
|
+
opts: { port: number; onListen?: (a: { port: number }) => void },
|
|
29
|
+
handler: (req: Request) => Response | Promise<Response>,
|
|
30
|
+
): DenoHttpServer;
|
|
31
|
+
}
|
|
32
|
+
declare const Deno: DenoGlobal;
|
|
33
|
+
|
|
34
|
+
export interface DenoHostOptions {
|
|
35
|
+
cwd?: string;
|
|
36
|
+
log?: HostContext["log"];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createDenoHost(opts: DenoHostOptions = {}): HostContext {
|
|
40
|
+
const cwd = opts.cwd ?? Deno.cwd();
|
|
41
|
+
const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
42
|
+
const log: HostContext["log"] =
|
|
43
|
+
opts.log ??
|
|
44
|
+
((level, msg, fields) => {
|
|
45
|
+
const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
|
|
46
|
+
(level === "error" ? console.error : level === "warn" ? console.warn : console.log)(
|
|
47
|
+
`[urban] ${line}`,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
runtime: "deno",
|
|
53
|
+
env: (name) => Deno.env.get(name),
|
|
54
|
+
readTextFile: (p) => Deno.readTextFile(abs(p)),
|
|
55
|
+
async listDir(dir) {
|
|
56
|
+
try {
|
|
57
|
+
const names: string[] = [];
|
|
58
|
+
for await (const e of Deno.readDir(abs(dir))) {
|
|
59
|
+
if (e.isFile) names.push(e.name);
|
|
60
|
+
}
|
|
61
|
+
return names;
|
|
62
|
+
} catch {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
async exists(p) {
|
|
67
|
+
try {
|
|
68
|
+
await Deno.stat(abs(p));
|
|
69
|
+
return true;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
openSqlite(path) {
|
|
75
|
+
const db = new DatabaseSync(abs(path));
|
|
76
|
+
return wrapSqlite(db);
|
|
77
|
+
},
|
|
78
|
+
importModule: (p) => import(pathToFileURL(abs(p)).href) as Promise<Record<string, unknown>>,
|
|
79
|
+
async serveHttp(port, handler) {
|
|
80
|
+
return startDenoServer(port, handler);
|
|
81
|
+
},
|
|
82
|
+
now: () => Date.now(),
|
|
83
|
+
log,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function wrapSqlite(db: DatabaseSync): SqliteDb {
|
|
88
|
+
return {
|
|
89
|
+
exec: (sql) => db.exec(sql),
|
|
90
|
+
run: (sql, params = []) => {
|
|
91
|
+
const stmt = db.prepare(sql);
|
|
92
|
+
const r = stmt.run(...(params as never[]));
|
|
93
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
94
|
+
},
|
|
95
|
+
all: <T>(sql: string, params: unknown[] = []) => {
|
|
96
|
+
const stmt = db.prepare(sql);
|
|
97
|
+
return stmt.all(...(params as never[])) as T[];
|
|
98
|
+
},
|
|
99
|
+
close: () => db.close(),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function startDenoServer(port: number, handler: HttpHandler): Promise<HttpServer> {
|
|
104
|
+
return new Promise<HttpServer>((resolveServer) => {
|
|
105
|
+
const server = Deno.serve({ port, onListen: ({ port: p }) => {
|
|
106
|
+
resolveServer({
|
|
107
|
+
port: p,
|
|
108
|
+
stop: () => server.shutdown(),
|
|
109
|
+
});
|
|
110
|
+
} }, async (request: Request): Promise<Response> => {
|
|
111
|
+
const url = new URL(request.url);
|
|
112
|
+
const bodyText = request.body ? await request.text() : "";
|
|
113
|
+
const req: HttpRequest = {
|
|
114
|
+
method: request.method,
|
|
115
|
+
path: url.pathname,
|
|
116
|
+
query: url.searchParams,
|
|
117
|
+
headers: request.headers,
|
|
118
|
+
text: () => Promise.resolve(bodyText),
|
|
119
|
+
};
|
|
120
|
+
const res = await handler(req);
|
|
121
|
+
return new Response(res.body ?? "", { status: res.status ?? 200, headers: res.headers });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Runtime detection: pick the Node or Deno host at load time. Importing both adapter
|
|
2
|
+
// modules is safe — neither touches its runtime's globals at module top level.
|
|
3
|
+
|
|
4
|
+
import type { HostContext } from "../core/host.ts";
|
|
5
|
+
import { createNodeHost } from "./node.ts";
|
|
6
|
+
import { createDenoHost } from "./deno.ts";
|
|
7
|
+
|
|
8
|
+
/** True when running under Deno. */
|
|
9
|
+
export function isDeno(): boolean {
|
|
10
|
+
const g = globalThis as { Deno?: { version?: unknown } };
|
|
11
|
+
return typeof g.Deno !== "undefined" && typeof g.Deno.version !== "undefined";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SelectHostOptions {
|
|
15
|
+
cwd?: string;
|
|
16
|
+
log?: HostContext["log"];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Select and construct the host for the current runtime. */
|
|
20
|
+
export function selectHost(opts: SelectHostOptions = {}): HostContext {
|
|
21
|
+
return isDeno() ? createDenoHost(opts) : createNodeHost(opts);
|
|
22
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Node adapter — implements HostContext against `node:*`. This is one of only two files
|
|
2
|
+
// (with deno.ts) allowed to touch a concrete runtime.
|
|
3
|
+
|
|
4
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { isAbsolute, resolve } from "node:path";
|
|
7
|
+
import { pathToFileURL } from "node:url";
|
|
8
|
+
import { DatabaseSync } from "node:sqlite";
|
|
9
|
+
import type {
|
|
10
|
+
HostContext,
|
|
11
|
+
HttpHandler,
|
|
12
|
+
HttpRequest,
|
|
13
|
+
HttpServer,
|
|
14
|
+
SqliteDb,
|
|
15
|
+
} from "../core/host.ts";
|
|
16
|
+
|
|
17
|
+
export interface NodeHostOptions {
|
|
18
|
+
/** Base directory relative paths resolve against. Default process.cwd(). */
|
|
19
|
+
cwd?: string;
|
|
20
|
+
log?: HostContext["log"];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createNodeHost(opts: NodeHostOptions = {}): HostContext {
|
|
24
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
25
|
+
const abs = (p: string) => (isAbsolute(p) ? p : resolve(cwd, p));
|
|
26
|
+
const log: HostContext["log"] =
|
|
27
|
+
opts.log ??
|
|
28
|
+
((level, msg, fields) => {
|
|
29
|
+
const line = fields ? `${msg} ${JSON.stringify(fields)}` : msg;
|
|
30
|
+
(level === "error" ? console.error : level === "warn" ? console.warn : console.log)(
|
|
31
|
+
`[urban] ${line}`,
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
runtime: "node",
|
|
37
|
+
env: (name) => process.env[name],
|
|
38
|
+
readTextFile: (p) => readFile(abs(p), "utf8"),
|
|
39
|
+
async listDir(dir) {
|
|
40
|
+
try {
|
|
41
|
+
const entries = await readdir(abs(dir), { withFileTypes: true });
|
|
42
|
+
return entries.filter((e) => e.isFile()).map((e) => e.name);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
async exists(p) {
|
|
48
|
+
try {
|
|
49
|
+
await stat(abs(p));
|
|
50
|
+
return true;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
openSqlite(path) {
|
|
56
|
+
const db = new DatabaseSync(abs(path));
|
|
57
|
+
return wrapNodeSqlite(db);
|
|
58
|
+
},
|
|
59
|
+
importModule: (p) => import(pathToFileURL(abs(p)).href) as Promise<Record<string, unknown>>,
|
|
60
|
+
async serveHttp(port, handler) {
|
|
61
|
+
return await startNodeServer(port, handler);
|
|
62
|
+
},
|
|
63
|
+
now: () => Date.now(),
|
|
64
|
+
log,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function wrapNodeSqlite(db: DatabaseSync): SqliteDb {
|
|
69
|
+
return {
|
|
70
|
+
exec: (sql) => db.exec(sql),
|
|
71
|
+
run: (sql, params = []) => {
|
|
72
|
+
const stmt = db.prepare(sql);
|
|
73
|
+
const r = stmt.run(...(params as never[]));
|
|
74
|
+
return { changes: Number(r.changes), lastInsertRowid: r.lastInsertRowid };
|
|
75
|
+
},
|
|
76
|
+
all: <T>(sql: string, params: unknown[] = []) => {
|
|
77
|
+
const stmt = db.prepare(sql);
|
|
78
|
+
return stmt.all(...(params as never[])) as T[];
|
|
79
|
+
},
|
|
80
|
+
close: () => db.close(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function startNodeServer(port: number, handler: HttpHandler): Promise<HttpServer> {
|
|
85
|
+
const server = createServer(async (nreq, nres) => {
|
|
86
|
+
const chunks: Buffer[] = [];
|
|
87
|
+
for await (const c of nreq) chunks.push(c as Buffer);
|
|
88
|
+
const bodyText = Buffer.concat(chunks).toString("utf8");
|
|
89
|
+
const url = new URL(nreq.url ?? "/", "http://localhost");
|
|
90
|
+
const headers = new Headers();
|
|
91
|
+
for (const [k, v] of Object.entries(nreq.headers)) {
|
|
92
|
+
if (typeof v === "string") headers.set(k, v);
|
|
93
|
+
else if (Array.isArray(v)) headers.set(k, v.join(", "));
|
|
94
|
+
}
|
|
95
|
+
const req: HttpRequest = {
|
|
96
|
+
method: nreq.method ?? "GET",
|
|
97
|
+
path: url.pathname,
|
|
98
|
+
query: url.searchParams,
|
|
99
|
+
headers,
|
|
100
|
+
text: () => Promise.resolve(bodyText),
|
|
101
|
+
};
|
|
102
|
+
try {
|
|
103
|
+
const res = await handler(req);
|
|
104
|
+
nres.statusCode = res.status ?? 200;
|
|
105
|
+
for (const [k, v] of Object.entries(res.headers ?? {})) nres.setHeader(k, v);
|
|
106
|
+
nres.end(res.body ?? "");
|
|
107
|
+
} catch (err) {
|
|
108
|
+
nres.statusCode = 500;
|
|
109
|
+
nres.end(String(err));
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
await new Promise<void>((res) => server.listen(port, res));
|
|
113
|
+
const addr = server.address();
|
|
114
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
|
|
115
|
+
return {
|
|
116
|
+
port: actualPort,
|
|
117
|
+
stop: () => new Promise<void>((res) => server.close(() => res())),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AppManifest } from "./manifest.ts";
|
|
2
|
+
import type { EngineClient, HostContext } from "./host.ts";
|
|
3
|
+
|
|
4
|
+
/** Everything a runtime module needs. Passed to each module's mount function. */
|
|
5
|
+
export interface RuntimeContext {
|
|
6
|
+
manifest: AppManifest;
|
|
7
|
+
host: HostContext;
|
|
8
|
+
engine: EngineClient;
|
|
9
|
+
/** App root directory (paths in the manifest are relative to this). */
|
|
10
|
+
root: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A mounted module returns a disposer so the runtime can tear it down cleanly. */
|
|
14
|
+
export interface Mounted {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
stop(): Promise<void>;
|
|
17
|
+
/** Optional human-readable summary for `inspect()`. */
|
|
18
|
+
describe?(): Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// AppApi is imported lazily to avoid a cycle; declared here as the injected handler surface.
|
|
22
|
+
import type { DataLayer } from "./modules/datasource.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The surface injected into worker/trigger/surface handlers — the app's runtime API.
|
|
26
|
+
* This is how a handler reaches the datasource (typed accessors), the engine, and host
|
|
27
|
+
* utilities without hard-coding any of them.
|
|
28
|
+
*/
|
|
29
|
+
export interface AppApi {
|
|
30
|
+
manifest: AppManifest;
|
|
31
|
+
data: DataLayer;
|
|
32
|
+
engine: EngineClient;
|
|
33
|
+
env(name: string): string | undefined;
|
|
34
|
+
log(level: "info" | "warn" | "error", msg: string, fields?: Record<string, unknown>): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { expandPattern, expandPatterns } from "./glob.ts";
|
|
4
|
+
import type { HostContext } from "./host.ts";
|
|
5
|
+
|
|
6
|
+
function fakeHost(files: Record<string, string[]>): HostContext {
|
|
7
|
+
// files: dir -> file names
|
|
8
|
+
return {
|
|
9
|
+
runtime: "node",
|
|
10
|
+
env: () => undefined,
|
|
11
|
+
readTextFile: async () => "",
|
|
12
|
+
listDir: async (dir) => files[dir.replace(/\/+$/, "")] ?? [],
|
|
13
|
+
exists: async (p) => {
|
|
14
|
+
const slash = p.lastIndexOf("/");
|
|
15
|
+
const dir = slash >= 0 ? p.slice(0, slash) : "";
|
|
16
|
+
const name = slash >= 0 ? p.slice(slash + 1) : p;
|
|
17
|
+
return (files[dir] ?? []).includes(name);
|
|
18
|
+
},
|
|
19
|
+
openSqlite: () => {
|
|
20
|
+
throw new Error("n/a");
|
|
21
|
+
},
|
|
22
|
+
importModule: async () => ({}),
|
|
23
|
+
serveHttp: async () => ({ port: 0, stop: async () => {} }),
|
|
24
|
+
now: () => 0,
|
|
25
|
+
log: () => {},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
test("expandPattern: *.ext filters by extension and sorts", async () => {
|
|
30
|
+
const host = fakeHost({ "root/processes": ["b.bpmn", "a.bpmn", "note.md"] });
|
|
31
|
+
const out = await expandPattern(host, "root", "processes/*.bpmn");
|
|
32
|
+
assert.deepEqual(out, ["root/processes/a.bpmn", "root/processes/b.bpmn"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("expandPattern: literal path resolves only if it exists", async () => {
|
|
36
|
+
const host = fakeHost({ "root/dir": ["x.dmn"] });
|
|
37
|
+
assert.deepEqual(await expandPattern(host, "root", "dir/x.dmn"), ["root/dir/x.dmn"]);
|
|
38
|
+
assert.deepEqual(await expandPattern(host, "root", "dir/missing.dmn"), []);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("expandPatterns de-duplicates preserving order", async () => {
|
|
42
|
+
const host = fakeHost({ "root/d": ["a.form", "b.form"] });
|
|
43
|
+
const out = await expandPatterns(host, "root", ["d/*.form", "d/a.form"]);
|
|
44
|
+
assert.deepEqual(out, ["root/d/a.form", "root/d/b.form"]);
|
|
45
|
+
});
|