@olenbetong/appframe-vite 6.0.4 → 6.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,9 +1,9 @@
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
9
  dotenv.config({ quiet: true });
@@ -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,230 @@
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 } 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
+ await withTimeout(client.login(username, password), TIMEOUT_MS, "login");
140
+ const articleId = appframe.article?.id ?? appframe.article;
141
+ const articleHost = appframe.article?.hostname ?? hostname;
142
+ const scriptGlobal = {
143
+ __VERSION__: "0.1.0",
144
+ af: {
145
+ controls: new Proxy({}, { get: () => class {
146
+ } }),
147
+ common: {
148
+ expose(path, value) {
149
+ let properties = path.split(".");
150
+ let final = properties.pop();
151
+ let current = scriptGlobal;
152
+ for (let prop of properties) {
153
+ if (!current[prop])
154
+ current[prop] = {};
155
+ current = current[prop];
156
+ }
157
+ current[final] = value;
158
+ },
159
+ localStorage: {
160
+ get(_, fallback) {
161
+ return fallback;
162
+ },
163
+ },
164
+ },
165
+ DataObject,
166
+ Procedure,
167
+ },
168
+ };
169
+ const response = await withTimeout(client.fetch(`/file/article/static-script/${articleId}.js`, { timeout: TIMEOUT_MS }), TIMEOUT_MS, "fetch article script");
170
+ if (!response.ok) {
171
+ throw new Error(`Failed to fetch article script: ${response.status} ${response.statusText}`);
172
+ }
173
+ const scriptText = await response.text();
174
+ const context = vm.createContext(scriptGlobal);
175
+ new vm.Script(scriptText).runInContext(context);
176
+ // Resolve custom types file
177
+ let customTypesPath = "./src/custom.d.ts";
178
+ let hasCustomTypesFile = existsSync(resolve(process.cwd(), customTypesPath));
179
+ if (!hasCustomTypesFile) {
180
+ customTypesPath = "./src/custom.ts";
181
+ hasCustomTypesFile = existsSync(resolve(process.cwd(), customTypesPath));
182
+ }
183
+ let typeOverrides = {};
184
+ try {
185
+ typeOverrides = await importJson("./types.json", true);
186
+ }
187
+ catch {
188
+ // No overrides file — that's fine
189
+ }
190
+ let types = buildTypes(scriptGlobal.af, hasCustomTypesFile, typeOverrides);
191
+ const headerComment = "// This file is automatically generated. Do not modify directly;\n" +
192
+ "// your changes will be overwritten. To customize the generated types,\n" +
193
+ "// edit `types.json` and `src/custom.d.ts`.\n\n";
194
+ types = headerComment + types;
195
+ // Append article feature flags
196
+ const dsFeatures = createArticlesFeaturesDataHandler();
197
+ const features = await withTimeout(dsFeatures.retrieve({
198
+ maxRecords: -1,
199
+ whereClause: `[HostName] = '${articleHost}' AND [ArticleId] = '${articleId}'`,
200
+ }), TIMEOUT_MS, "fetch article features");
201
+ if (features.length) {
202
+ types += `\n\ndeclare module "@olenbetong/appframe-core" {\n\tinterface AfArticle {\n\t\tfeatures: {\n\t\t\t${features
203
+ .map((f) => `/**\n\t\t\t * ${f.Name}: ${f.Description}\n\t\t\t */\n\t\t\t${f.Key}: boolean;`)
204
+ .join("\n\t\t\t")}\n\t\t};\n\t}\n}`;
205
+ }
206
+ const outPath = resolve(process.cwd(), "./src/appframe.d.ts");
207
+ await fs.writeFile(outPath, new Uint8Array(Buffer.from(types)));
208
+ await formatFile("./src/appframe.d.ts");
209
+ }
210
+ export async function runGenerateTypes(hostname, username, password, appframe, logger) {
211
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
212
+ try {
213
+ await doGenerateTypes(hostname, username, password, appframe);
214
+ logger.info(createLogMessage("typescript types generated successfully", { source: hostname }));
215
+ return;
216
+ }
217
+ catch (error) {
218
+ if (attempt < MAX_RETRIES) {
219
+ // Exponential backoff before retry
220
+ await new Promise((r) => setTimeout(r, RETRY_DELAY_BASE_MS * attempt));
221
+ }
222
+ else {
223
+ logger.error(createLogMessage(`failed to generate types after ${MAX_RETRIES} attempt(s): ${error?.message ?? error}`, {
224
+ source: hostname,
225
+ type: "error",
226
+ }));
227
+ }
228
+ }
229
+ }
230
+ }
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("");
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,20 @@
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
+ export interface LogMessageOptions {
9
+ /** Pre-formatted chalk tag, e.g. from `getServerPrefix()` or `PLUGIN_TAG`. */
10
+ tag?: string;
11
+ /** Parenthetical source label, e.g. `"client"` renders as `(client)`. */
12
+ source?: string;
13
+ /** Controls message text color. Defaults to uncolored. */
14
+ type?: "info" | "warn" | "error";
15
+ }
16
+ /**
17
+ * Assembles a consistently-formatted log message:
18
+ * `HH:mm:ss [tag] (source) message`
19
+ */
20
+ export declare function createLogMessage(message: string, options?: LogMessageOptions): string;
package/lib/utils.js ADDED
@@ -0,0 +1,51 @@
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
+ * Assembles a consistently-formatted log message:
45
+ * `HH:mm:ss [tag] (source) message`
46
+ */
47
+ export function createLogMessage(message, options = {}) {
48
+ const { tag = PLUGIN_TAG, source, type } = options;
49
+ const coloredMessage = type === "error" ? chalk.red(message) : type === "warn" ? chalk.yellow(message) : message;
50
+ return [timestamp(), tag, source ? chalk.gray(`(${source})`) : undefined, coloredMessage].filter(Boolean).join(" ");
51
+ }
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.0",
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": {
@@ -33,7 +36,7 @@
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": {