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.
- package/cli/commands/dev.ts +11 -2
- package/cli/compiler/artifacts/manifest.ts +8 -3
- package/cli/scaffold/index.ts +4 -2
- package/cli/scaffold/templates.ts +7 -1
- package/cli/utils/agents.ts +102 -11
- package/package.json +1 -1
- package/server/app/container/index.ts +7 -1
- package/server/services/router/http/index.ts +52 -24
- package/server/services/router/index.ts +66 -10
- package/server/services/router/response/page/document.tsx +26 -14
package/cli/commands/dev.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
251
|
+
message: `Add "${requiredEntry}" to .gitignore so Proteum-managed paths stay untracked.`,
|
|
247
252
|
filepath: gitignoreFilepath,
|
|
248
253
|
});
|
|
249
254
|
}
|
package/cli/scaffold/index.ts
CHANGED
|
@@ -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 = (
|
|
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
|
package/cli/utils/agents.ts
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
165
|
-
throw
|
|
166
|
-
|
|
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,
|
|
333
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
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,
|
|
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,
|
|
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=
|
|
91
|
-
<link
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
}
|