proteum 2.1.6 → 2.1.8

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.
@@ -0,0 +1,337 @@
1
+ import path from 'path';
2
+
3
+ import fs from 'fs-extra';
4
+
5
+ export const devSessionRegistryVersion = 1 as const;
6
+
7
+ export type TDevSessionState = 'starting' | 'ready';
8
+
9
+ export type TDevSessionRecord = {
10
+ version: typeof devSessionRegistryVersion;
11
+ pid: number;
12
+ appRoot: string;
13
+ routerPort: number;
14
+ publicUrl: string;
15
+ startedAt: string;
16
+ updatedAt: string;
17
+ sessionFilePath: string;
18
+ state: TDevSessionState;
19
+ };
20
+
21
+ export type TDevSessionInspection = {
22
+ sessionFilePath: string;
23
+ record: TDevSessionRecord | null;
24
+ live: boolean;
25
+ stale: boolean;
26
+ invalid: boolean;
27
+ parseError: string;
28
+ };
29
+
30
+ export type TStopDevSessionResult = {
31
+ sessionFilePath: string;
32
+ pid: number | null;
33
+ routerPort: number | null;
34
+ publicUrl: string;
35
+ state: TDevSessionState | '';
36
+ matched: boolean;
37
+ stopped: boolean;
38
+ removed: boolean;
39
+ stale: boolean;
40
+ live: boolean;
41
+ invalid: boolean;
42
+ parseError: string;
43
+ };
44
+
45
+ const defaultRegistryDirectoryParts = ['var', 'run', 'proteum', 'dev'];
46
+
47
+ const sleep = async (durationMs: number) => await new Promise((resolve) => setTimeout(resolve, durationMs));
48
+
49
+ const isRecordShape = (value: unknown): value is TDevSessionRecord => {
50
+ if (!value || typeof value !== 'object') return false;
51
+
52
+ const candidate = value as Partial<TDevSessionRecord>;
53
+
54
+ return (
55
+ candidate.version === devSessionRegistryVersion &&
56
+ typeof candidate.pid === 'number' &&
57
+ Number.isInteger(candidate.pid) &&
58
+ candidate.pid > 0 &&
59
+ typeof candidate.appRoot === 'string' &&
60
+ candidate.appRoot.length > 0 &&
61
+ typeof candidate.routerPort === 'number' &&
62
+ Number.isInteger(candidate.routerPort) &&
63
+ candidate.routerPort > 0 &&
64
+ typeof candidate.publicUrl === 'string' &&
65
+ typeof candidate.startedAt === 'string' &&
66
+ typeof candidate.updatedAt === 'string' &&
67
+ typeof candidate.sessionFilePath === 'string' &&
68
+ candidate.sessionFilePath.length > 0 &&
69
+ (candidate.state === 'starting' || candidate.state === 'ready')
70
+ );
71
+ };
72
+
73
+ const canSignalProcess = (pid: number, signal: NodeJS.Signals | 0) => {
74
+ try {
75
+ process.kill(pid, signal);
76
+ return true;
77
+ } catch (error) {
78
+ const errno = error as NodeJS.ErrnoException;
79
+
80
+ if (errno.code === 'ESRCH') return false;
81
+ if (errno.code === 'EPERM') return true;
82
+
83
+ throw error;
84
+ }
85
+ };
86
+
87
+ export const isProcessAlive = (pid: number) => canSignalProcess(pid, 0);
88
+
89
+ const waitForProcessExit = async (pid: number, timeoutMs: number) => {
90
+ const deadline = Date.now() + timeoutMs;
91
+
92
+ while (Date.now() < deadline) {
93
+ if (!isProcessAlive(pid)) return true;
94
+ await sleep(100);
95
+ }
96
+
97
+ return !isProcessAlive(pid);
98
+ };
99
+
100
+ export const getDevSessionRegistryDirectory = (appRoot: string) => path.join(appRoot, ...defaultRegistryDirectoryParts);
101
+
102
+ export const resolveDevSessionFilePath = ({
103
+ appRoot,
104
+ port,
105
+ sessionFilePath,
106
+ }: {
107
+ appRoot: string;
108
+ port: number;
109
+ sessionFilePath?: string;
110
+ }) => {
111
+ if (sessionFilePath && sessionFilePath.trim()) {
112
+ return path.isAbsolute(sessionFilePath)
113
+ ? path.normalize(sessionFilePath)
114
+ : path.resolve(appRoot, sessionFilePath);
115
+ }
116
+
117
+ return path.join(getDevSessionRegistryDirectory(appRoot), `${port}.json`);
118
+ };
119
+
120
+ export const createDevSessionRecord = ({
121
+ appRoot,
122
+ port,
123
+ sessionFilePath,
124
+ }: {
125
+ appRoot: string;
126
+ port: number;
127
+ sessionFilePath: string;
128
+ }): TDevSessionRecord => {
129
+ const timestamp = new Date().toISOString();
130
+
131
+ return {
132
+ version: devSessionRegistryVersion,
133
+ pid: process.pid,
134
+ appRoot,
135
+ routerPort: port,
136
+ publicUrl: '',
137
+ startedAt: timestamp,
138
+ updatedAt: timestamp,
139
+ sessionFilePath,
140
+ state: 'starting',
141
+ };
142
+ };
143
+
144
+ export const writeDevSessionRecord = async (record: TDevSessionRecord) => {
145
+ await fs.ensureDir(path.dirname(record.sessionFilePath));
146
+ await fs.writeJson(record.sessionFilePath, record, { spaces: 2 });
147
+ };
148
+
149
+ export const updateDevSessionRecord = async ({
150
+ sessionFilePath,
151
+ patch,
152
+ }: {
153
+ sessionFilePath: string;
154
+ patch: Partial<Omit<TDevSessionRecord, 'version' | 'pid' | 'appRoot' | 'routerPort' | 'startedAt' | 'sessionFilePath'>>;
155
+ }) => {
156
+ const inspection = await inspectDevSessionFile(sessionFilePath);
157
+ if (!inspection || !inspection.record) return;
158
+
159
+ await writeDevSessionRecord({
160
+ ...inspection.record,
161
+ ...patch,
162
+ updatedAt: new Date().toISOString(),
163
+ });
164
+ };
165
+
166
+ export const removeDevSessionRecord = async (sessionFilePath: string) => {
167
+ await fs.remove(sessionFilePath);
168
+ };
169
+
170
+ export const removeDevSessionRecordSync = (sessionFilePath: string) => {
171
+ try {
172
+ fs.removeSync(sessionFilePath);
173
+ } catch {
174
+ // Best-effort cleanup during process exit.
175
+ }
176
+ };
177
+
178
+ export const inspectDevSessionFile = async (sessionFilePath: string): Promise<TDevSessionInspection | null> => {
179
+ if (!(await fs.pathExists(sessionFilePath))) return null;
180
+
181
+ try {
182
+ const rawValue = await fs.readJson(sessionFilePath);
183
+ if (!isRecordShape(rawValue)) {
184
+ return {
185
+ sessionFilePath,
186
+ record: null,
187
+ live: false,
188
+ stale: true,
189
+ invalid: true,
190
+ parseError: 'Session file contents do not match the Proteum dev session schema.',
191
+ };
192
+ }
193
+
194
+ const record = rawValue;
195
+ const live = isProcessAlive(record.pid);
196
+
197
+ return {
198
+ sessionFilePath,
199
+ record,
200
+ live,
201
+ stale: !live,
202
+ invalid: false,
203
+ parseError: '',
204
+ };
205
+ } catch (error) {
206
+ return {
207
+ sessionFilePath,
208
+ record: null,
209
+ live: false,
210
+ stale: true,
211
+ invalid: true,
212
+ parseError: error instanceof Error ? error.message : String(error),
213
+ };
214
+ }
215
+ };
216
+
217
+ export const listDevSessionFiles = async ({
218
+ appRoot,
219
+ sessionFilePath,
220
+ }: {
221
+ appRoot: string;
222
+ sessionFilePath?: string;
223
+ }) => {
224
+ if (sessionFilePath && sessionFilePath.trim())
225
+ return [resolveDevSessionFilePath({ appRoot, port: 1, sessionFilePath })];
226
+
227
+ const registryDirectory = getDevSessionRegistryDirectory(appRoot);
228
+ if (!(await fs.pathExists(registryDirectory))) return [];
229
+
230
+ const entries = await fs.readdir(registryDirectory);
231
+
232
+ return entries
233
+ .filter((entry) => entry.endsWith('.json'))
234
+ .sort((left, right) => left.localeCompare(right))
235
+ .map((entry) => path.join(registryDirectory, entry));
236
+ };
237
+
238
+ export const listDevSessionInspections = async ({
239
+ appRoot,
240
+ sessionFilePath,
241
+ }: {
242
+ appRoot: string;
243
+ sessionFilePath?: string;
244
+ }) => {
245
+ const sessionFilePaths = await listDevSessionFiles({ appRoot, sessionFilePath });
246
+ const inspections = await Promise.all(sessionFilePaths.map((entryPath) => inspectDevSessionFile(entryPath)));
247
+
248
+ return inspections.filter((inspection): inspection is TDevSessionInspection => inspection !== null);
249
+ };
250
+
251
+ export const stopDevSessionFile = async (sessionFilePath: string): Promise<TStopDevSessionResult> => {
252
+ const inspection = await inspectDevSessionFile(sessionFilePath);
253
+
254
+ if (!inspection) {
255
+ return {
256
+ sessionFilePath,
257
+ pid: null,
258
+ routerPort: null,
259
+ publicUrl: '',
260
+ state: '',
261
+ matched: false,
262
+ stopped: false,
263
+ removed: false,
264
+ stale: false,
265
+ live: false,
266
+ invalid: false,
267
+ parseError: '',
268
+ };
269
+ }
270
+
271
+ if (!inspection.record) {
272
+ await removeDevSessionRecord(sessionFilePath);
273
+
274
+ return {
275
+ sessionFilePath,
276
+ pid: null,
277
+ routerPort: null,
278
+ publicUrl: '',
279
+ state: '',
280
+ matched: true,
281
+ stopped: true,
282
+ removed: true,
283
+ stale: true,
284
+ live: false,
285
+ invalid: true,
286
+ parseError: inspection.parseError,
287
+ };
288
+ }
289
+
290
+ const { record } = inspection;
291
+
292
+ if (!inspection.live) {
293
+ await removeDevSessionRecord(sessionFilePath);
294
+
295
+ return {
296
+ sessionFilePath,
297
+ pid: record.pid,
298
+ routerPort: record.routerPort,
299
+ publicUrl: record.publicUrl,
300
+ state: record.state,
301
+ matched: true,
302
+ stopped: true,
303
+ removed: true,
304
+ stale: true,
305
+ live: false,
306
+ invalid: false,
307
+ parseError: '',
308
+ };
309
+ }
310
+
311
+ if (canSignalProcess(record.pid, 'SIGTERM')) {
312
+ const exitedAfterTerm = await waitForProcessExit(record.pid, 5000);
313
+ if (!exitedAfterTerm && canSignalProcess(record.pid, 'SIGKILL')) {
314
+ await waitForProcessExit(record.pid, 2000);
315
+ }
316
+ }
317
+
318
+ const live = isProcessAlive(record.pid);
319
+ if (!live) {
320
+ await removeDevSessionRecord(sessionFilePath);
321
+ }
322
+
323
+ return {
324
+ sessionFilePath,
325
+ pid: record.pid,
326
+ routerPort: record.routerPort,
327
+ publicUrl: record.publicUrl,
328
+ state: record.state,
329
+ matched: true,
330
+ stopped: !live,
331
+ removed: !live,
332
+ stale: !live,
333
+ live,
334
+ invalid: false,
335
+ parseError: '',
336
+ };
337
+ };
@@ -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.8",
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) => {