@olenbetong/appframe-vite 6.0.4 → 6.1.1
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/lib/cli.d.ts +2 -0
- package/lib/cli.js +29 -0
- package/lib/devServer.js +25 -4
- package/lib/generateTypes.d.ts +4 -0
- package/lib/generateTypes.js +250 -0
- package/lib/index.js +26 -12
- package/lib/localization.js +7 -0
- package/lib/proxy.d.ts +9 -1
- package/lib/proxy.js +124 -20
- package/lib/utils.d.ts +40 -0
- package/lib/utils.js +102 -0
- package/package.json +6 -3
package/lib/cli.d.ts
ADDED
package/lib/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { config } from "dotenv";
|
|
3
|
+
import { runGenerateTypes } from "./generateTypes.js";
|
|
4
|
+
import { importJson } from "./importJson.js";
|
|
5
|
+
import { createLogMessage } from "./utils.js";
|
|
6
|
+
async function generateTypes() {
|
|
7
|
+
config({ path: `${process.cwd()}/.env`, quiet: true });
|
|
8
|
+
const pkg = await importJson("./package.json", true);
|
|
9
|
+
const hostname = pkg.appframe.proxy?.hostname ?? "dev.obet.no";
|
|
10
|
+
const { APPFRAME_LOGIN: username = "", APPFRAME_PWD: password = "" } = process.env;
|
|
11
|
+
const logger = {
|
|
12
|
+
info: (msg) => console.log(msg),
|
|
13
|
+
error: (msg) => console.error(msg),
|
|
14
|
+
};
|
|
15
|
+
logger.info(createLogMessage("Generating types…", { source: hostname }));
|
|
16
|
+
await runGenerateTypes(hostname, username, password, pkg.appframe, logger);
|
|
17
|
+
}
|
|
18
|
+
const [, , command] = process.argv;
|
|
19
|
+
switch (command) {
|
|
20
|
+
case "generate-types":
|
|
21
|
+
await generateTypes();
|
|
22
|
+
break;
|
|
23
|
+
default:
|
|
24
|
+
console.error(createLogMessage(`Unknown command: ${command ?? "(none)"}`, { type: "error" }));
|
|
25
|
+
console.error("Usage: appframe-vite <command>");
|
|
26
|
+
console.error("Commands:");
|
|
27
|
+
console.error(" generate-types Generate TypeScript type definitions");
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
package/lib/devServer.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { Client } from "@olenbetong/appframe-data";
|
|
2
|
-
import dotenv from "dotenv";
|
|
3
|
-
import { JSDOM } from "jsdom";
|
|
4
1
|
import { existsSync } from "node:fs";
|
|
5
2
|
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
6
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { Client } from "@olenbetong/appframe-data";
|
|
5
|
+
import dotenv from "dotenv";
|
|
6
|
+
import { JSDOM } from "jsdom";
|
|
7
7
|
import { importJson } from "./importJson.js";
|
|
8
8
|
import { getStringCache } from "./localization.js";
|
|
9
|
+
import { diagnoseServerResponse } from "./utils.js";
|
|
9
10
|
dotenv.config({ quiet: true });
|
|
10
11
|
let appPkg = await importJson("./package.json", true);
|
|
11
12
|
let { appframe } = appPkg;
|
|
@@ -129,7 +130,27 @@ async function getArticleHtml() {
|
|
|
129
130
|
let { hostname, username, password } = await getLoginInfo();
|
|
130
131
|
let article = appframe.article?.id;
|
|
131
132
|
let client = new Client(hostname);
|
|
132
|
-
|
|
133
|
+
try {
|
|
134
|
+
await client.login(username, password);
|
|
135
|
+
}
|
|
136
|
+
catch (loginError) {
|
|
137
|
+
const isJsonError = loginError instanceof SyntaxError &&
|
|
138
|
+
(loginError.message.includes("not valid JSON") || loginError.message.includes("Unexpected token"));
|
|
139
|
+
if (isJsonError) {
|
|
140
|
+
let loginBody = `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&remember=true&RequireTwoFactor=0`;
|
|
141
|
+
let diagnostic = await diagnoseServerResponse(`https://${hostname}/login`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
body: loginBody,
|
|
144
|
+
headers: {
|
|
145
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
146
|
+
Accept: "application/json",
|
|
147
|
+
},
|
|
148
|
+
followRedirects: true,
|
|
149
|
+
});
|
|
150
|
+
throw new Error(`login to ${hostname} (article HTML fetch): server returned non-JSON. Server response:\n${diagnostic}`);
|
|
151
|
+
}
|
|
152
|
+
throw loginError;
|
|
153
|
+
}
|
|
133
154
|
let response = await client.fetch(`/${article}`, {
|
|
134
155
|
timeout: 30_000,
|
|
135
156
|
method: "GET",
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ViteDevServer } from "vite";
|
|
2
|
+
type Logger = Pick<ViteDevServer["config"]["logger"], "info" | "error">;
|
|
3
|
+
export declare function runGenerateTypes(hostname: string, username: string, password: string, appframe: any, logger: Logger): Promise<void>;
|
|
4
|
+
export {};
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import vm from "node:vm";
|
|
6
|
+
import { Client, DataObject, generateApiDataHandler, getDefaultClient, Procedure, setDefaultClient, } from "@olenbetong/appframe-data";
|
|
7
|
+
import { importJson } from "./importJson.js";
|
|
8
|
+
import { createLogMessage, diagnoseServerResponse, wrapJsonError } from "./utils.js";
|
|
9
|
+
// ─── Type generation logic ────────────────────────────────────────────────────
|
|
10
|
+
function afTypeToTsType(type, isProc = false) {
|
|
11
|
+
if (type === "uniqueidentifier")
|
|
12
|
+
return "string";
|
|
13
|
+
if (type && ["date", "datetime"].includes(type))
|
|
14
|
+
return isProc ? "string | Date" : "Date";
|
|
15
|
+
return type ?? "string";
|
|
16
|
+
}
|
|
17
|
+
function getDataObjectTypes(af, parameterOverrides = {}) {
|
|
18
|
+
let result = [];
|
|
19
|
+
let globals = [];
|
|
20
|
+
let dataObjects = Object.values((af.article?.dataObjects ?? {})).sort((a, b) => a.getDataSourceId() >= b.getDataSourceId() ? 1 : -1);
|
|
21
|
+
for (let dataObject of dataObjects) {
|
|
22
|
+
let id = dataObject.getDataSourceId();
|
|
23
|
+
let typeName = id.startsWith("ds") ? id.substring(2) : id;
|
|
24
|
+
typeName = `${typeName}Record`;
|
|
25
|
+
result.push(`export interface ${typeName} extends Record<string, unknown> {`);
|
|
26
|
+
for (let field of dataObject.getFields()) {
|
|
27
|
+
let type = parameterOverrides[id]?.[field.name] ??
|
|
28
|
+
afTypeToTsType(field.type ?? "string") + (field.nullable ? " | null" : "");
|
|
29
|
+
let propName = field.alias ?? field.aliasName ?? field.name;
|
|
30
|
+
let fieldName = propName.indexOf("-") > 0 ? `"${propName}"` : propName;
|
|
31
|
+
result.push(`\t${fieldName}: ${type};`);
|
|
32
|
+
}
|
|
33
|
+
result.push("};");
|
|
34
|
+
result.push("");
|
|
35
|
+
globals.push(`${id}: DataObject<${typeName}>;`);
|
|
36
|
+
}
|
|
37
|
+
if (globals.length === 0)
|
|
38
|
+
return "";
|
|
39
|
+
result.push("declare global {");
|
|
40
|
+
result.push(globals.map((g) => `\tvar ${g}`).join("\n"));
|
|
41
|
+
result.push("\tinterface Window {");
|
|
42
|
+
result.push(globals.map((g) => `\t\t${g}`).join("\n"));
|
|
43
|
+
result.push("\t}");
|
|
44
|
+
result.push("}");
|
|
45
|
+
return result.join("\n");
|
|
46
|
+
}
|
|
47
|
+
function getProcedureTypes(af, parameterOverrides = {}, returnTypes = {}) {
|
|
48
|
+
let result = [];
|
|
49
|
+
let globals = [];
|
|
50
|
+
let procedures = Object.values((af.article?.procedures ?? {})).sort((a, b) => (a.options.procedureId >= b.options.procedureId ? 1 : -1));
|
|
51
|
+
for (let procedure of procedures) {
|
|
52
|
+
let id = procedure.options.procedureId;
|
|
53
|
+
let params = procedure.getParameters();
|
|
54
|
+
let typeName = id.startsWith("proc") ? id.substring(4) : id;
|
|
55
|
+
typeName = `${typeName}Params`;
|
|
56
|
+
if (params.length > 0) {
|
|
57
|
+
result.push(`export type ${typeName} = {`);
|
|
58
|
+
for (let param of params) {
|
|
59
|
+
let type = parameterOverrides[id]?.[param.name] ?? `${afTypeToTsType(param.type, true)} | null`;
|
|
60
|
+
let required = parameterOverrides[id]?.__required?.includes(param.name);
|
|
61
|
+
result.push(`\t${param.name}${required || param.required ? "" : "?"}: ${type};`);
|
|
62
|
+
}
|
|
63
|
+
result.push("};");
|
|
64
|
+
result.push("");
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
result.push(`export type ${typeName} = null | undefined | Record<string, never>;\n`);
|
|
68
|
+
}
|
|
69
|
+
globals.push(`${id}: Procedure<${typeName}, ${Object.keys(returnTypes).includes(id) ? returnTypes[id] : "any"}>;`);
|
|
70
|
+
}
|
|
71
|
+
if (globals.length === 0)
|
|
72
|
+
return "";
|
|
73
|
+
result.push("declare global {");
|
|
74
|
+
result.push(globals.map((g) => `\tvar ${g}`).join("\n"));
|
|
75
|
+
result.push("\tinterface Window {");
|
|
76
|
+
result.push(globals.map((g) => `\t\t${g}`).join("\n"));
|
|
77
|
+
result.push("\t}");
|
|
78
|
+
result.push("}");
|
|
79
|
+
return result.join("\n");
|
|
80
|
+
}
|
|
81
|
+
function buildTypes(af, customExists = false, overridesConfig = {}) {
|
|
82
|
+
let dataObjects = getDataObjectTypes(af, overridesConfig.parameterTypes);
|
|
83
|
+
let procedures = getProcedureTypes(af, overridesConfig.parameterTypes, overridesConfig.procedureReturnTypes);
|
|
84
|
+
let types = [];
|
|
85
|
+
if (dataObjects.length)
|
|
86
|
+
types.push("DataObject");
|
|
87
|
+
if (procedures.length)
|
|
88
|
+
types.push("Procedure");
|
|
89
|
+
let result = [
|
|
90
|
+
`import type { ${types.join(", ")} } from "@olenbetong/appframe-data";${customExists && (dataObjects.includes("Custom.") || procedures.includes("Custom."))
|
|
91
|
+
? '\nimport type * as Custom from "./custom";'
|
|
92
|
+
: ""}`,
|
|
93
|
+
];
|
|
94
|
+
if (dataObjects.length)
|
|
95
|
+
result.push(dataObjects);
|
|
96
|
+
if (procedures.length)
|
|
97
|
+
result.push(procedures);
|
|
98
|
+
return result.join("\n\n");
|
|
99
|
+
}
|
|
100
|
+
// ─── Article features ─────────────────────────────────────────────────────────
|
|
101
|
+
function createArticlesFeaturesDataHandler() {
|
|
102
|
+
return generateApiDataHandler({
|
|
103
|
+
client: getDefaultClient(),
|
|
104
|
+
resource: "aviw_FeatureMgmt_ArticlesFeatures",
|
|
105
|
+
fields: [
|
|
106
|
+
{ name: "HostName", type: "string", nullable: false },
|
|
107
|
+
{ name: "ArticleId", type: "string", nullable: false },
|
|
108
|
+
{ name: "Key", type: "string", nullable: false },
|
|
109
|
+
{ name: "Name", type: "string", nullable: false },
|
|
110
|
+
{ name: "Description", type: "string", nullable: true },
|
|
111
|
+
],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
115
|
+
function withTimeout(promise, ms, label) {
|
|
116
|
+
return Promise.race([
|
|
117
|
+
promise,
|
|
118
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out: ${label}`)), ms)),
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
/** Run biome format on a file. Silently skips if biome is unavailable. */
|
|
122
|
+
async function formatFile(filePath) {
|
|
123
|
+
await new Promise((resolve) => {
|
|
124
|
+
const child = spawn("pnpm", ["biome", "format", filePath, "--write"], {
|
|
125
|
+
shell: process.platform === "win32",
|
|
126
|
+
stdio: "pipe",
|
|
127
|
+
});
|
|
128
|
+
child.on("close", () => resolve());
|
|
129
|
+
child.on("error", () => resolve());
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// ─── Core type generation ─────────────────────────────────────────────────────
|
|
133
|
+
const TIMEOUT_MS = 30_000;
|
|
134
|
+
const MAX_RETRIES = 3;
|
|
135
|
+
const RETRY_DELAY_BASE_MS = 2_000;
|
|
136
|
+
async function doGenerateTypes(hostname, username, password, appframe) {
|
|
137
|
+
const client = new Client(hostname);
|
|
138
|
+
setDefaultClient(client);
|
|
139
|
+
try {
|
|
140
|
+
await withTimeout(client.login(username, password), TIMEOUT_MS, "login");
|
|
141
|
+
}
|
|
142
|
+
catch (loginError) {
|
|
143
|
+
const isJsonError = loginError instanceof SyntaxError &&
|
|
144
|
+
(loginError.message.includes("not valid JSON") || loginError.message.includes("Unexpected token"));
|
|
145
|
+
if (isJsonError) {
|
|
146
|
+
let loginBody = `username=${encodeURIComponent(username)}&password=${encodeURIComponent(password)}&remember=true&RequireTwoFactor=0`;
|
|
147
|
+
let diagnostic = await diagnoseServerResponse(`https://${hostname}/login`, {
|
|
148
|
+
method: "POST",
|
|
149
|
+
body: loginBody,
|
|
150
|
+
headers: {
|
|
151
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
152
|
+
Accept: "application/json",
|
|
153
|
+
},
|
|
154
|
+
followRedirects: true,
|
|
155
|
+
});
|
|
156
|
+
throw new Error(`login to ${hostname}: server returned non-JSON. Server response:\n${diagnostic}`);
|
|
157
|
+
}
|
|
158
|
+
throw loginError;
|
|
159
|
+
}
|
|
160
|
+
const articleId = appframe.article?.id ?? appframe.article;
|
|
161
|
+
const articleHost = appframe.article?.hostname ?? hostname;
|
|
162
|
+
const scriptGlobal = {
|
|
163
|
+
__VERSION__: "0.1.0",
|
|
164
|
+
af: {
|
|
165
|
+
controls: new Proxy({}, { get: () => class {
|
|
166
|
+
} }),
|
|
167
|
+
common: {
|
|
168
|
+
expose(path, value) {
|
|
169
|
+
let properties = path.split(".");
|
|
170
|
+
let final = properties.pop();
|
|
171
|
+
let current = scriptGlobal;
|
|
172
|
+
for (let prop of properties) {
|
|
173
|
+
if (!current[prop])
|
|
174
|
+
current[prop] = {};
|
|
175
|
+
current = current[prop];
|
|
176
|
+
}
|
|
177
|
+
current[final] = value;
|
|
178
|
+
},
|
|
179
|
+
localStorage: {
|
|
180
|
+
get(_, fallback) {
|
|
181
|
+
return fallback;
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
DataObject,
|
|
186
|
+
Procedure,
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const response = await withTimeout(client.fetch(`/file/article/static-script/${articleId}.js`, { timeout: TIMEOUT_MS }), TIMEOUT_MS, "fetch article script");
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(`Failed to fetch article script: ${response.status} ${response.statusText}`);
|
|
192
|
+
}
|
|
193
|
+
const scriptText = await response.text();
|
|
194
|
+
const context = vm.createContext(scriptGlobal);
|
|
195
|
+
new vm.Script(scriptText).runInContext(context);
|
|
196
|
+
// Resolve custom types file
|
|
197
|
+
let customTypesPath = "./src/custom.d.ts";
|
|
198
|
+
let hasCustomTypesFile = existsSync(resolve(process.cwd(), customTypesPath));
|
|
199
|
+
if (!hasCustomTypesFile) {
|
|
200
|
+
customTypesPath = "./src/custom.ts";
|
|
201
|
+
hasCustomTypesFile = existsSync(resolve(process.cwd(), customTypesPath));
|
|
202
|
+
}
|
|
203
|
+
let typeOverrides = {};
|
|
204
|
+
try {
|
|
205
|
+
typeOverrides = await importJson("./types.json", true);
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
// No overrides file — that's fine
|
|
209
|
+
}
|
|
210
|
+
let types = buildTypes(scriptGlobal.af, hasCustomTypesFile, typeOverrides);
|
|
211
|
+
const headerComment = "// This file is automatically generated. Do not modify directly;\n" +
|
|
212
|
+
"// your changes will be overwritten. To customize the generated types,\n" +
|
|
213
|
+
"// edit `types.json` and `src/custom.d.ts`.\n\n";
|
|
214
|
+
types = headerComment + types;
|
|
215
|
+
// Append article feature flags
|
|
216
|
+
const dsFeatures = createArticlesFeaturesDataHandler();
|
|
217
|
+
const features = await wrapJsonError(`fetch article features for '${articleId}' on ${articleHost}`, () => withTimeout(dsFeatures.retrieve({
|
|
218
|
+
maxRecords: -1,
|
|
219
|
+
whereClause: `[HostName] = '${articleHost}' AND [ArticleId] = '${articleId}'`,
|
|
220
|
+
}), TIMEOUT_MS, "fetch article features"));
|
|
221
|
+
if (features.length) {
|
|
222
|
+
types += `\n\ndeclare module "@olenbetong/appframe-core" {\n\tinterface AfArticle {\n\t\tfeatures: {\n\t\t\t${features
|
|
223
|
+
.map((f) => `/**\n\t\t\t * ${f.Name}: ${f.Description}\n\t\t\t */\n\t\t\t${f.Key}: boolean;`)
|
|
224
|
+
.join("\n\t\t\t")}\n\t\t};\n\t}\n}`;
|
|
225
|
+
}
|
|
226
|
+
const outPath = resolve(process.cwd(), "./src/appframe.d.ts");
|
|
227
|
+
await fs.writeFile(outPath, new Uint8Array(Buffer.from(types)));
|
|
228
|
+
await formatFile("./src/appframe.d.ts");
|
|
229
|
+
}
|
|
230
|
+
export async function runGenerateTypes(hostname, username, password, appframe, logger) {
|
|
231
|
+
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
|
232
|
+
try {
|
|
233
|
+
await doGenerateTypes(hostname, username, password, appframe);
|
|
234
|
+
logger.info(createLogMessage("typescript types generated successfully", { source: hostname }));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (attempt < MAX_RETRIES) {
|
|
239
|
+
// Exponential backoff before retry
|
|
240
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_BASE_MS * attempt));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
logger.error(createLogMessage(`failed to generate types after ${MAX_RETRIES} attempt(s): ${error?.message ?? error}`, {
|
|
244
|
+
source: hostname,
|
|
245
|
+
type: "error",
|
|
246
|
+
}));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -2,9 +2,10 @@ import bodyParser from "body-parser";
|
|
|
2
2
|
import { watch } from "chokidar";
|
|
3
3
|
import { addAppframeBuildConfig } from "./build.js";
|
|
4
4
|
import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
|
|
5
|
-
import {
|
|
5
|
+
import { runGenerateTypes } from "./generateTypes.js";
|
|
6
6
|
import { localizeMiddleware } from "./localization.js";
|
|
7
|
-
import { getLastSession, login } from "./proxy.js";
|
|
7
|
+
import { checkSession, getLastSession, login } from "./proxy.js";
|
|
8
|
+
import { createLogMessage, getServerName } from "./utils.js";
|
|
8
9
|
let command = "build";
|
|
9
10
|
let interval;
|
|
10
11
|
let server;
|
|
@@ -22,7 +23,7 @@ try {
|
|
|
22
23
|
});
|
|
23
24
|
}
|
|
24
25
|
catch (error) {
|
|
25
|
-
console.log(`
|
|
26
|
+
console.log(createLogMessage(`failed to watch package.json: ${error.message}`, { type: "warn" }));
|
|
26
27
|
}
|
|
27
28
|
const jsonParser = bodyParser.json();
|
|
28
29
|
export default function appframe() {
|
|
@@ -86,14 +87,25 @@ export default function appframe() {
|
|
|
86
87
|
let { hostname, username, password } = await getLoginInfo();
|
|
87
88
|
lastHostname = hostname;
|
|
88
89
|
await login(hostname, username, password);
|
|
90
|
+
console.log(createLogMessage(`connected to '${getServerName(hostname)}'`, { source: hostname }));
|
|
89
91
|
if (interval)
|
|
90
92
|
clearInterval(interval);
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
// sessions, we run the login procedure every 5 minutes to renew the session.
|
|
93
|
+
// Every 5 minutes, probe /api/user/usersession to check whether the session
|
|
94
|
+
// is still alive. Only re-authenticates if the server reports it has expired.
|
|
95
|
+
// Runs silently — only logs if renewal fails.
|
|
95
96
|
interval = setInterval(async () => {
|
|
96
|
-
|
|
97
|
+
try {
|
|
98
|
+
const alive = await checkSession(hostname);
|
|
99
|
+
if (!alive) {
|
|
100
|
+
await login(hostname, username, password, { silent: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
server?.config.logger.error(createLogMessage(`session renewal failed: ${error?.message ?? error}`, {
|
|
105
|
+
source: hostname,
|
|
106
|
+
type: "error",
|
|
107
|
+
}));
|
|
108
|
+
}
|
|
97
109
|
}, 1000 * 60 * 5);
|
|
98
110
|
for (let route of routes) {
|
|
99
111
|
proxy[route] = {
|
|
@@ -123,16 +135,18 @@ export default function appframe() {
|
|
|
123
135
|
return config;
|
|
124
136
|
},
|
|
125
137
|
async configureServer(_server) {
|
|
126
|
-
let { appframe } = await
|
|
138
|
+
let { appframe, hostname, username, password } = await getLoginInfo();
|
|
127
139
|
server = _server;
|
|
140
|
+
// Run type generation in the background — doesn't block the dev server from starting.
|
|
141
|
+
runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
|
|
128
142
|
_server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, jsonParser);
|
|
129
143
|
_server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, localizeMiddleware);
|
|
130
144
|
_server.middlewares.use("/data/Logger/LogError", jsonParser);
|
|
131
145
|
_server.middlewares.use("/data/Logger/LogError", (req, res) => {
|
|
132
146
|
// @ts-expect-error
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
console.log("
|
|
147
|
+
let error = req.body?.error;
|
|
148
|
+
if (error) {
|
|
149
|
+
console.log(createLogMessage(error, { source: "client", type: "error" }));
|
|
136
150
|
}
|
|
137
151
|
res.statusCode = 200;
|
|
138
152
|
res.end("");
|
package/lib/localization.js
CHANGED
|
@@ -67,6 +67,13 @@ export const localizeMiddleware = async (req, res, _next) => {
|
|
|
67
67
|
"X-Requested-With": "XMLHttpRequest",
|
|
68
68
|
},
|
|
69
69
|
});
|
|
70
|
+
const contentType = result.headers.get("content-type") ?? "";
|
|
71
|
+
if (!result.ok || !contentType.includes("application/json")) {
|
|
72
|
+
let body = await result.text();
|
|
73
|
+
throw new Error(`POST ${uri} returned ${result.status} ${result.statusText} ` +
|
|
74
|
+
`(Content-Type: ${contentType || "none"}). ` +
|
|
75
|
+
`Response snippet: ${body.slice(0, 300)}`);
|
|
76
|
+
}
|
|
70
77
|
let data = await result.json();
|
|
71
78
|
addStringToCache(text, data);
|
|
72
79
|
res.statusCode = 200;
|
package/lib/proxy.d.ts
CHANGED
|
@@ -4,5 +4,13 @@ type LoginCacheEntry = {
|
|
|
4
4
|
authCookies: CookieObject;
|
|
5
5
|
};
|
|
6
6
|
export declare function getLastSession(hostname: string): LoginCacheEntry | undefined;
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Checks whether the current cached session for the given hostname is still
|
|
9
|
+
* valid on the server by probing GET /api/user/usersession. Returns true if
|
|
10
|
+
* the session is alive (200 OK), false if it has expired (redirect/error).
|
|
11
|
+
*/
|
|
12
|
+
export declare function checkSession(hostname: string): Promise<boolean>;
|
|
13
|
+
export declare function login(hostname: string, username: string, password: string, loginOptions?: {
|
|
14
|
+
silent?: boolean;
|
|
15
|
+
}): Promise<CookieObject>;
|
|
8
16
|
export {};
|
package/lib/proxy.js
CHANGED
|
@@ -1,8 +1,55 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
3
|
import https from "node:https";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { createLogMessage } from "./utils.js";
|
|
3
6
|
const lastLogin = new Map();
|
|
4
7
|
let currentLogin = null;
|
|
5
8
|
const isInteractive = process.stdout.isTTY;
|
|
9
|
+
// Re-auth only when the cached session is older than this. The keepalive
|
|
10
|
+
// checkSession() probe handles actual liveness, so this just limits how
|
|
11
|
+
// often we do a full HTTP login when restarting dev servers.
|
|
12
|
+
const SESSION_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
13
|
+
// ─── Shared session store ─────────────────────────────────────────────────────
|
|
14
|
+
function findWorkspaceRoot(startDir) {
|
|
15
|
+
let dir = startDir;
|
|
16
|
+
while (true) {
|
|
17
|
+
if (existsSync(resolve(dir, "pnpm-workspace.yaml")))
|
|
18
|
+
return dir;
|
|
19
|
+
const parent = dirname(dir);
|
|
20
|
+
if (parent === dir)
|
|
21
|
+
return startDir; // reached fs root, fall back
|
|
22
|
+
dir = parent;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const sharedSessionDir = resolve(findWorkspaceRoot(process.cwd()), "node_modules/.appframe");
|
|
26
|
+
const sharedSessionFile = resolve(sharedSessionDir, "sessions.json");
|
|
27
|
+
async function readSessionStore() {
|
|
28
|
+
try {
|
|
29
|
+
const raw = await readFile(sharedSessionFile, "utf-8");
|
|
30
|
+
return JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function writeSessionStore(hostname, entry) {
|
|
37
|
+
// Fire-and-forget: best-effort, errors are silently ignored.
|
|
38
|
+
(async () => {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(sharedSessionDir)) {
|
|
41
|
+
mkdirSync(sharedSessionDir, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const store = await readSessionStore();
|
|
44
|
+
store[hostname] = entry;
|
|
45
|
+
await writeFile(sharedSessionFile, JSON.stringify(store, null, "\t"));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Ignore write errors
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
}
|
|
52
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
6
53
|
function write(text) {
|
|
7
54
|
if (isInteractive) {
|
|
8
55
|
process.stdout.write?.(text);
|
|
@@ -15,20 +62,64 @@ export function getLastSession(hostname) {
|
|
|
15
62
|
return lastLogin.get(hostname);
|
|
16
63
|
}
|
|
17
64
|
const agent = new https.Agent({ keepAlive: false });
|
|
18
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Checks whether the current cached session for the given hostname is still
|
|
67
|
+
* valid on the server by probing GET /api/user/usersession. Returns true if
|
|
68
|
+
* the session is alive (200 OK), false if it has expired (redirect/error).
|
|
69
|
+
*/
|
|
70
|
+
export async function checkSession(hostname) {
|
|
71
|
+
const session = lastLogin.get(hostname);
|
|
72
|
+
if (!session)
|
|
73
|
+
return false;
|
|
74
|
+
const { authCookies } = session;
|
|
75
|
+
const cookieParts = [];
|
|
76
|
+
if (authCookies["AppframeWebAuth"])
|
|
77
|
+
cookieParts.push(`AppframeWebAuth=${authCookies["AppframeWebAuth"]}`);
|
|
78
|
+
if (authCookies["AppframeWebSession"])
|
|
79
|
+
cookieParts.push(`AppframeWebSession=${authCookies["AppframeWebSession"]}`);
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
const options = {
|
|
82
|
+
agent,
|
|
83
|
+
hostname,
|
|
84
|
+
port: 443,
|
|
85
|
+
path: "/api/user/usersession",
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: {
|
|
88
|
+
Cookie: cookieParts.join(";"),
|
|
89
|
+
Accept: "application/json",
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const req = https.request(options, (response) => {
|
|
93
|
+
response.resume(); // drain body to free the connection
|
|
94
|
+
resolve(response.statusCode === 200);
|
|
95
|
+
});
|
|
96
|
+
req.on("error", () => resolve(false));
|
|
97
|
+
req.end();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
export async function login(hostname, username, password, loginOptions) {
|
|
101
|
+
const silent = loginOptions?.silent ?? false;
|
|
102
|
+
// Hydrate in-memory cache from the shared disk store if this is the first
|
|
103
|
+
// call for this hostname (covers restarts and cross-app session sharing).
|
|
104
|
+
if (!lastLogin.has(hostname)) {
|
|
105
|
+
const store = await readSessionStore();
|
|
106
|
+
if (store[hostname]) {
|
|
107
|
+
lastLogin.set(hostname, store[hostname]);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
19
110
|
if (currentLogin) {
|
|
20
111
|
await currentLogin;
|
|
21
112
|
}
|
|
22
113
|
let loginPromise = new Promise((resolve, reject) => {
|
|
23
|
-
let hostPrefix = `[${hostname.split(".")[0]}]`;
|
|
24
114
|
if (lastLogin.has(hostname)) {
|
|
25
|
-
let { timestamp, authCookies } = lastLogin.get(hostname);
|
|
26
|
-
if (Date.now() -
|
|
115
|
+
let { timestamp: cachedAt, authCookies } = lastLogin.get(hostname);
|
|
116
|
+
if (Date.now() - cachedAt < SESSION_MAX_AGE_MS) {
|
|
27
117
|
resolve(authCookies);
|
|
28
118
|
return;
|
|
29
119
|
}
|
|
30
120
|
else {
|
|
31
|
-
|
|
121
|
+
if (!silent)
|
|
122
|
+
write(createLogMessage("renewing authentication cookies...", { source: hostname }));
|
|
32
123
|
lastLogin.delete(hostname);
|
|
33
124
|
}
|
|
34
125
|
}
|
|
@@ -45,14 +136,21 @@ export async function login(hostname, username, password) {
|
|
|
45
136
|
"Content-Length": data.length,
|
|
46
137
|
},
|
|
47
138
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
139
|
+
if (!silent) {
|
|
140
|
+
process.stdout.clearLine?.(0);
|
|
141
|
+
process.stdout.cursorTo?.(0);
|
|
142
|
+
}
|
|
143
|
+
let initialLogMessage = createLogMessage("authenticating.", { source: hostname });
|
|
144
|
+
if (!silent) {
|
|
145
|
+
write(`\r${initialLogMessage}`);
|
|
146
|
+
}
|
|
147
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: is there another way?
|
|
148
|
+
let strippedInitialLogMessage = initialLogMessage.replace(/\u001b\[[0-9]+m/g, ""); // remove ANSI color codes for accurate cursor positioning
|
|
149
|
+
let initialCursorPos = strippedInitialLogMessage.length;
|
|
52
150
|
let start = performance.now();
|
|
53
151
|
let counter = 0;
|
|
54
152
|
let interval = setInterval(() => {
|
|
55
|
-
if (isInteractive) {
|
|
153
|
+
if (!silent && isInteractive) {
|
|
56
154
|
if (counter >= 2) {
|
|
57
155
|
process.stdout.cursorTo?.(initialCursorPos);
|
|
58
156
|
write(" ");
|
|
@@ -78,21 +176,27 @@ export async function login(hostname, username, password) {
|
|
|
78
176
|
cookieObj[key] = value;
|
|
79
177
|
}
|
|
80
178
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
179
|
+
if (!silent) {
|
|
180
|
+
process.stdout.clearLine?.(0);
|
|
181
|
+
process.stdout.cursorTo?.(0);
|
|
182
|
+
write(createLogMessage(`authenticated (${Math.floor(performance.now() - start)}ms)\n`, {
|
|
183
|
+
source: hostname,
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
const entry = { timestamp: Date.now(), authCookies: cookieObj };
|
|
187
|
+
lastLogin.set(hostname, entry);
|
|
188
|
+
writeSessionStore(hostname, entry);
|
|
88
189
|
resolve(cookieObj);
|
|
89
190
|
}
|
|
90
191
|
else {
|
|
91
|
-
reject(Error(
|
|
192
|
+
reject(Error(createLogMessage(`authentication failed: ${response.statusCode} ${response.statusMessage}`, {
|
|
193
|
+
source: hostname,
|
|
194
|
+
type: "error",
|
|
195
|
+
})));
|
|
92
196
|
}
|
|
93
197
|
});
|
|
94
198
|
request.on("error", (error) => {
|
|
95
|
-
console.error(error.message);
|
|
199
|
+
console.error(createLogMessage(error.message, { source: hostname, type: "error" }));
|
|
96
200
|
});
|
|
97
201
|
request.write(data);
|
|
98
202
|
request.end();
|
package/lib/utils.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Returns the current time as `HH:mm:ss` in gray, matching Vite's log format. */
|
|
2
|
+
export declare function timestamp(): string;
|
|
3
|
+
/** Standard `[appframe-vite]` tag for plugin-level log messages. */
|
|
4
|
+
export declare const PLUGIN_TAG: string;
|
|
5
|
+
/** Returns a colored environment tag (`[dev]` / `[stage]` / `[prod]`) based on hostname. */
|
|
6
|
+
export declare function getServerPrefix(hostname: string): string;
|
|
7
|
+
export declare function getServerName(hostname: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Wraps an async operation. If the caught error is a JSON parse error
|
|
10
|
+
* (SyntaxError from undici/fetch `.json()` on an HTML response), re-throws
|
|
11
|
+
* with `context` prepended so it's obvious which network call failed.
|
|
12
|
+
*/
|
|
13
|
+
export declare function wrapJsonError<T>(context: string, fn: () => Promise<T>): Promise<T>;
|
|
14
|
+
/**
|
|
15
|
+
* Makes a diagnostic HTTP request and returns the full response body + metadata
|
|
16
|
+
* as a human-readable string. Used to show what the server is actually returning
|
|
17
|
+
* when a JSON-parse error occurs inside a library that swallows the response body.
|
|
18
|
+
*
|
|
19
|
+
* Pass followRedirects: true to follow the redirect chain (useful when the library
|
|
20
|
+
* auto-follows redirects and fails on the final response).
|
|
21
|
+
*/
|
|
22
|
+
export declare function diagnoseServerResponse(url: string, options?: {
|
|
23
|
+
method?: string;
|
|
24
|
+
body?: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
followRedirects?: boolean;
|
|
27
|
+
}): Promise<string>;
|
|
28
|
+
export interface LogMessageOptions {
|
|
29
|
+
/** Pre-formatted chalk tag, e.g. from `getServerPrefix()` or `PLUGIN_TAG`. */
|
|
30
|
+
tag?: string;
|
|
31
|
+
/** Parenthetical source label, e.g. `"client"` renders as `(client)`. */
|
|
32
|
+
source?: string;
|
|
33
|
+
/** Controls message text color. Defaults to uncolored. */
|
|
34
|
+
type?: "info" | "warn" | "error";
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Assembles a consistently-formatted log message:
|
|
38
|
+
* `HH:mm:ss [tag] (source) message`
|
|
39
|
+
*/
|
|
40
|
+
export declare function createLogMessage(message: string, options?: LogMessageOptions): string;
|
package/lib/utils.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
/** Returns the current time as `HH:mm:ss` in gray, matching Vite's log format. */
|
|
3
|
+
export function timestamp() {
|
|
4
|
+
return chalk.gray(new Date().toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit", second: "2-digit" }));
|
|
5
|
+
}
|
|
6
|
+
/** Standard `[appframe-vite]` tag for plugin-level log messages. */
|
|
7
|
+
export const PLUGIN_TAG = chalk.cyan("[appframe-vite]");
|
|
8
|
+
/** Returns a colored environment tag (`[dev]` / `[stage]` / `[prod]`) based on hostname. */
|
|
9
|
+
export function getServerPrefix(hostname) {
|
|
10
|
+
if (hostname.startsWith("dev"))
|
|
11
|
+
return chalk.bgGreen("[dev]");
|
|
12
|
+
if (hostname.startsWith("stage"))
|
|
13
|
+
return chalk.bgYellow("[stage]");
|
|
14
|
+
return chalk.bgRed("[prod]");
|
|
15
|
+
}
|
|
16
|
+
export function getServerName(hostname) {
|
|
17
|
+
const devPattern = /^(dev|dev-prod|dev\.synergi)\.obet\.no$/;
|
|
18
|
+
if (devPattern.test(hostname)) {
|
|
19
|
+
return "SynergiDev";
|
|
20
|
+
}
|
|
21
|
+
const partnerDevPattern = /^(dev|dev-prod)\.partner\.obet\.no$/;
|
|
22
|
+
if (partnerDevPattern.test(hostname)) {
|
|
23
|
+
return "PartnerDev";
|
|
24
|
+
}
|
|
25
|
+
const stagePattern = /^(stage|stage-prod|stage\.synergi)\.obet\.no$/;
|
|
26
|
+
if (stagePattern.test(hostname)) {
|
|
27
|
+
return "SynergiStage";
|
|
28
|
+
}
|
|
29
|
+
const partnerStagePattern = /^(stage|stage-prod)\.partner\.obet\.no$/;
|
|
30
|
+
if (partnerStagePattern.test(hostname)) {
|
|
31
|
+
return "PartnerStage";
|
|
32
|
+
}
|
|
33
|
+
const webPattern = /^(test|test\.synergi|synergi)\.(olenbetong|obet)\.no$/;
|
|
34
|
+
if (webPattern.test(hostname)) {
|
|
35
|
+
return "SynergiWeb";
|
|
36
|
+
}
|
|
37
|
+
const partnerWebPattern = /^(test|test\.partner|partner)\.(olenbetong|obet)\.no$/;
|
|
38
|
+
if (partnerWebPattern.test(hostname)) {
|
|
39
|
+
return "Partner";
|
|
40
|
+
}
|
|
41
|
+
return hostname;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Wraps an async operation. If the caught error is a JSON parse error
|
|
45
|
+
* (SyntaxError from undici/fetch `.json()` on an HTML response), re-throws
|
|
46
|
+
* with `context` prepended so it's obvious which network call failed.
|
|
47
|
+
*/
|
|
48
|
+
export async function wrapJsonError(context, fn) {
|
|
49
|
+
try {
|
|
50
|
+
return await fn();
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
if (error instanceof SyntaxError &&
|
|
54
|
+
(error.message.includes("not valid JSON") || error.message.includes("Unexpected token"))) {
|
|
55
|
+
throw new Error(`${context}: server returned non-JSON — likely an HTML redirect or error page. ` +
|
|
56
|
+
`Original parse error: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Makes a diagnostic HTTP request and returns the full response body + metadata
|
|
63
|
+
* as a human-readable string. Used to show what the server is actually returning
|
|
64
|
+
* when a JSON-parse error occurs inside a library that swallows the response body.
|
|
65
|
+
*
|
|
66
|
+
* Pass followRedirects: true to follow the redirect chain (useful when the library
|
|
67
|
+
* auto-follows redirects and fails on the final response).
|
|
68
|
+
*/
|
|
69
|
+
export async function diagnoseServerResponse(url, options = {}) {
|
|
70
|
+
const method = options.method ?? "GET";
|
|
71
|
+
const redirect = options.followRedirects ? "follow" : "manual";
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(url, {
|
|
74
|
+
method,
|
|
75
|
+
headers: { Accept: "text/html,application/json,*/*", ...options.headers },
|
|
76
|
+
body: options.body,
|
|
77
|
+
redirect,
|
|
78
|
+
});
|
|
79
|
+
const contentType = res.headers.get("content-type") ?? "(none)";
|
|
80
|
+
const location = res.headers.get("location");
|
|
81
|
+
const body = await res.text();
|
|
82
|
+
let info = `${method} ${url} → ${res.status} ${res.statusText} | Content-Type: ${contentType}`;
|
|
83
|
+
if (location)
|
|
84
|
+
info += ` | Location: ${location}`;
|
|
85
|
+
if (res.redirected)
|
|
86
|
+
info += ` | Final URL: ${res.url}`;
|
|
87
|
+
info += `\n--- Response body ---\n${body}\n--- End of body ---`;
|
|
88
|
+
return info;
|
|
89
|
+
}
|
|
90
|
+
catch (diagError) {
|
|
91
|
+
return `${method} ${url} failed: ${diagError?.message ?? diagError}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Assembles a consistently-formatted log message:
|
|
96
|
+
* `HH:mm:ss [tag] (source) message`
|
|
97
|
+
*/
|
|
98
|
+
export function createLogMessage(message, options = {}) {
|
|
99
|
+
const { tag = PLUGIN_TAG, source, type } = options;
|
|
100
|
+
const coloredMessage = type === "error" ? chalk.red(message) : type === "warn" ? chalk.yellow(message) : message;
|
|
101
|
+
return [timestamp(), tag, source ? chalk.gray(`(${source})`) : undefined, coloredMessage].filter(Boolean).join(" ");
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-vite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.1.1",
|
|
4
4
|
"description": "Tools to use and deploy Vite applications to Appframe",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"types": "./lib/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"appframe-vite": "./lib/cli.js"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": "./lib/index.js",
|
|
10
13
|
"./proxy": {
|
|
@@ -28,12 +31,12 @@
|
|
|
28
31
|
"dotenv": "^17.2.3",
|
|
29
32
|
"jsdom": "29.0.2",
|
|
30
33
|
"rollup-plugin-visualizer": "^6.0.5",
|
|
31
|
-
"@olenbetong/appframe-data": "1.4.
|
|
34
|
+
"@olenbetong/appframe-data": "1.4.2"
|
|
32
35
|
},
|
|
33
36
|
"devDependencies": {
|
|
34
37
|
"@types/jsdom": "^27.0.0",
|
|
35
38
|
"@types/node": "25.6.0",
|
|
36
|
-
"typescript": "6.0.
|
|
39
|
+
"typescript": "6.0.3",
|
|
37
40
|
"vite": "8.0.8"
|
|
38
41
|
},
|
|
39
42
|
"peerDependencies": {
|