-
Notifications
You must be signed in to change notification settings - Fork 7
/
index.ts
388 lines (330 loc) · 16.6 KB
/
index.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
import { dirname } from 'node:path';
import fs from 'node:fs';
import { fileURLToPath } from 'url';
import Debug from 'debug';
import {
ConfigServerClient, injectDmnoGlobals,
} from 'dmno';
import type { AstroIntegration } from 'astro';
const debug = Debug('dmno:astro-integration');
debug('Loaded DMNO astro integration file');
const startLoadAt = new Date();
const __dirname = dirname(fileURLToPath(import.meta.url));
let astroCommand: 'dev' | 'build' | 'preview' | 'sync' | undefined;
let dmnoHasTriggeredReload = false;
let enableDynamicPublicClientLoading = false;
let configItemKeysAccessed: Record<string, boolean> = {};
let dmnoConfigValid = true;
let dmnoConfigClient: ConfigServerClient;
let dmnoInjectionResult: ReturnType<typeof injectDmnoGlobals>;
let ssrOutputDirPath: string;
let ssrInjectConfigAtBuildTime = false;
async function reloadDmnoConfig() {
const injectedEnvExists = globalThis.process?.env.DMNO_INJECTED_ENV;
if (injectedEnvExists && astroCommand !== 'dev') {
debug('using injected dmno config');
dmnoInjectionResult = injectDmnoGlobals();
} else {
debug('using injected dmno config server');
(process as any).dmnoConfigClient ||= new ConfigServerClient();
dmnoConfigClient = (process as any).dmnoConfigClient;
const resolvedService = await dmnoConfigClient.getServiceConfig();
const injectedConfig = resolvedService.injectedEnv;
dmnoConfigValid = resolvedService.serviceDetails.isValid;
configItemKeysAccessed = {};
// shows nicely formatted errors in the terminal
ConfigServerClient.checkServiceIsValid(resolvedService.serviceDetails);
dmnoInjectionResult = injectDmnoGlobals({
injectedConfig,
trackingObject: configItemKeysAccessed,
});
}
// We may want to fetch via the CLI instead - it would be slightly faster during a build
// however we dont know if we are in dev/build mode until later and we do need the config injected right away
// const injectedDmnoEnv = execSync('npm exec -- dmno resolve -f json-injected').toString();
// injectionResult = injectDmnoGlobals({ injectedConfig: JSON.parse(injectedDmnoEnv) });
}
// we run this right away so the globals get injected into the astro.config file
await reloadDmnoConfig();
const loadingTime = +new Date() - +startLoadAt;
debug(`Initial dmno env load completed in ${loadingTime}ms`);
type DmnoAstroIntegrationOptions = {
};
async function prependFile(filePath: string, textToPrepend: string) {
const originalFileContents = await fs.promises.readFile(filePath, 'utf8');
await fs.promises.writeFile(filePath, `${textToPrepend}\n\n${originalFileContents}`);
}
function dmnoAstroIntegration(dmnoIntegrationOpts?: DmnoAstroIntegrationOptions): AstroIntegration {
return {
name: 'dmno-astro-integration',
hooks: {
'astro:config:setup': async (opts) => {
const {
isRestart, logger, addDevToolbarApp, updateConfig,
injectScript, addMiddleware, injectRoute,
} = opts;
astroCommand = opts.command;
// // this handles the case where astro's vite server reloaded but this file did not get reloaded
// // we need to reload if we just found out we are in dev mode - so it will use the config client
if (dmnoHasTriggeredReload) {
await reloadDmnoConfig();
dmnoHasTriggeredReload = false;
}
if (!dmnoConfigValid) {
// if we are runnign a build and config is invalid, we want to just bail
if (opts.command === 'build') {
// throwing an error results in a long useless stack trace, so we just exit
console.error('💥 DMNO config validation failed 💥');
process.exit(1);
} else {
// we'll let the server proceed and trigger the error overlay via HMR
}
}
if (opts.config.output === 'static') {
enableDynamicPublicClientLoading = false;
} else {
enableDynamicPublicClientLoading = dmnoInjectionResult.publicDynamicKeys.length > 0;
}
updateConfig({
vite: {
plugins: [{
name: 'astro-vite-plugin',
async config(config, env) {
debug('Injecting static replacements', dmnoInjectionResult.staticReplacements);
// console.log('vite config hook', config, env);
// inject rollup rewrites via config.define
config.define = {
...config.define,
// always inject public static replacements
...dmnoInjectionResult.staticReplacements.dmnoPublicConfig,
// only inject sensitive static replacements when building SSR code
...config.build?.ssr && dmnoInjectionResult.staticReplacements.dmnoConfig,
};
},
...astroCommand === 'dev' && {
async configureServer(server) {
if (!isRestart && !!dmnoConfigClient) {
debug('initializing dmno reload > astro restart trigger');
dmnoConfigClient.eventBus.on('reload', () => {
opts.logger.info('💫 dmno config updated - restarting astro server');
// eslint-disable-next-line @typescript-eslint/no-floating-promises
server.restart();
dmnoHasTriggeredReload = true;
});
}
// we use an HMR message which triggers the astro error overlay
// we use a middleware so that it will show again if the user reloads the page
if (!dmnoConfigValid) {
server.middlewares.use((req, res, next) => {
server.hot.send({
type: 'error',
err: {
name: 'Invalid DMNO config',
message: 'Your config is currently invalid',
// hint: 'check your terminal for more details',
stack: 'check your terminal for more details',
// docslink: 'https://dmno.dev/docs',
// cause: 'this is a cause',
// loc: {
// file: 'file',
// line: 123,
// column: 456,
// },
// needs to be formatted a specific way
// highlightedCode: 'highlighted code goes here?',
},
});
return next();
});
}
},
},
// leak detection in _built_ files
transform(src, id) {
if (!dmnoInjectionResult.serviceSettings.preventClientLeaks) {
return src;
}
// TODO: can probably add some rules to skip leak detection on files coming from external deps
// skip detection if injected ssr (backend) script
if (id === 'astro:scripts/page-ssr.js') return src;
// TODO: better error details to help user find the problem
(globalThis as any)._dmnoLeakScan(src, { method: 'astro vite plugin', file: id });
return src;
},
}],
},
});
// injectScript('page-ssr', [
// 'console.log(\'PAGE-SSR-INJECTED SCRIPT\');',
// ].join('\n'));
// inject script into CLIENT context
injectScript('page', [
// client side DMNO_PUBLIC_CONFIG proxy object
// TODO: ideally we can throw a better error if we know its a dynamic item and we aren't loading dynamic stuff
`
window._DMNO_PUBLIC_STATIC_CONFIG = window.DMNO_PUBLIC_CONFIG || {};
window.DMNO_PUBLIC_CONFIG = new Proxy({}, {
get(o, key) {
if (key in window._DMNO_PUBLIC_STATIC_CONFIG) {
return window._DMNO_PUBLIC_STATIC_CONFIG[key];
}
`,
// if dynamic public config is enabled, we'll fetch it on-demand
// this is fine because we only hit this block if the rewrite failed
// (or wasnt found in the static vars during dev)
enableDynamicPublicClientLoading ? `
if (!window._DMNO_PUBLIC_DYNAMIC_CONFIG) {
const request = new XMLHttpRequest();
request.open("GET", "/public-dynamic-config.json", false); // false means sync/blocking!
request.send(null);
if (request.status !== 200) {
throw new Error('Failed to load public dynamic DMNO config');
}
window._DMNO_PUBLIC_DYNAMIC_CONFIG = JSON.parse(request.responseText);
console.log('loaded public dynamic config', window._DMNO_PUBLIC_DYNAMIC_CONFIG);
}
if (key in window._DMNO_PUBLIC_DYNAMIC_CONFIG) {
return window._DMNO_PUBLIC_DYNAMIC_CONFIG[key];
}
` : `
if (${JSON.stringify(dmnoInjectionResult.publicDynamicKeys)}.includes(key)) {
throw new Error(\`❌ Unable to access dynamic config item \\\`\${key}\\\` in Astro "static" output mode\`);
}
`,
// in dev mode, we'll give a more detailed error message, letting the user know if they tried to access a sensitive or non-existant item
astroCommand === 'dev' ? `
if (${JSON.stringify(dmnoInjectionResult.sensitiveKeys)}.includes(key)) {
throw new Error(\`❌ \\\`DMNO_PUBLIC_CONFIG.\${key}\\\` not found - it is sensitive and must be accessed via DMNO_CONFIG on the server only\`);
} else {
throw new Error(\`❌ \\\`DMNO_PUBLIC_CONFIG.\${key}\\\` not found - it does not exist in your config schema\`);
}
` : `
throw new Error(\`❌ \\\`DMNO_PUBLIC_CONFIG.\${key}\\\` not found - it may be sensitive or it may not exist at all\`);
`,
`
}
});
`,
// DMNO_CONFIG proxy object just to give a helpful error message
// TODO: we could make this a warning instead? because it does get replaced during the build and doesn't actually harm anything
`
window.DMNO_CONFIG = new Proxy({}, {
get(o, key) {
throw new Error(\`❌ You cannot access DMNO_CONFIG on the client, try DMNO_PUBLIC_CONFIG.\${key} instead \`);
}
});
`,
].join('\n'));
if (enableDynamicPublicClientLoading) {
injectRoute({
pattern: 'public-dynamic-config.json',
// Use relative path syntax for a local route.
entrypoint: `${__dirname}/fetch-public-dynamic-config.json.js`,
});
}
if (dmnoInjectionResult.serviceSettings.preventClientLeaks) {
// add leak detection middleware!
addMiddleware({
entrypoint: `${__dirname}/astro-middleware.js`,
order: 'post', // not positive on this?
});
}
// enable the toolbar (currently does nothing...)
addDevToolbarApp(`${__dirname}/dev-toolbar-app.js`);
},
'astro:config:done': async (opts) => {
ssrOutputDirPath = opts.config.build.server.pathname;
// currently we only trigger this behaviour for the netlify adapter, but we may also enable it via an explicit option
ssrInjectConfigAtBuildTime = [
'@astrojs/netlify',
'@astrojs/vercel/serverless',
].includes(opts.config.adapter?.name || '');
},
'astro:build:ssr': async (opts) => {
// console.log('build:ssr', opts);
if (!ssrOutputDirPath) throw new Error('Did not set ssr output path');
// For the netlify adapter (and posibly others in the future), we need to inject the resolved config at build time
// because we don't have control over how the server side code is run (ie cannot use `dmno run`)
// also the nature of how functions and edge functions are run on lamdbas and deno
// mean we need some extra calls to re-patch globals for log redaction and http interception
if (ssrInjectConfigAtBuildTime) {
// first we'll create a new file which includes the code that injects dmno globals and other global patching behaviour
// but we'll add the actual resolved config values so we dont need to inject them on boot
const standaloneInjectorPath = fileURLToPath(import.meta.resolve('dmno/injector-standalone'));
const injectorSrc = await fs.promises.readFile(standaloneInjectorPath, 'utf8');
const builtSsrInjectorPath = `${ssrOutputDirPath}inject-dmno-config.mjs`;
await fs.promises.writeFile(
builtSsrInjectorPath,
[
injectorSrc,
'// INJECTED BY @dmno/astro-integration -----',
'if (!globalThis._injectDmnoGlobals) {',
' globalThis._injectDmnoGlobals = injectDmnoGlobals;',
` injectDmnoGlobals({ injectedConfig: ${JSON.stringify(dmnoInjectionResult.injectedDmnoEnv)} });`,
'}',
].join('\n'),
);
for (const entryModuleKey in opts.manifest.entryModules) {
// console.log('entry module - ', entryModuleKey);
const entryPath = opts.manifest.entryModules[entryModuleKey];
if (!entryPath) continue;
const fullEntryPath = `${ssrOutputDirPath}${entryPath}`;
try {
await prependFile(fullEntryPath, [
// main entry needs the dmno config import
[
'\0@astrojs-ssr-virtual-entry',
'\0astro-internal:middleware',
].includes(entryModuleKey) ? "import './inject-dmno-config.mjs';" : '',
'',
// every other entry file needs to re-call the injector
// ideally we wouldnt need this, but it is needed with the way the lambdas are set up
// (we also skip a few internal files)
[
'\0@astro-renderers',
'\0@astrojs-manifest',
].includes(entryModuleKey) ? '' : `
try { globalThis._injectDmnoGlobals(); }
catch (err) { console.log('error injecting globals', err); }
`,
].join('\n'));
} catch (err) {
// manifest file is in the list but does not exist
}
}
if (opts.middlewareEntryPoint) {
const middlewareEntryPath = fileURLToPath(opts.middlewareEntryPoint);
await prependFile(middlewareEntryPath, [
"import './inject-dmno-config.mjs';",
].join('\n'));
}
// when building for the node adapter, we only need to inject importing the globals injector and trigger it once
} else if (opts.manifest.entryModules['\x00@astrojs-ssr-virtual-entry']) {
const entryPath = ssrOutputDirPath + opts.manifest.entryModules['\x00@astrojs-ssr-virtual-entry'];
await prependFile(entryPath, "import 'dmno/auto-inject-globals';");
}
// when building a static build, we dont need to do anything, since we've already injected it in the process
},
'astro:build:done': async (opts) => {
// if we didn't actually pre-render any pages, we can move one
// (this would be the case in output=server mode with no `prerender` pages
if (!opts.pages.length) return;
// otherwise, we want to check which config was used during prerendering
// so if any were expected to be dyanmic (ie loaded at boot time) we can throw/warn
// TODO: currently we're just showing a warning, may want to throw? have more settings?
const dynamicKeysUsedDuringPrerender = Object.keys(configItemKeysAccessed)
.filter((k) => dmnoInjectionResult.dynamicKeys.includes(k));
if (dynamicKeysUsedDuringPrerender.length) {
opts.logger.warn('Dynamic config items were accessed during pre-render:');
dynamicKeysUsedDuringPrerender.forEach((k) => {
opts.logger.warn(`- ${k}`);
});
opts.logger.warn('> Change service\'s default behavior by adjusting `settings.dynamicConfig`');
opts.logger.warn('> Or adjust individual items to `{ "dynamic": "false" }` to make them static');
opts.logger.warn('> See https://dmno.dev/docs/guides/dynamic-config/ for more info');
}
},
},
};
}
export default dmnoAstroIntegration;