proteum 2.1.6 → 2.1.7

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.
@@ -174,13 +174,22 @@ async function startApp(app: App) {
174
174
  });
175
175
 
176
176
  const child = cp;
177
+ let childReady = false;
177
178
 
178
- child.on('exit', () => {
179
- if (cp === child) cp = undefined;
179
+ child.on('exit', (code, signal) => {
180
+ const isCurrentChild = cp === child;
181
+ if (isCurrentChild) cp = undefined;
182
+ if (!isCurrentChild || devSessionStopping || childReady) return;
183
+
184
+ console.error(
185
+ `Proteum dev server exited before reporting ready.${code !== null ? ` Exit code: ${code}.` : ''}${signal ? ` Signal: ${signal}.` : ''}`,
186
+ );
187
+ process.exit(code && code !== 0 ? code : 1);
180
188
  });
181
189
 
182
190
  child.on('message', (message: unknown) => {
183
191
  if (isServerReadyMessage(message)) {
192
+ childReady = true;
184
193
  void (async () => {
185
194
  console.info(
186
195
  await renderServerReadyBanner({
@@ -5,6 +5,7 @@ import app from '../../app';
5
5
  import cli from '../..';
6
6
  import { inspectProteumEnv } from '../../../common/env/proteumEnv';
7
7
  import { reservedRouteSetupKeys, routeSetupOptionKeys } from '../../../common/router/pageSetup';
8
+ import { getProjectInstructionGitignoreEntries } from '../../utils/agents';
8
9
  import {
9
10
  TProteumManifest,
10
11
  TProteumManifestCommand,
@@ -39,6 +40,10 @@ const collectManifestDiagnostics = ({
39
40
  routes: TProteumManifest['routes'];
40
41
  }) => {
41
42
  const diagnostics: TProteumManifestDiagnostic[] = [];
43
+ const expectedGitignoreEntries = [
44
+ ...requiredGitignoreEntries,
45
+ ...getProjectInstructionGitignoreEntries({ coreRoot: cli.paths.core.root }),
46
+ ];
42
47
 
43
48
  const pushDiagnostic = (diagnostic: TProteumManifestDiagnostic) => {
44
49
  diagnostics.push(diagnostic);
@@ -224,7 +229,7 @@ const collectManifestDiagnostics = ({
224
229
  pushDiagnostic({
225
230
  level: 'warning',
226
231
  code: 'app.gitignore-missing',
227
- message: `Missing .gitignore. Proteum generated output should ignore ${requiredGitignoreEntries.join(', ')}.`,
232
+ message: `Missing .gitignore. Proteum-managed paths should ignore ${expectedGitignoreEntries.join(', ')}.`,
228
233
  filepath: gitignoreFilepath,
229
234
  });
230
235
  } else {
@@ -236,14 +241,14 @@ const collectManifestDiagnostics = ({
236
241
  .map(normalizeGitignoreEntry),
237
242
  );
238
243
 
239
- for (const requiredEntry of requiredGitignoreEntries) {
244
+ for (const requiredEntry of expectedGitignoreEntries) {
240
245
  const normalizedRequiredEntry = normalizeGitignoreEntry(requiredEntry);
241
246
  if (entries.has(normalizedRequiredEntry)) continue;
242
247
 
243
248
  pushDiagnostic({
244
249
  level: 'warning',
245
250
  code: 'app.gitignore-generated-entry-missing',
246
- message: `Add "${requiredEntry}" to .gitignore so Proteum generated output stays untracked.`,
251
+ message: `Add "${requiredEntry}" to .gitignore so Proteum-managed paths stay untracked.`,
247
252
  filepath: gitignoreFilepath,
248
253
  });
249
254
  }
@@ -5,7 +5,7 @@ import { UsageError } from 'clipanion';
5
5
 
6
6
  import cli from '..';
7
7
  import { loadApplicationIdentityConfig } from '../../common/applicationConfigLoader';
8
- import { ensureProjectAgentSymlinks } from '../utils/agents';
8
+ import { ensureProjectAgentSymlinks, renderProjectInstructionGitignoreBlock } from '../utils/agents';
9
9
  import { runProcess } from '../utils/runProcess';
10
10
  import {
11
11
  createClientTsconfigTemplate,
@@ -633,7 +633,9 @@ const createInitFilePlans = (config: TScaffoldInitConfig): TScaffoldFilePlan[] =
633
633
  },
634
634
  {
635
635
  relativePath: '.gitignore',
636
- content: createGitignoreTemplate(),
636
+ content: createGitignoreTemplate({
637
+ projectInstructionGitignoreBlock: renderProjectInstructionGitignoreBlock({ coreRoot: cli.paths.core.root }),
638
+ }),
637
639
  },
638
640
  {
639
641
  relativePath: 'eslint.config.mjs',
@@ -237,7 +237,11 @@ export const createServerTsconfigTemplate = () => `{
237
237
  }
238
238
  `;
239
239
 
240
- export const createGitignoreTemplate = () => `node_modules
240
+ export const createGitignoreTemplate = ({
241
+ projectInstructionGitignoreBlock,
242
+ }: {
243
+ projectInstructionGitignoreBlock: string;
244
+ }) => `node_modules
241
245
  /.proteum
242
246
  /.cache
243
247
  /bin
@@ -245,6 +249,8 @@ export const createGitignoreTemplate = () => `node_modules
245
249
  /var
246
250
  /proteum.connected.json
247
251
  .env
252
+
253
+ ${projectInstructionGitignoreBlock}
248
254
  `;
249
255
 
250
256
  export const createEnvTemplate = ({ port, url }: { port: number; url: string }) => `ENV_NAME=local
@@ -11,6 +11,7 @@ import { logVerbose } from '../runtime/verbose';
11
11
  - TYPES
12
12
  ----------------------------------*/
13
13
 
14
+ type TProjectInstructionArgs = { coreRoot: string };
14
15
  type TEnsureProjectAgentSymlinksArgs = { appRoot: string; coreRoot: string };
15
16
 
16
17
  type TAgentLinkDefinition = { projectPath: string; sourcePath: string; ensureParentDir?: boolean };
@@ -34,41 +35,107 @@ const projectAgentLinkDefinitions: TAgentLinkDefinition[] = [
34
35
  { projectPath: path.join('server', 'routes', 'AGENTS.md'), sourcePath: path.join('server', 'routes', 'AGENTS.md') },
35
36
  { projectPath: path.join('tests', 'e2e', 'AGENTS.md'), sourcePath: path.join('tests', 'AGENTS.md') },
36
37
  ];
38
+ const projectInstructionGitignoreBlockStart = '# Proteum-managed instruction symlinks';
39
+ const projectInstructionGitignoreBlockEnd = '# End Proteum-managed instruction symlinks';
37
40
 
38
41
  /*----------------------------------
39
42
  - PUBLIC API
40
43
  ----------------------------------*/
41
44
 
42
45
  export function ensureProjectAgentSymlinks({ appRoot, coreRoot }: TEnsureProjectAgentSymlinksArgs) {
43
- const agentSourceRoot = path.join(coreRoot, 'agents', 'project');
44
- const agentLinks = projectAgentLinkDefinitions.map((linkDefinition) => ({
45
- ...linkDefinition,
46
- sourcePath: path.join(agentSourceRoot, linkDefinition.sourcePath),
47
- }));
46
+ ensureSymlinks(appRoot, getProjectAgentLinkDefinitions({ coreRoot }), '[agents]');
47
+ ensureSymlinks(appRoot, getProjectSkillLinkDefinitions({ coreRoot }), '[skills]');
48
+ ensureProjectInstructionGitignoreEntries({ appRoot, coreRoot });
49
+ }
50
+
51
+ export function getProjectInstructionGitignoreEntries({ coreRoot }: TProjectInstructionArgs) {
52
+ const entries = new Set<string>();
48
53
 
49
- ensureSymlinks(appRoot, agentLinks, '[agents]');
50
- ensureProjectSkillSymlinks({ appRoot, coreRoot });
54
+ for (const linkDefinition of [
55
+ ...getProjectAgentLinkDefinitions({ coreRoot }),
56
+ ...getProjectSkillLinkDefinitions({ coreRoot }),
57
+ ]) {
58
+ entries.add(`/${normalizeProjectPathForGitignore(linkDefinition.projectPath)}`);
59
+ }
60
+
61
+ return Array.from(entries);
62
+ }
63
+
64
+ export function renderProjectInstructionGitignoreBlock({ coreRoot }: TProjectInstructionArgs) {
65
+ return [
66
+ projectInstructionGitignoreBlockStart,
67
+ ...getProjectInstructionGitignoreEntries({ coreRoot }),
68
+ projectInstructionGitignoreBlockEnd,
69
+ ].join('\n');
51
70
  }
52
71
 
53
72
  /*----------------------------------
54
73
  - HELPERS
55
74
  ----------------------------------*/
56
75
 
57
- function ensureProjectSkillSymlinks({ appRoot, coreRoot }: TEnsureProjectAgentSymlinksArgs) {
76
+ function getProjectAgentLinkDefinitions({ coreRoot }: TProjectInstructionArgs) {
77
+ const agentSourceRoot = path.join(coreRoot, 'agents', 'project');
78
+
79
+ return projectAgentLinkDefinitions.map((linkDefinition) => ({
80
+ ...linkDefinition,
81
+ sourcePath: path.join(agentSourceRoot, linkDefinition.sourcePath),
82
+ }));
83
+ }
84
+
85
+ function getProjectSkillLinkDefinitions({ coreRoot }: TProjectInstructionArgs) {
58
86
  const frameworkSkillsRoot = path.join(coreRoot, 'skills');
59
- if (!fs.existsSync(frameworkSkillsRoot)) return;
87
+ if (!fs.existsSync(frameworkSkillsRoot)) return [];
60
88
 
61
- const skillLinks: TAgentLinkDefinition[] = fs
89
+ return fs
62
90
  .readdirSync(frameworkSkillsRoot, { withFileTypes: true })
63
91
  .filter((dirent) => dirent.isDirectory())
92
+ .sort((left, right) => left.name.localeCompare(right.name))
64
93
  .map((dirent) => ({
65
94
  projectPath: path.join('skills', dirent.name),
66
95
  sourcePath: path.join(frameworkSkillsRoot, dirent.name),
67
96
  ensureParentDir: true,
68
97
  }))
69
98
  .filter((linkDefinition) => pathEntryExists(path.join(linkDefinition.sourcePath, 'SKILL.md')));
99
+ }
100
+
101
+ function ensureProjectInstructionGitignoreEntries({ appRoot, coreRoot }: TEnsureProjectAgentSymlinksArgs) {
102
+ const gitignoreFilepath = path.join(appRoot, '.gitignore');
103
+ if (!pathEntryExists(gitignoreFilepath)) return;
104
+
105
+ const managedEntries = getProjectInstructionGitignoreEntries({ coreRoot });
106
+ const managedNormalizedEntries = new Set(managedEntries.map(normalizeGitignoreEntry));
107
+ const lines = fs.readFileSync(gitignoreFilepath, 'utf8').split(/\r?\n/);
108
+ const filteredLines: string[] = [];
109
+
110
+ let insideManagedBlock = false;
70
111
 
71
- ensureSymlinks(appRoot, skillLinks, '[skills]');
112
+ for (const line of lines) {
113
+ const trimmedLine = line.trim();
114
+
115
+ if (trimmedLine === projectInstructionGitignoreBlockStart) {
116
+ insideManagedBlock = true;
117
+ continue;
118
+ }
119
+
120
+ if (trimmedLine === projectInstructionGitignoreBlockEnd) {
121
+ insideManagedBlock = false;
122
+ continue;
123
+ }
124
+
125
+ if (insideManagedBlock) continue;
126
+ if (shouldSkipLegacyManagedGitignoreLine(line, managedNormalizedEntries)) continue;
127
+
128
+ filteredLines.push(line);
129
+ }
130
+
131
+ const baseContent = trimTrailingBlankLines(filteredLines).join('\n');
132
+ const managedBlock = renderProjectInstructionGitignoreBlock({ coreRoot });
133
+ const nextContent = baseContent ? `${baseContent}\n\n${managedBlock}\n` : `${managedBlock}\n`;
134
+
135
+ if (nextContent === fs.readFileSync(gitignoreFilepath, 'utf8')) return;
136
+
137
+ fs.writeFileSync(gitignoreFilepath, nextContent);
138
+ logVerbose(`[agents] Updated ${path.relative(appRoot, gitignoreFilepath) || '.gitignore'} with Proteum-managed instruction ignore entries.`);
72
139
  }
73
140
 
74
141
  function ensureSymlinks(appRoot: string, linkDefinitions: TAgentLinkDefinition[], logPrefix: string) {
@@ -93,6 +160,30 @@ function ensureSymlinks(appRoot: string, linkDefinitions: TAgentLinkDefinition[]
93
160
  }
94
161
  }
95
162
 
163
+ function normalizeProjectPathForGitignore(projectPath: string) {
164
+ return projectPath.replace(/\\/g, '/');
165
+ }
166
+
167
+ function normalizeGitignoreEntry(value: string) {
168
+ return value.trim().replace(/#.*/, '').replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+$/, '');
169
+ }
170
+
171
+ function shouldSkipLegacyManagedGitignoreLine(line: string, managedNormalizedEntries: Set<string>) {
172
+ const normalizedLine = normalizeGitignoreEntry(line);
173
+ if (!normalizedLine) return false;
174
+ if (line.trim().startsWith('#')) return false;
175
+
176
+ return managedNormalizedEntries.has(normalizedLine);
177
+ }
178
+
179
+ function trimTrailingBlankLines(lines: string[]) {
180
+ const trimmedLines = [...lines];
181
+
182
+ while (trimmedLines.length > 0 && trimmedLines[trimmedLines.length - 1].trim() === '') trimmedLines.pop();
183
+
184
+ return trimmedLines;
185
+ }
186
+
96
187
  function pathEntryExists(filepath: string) {
97
188
  try {
98
189
  fs.lstatSync(filepath);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.1.6",
4
+ "version": "2.1.7",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -74,7 +74,13 @@ export class ApplicationContainer<TServicesIndex extends StartedServicesIndex =
74
74
 
75
75
  // Start application
76
76
  try {
77
- this.application.start();
77
+ void this.application.start().catch(async (error) => {
78
+ try {
79
+ await this.handleBug(error, 'Failed to start the Application');
80
+ } finally {
81
+ process.exit(1);
82
+ }
83
+ });
78
84
  } catch (error) {
79
85
  this.handleBug(error, 'Failed to start the Application');
80
86
  process.exit(1);
@@ -92,6 +92,27 @@ const createContentSecurityPolicy = (config: Config['csp']): TContentSecurityPol
92
92
  };
93
93
  };
94
94
 
95
+ const immutablePublicAssetCacheControl = 'public, max-age=31536000, immutable';
96
+ const revalidatedPublicAssetCacheControl = 'public, max-age=0, must-revalidate';
97
+ const hashedPublicAssetPattern = /(^|[-_.])[a-f0-9]{6,}(?=(\.[^.]+)+$)/i;
98
+ const connectedProjectBootRetryCount = 10;
99
+ const connectedProjectBootRetryDelayMs = 5_000;
100
+
101
+ const isVersionedPublicAssetRequest = (res: express.Response, filePath: string) => {
102
+ const requestUrl = res.req?.originalUrl || res.req?.url || '';
103
+ const searchParams = new URL(requestUrl, 'http://proteum.local').searchParams;
104
+ if (searchParams.has('v')) return true;
105
+
106
+ return hashedPublicAssetPattern.test(path.basename(filePath));
107
+ };
108
+
109
+ const resolvePublicAssetCacheControl = (res: express.Response, filePath: string) =>
110
+ isVersionedPublicAssetRequest(res, filePath) ? immutablePublicAssetCacheControl : revalidatedPublicAssetCacheControl;
111
+ const wait = async (durationMs: number) =>
112
+ await new Promise<void>((resolve) => {
113
+ setTimeout(resolve, durationMs);
114
+ });
115
+
95
116
  /*----------------------------------
96
117
  - FUNCTION
97
118
  ----------------------------------*/
@@ -149,22 +170,38 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
149
170
  private async verifyConnectedProjectsBeforeStart() {
150
171
  for (const connectedProject of Object.values(this.app.connectedProjects || {})) {
151
172
  const healthUrl = new URL(connectedProjectHealthPath, connectedProject.urlInternal).toString();
173
+ let lastError: Error | undefined;
152
174
 
153
- let response: Response;
154
- try {
155
- response = await fetch(healthUrl, {
156
- headers: { Accept: 'application/json' },
157
- });
158
- } catch (error) {
159
- throw new Error(
160
- `Connected project "${connectedProject.namespace}" is unreachable at ${connectedProject.urlInternal}. ${error instanceof Error ? error.message : String(error)}`,
161
- );
162
- }
175
+ for (let retryIndex = 0; retryIndex <= connectedProjectBootRetryCount; retryIndex++) {
176
+ try {
177
+ const response = await fetch(healthUrl, {
178
+ headers: { Accept: 'application/json' },
179
+ });
180
+
181
+ if (!response.ok) {
182
+ throw new Error(
183
+ `Connected project "${connectedProject.namespace}" health check failed at ${healthUrl} with status ${response.status}.`,
184
+ );
185
+ }
186
+
187
+ lastError = undefined;
188
+ break;
189
+ } catch (error) {
190
+ lastError =
191
+ error instanceof Error && error.message.startsWith(`Connected project "${connectedProject.namespace}"`)
192
+ ? error
193
+ : new Error(
194
+ `Connected project "${connectedProject.namespace}" is unreachable at ${connectedProject.urlInternal}. ${error instanceof Error ? error.message : String(error)}`,
195
+ );
196
+ }
163
197
 
164
- if (!response.ok) {
165
- throw new Error(
166
- `Connected project "${connectedProject.namespace}" health check failed at ${healthUrl} with status ${response.status}.`,
198
+ if (!lastError) continue;
199
+ if (retryIndex === connectedProjectBootRetryCount) throw lastError;
200
+
201
+ console.warn(
202
+ `[connect] ${lastError.message} Retrying ${retryIndex + 1}/${connectedProjectBootRetryCount} in ${connectedProjectBootRetryDelayMs / 1000}s.`,
167
203
  );
204
+ await wait(connectedProjectBootRetryDelayMs);
168
205
  }
169
206
  }
170
207
  }
@@ -329,17 +366,8 @@ export default class HttpServer<TRouter extends TServerRouter = TServerRouter> {
329
366
  '/public',
330
367
  express.static(path.join(Container.path.root, APP_OUTPUT_DIR, 'public'), {
331
368
  dotfiles: 'deny',
332
- setHeaders: function setCustomCacheControl(res, path) {
333
- const dontCache = ['/public/icons', '/public/client'];
334
-
335
- res.setHeader('Cache-Control', 'public, max-age=0');
336
-
337
- // Set long term cache, except for non-hashed filenames
338
- /*if (dontCache.some( p => path.startsWith( p ))) {
339
- res.setHeader('Cache-Control', 'public, max-age=0');
340
- } else {
341
- res.setHeader('Cache-Control', 'public, max-age=604800000'); // 7 Days
342
- }*/
369
+ setHeaders: function setCustomCacheControl(res, filePath) {
370
+ res.setHeader('Cache-Control', resolvePublicAssetCacheControl(res, filePath));
343
371
  },
344
372
  }),
345
373
  (req, res) => {
@@ -95,6 +95,9 @@ export type TApiResponseData = { data: any; triggers?: { [cle: string]: any } };
95
95
 
96
96
  export type HttpHeaders = { [cle: string]: string };
97
97
 
98
+ const dynamicHtmlCacheControl = 'no-store, no-cache, must-revalidate, proxy-revalidate';
99
+ const staticHtmlCacheControl = 'public, max-age=0, must-revalidate';
100
+
98
101
  /*----------------------------------
99
102
  - SERVICE CONFIG
100
103
  ----------------------------------*/
@@ -571,14 +574,10 @@ export default class ServerRouter<
571
574
  - RESOLUTION
572
575
  ----------------------------------*/
573
576
  public async middleware(req: express.Request, res: express.Response) {
574
- // Don't cache HTML, because in case of update, assets file name will change (hash.ext)
575
- // https://github.com/helmetjs/nocache/blob/main/index.ts
576
- res.setHeader('Surrogate-Control', 'no-store');
577
- res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
578
-
579
577
  // Create request
580
578
  let requestId = uuid();
581
579
  const cachedPage = req.headers['bypasscache'] ? undefined : this.cache[req.path];
580
+ this.applyHtmlCacheHeaders(res, Boolean(cachedPage));
582
581
  const headers: HttpHeaders = Object.fromEntries(
583
582
  Object.entries(req.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(', ') : value || '']),
584
583
  );
@@ -638,10 +637,39 @@ export default class ServerRouter<
638
637
  // Static pages
639
638
  if (cachedPage) {
640
639
  console.log('[router] Get static page from cache', req.path);
640
+ res.status(response.statusCode);
641
+ res.header(response.headers);
642
+
643
+ if (response.headers['Location']) {
644
+ res.send(response.data === undefined ? '' : response.data);
645
+ this.app.container.Trace.record(
646
+ request.id,
647
+ 'response.send',
648
+ {
649
+ cached: true,
650
+ statusCode: response.statusCode,
651
+ contentType: response.headers['Content-Type'] || '',
652
+ headerKeys: Object.keys(response.headers),
653
+ redirected: true,
654
+ },
655
+ 'summary',
656
+ );
657
+ this.app.container.Trace.finishRequest(request.id, {
658
+ statusCode: response.statusCode,
659
+ user: request.user?.email,
660
+ });
661
+ return;
662
+ }
663
+
641
664
  this.app.container.Trace.record(
642
665
  request.id,
643
666
  'response.send',
644
- { cached: true, statusCode: response.statusCode, contentType: 'text/html' },
667
+ {
668
+ cached: true,
669
+ statusCode: response.statusCode,
670
+ contentType: response.headers['Content-Type'] || 'text/html',
671
+ headerKeys: Object.keys(response.headers),
672
+ },
645
673
  'summary',
646
674
  );
647
675
  res.send(cachedPage.rendered);
@@ -870,6 +898,12 @@ export default class ServerRouter<
870
898
  }
871
899
 
872
900
  this.app.container.Trace.record(request.id, 'resolve.routes-evaluated', routeStats, 'resolve');
901
+
902
+ if (isStatic) {
903
+ resolve(response);
904
+ return;
905
+ }
906
+
873
907
  this.app.container.Trace.record(request.id, 'resolve.not-found', { path: request.path }, 'summary');
874
908
  reject(new NotFound());
875
909
  } catch (error) {
@@ -923,10 +957,19 @@ export default class ServerRouter<
923
957
  await response.runController(route);
924
958
  if (!response.wasProvided) return;
925
959
 
926
- // Set in cache
927
- if (response.request.path && route.options.static && route.options.static.urls.includes('*')) {
928
- console.log('[router] Set in cache', response.request.path);
929
- this.renderStatic(response.request.path, route.options.static, response.data);
960
+ if (response.request.path && route.options.static) {
961
+ const staticUrls = route.options.static.urls.includes('*') ? [response.request.path] : route.options.static.urls;
962
+
963
+ for (const staticUrl of staticUrls) {
964
+ if (!staticUrl) continue;
965
+
966
+ console.log('[router] Set in cache', staticUrl);
967
+ void this.renderStatic(
968
+ staticUrl,
969
+ route.options.static,
970
+ staticUrl === response.request.path ? response.data : undefined,
971
+ );
972
+ }
930
973
  }
931
974
 
932
975
  const timeEndResolving = Date.now();
@@ -1008,4 +1051,17 @@ export default class ServerRouter<
1008
1051
 
1009
1052
  return response;
1010
1053
  }
1054
+
1055
+ private applyHtmlCacheHeaders(res: express.Response, isStaticHtml: boolean) {
1056
+ if (isStaticHtml) {
1057
+ res.removeHeader('Surrogate-Control');
1058
+ res.setHeader('Cache-Control', staticHtmlCacheControl);
1059
+ return;
1060
+ }
1061
+
1062
+ // Don't cache dynamic HTML, because updated releases can change asset hashes.
1063
+ // https://github.com/helmetjs/nocache/blob/main/index.ts
1064
+ res.setHeader('Surrogate-Control', 'no-store');
1065
+ res.setHeader('Cache-Control', dynamicHtmlCacheControl);
1066
+ }
1011
1067
  }
@@ -34,12 +34,10 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
34
34
  <head>
35
35
  {/* Format */}
36
36
  <meta charSet="utf-8" />
37
- <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1" />
37
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
38
38
 
39
39
  {/* CSS */}
40
40
  {this.clientStyles()}
41
- <link rel="preload" as="font" href={'/public/icons.woff2?v=' + BUILD_ID} type="font/woff2" />
42
- <link rel="stylesheet" type="text/css" href="/public/icons.css" />
43
41
 
44
42
  {/* JS */}
45
43
  <script
@@ -69,7 +67,7 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
69
67
  {/* Format */}
70
68
  <meta charSet="utf-8" />
71
69
  <meta content="IE=edge" httpEquiv="X-UA-Compatible" />
72
- <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1" />
70
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
73
71
 
74
72
  {/* Mobile */}
75
73
  <meta name="application-name" content={this.app.identity.web.title} />
@@ -80,18 +78,28 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
80
78
  <meta name="msapplication-TileColor" content={this.app.identity.maincolor} />
81
79
  <meta name="apple-mobile-web-app-capable" content="yes" />
82
80
  <meta name="mobile-web-app-capable" content="yes" />
83
- <meta
84
- name="viewport"
85
- content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
86
- />
87
81
 
88
82
  {/* https://stackoverflow.com/questions/48956465/favicon-standard-2019-svg-ico-png-and-dimensions */}
89
83
  {/*<link rel="manifest" href={RES['manifest.json']} />*/}
90
- <link rel="shortcut icon" href="/public/app/favicon.ico" />
91
- <link rel="icon" type="image/png" sizes="16x16" href="/public/app/favicon-16x16.png" />
92
- <link rel="icon" type="image/png" sizes="32x32" href="/public/app/favicon-32x32.png" />
93
- <link rel="apple-touch-icon" sizes="180x180" href="/public/app/apple-touch-icon-180x180.png" />
94
- <meta name="msapplication-config" content="/public/app/browserconfig.xml" />
84
+ <link rel="shortcut icon" href={this.publicAssetUrl('app/favicon.ico', true)} />
85
+ <link
86
+ rel="icon"
87
+ type="image/png"
88
+ sizes="16x16"
89
+ href={this.publicAssetUrl('app/favicon-16x16.png', true)}
90
+ />
91
+ <link
92
+ rel="icon"
93
+ type="image/png"
94
+ sizes="32x32"
95
+ href={this.publicAssetUrl('app/favicon-32x32.png', true)}
96
+ />
97
+ <link
98
+ rel="apple-touch-icon"
99
+ sizes="180x180"
100
+ href={this.publicAssetUrl('app/apple-touch-icon-180x180.png', true)}
101
+ />
102
+ <meta name="msapplication-config" content={this.publicAssetUrl('app/browserconfig.xml', true)} />
95
103
 
96
104
  {/* Page */}
97
105
  <title>{page.title}</title>
@@ -165,7 +173,7 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
165
173
  return (
166
174
  <>
167
175
  {scripts.map((script) => {
168
- const src = this.clientAssetUrl(script, true);
176
+ const src = this.clientAssetUrl(script);
169
177
 
170
178
  return (
171
179
  <React.Fragment key={script}>
@@ -248,6 +256,10 @@ export default class DocumentRenderer<TRouter extends TServerRouter> {
248
256
  }
249
257
 
250
258
  private clientAssetUrl(asset: string, withBuildId = false) {
259
+ return this.publicAssetUrl(asset, withBuildId);
260
+ }
261
+
262
+ private publicAssetUrl(asset: string, withBuildId = false) {
251
263
  return `/public/${asset}${withBuildId ? `?v=${BUILD_ID}` : ''}`;
252
264
  }
253
265
  }