@dunx/create-app 3.3.0 → 3.3.2
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 +10 -2
- package/dist/build-template.d.ts +9 -0
- package/dist/{chunk-szd4jhfn.js → chunk-akm69d19.js} +67 -10
- package/dist/cli.js +12 -2
- package/dist/generate.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/scaffold.d.ts +8 -0
- package/dist/wizard.d.ts +8 -4
- package/package.json +1 -1
- package/templates/features/cache/cache.controller.ts +15 -13
- package/templates/features/chat/chat.demo.ts +1 -52
- package/templates/features/chat/chat.module.ts +3 -2
- package/templates/features/chat/postgres-relay.demo.ts +171 -0
- package/templates/features/chat/ws-client.ts +65 -0
- package/templates/features/database/ledger.controller.ts +94 -19
- package/templates/features/guards/reports.controller.ts +4 -4
- package/templates/features/http/request-trail.ts +19 -2
- package/templates/features/http/trace.controller.ts +2 -2
- package/templates/features/jobs/jobs.controller.ts +10 -8
- package/templates/features/notes/notes.controller.ts +8 -5
- package/templates/features/notes/notes.service.ts +14 -0
- package/templates/features/pictures/images.controller.ts +6 -6
- package/templates/features/schedule/maintenance.service.ts +40 -2
- package/templates/features/schedule/schedule.demo.ts +12 -0
- package/templates/features/storage/files.controller.ts +25 -18
- package/templates/features/users/users.controller.ts +8 -11
package/README.md
CHANGED
|
@@ -43,6 +43,12 @@ There is no flag for choosing features. The command opens a list:
|
|
|
43
43
|
two lines under the list update as you go: one shows what your selection drags in,
|
|
44
44
|
the other shows which of it needs Redis or Postgres running to do anything.
|
|
45
45
|
|
|
46
|
+
After the list, one more question: whether to compile the app to a single
|
|
47
|
+
standalone binary. Yes adds a `scripts/build.ts` and a `build` script, so
|
|
48
|
+
`bun run build` writes one executable with `bun build --compile` that a host runs
|
|
49
|
+
with nothing installed. It needs Bun >= 1.4.1, the floor the generated
|
|
50
|
+
`package.json` already declares.
|
|
51
|
+
|
|
46
52
|
Three more questions appear only when there is something to ask: a directory, when
|
|
47
53
|
the command line named none; a package name, when the directory's is one npm would
|
|
48
54
|
reject; and whether to write into a directory that already has files in it.
|
|
@@ -112,7 +118,7 @@ fails if the two ever drift.
|
|
|
112
118
|
|
|
113
119
|
Every app also gets an `AGENTS.md` naming its layout, its commands and the rules
|
|
114
120
|
dunx fails at boot over, plus a `CLAUDE.md` pointing at it. Both link
|
|
115
|
-
<https://
|
|
121
|
+
<https://dunx.win/setup.md>, which is served per release, rather
|
|
116
122
|
than copying the framework's own instructions into your repository.
|
|
117
123
|
|
|
118
124
|
The example is the one CI boots, so keeping them identical makes the template
|
|
@@ -152,7 +158,9 @@ const { directory, files } = await scaffold({
|
|
|
152
158
|
|
|
153
159
|
This is the scripted path the removed `--with` flag used to be. `features` takes
|
|
154
160
|
the same names the list shows, in any order, and pulls in what they require.
|
|
155
|
-
`FEATURES` exports the set. Omitting it writes the minimal template.
|
|
161
|
+
`FEATURES` exports the set. Omitting it writes the minimal template. `binary: true`
|
|
162
|
+
adds the standalone-binary build, and forces the generation path so it works with
|
|
163
|
+
no features too.
|
|
156
164
|
|
|
157
165
|
`scaffold` throws `ScaffoldError` for anything the caller can fix - an unknown
|
|
158
166
|
template, an unusable package name, a non-empty target without `force` - and lets
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The binary a compiled build writes, from the package name's last segment. */
|
|
2
|
+
export declare const binaryName: (name: string) => string;
|
|
3
|
+
/**
|
|
4
|
+
* The `scripts/build.ts` a `binary` scaffold gets. `Bun.build` takes both the
|
|
5
|
+
* `@dunx/transform` plugin and `compile`, so the constructor-dependency records
|
|
6
|
+
* the container resolves are baked into the executable at build time - a compiled
|
|
7
|
+
* binary has no load-time plugin to record them.
|
|
8
|
+
*/
|
|
9
|
+
export declare const buildScript: (name: string) => string;
|
|
@@ -344,8 +344,8 @@ import { fileURLToPath } from "url";
|
|
|
344
344
|
var {Glob } = globalThis.Bun;
|
|
345
345
|
|
|
346
346
|
// src/agents.ts
|
|
347
|
-
var SETUP_URL = "https://
|
|
348
|
-
var LLMS_URL = "https://
|
|
347
|
+
var SETUP_URL = "https://dunx.win/setup.md";
|
|
348
|
+
var LLMS_URL = "https://dunx.win/llms.txt";
|
|
349
349
|
var CLAUDE_POINTER = `# CLAUDE.md
|
|
350
350
|
|
|
351
351
|
The instructions for this application live in \`AGENTS.md\`, imported below so every
|
|
@@ -456,6 +456,39 @@ var agentFiles = (name, features) => ({
|
|
|
456
456
|
"CLAUDE.md": CLAUDE_POINTER
|
|
457
457
|
});
|
|
458
458
|
|
|
459
|
+
// src/build-template.ts
|
|
460
|
+
var binaryName = (name) => name.split("/").at(-1) ?? name;
|
|
461
|
+
var buildScript = (name) => `// Generated by @dunx/create-app for ${name}. Yours to edit.
|
|
462
|
+
import { depsPlugin } from '@dunx/transform';
|
|
463
|
+
import { join, resolve } from 'node:path';
|
|
464
|
+
|
|
465
|
+
const DIR = resolve(import.meta.dir, '..');
|
|
466
|
+
const outfile = join(DIR, 'dist', '${binaryName(name)}');
|
|
467
|
+
|
|
468
|
+
const compiled = await Bun.build({
|
|
469
|
+
entrypoints: [join(DIR, 'src/main.ts')],
|
|
470
|
+
target: 'bun',
|
|
471
|
+
// One self-contained executable: the Bun runtime plus the app in one file, so a
|
|
472
|
+
// host needs nothing installed to run it.
|
|
473
|
+
compile: { outfile },
|
|
474
|
+
plugins: [depsPlugin],
|
|
475
|
+
minify: true,
|
|
476
|
+
// A stack trace is never read off a shipped binary; symbols would only add size.
|
|
477
|
+
sourcemap: 'none',
|
|
478
|
+
tsconfig: join(DIR, 'tsconfig.json'),
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
if (!compiled.success) {
|
|
482
|
+
for (const log of compiled.logs) console.error(log);
|
|
483
|
+
throw new AggregateError(compiled.logs, 'compile failed');
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const bytes = await Bun.file(outfile).bytes();
|
|
487
|
+
console.log(
|
|
488
|
+
\`Built \${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB -> \${outfile}\`,
|
|
489
|
+
);
|
|
490
|
+
`;
|
|
491
|
+
|
|
459
492
|
// src/generate.ts
|
|
460
493
|
var HEADER = (name) => `// Generated by @dunx/create-app for ${name}. Yours to edit.
|
|
461
494
|
`;
|
|
@@ -475,7 +508,7 @@ var dependenciesFor = (features) => uniq([
|
|
|
475
508
|
...features.flatMap((feature) => feature.dependencies)
|
|
476
509
|
]).sort();
|
|
477
510
|
var DUNX = /^@dunx\//;
|
|
478
|
-
var manifest = (features) => {
|
|
511
|
+
var manifest = (features, binary = false) => {
|
|
479
512
|
const deps = dependenciesFor(features);
|
|
480
513
|
const dependencies = {};
|
|
481
514
|
for (const dep of deps) {
|
|
@@ -484,6 +517,7 @@ var manifest = (features) => {
|
|
|
484
517
|
const scripts = {
|
|
485
518
|
dev: "bun --watch src/main.ts",
|
|
486
519
|
start: "bun src/main.ts",
|
|
520
|
+
...binary ? { build: "bun scripts/build.ts" } : {},
|
|
487
521
|
test: "bun test",
|
|
488
522
|
typecheck: "tsc --noEmit"
|
|
489
523
|
};
|
|
@@ -746,8 +780,9 @@ ${lines.join(`
|
|
|
746
780
|
`)}
|
|
747
781
|
`;
|
|
748
782
|
};
|
|
749
|
-
var readme = (name, features) => {
|
|
783
|
+
var readme = (name, features, binary = false) => {
|
|
750
784
|
const services = features.filter((feature) => feature.service !== undefined);
|
|
785
|
+
const bin = binaryName(name);
|
|
751
786
|
return `# ${name}
|
|
752
787
|
|
|
753
788
|
Scaffolded with \`bunx @dunx/create-app\`.
|
|
@@ -757,6 +792,24 @@ bun install
|
|
|
757
792
|
bun run dev # restarts on a change
|
|
758
793
|
bun run start
|
|
759
794
|
\`\`\`
|
|
795
|
+
${binary ? `
|
|
796
|
+
## Compile to a binary
|
|
797
|
+
|
|
798
|
+
\`\`\`bash
|
|
799
|
+
bun run build # -> dist/${bin} (the Bun runtime plus the app, one file)
|
|
800
|
+
\`\`\`
|
|
801
|
+
|
|
802
|
+
\`scripts/build.ts\` hands \`Bun.build\` the \`@dunx/transform\` plugin and \`compile\`
|
|
803
|
+
together, so the constructor-dependency records the container needs are baked into
|
|
804
|
+
the executable. This needs Bun >= 1.4.1, the version \`package.json\` already
|
|
805
|
+
requires: an earlier one dropped the records. Copy \`dist/${bin}\` to a host and run
|
|
806
|
+
it; it needs nothing installed.
|
|
807
|
+
|
|
808
|
+
Run it from a directory without this app's \`bunfig.toml\`. A standalone bun
|
|
809
|
+
executable still reads \`preload\` from the working directory's bunfig and would try
|
|
810
|
+
to load \`@dunx/transform/preload\`, which the binary no longer needs and cannot
|
|
811
|
+
resolve. A deployment host has no such file.
|
|
812
|
+
` : ""}
|
|
760
813
|
|
|
761
814
|
## What is wired up
|
|
762
815
|
|
|
@@ -821,15 +874,16 @@ var packageVersion = async () => {
|
|
|
821
874
|
return json.version ?? "0.0.0";
|
|
822
875
|
};
|
|
823
876
|
var fill = (contents, name, version) => contents.replaceAll(VERSION_PLACEHOLDER2, version).replaceAll("__DUNX_APP_NAME__", name);
|
|
824
|
-
var generated = (name, features) => {
|
|
877
|
+
var generated = (name, features, binary) => {
|
|
825
878
|
const groups = configGroupsFor(features);
|
|
826
879
|
return {
|
|
827
|
-
"package.json": manifest(features),
|
|
828
|
-
"README.md": readme(name, features),
|
|
880
|
+
"package.json": manifest(features, binary),
|
|
881
|
+
"README.md": readme(name, features, binary),
|
|
829
882
|
".env.example": envExample(groups),
|
|
830
883
|
"src/main.ts": main(name, features),
|
|
831
884
|
"src/app.module.ts": appModule(name, features),
|
|
832
|
-
"src/config.ts": config(name, groups)
|
|
885
|
+
"src/config.ts": config(name, groups),
|
|
886
|
+
...binary ? { "scripts/build.ts": buildScript(name) } : {}
|
|
833
887
|
};
|
|
834
888
|
};
|
|
835
889
|
var scaffold2 = async (options) => {
|
|
@@ -844,7 +898,8 @@ var scaffold2 = async (options) => {
|
|
|
844
898
|
} catch (error) {
|
|
845
899
|
throw new ScaffoldError2(error instanceof Error ? error.message : String(error));
|
|
846
900
|
}
|
|
847
|
-
const
|
|
901
|
+
const binary = options.binary === true;
|
|
902
|
+
const composing = features.length > 0 || binary;
|
|
848
903
|
const directory = resolve(options.cwd ?? process.cwd(), options.target);
|
|
849
904
|
const name = options.name ?? basename(directory);
|
|
850
905
|
if (!isValidPackageName2(name)) {
|
|
@@ -892,6 +947,7 @@ var scaffold2 = async (options) => {
|
|
|
892
947
|
name,
|
|
893
948
|
template,
|
|
894
949
|
features: [],
|
|
950
|
+
binary: false,
|
|
895
951
|
files: written.sort()
|
|
896
952
|
};
|
|
897
953
|
}
|
|
@@ -908,7 +964,7 @@ var scaffold2 = async (options) => {
|
|
|
908
964
|
await copyTree(from, join("src", feature.source));
|
|
909
965
|
}
|
|
910
966
|
await writeAll({
|
|
911
|
-
...generated(name, features),
|
|
967
|
+
...generated(name, features, binary),
|
|
912
968
|
...agentFiles(name, features)
|
|
913
969
|
});
|
|
914
970
|
return {
|
|
@@ -916,6 +972,7 @@ var scaffold2 = async (options) => {
|
|
|
916
972
|
name,
|
|
917
973
|
template: "composed",
|
|
918
974
|
features: features.map((feature) => feature.name),
|
|
975
|
+
binary,
|
|
919
976
|
files: written.sort()
|
|
920
977
|
};
|
|
921
978
|
};
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
isValidPackageName2,
|
|
10
10
|
packageVersion,
|
|
11
11
|
scaffold2
|
|
12
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-akm69d19.js";
|
|
13
13
|
|
|
14
14
|
// src/cli.ts
|
|
15
15
|
import { parseArgs } from "util";
|
|
@@ -673,8 +673,14 @@ class Wizard {
|
|
|
673
673
|
const target = defaults.target ?? await this.#runner.ask(new DirectoryPrompt(this.#style, "Directory", DEFAULT_TARGET));
|
|
674
674
|
const name = await this.#name(defaults, target);
|
|
675
675
|
const features = await this.#runner.ask(new FeaturePrompt(this.#style, defaults.features));
|
|
676
|
+
const binary = await this.#binary(defaults);
|
|
676
677
|
const force = await this.#force(defaults, target);
|
|
677
|
-
return { target, name, features, force };
|
|
678
|
+
return { target, name, features, binary, force };
|
|
679
|
+
}
|
|
680
|
+
async#binary(defaults) {
|
|
681
|
+
if (defaults.binary !== undefined)
|
|
682
|
+
return defaults.binary;
|
|
683
|
+
return this.#runner.ask(new ConfirmPrompt(this.#style, "Compile to a standalone binary?", false));
|
|
678
684
|
}
|
|
679
685
|
async#name(defaults, target) {
|
|
680
686
|
if (defaults.name !== undefined)
|
|
@@ -766,6 +772,7 @@ var byAsking = async () => {
|
|
|
766
772
|
target,
|
|
767
773
|
name: values.name,
|
|
768
774
|
features: [],
|
|
775
|
+
binary: undefined,
|
|
769
776
|
force: values.force === true,
|
|
770
777
|
cwd: process.cwd()
|
|
771
778
|
});
|
|
@@ -802,6 +809,9 @@ Created ${result.name} in ${where}/`);
|
|
|
802
809
|
console.log(` cd ${where}`);
|
|
803
810
|
console.log(" bun install");
|
|
804
811
|
console.log(` bun run dev ${style.muted("# or `start`, which does not watch")}`);
|
|
812
|
+
if (result.binary) {
|
|
813
|
+
console.log(` bun run build ${style.muted("# compile to one standalone binary")}`);
|
|
814
|
+
}
|
|
805
815
|
} catch (error) {
|
|
806
816
|
if (error instanceof CancelledError) {
|
|
807
817
|
console.log(style.muted(error.message));
|
package/dist/generate.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Feature } from './features.js';
|
|
2
2
|
/** Every config group the selection needs, base first, in a stable order. */
|
|
3
3
|
export declare const configGroupsFor: (features: readonly Feature[]) => readonly string[];
|
|
4
|
-
export declare const manifest: (features: readonly Feature[]) => string;
|
|
4
|
+
export declare const manifest: (features: readonly Feature[], binary?: boolean) => string;
|
|
5
5
|
/**
|
|
6
6
|
* Third-party versions, exact for the reason dunx's own manifests are, and
|
|
7
7
|
* written here rather than read off `examples/full` at run time:
|
|
@@ -15,4 +15,4 @@ export declare const appModule: (name: string, features: readonly Feature[]) =>
|
|
|
15
15
|
export declare const config: (name: string, groups: readonly string[]) => string;
|
|
16
16
|
export declare const main: (name: string, features: readonly Feature[]) => string;
|
|
17
17
|
export declare const envExample: (groups: readonly string[]) => string;
|
|
18
|
-
export declare const readme: (name: string, features: readonly Feature[]) => string;
|
|
18
|
+
export declare const readme: (name: string, features: readonly Feature[], binary?: boolean) => string;
|
package/dist/index.js
CHANGED
package/dist/scaffold.d.ts
CHANGED
|
@@ -32,6 +32,12 @@ export interface ScaffoldOptions {
|
|
|
32
32
|
* what it was.
|
|
33
33
|
*/
|
|
34
34
|
readonly features?: readonly string[];
|
|
35
|
+
/**
|
|
36
|
+
* Add a `scripts/build.ts` and a `build` script that compile the app to one
|
|
37
|
+
* standalone executable. Forces the generation path, so it works with no
|
|
38
|
+
* features chosen too.
|
|
39
|
+
*/
|
|
40
|
+
readonly binary?: boolean;
|
|
35
41
|
/** Write into a directory that already has files in it. */
|
|
36
42
|
readonly force?: boolean;
|
|
37
43
|
readonly cwd?: string;
|
|
@@ -44,6 +50,8 @@ export interface ScaffoldResult {
|
|
|
44
50
|
readonly template: TemplateName | 'composed';
|
|
45
51
|
/** Resolved feature names, in import order. Empty for a fixed template. */
|
|
46
52
|
readonly features: readonly string[];
|
|
53
|
+
/** Whether a standalone-binary build was generated. */
|
|
54
|
+
readonly binary: boolean;
|
|
47
55
|
readonly files: readonly string[];
|
|
48
56
|
}
|
|
49
57
|
export declare class ScaffoldError extends Error {
|
package/dist/wizard.d.ts
CHANGED
|
@@ -4,6 +4,7 @@ export interface WizardAnswers {
|
|
|
4
4
|
readonly target: string;
|
|
5
5
|
readonly name: string;
|
|
6
6
|
readonly features: readonly string[];
|
|
7
|
+
readonly binary: boolean;
|
|
7
8
|
readonly force: boolean;
|
|
8
9
|
}
|
|
9
10
|
/** What the flags already settled, so the wizard skips asking again. */
|
|
@@ -11,16 +12,19 @@ export interface WizardDefaults {
|
|
|
11
12
|
readonly target: string | undefined;
|
|
12
13
|
readonly name: string | undefined;
|
|
13
14
|
readonly features: readonly string[];
|
|
15
|
+
/** `undefined` asks; a boolean skips the question, the way `target` does. */
|
|
16
|
+
readonly binary: boolean | undefined;
|
|
14
17
|
readonly force: boolean;
|
|
15
18
|
readonly cwd: string;
|
|
16
19
|
}
|
|
17
20
|
/**
|
|
18
21
|
* The questions, in the order the answers are needed.
|
|
19
22
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
23
|
+
* The directory, package name and force questions are skipped when there is
|
|
24
|
+
* nothing to ask: a target given on the command line, a package name that is
|
|
25
|
+
* already legal, a directory that is already empty. Running
|
|
26
|
+
* `bunx @dunx/create-app my-api` in a clean directory therefore asks the two that
|
|
27
|
+
* nothing else can answer, features and the binary build.
|
|
24
28
|
*/
|
|
25
29
|
export declare class Wizard {
|
|
26
30
|
#private;
|
package/package.json
CHANGED
|
@@ -42,33 +42,35 @@ export class CacheController {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
@Get('/:id', oneSession)
|
|
45
|
-
async read(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
);
|
|
45
|
+
async read({ params }: Input<typeof oneSession>): Promise<{
|
|
46
|
+
id: string;
|
|
47
|
+
data: unknown;
|
|
48
|
+
ttl: number;
|
|
49
|
+
}> {
|
|
50
|
+
const found = await this.degrades(() => this.sessions.read(params.id));
|
|
51
51
|
if (found === null) {
|
|
52
52
|
throw new HttpError(
|
|
53
53
|
HttpStatusCode.NOT_FOUND,
|
|
54
|
-
`No session "${
|
|
54
|
+
`No session "${params.id}"`,
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
57
|
return found;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
@Put('/:id', putSession)
|
|
61
|
-
store(
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
store({ body, params }: Input<typeof putSession>): Promise<{
|
|
62
|
+
id: string;
|
|
63
|
+
ttl: number;
|
|
64
|
+
visits: number;
|
|
65
|
+
}> {
|
|
64
66
|
return this.degrades(() =>
|
|
65
|
-
this.sessions.store(
|
|
67
|
+
this.sessions.store(params.id, body.data, body.ttl),
|
|
66
68
|
);
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
@Delete('/:id', oneSession)
|
|
70
|
-
remove(
|
|
71
|
-
return this.degrades(() => this.sessions.remove(
|
|
72
|
+
remove({ params }: Input<typeof oneSession>): Promise<{ removed: number }> {
|
|
73
|
+
return this.degrades(() => this.sessions.remove(params.id));
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
private async degrades<T>(run: () => Promise<T>): Promise<T> {
|
|
@@ -2,61 +2,10 @@ import { Logger, Module } from '@dunx/core';
|
|
|
2
2
|
import { HttpFactory, PubSub, type HttpApp } from '@dunx/http';
|
|
3
3
|
import { isConnectionError, RedisConnection } from '@dunx/infra/redis';
|
|
4
4
|
import { RELAY_CHANNEL } from '../config.js';
|
|
5
|
+
import { connect, type Client } from './ws-client.js';
|
|
5
6
|
import { ChatGateway } from './chat.gateway.js';
|
|
6
7
|
import { Lobby } from './lobby.service.js';
|
|
7
8
|
|
|
8
|
-
interface Client {
|
|
9
|
-
next(): Promise<string>;
|
|
10
|
-
send(event: string, data: unknown): void;
|
|
11
|
-
close(): void;
|
|
12
|
-
/** Every frame this socket ever received, so a *second* delivery is visible. */
|
|
13
|
-
readonly received: readonly string[];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/** A real `new WebSocket()`, with a deadline so a stall fails instead of hanging. */
|
|
17
|
-
const connect = async (base: string): Promise<Client> => {
|
|
18
|
-
const socket = new WebSocket(
|
|
19
|
-
new URL('chat', base).href.replace('http', 'ws'),
|
|
20
|
-
);
|
|
21
|
-
const frames: string[] = [];
|
|
22
|
-
const received: string[] = [];
|
|
23
|
-
const waiting: ((frame: string) => void)[] = [];
|
|
24
|
-
|
|
25
|
-
socket.addEventListener('message', (event: MessageEvent) => {
|
|
26
|
-
const frame = String(event.data);
|
|
27
|
-
received.push(frame);
|
|
28
|
-
const waiter = waiting.shift();
|
|
29
|
-
if (waiter) waiter(frame);
|
|
30
|
-
else frames.push(frame);
|
|
31
|
-
});
|
|
32
|
-
await new Promise<void>((resolve, reject) => {
|
|
33
|
-
socket.addEventListener('open', () => resolve(), { once: true });
|
|
34
|
-
setTimeout(() => reject(new Error('the socket never opened')), 2000);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
return {
|
|
38
|
-
next: () =>
|
|
39
|
-
new Promise<string>((resolve, reject) => {
|
|
40
|
-
const queued = frames.shift();
|
|
41
|
-
if (queued !== undefined) {
|
|
42
|
-
resolve(queued);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
const timer = setTimeout(
|
|
46
|
-
() => reject(new Error('no frame arrived')),
|
|
47
|
-
2000,
|
|
48
|
-
);
|
|
49
|
-
waiting.push((frame) => {
|
|
50
|
-
clearTimeout(timer);
|
|
51
|
-
resolve(frame);
|
|
52
|
-
});
|
|
53
|
-
}),
|
|
54
|
-
send: (event, data) => socket.send(JSON.stringify({ event, data })),
|
|
55
|
-
close: () => socket.close(),
|
|
56
|
-
received,
|
|
57
|
-
};
|
|
58
|
-
};
|
|
59
|
-
|
|
60
9
|
/**
|
|
61
10
|
* A second node in-process: two `Bun.serve` instances, two containers, two
|
|
62
11
|
* `PubSub` origin ids. It reuses the same `ChatGateway` and excludes `ChatDemo`,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Module } from '@dunx/core';
|
|
2
2
|
import { CacheModule } from '../cache/cache.module.js';
|
|
3
3
|
import { ChatDemo } from './chat.demo.js';
|
|
4
|
+
import { PostgresRelayDemo } from './postgres-relay.demo.js';
|
|
4
5
|
import { ChatGateway } from './chat.gateway.js';
|
|
5
6
|
import { Lobby } from './lobby.service.js';
|
|
6
7
|
|
|
@@ -9,7 +10,7 @@ import { Lobby } from './lobby.service.js';
|
|
|
9
10
|
@Module({
|
|
10
11
|
// `RedisConnection`, for cross-process fan-out.
|
|
11
12
|
imports: [CacheModule],
|
|
12
|
-
providers: [ChatGateway, Lobby, ChatDemo],
|
|
13
|
-
exports: [Lobby, ChatDemo],
|
|
13
|
+
providers: [ChatGateway, Lobby, ChatDemo, PostgresRelayDemo],
|
|
14
|
+
exports: [Lobby, ChatDemo, PostgresRelayDemo],
|
|
14
15
|
})
|
|
15
16
|
export class ChatModule {}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { Logger, Module, provide } from '@dunx/core';
|
|
2
|
+
import {
|
|
3
|
+
HttpFactory,
|
|
4
|
+
HttpOptionsProvider,
|
|
5
|
+
PubSub,
|
|
6
|
+
WsRelay,
|
|
7
|
+
WsRelayModule,
|
|
8
|
+
type PubSubRelay,
|
|
9
|
+
} from '@dunx/http';
|
|
10
|
+
import { RELAY_CHANNEL } from '../config.js';
|
|
11
|
+
import { ChatGateway } from './chat.gateway.js';
|
|
12
|
+
import { Lobby } from './lobby.service.js';
|
|
13
|
+
import { connect } from './ws-client.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read here rather than injected: the module is declared at file scope, so it
|
|
17
|
+
* cannot reach `ConfigService`. The default matches `compose.yml` and the schema's.
|
|
18
|
+
*/
|
|
19
|
+
const POSTGRES_URL =
|
|
20
|
+
Bun.env['POSTGRES_URL'] ?? 'postgres://dunx:dunx@localhost:5432/dunx';
|
|
21
|
+
|
|
22
|
+
/** Host and database only. The url carries a password and a log outlives it. */
|
|
23
|
+
const where = (url: string): string => {
|
|
24
|
+
try {
|
|
25
|
+
const parsed = new URL(url);
|
|
26
|
+
return `${parsed.host}${parsed.pathname}`;
|
|
27
|
+
} catch {
|
|
28
|
+
return 'the configured Postgres';
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The same fan-out as the Redis relay, over `Bun.SQL`'s `LISTEN`/`NOTIFY`, for an
|
|
34
|
+
* app that already has Postgres and would rather not run a broker.
|
|
35
|
+
*
|
|
36
|
+
* Two nodes, each with its own `WsRelayModule.forPostgres` and its own `PubSub`
|
|
37
|
+
* origin. Neither shares a container with the other, which is what makes the
|
|
38
|
+
* delivery real rather than a same-process shortcut.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* Binding the relay is not the same as using it. `WsRelayModule` puts a `WsRelay`
|
|
42
|
+
* in the container; what attaches it to `PubSub` is an `HttpOptionsProvider`
|
|
43
|
+
* answering `relay`, exactly as `AppHttpOptions` does for the app itself. Without
|
|
44
|
+
* this the nodes come up, the sockets work, and every publish stays local.
|
|
45
|
+
*/
|
|
46
|
+
class NodeHttpOptions extends HttpOptionsProvider {
|
|
47
|
+
constructor(private readonly bus: WsRelay) {
|
|
48
|
+
super();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
override get relay(): PubSubRelay {
|
|
52
|
+
return this.bus;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
override readonly relayChannel = RELAY_CHANNEL;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Module({
|
|
59
|
+
imports: [WsRelayModule.forPostgres({ url: POSTGRES_URL })],
|
|
60
|
+
providers: [
|
|
61
|
+
ChatGateway,
|
|
62
|
+
Lobby,
|
|
63
|
+
// Bound to the token `HttpFactory` asks for, not registered as itself:
|
|
64
|
+
// the factory looks up `HttpOptionsProvider` and promotes a default when
|
|
65
|
+
// nothing answers it, so a bare subclass in `providers` is never consulted.
|
|
66
|
+
provide(HttpOptionsProvider, { useClass: NodeHttpOptions }),
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
class PostgresNode {}
|
|
70
|
+
|
|
71
|
+
interface Node {
|
|
72
|
+
readonly app: Awaited<ReturnType<typeof HttpFactory.create>>;
|
|
73
|
+
readonly url: string;
|
|
74
|
+
readonly pubsub: PubSub;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Postgres caps a `NOTIFY` payload at 7999 bytes, envelope included. */
|
|
78
|
+
const OVER_THE_NOTIFY_CAP = 9000;
|
|
79
|
+
|
|
80
|
+
export class PostgresRelayDemo {
|
|
81
|
+
constructor(private readonly logger: Logger) {}
|
|
82
|
+
|
|
83
|
+
async demonstrate(): Promise<void> {
|
|
84
|
+
const { logger } = this;
|
|
85
|
+
if (!(await this.#postgresUp())) {
|
|
86
|
+
logger.warn(
|
|
87
|
+
`skipping the Postgres relay demo: nothing answering at ${where(POSTGRES_URL)}`,
|
|
88
|
+
);
|
|
89
|
+
logger.info('`bun run services:up` starts it, and CI runs the same file');
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Started one at a time and collected as they come up. `Promise.all` rejects
|
|
94
|
+
// on the first failure and abandons a peer that had already bound a port,
|
|
95
|
+
// which keeps the process alive after the demo has given up.
|
|
96
|
+
const nodes: Node[] = [];
|
|
97
|
+
try {
|
|
98
|
+
nodes.push(await this.#node(), await this.#node());
|
|
99
|
+
const [a, b] = nodes as [Node, Node];
|
|
100
|
+
logger.info(
|
|
101
|
+
`two nodes on LISTEN/NOTIFY, origins …${a.pubsub.origin.slice(-6)} / …${b.pubsub.origin.slice(-6)}`,
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const [onA, onB] = await Promise.all([connect(a.url), connect(b.url)]);
|
|
105
|
+
await Promise.all([onA.next(), onB.next()]);
|
|
106
|
+
|
|
107
|
+
const said = 'across nodes, over Postgres';
|
|
108
|
+
a.pubsub.publishEvent(Lobby.TOPIC, 'said', said);
|
|
109
|
+
logger.info(
|
|
110
|
+
`node B's client <- ${await onB.next()} (relayed via NOTIFY)`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await Bun.sleep(250);
|
|
114
|
+
const seen = (frames: readonly string[]): number =>
|
|
115
|
+
frames.filter((frame) => frame.includes(said)).length;
|
|
116
|
+
logger.info(
|
|
117
|
+
`deliveries: A ${seen(onA.received)}, B ${seen(onB.received)} ` +
|
|
118
|
+
'(one each, so the publisher did not fan its own frame out twice)',
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Postgres refuses the NOTIFY rather than truncating it. The publish is
|
|
122
|
+
// reported and fan-out stays local, which is the documented degradation.
|
|
123
|
+
const huge = 'x'.repeat(OVER_THE_NOTIFY_CAP);
|
|
124
|
+
a.pubsub.publishEvent(Lobby.TOPIC, 'said', huge);
|
|
125
|
+
await Bun.sleep(400);
|
|
126
|
+
const huge_ = (frames: readonly string[]): number =>
|
|
127
|
+
frames.filter((frame) => frame.includes(huge)).length;
|
|
128
|
+
logger.info(
|
|
129
|
+
`a ${OVER_THE_NOTIFY_CAP}-byte frame: A ${huge_(onA.received)}, B ${huge_(onB.received)} ` +
|
|
130
|
+
'(over the 7999-byte NOTIFY cap, so the publishing node still delivers ' +
|
|
131
|
+
'it and the relay reports one warn)',
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
onA.close();
|
|
135
|
+
onB.close();
|
|
136
|
+
await Bun.sleep(20);
|
|
137
|
+
} finally {
|
|
138
|
+
await Promise.all(nodes.map((node) => node.app.shutdown()));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async #node(): Promise<Node> {
|
|
143
|
+
const app = await HttpFactory.create(PostgresNode, {
|
|
144
|
+
requestLogging: false,
|
|
145
|
+
});
|
|
146
|
+
try {
|
|
147
|
+
const url = await app.listen(0);
|
|
148
|
+
return { app, url, pubsub: app.get(PubSub) };
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// The container is up even when the port is not, so it is ours to close.
|
|
151
|
+
await app.shutdown();
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A relay demo needs its backend; an absent one is a skip, not a failure. */
|
|
157
|
+
async #postgresUp(): Promise<boolean> {
|
|
158
|
+
const sql = new Bun.SQL(POSTGRES_URL, {
|
|
159
|
+
max: 1,
|
|
160
|
+
connectionTimeout: 2,
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
await sql`select 1`;
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
} finally {
|
|
168
|
+
await sql.close().catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real `new WebSocket()` against the chat gateway, with a deadline so a stall
|
|
3
|
+
* fails instead of hanging.
|
|
4
|
+
*
|
|
5
|
+
* Shared rather than copied: both relay demos and the soak workload open sockets
|
|
6
|
+
* the same way, and a second copy of the frame-queue handling is where the two
|
|
7
|
+
* would drift.
|
|
8
|
+
*/
|
|
9
|
+
export interface Client {
|
|
10
|
+
next(): Promise<string>;
|
|
11
|
+
send(event: string, data: unknown): void;
|
|
12
|
+
close(): void;
|
|
13
|
+
/** Every frame this socket ever received, so a *second* delivery is visible. */
|
|
14
|
+
readonly received: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A real `new WebSocket()`, with a deadline so a stall fails instead of hanging. */
|
|
18
|
+
export const connect = async (base: string): Promise<Client> => {
|
|
19
|
+
const socket = new WebSocket(
|
|
20
|
+
new URL('chat', base).href.replace('http', 'ws'),
|
|
21
|
+
);
|
|
22
|
+
const frames: string[] = [];
|
|
23
|
+
const received: string[] = [];
|
|
24
|
+
const waiting: ((frame: string) => void)[] = [];
|
|
25
|
+
|
|
26
|
+
socket.addEventListener('message', (event: MessageEvent) => {
|
|
27
|
+
const frame = String(event.data);
|
|
28
|
+
received.push(frame);
|
|
29
|
+
const waiter = waiting.shift();
|
|
30
|
+
if (waiter) waiter(frame);
|
|
31
|
+
else frames.push(frame);
|
|
32
|
+
});
|
|
33
|
+
await new Promise<void>((resolve, reject) => {
|
|
34
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
35
|
+
setTimeout(() => reject(new Error('the socket never opened')), 2000);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
next: () =>
|
|
40
|
+
new Promise<string>((resolve, reject) => {
|
|
41
|
+
const queued = frames.shift();
|
|
42
|
+
if (queued !== undefined) {
|
|
43
|
+
resolve(queued);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const waiter = (frame: string): void => {
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
resolve(frame);
|
|
49
|
+
};
|
|
50
|
+
// Dropped from the queue before rejecting, or the next frame is handed to
|
|
51
|
+
// this dead promise and discarded, and every later `next()` waits one
|
|
52
|
+
// frame behind. Only shows up after a timeout, which is when the test is
|
|
53
|
+
// already trying to explain itself.
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
const at = waiting.indexOf(waiter);
|
|
56
|
+
if (at !== -1) waiting.splice(at, 1);
|
|
57
|
+
reject(new Error('no frame arrived'));
|
|
58
|
+
}, 2000);
|
|
59
|
+
waiting.push(waiter);
|
|
60
|
+
}),
|
|
61
|
+
send: (event, data) => socket.send(JSON.stringify({ event, data })),
|
|
62
|
+
close: () => socket.close(),
|
|
63
|
+
received,
|
|
64
|
+
};
|
|
65
|
+
};
|
|
@@ -7,7 +7,14 @@ import {
|
|
|
7
7
|
Post,
|
|
8
8
|
type Input,
|
|
9
9
|
} from '@dunx/http';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
decodeCursor,
|
|
12
|
+
encodeCursor,
|
|
13
|
+
PAGINATION,
|
|
14
|
+
pageOf,
|
|
15
|
+
parsePageOptions,
|
|
16
|
+
type Page,
|
|
17
|
+
} from '@dunx/infra/pagination';
|
|
11
18
|
import { z } from 'zod';
|
|
12
19
|
import { Ledger } from './ledger.service.js';
|
|
13
20
|
import type { Entry } from './schema.js';
|
|
@@ -69,6 +76,18 @@ const pageQuery = z
|
|
|
69
76
|
});
|
|
70
77
|
|
|
71
78
|
const pagedEntries = { query: pageQuery } as const;
|
|
79
|
+
const keyset = {
|
|
80
|
+
query: z.object({
|
|
81
|
+
take: z.coerce
|
|
82
|
+
.number()
|
|
83
|
+
.int()
|
|
84
|
+
.min(PAGINATION.MIN_TAKE)
|
|
85
|
+
.max(PAGINATION.MAX_TAKE)
|
|
86
|
+
.optional(),
|
|
87
|
+
cursor: z.string().max(PAGINATION.MAX_CURSOR).optional(),
|
|
88
|
+
}),
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
72
91
|
const oneEntry = { params: EntryIndex } as const;
|
|
73
92
|
const createEntry = { body: CreateEntry } as const;
|
|
74
93
|
const transfer = { body: Transfer } as const;
|
|
@@ -78,12 +97,12 @@ export class LedgerController {
|
|
|
78
97
|
constructor(private readonly ledger: Ledger) {}
|
|
79
98
|
|
|
80
99
|
@Get('/', listEntries)
|
|
81
|
-
list(
|
|
100
|
+
list({ query }: Input<typeof listEntries>): {
|
|
82
101
|
entries: readonly Entry[];
|
|
83
102
|
balance: number;
|
|
84
103
|
} {
|
|
85
104
|
return {
|
|
86
|
-
entries: this.ledger.list(
|
|
105
|
+
entries: this.ledger.list(query.limit),
|
|
87
106
|
balance: this.ledger.balance(),
|
|
88
107
|
};
|
|
89
108
|
}
|
|
@@ -91,34 +110,34 @@ export class LedgerController {
|
|
|
91
110
|
/** Walked by cursor. Declared before `/:id` for readability only: `Bun.serve`
|
|
92
111
|
* matches a static segment ahead of a parameter. */
|
|
93
112
|
@Get('/page', pagedEntries)
|
|
94
|
-
page(
|
|
95
|
-
return this.ledger.page(
|
|
113
|
+
page({ query }: Input<typeof pagedEntries>): Page<Entry> {
|
|
114
|
+
return this.ledger.page(query);
|
|
96
115
|
}
|
|
97
116
|
|
|
98
117
|
@Get('/:id', oneEntry)
|
|
99
|
-
one(
|
|
100
|
-
const entry = this.ledger.find(
|
|
118
|
+
one({ params }: Input<typeof oneEntry>): Entry {
|
|
119
|
+
const entry = this.ledger.find(params.id);
|
|
101
120
|
if (entry === undefined) {
|
|
102
121
|
throw new HttpError(
|
|
103
122
|
HttpStatusCode.NOT_FOUND,
|
|
104
|
-
`No ledger entry ${
|
|
123
|
+
`No ledger entry ${params.id}`,
|
|
105
124
|
);
|
|
106
125
|
}
|
|
107
126
|
return entry;
|
|
108
127
|
}
|
|
109
128
|
|
|
110
129
|
@Post('/', createEntry)
|
|
111
|
-
create(
|
|
112
|
-
return this.ledger.add(
|
|
130
|
+
create({ body }: Input<typeof createEntry>): Entry {
|
|
131
|
+
return this.ledger.add(body.memo, body.amount);
|
|
113
132
|
}
|
|
114
133
|
|
|
115
134
|
/** `"fail": true` throws between the two inserts; the 409's unchanged `rows`
|
|
116
135
|
* is proof the first leg rolled back. */
|
|
117
136
|
@Post('/transfer', transfer)
|
|
118
|
-
async transfer(
|
|
119
|
-
|
|
120
|
-
): Promise<{ balance: number; rows: number }> {
|
|
121
|
-
const { from, to, amount, fail } =
|
|
137
|
+
async transfer({
|
|
138
|
+
body,
|
|
139
|
+
}: Input<typeof transfer>): Promise<{ balance: number; rows: number }> {
|
|
140
|
+
const { from, to, amount, fail } = body;
|
|
122
141
|
try {
|
|
123
142
|
const balance = await this.ledger.transfer(from, to, amount, fail);
|
|
124
143
|
return { balance, rows: this.ledger.rows() };
|
|
@@ -136,11 +155,11 @@ export class LedgerController {
|
|
|
136
155
|
* what allows it - `transactionSync` will not compile against the async handle.
|
|
137
156
|
*/
|
|
138
157
|
@Post('/transfer-sync', transfer)
|
|
139
|
-
transferSync(
|
|
158
|
+
transferSync({ body }: Input<typeof transfer>): {
|
|
140
159
|
balance: number;
|
|
141
160
|
rows: number;
|
|
142
161
|
} {
|
|
143
|
-
const { from, to, amount, fail } =
|
|
162
|
+
const { from, to, amount, fail } = body;
|
|
144
163
|
try {
|
|
145
164
|
const balance = this.ledger.transferSync(from, to, amount, fail);
|
|
146
165
|
return { balance, rows: this.ledger.rows() };
|
|
@@ -152,13 +171,69 @@ export class LedgerController {
|
|
|
152
171
|
}
|
|
153
172
|
}
|
|
154
173
|
|
|
174
|
+
/**
|
|
175
|
+
* Keyset pagination without the service: `parsePageOptions` reads the query the
|
|
176
|
+
* way the module's own docs suggest a schema does, `pageOf` shapes the envelope
|
|
177
|
+
* from rows the caller already has, and a cursor round-trips through
|
|
178
|
+
* `encodeCursor`/`decodeCursor`.
|
|
179
|
+
*
|
|
180
|
+
* The cursor is opaque on purpose and every malformed one collapses to the same
|
|
181
|
+
* `CursorError`, so `?cursor=not-a-cursor` answers 400 rather than telling the
|
|
182
|
+
* caller which layer rejected it.
|
|
183
|
+
*/
|
|
184
|
+
@Get('/keyset', keyset)
|
|
185
|
+
keyset({ query }: Input<typeof keyset>): {
|
|
186
|
+
options: { take: number; direction: string; order: string };
|
|
187
|
+
page: Page<Entry>;
|
|
188
|
+
roundTrip: { encoded: string; decoded: { s: string; i: string } } | null;
|
|
189
|
+
} {
|
|
190
|
+
// Throws PageOptionsError on a take outside the range, which the framework
|
|
191
|
+
// renders as a 400 because the class carries the status.
|
|
192
|
+
const options = parsePageOptions(query as Record<string, unknown>);
|
|
193
|
+
// A cursor handed back in is decoded first, which is where a hand-written one
|
|
194
|
+
// fails, and it is what the next page is keyed from.
|
|
195
|
+
const roundTrip =
|
|
196
|
+
query.cursor === undefined
|
|
197
|
+
? null
|
|
198
|
+
: { encoded: query.cursor, decoded: decodeCursor(query.cursor) };
|
|
199
|
+
|
|
200
|
+
const cursorOf = (row: Entry): string =>
|
|
201
|
+
// No timestamp on this table, so the id is both sort value and tiebreak.
|
|
202
|
+
encodeCursor(row.id, String(row.id));
|
|
203
|
+
|
|
204
|
+
// Descending ids, so the page after a cursor is everything below it. One row
|
|
205
|
+
// more than asked for is what answers `hasNextPage` without a second count.
|
|
206
|
+
const after = roundTrip === null ? undefined : Number(roundTrip.decoded.i);
|
|
207
|
+
const scanned = this.ledger
|
|
208
|
+
.list(PAGINATION.MAX_TAKE)
|
|
209
|
+
.filter((row) => after === undefined || row.id < after);
|
|
210
|
+
const rows = scanned.slice(0, options.take);
|
|
211
|
+
|
|
212
|
+
const page = pageOf(rows, {
|
|
213
|
+
take: options.take,
|
|
214
|
+
hasNextPage: scanned.length > options.take,
|
|
215
|
+
hasPreviousPage: after !== undefined,
|
|
216
|
+
cursorOf,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
options: {
|
|
221
|
+
take: options.take,
|
|
222
|
+
direction: options.direction,
|
|
223
|
+
order: options.order,
|
|
224
|
+
},
|
|
225
|
+
page,
|
|
226
|
+
roundTrip,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
155
230
|
@Delete('/:id', oneEntry)
|
|
156
|
-
remove(
|
|
157
|
-
const deleted = this.ledger.remove(
|
|
231
|
+
remove({ params }: Input<typeof oneEntry>): { deleted: boolean } {
|
|
232
|
+
const deleted = this.ledger.remove(params.id);
|
|
158
233
|
if (!deleted) {
|
|
159
234
|
throw new HttpError(
|
|
160
235
|
HttpStatusCode.NOT_FOUND,
|
|
161
|
-
`No ledger entry ${
|
|
236
|
+
`No ledger entry ${params.id}`,
|
|
162
237
|
);
|
|
163
238
|
}
|
|
164
239
|
return { deleted };
|
|
@@ -55,15 +55,15 @@ export class ReportsController {
|
|
|
55
55
|
// Method-scoped guard, reading the class-level @Roles('admin').
|
|
56
56
|
@UseGuards(RolesGuard)
|
|
57
57
|
@Post('/', createReport)
|
|
58
|
-
create(
|
|
59
|
-
return this.reports.add(
|
|
58
|
+
create({ body }: Input<typeof createReport>): readonly string[] {
|
|
59
|
+
return this.reports.add(body.title);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// A method-level @Roles wins over the class-level one.
|
|
63
63
|
@Roles('editor')
|
|
64
64
|
@UseGuards(RolesGuard)
|
|
65
65
|
@Patch('/:id', renameReport)
|
|
66
|
-
rename(
|
|
67
|
-
return this.reports.rename(
|
|
66
|
+
rename({ body, params }: Input<typeof renameReport>): readonly string[] {
|
|
67
|
+
return this.reports.rename(params.id, body.title);
|
|
68
68
|
}
|
|
69
69
|
}
|
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
import type { BunRequest } from 'bun';
|
|
2
2
|
import type { Middleware, Next, RouteContext } from '@dunx/http';
|
|
3
3
|
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* The observable side effect: whatever the middleware saw is readable after.
|
|
6
|
+
*
|
|
7
|
+
* Capped, because this grows by one entry on **every request** and this folder is
|
|
8
|
+
* vendored into `@dunx/create-app`'s `http` feature. Unbounded it put 46 MiB on
|
|
9
|
+
* the heap across 3.1 million requests in the soak run and read as a framework
|
|
10
|
+
* leak until a heap census named the strings. A demo that keeps the last few
|
|
11
|
+
* hundred shows the same thing and survives production traffic.
|
|
12
|
+
*/
|
|
13
|
+
const KEEP = 500;
|
|
14
|
+
|
|
5
15
|
export class RequestTrail {
|
|
6
16
|
readonly entries: string[] = [];
|
|
17
|
+
|
|
18
|
+
record(entry: string): void {
|
|
19
|
+
this.entries.push(entry);
|
|
20
|
+
if (this.entries.length > KEEP) {
|
|
21
|
+
this.entries.splice(0, this.entries.length - KEEP);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
7
24
|
}
|
|
8
25
|
|
|
9
26
|
/**
|
|
@@ -23,7 +40,7 @@ export class RequestTrailMiddleware implements Middleware {
|
|
|
23
40
|
next: Next,
|
|
24
41
|
): Promise<Response> {
|
|
25
42
|
const response = await next();
|
|
26
|
-
this.trail.
|
|
43
|
+
this.trail.record(
|
|
27
44
|
`${req.method} ${new URL(req.url).pathname} -> ${response.status} ` +
|
|
28
45
|
`(${ctx.controller}.${ctx.handler})`,
|
|
29
46
|
);
|
|
@@ -14,7 +14,7 @@ export class TraceController {
|
|
|
14
14
|
constructor(private readonly context: RequestContext) {}
|
|
15
15
|
|
|
16
16
|
@Get('/')
|
|
17
|
-
current(
|
|
17
|
+
current({ req }: Input<RouteSchemas>): {
|
|
18
18
|
traceId: string | undefined;
|
|
19
19
|
spanId: string | undefined;
|
|
20
20
|
parentSpanId: string | undefined;
|
|
@@ -28,7 +28,7 @@ export class TraceController {
|
|
|
28
28
|
spanId: spanId as string | undefined,
|
|
29
29
|
parentSpanId: parentSpanId as string | undefined,
|
|
30
30
|
traceFlags: traceFlags as string | undefined,
|
|
31
|
-
inbound:
|
|
31
|
+
inbound: req.headers.get('traceparent'),
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -33,11 +33,13 @@ export class JobsController {
|
|
|
33
33
|
constructor(private readonly publisher: JobPublisher) {}
|
|
34
34
|
|
|
35
35
|
@Post('/thumbnails', enqueue)
|
|
36
|
-
async enqueue(
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
async enqueue({ body }: Input<typeof enqueue>): Promise<{
|
|
37
|
+
id: string;
|
|
38
|
+
queue: string;
|
|
39
|
+
state: string;
|
|
40
|
+
}> {
|
|
39
41
|
const job = await this.degrades(() =>
|
|
40
|
-
this.publisher.publish(THUMBNAIL_QUEUE, 'render',
|
|
42
|
+
this.publisher.publish(THUMBNAIL_QUEUE, 'render', body),
|
|
41
43
|
);
|
|
42
44
|
|
|
43
45
|
return {
|
|
@@ -50,24 +52,24 @@ export class JobsController {
|
|
|
50
52
|
/** `returnvalue` is whatever the handler returned, so this is how the web
|
|
51
53
|
* process reads a result computed elsewhere. */
|
|
52
54
|
@Get('/thumbnails/:id', oneJob)
|
|
53
|
-
async status(
|
|
55
|
+
async status({ params }: Input<typeof oneJob>): Promise<{
|
|
54
56
|
id: string;
|
|
55
57
|
state: string;
|
|
56
58
|
result: RenderResult | null;
|
|
57
59
|
failedReason: string | null;
|
|
58
60
|
}> {
|
|
59
61
|
const job = await this.degrades(() =>
|
|
60
|
-
this.publisher.queue(THUMBNAIL_QUEUE).getJob(
|
|
62
|
+
this.publisher.queue(THUMBNAIL_QUEUE).getJob(params.id),
|
|
61
63
|
);
|
|
62
64
|
if (job === undefined) {
|
|
63
65
|
throw new HttpError(
|
|
64
66
|
HttpStatusCode.NOT_FOUND,
|
|
65
|
-
`No job ${
|
|
67
|
+
`No job ${params.id} on "${THUMBNAIL_QUEUE}"`,
|
|
66
68
|
);
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
return {
|
|
70
|
-
id: job.id ??
|
|
72
|
+
id: job.id ?? params.id,
|
|
71
73
|
state: await job.getState(),
|
|
72
74
|
result: (job.returnvalue as RenderResult | null) ?? null,
|
|
73
75
|
failedReason: job.failedReason ?? null,
|
|
@@ -46,7 +46,10 @@ export class NotesController {
|
|
|
46
46
|
return this.notes.rows();
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
//
|
|
49
|
+
// Destructuring at the parameter is the usual shape, and it is what every other
|
|
50
|
+
// handler here does. The whole object has a name when a handler wants to pass it
|
|
51
|
+
// on: `whoami(input: Input<RouteSchemas>)` types the same. No schemas are
|
|
52
|
+
// declared on this route, so `req` is all it carries.
|
|
50
53
|
@ApiDoc({
|
|
51
54
|
summary: 'Echo the caller’s address',
|
|
52
55
|
description:
|
|
@@ -54,12 +57,12 @@ export class NotesController {
|
|
|
54
57
|
deprecated: true,
|
|
55
58
|
})
|
|
56
59
|
@Get('/whoami')
|
|
57
|
-
whoami(
|
|
58
|
-
return { ip: this.address.of(
|
|
60
|
+
whoami({ req }: Input<RouteSchemas>): { ip: string | undefined } {
|
|
61
|
+
return { ip: this.address.of(req) };
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
@Post('/', createNote)
|
|
62
|
-
create(
|
|
63
|
-
return this.notes.add(
|
|
65
|
+
create({ body }: Input<typeof createNote>): readonly string[] {
|
|
66
|
+
return this.notes.add(body.text);
|
|
64
67
|
}
|
|
65
68
|
}
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
import { Logger } from '@dunx/core';
|
|
2
2
|
import type { OnInit } from '@dunx/core';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* The list is capped, and the cap is the point.
|
|
6
|
+
*
|
|
7
|
+
* An in-memory demo store that only ever grows is a leak the moment anyone runs
|
|
8
|
+
* real traffic through it, and this folder is vendored into `@dunx/create-app`'s
|
|
9
|
+
* `notes` feature, so an unbounded array would ship into every scaffold. The soak
|
|
10
|
+
* run found it: 42,000 posts a minute put 250,000 retained strings on the heap and
|
|
11
|
+
* read as a framework leak until the census named them.
|
|
12
|
+
*/
|
|
13
|
+
const KEEP = 200;
|
|
14
|
+
|
|
4
15
|
export class NotesService implements OnInit {
|
|
5
16
|
readonly #rows = ['read the architecture doc', 'measure before deciding'];
|
|
6
17
|
|
|
@@ -16,6 +27,9 @@ export class NotesService implements OnInit {
|
|
|
16
27
|
|
|
17
28
|
add(text: string): readonly string[] {
|
|
18
29
|
this.#rows.push(text);
|
|
30
|
+
if (this.#rows.length > KEEP) {
|
|
31
|
+
this.#rows.splice(0, this.#rows.length - KEEP);
|
|
32
|
+
}
|
|
19
33
|
return this.#rows;
|
|
20
34
|
}
|
|
21
35
|
}
|
|
@@ -33,8 +33,8 @@ export class ImagesController {
|
|
|
33
33
|
|
|
34
34
|
/** Returns the encoded image itself, so a browser renders it inline. */
|
|
35
35
|
@Get('/render', render)
|
|
36
|
-
async render(
|
|
37
|
-
const encoded = await this.thumbnails.render(
|
|
36
|
+
async render({ query }: Input<typeof render>): Promise<Response> {
|
|
37
|
+
const encoded = await this.thumbnails.render(query);
|
|
38
38
|
return new Response(encoded.bytes, {
|
|
39
39
|
headers: {
|
|
40
40
|
'content-type': encoded.mimeType,
|
|
@@ -45,14 +45,14 @@ export class ImagesController {
|
|
|
45
45
|
|
|
46
46
|
/** The same render, described rather than returned - easier to read in swagger. */
|
|
47
47
|
@Get('/metadata', render)
|
|
48
|
-
async metadata(
|
|
48
|
+
async metadata({ query }: Input<typeof render>): Promise<{
|
|
49
49
|
width: number;
|
|
50
50
|
height: number;
|
|
51
51
|
format: string;
|
|
52
52
|
mimeType: string;
|
|
53
53
|
bytes: number;
|
|
54
54
|
}> {
|
|
55
|
-
const encoded = await this.thumbnails.render(
|
|
55
|
+
const encoded = await this.thumbnails.render(query);
|
|
56
56
|
return {
|
|
57
57
|
width: encoded.width,
|
|
58
58
|
height: encoded.height,
|
|
@@ -67,11 +67,11 @@ export class ImagesController {
|
|
|
67
67
|
* is a header read rather than a decode, so a truncated file still answers.
|
|
68
68
|
*/
|
|
69
69
|
@Post('/describe', describe)
|
|
70
|
-
describe(
|
|
70
|
+
describe({ body }: Input<typeof describe>): Promise<{
|
|
71
71
|
width: number;
|
|
72
72
|
height: number;
|
|
73
73
|
format: string;
|
|
74
74
|
}> {
|
|
75
|
-
return this.thumbnails.describe(
|
|
75
|
+
return this.thumbnails.describe(body.base64);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { Counter, Logger } from '@dunx/core';
|
|
2
|
-
import { Cron, Interval, OnceOnBoot } from '@dunx/infra/schedule';
|
|
1
|
+
import { Counter, Gauge, Logger } from '@dunx/core';
|
|
2
|
+
import { Cron, Interval, OnceOnBoot, Overlap } from '@dunx/infra/schedule';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The three schedule decorators on one class, discovered off the prototype chain
|
|
@@ -43,6 +43,44 @@ export class Maintenance {
|
|
|
43
43
|
return this.#compactions.value;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
readonly #slow = new Counter();
|
|
47
|
+
/**
|
|
48
|
+
* A `Gauge`, not `#inFlight += 1`. A compound assignment to a private field in
|
|
49
|
+
* a class that also has a decorated member is a `SyntaxError` in Bun's parser,
|
|
50
|
+
* still on 1.4.2 - which is what the note at the top of this class is about,
|
|
51
|
+
* and which this method walked straight into before it was written this way.
|
|
52
|
+
*/
|
|
53
|
+
readonly #inFlight = new Gauge();
|
|
54
|
+
readonly #peak = new Gauge();
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* `overlap: Overlap.CONCURRENT`, which is the half `skip` hides.
|
|
58
|
+
*
|
|
59
|
+
* The default refuses to start a run while the last one is still going, so a
|
|
60
|
+
* handler that outlives its own cadence quietly runs at the rate it can finish.
|
|
61
|
+
* `concurrent` starts anyway, and `maxInFlight` is how you can tell: triggered
|
|
62
|
+
* twice inside its own sleep it reaches 2, where the sweep above stays at 1.
|
|
63
|
+
*/
|
|
64
|
+
@Interval(600_000, {
|
|
65
|
+
name: 'maintenance.overlapping',
|
|
66
|
+
overlap: Overlap.CONCURRENT,
|
|
67
|
+
})
|
|
68
|
+
async overlappingWork(): Promise<number> {
|
|
69
|
+
this.#inFlight.inc();
|
|
70
|
+
this.#peak.set(Math.max(this.#peak.value, this.#inFlight.value));
|
|
71
|
+
try {
|
|
72
|
+
await Bun.sleep(40);
|
|
73
|
+
this.#slow.inc();
|
|
74
|
+
return this.#slow.value;
|
|
75
|
+
} finally {
|
|
76
|
+
this.#inFlight.dec();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
get overlapping(): { runs: number; maxInFlight: number } {
|
|
81
|
+
return { runs: this.#slow.value, maxInFlight: this.#peak.value };
|
|
82
|
+
}
|
|
83
|
+
|
|
46
84
|
get counts(): { sweeps: number; compactions: number; warmed: boolean } {
|
|
47
85
|
return {
|
|
48
86
|
sweeps: this.#sweeps.value,
|
|
@@ -41,6 +41,18 @@ export class ScheduleDemo {
|
|
|
41
41
|
'neither waited for a clock',
|
|
42
42
|
);
|
|
43
43
|
|
|
44
|
+
// Two triggers inside one run's own sleep. `concurrent` lets the second start,
|
|
45
|
+
// so both are in flight at once; the default would have skipped it.
|
|
46
|
+
await Promise.all([
|
|
47
|
+
this.registry.trigger('maintenance.overlapping'),
|
|
48
|
+
this.registry.trigger('maintenance.overlapping'),
|
|
49
|
+
]);
|
|
50
|
+
const overlapping = this.maintenance.overlapping;
|
|
51
|
+
this.logger.info(
|
|
52
|
+
`overlap: concurrent -> ${overlapping.runs} runs, ` +
|
|
53
|
+
`${overlapping.maxInFlight} in flight at once (skip would hold it at 1)`,
|
|
54
|
+
);
|
|
55
|
+
|
|
44
56
|
const entry = this.registry.get('maintenance.compact');
|
|
45
57
|
this.logger.info(
|
|
46
58
|
`runs recorded on the entry -> ${entry?.runs ?? 0}, lastError ` +
|
|
@@ -40,13 +40,14 @@ export class FilesController {
|
|
|
40
40
|
constructor(private readonly storage: Storage) {}
|
|
41
41
|
|
|
42
42
|
@Get('/', listFiles)
|
|
43
|
-
async list(
|
|
44
|
-
|
|
45
|
-
|
|
43
|
+
async list({ query }: Input<typeof listFiles>): Promise<{
|
|
44
|
+
root: string;
|
|
45
|
+
keys: readonly string[];
|
|
46
|
+
}> {
|
|
46
47
|
const keys: string[] = [];
|
|
47
48
|
for await (const entry of this.storage.list({
|
|
48
|
-
prefix:
|
|
49
|
-
glob:
|
|
49
|
+
prefix: query.prefix,
|
|
50
|
+
glob: query.glob,
|
|
50
51
|
})) {
|
|
51
52
|
keys.push(entry.key);
|
|
52
53
|
}
|
|
@@ -57,10 +58,13 @@ export class FilesController {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
@Get('/object', objectKey)
|
|
60
|
-
async read(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
async read({ query }: Input<typeof objectKey>): Promise<{
|
|
62
|
+
key: string;
|
|
63
|
+
size: number;
|
|
64
|
+
type: string;
|
|
65
|
+
content: string;
|
|
66
|
+
}> {
|
|
67
|
+
const { key } = query;
|
|
64
68
|
await this.present(key);
|
|
65
69
|
const stat = await this.storage.stat(key);
|
|
66
70
|
return {
|
|
@@ -72,12 +76,13 @@ export class FilesController {
|
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
@Put('/object', writeFile)
|
|
75
|
-
async write(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
async write({
|
|
80
|
+
body,
|
|
81
|
+
query,
|
|
82
|
+
}: Input<typeof writeFile>): Promise<{ key: string; bytes: number }> {
|
|
83
|
+
const { key } = query;
|
|
79
84
|
try {
|
|
80
|
-
return { key, bytes: await this.storage.write(key,
|
|
85
|
+
return { key, bytes: await this.storage.write(key, body.content) };
|
|
81
86
|
} catch (error) {
|
|
82
87
|
if (!(error instanceof PathTraversalError)) throw error;
|
|
83
88
|
throw new HttpError(HttpStatusCode.BAD_REQUEST, error.message);
|
|
@@ -85,8 +90,10 @@ export class FilesController {
|
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
@Delete('/object', objectKey)
|
|
88
|
-
async remove(
|
|
89
|
-
|
|
93
|
+
async remove({
|
|
94
|
+
query,
|
|
95
|
+
}: Input<typeof objectKey>): Promise<{ deleted: boolean }> {
|
|
96
|
+
const { key } = query;
|
|
90
97
|
await this.present(key);
|
|
91
98
|
await this.storage.delete(key);
|
|
92
99
|
return { deleted: true };
|
|
@@ -95,8 +102,8 @@ export class FilesController {
|
|
|
95
102
|
/** Nothing signs bytes on a local disk, so this refuses rather than hand back
|
|
96
103
|
* a URL that cannot work. */
|
|
97
104
|
@Get('/presign', objectKey)
|
|
98
|
-
async presign(
|
|
99
|
-
const { key } =
|
|
105
|
+
async presign({ query }: Input<typeof objectKey>): Promise<{ url: string }> {
|
|
106
|
+
const { key } = query;
|
|
100
107
|
await this.present(key);
|
|
101
108
|
try {
|
|
102
109
|
return { url: this.storage.presign(key) };
|
|
@@ -24,21 +24,18 @@ export class UsersController {
|
|
|
24
24
|
// accepted against a schema inferring `User[]` - mutability does not survive
|
|
25
25
|
// serialisation.
|
|
26
26
|
@Get('/', listUsers)
|
|
27
|
-
list(
|
|
28
|
-
return this.users.findAll(
|
|
27
|
+
list({ query }: Input<typeof listUsers>): Promise<readonly User[]> {
|
|
28
|
+
return this.users.findAll(query.limit, query.q);
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
// Only the success status is checked - the 404 in `oneUser.response` leaves via
|
|
32
32
|
// a thrown HttpError, which no return type can describe.
|
|
33
33
|
@Get('/:id', oneUser)
|
|
34
|
-
async one(
|
|
34
|
+
async one({ params }: Input<typeof oneUser>): Promise<User> {
|
|
35
35
|
// Already a number: the params schema coerced it before this ran.
|
|
36
|
-
const user = await this.users.find(
|
|
36
|
+
const user = await this.users.find(params.id);
|
|
37
37
|
if (user === null) {
|
|
38
|
-
throw new HttpError(
|
|
39
|
-
HttpStatusCode.NOT_FOUND,
|
|
40
|
-
`No user ${input.params.id}`,
|
|
41
|
-
);
|
|
38
|
+
throw new HttpError(HttpStatusCode.NOT_FOUND, `No user ${params.id}`);
|
|
42
39
|
}
|
|
43
40
|
return user;
|
|
44
41
|
}
|
|
@@ -46,10 +43,10 @@ export class UsersController {
|
|
|
46
43
|
// No req.json(), no Response.json(), no status - the body arrives validated and
|
|
47
44
|
// typed, and 201 is the POST default.
|
|
48
45
|
@Post('/', createUser)
|
|
49
|
-
create(
|
|
46
|
+
create({ body }: Input<typeof createUser>): Promise<User> {
|
|
50
47
|
return this.users.create(
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
body.name,
|
|
49
|
+
body.tags.map((tag) => tag.label),
|
|
53
50
|
);
|
|
54
51
|
}
|
|
55
52
|
}
|