v1.2.4
This commit is contained in:
parent
63d51b8c6d
commit
a5dc3395e3
22
Caddyfile
22
Caddyfile
@ -11,6 +11,26 @@ https://presupuestos.uecko.com:13001 {
|
||||
|
||||
:443 {
|
||||
root * /srv
|
||||
|
||||
# Los bundles con hash son inmutables. Un bundle inexistente debe dar 404,
|
||||
# nunca el HTML de la SPA (que no es un módulo JavaScript).
|
||||
handle /assets/* {
|
||||
@existing file
|
||||
@missing not file
|
||||
header @existing Cache-Control "public, max-age=31536000, immutable"
|
||||
header @missing Cache-Control "no-cache"
|
||||
file_server
|
||||
try_files {path} /index.html # Esto asegura que las rutas en React funcionen correctamente
|
||||
}
|
||||
|
||||
handle /version.json {
|
||||
header Cache-Control "no-store"
|
||||
file_server
|
||||
}
|
||||
|
||||
handle {
|
||||
# También se aplica al HTML servido para /quotes, /login, etc.
|
||||
header Cache-Control "no-cache"
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
}
|
||||
}
|
||||
1
client/env.d.ts
vendored
1
client/env.d.ts
vendored
@ -2,3 +2,4 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare const __APP_VERSION__: string;
|
||||
declare const __APP_BUILD_ID__: string;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@uecko-presupuestador/client",
|
||||
"private": true,
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"author": "Rodax Software <dev@rodax-software.com>",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@ -9,6 +9,7 @@ import { LoadingOverlay, TailwindIndicator } from "./components";
|
||||
import { createAxiosAuthActions, createAxiosDataProvider } from "./lib/axios";
|
||||
import { DataSourceProvider } from "./lib/hooks";
|
||||
import i18n from "./locales/i18n.ts";
|
||||
import { AppUpdateNotice } from "./components/AppUpdateNotice";
|
||||
|
||||
function App() {
|
||||
const queryClient = new QueryClient({
|
||||
@ -22,6 +23,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<AppUpdateNotice />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<DataSourceProvider dataSource={createAxiosDataProvider(import.meta.env.VITE_API_URL)}>
|
||||
<AuthProvider authActions={createAxiosAuthActions(import.meta.env.VITE_API_URL)}>
|
||||
|
||||
28
client/src/components/AppUpdateNotice.tsx
Normal file
28
client/src/components/AppUpdateNotice.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { appUpdateMonitor } from "@/lib/appUpdates/appUpdateMonitor";
|
||||
import { Button } from "@/ui/button";
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function AppUpdateNotice() {
|
||||
const status = useSyncExternalStore(appUpdateMonitor.subscribe, appUpdateMonitor.getSnapshot);
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (status === "current") return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role='alert'
|
||||
className='fixed bottom-4 left-4 right-4 z-[100] flex flex-wrap items-center justify-between gap-3 rounded-md border bg-background p-4 shadow-lg'
|
||||
>
|
||||
<div>
|
||||
<p className='font-semibold'>
|
||||
{t(`app_update.${status === "load-error" ? "load_error" : "available"}`)}
|
||||
</p>
|
||||
<p className='text-sm text-muted-foreground'>{t("app_update.save_first")}</p>
|
||||
</div>
|
||||
<Button type='button' onClick={() => window.location.reload()}>
|
||||
{t("app_update.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
client/src/lib/appUpdates/appUpdateMonitor.ts
Normal file
5
client/src/lib/appUpdates/appUpdateMonitor.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createAppUpdateMonitor } from "./createAppUpdateMonitor";
|
||||
|
||||
export const appUpdateMonitor = createAppUpdateMonitor(
|
||||
__APP_BUILD_ID__, `${import.meta.env.BASE_URL}version.json`
|
||||
);
|
||||
79
client/src/lib/appUpdates/createAppUpdateMonitor.test.ts
Normal file
79
client/src/lib/appUpdates/createAppUpdateMonitor.test.ts
Normal file
@ -0,0 +1,79 @@
|
||||
import { createAppUpdateMonitor } from "./createAppUpdateMonitor";
|
||||
|
||||
describe("app update monitor", () => {
|
||||
const fetchMock = jest.fn();
|
||||
let stop: (() => void) | undefined;
|
||||
const flush = async () => { await Promise.resolve(); await Promise.resolve(); };
|
||||
const response = (buildId: string) => ({ ok: true, json: async () => ({ buildId }) });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
fetchMock.mockReset();
|
||||
global.fetch = fetchMock;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stop?.();
|
||||
stop = undefined;
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("detects a deployment in an open tab without reloading", async () => {
|
||||
fetchMock.mockResolvedValueOnce(response("old")).mockResolvedValueOnce(response("new"));
|
||||
const monitor = createAppUpdateMonitor("old", "/version.json");
|
||||
const listener = jest.fn();
|
||||
monitor.subscribe(listener);
|
||||
stop = monitor.start();
|
||||
await flush();
|
||||
expect(monitor.getSnapshot()).toBe("current");
|
||||
jest.advanceTimersByTime(60000);
|
||||
await flush();
|
||||
expect(monitor.getSnapshot()).toBe("available");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith("/version.json", expect.objectContaining({ cache: "no-store" }));
|
||||
});
|
||||
|
||||
it("retries after network failures and ignores invalid version responses", async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error("offline"))
|
||||
.mockResolvedValueOnce({ ok: false })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ version: "new" }) })
|
||||
.mockResolvedValueOnce(response("new"));
|
||||
const monitor = createAppUpdateMonitor("old", "/version.json");
|
||||
stop = monitor.start();
|
||||
await flush();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
await flush();
|
||||
expect(monitor.getSnapshot()).toBe("current");
|
||||
}
|
||||
window.dispatchEvent(new Event("online"));
|
||||
await flush();
|
||||
expect(monitor.getSnapshot()).toBe("available");
|
||||
});
|
||||
|
||||
it("reports failed dynamic imports without swallowing the original error", async () => {
|
||||
fetchMock.mockResolvedValue(response("old"));
|
||||
const monitor = createAppUpdateMonitor("old", "/version.json");
|
||||
stop = monitor.start();
|
||||
await flush();
|
||||
const event = new Event("vite:preloadError", { cancelable: true });
|
||||
window.dispatchEvent(event);
|
||||
expect(monitor.getSnapshot()).toBe("load-error");
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("stops polling and ignores an in-flight response after cleanup", async () => {
|
||||
let resolve: (value: ReturnType<typeof response>) => void = () => {};
|
||||
fetchMock.mockReturnValue(new Promise((done) => { resolve = done; }));
|
||||
const monitor = createAppUpdateMonitor("old", "/version.json");
|
||||
stop = monitor.start();
|
||||
stop();
|
||||
resolve(response("new"));
|
||||
await flush();
|
||||
window.dispatchEvent(new Event("vite:preloadError"));
|
||||
window.dispatchEvent(new Event("focus"));
|
||||
jest.advanceTimersByTime(120000);
|
||||
expect(monitor.getSnapshot()).toBe("current");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
73
client/src/lib/appUpdates/createAppUpdateMonitor.ts
Normal file
73
client/src/lib/appUpdates/createAppUpdateMonitor.ts
Normal file
@ -0,0 +1,73 @@
|
||||
export type AppUpdateStatus = "current" | "available" | "load-error";
|
||||
|
||||
export function createAppUpdateMonitor(buildId: string, versionUrl: string) {
|
||||
let status: AppUpdateStatus = "current";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const update = (next: AppUpdateStatus) => {
|
||||
if (status === next) return;
|
||||
status = next;
|
||||
listeners.forEach((listener) => listener());
|
||||
};
|
||||
|
||||
return {
|
||||
getSnapshot: () => status,
|
||||
subscribe: (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
},
|
||||
start: () => {
|
||||
let checking = false;
|
||||
const controller = new AbortController();
|
||||
const check = async () => {
|
||||
if (checking || document.visibilityState === "hidden" || status !== "current") return;
|
||||
checking = true;
|
||||
const request = new AbortController();
|
||||
const timeout = window.setTimeout(() => request.abort(), 10000);
|
||||
const abort = () => request.abort();
|
||||
controller.signal.addEventListener("abort", abort);
|
||||
try {
|
||||
const response = await fetch(versionUrl, { cache: "no-store", signal: request.signal });
|
||||
if (!response.ok) return;
|
||||
const version: unknown = await response.json();
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
typeof version === "object" &&
|
||||
version !== null &&
|
||||
"buildId" in version &&
|
||||
typeof version.buildId === "string" &&
|
||||
version.buildId.length > 0 &&
|
||||
version.buildId !== buildId &&
|
||||
status === "current"
|
||||
) {
|
||||
update("available");
|
||||
}
|
||||
} catch {
|
||||
// Sin conexión o despliegue en curso: se vuelve a comprobar más tarde.
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
controller.signal.removeEventListener("abort", abort);
|
||||
checking = false;
|
||||
}
|
||||
};
|
||||
const onLoadError = () => update("load-error");
|
||||
window.addEventListener("vite:preloadError", onLoadError);
|
||||
window.addEventListener("focus", check);
|
||||
window.addEventListener("online", check);
|
||||
document.addEventListener("visibilitychange", check);
|
||||
const interval = window.setInterval(check, 60000);
|
||||
void check();
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener("vite:preloadError", onLoadError);
|
||||
window.removeEventListener("focus", check);
|
||||
window.removeEventListener("online", check);
|
||||
document.removeEventListener("visibilitychange", check);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,5 +1,11 @@
|
||||
{
|
||||
"translation": {
|
||||
"app_update": {
|
||||
"available": "Hi ha una nova versió disponible.",
|
||||
"load_error": "No s’ha pogut carregar una part de l’aplicació. Recarrega per tornar-ho a provar.",
|
||||
"save_first": "Desa els canvis pendents abans de recarregar.",
|
||||
"reload": "Recarregar"
|
||||
},
|
||||
"common": {
|
||||
"today": "Avui"
|
||||
},
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
{
|
||||
"translation": {
|
||||
"app_update": {
|
||||
"available": "A new version is available.",
|
||||
"load_error": "Part of the application could not be loaded. Reload to try again.",
|
||||
"save_first": "Save any pending changes before reloading.",
|
||||
"reload": "Reload"
|
||||
},
|
||||
"common": {
|
||||
"required": "required",
|
||||
"cancel": "Cancel",
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
{
|
||||
"translation": {
|
||||
"app_update": {
|
||||
"available": "Hay una nueva versión disponible.",
|
||||
"load_error": "No se ha podido cargar una parte de la aplicación. Recarga para volver a intentarlo.",
|
||||
"save_first": "Guarda los cambios pendientes antes de recargar.",
|
||||
"reload": "Recargar"
|
||||
},
|
||||
"common": {
|
||||
"required": "obligatorio",
|
||||
"cancel": "Cancelar",
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
{
|
||||
"translation": {
|
||||
"app_update": {
|
||||
"available": "Une nouvelle version est disponible.",
|
||||
"load_error": "Une partie de l’application n’a pas pu être chargée. Rechargez pour réessayer.",
|
||||
"save_first": "Enregistrez les modifications en attente avant de recharger.",
|
||||
"reload": "Recharger"
|
||||
},
|
||||
"common": {
|
||||
"required": "obligatoire",
|
||||
"cancel": "Annuler",
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { appUpdateMonitor } from "./lib/appUpdates/appUpdateMonitor";
|
||||
import "./global.css";
|
||||
|
||||
if (import.meta.env.PROD) appUpdateMonitor.start();
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("uecko")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { readFileSync } from "fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite";
|
||||
import { robots } from "vite-plugin-robots";
|
||||
@ -8,6 +9,7 @@ import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||
const require = createRequire(import.meta.url);
|
||||
const buildId = randomUUID();
|
||||
|
||||
const pdfjsDistPath = path.dirname(require.resolve("pdfjs-dist/package.json"));
|
||||
const cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps"));
|
||||
@ -18,12 +20,24 @@ const standardFontsDir = normalizePath(
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
__APP_BUILD_ID__: JSON.stringify(buildId),
|
||||
},
|
||||
build: {
|
||||
outDir: "../dist/client",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "app-build-version",
|
||||
apply: "build",
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "version.json",
|
||||
source: JSON.stringify({ buildId }),
|
||||
});
|
||||
},
|
||||
},
|
||||
react(),
|
||||
robots({}),
|
||||
|
||||
|
||||
@ -0,0 +1,73 @@
|
||||
// vite.config.ts
|
||||
import react from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/@vitejs/plugin-react/dist/index.mjs";
|
||||
import { readFileSync } from "fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/vite/dist/node/index.js";
|
||||
import { robots } from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/vite-plugin-robots/dist/index.mjs";
|
||||
import { viteStaticCopy } from "file:///home/rodax/Documentos/uecko-presupuestos/client/node_modules/vite-plugin-static-copy/dist/index.js";
|
||||
var __vite_injected_original_dirname = "/home/rodax/Documentos/uecko-presupuestos/client";
|
||||
var __vite_injected_original_import_meta_url = "file:///home/rodax/Documentos/uecko-presupuestos/client/vite.config.ts";
|
||||
var pkg = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||
var require2 = createRequire(__vite_injected_original_import_meta_url);
|
||||
var buildId = randomUUID();
|
||||
var pdfjsDistPath = path.dirname(require2.resolve("pdfjs-dist/package.json"));
|
||||
var cMapsDir = normalizePath(path.join(pdfjsDistPath, "cmaps"));
|
||||
var standardFontsDir = normalizePath(
|
||||
path.join(path.dirname(require2.resolve("pdfjs-dist/package.json")), "standard_fonts")
|
||||
);
|
||||
var vite_config_default = defineConfig({
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
__APP_BUILD_ID__: JSON.stringify(buildId)
|
||||
},
|
||||
build: {
|
||||
outDir: "../dist/client",
|
||||
emptyOutDir: true
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
name: "app-build-version",
|
||||
apply: "build",
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: "version.json",
|
||||
source: JSON.stringify({ buildId })
|
||||
});
|
||||
}
|
||||
},
|
||||
react(),
|
||||
robots({}),
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: cMapsDir,
|
||||
dest: ""
|
||||
},
|
||||
{
|
||||
src: standardFontsDir,
|
||||
dest: ""
|
||||
}
|
||||
]
|
||||
})
|
||||
],
|
||||
css: { postcss: "./postcss.config.js" },
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: "@",
|
||||
replacement: path.resolve(__vite_injected_original_dirname, "./src")
|
||||
},
|
||||
{
|
||||
find: "@shared",
|
||||
replacement: path.resolve(__vite_injected_original_dirname, "../shared/lib/")
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
export {
|
||||
vite_config_default as default
|
||||
};
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvaG9tZS9yb2RheC9Eb2N1bWVudG9zL3VlY2tvLXByZXN1cHVlc3Rvcy9jbGllbnRcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfZmlsZW5hbWUgPSBcIi9ob21lL3JvZGF4L0RvY3VtZW50b3MvdWVja28tcHJlc3VwdWVzdG9zL2NsaWVudC92aXRlLmNvbmZpZy50c1wiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9pbXBvcnRfbWV0YV91cmwgPSBcImZpbGU6Ly8vaG9tZS9yb2RheC9Eb2N1bWVudG9zL3VlY2tvLXByZXN1cHVlc3Rvcy9jbGllbnQvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgcmVhY3QgZnJvbSBcIkB2aXRlanMvcGx1Z2luLXJlYWN0XCI7XG5pbXBvcnQgeyByZWFkRmlsZVN5bmMgfSBmcm9tIFwiZnNcIjtcbmltcG9ydCB7IGNyZWF0ZVJlcXVpcmUgfSBmcm9tIFwibm9kZTptb2R1bGVcIjtcbmltcG9ydCB7IHJhbmRvbVVVSUQgfSBmcm9tIFwibm9kZTpjcnlwdG9cIjtcbmltcG9ydCBwYXRoIGZyb20gXCJub2RlOnBhdGhcIjtcbmltcG9ydCB7IGRlZmluZUNvbmZpZywgbm9ybWFsaXplUGF0aCB9IGZyb20gXCJ2aXRlXCI7XG5pbXBvcnQgeyByb2JvdHMgfSBmcm9tIFwidml0ZS1wbHVnaW4tcm9ib3RzXCI7XG5pbXBvcnQgeyB2aXRlU3RhdGljQ29weSB9IGZyb20gXCJ2aXRlLXBsdWdpbi1zdGF0aWMtY29weVwiO1xuXG5jb25zdCBwa2cgPSBKU09OLnBhcnNlKHJlYWRGaWxlU3luYyhcIi4vcGFja2FnZS5qc29uXCIsIFwidXRmLThcIikpO1xuY29uc3QgcmVxdWlyZSA9IGNyZWF0ZVJlcXVpcmUoaW1wb3J0Lm1ldGEudXJsKTtcbmNvbnN0IGJ1aWxkSWQgPSByYW5kb21VVUlEKCk7XG5cbmNvbnN0IHBkZmpzRGlzdFBhdGggPSBwYXRoLmRpcm5hbWUocmVxdWlyZS5yZXNvbHZlKFwicGRmanMtZGlzdC9wYWNrYWdlLmpzb25cIikpO1xuY29uc3QgY01hcHNEaXIgPSBub3JtYWxpemVQYXRoKHBhdGguam9pbihwZGZqc0Rpc3RQYXRoLCBcImNtYXBzXCIpKTtcbmNvbnN0IHN0YW5kYXJkRm9udHNEaXIgPSBub3JtYWxpemVQYXRoKFxuICBwYXRoLmpvaW4ocGF0aC5kaXJuYW1lKHJlcXVpcmUucmVzb2x2ZShcInBkZmpzLWRpc3QvcGFja2FnZS5qc29uXCIpKSwgXCJzdGFuZGFyZF9mb250c1wiKVxuKTtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgZGVmaW5lOiB7XG4gICAgX19BUFBfVkVSU0lPTl9fOiBKU09OLnN0cmluZ2lmeShwa2cudmVyc2lvbiksXG4gICAgX19BUFBfQlVJTERfSURfXzogSlNPTi5zdHJpbmdpZnkoYnVpbGRJZCksXG4gIH0sXG4gIGJ1aWxkOiB7XG4gICAgb3V0RGlyOiBcIi4uL2Rpc3QvY2xpZW50XCIsXG4gICAgZW1wdHlPdXREaXI6IHRydWUsXG4gIH0sXG4gIHBsdWdpbnM6IFtcbiAgICB7XG4gICAgICBuYW1lOiBcImFwcC1idWlsZC12ZXJzaW9uXCIsXG4gICAgICBhcHBseTogXCJidWlsZFwiLFxuICAgICAgZ2VuZXJhdGVCdW5kbGUoKSB7XG4gICAgICAgIHRoaXMuZW1pdEZpbGUoe1xuICAgICAgICAgIHR5cGU6IFwiYXNzZXRcIixcbiAgICAgICAgICBmaWxlTmFtZTogXCJ2ZXJzaW9uLmpzb25cIixcbiAgICAgICAgICBzb3VyY2U6IEpTT04uc3RyaW5naWZ5KHsgYnVpbGRJZCB9KSxcbiAgICAgICAgfSk7XG4gICAgICB9LFxuICAgIH0sXG4gICAgcmVhY3QoKSxcbiAgICByb2JvdHMoe30pLFxuXG4gICAgdml0ZVN0YXRpY0NvcHkoe1xuICAgICAgdGFyZ2V0czogW1xuICAgICAgICB7XG4gICAgICAgICAgc3JjOiBjTWFwc0RpcixcbiAgICAgICAgICBkZXN0OiBcIlwiLFxuICAgICAgICB9LFxuICAgICAgICB7XG4gICAgICAgICAgc3JjOiBzdGFuZGFyZEZvbnRzRGlyLFxuICAgICAgICAgIGRlc3Q6IFwiXCIsXG4gICAgICAgIH0sXG4gICAgICBdLFxuICAgIH0pLFxuICBdLFxuXG4gIGNzczogeyBwb3N0Y3NzOiBcIi4vcG9zdGNzcy5jb25maWcuanNcIiB9LFxuICByZXNvbHZlOiB7XG4gICAgYWxpYXM6IFtcbiAgICAgIHtcbiAgICAgICAgZmluZDogXCJAXCIsXG4gICAgICAgIHJlcGxhY2VtZW50OiBwYXRoLnJlc29sdmUoX19kaXJuYW1lLCBcIi4vc3JjXCIpLFxuICAgICAgfSxcbiAgICAgIHtcbiAgICAgICAgZmluZDogXCJAc2hhcmVkXCIsXG4gICAgICAgIHJlcGxhY2VtZW50OiBwYXRoLnJlc29sdmUoX19kaXJuYW1lLCBcIi4uL3NoYXJlZC9saWIvXCIpLFxuICAgICAgfSxcbiAgICBdLFxuICB9LFxufSk7XG4iXSwKICAibWFwcGluZ3MiOiAiO0FBQWtVLE9BQU8sV0FBVztBQUNwVixTQUFTLG9CQUFvQjtBQUM3QixTQUFTLHFCQUFxQjtBQUM5QixTQUFTLGtCQUFrQjtBQUMzQixPQUFPLFVBQVU7QUFDakIsU0FBUyxjQUFjLHFCQUFxQjtBQUM1QyxTQUFTLGNBQWM7QUFDdkIsU0FBUyxzQkFBc0I7QUFQL0IsSUFBTSxtQ0FBbUM7QUFBK0osSUFBTSwyQ0FBMkM7QUFTelAsSUFBTSxNQUFNLEtBQUssTUFBTSxhQUFhLGtCQUFrQixPQUFPLENBQUM7QUFDOUQsSUFBTUEsV0FBVSxjQUFjLHdDQUFlO0FBQzdDLElBQU0sVUFBVSxXQUFXO0FBRTNCLElBQU0sZ0JBQWdCLEtBQUssUUFBUUEsU0FBUSxRQUFRLHlCQUF5QixDQUFDO0FBQzdFLElBQU0sV0FBVyxjQUFjLEtBQUssS0FBSyxlQUFlLE9BQU8sQ0FBQztBQUNoRSxJQUFNLG1CQUFtQjtBQUFBLEVBQ3ZCLEtBQUssS0FBSyxLQUFLLFFBQVFBLFNBQVEsUUFBUSx5QkFBeUIsQ0FBQyxHQUFHLGdCQUFnQjtBQUN0RjtBQUVBLElBQU8sc0JBQVEsYUFBYTtBQUFBLEVBQzFCLFFBQVE7QUFBQSxJQUNOLGlCQUFpQixLQUFLLFVBQVUsSUFBSSxPQUFPO0FBQUEsSUFDM0Msa0JBQWtCLEtBQUssVUFBVSxPQUFPO0FBQUEsRUFDMUM7QUFBQSxFQUNBLE9BQU87QUFBQSxJQUNMLFFBQVE7QUFBQSxJQUNSLGFBQWE7QUFBQSxFQUNmO0FBQUEsRUFDQSxTQUFTO0FBQUEsSUFDUDtBQUFBLE1BQ0UsTUFBTTtBQUFBLE1BQ04sT0FBTztBQUFBLE1BQ1AsaUJBQWlCO0FBQ2YsYUFBSyxTQUFTO0FBQUEsVUFDWixNQUFNO0FBQUEsVUFDTixVQUFVO0FBQUEsVUFDVixRQUFRLEtBQUssVUFBVSxFQUFFLFFBQVEsQ0FBQztBQUFBLFFBQ3BDLENBQUM7QUFBQSxNQUNIO0FBQUEsSUFDRjtBQUFBLElBQ0EsTUFBTTtBQUFBLElBQ04sT0FBTyxDQUFDLENBQUM7QUFBQSxJQUVULGVBQWU7QUFBQSxNQUNiLFNBQVM7QUFBQSxRQUNQO0FBQUEsVUFDRSxLQUFLO0FBQUEsVUFDTCxNQUFNO0FBQUEsUUFDUjtBQUFBLFFBQ0E7QUFBQSxVQUNFLEtBQUs7QUFBQSxVQUNMLE1BQU07QUFBQSxRQUNSO0FBQUEsTUFDRjtBQUFBLElBQ0YsQ0FBQztBQUFBLEVBQ0g7QUFBQSxFQUVBLEtBQUssRUFBRSxTQUFTLHNCQUFzQjtBQUFBLEVBQ3RDLFNBQVM7QUFBQSxJQUNQLE9BQU87QUFBQSxNQUNMO0FBQUEsUUFDRSxNQUFNO0FBQUEsUUFDTixhQUFhLEtBQUssUUFBUSxrQ0FBVyxPQUFPO0FBQUEsTUFDOUM7QUFBQSxNQUNBO0FBQUEsUUFDRSxNQUFNO0FBQUEsUUFDTixhQUFhLEtBQUssUUFBUSxrQ0FBVyxnQkFBZ0I7QUFBQSxNQUN2RDtBQUFBLElBQ0Y7QUFBQSxFQUNGO0FBQ0YsQ0FBQzsiLAogICJuYW1lcyI6IFsicmVxdWlyZSJdCn0K
|
||||
1
dist/client/assets/index-CSIsiVBT.css
vendored
1
dist/client/assets/index-CSIsiVBT.css
vendored
File diff suppressed because one or more lines are too long
1
dist/client/assets/index-Cipqnmx2.css
vendored
Normal file
1
dist/client/assets/index-Cipqnmx2.css
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,4 +1,4 @@
|
||||
var Pv=Object.defineProperty;var qv=(n,o,r)=>o in n?Pv(n,o,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[o]=r;var je=(n,o,r)=>qv(n,typeof o!="symbol"?o+"":o,r);import{u as Fc,a as rn,b as Bt,c as qn,d as Re,j as i,P as kv,e as $v,f as pe,g as Bv,N as as,R as Pe,J as Et,h as qe,i as dr,D as fr,l as Ua,M as Ot,V as Uv,r as T,k as Dc,m as Mc,n as hr,o as Tc,B as H,C as Vv,p as pr,q as xr,s as _r,S as go,t as vr,v as es,w as b,x as mo,y as os,z as ls,A as us,E as Kr,F as ho,G as kn,H as $n,I as Bn,K as Un,L as zv,O as Vt,Q as Hv,T as Gv,U as Kv,W as Te,X as ct,Y as ze,Z as We,_ as Oe,$ as bn,a0 as po,a1 as Wv,a2 as xo,a3 as wn,a4 as _o,a5 as vo,a6 as Zr,a7 as Rt,a8 as Oc,a9 as Qv,aa as ts,ab as Hn,ac as bo,ad as wo,ae as Ln,af as So,ag as Lc,ah as jo,ai as Co,aj as yo,ak as cs,al as Yv,am as On,an as Pt,ao as pc,ap as Zv,aq as Xv,ar as Jv,as as No,at as Ht,au as Cn,av as ds,aw as Pc,ax as _n,ay as e0,az as qc,aA as t0,aB as oe,aC as fs,aD as xc,aE as kc,aF as $c,aG as Bc,aH as Vn,aI as Uc,aJ as gs,aK as ei,aL as ti,aM as ni,aN as Xr,aO as n0,aP as Vc,aQ as Qe,aR as Ye,aS as to,aT as Ze,aU as gr,aV as br,aW as mr,aX as r0,aY as i0,aZ as s0,a_ as no,a$ as a0,b0 as o0,b1 as ur,b2 as zc,b3 as ns,b4 as Hc,b5 as zn,b6 as Jr,b7 as Ut,b8 as l0,b9 as hn,ba as ms,bb as Gc,bc as u0,bd as hs,be as c0,bf as Kc,bg as Wc,bh as Qc,bi as Yc,bj as Zc,bk as Xc,bl as Jc,bm as ed,bn as td,bo as d0,bp as Qi,bq as f0,br as g0,bs as m0,bt as h0,bu as p0,bv as x0,bw as _0,bx as v0,by as b0,bz as w0,bA as S0,bB as _c,bC as nd,bD as rd,bE as id,bF as sd,bG as ad,bH as od,bI as ld,bJ as j0,bK as Va,bL as Wr,bM as C0,bN as ud,bO as y0,bP as N0,bQ as cd,bR as dd,bS as ro,bT as io,bU as R0,bV as E0,bW as A0}from"./index-BdgZcBCl.js";const I0=n=>{const{onSuccess:o,...r}=n||{},a=qn(),{login:l}=Fc(),c=rn();return Bt({mutationKey:a().auth().action("login").get(),mutationFn:l,onSuccess:async(d,m,p)=>{d.success&&c.removeQueries({queryKey:a().auth().get()}),await(o==null?void 0:o(d,m,p))},...r})};/**
|
||||
var Pv=Object.defineProperty;var qv=(n,o,r)=>o in n?Pv(n,o,{enumerable:!0,configurable:!0,writable:!0,value:r}):n[o]=r;var je=(n,o,r)=>qv(n,typeof o!="symbol"?o+"":o,r);import{u as Fc,a as rn,b as Bt,c as qn,d as Re,j as i,P as kv,e as $v,f as pe,g as Bv,N as as,R as Pe,J as Et,h as qe,i as dr,D as fr,l as Ua,M as Ot,V as Uv,r as T,k as Dc,m as Mc,n as hr,o as Tc,B as H,C as Vv,p as pr,q as xr,s as _r,S as go,t as vr,v as es,w as b,x as mo,y as os,z as ls,A as us,E as Kr,F as ho,G as kn,H as $n,I as Bn,K as Un,L as zv,O as Vt,Q as Hv,T as Gv,U as Kv,W as Te,X as ct,Y as ze,Z as We,_ as Oe,$ as bn,a0 as po,a1 as Wv,a2 as xo,a3 as wn,a4 as _o,a5 as vo,a6 as Zr,a7 as Rt,a8 as Oc,a9 as Qv,aa as ts,ab as Hn,ac as bo,ad as wo,ae as Ln,af as So,ag as Lc,ah as jo,ai as Co,aj as yo,ak as cs,al as Yv,am as On,an as Pt,ao as pc,ap as Zv,aq as Xv,ar as Jv,as as No,at as Ht,au as Cn,av as ds,aw as Pc,ax as _n,ay as e0,az as qc,aA as t0,aB as oe,aC as fs,aD as xc,aE as kc,aF as $c,aG as Bc,aH as Vn,aI as Uc,aJ as gs,aK as ei,aL as ti,aM as ni,aN as Xr,aO as n0,aP as Vc,aQ as Qe,aR as Ye,aS as to,aT as Ze,aU as gr,aV as br,aW as mr,aX as r0,aY as i0,aZ as s0,a_ as no,a$ as a0,b0 as o0,b1 as ur,b2 as zc,b3 as ns,b4 as Hc,b5 as zn,b6 as Jr,b7 as Ut,b8 as l0,b9 as hn,ba as ms,bb as Gc,bc as u0,bd as hs,be as c0,bf as Kc,bg as Wc,bh as Qc,bi as Yc,bj as Zc,bk as Xc,bl as Jc,bm as ed,bn as td,bo as d0,bp as Qi,bq as f0,br as g0,bs as m0,bt as h0,bu as p0,bv as x0,bw as _0,bx as v0,by as b0,bz as w0,bA as S0,bB as _c,bC as nd,bD as rd,bE as id,bF as sd,bG as ad,bH as od,bI as ld,bJ as j0,bK as Va,bL as Wr,bM as C0,bN as ud,bO as y0,bP as N0,bQ as cd,bR as dd,bS as ro,bT as io,bU as R0,bV as E0,bW as A0}from"./index-DCwYKvrD.js";const I0=n=>{const{onSuccess:o,...r}=n||{},a=qn(),{login:l}=Fc(),c=rn();return Bt({mutationKey:a().auth().action("login").get(),mutationFn:l,onSuccess:async(d,m,p)=>{d.success&&c.removeQueries({queryKey:a().auth().get()}),await(o==null?void 0:o(d,m,p))},...r})};/**
|
||||
* @license lucide-react v0.427.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
4
dist/client/index.html
vendored
4
dist/client/index.html
vendored
@ -8,8 +8,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="https://fonts.upset.dev/css2?family=Poppins&display=swap" rel="stylesheet" />
|
||||
<title>Presupuestador Uecko</title>
|
||||
<script type="module" crossorigin src="/assets/index-BdgZcBCl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CSIsiVBT.css">
|
||||
<script type="module" crossorigin src="/assets/index-DCwYKvrD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Cipqnmx2.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
1
dist/client/version.json
vendored
Normal file
1
dist/client/version.json
vendored
Normal file
@ -0,0 +1 @@
|
||||
{"buildId":"57cae076-0740-411c-921e-13d28d1741ae"}
|
||||
24
doc/despliegue-spa.md
Normal file
24
doc/despliegue-spa.md
Normal file
@ -0,0 +1,24 @@
|
||||
# Actualizaciones de la SPA
|
||||
|
||||
El HTML y las rutas de la SPA se sirven con `Cache-Control: no-cache`: el navegador puede guardarlos, pero debe revalidarlos antes de reutilizarlos. Los archivos existentes de `/assets/` tienen nombres con hash y se sirven con caché de un año e `immutable`. Los archivos que faltan en ese directorio devuelven 404, sin sustituirlos por HTML.
|
||||
|
||||
Cada build genera un identificador independiente de la versión de `package.json`, incluido tanto en JavaScript como en `version.json`. Las pestañas abiertas consultan ese archivo sin caché al inicio, cada minuto mientras están visibles y al recuperar el foco o la conexión. Una versión diferente muestra un aviso para guardar y recargar. Un fallo de importación dinámica de Vite muestra un aviso de recarga incluso si no es posible consultar la versión. No se recarga automáticamente ni se oculta el error original.
|
||||
|
||||
## Publicación
|
||||
|
||||
La configuración de Caddy debe aplicarse en producción para que las cabeceras tengan efecto. Si hay un proxy o CDN delante, debe respetar estas cabeceras y dejar de servir copias antiguas del HTML y `version.json`.
|
||||
|
||||
Actualmente `yarn build` del cliente borra `dist/client`, y Docker monta esa misma carpeta en Caddy. Compilar sobre la carpeta publicada provoca una ventana con archivos ausentes. Preparar el build en una carpeta de release separada, comprobarlo y publicar la release completa de forma atómica evita esa ventana.
|
||||
|
||||
La nueva release debe contener también los assets con hash de las releases anteriores durante el periodo en que se admitan pestañas abiertas (por ejemplo, varios días). Los assets se copian antes de publicar el HTML y `version.json` nuevos; nunca se sobrescribe un asset con hash con contenido distinto. El cambio atómico debe hacerse sobre una ruta visible para Caddy: sustituir solo un enlace o directorio en el host puede no actualizar un bind mount Docker existente. La mecánica concreta debe ajustarse al despliegue.
|
||||
|
||||
El aviso es una recuperación adicional, no sustituye la conservación de assets ni la publicación atómica. Una pestaña cargada antes de incorporar el monitor necesitará una primera recarga. Las cabeceras nuevas tampoco pueden invalidar por sí solas un HTML que ya tenga una caché vigente en un navegador o CDN.
|
||||
|
||||
## Comprobación en producción
|
||||
|
||||
- `/`, `/index.html` y `/quotes`: cabecera `Cache-Control: no-cache`.
|
||||
- `/version.json`: JSON con `buildId` y cabecera `Cache-Control: no-store`.
|
||||
- Un bundle existente en `/assets/`: `Cache-Control: public, max-age=31536000, immutable`.
|
||||
- `/assets/inexistente.js`: 404, sin HTML de la SPA ni caché inmutable.
|
||||
- Abrir una pestaña, publicar otra release y volver a la pestaña: aparece el aviso; al recargar se mantiene la URL y se carga la release nueva.
|
||||
- Simular un fallo de carga de un módulo: aparece el aviso de recarga y no hay bucles de recarga automática.
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "uecko-presupuestador",
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"author": "Rodax Software <dev@rodax-software.com>",
|
||||
"license": "ISC",
|
||||
"private": true,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@uecko-presupuestador/server",
|
||||
"private": true,
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"author": "Rodax Software <dev@rodax-software.com>",
|
||||
"main": "./src/index.ts",
|
||||
"scripts": {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@uecko-presupuestador/shared",
|
||||
"private": false,
|
||||
"version": "1.2.3",
|
||||
"version": "1.2.4",
|
||||
"main": "./index.ts",
|
||||
"author": "Rodax Software <dev@rodax-software.com>",
|
||||
"license": "ISC",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user