@orkestrel/scaffold 0.0.19 → 0.0.21
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/dist/bin/scaffold.js +15 -9
- package/dist/bin/scaffold.js.map +1 -1
- package/dist/host/CLAUDE.md +51 -24
- package/dist/host/agents/skills/orkestrel-debrief/SKILL.md +69 -59
- package/dist/host/agents/skills/orkestrel-debrief/references/field-testing.md +2 -2
- package/dist/host/agents/skills/orkestrel-debrief/references/instruction-audit.md +82 -0
- package/dist/host/claude/agents/application.md +34 -0
- package/dist/host/claude/agents/codex.md +7 -0
- package/dist/host/claude/agents/orkestrel.md +3 -3
- package/dist/host/claude/agents/researcher.md +31 -0
- package/dist/host/claude/agents/reviewer.md +5 -0
- package/dist/host/claude/agents/scout.md +25 -0
- package/dist/host/claude/rules/quality.md +10 -0
- package/dist/host/claude/rules/tests.md +10 -0
- package/dist/host/claude/skills/orkestrel-debrief/SKILL.md +1 -1
- package/dist/host/codex/agents/analyst.toml +5 -1
- package/dist/host/codex/agents/application.toml +23 -0
- package/dist/host/codex/agents/researcher.toml +22 -0
- package/dist/host/codex/agents/reviewer.toml +3 -1
- package/dist/host/codex/agents/scout.toml +18 -0
- package/dist/host/codex/config.toml +15 -6
- package/dist/host/guides/src/scaffold.md +120 -29
- package/dist/host/manifest.json +35 -0
- package/dist/host/tests/setupPolicy.ts +49 -11
- package/dist/src/core/index.cjs +1661 -856
- package/dist/src/core/index.cjs.map +1 -1
- package/dist/src/core/index.d.cts +84 -7
- package/dist/src/core/index.d.ts +84 -7
- package/dist/src/core/index.js +1656 -857
- package/dist/src/core/index.js.map +1 -1
- package/package.json +4 -2
package/dist/src/core/index.cjs
CHANGED
|
@@ -229,15 +229,15 @@ var DEFAULT_VERSION = "0.0.1";
|
|
|
229
229
|
/** The `engines.node` range the `blueprint` builder fills. */
|
|
230
230
|
var DEFAULT_ENGINES = `>=${MINIMUM_NODE_VERSION}`;
|
|
231
231
|
/** The devDependency range generated packages pin `@orkestrel/scaffold` at. */
|
|
232
|
-
var SCAFFOLD_RANGE = "^0.0.
|
|
232
|
+
var SCAFFOLD_RANGE = "^0.0.21";
|
|
233
233
|
/** Tooling versions shared by scaffold and every generated workspace. */
|
|
234
234
|
var BASE_DEV_DEPENDENCIES = Object.freeze({
|
|
235
235
|
"@microsoft/api-extractor": "^7.58.12",
|
|
236
236
|
"@orkestrel/guide": "^0.0.8",
|
|
237
237
|
"@orkestrel/scaffold": SCAFFOLD_RANGE,
|
|
238
238
|
"@types/node": "^26.1.2",
|
|
239
|
-
oxfmt: "^0.
|
|
240
|
-
oxlint: "^1.
|
|
239
|
+
oxfmt: "^0.62.0",
|
|
240
|
+
oxlint: "^1.77.0",
|
|
241
241
|
typescript: "^6.0.3",
|
|
242
242
|
vite: "~8.2.0",
|
|
243
243
|
"vite-plugin-dts": "^5.0.3",
|
|
@@ -248,13 +248,23 @@ var SOURCE_BROWSER_DEV_DEPENDENCIES = Object.freeze({
|
|
|
248
248
|
"@vitest/browser-playwright": "^4.1.10",
|
|
249
249
|
playwright: "^1.62.1"
|
|
250
250
|
});
|
|
251
|
+
/** Baseline development dependency required by every private application environment. */
|
|
252
|
+
var APP_DEV_DEPENDENCIES = Object.freeze({ "@orkestrel/contract": "^0.0.9" });
|
|
251
253
|
/** Additional development dependencies required by a private Vue browser application. */
|
|
252
254
|
var APP_BROWSER_DEV_DEPENDENCIES = Object.freeze({
|
|
253
255
|
...SOURCE_BROWSER_DEV_DEPENDENCIES,
|
|
256
|
+
"@orkestrel/html": "^0.0.2",
|
|
254
257
|
"@vitejs/plugin-vue": "^6.0.8",
|
|
255
258
|
vue: "^3.5.40",
|
|
256
259
|
"vue-tsc": "^3.3.7"
|
|
257
260
|
});
|
|
261
|
+
/** Additional development dependencies required by a private server application. */
|
|
262
|
+
var APP_SERVER_DEV_DEPENDENCIES = Object.freeze({
|
|
263
|
+
"@orkestrel/emitter": "^0.0.5",
|
|
264
|
+
"@orkestrel/middleware": "^0.0.9",
|
|
265
|
+
"@orkestrel/router": "^0.0.8",
|
|
266
|
+
"@orkestrel/server": "^0.0.10"
|
|
267
|
+
});
|
|
258
268
|
/** Immutable official actions/checkout v6.0.2 commit used by generated CI. */
|
|
259
269
|
var CHECKOUT_ACTION_SHA = "de0fac2e4500dabe0009e67214ff5f5447ce83dd";
|
|
260
270
|
/** Immutable official actions/setup-node v6.4.0 commit used by generated CI. */
|
|
@@ -528,13 +538,45 @@ function serializeTypeScriptString(value) {
|
|
|
528
538
|
return `${output}'`;
|
|
529
539
|
}
|
|
530
540
|
/**
|
|
541
|
+
* Determine whether an application blueprint spans the shared browser/server boundary.
|
|
542
|
+
*
|
|
543
|
+
* @param spec - The blueprint to inspect.
|
|
544
|
+
* @returns True only when app/core, app/browser, and app/server are all selected.
|
|
545
|
+
*
|
|
546
|
+
* @example
|
|
547
|
+
* ```ts
|
|
548
|
+
* hasApplicationBoundary(blueprint('application', { app: ['core', 'browser', 'server'] }))
|
|
549
|
+
* ```
|
|
550
|
+
*/
|
|
551
|
+
function hasApplicationBoundary(spec) {
|
|
552
|
+
return spec.app.includes("core") && spec.app.includes("browser") && spec.app.includes("server");
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Determine whether an application blueprint emits its browser showcase.
|
|
556
|
+
*
|
|
557
|
+
* @param spec - The blueprint to inspect.
|
|
558
|
+
* @returns True only when showcase intent accompanies app/browser.
|
|
559
|
+
*
|
|
560
|
+
* @example
|
|
561
|
+
* ```ts
|
|
562
|
+
* hasApplicationShowcase(blueprint('application', { app: ['browser'], showcase: true }))
|
|
563
|
+
* ```
|
|
564
|
+
*/
|
|
565
|
+
function hasApplicationShowcase(spec) {
|
|
566
|
+
return spec.showcase && spec.app.includes("browser");
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
531
569
|
* Derive the declared public `Member[]` from a blueprint.
|
|
532
570
|
*
|
|
533
571
|
* @param spec - The blueprint to derive members from.
|
|
534
572
|
* @remarks
|
|
535
573
|
* Published source environments receive the canonical entity/type/factory/constant
|
|
536
574
|
* inventory. Application environments receive their exact public declaration kinds,
|
|
537
|
-
* including parsers, guards, handlers, errors, and runners where present.
|
|
575
|
+
* including parsers, guards, handlers, errors, and runners where present. Two groups
|
|
576
|
+
* move rather than duplicate: the health contract is declared against `app/server`
|
|
577
|
+
* while the server alone reads it and against `app/core` once the browser reads it
|
|
578
|
+
* too, and the showcase seed, factory, and root-view identity appear only for a
|
|
579
|
+
* blueprint whose showcase accompanies `app/browser`.
|
|
538
580
|
* @returns The declared `Member[]`, one set per environment.
|
|
539
581
|
*
|
|
540
582
|
* @example
|
|
@@ -547,6 +589,8 @@ function serializeTypeScriptString(value) {
|
|
|
547
589
|
function blueprintToMembers(spec) {
|
|
548
590
|
const pascal = pascalCase(spec.name);
|
|
549
591
|
const members = [];
|
|
592
|
+
const hasBoundary = hasApplicationBoundary(spec);
|
|
593
|
+
const hasShowcase = hasApplicationShowcase(spec);
|
|
550
594
|
for (const environment of spec.src) {
|
|
551
595
|
members.push(member(pascal, "entity", `The ${pascal} entity.`, environment));
|
|
552
596
|
members.push(member(`${pascal}Options`, "type", `Options for creating a ${pascal}.`, environment));
|
|
@@ -564,15 +608,21 @@ function blueprintToMembers(spec) {
|
|
|
564
608
|
members.push(member("isApplicationError", "guard", "Narrow a caught value to ApplicationError.", "core"));
|
|
565
609
|
members.push(member("parseApplicationName", "parser", "Parse an application name.", "core"));
|
|
566
610
|
members.push(member("createApplication", "factory", "Create an application identity.", "core"));
|
|
611
|
+
if (hasBoundary) members.push(member("ApplicationRecord", "type", "The shared application health record.", "core"), member("APP_HEALTH_METHOD", "constant", "The owned health request method.", "core"), member("APP_HEALTH_PATH", "constant", "The owned health request path.", "core"), member("APP_HEALTH_TIMEOUT", "constant", "The shared health read timeout.", "core"), member("isApplicationRecord", "guard", "Narrow a transport value to the shared record.", "core"), member("readApplicationHealth", "handler", "Read the shared health boundary as the application identity.", "core"));
|
|
567
612
|
}
|
|
568
613
|
if (spec.app.includes("browser")) {
|
|
569
614
|
members.push(member("BrowserApplicationErrorCode", "alias", "A browser application configuration error reason.", "browser"), member("BrowserApplicationErrorContext", "type", "Browser application boundary-failure context.", "browser"), member("BrowserApplicationOptions", "type", "Options for creating the browser application.", "browser"));
|
|
570
615
|
members.push(member("MAX_BROWSER_APPLICATION_NAME_LENGTH", "constant", "The maximum browser application-name length.", "browser"), member("MAX_BROWSER_APPLICATION_NAME_INPUT_LENGTH", "constant", "The maximum raw browser application-name input length.", "browser"), member("BrowserApplicationError", "error", "A browser application configuration error.", "browser"), member("isBrowserApplicationError", "guard", "Narrow a caught value to BrowserApplicationError.", "browser"), member("parseBrowserApplicationOptions", "parser", "Parse browser application options.", "browser"));
|
|
571
616
|
if (!spec.app.includes("core")) members.push(member("APP_NAME", "constant", "The browser application name.", "browser"));
|
|
617
|
+
if (hasShowcase && !spec.app.includes("core")) members.push(member("Application", "type", "The identity the root view renders.", "browser"));
|
|
618
|
+
if (hasShowcase) members.push(member("seedApplication", "factory", "Seed the inert showcase identity.", "browser"));
|
|
572
619
|
members.push(member("createBrowserApplication", "factory", "Create an unmounted Vue application.", "browser"));
|
|
620
|
+
if (hasShowcase) members.push(member("mountShowcaseApplication", "factory", "Mount the seeded showcase.", "browser"));
|
|
621
|
+
if (hasBoundary) members.push(member("mountBrowserApplication", "factory", "Mount the application over its server boundary.", "browser"));
|
|
573
622
|
}
|
|
574
623
|
if (spec.app.includes("server")) {
|
|
575
|
-
|
|
624
|
+
if (!hasBoundary) members.push(member("ApplicationRecord", "type", "The application health record.", "server"));
|
|
625
|
+
members.push(member("ApplicationState", "type", "Per-request application state.", "server"), member("ApplicationServerErrorCode", "alias", "An application server error reason.", "server"), member("ApplicationServerErrorContext", "type", "Application server boundary-failure context.", "server"), member("ApplicationServerOptions", "type", "Options for creating an application server.", "server"), member("ApplicationServerInterface", "type", "The application server lifecycle contract.", "server"), member("ApplicationServerRunnerInterface", "type", "The application server process lifecycle contract.", "server"), member("ApplicationServerRunnerEventMap", "alias", "Observable application server runner outcomes.", "server"), member("ApplicationServerRunnerOptions", "type", "Options for observing an application server runner.", "server"), member("DEFAULT_APP_HOST", "constant", "The loopback host default.", "server"), member("DEFAULT_APP_PORT", "constant", "The application port default.", "server"), member("DEFAULT_APP_START_TIMEOUT", "constant", "The application startup timeout default.", "server"), member("MAX_APP_START_TIMEOUT", "constant", "The maximum application startup timeout.", "server"), member("MAX_APP_HOST_INPUT_LENGTH", "constant", "The maximum raw application-host input length.", "server"), member("MAX_APP_NUMBER_INPUT_LENGTH", "constant", "The maximum raw application numeric input length.", "server"), member("APP_PORT_PATTERN", "constant", "The decimal application-port syntax.", "server"), member("APP_HOST_LABEL_PATTERN", "constant", "The DNS application-host label syntax.", "server"), member("APP_NUMERIC_HOST_PATTERN", "constant", "The ambiguous numeric-host rejection syntax.", "server"), ...hasBoundary ? [] : [member("APP_HEALTH_METHOD", "constant", "The owned health request method.", "server"), member("APP_HEALTH_PATH", "constant", "The owned health request path.", "server")], member("createApplicationDispatcher", "factory", "Create a standalone application route dispatcher.", "server"), member("ApplicationServer", "entity", "The composed application server.", "server"), member("ApplicationServerRunner", "entity", "The application server process lifecycle owner.", "server"), member("ApplicationServerError", "error", "A server configuration or lifecycle error.", "server"), member("isApplicationServerError", "guard", "Narrow a caught value to ApplicationServerError.", "server"), member("parseApplicationHost", "parser", "Parse an application host.", "server"), member("parseApplicationPort", "parser", "Parse an application port.", "server"), member("parseApplicationStartTimeout", "parser", "Parse an application startup timeout.", "server"), member("parseApplicationServerOptions", "parser", "Parse application server options.", "server"), member("handleApplicationHealth", "handler", "Return the application health record.", "server"), member("reportApplicationServerError", "handler", "Report a process-owned failure without exposing diagnostic context.", "server"), member("createApplicationServer", "factory", "Create a stopped application server.", "server"), member("startApplicationServer", "factory", "Start the process-owned application server.", "server"));
|
|
576
626
|
if (!spec.app.includes("core")) members.push(member("APP_NAME", "constant", "The server application name.", "server"));
|
|
577
627
|
}
|
|
578
628
|
return members;
|
|
@@ -1653,6 +1703,34 @@ function renderArray(entries, indent, prefix, suffix) {
|
|
|
1653
1703
|
return `[\n${items.map((item) => `${childIndent}${item}`).join(",\n")}\n${indent}]`;
|
|
1654
1704
|
}
|
|
1655
1705
|
/**
|
|
1706
|
+
* Render a single-quoted TypeScript string array literal through `oxfmt`'s
|
|
1707
|
+
* inline-or-broken rule — inline when the rendered width fits
|
|
1708
|
+
* `JSON_PRINT_WIDTH`, one item per line with a trailing comma on every line
|
|
1709
|
+
* (including the last) otherwise, matching `.oxfmtrc.json`'s
|
|
1710
|
+
* `trailingComma: "all"` for non-JSON files.
|
|
1711
|
+
*
|
|
1712
|
+
* @param entries - The array's string elements, in order.
|
|
1713
|
+
* @param indent - The current indentation prefix.
|
|
1714
|
+
* @param prefix - The text already emitted on this line before the array.
|
|
1715
|
+
* @param suffix - The text that will follow the array on this line.
|
|
1716
|
+
* @returns The rendered array fragment (no trailing newline).
|
|
1717
|
+
*
|
|
1718
|
+
* @example
|
|
1719
|
+
* ```ts
|
|
1720
|
+
* import { renderStringArray } from '@orkestrel/scaffold'
|
|
1721
|
+
*
|
|
1722
|
+
* renderStringArray(['app', 'guides', 'tests'], '', '', '') // "['app', 'guides', 'tests']"
|
|
1723
|
+
* ```
|
|
1724
|
+
*/
|
|
1725
|
+
function renderStringArray(entries, indent, prefix, suffix) {
|
|
1726
|
+
if (entries.length === 0) return "[]";
|
|
1727
|
+
const items = entries.map((entry) => serializeTypeScriptString(entry));
|
|
1728
|
+
const inline = `[${items.join(", ")}]`;
|
|
1729
|
+
if (fitsPrintWidth(`${prefix}${inline}${suffix}`)) return inline;
|
|
1730
|
+
const childIndent = `${indent}\t`;
|
|
1731
|
+
return `[\n${items.map((item) => `${childIndent}${item},`).join("\n")}\n${indent}]`;
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1656
1734
|
* Render a JSON object through `formatJson`'s one-key-per-line rule.
|
|
1657
1735
|
*
|
|
1658
1736
|
* @param entry - The object to render.
|
|
@@ -2895,7 +2973,10 @@ export * from './factories.js'
|
|
|
2895
2973
|
name: "appCoreTypes",
|
|
2896
2974
|
summary: "The host-independent application contract.",
|
|
2897
2975
|
category: "source",
|
|
2898
|
-
placeholders: Object.freeze([
|
|
2976
|
+
placeholders: Object.freeze([Object.freeze({
|
|
2977
|
+
name: "record",
|
|
2978
|
+
description: "The shared health record declared once both hosts read it."
|
|
2979
|
+
})]),
|
|
2899
2980
|
content: `/** A rejected shared application boundary. */
|
|
2900
2981
|
${EXPORT_KEYWORD} type ApplicationErrorCode = 'CONFIG'
|
|
2901
2982
|
|
|
@@ -2909,7 +2990,7 @@ ${EXPORT_KEYWORD} interface ApplicationErrorContext {
|
|
|
2909
2990
|
${EXPORT_KEYWORD} interface Application {
|
|
2910
2991
|
readonly name: string
|
|
2911
2992
|
}
|
|
2912
|
-
`
|
|
2993
|
+
{{record}}`
|
|
2913
2994
|
}),
|
|
2914
2995
|
appCoreConstants: Object.freeze({
|
|
2915
2996
|
id: "appCoreConstants",
|
|
@@ -2919,6 +3000,9 @@ ${EXPORT_KEYWORD} interface Application {
|
|
|
2919
3000
|
placeholders: Object.freeze([Object.freeze({
|
|
2920
3001
|
name: "nameLiteral",
|
|
2921
3002
|
description: "The JSON-serialized application name."
|
|
3003
|
+
}), Object.freeze({
|
|
3004
|
+
name: "health",
|
|
3005
|
+
description: "The shared health route constants declared once both hosts read them."
|
|
2922
3006
|
})]),
|
|
2923
3007
|
content: `/** The application name shared by every host environment. */
|
|
2924
3008
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_NAME = {{nameLiteral}}
|
|
@@ -2928,7 +3012,7 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} MAX_APPLICATION_NAME_LENGTH = 203
|
|
|
2928
3012
|
|
|
2929
3013
|
/** Maximum raw Unicode code units inspected before trimming an application name. */
|
|
2930
3014
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} MAX_APPLICATION_NAME_INPUT_LENGTH = 255
|
|
2931
|
-
`
|
|
3015
|
+
{{health}}`
|
|
2932
3016
|
}),
|
|
2933
3017
|
appCoreErrors: Object.freeze({
|
|
2934
3018
|
id: "appCoreErrors",
|
|
@@ -2937,6 +3021,7 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} MAX_APPLICATION_NAME_INPUT_LENGTH = 255
|
|
|
2937
3021
|
category: "source",
|
|
2938
3022
|
placeholders: Object.freeze([]),
|
|
2939
3023
|
content: `import type { ApplicationErrorCode, ApplicationErrorContext } from './types.js'
|
|
3024
|
+
import { holds } from '@orkestrel/contract'
|
|
2940
3025
|
|
|
2941
3026
|
/** A rejected shared application configuration value. */
|
|
2942
3027
|
${EXPORT_KEYWORD} class ApplicationError extends Error {
|
|
@@ -2965,11 +3050,34 @@ ${EXPORT_KEYWORD} class ApplicationError extends Error {
|
|
|
2965
3050
|
* \`\`\`
|
|
2966
3051
|
*/
|
|
2967
3052
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isApplicationError(value: unknown): value is ApplicationError {
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
3053
|
+
return holds(() => value instanceof ApplicationError)
|
|
3054
|
+
}
|
|
3055
|
+
`
|
|
3056
|
+
}),
|
|
3057
|
+
appCoreValidators: Object.freeze({
|
|
3058
|
+
id: "appCoreValidators",
|
|
3059
|
+
name: "appCoreValidators",
|
|
3060
|
+
summary: "The host-independent guard over the shared health record.",
|
|
3061
|
+
category: "source",
|
|
3062
|
+
placeholders: Object.freeze([]),
|
|
3063
|
+
content: `import type { ApplicationRecord } from './types.js'
|
|
3064
|
+
import { holds, isNonEmptyString, isRecord } from '@orkestrel/contract'
|
|
3065
|
+
|
|
3066
|
+
/**
|
|
3067
|
+
* Narrow one unvalidated transport value to the shared application record.
|
|
3068
|
+
*
|
|
3069
|
+
* @param value - The value read from the health route, before validation.
|
|
3070
|
+
* @returns True only for the exact record both hosts agreed on.
|
|
3071
|
+
*
|
|
3072
|
+
* @example
|
|
3073
|
+
* \`\`\`ts
|
|
3074
|
+
* import { isApplicationRecord } from '@app/core'
|
|
3075
|
+
*
|
|
3076
|
+
* isApplicationRecord({ name: 'example', status: 'ok' }) // true
|
|
3077
|
+
* \`\`\`
|
|
3078
|
+
*/
|
|
3079
|
+
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isApplicationRecord(value: unknown): value is ApplicationRecord {
|
|
3080
|
+
return holds(() => isRecord(value) && isNonEmptyString(value.name) && value.status === 'ok')
|
|
2973
3081
|
}
|
|
2974
3082
|
`
|
|
2975
3083
|
}),
|
|
@@ -2979,7 +3087,8 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isApplicationError(value: unknown): value
|
|
|
2979
3087
|
summary: "The host-independent application value parsers.",
|
|
2980
3088
|
category: "source",
|
|
2981
3089
|
placeholders: Object.freeze([]),
|
|
2982
|
-
content: `import {
|
|
3090
|
+
content: `import { isNonEmptyString } from '@orkestrel/contract'
|
|
3091
|
+
import { MAX_APPLICATION_NAME_INPUT_LENGTH, MAX_APPLICATION_NAME_LENGTH } from './constants.js'
|
|
2983
3092
|
import { ApplicationError } from './errors.js'
|
|
2984
3093
|
|
|
2985
3094
|
/**
|
|
@@ -3001,7 +3110,7 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationName(value: unknown): stri
|
|
|
3001
3110
|
throw new ApplicationError('CONFIG', 'Application name must be a string', { value })
|
|
3002
3111
|
}
|
|
3003
3112
|
const name = value.trim()
|
|
3004
|
-
if (name
|
|
3113
|
+
if (!isNonEmptyString(name) || name.length > MAX_APPLICATION_NAME_LENGTH) {
|
|
3005
3114
|
throw new ApplicationError(
|
|
3006
3115
|
'CONFIG',
|
|
3007
3116
|
\`Application name must contain 1 through \${MAX_APPLICATION_NAME_LENGTH} characters\`,
|
|
@@ -3010,6 +3119,52 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationName(value: unknown): stri
|
|
|
3010
3119
|
}
|
|
3011
3120
|
return name
|
|
3012
3121
|
}
|
|
3122
|
+
`
|
|
3123
|
+
}),
|
|
3124
|
+
appCoreHandlers: Object.freeze({
|
|
3125
|
+
id: "appCoreHandlers",
|
|
3126
|
+
name: "appCoreHandlers",
|
|
3127
|
+
summary: "The host-independent read of the shared health boundary.",
|
|
3128
|
+
category: "source",
|
|
3129
|
+
placeholders: Object.freeze([]),
|
|
3130
|
+
content: `import type { Application } from './types.js'
|
|
3131
|
+
import { APP_HEALTH_PATH, APP_HEALTH_TIMEOUT } from './constants.js'
|
|
3132
|
+
import { isApplicationRecord } from './validators.js'
|
|
3133
|
+
|
|
3134
|
+
/**
|
|
3135
|
+
* Read the application server's health route and translate its unvalidated JSON
|
|
3136
|
+
* into the shared application identity.
|
|
3137
|
+
*
|
|
3138
|
+
* @param origin - The absolute origin serving the application health route.
|
|
3139
|
+
* @returns The identity the running server reported, or undefined when the boundary
|
|
3140
|
+
* is unreachable, too slow, or off-contract.
|
|
3141
|
+
*
|
|
3142
|
+
* @remarks
|
|
3143
|
+
* The single translation point between the two hosts: the response body is read as
|
|
3144
|
+
* \`unknown\` and narrowed by {@link isApplicationRecord} before any field is consumed,
|
|
3145
|
+
* so a missing, slow, or foreign server degrades to \`undefined\` instead of leaking an
|
|
3146
|
+
* unvalidated value into the application. The record's \`status\` proves liveness; the
|
|
3147
|
+
* identity is what the caller renders.
|
|
3148
|
+
*
|
|
3149
|
+
* @example
|
|
3150
|
+
* \`\`\`ts
|
|
3151
|
+
* import { readApplicationHealth } from '@app/core'
|
|
3152
|
+
*
|
|
3153
|
+
* await readApplicationHealth('http://127.0.0.1:3000') // { name: 'example' }
|
|
3154
|
+
* \`\`\`
|
|
3155
|
+
*/
|
|
3156
|
+
${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD} readApplicationHealth(origin: string): Promise<Application | undefined> {
|
|
3157
|
+
try {
|
|
3158
|
+
const response = await fetch(new URL(APP_HEALTH_PATH, origin), {
|
|
3159
|
+
signal: AbortSignal.timeout(APP_HEALTH_TIMEOUT),
|
|
3160
|
+
})
|
|
3161
|
+
if (!response.ok) return undefined
|
|
3162
|
+
const record: unknown = await response.json()
|
|
3163
|
+
return isApplicationRecord(record) ? Object.freeze({ name: record.name }) : undefined
|
|
3164
|
+
} catch {
|
|
3165
|
+
return undefined
|
|
3166
|
+
}
|
|
3167
|
+
}
|
|
3013
3168
|
`
|
|
3014
3169
|
}),
|
|
3015
3170
|
appCoreFactories: Object.freeze({
|
|
@@ -3045,12 +3200,18 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} createApplication(name: string = APP_NAME)
|
|
|
3045
3200
|
name: "appCoreIndex",
|
|
3046
3201
|
summary: "The application core barrel.",
|
|
3047
3202
|
category: "source",
|
|
3048
|
-
placeholders: Object.freeze([
|
|
3203
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3204
|
+
name: "validators",
|
|
3205
|
+
description: "The optional shared-record guard barrel row."
|
|
3206
|
+
}), Object.freeze({
|
|
3207
|
+
name: "handlers",
|
|
3208
|
+
description: "The optional shared health-boundary barrel row."
|
|
3209
|
+
})]),
|
|
3049
3210
|
content: `export * from './types.js'
|
|
3050
3211
|
export * from './constants.js'
|
|
3051
3212
|
export * from './errors.js'
|
|
3052
|
-
export * from './parsers.js'
|
|
3053
|
-
export * from './factories.js'
|
|
3213
|
+
{{validators}}export * from './parsers.js'
|
|
3214
|
+
{{handlers}}export * from './factories.js'
|
|
3054
3215
|
`
|
|
3055
3216
|
}),
|
|
3056
3217
|
appBrowserTypes: Object.freeze({
|
|
@@ -3058,7 +3219,10 @@ export * from './factories.js'
|
|
|
3058
3219
|
name: "appBrowserTypes",
|
|
3059
3220
|
summary: "The browser application options.",
|
|
3060
3221
|
category: "source",
|
|
3061
|
-
placeholders: Object.freeze([
|
|
3222
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3223
|
+
name: "application",
|
|
3224
|
+
description: "The browser-owned root-view identity declared without application core."
|
|
3225
|
+
})]),
|
|
3062
3226
|
content: `/** A rejected browser application boundary. */
|
|
3063
3227
|
${EXPORT_KEYWORD} type BrowserApplicationErrorCode = 'CONFIG'
|
|
3064
3228
|
|
|
@@ -3072,7 +3236,7 @@ ${EXPORT_KEYWORD} interface BrowserApplicationErrorContext {
|
|
|
3072
3236
|
${EXPORT_KEYWORD} interface BrowserApplicationOptions {
|
|
3073
3237
|
readonly name?: string
|
|
3074
3238
|
}
|
|
3075
|
-
`
|
|
3239
|
+
{{application}}`
|
|
3076
3240
|
}),
|
|
3077
3241
|
appBrowserConstants: Object.freeze({
|
|
3078
3242
|
id: "appBrowserConstants",
|
|
@@ -3097,6 +3261,7 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} MAX_BROWSER_APPLICATION_NAME_INPUT_LENGTH = 2
|
|
|
3097
3261
|
category: "source",
|
|
3098
3262
|
placeholders: Object.freeze([]),
|
|
3099
3263
|
content: `import type { BrowserApplicationErrorCode, BrowserApplicationErrorContext } from './types.js'
|
|
3264
|
+
import { holds } from '@orkestrel/contract'
|
|
3100
3265
|
|
|
3101
3266
|
/** A rejected browser application configuration value. */
|
|
3102
3267
|
${EXPORT_KEYWORD} class BrowserApplicationError extends Error {
|
|
@@ -3129,11 +3294,7 @@ ${EXPORT_KEYWORD} class BrowserApplicationError extends Error {
|
|
|
3129
3294
|
* \`\`\`
|
|
3130
3295
|
*/
|
|
3131
3296
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isBrowserApplicationError(value: unknown): value is BrowserApplicationError {
|
|
3132
|
-
|
|
3133
|
-
return value instanceof BrowserApplicationError
|
|
3134
|
-
} catch {
|
|
3135
|
-
return false
|
|
3136
|
-
}
|
|
3297
|
+
return holds(() => value instanceof BrowserApplicationError)
|
|
3137
3298
|
}
|
|
3138
3299
|
`
|
|
3139
3300
|
}),
|
|
@@ -3144,6 +3305,7 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isBrowserApplicationError(value: unknown):
|
|
|
3144
3305
|
category: "source",
|
|
3145
3306
|
placeholders: Object.freeze([]),
|
|
3146
3307
|
content: `import type { BrowserApplicationOptions } from './types.js'
|
|
3308
|
+
import { isNonEmptyString } from '@orkestrel/contract'
|
|
3147
3309
|
import {
|
|
3148
3310
|
MAX_BROWSER_APPLICATION_NAME_INPUT_LENGTH,
|
|
3149
3311
|
MAX_BROWSER_APPLICATION_NAME_LENGTH,
|
|
@@ -3180,6 +3342,7 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseBrowserApplicationOptions(value: unkn
|
|
|
3180
3342
|
{ value },
|
|
3181
3343
|
)
|
|
3182
3344
|
}
|
|
3345
|
+
// Walk own descriptors directly: reading values through a getter would run caller code.
|
|
3183
3346
|
const keys = Reflect.ownKeys(value)
|
|
3184
3347
|
if (keys.some((key) => key !== 'name')) {
|
|
3185
3348
|
throw new BrowserApplicationError('CONFIG', 'Unknown browser application option', {
|
|
@@ -3204,7 +3367,7 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseBrowserApplicationOptions(value: unkn
|
|
|
3204
3367
|
})
|
|
3205
3368
|
}
|
|
3206
3369
|
const name = descriptor.value.trim()
|
|
3207
|
-
if (name
|
|
3370
|
+
if (!isNonEmptyString(name) || name.length > MAX_BROWSER_APPLICATION_NAME_LENGTH) {
|
|
3208
3371
|
throw new BrowserApplicationError(
|
|
3209
3372
|
'CONFIG',
|
|
3210
3373
|
\`Browser application name must contain 1 through \${MAX_BROWSER_APPLICATION_NAME_LENGTH} characters\`,
|
|
@@ -3221,22 +3384,73 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseBrowserApplicationOptions(value: unkn
|
|
|
3221
3384
|
}
|
|
3222
3385
|
`
|
|
3223
3386
|
}),
|
|
3224
|
-
|
|
3225
|
-
id: "
|
|
3226
|
-
name: "
|
|
3227
|
-
summary: "The
|
|
3387
|
+
appBrowserSeeders: Object.freeze({
|
|
3388
|
+
id: "appBrowserSeeders",
|
|
3389
|
+
name: "appBrowserSeeders",
|
|
3390
|
+
summary: "The frozen, inert identity the showcase renders.",
|
|
3228
3391
|
category: "source",
|
|
3229
3392
|
placeholders: Object.freeze([Object.freeze({
|
|
3393
|
+
name: "applicationImport",
|
|
3394
|
+
description: "The selected layer type import for the root-view identity."
|
|
3395
|
+
}), Object.freeze({
|
|
3230
3396
|
name: "nameImport",
|
|
3231
3397
|
description: "The selected layer import for APP_NAME."
|
|
3232
3398
|
})]),
|
|
3399
|
+
content: `{{applicationImport}}
|
|
3400
|
+
{{nameImport}}
|
|
3401
|
+
|
|
3402
|
+
/**
|
|
3403
|
+
* Seed the inert identity the showcase renders.
|
|
3404
|
+
*
|
|
3405
|
+
* @returns A fresh frozen identity, identical on every call.
|
|
3406
|
+
*
|
|
3407
|
+
* @remarks
|
|
3408
|
+
* The showcase's only data. It is exactly the value the shipped root view receives from
|
|
3409
|
+
* the running application, so the showcase exercises the shipped view rather than a
|
|
3410
|
+
* parallel copy of it.
|
|
3411
|
+
*
|
|
3412
|
+
* @example
|
|
3413
|
+
* \`\`\`ts
|
|
3414
|
+
* import { seedApplication } from '@app/browser'
|
|
3415
|
+
*
|
|
3416
|
+
* seedApplication().name // the seeded showcase identity
|
|
3417
|
+
* \`\`\`
|
|
3418
|
+
*/
|
|
3419
|
+
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} seedApplication(): Application {
|
|
3420
|
+
return Object.freeze({ name: \`\${APP_NAME} showcase\` })
|
|
3421
|
+
}
|
|
3422
|
+
`
|
|
3423
|
+
}),
|
|
3424
|
+
appBrowserFactories: Object.freeze({
|
|
3425
|
+
id: "appBrowserFactories",
|
|
3426
|
+
name: "appBrowserFactories",
|
|
3427
|
+
summary: "The Vue browser application factory.",
|
|
3428
|
+
category: "source",
|
|
3429
|
+
placeholders: Object.freeze([
|
|
3430
|
+
Object.freeze({
|
|
3431
|
+
name: "nameImport",
|
|
3432
|
+
description: "The selected layer import for APP_NAME."
|
|
3433
|
+
}),
|
|
3434
|
+
Object.freeze({
|
|
3435
|
+
name: "seedImport",
|
|
3436
|
+
description: "The optional showcase seeder import."
|
|
3437
|
+
}),
|
|
3438
|
+
Object.freeze({
|
|
3439
|
+
name: "showcase",
|
|
3440
|
+
description: "The optional seeded showcase factory."
|
|
3441
|
+
}),
|
|
3442
|
+
Object.freeze({
|
|
3443
|
+
name: "boundary",
|
|
3444
|
+
description: "The optional server-boundary startup factory."
|
|
3445
|
+
})
|
|
3446
|
+
]),
|
|
3233
3447
|
content: `import type { App } from 'vue'
|
|
3234
3448
|
import type { BrowserApplicationOptions } from './types.js'
|
|
3235
3449
|
import { createApp } from 'vue'
|
|
3236
3450
|
import ApplicationView from './ApplicationView.vue'
|
|
3237
3451
|
{{nameImport}}
|
|
3238
3452
|
import { parseBrowserApplicationOptions } from './parsers.js'
|
|
3239
|
-
|
|
3453
|
+
{{seedImport}}
|
|
3240
3454
|
/**
|
|
3241
3455
|
* Create an unmounted Vue application.
|
|
3242
3456
|
*
|
|
@@ -3254,19 +3468,22 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} createBrowserApplication(options: BrowserA
|
|
|
3254
3468
|
const parsed = parseBrowserApplicationOptions(options)
|
|
3255
3469
|
return createApp(ApplicationView, { name: parsed.name ?? APP_NAME })
|
|
3256
3470
|
}
|
|
3257
|
-
`
|
|
3471
|
+
{{showcase}}{{boundary}}`
|
|
3258
3472
|
}),
|
|
3259
3473
|
appBrowserIndex: Object.freeze({
|
|
3260
3474
|
id: "appBrowserIndex",
|
|
3261
3475
|
name: "appBrowserIndex",
|
|
3262
3476
|
summary: "The browser application barrel.",
|
|
3263
3477
|
category: "source",
|
|
3264
|
-
placeholders: Object.freeze([
|
|
3478
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3479
|
+
name: "seeders",
|
|
3480
|
+
description: "The optional showcase seeder barrel row."
|
|
3481
|
+
})]),
|
|
3265
3482
|
content: `export * from './types.js'
|
|
3266
3483
|
export * from './constants.js'
|
|
3267
3484
|
export * from './errors.js'
|
|
3268
3485
|
export * from './parsers.js'
|
|
3269
|
-
export * from './factories.js'
|
|
3486
|
+
{{seeders}}export * from './factories.js'
|
|
3270
3487
|
`
|
|
3271
3488
|
}),
|
|
3272
3489
|
appBrowserMain: Object.freeze({
|
|
@@ -3274,10 +3491,27 @@ export * from './factories.js'
|
|
|
3274
3491
|
name: "appBrowserMain",
|
|
3275
3492
|
summary: "The browser executable entry.",
|
|
3276
3493
|
category: "source",
|
|
3494
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3495
|
+
name: "factory",
|
|
3496
|
+
description: "The factory the shipped entry mounts through."
|
|
3497
|
+
}), Object.freeze({
|
|
3498
|
+
name: "mount",
|
|
3499
|
+
description: "The mounting statement."
|
|
3500
|
+
})]),
|
|
3501
|
+
content: `import { {{factory}} } from './index.js'
|
|
3502
|
+
|
|
3503
|
+
{{mount}}
|
|
3504
|
+
`
|
|
3505
|
+
}),
|
|
3506
|
+
appBrowserShowcase: Object.freeze({
|
|
3507
|
+
id: "appBrowserShowcase",
|
|
3508
|
+
name: "appBrowserShowcase",
|
|
3509
|
+
summary: "The showcase executable entry.",
|
|
3510
|
+
category: "source",
|
|
3277
3511
|
placeholders: Object.freeze([]),
|
|
3278
|
-
content: `import {
|
|
3512
|
+
content: `import { mountShowcaseApplication } from './index.js'
|
|
3279
3513
|
|
|
3280
|
-
|
|
3514
|
+
mountShowcaseApplication('#app')
|
|
3281
3515
|
`
|
|
3282
3516
|
}),
|
|
3283
3517
|
appBrowserView: Object.freeze({
|
|
@@ -3322,6 +3556,33 @@ defineProps<{ readonly name: string }>()
|
|
|
3322
3556
|
<script type="module" src="/main.ts"><\/script>
|
|
3323
3557
|
</body>
|
|
3324
3558
|
</html>
|
|
3559
|
+
`
|
|
3560
|
+
}),
|
|
3561
|
+
appBrowserShowcaseHtml: Object.freeze({
|
|
3562
|
+
id: "appBrowserShowcaseHtml",
|
|
3563
|
+
name: "appBrowserShowcaseHtml",
|
|
3564
|
+
summary: "The showcase HTML entry with its development security policy.",
|
|
3565
|
+
category: "source",
|
|
3566
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3567
|
+
name: "name",
|
|
3568
|
+
description: "The application name."
|
|
3569
|
+
})]),
|
|
3570
|
+
content: `<!doctype html>
|
|
3571
|
+
<html lang="en">
|
|
3572
|
+
<head>
|
|
3573
|
+
<meta
|
|
3574
|
+
http-equiv="Content-Security-Policy"
|
|
3575
|
+
content="default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src data:; font-src data:; script-src-attr 'none'"
|
|
3576
|
+
/>
|
|
3577
|
+
<meta charset="UTF-8" />
|
|
3578
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
3579
|
+
<title>{{name}} showcase</title>
|
|
3580
|
+
</head>
|
|
3581
|
+
<body>
|
|
3582
|
+
<div id="app"></div>
|
|
3583
|
+
<script type="module" src="/showcase.ts"><\/script>
|
|
3584
|
+
</body>
|
|
3585
|
+
</html>
|
|
3325
3586
|
`
|
|
3326
3587
|
}),
|
|
3327
3588
|
appBrowserEnv: Object.freeze({
|
|
@@ -3345,8 +3606,20 @@ declare module '*.vue' {
|
|
|
3345
3606
|
name: "appServerTypes",
|
|
3346
3607
|
summary: "The application server contract.",
|
|
3347
3608
|
category: "source",
|
|
3348
|
-
placeholders: Object.freeze([
|
|
3349
|
-
|
|
3609
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3610
|
+
name: "record",
|
|
3611
|
+
description: "The health record, declared here only while the server alone reads it."
|
|
3612
|
+
})]),
|
|
3613
|
+
content: `import type { EmitterErrorHandler, EmitterHooks, EmitterInterface } from '@orkestrel/emitter'
|
|
3614
|
+
import type { IdentifierState } from '@orkestrel/middleware'
|
|
3615
|
+
import type { ConnectionInfo, ServerStatus } from '@orkestrel/server'
|
|
3616
|
+
|
|
3617
|
+
{{record}}/** Per-request application state derived from connection facts. */
|
|
3618
|
+
${EXPORT_KEYWORD} interface ApplicationState extends IdentifierState {
|
|
3619
|
+
readonly connection: ConnectionInfo
|
|
3620
|
+
}
|
|
3621
|
+
|
|
3622
|
+
/** A rejected application server boundary. */
|
|
3350
3623
|
${EXPORT_KEYWORD} type ApplicationServerErrorCode = 'CONFIG' | 'LIFECYCLE'
|
|
3351
3624
|
|
|
3352
3625
|
/** Diagnostic context attached to an application server boundary error. */
|
|
@@ -3357,26 +3630,49 @@ ${EXPORT_KEYWORD} interface ApplicationServerErrorContext {
|
|
|
3357
3630
|
|
|
3358
3631
|
/** Options for creating an application server. */
|
|
3359
3632
|
${EXPORT_KEYWORD} interface ApplicationServerOptions {
|
|
3360
|
-
readonly
|
|
3361
|
-
|
|
3362
|
-
|
|
3633
|
+
readonly server?: {
|
|
3634
|
+
readonly host?: string
|
|
3635
|
+
readonly port?: number
|
|
3636
|
+
readonly timeout?: number
|
|
3637
|
+
}
|
|
3363
3638
|
}
|
|
3364
3639
|
|
|
3365
3640
|
/** A lifecycle-safe application server. */
|
|
3366
3641
|
${EXPORT_KEYWORD} interface ApplicationServerInterface {
|
|
3367
3642
|
readonly host: string
|
|
3368
|
-
readonly port: number
|
|
3369
|
-
readonly
|
|
3370
|
-
readonly url: string
|
|
3643
|
+
readonly port: number | undefined
|
|
3644
|
+
readonly status: ServerStatus
|
|
3645
|
+
readonly url: string | undefined
|
|
3371
3646
|
start(signal?: AbortSignal): Promise<void>
|
|
3372
3647
|
stop(): Promise<void>
|
|
3648
|
+
destroy(): Promise<void>
|
|
3649
|
+
}
|
|
3650
|
+
|
|
3651
|
+
/** Observable process-lifecycle outcomes for an application server runner. */
|
|
3652
|
+
${EXPORT_KEYWORD} type ApplicationServerRunnerEventMap = {
|
|
3653
|
+
readonly ready: readonly [url: string]
|
|
3654
|
+
readonly fail: readonly [error: unknown]
|
|
3373
3655
|
}
|
|
3374
3656
|
|
|
3375
3657
|
/** The process lifecycle owner for an application server. */
|
|
3376
3658
|
${EXPORT_KEYWORD} interface ApplicationServerRunnerInterface {
|
|
3659
|
+
readonly emitter: EmitterInterface<ApplicationServerRunnerEventMap>
|
|
3377
3660
|
start(): void
|
|
3378
3661
|
stop(): Promise<void>
|
|
3379
3662
|
}
|
|
3663
|
+
|
|
3664
|
+
/**
|
|
3665
|
+
* Options for observing an application server runner.
|
|
3666
|
+
*
|
|
3667
|
+
* @remarks
|
|
3668
|
+
* Initial \`on\` hooks run before the runner's own readiness and failure effects. When no earlier
|
|
3669
|
+
* failure set an exit code, a synchronous fail hook therefore observes \`process.exitCode\` as
|
|
3670
|
+
* \`undefined\` before the default reporter sets it to \`1\`.
|
|
3671
|
+
*/
|
|
3672
|
+
${EXPORT_KEYWORD} interface ApplicationServerRunnerOptions {
|
|
3673
|
+
readonly on?: EmitterHooks<ApplicationServerRunnerEventMap>
|
|
3674
|
+
readonly error?: EmitterErrorHandler
|
|
3675
|
+
}
|
|
3380
3676
|
`
|
|
3381
3677
|
}),
|
|
3382
3678
|
appServerConstants: Object.freeze({
|
|
@@ -3387,6 +3683,9 @@ ${EXPORT_KEYWORD} interface ApplicationServerRunnerInterface {
|
|
|
3387
3683
|
placeholders: Object.freeze([Object.freeze({
|
|
3388
3684
|
name: "nameConstant",
|
|
3389
3685
|
description: "The optional server-only APP_NAME declaration."
|
|
3686
|
+
}), Object.freeze({
|
|
3687
|
+
name: "health",
|
|
3688
|
+
description: "The health route constants, declared here only while the server alone reads them."
|
|
3390
3689
|
})]),
|
|
3391
3690
|
content: `{{nameConstant}}/** The fail-closed loopback host default. */
|
|
3392
3691
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} DEFAULT_APP_HOST = '127.0.0.1'
|
|
@@ -3406,24 +3705,6 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} MAX_APP_HOST_INPUT_LENGTH = 255
|
|
|
3406
3705
|
/** Maximum raw characters inspected at an application numeric boundary. */
|
|
3407
3706
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} MAX_APP_NUMBER_INPUT_LENGTH = 32
|
|
3408
3707
|
|
|
3409
|
-
/** Maximum simultaneous connections accepted by the generated server. */
|
|
3410
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_MAX_CONNECTIONS = 16
|
|
3411
|
-
|
|
3412
|
-
/** Maximum request headers accepted before Node rejects the request. */
|
|
3413
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_MAX_HEADERS = 100
|
|
3414
|
-
|
|
3415
|
-
/** Maximum milliseconds allowed to receive complete request headers. */
|
|
3416
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEADERS_TIMEOUT = 10_000
|
|
3417
|
-
|
|
3418
|
-
/** Maximum milliseconds allowed for one complete request. */
|
|
3419
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_REQUEST_TIMEOUT = 30_000
|
|
3420
|
-
|
|
3421
|
-
/** Idle keep-alive milliseconds before a connection is closed. */
|
|
3422
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_KEEP_ALIVE_TIMEOUT = 5_000
|
|
3423
|
-
|
|
3424
|
-
/** Maximum requests served through one keep-alive connection. */
|
|
3425
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_MAX_REQUESTS_PER_SOCKET = 100
|
|
3426
|
-
|
|
3427
3708
|
/** The decimal-only syntax accepted at the APP_PORT string boundary. */
|
|
3428
3709
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_PORT_PATTERN = /^\\d+$/
|
|
3429
3710
|
|
|
@@ -3432,13 +3713,7 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HOST_LABEL_PATTERN = /^[A-Za-z0-9](?:[A-Z
|
|
|
3432
3713
|
|
|
3433
3714
|
/** Numeric-looking non-IP hosts rejected before platform DNS interpretation. */
|
|
3434
3715
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_NUMERIC_HOST_PATTERN = /^[0-9.]+$/
|
|
3435
|
-
|
|
3436
|
-
/** The only HTTP method owned by the application health route. */
|
|
3437
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEALTH_METHOD = 'GET'
|
|
3438
|
-
|
|
3439
|
-
/** The only HTTP path owned by the generated application server. */
|
|
3440
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEALTH_PATH = '/'
|
|
3441
|
-
`
|
|
3716
|
+
{{health}}`
|
|
3442
3717
|
}),
|
|
3443
3718
|
appServerErrors: Object.freeze({
|
|
3444
3719
|
id: "appServerErrors",
|
|
@@ -3447,6 +3722,7 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEALTH_PATH = '/'
|
|
|
3447
3722
|
category: "source",
|
|
3448
3723
|
placeholders: Object.freeze([]),
|
|
3449
3724
|
content: `import type { ApplicationServerErrorCode, ApplicationServerErrorContext } from './types.js'
|
|
3725
|
+
import { holds } from '@orkestrel/contract'
|
|
3450
3726
|
|
|
3451
3727
|
/** A rejected application server configuration or lifecycle operation. */
|
|
3452
3728
|
${EXPORT_KEYWORD} class ApplicationServerError extends Error {
|
|
@@ -3479,11 +3755,7 @@ ${EXPORT_KEYWORD} class ApplicationServerError extends Error {
|
|
|
3479
3755
|
* \`\`\`
|
|
3480
3756
|
*/
|
|
3481
3757
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isApplicationServerError(value: unknown): value is ApplicationServerError {
|
|
3482
|
-
|
|
3483
|
-
return value instanceof ApplicationServerError
|
|
3484
|
-
} catch {
|
|
3485
|
-
return false
|
|
3486
|
-
}
|
|
3758
|
+
return holds(() => value instanceof ApplicationServerError)
|
|
3487
3759
|
}
|
|
3488
3760
|
`
|
|
3489
3761
|
}),
|
|
@@ -3494,6 +3766,7 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isApplicationServerError(value: unknown):
|
|
|
3494
3766
|
category: "source",
|
|
3495
3767
|
placeholders: Object.freeze([]),
|
|
3496
3768
|
content: `import type { ApplicationServerOptions } from './types.js'
|
|
3769
|
+
import { isNonEmptyString, parseString } from '@orkestrel/contract'
|
|
3497
3770
|
import { isIP } from 'node:net'
|
|
3498
3771
|
import {
|
|
3499
3772
|
APP_HOST_LABEL_PATTERN,
|
|
@@ -3523,7 +3796,7 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationHost(value: unknown): stri
|
|
|
3523
3796
|
const host = value.trim()
|
|
3524
3797
|
const family = isIP(host)
|
|
3525
3798
|
if (
|
|
3526
|
-
host
|
|
3799
|
+
!isNonEmptyString(host) ||
|
|
3527
3800
|
host.length > 253 ||
|
|
3528
3801
|
(family === 0 &&
|
|
3529
3802
|
(APP_NUMERIC_HOST_PATTERN.test(host) ||
|
|
@@ -3545,18 +3818,14 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationHost(value: unknown): stri
|
|
|
3545
3818
|
*/
|
|
3546
3819
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationPort(value: unknown): number {
|
|
3547
3820
|
if (value === undefined) return DEFAULT_APP_PORT
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
value,
|
|
3551
|
-
})
|
|
3552
|
-
}
|
|
3553
|
-
if (typeof value === 'string' && value.length > MAX_APP_NUMBER_INPUT_LENGTH) {
|
|
3821
|
+
const text = parseString(value)
|
|
3822
|
+
if (text === undefined || text.length > MAX_APP_NUMBER_INPUT_LENGTH) {
|
|
3554
3823
|
throw new ApplicationServerError('CONFIG', 'APP_PORT must be an integer from 0 through 65535', {
|
|
3555
3824
|
value,
|
|
3556
3825
|
})
|
|
3557
3826
|
}
|
|
3558
|
-
const
|
|
3559
|
-
const port = APP_PORT_PATTERN.test(
|
|
3827
|
+
const normalized = text.trim()
|
|
3828
|
+
const port = APP_PORT_PATTERN.test(normalized) ? Number(normalized) : Number.NaN
|
|
3560
3829
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
3561
3830
|
throw new ApplicationServerError('CONFIG', 'APP_PORT must be an integer from 0 through 65535', {
|
|
3562
3831
|
value,
|
|
@@ -3574,22 +3843,16 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationPort(value: unknown): numb
|
|
|
3574
3843
|
*/
|
|
3575
3844
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationStartTimeout(value: unknown): number {
|
|
3576
3845
|
if (value === undefined) return DEFAULT_APP_START_TIMEOUT
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
'CONFIG',
|
|
3580
|
-
\`APP_START_TIMEOUT must be an integer from 1 through \${MAX_APP_START_TIMEOUT}\`,
|
|
3581
|
-
{ value },
|
|
3582
|
-
)
|
|
3583
|
-
}
|
|
3584
|
-
if (typeof value === 'string' && value.length > MAX_APP_NUMBER_INPUT_LENGTH) {
|
|
3846
|
+
const text = parseString(value)
|
|
3847
|
+
if (text === undefined || text.length > MAX_APP_NUMBER_INPUT_LENGTH) {
|
|
3585
3848
|
throw new ApplicationServerError(
|
|
3586
3849
|
'CONFIG',
|
|
3587
3850
|
\`APP_START_TIMEOUT must be an integer from 1 through \${MAX_APP_START_TIMEOUT}\`,
|
|
3588
3851
|
{ value },
|
|
3589
3852
|
)
|
|
3590
3853
|
}
|
|
3591
|
-
const
|
|
3592
|
-
const timeout = APP_PORT_PATTERN.test(
|
|
3854
|
+
const normalized = text.trim()
|
|
3855
|
+
const timeout = APP_PORT_PATTERN.test(normalized) ? Number(normalized) : Number.NaN
|
|
3593
3856
|
if (!Number.isInteger(timeout) || timeout < 1 || timeout > MAX_APP_START_TIMEOUT) {
|
|
3594
3857
|
throw new ApplicationServerError(
|
|
3595
3858
|
'CONFIG',
|
|
@@ -3625,24 +3888,58 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationServerOptions(value: unkno
|
|
|
3625
3888
|
)
|
|
3626
3889
|
}
|
|
3627
3890
|
const keys = Reflect.ownKeys(value)
|
|
3628
|
-
const unknown = keys.filter((key) => key !== '
|
|
3891
|
+
const unknown = keys.filter((key) => key !== 'server')
|
|
3629
3892
|
if (unknown.length > 0) {
|
|
3630
3893
|
throw new ApplicationServerError('CONFIG', 'Unknown application server option', { value })
|
|
3631
3894
|
}
|
|
3632
|
-
const
|
|
3633
|
-
|
|
3634
|
-
|
|
3895
|
+
const serverDescriptor = Reflect.getOwnPropertyDescriptor(value, 'server')
|
|
3896
|
+
if (
|
|
3897
|
+
keys.includes('server') &&
|
|
3898
|
+
(serverDescriptor === undefined || !Reflect.has(serverDescriptor, 'value'))
|
|
3899
|
+
) {
|
|
3900
|
+
throw new ApplicationServerError(
|
|
3901
|
+
'CONFIG',
|
|
3902
|
+
'Application server options must use data properties',
|
|
3903
|
+
{ value },
|
|
3904
|
+
)
|
|
3905
|
+
}
|
|
3906
|
+
const server = serverDescriptor?.value
|
|
3907
|
+
if (server === undefined) return {}
|
|
3908
|
+
if (typeof server !== 'object' || server === null || Array.isArray(server)) {
|
|
3909
|
+
throw new ApplicationServerError('CONFIG', 'Application server settings must be an object', {
|
|
3910
|
+
value,
|
|
3911
|
+
})
|
|
3912
|
+
}
|
|
3913
|
+
const serverPrototype = Reflect.getPrototypeOf(server)
|
|
3914
|
+
if (serverPrototype !== Object.prototype && serverPrototype !== null) {
|
|
3915
|
+
throw new ApplicationServerError(
|
|
3916
|
+
'CONFIG',
|
|
3917
|
+
'Application server settings must be a plain record',
|
|
3918
|
+
{ value },
|
|
3919
|
+
)
|
|
3920
|
+
}
|
|
3921
|
+
// Walk own descriptors directly: reading values through a getter would run caller code.
|
|
3922
|
+
const serverKeys = Reflect.ownKeys(server)
|
|
3923
|
+
const serverUnknown = serverKeys.filter(
|
|
3924
|
+
(key) => key !== 'host' && key !== 'port' && key !== 'timeout',
|
|
3925
|
+
)
|
|
3926
|
+
if (serverUnknown.length > 0) {
|
|
3927
|
+
throw new ApplicationServerError('CONFIG', 'Unknown application server setting', { value })
|
|
3928
|
+
}
|
|
3929
|
+
const hostDescriptor = Reflect.getOwnPropertyDescriptor(server, 'host')
|
|
3930
|
+
const portDescriptor = Reflect.getOwnPropertyDescriptor(server, 'port')
|
|
3931
|
+
const timeoutDescriptor = Reflect.getOwnPropertyDescriptor(server, 'timeout')
|
|
3635
3932
|
if (
|
|
3636
|
-
(
|
|
3933
|
+
(serverKeys.includes('host') &&
|
|
3637
3934
|
(hostDescriptor === undefined || !Reflect.has(hostDescriptor, 'value'))) ||
|
|
3638
|
-
(
|
|
3935
|
+
(serverKeys.includes('port') &&
|
|
3639
3936
|
(portDescriptor === undefined || !Reflect.has(portDescriptor, 'value'))) ||
|
|
3640
|
-
(
|
|
3937
|
+
(serverKeys.includes('timeout') &&
|
|
3641
3938
|
(timeoutDescriptor === undefined || !Reflect.has(timeoutDescriptor, 'value')))
|
|
3642
3939
|
) {
|
|
3643
3940
|
throw new ApplicationServerError(
|
|
3644
3941
|
'CONFIG',
|
|
3645
|
-
'Application server
|
|
3942
|
+
'Application server settings must use data properties',
|
|
3646
3943
|
{ value },
|
|
3647
3944
|
)
|
|
3648
3945
|
}
|
|
@@ -3650,9 +3947,11 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationServerOptions(value: unkno
|
|
|
3650
3947
|
const port = portDescriptor?.value
|
|
3651
3948
|
const timeout = timeoutDescriptor?.value
|
|
3652
3949
|
return {
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3950
|
+
server: {
|
|
3951
|
+
...(host === undefined ? {} : { host: parseApplicationHost(host) }),
|
|
3952
|
+
...(port === undefined ? {} : { port: parseApplicationPort(port) }),
|
|
3953
|
+
...(timeout === undefined ? {} : { timeout: parseApplicationStartTimeout(timeout) }),
|
|
3954
|
+
},
|
|
3656
3955
|
}
|
|
3657
3956
|
} catch (error) {
|
|
3658
3957
|
if (isApplicationServerError(error)) throw error
|
|
@@ -3661,47 +3960,56 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} parseApplicationServerOptions(value: unkno
|
|
|
3661
3960
|
})
|
|
3662
3961
|
}
|
|
3663
3962
|
}
|
|
3963
|
+
`
|
|
3964
|
+
}),
|
|
3965
|
+
appServerRoutes: Object.freeze({
|
|
3966
|
+
id: "appServerRoutes",
|
|
3967
|
+
name: "appServerRoutes",
|
|
3968
|
+
summary: "The standalone application route dispatcher factory.",
|
|
3969
|
+
category: "source",
|
|
3970
|
+
placeholders: Object.freeze([Object.freeze({
|
|
3971
|
+
name: "healthImport",
|
|
3972
|
+
description: "The selected layer import for the health route constants."
|
|
3973
|
+
})]),
|
|
3974
|
+
content: `import type { DispatcherInterface } from '@orkestrel/router'
|
|
3975
|
+
import type { ApplicationState } from './types.js'
|
|
3976
|
+
import { createDispatcher } from '@orkestrel/router'
|
|
3977
|
+
{{healthImport}}
|
|
3978
|
+
import { handleApplicationHealth } from './handlers.js'
|
|
3979
|
+
|
|
3980
|
+
/** Create a fresh dispatcher for the generated application's fetch-standard health boundary. */
|
|
3981
|
+
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} createApplicationDispatcher(): DispatcherInterface<ApplicationState> {
|
|
3982
|
+
return createDispatcher<ApplicationState>({
|
|
3983
|
+
routes: [
|
|
3984
|
+
{
|
|
3985
|
+
method: APP_HEALTH_METHOD,
|
|
3986
|
+
path: APP_HEALTH_PATH,
|
|
3987
|
+
handler: handleApplicationHealth,
|
|
3988
|
+
},
|
|
3989
|
+
],
|
|
3990
|
+
})
|
|
3991
|
+
}
|
|
3664
3992
|
`
|
|
3665
3993
|
}),
|
|
3666
3994
|
appServerHandlers: Object.freeze({
|
|
3667
3995
|
id: "appServerHandlers",
|
|
3668
3996
|
name: "appServerHandlers",
|
|
3669
|
-
summary: "The server
|
|
3997
|
+
summary: "The application server diagnostic reporter.",
|
|
3670
3998
|
category: "source",
|
|
3671
3999
|
placeholders: Object.freeze([Object.freeze({
|
|
4000
|
+
name: "recordImport",
|
|
4001
|
+
description: "The selected layer type import for the health record."
|
|
4002
|
+
}), Object.freeze({
|
|
3672
4003
|
name: "nameImport",
|
|
3673
4004
|
description: "The selected layer import for APP_NAME."
|
|
3674
4005
|
})]),
|
|
3675
|
-
content: `
|
|
4006
|
+
content: `{{recordImport}}
|
|
3676
4007
|
{{nameImport}}
|
|
3677
|
-
import { APP_HEALTH_METHOD, APP_HEALTH_PATH } from './constants.js'
|
|
3678
4008
|
import { isApplicationServerError } from './errors.js'
|
|
3679
4009
|
|
|
3680
|
-
/**
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
* @param request - The incoming Node request.
|
|
3684
|
-
* @param response - The Node response to complete exactly once.
|
|
3685
|
-
*/
|
|
3686
|
-
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} handleApplicationRequest(request: IncomingMessage, response: ServerResponse): void {
|
|
3687
|
-
if (request.method !== APP_HEALTH_METHOD) {
|
|
3688
|
-
response.writeHead(405, {
|
|
3689
|
-
allow: APP_HEALTH_METHOD,
|
|
3690
|
-
'content-type': 'text/plain; charset=utf-8',
|
|
3691
|
-
})
|
|
3692
|
-
response.end('Method Not Allowed')
|
|
3693
|
-
return
|
|
3694
|
-
}
|
|
3695
|
-
if (request.url !== APP_HEALTH_PATH) {
|
|
3696
|
-
response.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
3697
|
-
response.end('Not Found')
|
|
3698
|
-
return
|
|
3699
|
-
}
|
|
3700
|
-
response.writeHead(200, {
|
|
3701
|
-
'cache-control': 'no-store',
|
|
3702
|
-
'content-type': 'application/json; charset=utf-8',
|
|
3703
|
-
})
|
|
3704
|
-
response.end(JSON.stringify({ name: APP_NAME, status: 'ok' }))
|
|
4010
|
+
/** Return the generated application's shared health record. */
|
|
4011
|
+
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} handleApplicationHealth(): Response {
|
|
4012
|
+
return Response.json({ name: APP_NAME, status: 'ok' } satisfies ApplicationRecord)
|
|
3705
4013
|
}
|
|
3706
4014
|
|
|
3707
4015
|
/**
|
|
@@ -3729,8 +4037,8 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} reportApplicationServerError(error: unknow
|
|
|
3729
4037
|
} catch {
|
|
3730
4038
|
message = '[ERROR] Application server failed'
|
|
3731
4039
|
}
|
|
3732
|
-
process.stderr.write(\`\${message}\\n\`)
|
|
3733
4040
|
process.exitCode = 1
|
|
4041
|
+
process.stderr.write(\`\${message}\\n\`)
|
|
3734
4042
|
}
|
|
3735
4043
|
`
|
|
3736
4044
|
}),
|
|
@@ -3740,176 +4048,106 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} reportApplicationServerError(error: unknow
|
|
|
3740
4048
|
summary: "The application server implementation.",
|
|
3741
4049
|
category: "source",
|
|
3742
4050
|
placeholders: Object.freeze([]),
|
|
3743
|
-
content: `import type {
|
|
3744
|
-
import type {
|
|
3745
|
-
import {
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
APP_MAX_REQUESTS_PER_SOCKET,
|
|
3754
|
-
APP_REQUEST_TIMEOUT,
|
|
3755
|
-
} from './constants.js'
|
|
3756
|
-
import { ApplicationServerError, isApplicationServerError } from './errors.js'
|
|
3757
|
-
import { handleApplicationRequest } from './handlers.js'
|
|
4051
|
+
content: `import type { DispatcherInterface } from '@orkestrel/router'
|
|
4052
|
+
import type { ConnectionInfo, ServerInterface } from '@orkestrel/server'
|
|
4053
|
+
import type {
|
|
4054
|
+
ApplicationServerInterface,
|
|
4055
|
+
ApplicationServerOptions,
|
|
4056
|
+
ApplicationState,
|
|
4057
|
+
} from './types.js'
|
|
4058
|
+
import { createBoundary, createDeadline, createSecurity } from '@orkestrel/middleware'
|
|
4059
|
+
import { createServer } from '@orkestrel/server'
|
|
4060
|
+
import { ApplicationServerError } from './errors.js'
|
|
3758
4061
|
import {
|
|
3759
4062
|
parseApplicationHost,
|
|
3760
4063
|
parseApplicationPort,
|
|
3761
4064
|
parseApplicationServerOptions,
|
|
3762
4065
|
parseApplicationStartTimeout,
|
|
3763
4066
|
} from './parsers.js'
|
|
4067
|
+
import { createApplicationDispatcher } from './routes.js'
|
|
3764
4068
|
|
|
3765
|
-
/** A repeat-safe
|
|
4069
|
+
/** A repeat-safe application server composed from the installed server substrate. */
|
|
3766
4070
|
${EXPORT_KEYWORD} class ApplicationServer implements ApplicationServerInterface {
|
|
3767
4071
|
readonly host: string
|
|
3768
|
-
readonly #
|
|
3769
|
-
readonly #
|
|
3770
|
-
#port: number
|
|
3771
|
-
readonly #server: Server
|
|
3772
|
-
#transition: Promise<void> = Promise.resolve()
|
|
3773
|
-
readonly #starts = new Set<AbortController>()
|
|
3774
|
-
readonly #stopped = new WeakSet<AbortController>()
|
|
4072
|
+
readonly #dispatcher: DispatcherInterface<ApplicationState>
|
|
4073
|
+
readonly #server: ServerInterface<ApplicationState>
|
|
3775
4074
|
|
|
3776
4075
|
constructor(options: ApplicationServerOptions = {}) {
|
|
3777
4076
|
const parsed = parseApplicationServerOptions(options)
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
4077
|
+
const server = parsed.server
|
|
4078
|
+
const host = parseApplicationHost(
|
|
4079
|
+
server?.host === undefined ? process.env.APP_HOST : server.host,
|
|
3781
4080
|
)
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
parsed.timeout === undefined ? process.env.APP_START_TIMEOUT : parsed.timeout,
|
|
4081
|
+
const port = parseApplicationPort(
|
|
4082
|
+
server?.port === undefined ? process.env.APP_PORT : server.port,
|
|
3785
4083
|
)
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
this
|
|
3790
|
-
this.#
|
|
3791
|
-
this.#server
|
|
3792
|
-
|
|
4084
|
+
const timeout = parseApplicationStartTimeout(
|
|
4085
|
+
server?.timeout === undefined ? process.env.APP_START_TIMEOUT : server.timeout,
|
|
4086
|
+
)
|
|
4087
|
+
this.host = host
|
|
4088
|
+
this.#dispatcher = createApplicationDispatcher()
|
|
4089
|
+
this.#server = createServer<ApplicationState>({
|
|
4090
|
+
dispatcher: this.#dispatcher,
|
|
4091
|
+
state: ApplicationServer.#state,
|
|
4092
|
+
middleware: [
|
|
4093
|
+
createBoundary<ApplicationState>(),
|
|
4094
|
+
createSecurity<ApplicationState>(),
|
|
4095
|
+
createDeadline<ApplicationState>({ ms: timeout }),
|
|
4096
|
+
],
|
|
4097
|
+
host,
|
|
4098
|
+
port,
|
|
4099
|
+
timeouts: { start: timeout },
|
|
4100
|
+
})
|
|
3793
4101
|
}
|
|
3794
4102
|
|
|
3795
|
-
|
|
3796
|
-
return
|
|
4103
|
+
static #state(connection: ConnectionInfo): ApplicationState {
|
|
4104
|
+
return { connection }
|
|
3797
4105
|
}
|
|
3798
4106
|
|
|
3799
|
-
get
|
|
3800
|
-
return this.#server.
|
|
4107
|
+
get port(): number | undefined {
|
|
4108
|
+
return this.#server.port
|
|
3801
4109
|
}
|
|
3802
4110
|
|
|
3803
|
-
get
|
|
3804
|
-
|
|
3805
|
-
return \`http://\${hostname}:\${this.port}\`
|
|
4111
|
+
get status(): ApplicationServerInterface['status'] {
|
|
4112
|
+
return this.#server.status
|
|
3806
4113
|
}
|
|
3807
4114
|
|
|
3808
|
-
|
|
3809
|
-
const
|
|
3810
|
-
|
|
3811
|
-
const
|
|
3812
|
-
|
|
3813
|
-
return queued
|
|
4115
|
+
get url(): string | undefined {
|
|
4116
|
+
const port = this.port
|
|
4117
|
+
if (port === undefined) return undefined
|
|
4118
|
+
const hostname = this.host.includes(':') ? \`[\${this.host}]\` : this.host
|
|
4119
|
+
return \`http://\${hostname}:\${port}\`
|
|
3814
4120
|
}
|
|
3815
4121
|
|
|
3816
|
-
|
|
3817
|
-
for (const controller of this.#starts) {
|
|
3818
|
-
this.#stopped.add(controller)
|
|
3819
|
-
controller.abort()
|
|
3820
|
-
}
|
|
3821
|
-
return this.#queue(this.#stop.bind(this))
|
|
3822
|
-
}
|
|
3823
|
-
|
|
3824
|
-
#queue(operation: () => Promise<void>): Promise<void> {
|
|
3825
|
-
const queued = this.#transition.then(operation, operation)
|
|
3826
|
-
this.#transition = queued.then(
|
|
3827
|
-
() => undefined,
|
|
3828
|
-
() => undefined,
|
|
3829
|
-
)
|
|
3830
|
-
return queued
|
|
3831
|
-
}
|
|
3832
|
-
|
|
3833
|
-
async #start(controller: AbortController, signal?: AbortSignal): Promise<void> {
|
|
3834
|
-
if (this.listening) return
|
|
3835
|
-
if (controller.signal.aborted) {
|
|
3836
|
-
if (this.#stopped.has(controller)) return
|
|
3837
|
-
throw new ApplicationServerError('LIFECYCLE', 'Application server startup was cancelled')
|
|
3838
|
-
}
|
|
4122
|
+
async start(signal?: AbortSignal): Promise<void> {
|
|
3839
4123
|
try {
|
|
3840
|
-
|
|
3841
|
-
throw new ApplicationServerError('LIFECYCLE', 'Application server startup was cancelled', {
|
|
3842
|
-
cause: signal.reason,
|
|
3843
|
-
})
|
|
3844
|
-
}
|
|
3845
|
-
await this.#listen(controller, signal)
|
|
4124
|
+
await this.#server.start(signal)
|
|
3846
4125
|
} catch (error) {
|
|
3847
|
-
|
|
3848
|
-
if (isApplicationServerError(error)) throw error
|
|
3849
|
-
throw new ApplicationServerError('LIFECYCLE', 'Failed to inspect startup signal', {
|
|
4126
|
+
throw new ApplicationServerError('LIFECYCLE', 'Failed to start application server', {
|
|
3850
4127
|
cause: error,
|
|
3851
4128
|
})
|
|
3852
4129
|
}
|
|
3853
4130
|
}
|
|
3854
4131
|
|
|
3855
|
-
async
|
|
3856
|
-
if (!this.listening) return
|
|
3857
|
-
await this.#close()
|
|
3858
|
-
}
|
|
3859
|
-
|
|
3860
|
-
async #listen(controller: AbortController, signal?: AbortSignal): Promise<void> {
|
|
3861
|
-
const relay = signal === undefined ? undefined : this.#abort.bind(this, controller, signal)
|
|
3862
|
-
const timer = setTimeout(this.#expire.bind(this, controller), this.#timeout)
|
|
4132
|
+
async stop(): Promise<void> {
|
|
3863
4133
|
try {
|
|
3864
|
-
|
|
3865
|
-
signal.addEventListener('abort', relay, { once: true })
|
|
3866
|
-
}
|
|
3867
|
-
this.#server.listen({
|
|
3868
|
-
port: this.#requestedPort,
|
|
3869
|
-
host: this.host,
|
|
3870
|
-
signal: controller.signal,
|
|
3871
|
-
})
|
|
3872
|
-
await once(this.#server, 'listening', { signal: controller.signal })
|
|
4134
|
+
await this.#server.stop()
|
|
3873
4135
|
} catch (error) {
|
|
3874
|
-
throw new ApplicationServerError('LIFECYCLE', 'Failed to
|
|
4136
|
+
throw new ApplicationServerError('LIFECYCLE', 'Failed to stop application server', {
|
|
3875
4137
|
cause: error,
|
|
3876
4138
|
})
|
|
3877
|
-
} finally {
|
|
3878
|
-
clearTimeout(timer)
|
|
3879
|
-
if (relay !== undefined && signal !== undefined) {
|
|
3880
|
-
signal.removeEventListener('abort', relay)
|
|
3881
|
-
}
|
|
3882
|
-
}
|
|
3883
|
-
const address = this.#server.address()
|
|
3884
|
-
if (address === null || typeof address === 'string') {
|
|
3885
|
-
await this.#close()
|
|
3886
|
-
throw new ApplicationServerError('LIFECYCLE', 'Server did not expose a TCP address')
|
|
3887
4139
|
}
|
|
3888
|
-
this.#port = address.port
|
|
3889
4140
|
}
|
|
3890
4141
|
|
|
3891
|
-
|
|
3892
|
-
controller.abort(signal.reason)
|
|
3893
|
-
}
|
|
3894
|
-
|
|
3895
|
-
#expire(controller: AbortController): void {
|
|
3896
|
-
controller.abort(new Error(\`Application server startup exceeded \${this.#timeout} milliseconds\`))
|
|
3897
|
-
}
|
|
3898
|
-
|
|
3899
|
-
#settle(controller: AbortController): void {
|
|
3900
|
-
this.#starts.delete(controller)
|
|
3901
|
-
}
|
|
3902
|
-
|
|
3903
|
-
async #close(): Promise<void> {
|
|
4142
|
+
async destroy(): Promise<void> {
|
|
3904
4143
|
try {
|
|
3905
|
-
|
|
3906
|
-
this.#server.closeIdleConnections()
|
|
3907
|
-
this.#server.closeAllConnections()
|
|
3908
|
-
await closed
|
|
4144
|
+
await this.#server.destroy()
|
|
3909
4145
|
} catch (error) {
|
|
3910
|
-
throw new ApplicationServerError('LIFECYCLE', 'Failed to
|
|
4146
|
+
throw new ApplicationServerError('LIFECYCLE', 'Failed to destroy application server', {
|
|
3911
4147
|
cause: error,
|
|
3912
4148
|
})
|
|
4149
|
+
} finally {
|
|
4150
|
+
this.#dispatcher.destroy()
|
|
3913
4151
|
}
|
|
3914
4152
|
}
|
|
3915
4153
|
}
|
|
@@ -3932,16 +4170,17 @@ import { ApplicationServerRunner } from './ApplicationServerRunner.js'
|
|
|
3932
4170
|
/**
|
|
3933
4171
|
* Create a stopped application server.
|
|
3934
4172
|
*
|
|
3935
|
-
* @param options - Optional
|
|
4173
|
+
* @param options - Optional grouped server overrides.
|
|
3936
4174
|
* @returns A lifecycle-safe application server.
|
|
3937
4175
|
*
|
|
3938
4176
|
* @example
|
|
3939
4177
|
* \`\`\`ts
|
|
3940
4178
|
* import { createApplicationServer } from '@app/server'
|
|
3941
4179
|
*
|
|
3942
|
-
* ${CONST_KEYWORD} server = createApplicationServer({ port: 0 })
|
|
4180
|
+
* ${CONST_KEYWORD} server = createApplicationServer({ server: { port: 0 } })
|
|
3943
4181
|
* await server.start()
|
|
3944
4182
|
* await server.stop()
|
|
4183
|
+
* await server.destroy()
|
|
3945
4184
|
* \`\`\`
|
|
3946
4185
|
*/
|
|
3947
4186
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} createApplicationServer(
|
|
@@ -3953,21 +4192,21 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} createApplicationServer(
|
|
|
3953
4192
|
/**
|
|
3954
4193
|
* Start a process-owned application server.
|
|
3955
4194
|
*
|
|
3956
|
-
* @param options - Optional
|
|
4195
|
+
* @param options - Optional grouped server overrides.
|
|
3957
4196
|
* @returns The runner that owns signals and provides explicit asynchronous cleanup.
|
|
3958
4197
|
*
|
|
3959
4198
|
* @example
|
|
3960
4199
|
* \`\`\`ts
|
|
3961
4200
|
* import { startApplicationServer } from '@app/server'
|
|
3962
4201
|
*
|
|
3963
|
-
* ${CONST_KEYWORD} runner = startApplicationServer({ port: 0 })
|
|
4202
|
+
* ${CONST_KEYWORD} runner = startApplicationServer({ server: { port: 0 } })
|
|
3964
4203
|
* await runner.stop()
|
|
3965
4204
|
* \`\`\`
|
|
3966
4205
|
*/
|
|
3967
4206
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} startApplicationServer(
|
|
3968
4207
|
options: ApplicationServerOptions = {},
|
|
3969
4208
|
): ApplicationServerRunnerInterface {
|
|
3970
|
-
const runner = new ApplicationServerRunner(options)
|
|
4209
|
+
const runner = new ApplicationServerRunner(new ApplicationServer(options))
|
|
3971
4210
|
runner.start()
|
|
3972
4211
|
return runner
|
|
3973
4212
|
}
|
|
@@ -3978,57 +4217,144 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} startApplicationServer(
|
|
|
3978
4217
|
name: "appServerRunner",
|
|
3979
4218
|
summary: "The application server process lifecycle owner.",
|
|
3980
4219
|
category: "source",
|
|
3981
|
-
placeholders: Object.freeze([
|
|
3982
|
-
|
|
4220
|
+
placeholders: Object.freeze([Object.freeze({
|
|
4221
|
+
name: "nameImport",
|
|
4222
|
+
description: "The selected layer import for APP_NAME."
|
|
4223
|
+
})]),
|
|
4224
|
+
content: `import type { EmitterInterface } from '@orkestrel/emitter'
|
|
4225
|
+
import type {
|
|
3983
4226
|
ApplicationServerInterface,
|
|
3984
|
-
|
|
4227
|
+
ApplicationServerRunnerEventMap,
|
|
3985
4228
|
ApplicationServerRunnerInterface,
|
|
4229
|
+
ApplicationServerRunnerOptions,
|
|
3986
4230
|
} from './types.js'
|
|
3987
|
-
import {
|
|
4231
|
+
import { Emitter } from '@orkestrel/emitter'
|
|
4232
|
+
{{nameImport}}
|
|
4233
|
+
import { ApplicationServerError } from './errors.js'
|
|
3988
4234
|
import { reportApplicationServerError } from './handlers.js'
|
|
3989
4235
|
|
|
3990
4236
|
/** Own process signals and startup failure handling for one application server. */
|
|
3991
4237
|
${EXPORT_KEYWORD} class ApplicationServerRunner implements ApplicationServerRunnerInterface {
|
|
4238
|
+
readonly #emitter: Emitter<ApplicationServerRunnerEventMap>
|
|
3992
4239
|
readonly #server: ApplicationServerInterface
|
|
3993
|
-
readonly #
|
|
4240
|
+
readonly #handler: () => void
|
|
4241
|
+
#controller: AbortController | undefined
|
|
4242
|
+
#queue: Promise<void> = Promise.resolve()
|
|
4243
|
+
#stopping: Promise<void> | undefined
|
|
4244
|
+
#announcement: number | undefined
|
|
3994
4245
|
#generation = 0
|
|
3995
4246
|
#started = false
|
|
3996
4247
|
|
|
3997
|
-
constructor(options:
|
|
3998
|
-
this.#
|
|
3999
|
-
|
|
4248
|
+
constructor(server: ApplicationServerInterface, options: ApplicationServerRunnerOptions = {}) {
|
|
4249
|
+
this.#emitter = new Emitter({
|
|
4250
|
+
...(options.on === undefined ? {} : { on: options.on }),
|
|
4251
|
+
...(options.error === undefined ? {} : { error: options.error }),
|
|
4252
|
+
})
|
|
4253
|
+
this.#server = server
|
|
4254
|
+
this.#handler = this.#shutdown.bind(this)
|
|
4255
|
+
this.#emitter.on('ready', this.#announce.bind(this))
|
|
4256
|
+
this.#emitter.on('fail', reportApplicationServerError)
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
get emitter(): EmitterInterface<ApplicationServerRunnerEventMap> {
|
|
4260
|
+
return this.#emitter
|
|
4000
4261
|
}
|
|
4001
4262
|
|
|
4002
4263
|
start(): void {
|
|
4003
4264
|
if (this.#started) return
|
|
4004
4265
|
this.#started = true
|
|
4266
|
+
this.#stopping = undefined
|
|
4005
4267
|
const generation = ++this.#generation
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4268
|
+
const controller = new AbortController()
|
|
4269
|
+
this.#controller = controller
|
|
4270
|
+
process.once('SIGINT', this.#handler)
|
|
4271
|
+
process.once('SIGTERM', this.#handler)
|
|
4272
|
+
this.#queue = this.#queue.then(this.#begin.bind(this, generation, controller))
|
|
4009
4273
|
}
|
|
4010
4274
|
|
|
4011
4275
|
stop(): Promise<void> {
|
|
4012
|
-
this.#
|
|
4276
|
+
if (this.#stopping !== undefined) return this.#stopping
|
|
4277
|
+
// Aborting first lets an in-flight substrate startup settle before stop inspects its state,
|
|
4278
|
+
// and the generation bump is what discards that abort's rejection while leaving a genuine
|
|
4279
|
+
// stop failure reportable. The shared queue then keeps a subsequent restart behind this complete
|
|
4280
|
+
// shutdown; concurrent callers join one substrate stop through #stopping, and the trailing catch
|
|
4281
|
+
// discards nothing — it only keeps one failed stop from wedging every later one.
|
|
4282
|
+
++this.#generation
|
|
4283
|
+
this.#controller?.abort()
|
|
4013
4284
|
this.#release()
|
|
4014
|
-
|
|
4285
|
+
const stopping = this.#queue.then(() => this.#server.stop())
|
|
4286
|
+
this.#stopping = stopping
|
|
4287
|
+
this.#queue = stopping.catch(() => undefined)
|
|
4288
|
+
void stopping.then(this.#finishStop.bind(this, stopping), this.#rejectStop.bind(this, stopping))
|
|
4289
|
+
return stopping
|
|
4290
|
+
}
|
|
4291
|
+
|
|
4292
|
+
async #begin(generation: number, controller: AbortController): Promise<void> {
|
|
4293
|
+
if (generation !== this.#generation || !this.#started) return
|
|
4294
|
+
try {
|
|
4295
|
+
await this.#server.start(controller.signal)
|
|
4296
|
+
await this.#ready(generation)
|
|
4297
|
+
} catch (error) {
|
|
4298
|
+
this.#fail(generation, error)
|
|
4299
|
+
}
|
|
4015
4300
|
}
|
|
4016
4301
|
|
|
4017
4302
|
#fail(generation: number, error: unknown): void {
|
|
4018
4303
|
if (generation !== this.#generation) return
|
|
4019
4304
|
this.#release()
|
|
4020
|
-
|
|
4305
|
+
this.#emitter.emit('fail', error)
|
|
4306
|
+
}
|
|
4307
|
+
|
|
4308
|
+
#finishStop(stopping: Promise<void>): void {
|
|
4309
|
+
if (this.#stopping === stopping) this.#stopping = undefined
|
|
4310
|
+
}
|
|
4311
|
+
|
|
4312
|
+
#rejectStop(stopping: Promise<void>, error: unknown): void {
|
|
4313
|
+
this.#finishStop(stopping)
|
|
4314
|
+
this.#emitter.emit('fail', error)
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
async #ready(generation: number): Promise<void> {
|
|
4318
|
+
if (generation !== this.#generation || !this.#started) return
|
|
4319
|
+
const url = this.#server.url
|
|
4320
|
+
if (url === undefined) {
|
|
4321
|
+
const failure = new ApplicationServerError(
|
|
4322
|
+
'LIFECYCLE',
|
|
4323
|
+
'Application server did not expose a URL after successful startup',
|
|
4324
|
+
)
|
|
4325
|
+
try {
|
|
4326
|
+
await this.#server.stop()
|
|
4327
|
+
} catch (error) {
|
|
4328
|
+
this.#fail(generation, error)
|
|
4329
|
+
return
|
|
4330
|
+
}
|
|
4331
|
+
this.#fail(generation, failure)
|
|
4332
|
+
return
|
|
4333
|
+
}
|
|
4334
|
+
this.#announcement = generation
|
|
4335
|
+
this.#emitter.emit('ready', url)
|
|
4336
|
+
this.#announcement = undefined
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
#announce(url: string): void {
|
|
4340
|
+
if (this.#announcement !== this.#generation) return
|
|
4341
|
+
try {
|
|
4342
|
+
process.stderr.write(\`[READY] \${APP_NAME} \${url}\\n\`)
|
|
4343
|
+
} catch (error) {
|
|
4344
|
+
const stopped = this.stop()
|
|
4345
|
+
const generation = this.#generation
|
|
4346
|
+
void stopped.then(this.#fail.bind(this, generation, error), () => undefined)
|
|
4347
|
+
}
|
|
4021
4348
|
}
|
|
4022
4349
|
|
|
4023
4350
|
#shutdown(): void {
|
|
4024
4351
|
const stopped = this.stop()
|
|
4025
|
-
|
|
4026
|
-
void stopped.catch(this.#fail.bind(this, generation))
|
|
4352
|
+
void stopped.catch(() => undefined)
|
|
4027
4353
|
}
|
|
4028
4354
|
|
|
4029
4355
|
#release(): void {
|
|
4030
|
-
process.off('SIGINT', this.#
|
|
4031
|
-
process.off('SIGTERM', this.#
|
|
4356
|
+
process.off('SIGINT', this.#handler)
|
|
4357
|
+
process.off('SIGTERM', this.#handler)
|
|
4032
4358
|
this.#started = false
|
|
4033
4359
|
}
|
|
4034
4360
|
}
|
|
@@ -4044,6 +4370,7 @@ ${EXPORT_KEYWORD} class ApplicationServerRunner implements ApplicationServerRunn
|
|
|
4044
4370
|
export * from './constants.js'
|
|
4045
4371
|
export * from './errors.js'
|
|
4046
4372
|
export * from './parsers.js'
|
|
4373
|
+
export * from './routes.js'
|
|
4047
4374
|
export * from './handlers.js'
|
|
4048
4375
|
export * from './ApplicationServer.js'
|
|
4049
4376
|
export * from './ApplicationServerRunner.js'
|
|
@@ -4068,10 +4395,16 @@ try {
|
|
|
4068
4395
|
setup: Object.freeze({
|
|
4069
4396
|
id: "setup",
|
|
4070
4397
|
name: "setup",
|
|
4071
|
-
summary: "The generated-minimal `tests/setup.ts`
|
|
4398
|
+
summary: "The generated-minimal `tests/setup.ts` shared test helpers.",
|
|
4072
4399
|
category: "tests",
|
|
4073
|
-
placeholders: Object.freeze([
|
|
4074
|
-
|
|
4400
|
+
placeholders: Object.freeze([Object.freeze({
|
|
4401
|
+
name: "eventImport",
|
|
4402
|
+
description: "The optional emitter types used by application server tests."
|
|
4403
|
+
}), Object.freeze({
|
|
4404
|
+
name: "eventHelper",
|
|
4405
|
+
description: "The optional typed event waiter used by application server tests."
|
|
4406
|
+
})]),
|
|
4407
|
+
content: `{{eventImport}}// ── Call recorder (a real callback, not a mock) ──────────────────────────────
|
|
4075
4408
|
//
|
|
4076
4409
|
// The test rules require a recording callback when a test only needs to count calls or inspect arguments:
|
|
4077
4410
|
// recorder — a real listener that records every invocation — rather than a test-
|
|
@@ -4111,12 +4444,64 @@ ${EXPORT_KEYWORD} function createRecorder<TArgs extends readonly unknown[]>(): T
|
|
|
4111
4444
|
},
|
|
4112
4445
|
}
|
|
4113
4446
|
}
|
|
4114
|
-
|
|
4447
|
+
{{eventHelper}}
|
|
4115
4448
|
/** Whether a repository-relative Vue SFC belongs to the private browser application. */
|
|
4116
4449
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isBrowserVuePath(path: string): boolean {
|
|
4117
4450
|
const normalized = path.replaceAll('\\\\', '/')
|
|
4118
4451
|
return normalized.startsWith('app/browser/')
|
|
4119
4452
|
}
|
|
4453
|
+
`
|
|
4454
|
+
}),
|
|
4455
|
+
configTest: Object.freeze({
|
|
4456
|
+
id: "configTest",
|
|
4457
|
+
name: "configTest",
|
|
4458
|
+
summary: "The generated root Vite configuration behavior test.",
|
|
4459
|
+
category: "tests",
|
|
4460
|
+
placeholders: Object.freeze([Object.freeze({
|
|
4461
|
+
name: "imports",
|
|
4462
|
+
description: "The machinery-aware root configuration imports."
|
|
4463
|
+
}), Object.freeze({
|
|
4464
|
+
name: "cases",
|
|
4465
|
+
description: "The machinery-aware root configuration cases."
|
|
4466
|
+
})]),
|
|
4467
|
+
content: `{{imports}}
|
|
4468
|
+
|
|
4469
|
+
describe('root Vite configuration', () => {
|
|
4470
|
+
it('keeps workspace paths physically contained', () => {
|
|
4471
|
+
const root = resolveWorkspacePath('.')
|
|
4472
|
+
const source = resolveWorkspacePath('src')
|
|
4473
|
+
const parent = resolveWorkspacePath('..')
|
|
4474
|
+
|
|
4475
|
+
expect(workspacePath(root)).toBe('')
|
|
4476
|
+
expect(workspacePath(source)).toBe('src')
|
|
4477
|
+
expect(workspacePath(parent)).toBeUndefined()
|
|
4478
|
+
expect(containedPath(root, source)).toBe(true)
|
|
4479
|
+
expect(containedPath(root, parent)).toBe(false)
|
|
4480
|
+
})
|
|
4481
|
+
|
|
4482
|
+
it('enforces environment direction for paths and module sources', () => {
|
|
4483
|
+
expect(environmentPathError('src/core', 'app/core/index.ts')).toBe(
|
|
4484
|
+
'Published modules cannot depend on private application modules',
|
|
4485
|
+
)
|
|
4486
|
+
expect(environmentPathError('app/core', 'src/browser/index.ts')).toBe(
|
|
4487
|
+
'Core modules must remain host-independent',
|
|
4488
|
+
)
|
|
4489
|
+
expect(environmentPathError('app/browser', 'src/server/index.ts')).toBe(
|
|
4490
|
+
'Browser modules cannot depend on Node or server-only modules',
|
|
4491
|
+
)
|
|
4492
|
+
expect(environmentPathError('app/server', 'src/browser/index.ts')).toBe(
|
|
4493
|
+
'Server modules cannot depend on Vue or browser-only modules',
|
|
4494
|
+
)
|
|
4495
|
+
expect(environmentPathError('app/browser', 'src/core/index.ts')).toBeUndefined()
|
|
4496
|
+
expect(environmentSourceError('src/core', 'node:path')).toBe(
|
|
4497
|
+
'Core modules must remain host-independent',
|
|
4498
|
+
)
|
|
4499
|
+
expect(environmentSourceError('app/server', 'vue')).toBe(
|
|
4500
|
+
'Server modules cannot depend on Vue or browser-only modules',
|
|
4501
|
+
)
|
|
4502
|
+
expect(environmentSourceError('app/browser', '@app/core')).toBeUndefined()
|
|
4503
|
+
}){{cases}}
|
|
4504
|
+
})
|
|
4120
4505
|
`
|
|
4121
4506
|
}),
|
|
4122
4507
|
policyTest: Object.freeze({
|
|
@@ -4124,32 +4509,17 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} isBrowserVuePath(path: string): boolean {
|
|
|
4124
4509
|
name: "policyTest",
|
|
4125
4510
|
summary: "The generated repository filename-policy test.",
|
|
4126
4511
|
category: "tests",
|
|
4127
|
-
placeholders: Object.freeze([
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
}),
|
|
4136
|
-
Object.freeze({
|
|
4137
|
-
name: "browserPolicyTest",
|
|
4138
|
-
description: "The optional capability-gated Chromium policy test."
|
|
4139
|
-
}),
|
|
4140
|
-
Object.freeze({
|
|
4141
|
-
name: "vuePolicyImport",
|
|
4142
|
-
description: "The optional official Vue SFC compiler import."
|
|
4143
|
-
}),
|
|
4144
|
-
Object.freeze({
|
|
4145
|
-
name: "workspacePolicyAssertion",
|
|
4146
|
-
description: "The formatter-stable workspace policy assertion."
|
|
4147
|
-
})
|
|
4148
|
-
]),
|
|
4149
|
-
content: `import { globSync{{browserPolicySpecifier}} } from 'node:fs'
|
|
4512
|
+
placeholders: Object.freeze([Object.freeze({
|
|
4513
|
+
name: "vuePolicyImport",
|
|
4514
|
+
description: "The optional official Vue SFC compiler import."
|
|
4515
|
+
}), Object.freeze({
|
|
4516
|
+
name: "workspacePolicyAssertion",
|
|
4517
|
+
description: "The formatter-stable workspace policy assertion."
|
|
4518
|
+
})]),
|
|
4519
|
+
content: `import { globSync } from 'node:fs'
|
|
4150
4520
|
import { describe, expect, it } from 'vitest'
|
|
4151
4521
|
import { isBrowserVuePath } from './setup.js'
|
|
4152
|
-
import { inspectCodingWorkspace } from './setupPolicy.js'{{
|
|
4522
|
+
import { inspectCodingWorkspace } from './setupPolicy.js'{{vuePolicyImport}}
|
|
4153
4523
|
|
|
4154
4524
|
describe('repository coding law', () => {
|
|
4155
4525
|
it('keeps Vue single-file components exclusively in browser environments', () => {
|
|
@@ -4160,7 +4530,7 @@ describe('repository coding law', () => {
|
|
|
4160
4530
|
|
|
4161
4531
|
it('enforces source placement, exports, readonly contracts, and syntax law', () => {
|
|
4162
4532
|
{{workspacePolicyAssertion}}
|
|
4163
|
-
})
|
|
4533
|
+
})
|
|
4164
4534
|
})
|
|
4165
4535
|
`
|
|
4166
4536
|
}),
|
|
@@ -4180,6 +4550,7 @@ ${IMPORT_KEYWORD} { createServer } from 'node:http'
|
|
|
4180
4550
|
/** One real application child process plus its captured diagnostic output. */
|
|
4181
4551
|
${EXPORT_KEYWORD} interface ApplicationProcessInterface {
|
|
4182
4552
|
readonly child: ChildProcess
|
|
4553
|
+
readonly ready: Promise<void>
|
|
4183
4554
|
output(): string
|
|
4184
4555
|
}
|
|
4185
4556
|
|
|
@@ -4219,11 +4590,17 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} buildApplicationServer(): void {
|
|
|
4219
4590
|
}
|
|
4220
4591
|
}
|
|
4221
4592
|
|
|
4222
|
-
/**
|
|
4223
|
-
${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD}
|
|
4593
|
+
/** Start a real Node server bound to one loopback port, or 0 for an ephemeral one. */
|
|
4594
|
+
${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD} startLoopbackServer(port: number): Promise<Server> {
|
|
4224
4595
|
const server = createServer()
|
|
4225
|
-
server.listen(
|
|
4596
|
+
server.listen(port, '127.0.0.1')
|
|
4226
4597
|
await once(server, 'listening')
|
|
4598
|
+
return server
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4601
|
+
/** Reserve and release a real loopback port for an immediate child-process bind. */
|
|
4602
|
+
${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD} reserveLoopbackPort(): Promise<number> {
|
|
4603
|
+
const server = await startLoopbackServer(0)
|
|
4227
4604
|
const address = server.address()
|
|
4228
4605
|
if (address === null || typeof address === 'string') {
|
|
4229
4606
|
await stopNodeServer(server)
|
|
@@ -4233,23 +4610,6 @@ ${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD} reserveLoopbackPort(): Promise<numbe
|
|
|
4233
4610
|
return address.port
|
|
4234
4611
|
}
|
|
4235
4612
|
|
|
4236
|
-
/** Wait until one in-process application server responds on loopback. */
|
|
4237
|
-
${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD} waitForLoopbackResponse(port: number): Promise<Response> {
|
|
4238
|
-
const deadline = Date.now() + 10_000
|
|
4239
|
-
let failure: unknown
|
|
4240
|
-
while (Date.now() < deadline) {
|
|
4241
|
-
try {
|
|
4242
|
-
return await fetch(\`http://127.0.0.1:\${port}\`, {
|
|
4243
|
-
signal: AbortSignal.timeout(250),
|
|
4244
|
-
})
|
|
4245
|
-
} catch (error) {
|
|
4246
|
-
failure = error
|
|
4247
|
-
await new Promise<void>((resolvePromise) => setTimeout(resolvePromise, 25))
|
|
4248
|
-
}
|
|
4249
|
-
}
|
|
4250
|
-
throw new Error(\`application server did not become ready: \${String(failure)}\`)
|
|
4251
|
-
}
|
|
4252
|
-
|
|
4253
4613
|
/** Start the built application entry as a real child process. */
|
|
4254
4614
|
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} startApplicationProcess(
|
|
4255
4615
|
port: number,
|
|
@@ -4266,16 +4626,20 @@ ${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} startApplicationProcess(
|
|
|
4266
4626
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
4267
4627
|
})
|
|
4268
4628
|
const output: string[] = []
|
|
4629
|
+
const readiness = Promise.withResolvers<void>()
|
|
4269
4630
|
child.stdout?.setEncoding('utf8')
|
|
4270
4631
|
child.stderr?.setEncoding('utf8')
|
|
4271
4632
|
child.stdout?.on('data', (chunk: unknown) => {
|
|
4272
4633
|
if (typeof chunk === 'string') output.push(chunk)
|
|
4273
4634
|
})
|
|
4274
4635
|
child.stderr?.on('data', (chunk: unknown) => {
|
|
4275
|
-
if (typeof chunk
|
|
4636
|
+
if (typeof chunk !== 'string') return
|
|
4637
|
+
output.push(chunk)
|
|
4638
|
+
if (output.join('').includes('[READY] ')) readiness.resolve()
|
|
4276
4639
|
})
|
|
4277
4640
|
return {
|
|
4278
4641
|
child,
|
|
4642
|
+
ready: readiness.promise,
|
|
4279
4643
|
output() {
|
|
4280
4644
|
return output.join('')
|
|
4281
4645
|
},
|
|
@@ -4440,15 +4804,28 @@ describe('create{{pascal}}', () => {
|
|
|
4440
4804
|
name: "appCoreTest",
|
|
4441
4805
|
summary: "The host-independent application test.",
|
|
4442
4806
|
category: "tests",
|
|
4443
|
-
placeholders: Object.freeze([
|
|
4807
|
+
placeholders: Object.freeze([
|
|
4808
|
+
Object.freeze({
|
|
4809
|
+
name: "guardImport",
|
|
4810
|
+
description: "The optional shared-record guard test import."
|
|
4811
|
+
}),
|
|
4812
|
+
Object.freeze({
|
|
4813
|
+
name: "readImport",
|
|
4814
|
+
description: "The optional shared health-boundary read test import."
|
|
4815
|
+
}),
|
|
4816
|
+
Object.freeze({
|
|
4817
|
+
name: "boundary",
|
|
4818
|
+
description: "The optional shared health-boundary contract test."
|
|
4819
|
+
})
|
|
4820
|
+
]),
|
|
4444
4821
|
content: `import {
|
|
4445
4822
|
APP_NAME,
|
|
4446
4823
|
ApplicationError,
|
|
4447
4824
|
createApplication,
|
|
4448
4825
|
isApplicationError,
|
|
4449
|
-
MAX_APPLICATION_NAME_INPUT_LENGTH,
|
|
4826
|
+
{{guardImport}} MAX_APPLICATION_NAME_INPUT_LENGTH,
|
|
4450
4827
|
parseApplicationName,
|
|
4451
|
-
} from '@app/core'
|
|
4828
|
+
{{readImport}}} from '@app/core'
|
|
4452
4829
|
import { describe, expect, it } from 'vitest'
|
|
4453
4830
|
|
|
4454
4831
|
describe('createApplication', () => {
|
|
@@ -4485,7 +4862,14 @@ describe('createApplication', () => {
|
|
|
4485
4862
|
revocable.revoke()
|
|
4486
4863
|
expect(isApplicationError(revocable.proxy)).toBe(false)
|
|
4487
4864
|
})
|
|
4488
|
-
|
|
4865
|
+
|
|
4866
|
+
it('refuses a foreign application error', () => {
|
|
4867
|
+
const foreign = new Error('foreign')
|
|
4868
|
+
foreign.name = 'ApplicationError'
|
|
4869
|
+
|
|
4870
|
+
expect(isApplicationError(foreign)).toBe(false)
|
|
4871
|
+
})
|
|
4872
|
+
}){{boundary}}
|
|
4489
4873
|
`
|
|
4490
4874
|
}),
|
|
4491
4875
|
appBrowserTest: Object.freeze({
|
|
@@ -4493,18 +4877,36 @@ describe('createApplication', () => {
|
|
|
4493
4877
|
name: "appBrowserTest",
|
|
4494
4878
|
summary: "The real-browser application mount test.",
|
|
4495
4879
|
category: "tests",
|
|
4496
|
-
placeholders: Object.freeze([
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4880
|
+
placeholders: Object.freeze([
|
|
4881
|
+
Object.freeze({
|
|
4882
|
+
name: "browserTestNameImport",
|
|
4883
|
+
description: "The layer-correct browser APP_NAME test import."
|
|
4884
|
+
}),
|
|
4885
|
+
Object.freeze({
|
|
4886
|
+
name: "showcaseImport",
|
|
4887
|
+
description: "The optional showcase factory test import."
|
|
4888
|
+
}),
|
|
4889
|
+
Object.freeze({
|
|
4890
|
+
name: "entryImport",
|
|
4891
|
+
description: "The optional seeder and boundary-entry test imports."
|
|
4892
|
+
}),
|
|
4893
|
+
Object.freeze({
|
|
4894
|
+
name: "showcase",
|
|
4895
|
+
description: "The optional real-browser showcase mount test."
|
|
4896
|
+
}),
|
|
4897
|
+
Object.freeze({
|
|
4898
|
+
name: "boundary",
|
|
4899
|
+
description: "The optional real-browser boundary degradation test."
|
|
4900
|
+
})
|
|
4901
|
+
]),
|
|
4500
4902
|
content: `{{browserTestNameImport}}
|
|
4501
4903
|
import {
|
|
4502
4904
|
BrowserApplicationError,
|
|
4503
4905
|
createBrowserApplication,
|
|
4504
|
-
isBrowserApplicationError,
|
|
4906
|
+
{{showcaseImport}} isBrowserApplicationError,
|
|
4505
4907
|
MAX_BROWSER_APPLICATION_NAME_INPUT_LENGTH,
|
|
4506
4908
|
parseBrowserApplicationOptions,
|
|
4507
|
-
} from '@app/browser'
|
|
4909
|
+
{{entryImport}}} from '@app/browser'
|
|
4508
4910
|
import { buildElement } from '../../setupBrowser.js'
|
|
4509
4911
|
import { describe, expect, it } from 'vitest'
|
|
4510
4912
|
|
|
@@ -4588,313 +4990,205 @@ describe('createBrowserApplication', () => {
|
|
|
4588
4990
|
expect(isBrowserApplicationError(new Error('plain'))).toBe(false)
|
|
4589
4991
|
expect(isBrowserApplicationError(revocable.proxy)).toBe(false)
|
|
4590
4992
|
})
|
|
4591
|
-
})
|
|
4993
|
+
}){{showcase}}{{boundary}}
|
|
4592
4994
|
`
|
|
4593
4995
|
}),
|
|
4594
4996
|
appServerTest: Object.freeze({
|
|
4595
4997
|
id: "appServerTest",
|
|
4596
4998
|
name: "appServerTest",
|
|
4597
|
-
summary: "The real
|
|
4999
|
+
summary: "The real dispatcher, server-substrate, and runner integration test.",
|
|
4598
5000
|
category: "tests",
|
|
4599
|
-
placeholders: Object.freeze([
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
5001
|
+
placeholders: Object.freeze([
|
|
5002
|
+
Object.freeze({
|
|
5003
|
+
name: "testNameImport",
|
|
5004
|
+
description: "The layer-correct APP_NAME and health-contract test import."
|
|
5005
|
+
}),
|
|
5006
|
+
Object.freeze({
|
|
5007
|
+
name: "serverImport",
|
|
5008
|
+
description: "The application server test import, minus any relocated health contract."
|
|
5009
|
+
}),
|
|
5010
|
+
Object.freeze({
|
|
5011
|
+
name: "boundary",
|
|
5012
|
+
description: "The optional real-server shared-boundary test."
|
|
5013
|
+
})
|
|
5014
|
+
]),
|
|
4603
5015
|
content: `{{testNameImport}}
|
|
4604
|
-
|
|
4605
|
-
APP_HEALTH_METHOD,
|
|
4606
|
-
APP_HEALTH_PATH,
|
|
4607
|
-
APP_MAX_CONNECTIONS,
|
|
4608
|
-
ApplicationServerRunner,
|
|
4609
|
-
createApplicationServer,
|
|
4610
|
-
startApplicationServer,
|
|
4611
|
-
} from '@app/server'
|
|
4612
|
-
import { once } from 'node:events'
|
|
4613
|
-
import { createServer } from 'node:http'
|
|
4614
|
-
import { connect } from 'node:net'
|
|
5016
|
+
{{serverImport}}
|
|
4615
5017
|
import { beforeAll, describe, expect, it } from 'vitest'
|
|
4616
5018
|
import {
|
|
4617
5019
|
buildApplicationServer,
|
|
4618
5020
|
reserveLoopbackPort,
|
|
4619
5021
|
startApplicationProcess,
|
|
5022
|
+
startLoopbackServer,
|
|
4620
5023
|
stopNodeServer,
|
|
4621
5024
|
waitForApplicationProcess,
|
|
4622
|
-
waitForApplicationResponse,
|
|
4623
|
-
waitForLoopbackResponse,
|
|
4624
|
-
waitForSocketClose,
|
|
4625
5025
|
} from '../../setupServer.js'
|
|
5026
|
+
import { createRecorder, waitForEvent } from '../../setup.js'
|
|
4626
5027
|
|
|
4627
5028
|
beforeAll(() => buildApplicationServer(), 60_000)
|
|
4628
5029
|
|
|
4629
|
-
describe('
|
|
4630
|
-
|
|
4631
|
-
|
|
5030
|
+
describe('application dispatcher', () => {
|
|
5031
|
+
const state = { connection: { encrypted: false } }
|
|
5032
|
+
|
|
5033
|
+
it('returns the typed shared application record for the health route', async () => {
|
|
5034
|
+
const dispatcher = createApplicationDispatcher()
|
|
4632
5035
|
try {
|
|
4633
|
-
|
|
4634
|
-
|
|
5036
|
+
const response = await dispatcher.handle(
|
|
5037
|
+
new Request(\`http://application.test\${APP_HEALTH_PATH}\`),
|
|
5038
|
+
state,
|
|
5039
|
+
)
|
|
4635
5040
|
|
|
4636
|
-
const response = await fetch(server.url)
|
|
4637
5041
|
expect(response.status).toBe(200)
|
|
4638
|
-
expect(response.headers.get('cache-control')).toBe('no-store')
|
|
4639
|
-
expect(response.headers.get('content-type')).toBe('application/json; charset=utf-8')
|
|
4640
5042
|
expect(await response.json()).toEqual({ name: APP_NAME, status: 'ok' })
|
|
5043
|
+
expect(APP_HEALTH_PATH).toBe('/health')
|
|
4641
5044
|
} finally {
|
|
4642
|
-
|
|
4643
|
-
await server.stop()
|
|
5045
|
+
dispatcher.destroy()
|
|
4644
5046
|
}
|
|
4645
5047
|
})
|
|
4646
5048
|
|
|
4647
|
-
it('
|
|
4648
|
-
const
|
|
5049
|
+
it('distinguishes a wrong method from a missing route', async () => {
|
|
5050
|
+
const dispatcher = createApplicationDispatcher()
|
|
4649
5051
|
try {
|
|
4650
|
-
await
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
expect(method.headers.get('allow')).toBe(APP_HEALTH_METHOD)
|
|
4654
|
-
expect(method.headers.get('content-type')).toBe('text/plain; charset=utf-8')
|
|
4655
|
-
expect(await method.text()).toBe('Method Not Allowed')
|
|
4656
|
-
const route = await fetch(\`\${server.url}/missing\`)
|
|
4657
|
-
expect(route.status).toBe(404)
|
|
4658
|
-
expect(route.headers.get('content-type')).toBe('text/plain; charset=utf-8')
|
|
4659
|
-
expect(await route.text()).toBe('Not Found')
|
|
4660
|
-
expect(APP_HEALTH_PATH).toBe('/')
|
|
4661
|
-
} finally {
|
|
4662
|
-
await server.stop()
|
|
4663
|
-
}
|
|
4664
|
-
})
|
|
4665
|
-
|
|
4666
|
-
it('serves concurrent loopback requests without cross-request state', async () => {
|
|
4667
|
-
const server = createApplicationServer({ host: '127.0.0.1', port: 0 })
|
|
4668
|
-
try {
|
|
4669
|
-
await server.start()
|
|
4670
|
-
const responses = await Promise.all(
|
|
4671
|
-
Array.from({ length: 8 }, async () => {
|
|
4672
|
-
const response = await fetch(server.url)
|
|
4673
|
-
return { status: response.status, body: await response.json() }
|
|
4674
|
-
}),
|
|
5052
|
+
const method = await dispatcher.handle(
|
|
5053
|
+
new Request(\`http://application.test\${APP_HEALTH_PATH}\`, { method: 'POST' }),
|
|
5054
|
+
state,
|
|
4675
5055
|
)
|
|
4676
|
-
|
|
4677
|
-
for (const response of responses) {
|
|
4678
|
-
expect(response).toEqual({
|
|
4679
|
-
status: 200,
|
|
4680
|
-
body: { name: APP_NAME, status: 'ok' },
|
|
4681
|
-
})
|
|
4682
|
-
}
|
|
4683
|
-
} finally {
|
|
4684
|
-
await server.stop()
|
|
4685
|
-
}
|
|
4686
|
-
})
|
|
4687
|
-
|
|
4688
|
-
it('validates direct options before allocating a listener', () => {
|
|
4689
|
-
expect(() => createApplicationServer({ host: ' ' })).toThrow(
|
|
4690
|
-
expect.objectContaining({ code: 'CONFIG' }),
|
|
4691
|
-
)
|
|
4692
|
-
expect(() => createApplicationServer({ port: Number.NaN })).toThrow(
|
|
4693
|
-
expect.objectContaining({ code: 'CONFIG' }),
|
|
4694
|
-
)
|
|
4695
|
-
expect(() => createApplicationServer({ port: -1 })).toThrow(
|
|
4696
|
-
expect.objectContaining({ code: 'CONFIG' }),
|
|
4697
|
-
)
|
|
4698
|
-
expect(() => createApplicationServer({ timeout: 0 })).toThrow(
|
|
4699
|
-
expect.objectContaining({ code: 'CONFIG' }),
|
|
4700
|
-
)
|
|
4701
|
-
for (const value of [null, 42, [], { host: 42 }, { port: [42] }]) {
|
|
4702
|
-
expect(() => Reflect.apply(createApplicationServer, undefined, [value])).toThrow(
|
|
4703
|
-
expect.objectContaining({ code: 'CONFIG' }),
|
|
4704
|
-
)
|
|
4705
|
-
}
|
|
4706
|
-
expect(createApplicationServer({ host: '::1' }).url).toBe('http://[::1]:3000')
|
|
4707
|
-
})
|
|
5056
|
+
const missing = await dispatcher.handle(new Request('http://application.test/missing'), state)
|
|
4708
5057
|
|
|
4709
|
-
|
|
4710
|
-
|
|
4711
|
-
|
|
4712
|
-
const previousTimeout = process.env.APP_START_TIMEOUT
|
|
4713
|
-
try {
|
|
4714
|
-
process.env.APP_HOST = ' 127.0.0.1 '
|
|
4715
|
-
process.env.APP_PORT = '0'
|
|
4716
|
-
process.env.APP_START_TIMEOUT = '250'
|
|
4717
|
-
const server = createApplicationServer()
|
|
4718
|
-
expect(server.host).toBe('127.0.0.1')
|
|
4719
|
-
expect(server.port).toBe(0)
|
|
4720
|
-
|
|
4721
|
-
process.env.APP_PORT = '1e3'
|
|
4722
|
-
expect(() => createApplicationServer()).toThrow(expect.objectContaining({ code: 'CONFIG' }))
|
|
4723
|
-
process.env.APP_PORT = '0'
|
|
4724
|
-
process.env.APP_START_TIMEOUT = '0'
|
|
4725
|
-
expect(() => createApplicationServer()).toThrow(expect.objectContaining({ code: 'CONFIG' }))
|
|
5058
|
+
expect(method.status).toBe(405)
|
|
5059
|
+
expect(method.headers.get('allow')).toContain(APP_HEALTH_METHOD)
|
|
5060
|
+
expect(missing.status).toBe(404)
|
|
4726
5061
|
} finally {
|
|
4727
|
-
|
|
4728
|
-
else process.env.APP_HOST = previousHost
|
|
4729
|
-
if (previousPort === undefined) delete process.env.APP_PORT
|
|
4730
|
-
else process.env.APP_PORT = previousPort
|
|
4731
|
-
if (previousTimeout === undefined) delete process.env.APP_START_TIMEOUT
|
|
4732
|
-
else process.env.APP_START_TIMEOUT = previousTimeout
|
|
5062
|
+
dispatcher.destroy()
|
|
4733
5063
|
}
|
|
4734
5064
|
})
|
|
5065
|
+
})
|
|
4735
5066
|
|
|
4736
|
-
|
|
5067
|
+
describe('ApplicationServer', () => {
|
|
5068
|
+
it('composes the real substrate on loopback with security and repeatable lifecycle', async () => {
|
|
4737
5069
|
const server = createApplicationServer({
|
|
4738
|
-
host: '127.0.0.1',
|
|
4739
|
-
port: 0,
|
|
4740
|
-
timeout: 1_000,
|
|
5070
|
+
server: { host: '127.0.0.1', port: 0, timeout: 1_000 },
|
|
4741
5071
|
})
|
|
4742
|
-
const controller = new AbortController()
|
|
4743
|
-
controller.abort(new Error('cancelled by test'))
|
|
4744
5072
|
try {
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
expect(
|
|
4748
|
-
expect(server.
|
|
4749
|
-
|
|
4750
|
-
await server.stop()
|
|
5073
|
+
expect(server.host).toBe('127.0.0.1')
|
|
5074
|
+
expect(server.port).toBeUndefined()
|
|
5075
|
+
expect(server.url).toBeUndefined()
|
|
5076
|
+
expect(server.status).toBe('idle')
|
|
4751
5077
|
await server.start()
|
|
4752
|
-
expect(
|
|
4753
|
-
|
|
4754
|
-
|
|
4755
|
-
|
|
4756
|
-
|
|
4757
|
-
|
|
4758
|
-
|
|
4759
|
-
|
|
4760
|
-
|
|
4761
|
-
|
|
4762
|
-
|
|
4763
|
-
await
|
|
4764
|
-
code: 'LIFECYCLE',
|
|
4765
|
-
})
|
|
4766
|
-
expect(server.listening).toBe(false)
|
|
5078
|
+
expect(server.status).toBe('listening')
|
|
5079
|
+
expect(server.port).toEqual(expect.any(Number))
|
|
5080
|
+
const firstUrl = server.url
|
|
5081
|
+
if (firstUrl === undefined) throw new Error('Expected a bound application URL')
|
|
5082
|
+
|
|
5083
|
+
const first = await fetch(\`\${firstUrl}\${APP_HEALTH_PATH}\`)
|
|
5084
|
+
expect(first.status).toBe(200)
|
|
5085
|
+
expect(first.headers.get('x-content-type-options')).toBe('nosniff')
|
|
5086
|
+
expect(first.headers.get('x-frame-options')).toBe('DENY')
|
|
5087
|
+
expect(first.headers.get('content-security-policy')).not.toBeNull()
|
|
5088
|
+
expect(first.headers.get('x-request-id')).not.toBeNull()
|
|
5089
|
+
expect(await first.json()).toEqual({ name: APP_NAME, status: 'ok' })
|
|
4767
5090
|
|
|
4768
|
-
await server.start()
|
|
4769
|
-
expect((await fetch(server.url)).status).toBe(200)
|
|
4770
|
-
} finally {
|
|
4771
5091
|
await server.stop()
|
|
4772
|
-
}
|
|
4773
|
-
})
|
|
4774
|
-
|
|
4775
|
-
it('fails closed on a port collision and preserves the owning server', async () => {
|
|
4776
|
-
const owner = createApplicationServer({ host: '127.0.0.1', port: 0 })
|
|
4777
|
-
let blocked: ReturnType<typeof createApplicationServer> | undefined
|
|
4778
|
-
try {
|
|
4779
|
-
await owner.start()
|
|
4780
|
-
blocked = createApplicationServer({ host: '127.0.0.1', port: owner.port })
|
|
4781
|
-
await expect(blocked.start()).rejects.toMatchObject({ code: 'LIFECYCLE' })
|
|
4782
|
-
expect(blocked.listening).toBe(false)
|
|
4783
|
-
expect((await fetch(owner.url)).status).toBe(200)
|
|
4784
|
-
} finally {
|
|
4785
|
-
await blocked?.stop()
|
|
4786
|
-
await owner.stop()
|
|
4787
|
-
}
|
|
4788
|
-
})
|
|
4789
|
-
|
|
4790
|
-
it('restarts cleanly after a completed stop', async () => {
|
|
4791
|
-
const server = createApplicationServer({ host: '127.0.0.1', port: 0 })
|
|
4792
|
-
try {
|
|
4793
|
-
await server.start()
|
|
4794
5092
|
await server.stop()
|
|
4795
|
-
|
|
4796
|
-
expect(
|
|
4797
|
-
|
|
4798
|
-
await server.stop()
|
|
4799
|
-
}
|
|
4800
|
-
})
|
|
5093
|
+
expect(server.port).toBeUndefined()
|
|
5094
|
+
expect(server.url).toBeUndefined()
|
|
5095
|
+
expect(server.status).toBe('stopped')
|
|
4801
5096
|
|
|
4802
|
-
it('requests a fresh ephemeral port when the previous port is occupied', async () => {
|
|
4803
|
-
const server = createApplicationServer({ host: '127.0.0.1', port: 0 })
|
|
4804
|
-
const occupant = createServer()
|
|
4805
|
-
try {
|
|
4806
5097
|
await server.start()
|
|
4807
|
-
const
|
|
5098
|
+
const secondUrl = server.url
|
|
5099
|
+
if (secondUrl === undefined) throw new Error('Expected a rebound application URL')
|
|
5100
|
+
const second = await fetch(\`\${secondUrl}\${APP_HEALTH_PATH}\`)
|
|
5101
|
+
expect(second.status).toBe(200)
|
|
4808
5102
|
await server.stop()
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
await server.start()
|
|
4813
|
-
|
|
4814
|
-
expect(server.port).not.toBe(previousPort)
|
|
4815
|
-
expect((await fetch(server.url)).status).toBe(200)
|
|
5103
|
+
await server.destroy()
|
|
5104
|
+
await server.destroy()
|
|
5105
|
+
expect(server.status).toBe('stopped')
|
|
4816
5106
|
} finally {
|
|
4817
|
-
await server.
|
|
4818
|
-
await stopNodeServer(occupant)
|
|
5107
|
+
await server.destroy()
|
|
4819
5108
|
}
|
|
4820
5109
|
})
|
|
4821
5110
|
|
|
4822
|
-
it('
|
|
4823
|
-
|
|
4824
|
-
|
|
4825
|
-
|
|
4826
|
-
|
|
4827
|
-
|
|
4828
|
-
|
|
4829
|
-
|
|
4830
|
-
|
|
4831
|
-
|
|
4832
|
-
} finally {
|
|
4833
|
-
await server.stop()
|
|
5111
|
+
it('rejects invalid grouped host and port options before binding', () => {
|
|
5112
|
+
for (const value of [
|
|
5113
|
+
{ server: { host: ' ' } },
|
|
5114
|
+
{ server: { port: Number.NaN } },
|
|
5115
|
+
{ server: { port: -1 } },
|
|
5116
|
+
{ server: { timeout: 0 } },
|
|
5117
|
+
]) {
|
|
5118
|
+
expect(() => Reflect.apply(createApplicationServer, undefined, [value])).toThrow(
|
|
5119
|
+
expect.objectContaining({ code: 'CONFIG' }),
|
|
5120
|
+
)
|
|
4834
5121
|
}
|
|
4835
5122
|
})
|
|
5123
|
+
})
|
|
4836
5124
|
|
|
4837
|
-
|
|
4838
|
-
|
|
5125
|
+
describe('ApplicationServerRunner', () => {
|
|
5126
|
+
it('serves a real request after a restart queued during shutdown', async () => {
|
|
5127
|
+
const port = await reserveLoopbackPort()
|
|
5128
|
+
const code = process.exitCode
|
|
5129
|
+
const server = createApplicationServer({ server: { host: '127.0.0.1', port } })
|
|
5130
|
+
const runner = new ApplicationServerRunner(server)
|
|
4839
5131
|
try {
|
|
4840
|
-
|
|
4841
|
-
const
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
5132
|
+
process.exitCode = undefined
|
|
5133
|
+
const first = waitForEvent(runner.emitter, 'ready')
|
|
5134
|
+
runner.start()
|
|
5135
|
+
await first
|
|
5136
|
+
const stopped = runner.stop()
|
|
5137
|
+
const second = waitForEvent(runner.emitter, 'ready')
|
|
5138
|
+
runner.start()
|
|
5139
|
+
await stopped
|
|
4846
5140
|
|
|
4847
|
-
|
|
4848
|
-
|
|
4849
|
-
|
|
4850
|
-
|
|
4851
|
-
|
|
4852
|
-
|
|
5141
|
+
const [url] = await second
|
|
5142
|
+
const response = await fetch(\`\${url}\${APP_HEALTH_PATH}\`)
|
|
5143
|
+
expect(response.status).toBe(200)
|
|
5144
|
+
expect(await response.json()).toEqual({ name: APP_NAME, status: 'ok' })
|
|
5145
|
+
const stopping = runner.stop()
|
|
5146
|
+
expect(runner.stop()).toBe(stopping)
|
|
5147
|
+
await stopping
|
|
5148
|
+
expect(process.exitCode).toBeUndefined()
|
|
4853
5149
|
} finally {
|
|
4854
|
-
|
|
5150
|
+
process.exitCode = code
|
|
5151
|
+
await runner.stop()
|
|
4855
5152
|
}
|
|
4856
5153
|
})
|
|
4857
5154
|
|
|
4858
|
-
it('
|
|
4859
|
-
const
|
|
4860
|
-
const
|
|
5155
|
+
it('cancels a startup still in flight so an immediate stop leaves the port free', async () => {
|
|
5156
|
+
const port = await reserveLoopbackPort()
|
|
5157
|
+
const ready = createRecorder<[url: string]>()
|
|
5158
|
+
const failed = createRecorder<[error: unknown]>()
|
|
5159
|
+
const server = createApplicationServer({ server: { host: '127.0.0.1', port } })
|
|
5160
|
+
const runner = new ApplicationServerRunner(server, {
|
|
5161
|
+
on: { ready: ready.handler, fail: failed.handler },
|
|
5162
|
+
})
|
|
4861
5163
|
try {
|
|
4862
|
-
|
|
4863
|
-
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
}
|
|
4869
|
-
const overflow = connect({ host: server.host, port: server.port })
|
|
5164
|
+
runner.start()
|
|
5165
|
+
await runner.stop()
|
|
5166
|
+
expect(ready.count).toBe(0)
|
|
5167
|
+
expect(failed.count).toBe(0)
|
|
5168
|
+
|
|
5169
|
+
const released = await startLoopbackServer(port)
|
|
4870
5170
|
try {
|
|
4871
|
-
|
|
4872
|
-
await once(overflow, 'connect')
|
|
4873
|
-
await closed
|
|
4874
|
-
expect(overflow.destroyed).toBe(true)
|
|
5171
|
+
expect(released.listening).toBe(true)
|
|
4875
5172
|
} finally {
|
|
4876
|
-
|
|
5173
|
+
await stopNodeServer(released)
|
|
4877
5174
|
}
|
|
4878
|
-
const released = sockets.pop()
|
|
4879
|
-
if (released === undefined) throw new Error('expected a held connection')
|
|
4880
|
-
const releasedClose = waitForSocketClose(released)
|
|
4881
|
-
released.destroy()
|
|
4882
|
-
await releasedClose
|
|
4883
|
-
const response = await waitForLoopbackResponse(server.port)
|
|
4884
|
-
expect(response.status).toBe(200)
|
|
4885
5175
|
} finally {
|
|
4886
|
-
|
|
4887
|
-
await server.stop()
|
|
5176
|
+
await runner.stop()
|
|
4888
5177
|
}
|
|
4889
5178
|
})
|
|
4890
5179
|
|
|
4891
|
-
it('
|
|
5180
|
+
it('announces readiness exactly once before the first response and releases on SIGTERM', async () => {
|
|
4892
5181
|
const port = await reserveLoopbackPort()
|
|
4893
5182
|
const application = startApplicationProcess(port)
|
|
4894
5183
|
try {
|
|
4895
|
-
|
|
5184
|
+
await application.ready
|
|
5185
|
+
const announcement = \`[READY] \${APP_NAME} http://127.0.0.1:\${port}\\n\`
|
|
5186
|
+
expect(application.output()).toBe(announcement)
|
|
5187
|
+
|
|
5188
|
+
const response = await fetch(\`http://127.0.0.1:\${port}\${APP_HEALTH_PATH}\`)
|
|
4896
5189
|
expect(response.status).toBe(200)
|
|
4897
5190
|
expect(await response.json()).toEqual({ name: APP_NAME, status: 'ok' })
|
|
5191
|
+
expect(application.output().split('[READY]')).toHaveLength(2)
|
|
4898
5192
|
|
|
4899
5193
|
expect(application.child.kill('SIGTERM')).toBe(true)
|
|
4900
5194
|
const exited = await waitForApplicationProcess(application)
|
|
@@ -4904,10 +5198,8 @@ describe('ApplicationServer', () => {
|
|
|
4904
5198
|
: { code: 0, signal: null },
|
|
4905
5199
|
)
|
|
4906
5200
|
|
|
4907
|
-
const released =
|
|
5201
|
+
const released = await startLoopbackServer(port)
|
|
4908
5202
|
try {
|
|
4909
|
-
released.listen(port, '127.0.0.1')
|
|
4910
|
-
await once(released, 'listening')
|
|
4911
5203
|
expect(released.listening).toBe(true)
|
|
4912
5204
|
} finally {
|
|
4913
5205
|
await stopNodeServer(released)
|
|
@@ -4920,105 +5212,81 @@ describe('ApplicationServer', () => {
|
|
|
4920
5212
|
}
|
|
4921
5213
|
})
|
|
4922
5214
|
|
|
4923
|
-
it('
|
|
5215
|
+
it('never announces readiness when a real bind fails', async () => {
|
|
4924
5216
|
const port = await reserveLoopbackPort()
|
|
4925
|
-
const owner =
|
|
4926
|
-
owner.listen(port, '127.0.0.1')
|
|
4927
|
-
await once(owner, 'listening')
|
|
5217
|
+
const owner = await startLoopbackServer(port)
|
|
4928
5218
|
const application = startApplicationProcess(port)
|
|
4929
5219
|
try {
|
|
4930
5220
|
const exited = await waitForApplicationProcess(application)
|
|
4931
5221
|
expect(exited).toEqual({ code: 1, signal: null })
|
|
4932
5222
|
expect(application.output()).toBe('[LIFECYCLE] Application server lifecycle failed\\n')
|
|
5223
|
+
expect(application.output()).not.toContain('[READY]')
|
|
4933
5224
|
expect(application.output()).not.toContain('EADDRINUSE')
|
|
4934
|
-
expect(application.output()).not.toContain('context')
|
|
4935
|
-
expect(application.output()).not.toContain('cause')
|
|
4936
|
-
expect(application.output()).not.toContain('at ')
|
|
4937
5225
|
expect(owner.listening).toBe(true)
|
|
4938
5226
|
} finally {
|
|
4939
|
-
if (application.child.exitCode === null && application.child.signalCode === null) {
|
|
4940
|
-
application.child.kill('SIGKILL')
|
|
4941
|
-
await waitForApplicationProcess(application)
|
|
4942
|
-
}
|
|
4943
5227
|
await stopNodeServer(owner)
|
|
4944
5228
|
}
|
|
4945
5229
|
})
|
|
4946
5230
|
|
|
4947
|
-
it('
|
|
4948
|
-
const secret = 'SENTINEL_SECRET/invalid'
|
|
4949
|
-
const application = startApplicationProcess(0, { APP_HOST: secret })
|
|
4950
|
-
const exited = await waitForApplicationProcess(application)
|
|
4951
|
-
expect(exited).toEqual({ code: 1, signal: null })
|
|
4952
|
-
expect(application.output()).toBe('[CONFIG] Application server configuration failed\\n')
|
|
4953
|
-
expect(application.output()).not.toContain(secret)
|
|
4954
|
-
expect(application.output()).not.toContain('context')
|
|
4955
|
-
expect(application.output()).not.toContain('cause')
|
|
4956
|
-
expect(application.output()).not.toContain('at ')
|
|
4957
|
-
})
|
|
4958
|
-
|
|
4959
|
-
it('releases process listeners through explicit, repeated, convenience, and signal cleanup', async () => {
|
|
4960
|
-
const signalCount = process.listenerCount('SIGTERM')
|
|
4961
|
-
const interruptCount = process.listenerCount('SIGINT')
|
|
4962
|
-
const runner = new ApplicationServerRunner({ host: '127.0.0.1', port: 0 })
|
|
4963
|
-
try {
|
|
4964
|
-
expect(runner.start()).toBeUndefined()
|
|
4965
|
-
expect(runner.start()).toBeUndefined()
|
|
4966
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount + 1)
|
|
4967
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount + 1)
|
|
4968
|
-
|
|
4969
|
-
await runner.stop()
|
|
4970
|
-
await runner.stop()
|
|
4971
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount)
|
|
4972
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount)
|
|
4973
|
-
|
|
4974
|
-
const convenience = startApplicationServer({ host: '127.0.0.1', port: 0 })
|
|
4975
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount + 1)
|
|
4976
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount + 1)
|
|
4977
|
-
await convenience.stop()
|
|
4978
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount)
|
|
4979
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount)
|
|
4980
|
-
|
|
4981
|
-
runner.start()
|
|
4982
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount + 1)
|
|
4983
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount + 1)
|
|
4984
|
-
process.emit('SIGTERM')
|
|
4985
|
-
await runner.stop()
|
|
4986
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount)
|
|
4987
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount)
|
|
4988
|
-
} finally {
|
|
4989
|
-
await runner.stop()
|
|
4990
|
-
}
|
|
4991
|
-
})
|
|
4992
|
-
|
|
4993
|
-
it('keeps a newer runner generation owned when an older start fails during restart', async () => {
|
|
4994
|
-
const signalCount = process.listenerCount('SIGTERM')
|
|
4995
|
-
const interruptCount = process.listenerCount('SIGINT')
|
|
4996
|
-
const previousExitCode = process.exitCode
|
|
5231
|
+
it('isolates a throwing fail listener, releases ownership, and starts again', async () => {
|
|
4997
5232
|
const port = await reserveLoopbackPort()
|
|
4998
|
-
const owner =
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5233
|
+
const owner = await startLoopbackServer(port)
|
|
5234
|
+
const interrupts = process.listenerCount('SIGINT')
|
|
5235
|
+
const terminations = process.listenerCount('SIGTERM')
|
|
5236
|
+
// The reporter owns the process exit code, so this in-process failure records the
|
|
5237
|
+
// code it set and then restores whatever the surrounding run had.
|
|
5238
|
+
const code = process.exitCode
|
|
5239
|
+
const errors = createRecorder<[error: unknown, event: string]>()
|
|
5240
|
+
const server = createApplicationServer({ server: { host: '127.0.0.1', port } })
|
|
5241
|
+
const runner = new ApplicationServerRunner(server, {
|
|
5242
|
+
on: {
|
|
5243
|
+
fail: () => {
|
|
5244
|
+
throw new Error('listener boom')
|
|
5245
|
+
},
|
|
5246
|
+
},
|
|
5247
|
+
error: errors.handler,
|
|
5248
|
+
})
|
|
5002
5249
|
try {
|
|
5250
|
+
process.exitCode = undefined
|
|
5251
|
+
const failed = waitForEvent(runner.emitter, 'fail')
|
|
5003
5252
|
runner.start()
|
|
5004
|
-
|
|
5005
|
-
|
|
5006
|
-
runner.start()
|
|
5007
|
-
await stopNodeServer(owner)
|
|
5008
|
-
await stopping
|
|
5253
|
+
expect(process.listenerCount('SIGINT')).toBe(interrupts + 1)
|
|
5254
|
+
expect(process.listenerCount('SIGTERM')).toBe(terminations + 1)
|
|
5009
5255
|
|
|
5010
|
-
const
|
|
5256
|
+
const [failure] = await failed
|
|
5257
|
+
expect(failure).toEqual(expect.objectContaining({ code: 'LIFECYCLE' }))
|
|
5258
|
+
expect(errors.count).toBe(1)
|
|
5259
|
+
expect(errors.calls[0]?.[1]).toBe('fail')
|
|
5260
|
+
expect(process.listenerCount('SIGINT')).toBe(interrupts)
|
|
5261
|
+
expect(process.listenerCount('SIGTERM')).toBe(terminations)
|
|
5262
|
+
expect(process.exitCode).toBe(1)
|
|
5263
|
+
|
|
5264
|
+
await stopNodeServer(owner)
|
|
5265
|
+
const ready = waitForEvent(runner.emitter, 'ready')
|
|
5266
|
+
runner.start()
|
|
5267
|
+
const [url] = await ready
|
|
5268
|
+
const response = await fetch(\`\${url}\${APP_HEALTH_PATH}\`)
|
|
5011
5269
|
expect(response.status).toBe(200)
|
|
5012
|
-
expect(process.exitCode).toBe(previousExitCode)
|
|
5013
|
-
expect(process.listenerCount('SIGTERM')).toBe(signalCount + 1)
|
|
5014
|
-
expect(process.listenerCount('SIGINT')).toBe(interruptCount + 1)
|
|
5015
5270
|
} finally {
|
|
5271
|
+
process.exitCode = code
|
|
5016
5272
|
await runner.stop()
|
|
5017
5273
|
await stopNodeServer(owner)
|
|
5018
|
-
process.exitCode = previousExitCode
|
|
5019
5274
|
}
|
|
5020
5275
|
})
|
|
5021
|
-
|
|
5276
|
+
|
|
5277
|
+
it('redacts malformed environment values from executable diagnostics', async () => {
|
|
5278
|
+
const secret = 'SENTINEL_SECRET/invalid'
|
|
5279
|
+
const application = startApplicationProcess(0, { APP_HOST: secret })
|
|
5280
|
+
const exited = await waitForApplicationProcess(application)
|
|
5281
|
+
expect(exited).toEqual({ code: 1, signal: null })
|
|
5282
|
+
expect(application.output()).toBe('[CONFIG] Application server configuration failed\\n')
|
|
5283
|
+
expect(application.output()).not.toContain(secret)
|
|
5284
|
+
expect(application.output()).not.toContain('[READY]')
|
|
5285
|
+
expect(application.output()).not.toContain('context')
|
|
5286
|
+
expect(application.output()).not.toContain('cause')
|
|
5287
|
+
expect(application.output()).not.toContain('at ')
|
|
5288
|
+
})
|
|
5289
|
+
}){{boundary}}
|
|
5022
5290
|
`
|
|
5023
5291
|
}),
|
|
5024
5292
|
appServerParsersTest: Object.freeze({
|
|
@@ -5042,6 +5310,7 @@ import { describe, expect, it } from 'vitest'
|
|
|
5042
5310
|
|
|
5043
5311
|
describe('application environment parsers', () => {
|
|
5044
5312
|
it('accepts port boundaries and trims a host', () => {
|
|
5313
|
+
expect(parseApplicationPort(3000)).toBe(3000)
|
|
5045
5314
|
expect(parseApplicationPort('0')).toBe(0)
|
|
5046
5315
|
expect(parseApplicationPort('65535')).toBe(65_535)
|
|
5047
5316
|
expect(parseApplicationPort(' 3000 ')).toBe(3000)
|
|
@@ -5053,6 +5322,19 @@ describe('application environment parsers', () => {
|
|
|
5053
5322
|
expect(parseApplicationStartTimeout(String(MAX_APP_START_TIMEOUT))).toBe(MAX_APP_START_TIMEOUT)
|
|
5054
5323
|
})
|
|
5055
5324
|
|
|
5325
|
+
it('never coerces hostile numeric objects through toString', () => {
|
|
5326
|
+
let calls = 0
|
|
5327
|
+
const hostile = {
|
|
5328
|
+
toString() {
|
|
5329
|
+
calls += 1
|
|
5330
|
+
return '3000'
|
|
5331
|
+
},
|
|
5332
|
+
}
|
|
5333
|
+
|
|
5334
|
+
expect(() => parseApplicationPort(hostile)).toThrow(ApplicationServerError)
|
|
5335
|
+
expect(calls).toBe(0)
|
|
5336
|
+
})
|
|
5337
|
+
|
|
5056
5338
|
it.each(['', '-1', '65536', '1.5', '+1', '0x10', '1e3', 'NaN', 'Infinity'])(
|
|
5057
5339
|
'rejects hostile APP_PORT value %s',
|
|
5058
5340
|
(value) => {
|
|
@@ -5094,7 +5376,15 @@ describe('application environment parsers', () => {
|
|
|
5094
5376
|
expect(() => parseApplicationHost(value)).toThrow(expect.objectContaining({ code: 'CONFIG' }))
|
|
5095
5377
|
})
|
|
5096
5378
|
|
|
5097
|
-
it
|
|
5379
|
+
it('parses the exact grouped server record including its timeout leaf', () => {
|
|
5380
|
+
expect(
|
|
5381
|
+
parseApplicationServerOptions({
|
|
5382
|
+
server: { host: ' 127.0.0.1 ', port: 0, timeout: 250 },
|
|
5383
|
+
}),
|
|
5384
|
+
).toEqual({ server: { host: '127.0.0.1', port: 0, timeout: 250 } })
|
|
5385
|
+
})
|
|
5386
|
+
|
|
5387
|
+
it.each([null, 42, [], { server: null }, { server: { port: [42] } }, { server: { timeout: 0 } }])(
|
|
5098
5388
|
'rejects hostile option container or leaf value %#',
|
|
5099
5389
|
(value) => {
|
|
5100
5390
|
expect(() => parseApplicationServerOptions(value)).toThrow(
|
|
@@ -5104,11 +5394,14 @@ describe('application environment parsers', () => {
|
|
|
5104
5394
|
)
|
|
5105
5395
|
|
|
5106
5396
|
it.each([
|
|
5107
|
-
Object.create({ host: '0.0.0.0' }),
|
|
5397
|
+
Object.create({ server: { host: '0.0.0.0' } }),
|
|
5108
5398
|
{ post: 0 },
|
|
5109
5399
|
new Date(),
|
|
5110
|
-
{
|
|
5111
|
-
|
|
5400
|
+
{ server: Object.create({ host: '0.0.0.0' }) },
|
|
5401
|
+
{ server: { post: 0 } },
|
|
5402
|
+
{ server: { [Symbol('host')]: '0.0.0.0' } },
|
|
5403
|
+
Object.defineProperty({}, 'server', { get: () => ({ host: '0.0.0.0' }) }),
|
|
5404
|
+
{ server: Object.defineProperty({}, 'host', { get: () => '0.0.0.0' }) },
|
|
5112
5405
|
new Proxy(
|
|
5113
5406
|
{},
|
|
5114
5407
|
{
|
|
@@ -5117,6 +5410,16 @@ describe('application environment parsers', () => {
|
|
|
5117
5410
|
},
|
|
5118
5411
|
},
|
|
5119
5412
|
),
|
|
5413
|
+
{
|
|
5414
|
+
server: new Proxy(
|
|
5415
|
+
{},
|
|
5416
|
+
{
|
|
5417
|
+
ownKeys: () => {
|
|
5418
|
+
throw new Error('hostile nested ownKeys trap')
|
|
5419
|
+
},
|
|
5420
|
+
},
|
|
5421
|
+
),
|
|
5422
|
+
},
|
|
5120
5423
|
])(
|
|
5121
5424
|
'rejects inherited, unknown, symbolic, accessor, instance, and hostile proxy options %#',
|
|
5122
5425
|
(value) => {
|
|
@@ -5126,6 +5429,19 @@ describe('application environment parsers', () => {
|
|
|
5126
5429
|
},
|
|
5127
5430
|
)
|
|
5128
5431
|
|
|
5432
|
+
it('does not invoke grouped option accessors', () => {
|
|
5433
|
+
let reads = 0
|
|
5434
|
+
const server = Object.defineProperty({}, 'timeout', {
|
|
5435
|
+
get() {
|
|
5436
|
+
reads += 1
|
|
5437
|
+
return 250
|
|
5438
|
+
},
|
|
5439
|
+
})
|
|
5440
|
+
|
|
5441
|
+
expect(() => parseApplicationServerOptions({ server })).toThrow(ApplicationServerError)
|
|
5442
|
+
expect(reads).toBe(0)
|
|
5443
|
+
})
|
|
5444
|
+
|
|
5129
5445
|
it('never reflects a hostile symbolic option name into diagnostics', () => {
|
|
5130
5446
|
let caught: unknown
|
|
5131
5447
|
try {
|
|
@@ -5175,9 +5491,7 @@ ${IMPORT_KEYWORD} { createSource, parseManifest } from '@orkestrel/guide'
|
|
|
5175
5491
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} GUIDE_ROOT = fileURLToPath(new URL('../', import.meta.url))
|
|
5176
5492
|
|
|
5177
5493
|
/** Repository roots whose TypeScript and Markdown files participate in guide parity. */
|
|
5178
|
-
${EXPORT_KEYWORD} ${CONST_KEYWORD} GUIDE_WALK_DIRECTORIES: readonly string[] = Object.freeze(
|
|
5179
|
-
{{walkDirs}}
|
|
5180
|
-
])
|
|
5494
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} GUIDE_WALK_DIRECTORIES: readonly string[] = Object.freeze({{walkDirs}})
|
|
5181
5495
|
|
|
5182
5496
|
{{specifiers}}
|
|
5183
5497
|
|
|
@@ -5569,7 +5883,9 @@ function devDependenciesFor(spec) {
|
|
|
5569
5883
|
return {
|
|
5570
5884
|
...dependencies,
|
|
5571
5885
|
...spec.src.includes("browser") ? SOURCE_BROWSER_DEV_DEPENDENCIES : {},
|
|
5886
|
+
...spec.app.length > 0 ? APP_DEV_DEPENDENCIES : {},
|
|
5572
5887
|
...spec.app.includes("browser") ? APP_BROWSER_DEV_DEPENDENCIES : {},
|
|
5888
|
+
...spec.app.includes("server") ? APP_SERVER_DEV_DEPENDENCIES : {},
|
|
5573
5889
|
...spec.showcase ? { "vite-plugin-singlefile": "^2.3.3" } : {},
|
|
5574
5890
|
...spec.bin ? { "@vitest/browser-playwright": SOURCE_BROWSER_DEV_DEPENDENCIES["@vitest/browser-playwright"] } : {}
|
|
5575
5891
|
};
|
|
@@ -5624,6 +5940,7 @@ function packageManifest(spec) {
|
|
|
5624
5940
|
...hasSource || spec.bin ? ["npm run test:src"] : [],
|
|
5625
5941
|
...spec.app.length > 0 ? ["npm run test:app"] : [],
|
|
5626
5942
|
"npm run test:policy",
|
|
5943
|
+
"npm run test:config",
|
|
5627
5944
|
"npm run test:guides"
|
|
5628
5945
|
].join(" && ");
|
|
5629
5946
|
if (hasSource || spec.bin) {
|
|
@@ -5639,6 +5956,7 @@ function packageManifest(spec) {
|
|
|
5639
5956
|
for (const environment of spec.app) scripts[`test:app:${environment}`] = `vitest run --config vite.config.ts --no-cache --reporter=dot --project ${APP_MATRIX[environment].project}`;
|
|
5640
5957
|
}
|
|
5641
5958
|
scripts["test:policy"] = "vitest run --config vite.config.ts --no-cache --reporter=dot --project policy";
|
|
5959
|
+
scripts["test:config"] = "vitest run --config vite.config.ts --no-cache --reporter=dot --project config";
|
|
5642
5960
|
scripts["test:guides"] = "vitest run --config vite.config.ts --reporter=dot --project guides";
|
|
5643
5961
|
scripts.build = [
|
|
5644
5962
|
"npm run clean",
|
|
@@ -5659,7 +5977,7 @@ function packageManifest(spec) {
|
|
|
5659
5977
|
if (spec.showcase) {
|
|
5660
5978
|
scripts.showcase = `vite --config ${SHOWCASE_CONFIG_PATH}`;
|
|
5661
5979
|
scripts["build:showcase"] = `vite build --config ${SHOWCASE_CONFIG_PATH}`;
|
|
5662
|
-
scripts.show = "npm run build:showcase && npm run copy dist/showcase/index.html demo/showcase.html";
|
|
5980
|
+
scripts.show = "npm run format && npm run build:showcase && npm run copy dist/showcase/index.html demo/showcase.html";
|
|
5663
5981
|
}
|
|
5664
5982
|
}
|
|
5665
5983
|
if (spec.app.includes("server")) {
|
|
@@ -5809,7 +6127,7 @@ function viteMachinery(src, app = [], bin = false, showcase = false) {
|
|
|
5809
6127
|
* @example
|
|
5810
6128
|
* ```ts
|
|
5811
6129
|
* viteProjectRegistrations(['core'], [], { integration: true })
|
|
5812
|
-
* // [{ project: 'srcCore' }, { project: 'policy' }, { project: 'guides' }, { project: 'integration' }]
|
|
6130
|
+
* // [{ project: 'srcCore' }, { project: 'policy' }, { project: 'config' }, { project: 'guides' }, { project: 'integration' }]
|
|
5813
6131
|
* ```
|
|
5814
6132
|
*/
|
|
5815
6133
|
function viteProjectRegistrations(src, app = [], facts = {}) {
|
|
@@ -5832,7 +6150,7 @@ function viteProjectRegistrations(src, app = [], facts = {}) {
|
|
|
5832
6150
|
});
|
|
5833
6151
|
if (environment === "server") registrations.push({ project: "appServer" });
|
|
5834
6152
|
}
|
|
5835
|
-
registrations.push({ project: "policy" }, { project: "guides" });
|
|
6153
|
+
registrations.push({ project: "policy" }, { project: "config" }, { project: "guides" });
|
|
5836
6154
|
if (facts.bin === true) registrations.push({ project: "srcBin" });
|
|
5837
6155
|
if (facts.integration === true) registrations.push({ project: "integration" });
|
|
5838
6156
|
if (facts.service === true) registrations.push({ project: "service" });
|
|
@@ -5842,7 +6160,7 @@ function viteProjectRegistrations(src, app = [], facts = {}) {
|
|
|
5842
6160
|
* Render the one ordered proof and structural-axis project definition block.
|
|
5843
6161
|
*
|
|
5844
6162
|
* @param facts - Optional structural facts.
|
|
5845
|
-
* @returns Policy, guides, then selected axis project definitions, separated by one blank line.
|
|
6163
|
+
* @returns Policy, config, guides, then selected axis project definitions, separated by one blank line.
|
|
5846
6164
|
*
|
|
5847
6165
|
* @example
|
|
5848
6166
|
* ```ts
|
|
@@ -5850,7 +6168,11 @@ function viteProjectRegistrations(src, app = [], facts = {}) {
|
|
|
5850
6168
|
* ```
|
|
5851
6169
|
*/
|
|
5852
6170
|
function viteProjectDefinitions(facts = {}) {
|
|
5853
|
-
const definitions = [
|
|
6171
|
+
const definitions = [
|
|
6172
|
+
policyViteProject(),
|
|
6173
|
+
configViteProject(),
|
|
6174
|
+
guidesViteProject()
|
|
6175
|
+
];
|
|
5854
6176
|
if (facts.bin === true) definitions.push(binViteProject());
|
|
5855
6177
|
if (facts.integration === true) definitions.push(integrationViteProject(facts));
|
|
5856
6178
|
if (facts.service === true) definitions.push(serviceViteProject());
|
|
@@ -5916,9 +6238,11 @@ import { chromium } from 'playwright'
|
|
|
5916
6238
|
` : "";
|
|
5917
6239
|
const vueImports = needsVue ? `import vue from '@vitejs/plugin-vue'
|
|
5918
6240
|
import { parse as parseVue } from 'vue/compiler-sfc'
|
|
6241
|
+
import { parseStartTag } from '@orkestrel/html'
|
|
5919
6242
|
` : "";
|
|
5920
6243
|
const showcaseImports = needsShowcase ? `import { viteSingleFile } from 'vite-plugin-singlefile'
|
|
5921
6244
|
` : "";
|
|
6245
|
+
const showcaseHashImport = needsShowcase ? "import { createHash } from 'node:crypto'\n" : "";
|
|
5922
6246
|
const viteTypeImports = needsVue ? `import type {
|
|
5923
6247
|
CSSOptions,
|
|
5924
6248
|
HtmlAssetSource,
|
|
@@ -6880,12 +7204,40 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} HTML_SECURITY_POLICY =
|
|
|
6880
7204
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} HTML_SECURITY_META =
|
|
6881
7205
|
'<meta\\n\\t\\t\\thttp-equiv="Content-Security-Policy"\\n\\t\\t\\tcontent="' +
|
|
6882
7206
|
HTML_SECURITY_POLICY +
|
|
7207
|
+
'"\\n\\t\\t/>'${needsShowcase ? `
|
|
7208
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} SHOWCASE_SECURITY_POLICY =
|
|
7209
|
+
"default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'self'; style-src 'unsafe-inline'; img-src data:; font-src data:; script-src-attr 'none'"
|
|
7210
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} SHOWCASE_SECURITY_META =
|
|
7211
|
+
'<meta\\n\\t\\t\\thttp-equiv="Content-Security-Policy"\\n\\t\\t\\tcontent="' +
|
|
7212
|
+
SHOWCASE_SECURITY_POLICY +
|
|
7213
|
+
'"\\n\\t\\t/>'
|
|
7214
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} SHOWCASE_BUILD_SECURITY_POLICY =
|
|
7215
|
+
"default-src 'none'; base-uri 'none'; object-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:; script-src-attr 'none'"
|
|
7216
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} SHOWCASE_BUILD_SECURITY_META =
|
|
7217
|
+
'<meta\\n\\t\\t\\thttp-equiv="Content-Security-Policy"\\n\\t\\t\\tcontent="' +
|
|
7218
|
+
SHOWCASE_BUILD_SECURITY_POLICY +
|
|
6883
7219
|
'"\\n\\t\\t/>'
|
|
6884
|
-
|
|
6885
|
-
|
|
7220
|
+
` : ""}
|
|
7221
|
+
|
|
7222
|
+
${EXPORT_KEYWORD} function hasSecurityPrologue(html: string, security: string): boolean {
|
|
7223
|
+
const normalized = html.replaceAll('\\r\\n', '\\n')
|
|
7224
|
+
const doctype = '<!doctype html>\\n'
|
|
7225
|
+
if (!normalized.startsWith(doctype)) return false
|
|
7226
|
+
const root = parseStartTag(normalized, doctype.length)
|
|
7227
|
+
return (
|
|
7228
|
+
root !== undefined &&
|
|
7229
|
+
root.name === 'html' &&
|
|
7230
|
+
!root.slashed &&
|
|
7231
|
+
normalized.startsWith('\\n\\t<head>\\n\\t\\t' + security + '\\n', root.next)
|
|
7232
|
+
)
|
|
7233
|
+
}
|
|
6886
7234
|
|
|
6887
|
-
${EXPORT_KEYWORD} function maskIgnoredHtml(
|
|
6888
|
-
|
|
7235
|
+
${EXPORT_KEYWORD} function maskIgnoredHtml(
|
|
7236
|
+
environmentKeys: ReadonlySet<string>,
|
|
7237
|
+
html: string,
|
|
7238
|
+
security: string,
|
|
7239
|
+
): string {
|
|
7240
|
+
if (!hasSecurityPrologue(html, security)) {
|
|
6889
7241
|
throw new Error(
|
|
6890
7242
|
'[orkestrel-environment-boundary] Browser HTML must preserve the generated security prologue',
|
|
6891
7243
|
)
|
|
@@ -6919,12 +7271,27 @@ ${EXPORT_KEYWORD} function maskIgnoredHtml(environmentKeys: ReadonlySet<string>,
|
|
|
6919
7271
|
)
|
|
6920
7272
|
}
|
|
6921
7273
|
|
|
6922
|
-
${EXPORT_KEYWORD} function
|
|
7274
|
+
${needsShowcase ? `${EXPORT_KEYWORD} function isShowcaseHtmlEntry(filename: string): boolean {
|
|
6923
7275
|
return (
|
|
6924
|
-
physicalPath(filename) ===
|
|
7276
|
+
physicalPath(filename) ===
|
|
7277
|
+
physicalPath(resolvePath(WORKSPACE_ROOT, 'app/browser/showcase.html'))
|
|
6925
7278
|
)
|
|
6926
7279
|
}
|
|
6927
7280
|
|
|
7281
|
+
` : ""}${EXPORT_KEYWORD} function isBrowserHtmlEntry(filename: string): boolean {
|
|
7282
|
+
${needsShowcase ? `return (
|
|
7283
|
+
isShowcaseHtmlEntry(filename) ||
|
|
7284
|
+
physicalPath(filename) === physicalPath(resolvePath(WORKSPACE_ROOT, 'app/browser/index.html'))
|
|
7285
|
+
)` : `return (
|
|
7286
|
+
physicalPath(filename) === physicalPath(resolvePath(WORKSPACE_ROOT, 'app/browser/index.html'))
|
|
7287
|
+
)`}
|
|
7288
|
+
}${needsShowcase ? `
|
|
7289
|
+
${EXPORT_KEYWORD} function browserHtmlSecurityMeta(filename: string, built: boolean): string | undefined {
|
|
7290
|
+
if (!isBrowserHtmlEntry(filename)) return undefined
|
|
7291
|
+
if (!isShowcaseHtmlEntry(filename)) return HTML_SECURITY_META
|
|
7292
|
+
return built ? SHOWCASE_BUILD_SECURITY_META : SHOWCASE_SECURITY_META
|
|
7293
|
+
}` : ""}
|
|
7294
|
+
|
|
6928
7295
|
${EXPORT_KEYWORD} function prepareHtml(): Plugin {
|
|
6929
7296
|
const environmentKeys = new Set<string>()
|
|
6930
7297
|
return {
|
|
@@ -6941,8 +7308,10 @@ ${EXPORT_KEYWORD} function prepareHtml(): Plugin {
|
|
|
6941
7308
|
transformIndexHtml: {
|
|
6942
7309
|
order: 'pre',
|
|
6943
7310
|
handler(html, context) {
|
|
6944
|
-
|
|
6945
|
-
|
|
7311
|
+
${needsShowcase ? `const security = browserHtmlSecurityMeta(context.filename, false)
|
|
7312
|
+
if (security === undefined) return undefined
|
|
7313
|
+
return maskIgnoredHtml(environmentKeys, html, security)` : `if (!isBrowserHtmlEntry(context.filename)) return undefined
|
|
7314
|
+
return maskIgnoredHtml(environmentKeys, html, HTML_SECURITY_META)`}
|
|
6946
7315
|
},
|
|
6947
7316
|
},
|
|
6948
7317
|
}
|
|
@@ -6966,8 +7335,13 @@ ${EXPORT_KEYWORD} function finalizeHtml(): Plugin {
|
|
|
6966
7335
|
transformIndexHtml: {
|
|
6967
7336
|
order: 'post',
|
|
6968
7337
|
handler(html, context) {
|
|
6969
|
-
|
|
6970
|
-
|
|
7338
|
+
${needsShowcase ? `const security = browserHtmlSecurityMeta(
|
|
7339
|
+
context.filename,
|
|
7340
|
+
context.bundle !== undefined,
|
|
7341
|
+
)
|
|
7342
|
+
if (security === undefined) return undefined
|
|
7343
|
+
if (!html.includes(security)) {` : `if (!isBrowserHtmlEntry(context.filename)) return undefined
|
|
7344
|
+
if (!html.includes(HTML_SECURITY_META)) {`}
|
|
6971
7345
|
throw new Error(
|
|
6972
7346
|
'[orkestrel-environment-boundary] Browser HTML must retain its security policy',
|
|
6973
7347
|
)
|
|
@@ -6977,26 +7351,49 @@ ${EXPORT_KEYWORD} function finalizeHtml(): Plugin {
|
|
|
6977
7351
|
}
|
|
6978
7352
|
}
|
|
6979
7353
|
|
|
6980
|
-
` : ""}${needsShowcase ? `${EXPORT_KEYWORD}
|
|
6981
|
-
"base-uri 'none'; object-src 'none'; script-src 'self' 'unsafe-inline'; script-src-attr 'none'; style-src 'self' 'unsafe-inline'"
|
|
6982
|
-
|
|
6983
|
-
${EXPORT_KEYWORD} function showcaseHtml(): Plugin {
|
|
7354
|
+
` : ""}${needsShowcase ? `${EXPORT_KEYWORD} function showcaseHtml(): Plugin {
|
|
6984
7355
|
return {
|
|
6985
7356
|
name: 'orkestrel-showcase-html',
|
|
6986
|
-
enforce: 'post',
|
|
6987
7357
|
transformIndexHtml: {
|
|
6988
7358
|
order: 'post',
|
|
6989
7359
|
handler(html, context) {
|
|
6990
|
-
if (!
|
|
6991
|
-
|
|
6992
|
-
|
|
7360
|
+
if (!isShowcaseHtmlEntry(context.filename) || context.bundle === undefined) {
|
|
7361
|
+
return undefined
|
|
7362
|
+
}
|
|
7363
|
+
if (!html.includes(SHOWCASE_SECURITY_META)) {
|
|
7364
|
+
throw new Error(
|
|
7365
|
+
'[orkestrel-showcase-html] Showcase build did not retain its development security policy',
|
|
7366
|
+
)
|
|
7367
|
+
}
|
|
7368
|
+
const secured = html.replace(SHOWCASE_SECURITY_META, SHOWCASE_BUILD_SECURITY_META)
|
|
7369
|
+
const build = createHash('sha256').update(secured).digest('hex')
|
|
7370
|
+
return {
|
|
7371
|
+
html: secured,
|
|
7372
|
+
tags: [
|
|
7373
|
+
{
|
|
7374
|
+
tag: 'meta',
|
|
7375
|
+
attrs: { name: 'build-id', content: build },
|
|
7376
|
+
injectTo: 'head',
|
|
7377
|
+
},
|
|
7378
|
+
],
|
|
6993
7379
|
}
|
|
6994
|
-
|
|
6995
|
-
|
|
6996
|
-
|
|
6997
|
-
|
|
6998
|
-
|
|
6999
|
-
)
|
|
7380
|
+
},
|
|
7381
|
+
},
|
|
7382
|
+
generateBundle: {
|
|
7383
|
+
order: 'post',
|
|
7384
|
+
handler(_options, bundle) {
|
|
7385
|
+
let html: (typeof bundle)[string] | undefined
|
|
7386
|
+
for (const output of Object.values(bundle)) {
|
|
7387
|
+
if (!output.fileName.endsWith('.html')) continue
|
|
7388
|
+
if (html !== undefined) {
|
|
7389
|
+
this.error('[orkestrel-showcase-html] Showcase build emitted multiple HTML entries')
|
|
7390
|
+
}
|
|
7391
|
+
html = output
|
|
7392
|
+
}
|
|
7393
|
+
if (html === undefined) {
|
|
7394
|
+
this.error('[orkestrel-showcase-html] Showcase build did not emit an HTML entry')
|
|
7395
|
+
}
|
|
7396
|
+
html.fileName = 'index.html'
|
|
7000
7397
|
},
|
|
7001
7398
|
},
|
|
7002
7399
|
}
|
|
@@ -7293,7 +7690,7 @@ ${needsBrowser ? `import { isCSSRequest, parseSync, preprocessCSS, transformWith
|
|
|
7293
7690
|
`}import { defineConfig, mergeConfig } from 'vitest/config'
|
|
7294
7691
|
import tsconfig from './tsconfig.json' with { type: 'json' }
|
|
7295
7692
|
import { fileURLToPath, URL } from 'node:url'
|
|
7296
|
-
import { isBuiltin } from 'node:module'
|
|
7693
|
+
${showcaseHashImport}import { isBuiltin } from 'node:module'
|
|
7297
7694
|
import {
|
|
7298
7695
|
${needsBrowser ? " accessSync,\n" : ""} closeSync,
|
|
7299
7696
|
constants as FS_CONSTANTS,
|
|
@@ -7616,7 +8013,7 @@ ${environmentBoundary}`;
|
|
|
7616
8013
|
* ```
|
|
7617
8014
|
*/
|
|
7618
8015
|
function policyViteProject() {
|
|
7619
|
-
return `${EXPORT_KEYWORD} const policy = (
|
|
8016
|
+
return `${EXPORT_KEYWORD} const policy = (options?: UserConfig): UserConfig =>
|
|
7620
8017
|
mergeConfig(
|
|
7621
8018
|
{
|
|
7622
8019
|
resolve,
|
|
@@ -7628,7 +8025,34 @@ function policyViteProject() {
|
|
|
7628
8025
|
browser: { enabled: false },
|
|
7629
8026
|
},
|
|
7630
8027
|
},
|
|
7631
|
-
|
|
8028
|
+
options ?? {},
|
|
8029
|
+
)
|
|
8030
|
+
`;
|
|
8031
|
+
}
|
|
8032
|
+
/**
|
|
8033
|
+
* Build the standalone Node-only root-configuration Vitest project.
|
|
8034
|
+
*
|
|
8035
|
+
* @returns The emitted `config` project definition.
|
|
8036
|
+
*
|
|
8037
|
+
* @example
|
|
8038
|
+
* ```ts
|
|
8039
|
+
* configViteProject().includes("label: 'config'") // true
|
|
8040
|
+
* ```
|
|
8041
|
+
*/
|
|
8042
|
+
function configViteProject() {
|
|
8043
|
+
return `${EXPORT_KEYWORD} const config = (options?: UserConfig): UserConfig =>
|
|
8044
|
+
mergeConfig(
|
|
8045
|
+
{
|
|
8046
|
+
resolve,
|
|
8047
|
+
test: {
|
|
8048
|
+
name: { label: 'config', color: 'yellow' },
|
|
8049
|
+
include: ['tests/config/**/*.test.ts'],
|
|
8050
|
+
setupFiles: ['./tests/setup.ts'],
|
|
8051
|
+
environment: 'node',
|
|
8052
|
+
browser: { enabled: false },
|
|
8053
|
+
},
|
|
8054
|
+
},
|
|
8055
|
+
options ?? {},
|
|
7632
8056
|
)
|
|
7633
8057
|
`;
|
|
7634
8058
|
}
|
|
@@ -7643,7 +8067,7 @@ function policyViteProject() {
|
|
|
7643
8067
|
* ```
|
|
7644
8068
|
*/
|
|
7645
8069
|
function guidesViteProject() {
|
|
7646
|
-
return `${EXPORT_KEYWORD} const guides = (
|
|
8070
|
+
return `${EXPORT_KEYWORD} const guides = (options?: UserConfig): UserConfig =>
|
|
7647
8071
|
mergeConfig(
|
|
7648
8072
|
{
|
|
7649
8073
|
resolve,
|
|
@@ -7656,7 +8080,7 @@ function guidesViteProject() {
|
|
|
7656
8080
|
browser: { enabled: false },
|
|
7657
8081
|
},
|
|
7658
8082
|
},
|
|
7659
|
-
|
|
8083
|
+
options ?? {},
|
|
7660
8084
|
)
|
|
7661
8085
|
`;
|
|
7662
8086
|
}
|
|
@@ -7671,7 +8095,7 @@ function guidesViteProject() {
|
|
|
7671
8095
|
* ```
|
|
7672
8096
|
*/
|
|
7673
8097
|
function binViteProject() {
|
|
7674
|
-
return `${EXPORT_KEYWORD} const srcBin = (
|
|
8098
|
+
return `${EXPORT_KEYWORD} const srcBin = (options?: UserConfig): UserConfig =>
|
|
7675
8099
|
mergeConfig(
|
|
7676
8100
|
{
|
|
7677
8101
|
resolve,
|
|
@@ -7698,7 +8122,7 @@ function binViteProject() {
|
|
|
7698
8122
|
browser: { enabled: false },
|
|
7699
8123
|
},
|
|
7700
8124
|
},
|
|
7701
|
-
|
|
8125
|
+
options ?? {},
|
|
7702
8126
|
)
|
|
7703
8127
|
`;
|
|
7704
8128
|
}
|
|
@@ -7716,7 +8140,7 @@ function binViteProject() {
|
|
|
7716
8140
|
* ```
|
|
7717
8141
|
*/
|
|
7718
8142
|
function integrationViteProject(facts = {}) {
|
|
7719
|
-
return `${EXPORT_KEYWORD} const integration = (
|
|
8143
|
+
return `${EXPORT_KEYWORD} const integration = (options?: UserConfig): UserConfig =>
|
|
7720
8144
|
mergeConfig(
|
|
7721
8145
|
{
|
|
7722
8146
|
resolve,
|
|
@@ -7733,7 +8157,7 @@ ${facts.bin === true && facts.integration === true && facts.global === true ? `
|
|
|
7733
8157
|
fileParallelism: false,
|
|
7734
8158
|
},
|
|
7735
8159
|
},
|
|
7736
|
-
|
|
8160
|
+
options ?? {},
|
|
7737
8161
|
)
|
|
7738
8162
|
`;
|
|
7739
8163
|
}
|
|
@@ -7748,7 +8172,7 @@ ${facts.bin === true && facts.integration === true && facts.global === true ? `
|
|
|
7748
8172
|
* ```
|
|
7749
8173
|
*/
|
|
7750
8174
|
function serviceViteProject() {
|
|
7751
|
-
return `${EXPORT_KEYWORD} const service = (
|
|
8175
|
+
return `${EXPORT_KEYWORD} const service = (options?: UserConfig): UserConfig =>
|
|
7752
8176
|
mergeConfig(
|
|
7753
8177
|
{
|
|
7754
8178
|
resolve,
|
|
@@ -7763,7 +8187,7 @@ function serviceViteProject() {
|
|
|
7763
8187
|
fileParallelism: false,
|
|
7764
8188
|
},
|
|
7765
8189
|
},
|
|
7766
|
-
|
|
8190
|
+
options ?? {},
|
|
7767
8191
|
)
|
|
7768
8192
|
`;
|
|
7769
8193
|
}
|
|
@@ -7790,7 +8214,7 @@ function singleSrcViteConfig(environment, facts = {}) {
|
|
|
7790
8214
|
const renderedTest = renderViteTest(viteProjectRegistrations([environment], [], facts), machinery.browser);
|
|
7791
8215
|
const definitions = viteProjectDefinitions(facts);
|
|
7792
8216
|
if (environment === "browser") return `${header}
|
|
7793
|
-
${EXPORT_KEYWORD} const srcBrowser = (
|
|
8217
|
+
${EXPORT_KEYWORD} const srcBrowser = (options?: UserConfig): UserConfig =>
|
|
7794
8218
|
mergeConfig(
|
|
7795
8219
|
{
|
|
7796
8220
|
resolve,
|
|
@@ -7815,7 +8239,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
7815
8239
|
name: { label: 'src:browser', color: 'yellow' },
|
|
7816
8240
|
include: ['tests/src/browser/**/*.test.ts'],
|
|
7817
8241
|
${facts.global === true ? `globalSetup: ['./${GLOBAL_SETUP_PATH}'],\n\t\t\t\t` : ""}setupFiles: ['./tests/setup.ts', './tests/setupBrowser.ts'],
|
|
7818
|
-
...(
|
|
8242
|
+
...(options?.test?.browser?.enabled === false
|
|
7819
8243
|
? {}
|
|
7820
8244
|
: {
|
|
7821
8245
|
deps: {
|
|
@@ -7835,7 +8259,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
7835
8259
|
fileParallelism: false,
|
|
7836
8260
|
},
|
|
7837
8261
|
},
|
|
7838
|
-
|
|
8262
|
+
options ?? {},
|
|
7839
8263
|
)
|
|
7840
8264
|
|
|
7841
8265
|
${definitions}
|
|
@@ -7845,7 +8269,7 @@ ${renderedTest}
|
|
|
7845
8269
|
})
|
|
7846
8270
|
`;
|
|
7847
8271
|
return `${header}
|
|
7848
|
-
${EXPORT_KEYWORD} const srcServer = (
|
|
8272
|
+
${EXPORT_KEYWORD} const srcServer = (options?: UserConfig): UserConfig =>
|
|
7849
8273
|
mergeConfig(
|
|
7850
8274
|
{
|
|
7851
8275
|
resolve,
|
|
@@ -7875,7 +8299,7 @@ ${EXPORT_KEYWORD} const srcServer = (config?: UserConfig): UserConfig =>
|
|
|
7875
8299
|
browser: { enabled: false },
|
|
7876
8300
|
},
|
|
7877
8301
|
},
|
|
7878
|
-
|
|
8302
|
+
options ?? {},
|
|
7879
8303
|
)
|
|
7880
8304
|
|
|
7881
8305
|
${definitions}
|
|
@@ -7924,7 +8348,7 @@ function rootViteConfig(src, facts = {}) {
|
|
|
7924
8348
|
if (onlyEnvironment === "browser" || onlyEnvironment === "server") return singleSrcViteConfig(onlyEnvironment, facts);
|
|
7925
8349
|
}
|
|
7926
8350
|
const browserBlock = `
|
|
7927
|
-
${EXPORT_KEYWORD} const srcBrowser = (
|
|
8351
|
+
${EXPORT_KEYWORD} const srcBrowser = (options?: UserConfig): UserConfig =>
|
|
7928
8352
|
srcCore(
|
|
7929
8353
|
mergeConfig(
|
|
7930
8354
|
{
|
|
@@ -7948,7 +8372,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
7948
8372
|
include: ['tests/src/browser/**/*.test.ts'],
|
|
7949
8373
|
exclude: ['tests/src/core/**/*.test.ts'],
|
|
7950
8374
|
${facts.global === true ? `globalSetup: ['./${GLOBAL_SETUP_PATH}'],\n\t\t\t\t\t` : ""}setupFiles: ['./tests/setup.ts', './tests/setupBrowser.ts'],
|
|
7951
|
-
...(
|
|
8375
|
+
...(options?.test?.browser?.enabled === false
|
|
7952
8376
|
? {}
|
|
7953
8377
|
: {
|
|
7954
8378
|
deps: {
|
|
@@ -7968,12 +8392,12 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
7968
8392
|
fileParallelism: false,
|
|
7969
8393
|
},
|
|
7970
8394
|
},
|
|
7971
|
-
|
|
8395
|
+
options ?? {},
|
|
7972
8396
|
),
|
|
7973
8397
|
)
|
|
7974
8398
|
`;
|
|
7975
8399
|
const serverBlock = `
|
|
7976
|
-
${EXPORT_KEYWORD} const srcServer = (
|
|
8400
|
+
${EXPORT_KEYWORD} const srcServer = (options?: UserConfig): UserConfig =>
|
|
7977
8401
|
srcCore(
|
|
7978
8402
|
mergeConfig(
|
|
7979
8403
|
{
|
|
@@ -8011,14 +8435,14 @@ ${EXPORT_KEYWORD} const srcServer = (config?: UserConfig): UserConfig =>
|
|
|
8011
8435
|
setupFiles: ['./tests/setup.ts', './tests/setupServer.ts'],
|
|
8012
8436
|
},
|
|
8013
8437
|
},
|
|
8014
|
-
|
|
8438
|
+
options ?? {},
|
|
8015
8439
|
),
|
|
8016
8440
|
)
|
|
8017
8441
|
`;
|
|
8018
8442
|
const blocks = nonCore.map((environment) => environment === "browser" ? browserBlock : serverBlock).join("");
|
|
8019
8443
|
const renderedTest = renderViteTest(viteProjectRegistrations(src, [], facts), machinery.browser);
|
|
8020
8444
|
return `${header}
|
|
8021
|
-
${EXPORT_KEYWORD} const srcCore = (
|
|
8445
|
+
${EXPORT_KEYWORD} const srcCore = (options?: UserConfig): UserConfig =>
|
|
8022
8446
|
mergeConfig(
|
|
8023
8447
|
{
|
|
8024
8448
|
resolve,
|
|
@@ -8036,7 +8460,7 @@ ${EXPORT_KEYWORD} const srcCore = (config?: UserConfig): UserConfig =>
|
|
|
8036
8460
|
browser: { enabled: false },
|
|
8037
8461
|
},
|
|
8038
8462
|
},
|
|
8039
|
-
|
|
8463
|
+
options ?? {},
|
|
8040
8464
|
)
|
|
8041
8465
|
${blocks}
|
|
8042
8466
|
${viteProjectDefinitions(facts)}
|
|
@@ -8066,7 +8490,7 @@ function applicationViteConfig(src, app, facts = {}) {
|
|
|
8066
8490
|
const header = viteHeader(machinery);
|
|
8067
8491
|
const blocks = [];
|
|
8068
8492
|
if (src.includes("core")) blocks.push(`
|
|
8069
|
-
${EXPORT_KEYWORD} const srcCore = (
|
|
8493
|
+
${EXPORT_KEYWORD} const srcCore = (options?: UserConfig): UserConfig =>
|
|
8070
8494
|
mergeConfig(
|
|
8071
8495
|
{
|
|
8072
8496
|
resolve,
|
|
@@ -8081,7 +8505,7 @@ ${EXPORT_KEYWORD} const srcCore = (config?: UserConfig): UserConfig =>
|
|
|
8081
8505
|
browser: { enabled: false },
|
|
8082
8506
|
},
|
|
8083
8507
|
},
|
|
8084
|
-
|
|
8508
|
+
options ?? {},
|
|
8085
8509
|
)
|
|
8086
8510
|
`);
|
|
8087
8511
|
if (src.includes("browser")) {
|
|
@@ -8089,7 +8513,7 @@ ${EXPORT_KEYWORD} const srcCore = (config?: UserConfig): UserConfig =>
|
|
|
8089
8513
|
output: { paths: { '@src/core': '../core/index.js' } },` : "";
|
|
8090
8514
|
const coreExternal = hasSourceCore ? `id === '@src/core' || ` : "";
|
|
8091
8515
|
blocks.push(`
|
|
8092
|
-
${EXPORT_KEYWORD} const srcBrowser = (
|
|
8516
|
+
${EXPORT_KEYWORD} const srcBrowser = (options?: UserConfig): UserConfig =>
|
|
8093
8517
|
mergeConfig(
|
|
8094
8518
|
{
|
|
8095
8519
|
resolve,
|
|
@@ -8114,7 +8538,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
8114
8538
|
name: { label: 'src:browser', color: 'yellow' },
|
|
8115
8539
|
include: ['tests/src/browser/**/*.test.ts'],
|
|
8116
8540
|
${hasSourceCore ? "exclude: ['tests/src/core/**/*.test.ts'],\n " : ""}${facts.global === true ? `globalSetup: ['./${GLOBAL_SETUP_PATH}'],\n\t\t\t\t` : ""}setupFiles: ['./tests/setup.ts', './tests/setupBrowser.ts'],
|
|
8117
|
-
...(
|
|
8541
|
+
...(options?.test?.browser?.enabled === false
|
|
8118
8542
|
? {}
|
|
8119
8543
|
: {
|
|
8120
8544
|
deps: {
|
|
@@ -8134,7 +8558,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
8134
8558
|
fileParallelism: false,
|
|
8135
8559
|
},
|
|
8136
8560
|
},
|
|
8137
|
-
|
|
8561
|
+
options ?? {},
|
|
8138
8562
|
)
|
|
8139
8563
|
`);
|
|
8140
8564
|
}
|
|
@@ -8155,7 +8579,7 @@ ${EXPORT_KEYWORD} const srcBrowser = (config?: UserConfig): UserConfig =>
|
|
|
8155
8579
|
const coreExternal = hasSourceCore ? `id === '@src/core' || ` : "";
|
|
8156
8580
|
const formats = hasSourceCore ? "" : "\n formats: ['es', 'cjs'],";
|
|
8157
8581
|
blocks.push(`
|
|
8158
|
-
${EXPORT_KEYWORD} const srcServer = (
|
|
8582
|
+
${EXPORT_KEYWORD} const srcServer = (options?: UserConfig): UserConfig =>
|
|
8159
8583
|
mergeConfig(
|
|
8160
8584
|
{
|
|
8161
8585
|
resolve,
|
|
@@ -8185,12 +8609,12 @@ ${EXPORT_KEYWORD} const srcServer = (config?: UserConfig): UserConfig =>
|
|
|
8185
8609
|
browser: { enabled: false },
|
|
8186
8610
|
},
|
|
8187
8611
|
},
|
|
8188
|
-
|
|
8612
|
+
options ?? {},
|
|
8189
8613
|
)
|
|
8190
8614
|
`);
|
|
8191
8615
|
}
|
|
8192
8616
|
if (app.includes("core")) blocks.push(`
|
|
8193
|
-
${EXPORT_KEYWORD} const appCore = (
|
|
8617
|
+
${EXPORT_KEYWORD} const appCore = (options?: UserConfig): UserConfig =>
|
|
8194
8618
|
mergeConfig(
|
|
8195
8619
|
{
|
|
8196
8620
|
resolve,
|
|
@@ -8204,32 +8628,27 @@ ${EXPORT_KEYWORD} const appCore = (config?: UserConfig): UserConfig =>
|
|
|
8204
8628
|
browser: { enabled: false },
|
|
8205
8629
|
},
|
|
8206
8630
|
},
|
|
8207
|
-
|
|
8631
|
+
options ?? {},
|
|
8208
8632
|
)
|
|
8209
8633
|
`);
|
|
8210
8634
|
if (app.includes("browser")) blocks.push(facts.showcase === true ? `
|
|
8211
|
-
${
|
|
8212
|
-
|
|
8635
|
+
${EXPORT_KEYWORD} function appBrowser(...config: never[]): UserConfig {
|
|
8636
|
+
if (config.length > 0) {
|
|
8637
|
+
throw new Error(
|
|
8638
|
+
'[orkestrel-environment-boundary] Browser configuration overrides are not permitted by the generated boundary',
|
|
8639
|
+
)
|
|
8640
|
+
}
|
|
8213
8641
|
return {
|
|
8214
8642
|
resolve,
|
|
8215
8643
|
css: ENVIRONMENT_CSS,
|
|
8216
8644
|
html: environmentHtml(),
|
|
8217
8645
|
plugins: [
|
|
8218
8646
|
restoreHtml(),
|
|
8219
|
-
outputBoundary(
|
|
8647
|
+
outputBoundary('dist/app/browser'),
|
|
8220
8648
|
environmentBoundary('app/browser'),
|
|
8221
8649
|
vue(),
|
|
8222
8650
|
prepareHtml(),
|
|
8223
8651
|
finalizeHtml(),
|
|
8224
|
-
...(showcase
|
|
8225
|
-
? [
|
|
8226
|
-
viteSingleFile({
|
|
8227
|
-
removeViteModuleLoader: true,
|
|
8228
|
-
useRecommendedBuildConfig: true,
|
|
8229
|
-
}),
|
|
8230
|
-
showcaseHtml(),
|
|
8231
|
-
]
|
|
8232
|
-
: []),
|
|
8233
8652
|
],
|
|
8234
8653
|
root: resolveWorkspacePath('app/browser'),
|
|
8235
8654
|
publicDir: false,
|
|
@@ -8240,18 +8659,9 @@ ${FUNCTION_KEYWORD} applicationBrowser(showcase: boolean): UserConfig {
|
|
|
8240
8659
|
},
|
|
8241
8660
|
},
|
|
8242
8661
|
build: {
|
|
8243
|
-
|
|
8244
|
-
? {
|
|
8245
|
-
cssMinify: 'lightningcss',
|
|
8246
|
-
minify: 'oxc',
|
|
8247
|
-
modulePreload: false,
|
|
8248
|
-
reportCompressedSize: false,
|
|
8249
|
-
sourcemap: false,
|
|
8250
|
-
target: 'esnext',
|
|
8251
|
-
}
|
|
8252
|
-
: { assetsInlineLimit: 0 }),
|
|
8662
|
+
assetsInlineLimit: 0,
|
|
8253
8663
|
emptyOutDir: true,
|
|
8254
|
-
outDir: resolveWorkspacePath(
|
|
8664
|
+
outDir: resolveWorkspacePath('dist/app/browser'),
|
|
8255
8665
|
rolldownOptions: {
|
|
8256
8666
|
input: resolveWorkspacePath('${APP_MATRIX.browser.entry}'),
|
|
8257
8667
|
},
|
|
@@ -8280,22 +8690,54 @@ ${FUNCTION_KEYWORD} applicationBrowser(showcase: boolean): UserConfig {
|
|
|
8280
8690
|
}
|
|
8281
8691
|
}
|
|
8282
8692
|
|
|
8283
|
-
${EXPORT_KEYWORD} function appBrowser(...config: never[]): UserConfig {
|
|
8284
|
-
if (config.length > 0) {
|
|
8285
|
-
throw new Error(
|
|
8286
|
-
'[orkestrel-environment-boundary] Browser configuration overrides are not permitted by the generated boundary',
|
|
8287
|
-
)
|
|
8288
|
-
}
|
|
8289
|
-
return applicationBrowser(false)
|
|
8290
|
-
}
|
|
8291
|
-
|
|
8292
8693
|
${EXPORT_KEYWORD} function appShowcase(...config: never[]): UserConfig {
|
|
8293
8694
|
if (config.length > 0) {
|
|
8294
8695
|
throw new Error(
|
|
8295
8696
|
'[orkestrel-environment-boundary] Showcase configuration overrides are not permitted by the generated boundary',
|
|
8296
8697
|
)
|
|
8297
8698
|
}
|
|
8298
|
-
return
|
|
8699
|
+
return {
|
|
8700
|
+
base: './',
|
|
8701
|
+
resolve,
|
|
8702
|
+
css: ENVIRONMENT_CSS,
|
|
8703
|
+
html: environmentHtml(),
|
|
8704
|
+
plugins: [
|
|
8705
|
+
restoreHtml(),
|
|
8706
|
+
outputBoundary('dist/showcase'),
|
|
8707
|
+
environmentBoundary('app/browser'),
|
|
8708
|
+
vue(),
|
|
8709
|
+
prepareHtml(),
|
|
8710
|
+
showcaseHtml(),
|
|
8711
|
+
viteSingleFile({
|
|
8712
|
+
removeViteModuleLoader: true,
|
|
8713
|
+
useRecommendedBuildConfig: true,
|
|
8714
|
+
}),
|
|
8715
|
+
finalizeHtml(),
|
|
8716
|
+
],
|
|
8717
|
+
root: resolveWorkspacePath('app/browser'),
|
|
8718
|
+
publicDir: false,
|
|
8719
|
+
server: {
|
|
8720
|
+
open: '/showcase.html',
|
|
8721
|
+
fs: {
|
|
8722
|
+
strict: true,
|
|
8723
|
+
allow: [...browserServerRoots()],
|
|
8724
|
+
},
|
|
8725
|
+
},
|
|
8726
|
+
build: {
|
|
8727
|
+
assetsInlineLimit: Number.MAX_SAFE_INTEGER,
|
|
8728
|
+
cssMinify: 'lightningcss',
|
|
8729
|
+
emptyOutDir: true,
|
|
8730
|
+
minify: 'oxc',
|
|
8731
|
+
modulePreload: false,
|
|
8732
|
+
outDir: resolveWorkspacePath('dist/showcase'),
|
|
8733
|
+
reportCompressedSize: false,
|
|
8734
|
+
rolldownOptions: {
|
|
8735
|
+
input: resolveWorkspacePath('app/browser/showcase.html'),
|
|
8736
|
+
},
|
|
8737
|
+
sourcemap: false,
|
|
8738
|
+
target: 'esnext',
|
|
8739
|
+
},
|
|
8740
|
+
}
|
|
8299
8741
|
}
|
|
8300
8742
|
` : `
|
|
8301
8743
|
${EXPORT_KEYWORD} function appBrowser(...config: never[]): UserConfig {
|
|
@@ -8357,7 +8799,7 @@ ${EXPORT_KEYWORD} function appBrowser(...config: never[]): UserConfig {
|
|
|
8357
8799
|
}
|
|
8358
8800
|
`);
|
|
8359
8801
|
if (app.includes("server")) blocks.push(`
|
|
8360
|
-
${EXPORT_KEYWORD} const appServer = (
|
|
8802
|
+
${EXPORT_KEYWORD} const appServer = (options?: UserConfig): UserConfig =>
|
|
8361
8803
|
mergeConfig(
|
|
8362
8804
|
{
|
|
8363
8805
|
resolve,
|
|
@@ -8384,7 +8826,7 @@ ${EXPORT_KEYWORD} const appServer = (config?: UserConfig): UserConfig =>
|
|
|
8384
8826
|
browser: { enabled: false },
|
|
8385
8827
|
},
|
|
8386
8828
|
},
|
|
8387
|
-
|
|
8829
|
+
options ?? {},
|
|
8388
8830
|
)
|
|
8389
8831
|
`);
|
|
8390
8832
|
const renderedTest = renderViteTest(viteProjectRegistrations(src, app, facts), machinery.browser);
|
|
@@ -8619,6 +9061,8 @@ function appTsconfig(environment, hasCore) {
|
|
|
8619
9061
|
const include = TYPESCRIPT_EXTENSIONS.map((extension) => `../../app/${environment}/**/*.${extension}`);
|
|
8620
9062
|
if (environment === "browser") include.push(`../../app/${environment}/**/*.vue`);
|
|
8621
9063
|
if (environment !== "core" && hasCore) include.push(...TYPESCRIPT_EXTENSIONS.map((extension) => `../../app/core/**/*.${extension}`));
|
|
9064
|
+
include.push(...TYPESCRIPT_EXTENSIONS.map((extension) => `../../tests/app/${environment}/**/*.${extension}`));
|
|
9065
|
+
include.push(environment === "browser" ? "../../tests/setupBrowser.ts" : environment === "server" ? "../../tests/setupServer.ts" : "../../tests/setup.ts");
|
|
8622
9066
|
return formatJson({
|
|
8623
9067
|
extends: "../../tsconfig.json",
|
|
8624
9068
|
compilerOptions: {
|
|
@@ -8799,7 +9243,7 @@ export default defineConfig(appShowcase())
|
|
|
8799
9243
|
* factory per environment (AGENTS §5's per-environment centralized-file pattern), so
|
|
8800
9244
|
* every environment gets the same uniform stub shape.
|
|
8801
9245
|
*
|
|
8802
|
-
* @param spec - The
|
|
9246
|
+
* @param spec - The blueprint carrying the declared source environment set.
|
|
8803
9247
|
* @param pascal - The package's PascalCase entity name.
|
|
8804
9248
|
* @returns The `source` group's `Artifact[]`.
|
|
8805
9249
|
*
|
|
@@ -8832,20 +9276,129 @@ function sourceArtifacts(spec, pascal) {
|
|
|
8832
9276
|
* Draft the application source artifacts for every selected app environment.
|
|
8833
9277
|
*
|
|
8834
9278
|
* @param spec - The blueprint carrying the application environment set.
|
|
9279
|
+
* @remarks
|
|
9280
|
+
* Two conditional shapes layer over the per-environment set. The health contract —
|
|
9281
|
+
* record, route constants, guard, and the one unknown-to-typed read — is declared by
|
|
9282
|
+
* `app/server` while the server alone reads it and RELOCATES to `app/core` the moment
|
|
9283
|
+
* the browser reads it too, because a contract two hosts share belongs to neither of
|
|
9284
|
+
* them. The showcase entry pair, its seeder, and its factory appear only for a
|
|
9285
|
+
* blueprint that declares the physical showcase wrapper alongside `app/browser`.
|
|
8835
9286
|
* @returns Complete, runnable app/core, app/browser, and app/server artifacts.
|
|
8836
9287
|
*/
|
|
8837
9288
|
function applicationArtifacts(spec) {
|
|
8838
9289
|
const artifacts = [];
|
|
8839
9290
|
const hasCore = spec.app.includes("core");
|
|
9291
|
+
const hasBrowser = spec.app.includes("browser");
|
|
9292
|
+
const hasBoundary = hasApplicationBoundary(spec);
|
|
9293
|
+
const hasShowcase = hasApplicationShowcase(spec);
|
|
9294
|
+
const showcaseSource = hasBoundary ? "its running server" : "its own configuration";
|
|
8840
9295
|
const nameLiteral = serializeTypeScriptString(spec.name);
|
|
8841
|
-
|
|
8842
|
-
|
|
9296
|
+
const sharedRecord = `
|
|
9297
|
+
/** The application record both hosts read at the health route. */
|
|
9298
|
+
${EXPORT_KEYWORD} interface ApplicationRecord {
|
|
9299
|
+
readonly name: string
|
|
9300
|
+
readonly status: 'ok'
|
|
9301
|
+
}
|
|
9302
|
+
`;
|
|
9303
|
+
const healthConstants = `
|
|
9304
|
+
/** The only HTTP method owned by the application health route. */
|
|
9305
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEALTH_METHOD = 'GET'
|
|
9306
|
+
|
|
9307
|
+
/** The only HTTP path owned by the generated application server. */
|
|
9308
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEALTH_PATH = '/health'
|
|
9309
|
+
`;
|
|
9310
|
+
if (hasCore) {
|
|
9311
|
+
artifacts.push(fillArtifact("app/core/types.ts", "source", "appCoreTypes", { record: hasBoundary ? sharedRecord : "" }, "core"), fillArtifact("app/core/constants.ts", "source", "appCoreConstants", {
|
|
9312
|
+
nameLiteral,
|
|
9313
|
+
health: hasBoundary ? `${healthConstants}
|
|
9314
|
+
/** Milliseconds allowed for one shared application health read. */
|
|
9315
|
+
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_HEALTH_TIMEOUT = 5_000
|
|
9316
|
+
` : ""
|
|
9317
|
+
}, "core"), fillArtifact("app/core/errors.ts", "source", "appCoreErrors", {}, "core"), fillArtifact("app/core/parsers.ts", "source", "appCoreParsers", {}, "core"), fillArtifact("app/core/factories.ts", "source", "appCoreFactories", {}, "core"), fillArtifact("app/core/index.ts", "source", "appCoreIndex", {
|
|
9318
|
+
validators: hasBoundary ? "export * from './validators.js'\n" : "",
|
|
9319
|
+
handlers: hasBoundary ? "export * from './handlers.js'\n" : ""
|
|
9320
|
+
}, "core"));
|
|
9321
|
+
if (hasBoundary) artifacts.push(fillArtifact("app/core/validators.ts", "source", "appCoreValidators", {}, "core"), fillArtifact("app/core/handlers.ts", "source", "appCoreHandlers", {}, "core"));
|
|
9322
|
+
}
|
|
9323
|
+
if (hasBrowser) {
|
|
8843
9324
|
const nameImport = hasCore ? "import { APP_NAME } from '@app/core'" : "import { APP_NAME } from './constants.js'";
|
|
8844
9325
|
const nameConstant = hasCore ? "" : `/** The browser-only application name. */
|
|
8845
9326
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_NAME = ${nameLiteral}
|
|
8846
9327
|
|
|
8847
9328
|
`;
|
|
8848
|
-
artifacts.push(fillArtifact("app/browser/types.ts", "source", "appBrowserTypes", {
|
|
9329
|
+
artifacts.push(fillArtifact("app/browser/types.ts", "source", "appBrowserTypes", { application: hasShowcase && !hasCore ? `
|
|
9330
|
+
/** The identity the root view renders. */
|
|
9331
|
+
${EXPORT_KEYWORD} interface Application {
|
|
9332
|
+
readonly name: string
|
|
9333
|
+
}
|
|
9334
|
+
` : "" }, "browser"), fillArtifact("app/browser/constants.ts", "source", "appBrowserConstants", { nameConstant }, "browser"), fillArtifact("app/browser/errors.ts", "source", "appBrowserErrors", {}, "browser"), fillArtifact("app/browser/parsers.ts", "source", "appBrowserParsers", {}, "browser"), fillArtifact("app/browser/factories.ts", "source", "appBrowserFactories", {
|
|
9335
|
+
nameImport: hasBoundary ? "import { APP_NAME, readApplicationHealth } from '@app/core'" : nameImport,
|
|
9336
|
+
seedImport: hasShowcase ? "import { seedApplication } from './seeders.js'\n" : "",
|
|
9337
|
+
showcase: hasShowcase ? `
|
|
9338
|
+
/**
|
|
9339
|
+
* Mount the showcase over its seeded, inert identity.
|
|
9340
|
+
*
|
|
9341
|
+
* @param target - The browser element or selector that receives the showcase.
|
|
9342
|
+
* @returns The mounted Vue application.
|
|
9343
|
+
*
|
|
9344
|
+
* @remarks
|
|
9345
|
+
* The showcase mounts the same {@link createBrowserApplication} root the shipped entry
|
|
9346
|
+
* mounts, so the two differ in exactly one expression — where the props come from. This
|
|
9347
|
+
* one reads {@link seedApplication}; the application reads ${showcaseSource}.
|
|
9348
|
+
*
|
|
9349
|
+
* @example
|
|
9350
|
+
* \`\`\`ts
|
|
9351
|
+
* import { mountShowcaseApplication } from '@app/browser'
|
|
9352
|
+
*
|
|
9353
|
+
* mountShowcaseApplication('#app')
|
|
9354
|
+
* \`\`\`
|
|
9355
|
+
*/
|
|
9356
|
+
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} mountShowcaseApplication(target: string | Element): App<Element> {
|
|
9357
|
+
const seed = seedApplication()
|
|
9358
|
+
const application = createBrowserApplication({ name: seed.name })
|
|
9359
|
+
application.mount(target)
|
|
9360
|
+
return application
|
|
9361
|
+
}
|
|
9362
|
+
` : "",
|
|
9363
|
+
boundary: hasBoundary ? `
|
|
9364
|
+
/**
|
|
9365
|
+
* Mount the application over its real server boundary.
|
|
9366
|
+
*
|
|
9367
|
+
* @param target - The browser element or selector that receives the application.
|
|
9368
|
+
* @returns The mounted Vue application, after one health read settles.
|
|
9369
|
+
*
|
|
9370
|
+
* @remarks
|
|
9371
|
+
* One health read runs before the mount, so the root view renders the identity the
|
|
9372
|
+
* running server reported. An unreachable or off-contract boundary yields \`undefined\`
|
|
9373
|
+
* and the application falls back to its own configuration rather than failing to mount.
|
|
9374
|
+
*
|
|
9375
|
+
* @example
|
|
9376
|
+
* \`\`\`ts
|
|
9377
|
+
* import { mountBrowserApplication } from '@app/browser'
|
|
9378
|
+
*
|
|
9379
|
+
* await mountBrowserApplication('#app')
|
|
9380
|
+
* \`\`\`
|
|
9381
|
+
*/
|
|
9382
|
+
${EXPORT_KEYWORD} async ${FUNCTION_KEYWORD} mountBrowserApplication(target: string | Element): Promise<App<Element>> {
|
|
9383
|
+
const seed = (await readApplicationHealth(window.location.origin)) ?? { name: APP_NAME }
|
|
9384
|
+
const application = createBrowserApplication({ name: seed.name })
|
|
9385
|
+
application.mount(target)
|
|
9386
|
+
return application
|
|
9387
|
+
}
|
|
9388
|
+
` : ""
|
|
9389
|
+
}, "browser"), fillArtifact("app/browser/index.ts", "source", "appBrowserIndex", { seeders: hasShowcase ? "export * from './seeders.js'\n" : "" }, "browser"), fillArtifact("app/browser/main.ts", "source", "appBrowserMain", hasBoundary ? {
|
|
9390
|
+
factory: "mountBrowserApplication",
|
|
9391
|
+
mount: `void mountBrowserApplication('#app').catch(() => {
|
|
9392
|
+
console.error('[ERROR] Browser application failed')
|
|
9393
|
+
})`
|
|
9394
|
+
} : {
|
|
9395
|
+
factory: "createBrowserApplication",
|
|
9396
|
+
mount: "createBrowserApplication().mount('#app')"
|
|
9397
|
+
}, "browser"), fillArtifact("app/browser/ApplicationView.vue", "source", "appBrowserView", {}, "browser"), fillArtifact("app/browser/index.html", "source", "appBrowserHtml", { name: escapeHtmlText(spec.name) }, "browser"), fillArtifact("app/browser/env.d.ts", "source", "appBrowserEnv", {}, "browser"));
|
|
9398
|
+
if (hasShowcase) artifacts.push(fillArtifact("app/browser/seeders.ts", "source", "appBrowserSeeders", {
|
|
9399
|
+
applicationImport: hasCore ? "import type { Application } from '@app/core'" : "import type { Application } from './types.js'",
|
|
9400
|
+
nameImport
|
|
9401
|
+
}, "browser"), fillArtifact("app/browser/showcase.ts", "source", "appBrowserShowcase", {}, "browser"), fillArtifact("app/browser/showcase.html", "source", "appBrowserShowcaseHtml", { name: escapeHtmlText(spec.name) }, "browser"));
|
|
8849
9402
|
}
|
|
8850
9403
|
if (spec.app.includes("server")) {
|
|
8851
9404
|
const nameImport = hasCore ? "import { APP_NAME } from '@app/core'" : "import { APP_NAME } from './constants.js'";
|
|
@@ -8853,7 +9406,19 @@ ${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_NAME = ${nameLiteral}
|
|
|
8853
9406
|
${EXPORT_KEYWORD} ${CONST_KEYWORD} APP_NAME = ${nameLiteral}
|
|
8854
9407
|
|
|
8855
9408
|
`;
|
|
8856
|
-
artifacts.push(fillArtifact("app/server/types.ts", "source", "appServerTypes", {
|
|
9409
|
+
artifacts.push(fillArtifact("app/server/types.ts", "source", "appServerTypes", { record: hasBoundary ? "" : `/** The application record returned by the health route. */
|
|
9410
|
+
${EXPORT_KEYWORD} interface ApplicationRecord {
|
|
9411
|
+
readonly name: string
|
|
9412
|
+
readonly status: 'ok'
|
|
9413
|
+
}
|
|
9414
|
+
|
|
9415
|
+
` }, "server"), fillArtifact("app/server/constants.ts", "source", "appServerConstants", {
|
|
9416
|
+
nameConstant,
|
|
9417
|
+
health: hasBoundary ? "" : healthConstants
|
|
9418
|
+
}, "server"), fillArtifact("app/server/errors.ts", "source", "appServerErrors", {}, "server"), fillArtifact("app/server/parsers.ts", "source", "appServerParsers", {}, "server"), fillArtifact("app/server/routes.ts", "source", "appServerRoutes", { healthImport: hasBoundary ? "import { APP_HEALTH_METHOD, APP_HEALTH_PATH } from '@app/core'" : "import { APP_HEALTH_METHOD, APP_HEALTH_PATH } from './constants.js'" }, "server"), fillArtifact("app/server/handlers.ts", "source", "appServerHandlers", {
|
|
9419
|
+
recordImport: hasBoundary ? "import type { ApplicationRecord } from '@app/core'" : "import type { ApplicationRecord } from './types.js'",
|
|
9420
|
+
nameImport
|
|
9421
|
+
}, "server"), fillArtifact("app/server/ApplicationServer.ts", "source", "appServerEntity", {}, "server"), fillArtifact("app/server/factories.ts", "source", "appServerFactories", {}, "server"), fillArtifact("app/server/ApplicationServerRunner.ts", "source", "appServerRunner", { nameImport }, "server"), fillArtifact("app/server/index.ts", "source", "appServerIndex", {}, "server"), fillArtifact("app/server/main.ts", "source", "appServerMain", {}, "server"));
|
|
8857
9422
|
}
|
|
8858
9423
|
return artifacts;
|
|
8859
9424
|
}
|
|
@@ -8926,7 +9491,7 @@ function paritySpecifiers(spec) {
|
|
|
8926
9491
|
function testArtifacts(spec, pascal) {
|
|
8927
9492
|
const hasBrowser = spec.src.includes("browser") || spec.app.includes("browser");
|
|
8928
9493
|
const hasVue = spec.app.includes("browser");
|
|
8929
|
-
const
|
|
9494
|
+
const machinery = viteMachinery(spec.src, spec.app, spec.bin, spec.showcase);
|
|
8930
9495
|
const vuePolicyImport = hasVue ? "\nimport { parse as parseVue } from 'vue/compiler-sfc'" : "";
|
|
8931
9496
|
const workspacePolicyAssertion = hasVue ? `expect(
|
|
8932
9497
|
inspectCodingWorkspace(process.cwd(), (path, content) => {
|
|
@@ -8937,37 +9502,218 @@ function testArtifacts(spec, pascal) {
|
|
|
8937
9502
|
)
|
|
8938
9503
|
}),
|
|
8939
9504
|
).toEqual([])` : "expect(inspectCodingWorkspace(process.cwd())).toEqual([])";
|
|
8940
|
-
const
|
|
9505
|
+
const configNames = [
|
|
9506
|
+
"containedPath",
|
|
9507
|
+
"environmentPathError",
|
|
9508
|
+
"environmentSourceError",
|
|
9509
|
+
"resolveWorkspacePath",
|
|
9510
|
+
"workspacePath"
|
|
9511
|
+
];
|
|
9512
|
+
if (machinery.output) configNames.push("enforceOutputPath");
|
|
9513
|
+
if (hasBrowser) configNames.push("isBrowserExecutable", "resolveBrowser", "resolveManagedBrowser", "resolveSystemBrowser", "SYSTEM_BROWSER_CHANNELS");
|
|
9514
|
+
if (hasVue) configNames.push("hasSecurityPrologue", "HTML_SECURITY_META", "HTML_SECURITY_POLICY");
|
|
9515
|
+
const configImports = [
|
|
9516
|
+
...hasVue ? ["import { readFileSync } from 'node:fs'"] : [],
|
|
9517
|
+
...hasBrowser ? ["import { chromium } from 'playwright'"] : [],
|
|
9518
|
+
"import { describe, expect, it } from 'vitest'",
|
|
9519
|
+
"import {",
|
|
9520
|
+
...configNames.map((name) => `\t${name},`),
|
|
9521
|
+
"} from '../../vite.config.js'"
|
|
9522
|
+
].join("\n");
|
|
9523
|
+
const configCases = [];
|
|
9524
|
+
if (machinery.output) configCases.push(`
|
|
9525
|
+
|
|
9526
|
+
it('contains build output in its exact workspace directory', () => {
|
|
9527
|
+
const expected = resolveWorkspacePath('dist/config-proof')
|
|
9528
|
+
|
|
9529
|
+
expect(() => enforceOutputPath(expected, expected)).not.toThrow()
|
|
9530
|
+
expect(() => enforceOutputPath(resolveWorkspacePath('dist/other'), expected)).toThrow(
|
|
9531
|
+
'exact configured workspace directory',
|
|
9532
|
+
)
|
|
9533
|
+
expect(() =>
|
|
9534
|
+
enforceOutputPath(
|
|
9535
|
+
resolveWorkspacePath('../config-proof'),
|
|
9536
|
+
resolveWorkspacePath('../config-proof'),
|
|
9537
|
+
),
|
|
9538
|
+
).toThrow('remain inside the workspace')
|
|
9539
|
+
})`);
|
|
9540
|
+
if (hasBrowser) configCases.push(`
|
|
8941
9541
|
|
|
8942
9542
|
it('resolves only a real managed executable or stable system browser channel', () => {
|
|
8943
|
-
const
|
|
8944
|
-
|
|
8945
|
-
|
|
8946
|
-
|
|
8947
|
-
|
|
8948
|
-
|
|
8949
|
-
|
|
8950
|
-
|
|
8951
|
-
|
|
8952
|
-
|
|
8953
|
-
|
|
8954
|
-
|
|
8955
|
-
|
|
8956
|
-
|
|
8957
|
-
|
|
8958
|
-
|
|
8959
|
-
|
|
8960
|
-
|
|
9543
|
+
const pinned = chromium.executablePath()
|
|
9544
|
+
const managed = resolveManagedBrowser(pinned)
|
|
9545
|
+
const channel = resolveSystemBrowser(process.platform, process.env)
|
|
9546
|
+
const options = resolveBrowser(pinned, process.platform, process.env)
|
|
9547
|
+
const expected =
|
|
9548
|
+
managed === undefined
|
|
9549
|
+
? channel === undefined
|
|
9550
|
+
? undefined
|
|
9551
|
+
: { launchOptions: { channel } }
|
|
9552
|
+
: managed === pinned
|
|
9553
|
+
? {}
|
|
9554
|
+
: { launchOptions: { executablePath: managed } }
|
|
9555
|
+
const executable = managed === undefined || isBrowserExecutable(managed)
|
|
9556
|
+
const stable =
|
|
9557
|
+
managed !== undefined ||
|
|
9558
|
+
channel === undefined ||
|
|
9559
|
+
SYSTEM_BROWSER_CHANNELS.some((browser) => browser.channel === channel)
|
|
9560
|
+
|
|
9561
|
+
expect(options).toEqual(expected)
|
|
9562
|
+
expect(executable).toBe(true)
|
|
9563
|
+
expect(stable).toBe(true)
|
|
9564
|
+
})`);
|
|
9565
|
+
if (hasVue) configCases.push(`
|
|
9566
|
+
|
|
9567
|
+
it('preserves the generated browser security prologue', () => {
|
|
9568
|
+
const document = readFileSync(resolveWorkspacePath('app/browser/index.html'), 'utf8')
|
|
9569
|
+
|
|
9570
|
+
expect(hasSecurityPrologue(document, HTML_SECURITY_META)).toBe(true)
|
|
9571
|
+
expect(HTML_SECURITY_META).toContain(HTML_SECURITY_POLICY)
|
|
9572
|
+
})`);
|
|
9573
|
+
const artifacts = [
|
|
9574
|
+
fillArtifact("tests/setup.ts", "tests", "setup", {
|
|
9575
|
+
eventImport: spec.app.includes("server") ? "import type { EmitterInterface, EventMap } from '@orkestrel/emitter'\n\n" : "",
|
|
9576
|
+
eventHelper: spec.app.includes("server") ? `
|
|
9577
|
+
/** Wait for one typed event occurrence and return its argument tuple. */
|
|
9578
|
+
${EXPORT_KEYWORD} ${FUNCTION_KEYWORD} waitForEvent<TMap extends EventMap, K extends keyof TMap>(
|
|
9579
|
+
emitter: EmitterInterface<TMap>,
|
|
9580
|
+
event: K,
|
|
9581
|
+
): Promise<TMap[K]> {
|
|
9582
|
+
return new Promise((resolvePromise) => emitter.once(event, (...args) => resolvePromise(args)))
|
|
9583
|
+
}
|
|
9584
|
+
` : ""
|
|
9585
|
+
}),
|
|
9586
|
+
fillArtifact("tests/policy.test.ts", "tests", "policyTest", {
|
|
9587
|
+
vuePolicyImport,
|
|
9588
|
+
workspacePolicyAssertion
|
|
9589
|
+
}),
|
|
9590
|
+
fillArtifact("tests/config/vite.test.ts", "tests", "configTest", {
|
|
9591
|
+
imports: configImports,
|
|
9592
|
+
cases: configCases.join("")
|
|
9593
|
+
})
|
|
9594
|
+
];
|
|
8961
9595
|
if (spec.src.includes("server") || spec.app.includes("server")) artifacts.push(fillArtifact("tests/setupServer.ts", "tests", "setupServer", {}, "server"));
|
|
8962
9596
|
if (spec.src.includes("browser") || spec.app.includes("browser")) artifacts.push(fillArtifact("tests/setupBrowser.ts", "tests", "setupBrowser", {}, "browser"));
|
|
8963
|
-
|
|
9597
|
+
const hasBoundary = hasApplicationBoundary(spec);
|
|
9598
|
+
const hasShowcase = hasApplicationShowcase(spec);
|
|
9599
|
+
if (spec.app.includes("core")) artifacts.push(fillArtifact("tests/app/core/factories.test.ts", "tests", "appCoreTest", {
|
|
9600
|
+
guardImport: hasBoundary ? " isApplicationRecord,\n" : "",
|
|
9601
|
+
readImport: hasBoundary ? " readApplicationHealth,\n" : "",
|
|
9602
|
+
boundary: hasBoundary ? `
|
|
9603
|
+
|
|
9604
|
+
describe('shared application health boundary', () => {
|
|
9605
|
+
it('accepts the shared record and refuses every off-contract value', () => {
|
|
9606
|
+
expect(isApplicationRecord({ name: APP_NAME, status: 'ok' })).toBe(true)
|
|
9607
|
+
expect(isApplicationRecord({ name: ' ', status: 'ok' })).toBe(true)
|
|
9608
|
+
for (const value of [
|
|
9609
|
+
null,
|
|
9610
|
+
[],
|
|
9611
|
+
'ok',
|
|
9612
|
+
{ name: APP_NAME },
|
|
9613
|
+
{ name: '', status: 'ok' },
|
|
9614
|
+
{ name: 1, status: 'ok' },
|
|
9615
|
+
{ name: APP_NAME, status: 'down' },
|
|
9616
|
+
]) {
|
|
9617
|
+
expect(isApplicationRecord(value)).toBe(false)
|
|
9618
|
+
}
|
|
9619
|
+
const revocable = Proxy.revocable({}, {})
|
|
9620
|
+
revocable.revoke()
|
|
9621
|
+
expect(isApplicationRecord(revocable.proxy)).toBe(false)
|
|
9622
|
+
})
|
|
9623
|
+
|
|
9624
|
+
it('refuses a malformed origin before reaching the network', async () => {
|
|
9625
|
+
expect(await readApplicationHealth('not-an-origin')).toBeUndefined()
|
|
9626
|
+
})
|
|
9627
|
+
})` : ""
|
|
9628
|
+
}, "core"));
|
|
8964
9629
|
if (spec.app.includes("browser")) {
|
|
8965
9630
|
const browserTestNameImport = spec.app.includes("core") ? "import { APP_NAME } from '@app/core'" : "import { APP_NAME } from '@app/browser'";
|
|
8966
|
-
artifacts.push(fillArtifact("tests/app/browser/factories.test.ts", "tests", "appBrowserTest", {
|
|
9631
|
+
artifacts.push(fillArtifact("tests/app/browser/factories.test.ts", "tests", "appBrowserTest", {
|
|
9632
|
+
browserTestNameImport,
|
|
9633
|
+
showcaseImport: hasShowcase ? " mountShowcaseApplication,\n" : "",
|
|
9634
|
+
entryImport: `${hasShowcase ? " seedApplication,\n" : ""}${hasBoundary ? " mountBrowserApplication,\n" : ""}`,
|
|
9635
|
+
showcase: hasShowcase ? `
|
|
9636
|
+
|
|
9637
|
+
describe('mountShowcaseApplication', () => {
|
|
9638
|
+
it('mounts the shipped root view over one frozen, inert seed', () => {
|
|
9639
|
+
const element = buildElement()
|
|
9640
|
+
const seeded = seedApplication()
|
|
9641
|
+
const application = mountShowcaseApplication(element)
|
|
9642
|
+
try {
|
|
9643
|
+
expect(element.textContent).toContain(seeded.name)
|
|
9644
|
+
expect(seeded).toEqual(seedApplication())
|
|
9645
|
+
expect(seeded).not.toBe(seedApplication())
|
|
9646
|
+
expect(Object.isFrozen(seeded)).toBe(true)
|
|
9647
|
+
} finally {
|
|
9648
|
+
application.unmount()
|
|
9649
|
+
element.remove()
|
|
9650
|
+
}
|
|
9651
|
+
})
|
|
9652
|
+
})` : "",
|
|
9653
|
+
boundary: hasBoundary ? `
|
|
9654
|
+
|
|
9655
|
+
describe('mountBrowserApplication', () => {
|
|
9656
|
+
it('mounts the configured identity when the boundary answers off-contract', async () => {
|
|
9657
|
+
const element = buildElement()
|
|
9658
|
+
const application = await mountBrowserApplication(element)
|
|
9659
|
+
try {
|
|
9660
|
+
expect(element.textContent).toContain(APP_NAME)
|
|
9661
|
+
} finally {
|
|
9662
|
+
application.unmount()
|
|
9663
|
+
element.remove()
|
|
9664
|
+
}
|
|
9665
|
+
})
|
|
9666
|
+
})` : ""
|
|
9667
|
+
}, "browser"));
|
|
8967
9668
|
}
|
|
8968
9669
|
if (spec.app.includes("server")) {
|
|
8969
|
-
const testNameImport =
|
|
8970
|
-
|
|
9670
|
+
const testNameImport = hasBoundary ? `import {
|
|
9671
|
+
APP_HEALTH_METHOD,
|
|
9672
|
+
APP_HEALTH_PATH,
|
|
9673
|
+
APP_NAME,
|
|
9674
|
+
isApplicationRecord,
|
|
9675
|
+
readApplicationHealth,
|
|
9676
|
+
} from '@app/core'` : spec.app.includes("core") ? "import { APP_NAME } from '@app/core'" : "import { APP_NAME } from '@app/server'";
|
|
9677
|
+
const serverImport = hasBoundary ? `import {
|
|
9678
|
+
ApplicationServerRunner,
|
|
9679
|
+
createApplicationDispatcher,
|
|
9680
|
+
createApplicationServer,
|
|
9681
|
+
} from '@app/server'` : `import {
|
|
9682
|
+
APP_HEALTH_METHOD,
|
|
9683
|
+
APP_HEALTH_PATH,
|
|
9684
|
+
ApplicationServerRunner,
|
|
9685
|
+
createApplicationDispatcher,
|
|
9686
|
+
createApplicationServer,
|
|
9687
|
+
} from '@app/server'`;
|
|
9688
|
+
artifacts.push(fillArtifact("tests/app/server/ApplicationServer.test.ts", "tests", "appServerTest", {
|
|
9689
|
+
testNameImport,
|
|
9690
|
+
serverImport,
|
|
9691
|
+
boundary: hasBoundary ? `
|
|
9692
|
+
|
|
9693
|
+
describe('shared application boundary', () => {
|
|
9694
|
+
it('answers the shared record and translates it into the shared identity', async () => {
|
|
9695
|
+
const server = createApplicationServer({ server: { host: '127.0.0.1', port: 0 } })
|
|
9696
|
+
try {
|
|
9697
|
+
await server.start()
|
|
9698
|
+
const url = server.url
|
|
9699
|
+
if (url === undefined) throw new Error('Expected a bound application URL')
|
|
9700
|
+
const response = await fetch(\`\${url}\${APP_HEALTH_PATH}\`)
|
|
9701
|
+
const record: unknown = await response.json()
|
|
9702
|
+
|
|
9703
|
+
expect(isApplicationRecord(record)).toBe(true)
|
|
9704
|
+
expect(await readApplicationHealth(url)).toEqual({ name: APP_NAME })
|
|
9705
|
+
} finally {
|
|
9706
|
+
await server.destroy()
|
|
9707
|
+
}
|
|
9708
|
+
})
|
|
9709
|
+
|
|
9710
|
+
it('reads undefined from a released loopback port', async () => {
|
|
9711
|
+
const port = await reserveLoopbackPort()
|
|
9712
|
+
|
|
9713
|
+
expect(await readApplicationHealth(\`http://127.0.0.1:\${port}\`)).toBeUndefined()
|
|
9714
|
+
})
|
|
9715
|
+
})` : ""
|
|
9716
|
+
}, "server"), fillArtifact("tests/app/server/parsers.test.ts", "tests", "appServerParsersTest", {}, "server"));
|
|
8971
9717
|
}
|
|
8972
9718
|
const inlineExplicitInstance = `instance: ${pascal}Interface = new ${pascal}({ id: 'example' })`;
|
|
8973
9719
|
const multilineExplicitInstance = `instance: ${pascal}Interface = new ${pascal}({
|
|
@@ -9011,12 +9757,12 @@ function testArtifacts(spec, pascal) {
|
|
|
9011
9757
|
}
|
|
9012
9758
|
artifacts.push(fillArtifact("tests/setupGuides.ts", "tests", "setupGuides", {
|
|
9013
9759
|
specifiers: paritySpecifiers(spec),
|
|
9014
|
-
walkDirs: [
|
|
9015
|
-
...spec.src.length > 0 ? ["
|
|
9016
|
-
...spec.app.length > 0 ? ["
|
|
9017
|
-
"
|
|
9018
|
-
"
|
|
9019
|
-
]
|
|
9760
|
+
walkDirs: renderStringArray([
|
|
9761
|
+
...spec.src.length > 0 ? ["src"] : [],
|
|
9762
|
+
...spec.app.length > 0 ? ["app"] : [],
|
|
9763
|
+
"guides",
|
|
9764
|
+
"tests"
|
|
9765
|
+
], "", "export const GUIDE_WALK_DIRECTORIES: readonly string[] = Object.freeze(", ")")
|
|
9020
9766
|
}), fillArtifact("tests/guides/src/parity.test.ts", "tests", "parityTest", { name: spec.name }));
|
|
9021
9767
|
return artifacts;
|
|
9022
9768
|
}
|
|
@@ -9065,6 +9811,8 @@ function guideMemberTable(category, members) {
|
|
|
9065
9811
|
*/
|
|
9066
9812
|
function guideUsage(spec, pascal) {
|
|
9067
9813
|
const examples = [];
|
|
9814
|
+
const hasBoundary = hasApplicationBoundary(spec);
|
|
9815
|
+
const hasShowcase = hasApplicationShowcase(spec);
|
|
9068
9816
|
if (spec.src.length > 0) examples.push(`\`\`\`ts
|
|
9069
9817
|
import { create${pascal} } from '@orkestrel/${spec.name}'
|
|
9070
9818
|
|
|
@@ -9081,6 +9829,24 @@ import {
|
|
|
9081
9829
|
${CONST_KEYWORD} name = parseApplicationName(' ${spec.name} ')
|
|
9082
9830
|
${CONST_KEYWORD} application = createApplication(name)
|
|
9083
9831
|
isApplicationError(new ApplicationError('CONFIG', 'invalid')) // true
|
|
9832
|
+
\`\`\``);
|
|
9833
|
+
if (hasBoundary) examples.push(`\`\`\`ts
|
|
9834
|
+
import type { ApplicationRecord } from '@app/core'
|
|
9835
|
+
import {
|
|
9836
|
+
APP_HEALTH_METHOD,
|
|
9837
|
+
APP_HEALTH_PATH,
|
|
9838
|
+
APP_HEALTH_TIMEOUT,
|
|
9839
|
+
isApplicationRecord,
|
|
9840
|
+
readApplicationHealth,
|
|
9841
|
+
} from '@app/core'
|
|
9842
|
+
|
|
9843
|
+
APP_HEALTH_METHOD // 'GET'
|
|
9844
|
+
APP_HEALTH_PATH // '/health'
|
|
9845
|
+
APP_HEALTH_TIMEOUT // 5000
|
|
9846
|
+
${CONST_KEYWORD} healthy: ApplicationRecord = { name: '${spec.name}', status: 'ok' }
|
|
9847
|
+
isApplicationRecord(healthy) // true
|
|
9848
|
+
isApplicationRecord({ name: '${spec.name}', status: 'down' }) // false
|
|
9849
|
+
await readApplicationHealth('http://127.0.0.1:3000') // { name: '${spec.name}' } or undefined
|
|
9084
9850
|
\`\`\``);
|
|
9085
9851
|
if (spec.app.includes("browser")) examples.push(`\`\`\`ts
|
|
9086
9852
|
import {
|
|
@@ -9096,27 +9862,36 @@ ${CONST_KEYWORD} browserOptions = parseBrowserApplicationOptions({
|
|
|
9096
9862
|
${CONST_KEYWORD} browser = createBrowserApplication(browserOptions)
|
|
9097
9863
|
browser.mount('#app')
|
|
9098
9864
|
isBrowserApplicationError(new BrowserApplicationError('CONFIG', 'invalid')) // true
|
|
9865
|
+
\`\`\``);
|
|
9866
|
+
if (hasShowcase) examples.push(`\`\`\`ts
|
|
9867
|
+
import { mountShowcaseApplication, seedApplication } from '@app/browser'
|
|
9868
|
+
|
|
9869
|
+
${CONST_KEYWORD} seed = seedApplication()
|
|
9870
|
+
${CONST_KEYWORD} showcase = mountShowcaseApplication('#app')
|
|
9871
|
+
seed.name // '${spec.name} showcase'
|
|
9872
|
+
showcase.unmount()
|
|
9873
|
+
\`\`\``);
|
|
9874
|
+
if (hasBoundary) examples.push(`\`\`\`ts
|
|
9875
|
+
import { mountBrowserApplication } from '@app/browser'
|
|
9876
|
+
|
|
9877
|
+
${CONST_KEYWORD} application = await mountBrowserApplication('#app')
|
|
9878
|
+
application.unmount()
|
|
9099
9879
|
\`\`\``);
|
|
9100
9880
|
if (spec.app.includes("server")) examples.push(`\`\`\`ts
|
|
9101
|
-
import {
|
|
9102
|
-
import {
|
|
9881
|
+
${hasBoundary ? `import type { ApplicationRecord } from '@app/core'
|
|
9882
|
+
import type { ApplicationState } from '@app/server'
|
|
9883
|
+
import { APP_HEALTH_METHOD, APP_HEALTH_PATH, isApplicationRecord } from '@app/core'` : "import type { ApplicationRecord, ApplicationState } from '@app/server'"}
|
|
9103
9884
|
import {
|
|
9104
|
-
APP_HEALTH_METHOD,
|
|
9885
|
+
${hasBoundary ? "" : ` APP_HEALTH_METHOD,
|
|
9105
9886
|
APP_HEALTH_PATH,
|
|
9106
|
-
|
|
9107
|
-
APP_HOST_LABEL_PATTERN,
|
|
9108
|
-
APP_KEEP_ALIVE_TIMEOUT,
|
|
9109
|
-
APP_MAX_CONNECTIONS,
|
|
9110
|
-
APP_MAX_HEADERS,
|
|
9111
|
-
APP_MAX_REQUESTS_PER_SOCKET,
|
|
9112
|
-
APP_REQUEST_TIMEOUT,
|
|
9887
|
+
`} APP_HOST_LABEL_PATTERN,
|
|
9113
9888
|
APP_NUMERIC_HOST_PATTERN,
|
|
9114
|
-
ApplicationServer,
|
|
9115
9889
|
ApplicationServerError,
|
|
9116
9890
|
DEFAULT_APP_START_TIMEOUT,
|
|
9117
9891
|
MAX_APP_START_TIMEOUT,
|
|
9892
|
+
createApplicationDispatcher,
|
|
9118
9893
|
createApplicationServer,
|
|
9119
|
-
|
|
9894
|
+
handleApplicationHealth,
|
|
9120
9895
|
isApplicationServerError,
|
|
9121
9896
|
parseApplicationHost,
|
|
9122
9897
|
parseApplicationPort,
|
|
@@ -9128,41 +9903,51 @@ import {
|
|
|
9128
9903
|
${CONST_KEYWORD} host = parseApplicationHost('127.0.0.1')
|
|
9129
9904
|
${CONST_KEYWORD} port = parseApplicationPort('0')
|
|
9130
9905
|
${CONST_KEYWORD} timeout = parseApplicationStartTimeout('5000')
|
|
9131
|
-
${CONST_KEYWORD} options = parseApplicationServerOptions({ host, port, timeout })
|
|
9906
|
+
${CONST_KEYWORD} options = parseApplicationServerOptions({ server: { host, port, timeout } })
|
|
9907
|
+
parseApplicationStartTimeout(String(DEFAULT_APP_START_TIMEOUT)) // 10000
|
|
9908
|
+
MAX_APP_START_TIMEOUT // 300000
|
|
9132
9909
|
APP_HOST_LABEL_PATTERN.test('api') // true
|
|
9133
9910
|
APP_NUMERIC_HOST_PATTERN.test('999.999.999.999') // true (and therefore rejected as a host)
|
|
9134
|
-
|
|
9135
|
-
|
|
9136
|
-
|
|
9137
|
-
|
|
9138
|
-
|
|
9139
|
-
|
|
9140
|
-
|
|
9141
|
-
|
|
9142
|
-
|
|
9143
|
-
|
|
9144
|
-
${CONST_KEYWORD}
|
|
9145
|
-
|
|
9146
|
-
|
|
9147
|
-
|
|
9148
|
-
|
|
9149
|
-
|
|
9150
|
-
|
|
9151
|
-
${CONST_KEYWORD}
|
|
9152
|
-
isApplicationServerError(
|
|
9153
|
-
reportApplicationServerError(
|
|
9154
|
-
|
|
9911
|
+
${CONST_KEYWORD} state: ApplicationState = { connection: { encrypted: false } }
|
|
9912
|
+
${CONST_KEYWORD} record: ApplicationRecord = { name: '${spec.name}', status: 'ok' }
|
|
9913
|
+
${CONST_KEYWORD} dispatcher = createApplicationDispatcher()
|
|
9914
|
+
try {
|
|
9915
|
+
${CONST_KEYWORD} response = await dispatcher.handle(
|
|
9916
|
+
new Request(\`http://application.test\${APP_HEALTH_PATH}\`, { method: APP_HEALTH_METHOD }),
|
|
9917
|
+
state,
|
|
9918
|
+
)
|
|
9919
|
+
${CONST_KEYWORD} health = handleApplicationHealth()
|
|
9920
|
+
${CONST_KEYWORD} encoded = Response.json(record)
|
|
9921
|
+
${hasBoundary ? `${CONST_KEYWORD} value: unknown = await health.clone().json()
|
|
9922
|
+
isApplicationRecord(value) // true
|
|
9923
|
+
` : ""}if (!response.ok || !health.ok || !encoded.ok) throw new Error('Application health failed')
|
|
9924
|
+
} finally {
|
|
9925
|
+
dispatcher.destroy()
|
|
9926
|
+
}
|
|
9927
|
+
|
|
9928
|
+
${CONST_KEYWORD} failure: unknown = new ApplicationServerError('CONFIG', 'invalid')
|
|
9929
|
+
if (isApplicationServerError(failure)) {
|
|
9930
|
+
reportApplicationServerError(failure) // writes only a stable CONFIG diagnostic
|
|
9931
|
+
}
|
|
9155
9932
|
|
|
9156
9933
|
${CONST_KEYWORD} server = createApplicationServer(options)
|
|
9157
9934
|
${CONST_KEYWORD} controller = new AbortController()
|
|
9158
9935
|
await server.start(controller.signal)
|
|
9159
9936
|
await server.stop()
|
|
9937
|
+
await server.destroy()
|
|
9160
9938
|
\`\`\`
|
|
9161
9939
|
|
|
9162
9940
|
\`\`\`ts
|
|
9163
|
-
import {
|
|
9164
|
-
|
|
9165
|
-
|
|
9941
|
+
import type { ApplicationServerRunnerEventMap, ApplicationServerRunnerOptions } from '@app/server'
|
|
9942
|
+
import { ApplicationServerRunner, createApplicationServer } from '@app/server'
|
|
9943
|
+
|
|
9944
|
+
${CONST_KEYWORD} event: keyof ApplicationServerRunnerEventMap = 'ready'
|
|
9945
|
+
${CONST_KEYWORD} observe: ApplicationServerRunnerOptions = { on: { fail: () => undefined } }
|
|
9946
|
+
${CONST_KEYWORD} runner = new ApplicationServerRunner(
|
|
9947
|
+
createApplicationServer({ server: { port: 0 } }),
|
|
9948
|
+
observe,
|
|
9949
|
+
)
|
|
9950
|
+
runner.emitter.once(event, (url) => console.log(url))
|
|
9166
9951
|
runner.start() // process owns shutdown signals
|
|
9167
9952
|
await runner.stop()
|
|
9168
9953
|
\`\`\`
|
|
@@ -9170,7 +9955,7 @@ await runner.stop()
|
|
|
9170
9955
|
\`\`\`ts
|
|
9171
9956
|
import { startApplicationServer } from '@app/server'
|
|
9172
9957
|
|
|
9173
|
-
${CONST_KEYWORD} processRunner = startApplicationServer({ port: 0 })
|
|
9958
|
+
${CONST_KEYWORD} processRunner = startApplicationServer({ server: { port: 0 } })
|
|
9174
9959
|
await processRunner.stop()
|
|
9175
9960
|
\`\`\``);
|
|
9176
9961
|
return examples.join("\n\n");
|
|
@@ -9193,15 +9978,23 @@ ${alignTable([
|
|
|
9193
9978
|
"Method",
|
|
9194
9979
|
"Returns",
|
|
9195
9980
|
"Behavior"
|
|
9196
|
-
], [[
|
|
9197
|
-
"`start`",
|
|
9198
|
-
"`Promise<void>`",
|
|
9199
|
-
"Serialize in call order; start only when stopped, and repeat safely when already listening. The optional `AbortSignal` and bounded startup timeout cancel pending name resolution/listen work. Rejects with `ApplicationServerError` code `LIFECYCLE` when startup fails, times out, or the caller aborts."
|
|
9200
9981
|
], [
|
|
9201
|
-
|
|
9202
|
-
|
|
9203
|
-
|
|
9204
|
-
|
|
9982
|
+
[
|
|
9983
|
+
"`start`",
|
|
9984
|
+
"`Promise<void>`",
|
|
9985
|
+
"Bind the installed `@orkestrel/server` substrate when idle or stopped. The optional `AbortSignal` and bounded startup timeout cancel pending binding. Rejects with `ApplicationServerError` code `LIFECYCLE` when startup fails, times out, or the caller aborts."
|
|
9986
|
+
],
|
|
9987
|
+
[
|
|
9988
|
+
"`stop`",
|
|
9989
|
+
"`Promise<void>`",
|
|
9990
|
+
"Drain and stop the installed server; repeated calls while stopped are safe. Rejects with `ApplicationServerError` code `LIFECYCLE` when closing fails."
|
|
9991
|
+
],
|
|
9992
|
+
[
|
|
9993
|
+
"`destroy`",
|
|
9994
|
+
"`Promise<void>`",
|
|
9995
|
+
"Perform terminal idempotent teardown through the installed server lifecycle, then destroy its owned dispatcher. Rejects with `ApplicationServerError` code `LIFECYCLE` when server teardown fails."
|
|
9996
|
+
]
|
|
9997
|
+
])}
|
|
9205
9998
|
|
|
9206
9999
|
#### \`ApplicationServerRunnerInterface\`
|
|
9207
10000
|
|
|
@@ -9212,17 +10005,18 @@ ${alignTable([
|
|
|
9212
10005
|
], [[
|
|
9213
10006
|
"`start`",
|
|
9214
10007
|
"`void`",
|
|
9215
|
-
"Register one
|
|
10008
|
+
"Register one generation-owned set of SIGINT/SIGTERM cleanup listeners, queue the substrate start behind any shutdown already in flight, emit `ready` after binding, and emit `fail` for a current lifecycle failure."
|
|
9216
10009
|
], [
|
|
9217
10010
|
"`stop`",
|
|
9218
10011
|
"`Promise<void>`",
|
|
9219
|
-
"
|
|
10012
|
+
"Abort a startup still in flight and release both process listeners, then wait for that startup to settle before stopping the server; concurrent calls join one substrate stop, and lifecycle failures emit `fail` and reject."
|
|
9220
10013
|
]])}
|
|
9221
10014
|
|
|
9222
|
-
The
|
|
9223
|
-
|
|
9224
|
-
|
|
9225
|
-
\`
|
|
10015
|
+
The runner exposes its readonly \`emitter\`. \`ApplicationServerRunnerEventMap\` emits \`ready\` with the bound URL and \`fail\` with an \`unknown\` error. \`ApplicationServerRunnerOptions\` accepts initial \`on\` hooks and an emitter \`error\` handler; initial hooks run before the runner's own announcement and reporting listeners, so when no earlier failure set an exit code, a synchronous \`fail\` hook sees \`process.exitCode === undefined\` before the default reporter sets it to \`1\`. The default listeners preserve one exact \`[READY] <name> <url>\` stderr line and the stable redacted failure diagnostics. In-process consumers and tests park on runner events; a child process still observes the \`[READY]\` line because that byte stream is its process-boundary channel.
|
|
10016
|
+
|
|
10017
|
+
The application server constructor validates grouped direct options plus \`APP_HOST\`, \`APP_PORT\`, and
|
|
10018
|
+
\`APP_START_TIMEOUT\` before binding. Direct options must be an exact plain own-key data record containing only a
|
|
10019
|
+
\`server\` record with \`host\`, \`port\`, and/or \`timeout\`; inherited properties, accessors, symbols, instances, proxies that
|
|
9226
10020
|
throw during reflection, and unknown keys fail closed. Invalid values throw
|
|
9227
10021
|
\`ApplicationServerError\` code \`CONFIG\`; the default host is loopback and port \`0\` is
|
|
9228
10022
|
supported for collision-free ephemeral allocation. Startup defaults to 10 seconds and accepts
|
|
@@ -9230,10 +10024,15 @@ only integer timeouts from 1 through 300,000 milliseconds. Lifecycle failures us
|
|
|
9230
10024
|
\`LIFECYCLE\`; both may carry \`context.cause\` or \`context.value\`. Narrow caught values with
|
|
9231
10025
|
\`isApplicationServerError\` before reading either field.
|
|
9232
10026
|
|
|
9233
|
-
|
|
9234
|
-
|
|
9235
|
-
|
|
9236
|
-
|
|
10027
|
+
Before binding, \`url\` is \`undefined\`; after a successful start it reflects the real bound port,
|
|
10028
|
+
and it returns to \`undefined\` after stop or destroy. \`ApplicationState\` extends middleware's
|
|
10029
|
+
\`IdentifierState\` and adds only its \`connection\` property; there is no redundant \`listening\` member.
|
|
10030
|
+
|
|
10031
|
+
Each \`createApplicationDispatcher()\` call returns a fresh dispatcher that owns exactly \`GET /health\`
|
|
10032
|
+
and serializes the shared \`ApplicationRecord\` shape \`{ name: APP_NAME, status: 'ok' }\` as JSON. The
|
|
10033
|
+
server composes \`createBoundary()\`, \`createSecurity()\`, then \`createDeadline({ ms: timeout })\`
|
|
10034
|
+
around that owned dispatcher; standalone callers destroy theirs after use. Every other path returns
|
|
10035
|
+
\`404\`, and every unsupported method returns \`405\` with \`Allow: GET\`.`;
|
|
9237
10036
|
}
|
|
9238
10037
|
/**
|
|
9239
10038
|
* Build links to every generated source and application test file.
|
|
@@ -9243,7 +10042,7 @@ method returns deterministic plain-text \`405 Method Not Allowed\` with \`Allow:
|
|
|
9243
10042
|
* @returns A newline-separated Markdown test inventory.
|
|
9244
10043
|
*/
|
|
9245
10044
|
function guideTests(spec, pascal) {
|
|
9246
|
-
const tests = ["- [`tests/policy.test.ts`](../../tests/policy.test.ts) — filename placement and
|
|
10045
|
+
const tests = ["- [`tests/policy.test.ts`](../../tests/policy.test.ts) — repository coding law and filename placement.", "- [`tests/config/vite.test.ts`](../../tests/config/vite.test.ts) — executable root Vite invariants and conditional browser capability."];
|
|
9247
10046
|
for (const environment of spec.src) tests.push(`- [\`tests/src/${environment}/${pascal}.test.ts\`](../../tests/src/${environment}/${pascal}.test.ts) — entity boundaries.`, `- [\`tests/src/${environment}/factories.test.ts\`](../../tests/src/${environment}/factories.test.ts) — factory behavior.`);
|
|
9248
10047
|
if (spec.app.includes("core")) tests.push("- [`tests/app/core/factories.test.ts`](../../tests/app/core/factories.test.ts) — host-independent identity behavior.");
|
|
9249
10048
|
if (spec.app.includes("browser")) tests.push("- [`tests/app/browser/factories.test.ts`](../../tests/app/browser/factories.test.ts) — real-browser mount and cleanup.");
|
|
@@ -9924,7 +10723,9 @@ function createBlueprint(data) {
|
|
|
9924
10723
|
}
|
|
9925
10724
|
//#endregion
|
|
9926
10725
|
exports.APP_BROWSER_DEV_DEPENDENCIES = APP_BROWSER_DEV_DEPENDENCIES;
|
|
10726
|
+
exports.APP_DEV_DEPENDENCIES = APP_DEV_DEPENDENCIES;
|
|
9927
10727
|
exports.APP_MATRIX = APP_MATRIX;
|
|
10728
|
+
exports.APP_SERVER_DEV_DEPENDENCIES = APP_SERVER_DEV_DEPENDENCIES;
|
|
9928
10729
|
exports.BASE_DEV_DEPENDENCIES = BASE_DEV_DEPENDENCIES;
|
|
9929
10730
|
exports.BIN_CONFIGS = BIN_CONFIGS;
|
|
9930
10731
|
exports.CATEGORIES = CATEGORIES;
|
|
@@ -10003,6 +10804,7 @@ exports.compareCodeUnit = compareCodeUnit;
|
|
|
10003
10804
|
exports.computeColumnWidth = computeColumnWidth;
|
|
10004
10805
|
exports.computeHash = computeHash;
|
|
10005
10806
|
exports.configArtifacts = configArtifacts;
|
|
10807
|
+
exports.configViteProject = configViteProject;
|
|
10006
10808
|
exports.contentByteLength = contentByteLength;
|
|
10007
10809
|
exports.contentCodePoint = contentCodePoint;
|
|
10008
10810
|
exports.contentToBytes = contentToBytes;
|
|
@@ -10032,6 +10834,8 @@ exports.guideMethods = guideMethods;
|
|
|
10032
10834
|
exports.guideTests = guideTests;
|
|
10033
10835
|
exports.guideUsage = guideUsage;
|
|
10034
10836
|
exports.guidesViteProject = guidesViteProject;
|
|
10837
|
+
exports.hasApplicationBoundary = hasApplicationBoundary;
|
|
10838
|
+
exports.hasApplicationShowcase = hasApplicationShowcase;
|
|
10035
10839
|
exports.hasBlueprintEnvironment = hasBlueprintEnvironment;
|
|
10036
10840
|
exports.hasOnlyDataProperties = hasOnlyDataProperties;
|
|
10037
10841
|
exports.hasValidArtifactBytes = hasValidArtifactBytes;
|
|
@@ -10087,6 +10891,7 @@ exports.policyViteProject = policyViteProject;
|
|
|
10087
10891
|
exports.rangeToFreshness = rangeToFreshness;
|
|
10088
10892
|
exports.renderArray = renderArray;
|
|
10089
10893
|
exports.renderObject = renderObject;
|
|
10894
|
+
exports.renderStringArray = renderStringArray;
|
|
10090
10895
|
exports.renderValue = renderValue;
|
|
10091
10896
|
exports.renderViteTest = renderViteTest;
|
|
10092
10897
|
exports.rootTsconfig = rootTsconfig;
|