@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 ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
- await client.login(username, password);
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 { importJson } from "./importJson.js";
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(`Failed to watch package.json: ${error.message}`);
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
- // After changing the plugin to use the Vite proxy instead of a custom
92
- // express server with node-http-proxy, we can no longer ensure the requests
93
- // have a valid login. To reduce the amount of problems we get with expired
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
- await login(hostname, username, password);
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 importJson("./package.json", true);
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
- if (req.body?.error) {
134
- // @ts-expect-error
135
- console.log("[Client error]", req.body?.error);
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("");
@@ -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
- export declare function login(hostname: string, username: string, password: string): Promise<CookieObject>;
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 chalk from "chalk";
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
- export async function login(hostname, username, password) {
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() - timestamp < 1000 * 60 * 15) {
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
- write(`${chalk.bgBlueBright(hostPrefix)} Renewing authentication cookies...`);
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
- process.stdout.clearLine?.(0);
49
- process.stdout.cursorTo?.(0);
50
- write(`\r${chalk.bgBlueBright(hostPrefix)} Authenticating.`);
51
- let initialCursorPos = `${hostPrefix} Authenticating.`.length;
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
- process.stdout.clearLine?.(0);
82
- process.stdout.cursorTo?.(0);
83
- write(`${chalk.bgBlueBright(hostPrefix)} Authenticated (${Math.floor(performance.now() - start)}ms)\n`);
84
- lastLogin.set(hostname, {
85
- timestamp: Date.now(),
86
- authCookies: cookieObj,
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(`${chalk.bgBlueBright(hostname)}: Authentication failed: ${response.statusCode} ${response.statusMessage}`));
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.0.4",
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.1"
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.2",
39
+ "typescript": "6.0.3",
37
40
  "vite": "8.0.8"
38
41
  },
39
42
  "peerDependencies": {