- Delete old Vite+Svelte frontend - Initialize new SvelteKit project with TypeScript - Configure Tailwind CSS v4 + DaisyUI - Implement JWT authentication with auto-refresh - Create login page with form validation (Zod) - Add protected route guards - Update Docker configuration for single-stage build - Add E2E tests with Playwright (6/11 passing) - Fix Svelte 5 reactivity with $state() runes Known issues: - 5 E2E tests failing (timing/async issues) - Token refresh implementation needs debugging - Validation error display timing
398 lines
17 KiB
JavaScript
398 lines
17 KiB
JavaScript
import { ReferenceTracker, getStaticValue } from '@eslint-community/eslint-utils';
|
|
import { createRule } from '../utils/index.js';
|
|
import globals from 'globals';
|
|
import { findVariable, getScope } from '../utils/ast-utils.js';
|
|
export default createRule('no-top-level-browser-globals', {
|
|
meta: {
|
|
docs: {
|
|
description: 'disallow using top-level browser global variables',
|
|
category: 'Possible Errors',
|
|
recommended: false
|
|
},
|
|
schema: [],
|
|
messages: {
|
|
unexpectedGlobal: 'Unexpected top-level browser global variable "{{name}}".'
|
|
},
|
|
type: 'problem',
|
|
conditions: [{ svelteFileTypes: ['.svelte', '.svelte.[js|ts]'] }]
|
|
},
|
|
create(context) {
|
|
const sourceCode = context.sourceCode;
|
|
const blowerGlobals = getBrowserGlobals();
|
|
const referenceTracker = new ReferenceTracker(sourceCode.scopeManager.globalScope, {
|
|
// Specifies the global variables that are allowed to prevent `window.window` from being iterated over.
|
|
globalObjectNames: ['globalThis']
|
|
});
|
|
const maybeGuards = [];
|
|
const functions = [];
|
|
const typeAnnotations = [];
|
|
function enterFunction(node) {
|
|
if (isTopLevelLocation(node)) {
|
|
functions.push(node);
|
|
}
|
|
}
|
|
function enterTypeAnnotation(node) {
|
|
if (!isInTypeAnnotation(node)) {
|
|
typeAnnotations.push(node);
|
|
}
|
|
}
|
|
function enterMetaProperty(node) {
|
|
if (node.meta.name !== 'import' || node.property.name !== 'meta')
|
|
return;
|
|
for (const ref of referenceTracker.iteratePropertyReferences(node, {
|
|
env: {
|
|
// See https://vite.dev/guide/ssr#conditional-logic
|
|
SSR: {
|
|
[ReferenceTracker.READ]: true
|
|
}
|
|
}
|
|
})) {
|
|
if (ref.node.type === 'Identifier' || ref.node.type === 'MemberExpression') {
|
|
const guardChecker = getGuardChecker({ node: ref.node, not: true });
|
|
if (guardChecker) {
|
|
maybeGuards.push({
|
|
isAvailableLocation: guardChecker,
|
|
browserEnvironment: true
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
function verifyGlobalReferences() {
|
|
// Collects guarded location checkers by checking module references
|
|
// that can check the browser environment.
|
|
for (const referenceNode of iterateBrowserCheckerModuleReferences()) {
|
|
if (!isTopLevelLocation(referenceNode))
|
|
continue;
|
|
const guardChecker = getGuardChecker({ node: referenceNode });
|
|
if (guardChecker) {
|
|
maybeGuards.push({
|
|
isAvailableLocation: guardChecker,
|
|
browserEnvironment: true
|
|
});
|
|
}
|
|
}
|
|
const reportCandidates = [];
|
|
// Collects references to global variables.
|
|
for (const ref of iterateBrowserGlobalReferences()) {
|
|
if (!isTopLevelLocation(ref.node) || isInTypeAnnotation(ref.node))
|
|
continue;
|
|
const guardChecker = getGuardCheckerFromReference(ref.node);
|
|
if (guardChecker) {
|
|
const name = ref.path.join('.');
|
|
maybeGuards.push({
|
|
reference: { node: ref.node, name },
|
|
isAvailableLocation: guardChecker,
|
|
browserEnvironment: name === 'window' || name === 'document'
|
|
});
|
|
}
|
|
else {
|
|
reportCandidates.push(ref);
|
|
}
|
|
}
|
|
for (const ref of reportCandidates) {
|
|
const name = ref.path.join('.');
|
|
if (isAvailableLocation({ node: ref.node, name })) {
|
|
continue;
|
|
}
|
|
context.report({
|
|
node: ref.node,
|
|
messageId: 'unexpectedGlobal',
|
|
data: { name }
|
|
});
|
|
}
|
|
}
|
|
return {
|
|
':function': enterFunction,
|
|
SvelteSnippetBlock: enterFunction,
|
|
'*.typeAnnotation': enterTypeAnnotation,
|
|
MetaProperty: enterMetaProperty,
|
|
'Program:exit': verifyGlobalReferences
|
|
};
|
|
/**
|
|
* Checks whether the node is in a location where the expression is available or not.
|
|
* @returns `true` if the expression is available.
|
|
*/
|
|
function isAvailableLocation(ref) {
|
|
for (const guard of maybeGuards.reverse()) {
|
|
if (guard.isAvailableLocation(ref.node)) {
|
|
if (guard.browserEnvironment || guard.reference?.name === ref.name) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Checks whether the node is in a top-level location.
|
|
* @returns `true` if the node is in a top-level location.
|
|
*/
|
|
function isTopLevelLocation(node) {
|
|
for (const func of functions) {
|
|
if (func.range[0] <= node.range[0] && node.range[1] <= func.range[1]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
/**
|
|
* Checks whether the node is in type annotation.
|
|
* @returns `true` if the node is in type annotation.
|
|
*/
|
|
function isInTypeAnnotation(node) {
|
|
for (const typeAnnotation of typeAnnotations) {
|
|
if (typeAnnotation.range[0] <= node.range[0] && node.range[1] <= typeAnnotation.range[1]) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Iterate over the references of modules that can check the browser environment.
|
|
*/
|
|
function* iterateBrowserCheckerModuleReferences() {
|
|
for (const ref of referenceTracker.iterateEsmReferences({
|
|
'esm-env': {
|
|
[ReferenceTracker.ESM]: true,
|
|
// See https://www.npmjs.com/package/esm-env
|
|
BROWSER: {
|
|
[ReferenceTracker.READ]: true
|
|
}
|
|
},
|
|
'$app/environment': {
|
|
[ReferenceTracker.ESM]: true,
|
|
// See https://svelte.dev/docs/kit/$app-environment#browser
|
|
browser: {
|
|
[ReferenceTracker.READ]: true
|
|
}
|
|
}
|
|
})) {
|
|
if (ref.node.type === 'Identifier' || ref.node.type === 'MemberExpression') {
|
|
yield ref.node;
|
|
}
|
|
else if (ref.node.type === 'ImportSpecifier') {
|
|
const variable = findVariable(context, ref.node.local);
|
|
if (variable) {
|
|
for (const reference of variable.references) {
|
|
if (reference.isRead() && reference.identifier.type === 'Identifier') {
|
|
yield reference.identifier;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
/**
|
|
* Iterate over the used references of global variables.
|
|
*/
|
|
function* iterateBrowserGlobalReferences() {
|
|
yield* referenceTracker.iterateGlobalReferences(Object.fromEntries(blowerGlobals.map((name) => [
|
|
name,
|
|
{
|
|
[ReferenceTracker.READ]: true
|
|
}
|
|
])));
|
|
}
|
|
/**
|
|
* If the node is a reference used in a guard clause that checks if the node is in a browser environment,
|
|
* it returns information about the expression that checks if the browser variable is available.
|
|
* @returns The guard info.
|
|
*/
|
|
function getGuardCheckerFromReference(node) {
|
|
const parent = node.parent;
|
|
if (!parent)
|
|
return null;
|
|
if (parent.type === 'BinaryExpression') {
|
|
if (parent.operator === 'instanceof' &&
|
|
parent.left === node &&
|
|
node.type === 'MemberExpression') {
|
|
// e.g. if (globalThis.window instanceof X)
|
|
return getGuardChecker({ node: parent });
|
|
}
|
|
const operand = parent.left === node ? parent.right : parent.right === node ? parent.left : null;
|
|
if (!operand)
|
|
return null;
|
|
const staticValue = getStaticValue(operand, getScope(context, operand));
|
|
if (!staticValue)
|
|
return null;
|
|
if (staticValue.value === undefined && node.type === 'MemberExpression') {
|
|
if (parent.operator === '!==' || parent.operator === '!=') {
|
|
// e.g. if (globalThis.window !== undefined), if (globalThis.window != undefined)
|
|
return getGuardChecker({ node: parent });
|
|
}
|
|
else if (parent.operator === '===' || parent.operator === '==') {
|
|
// e.g. if (globalThis.window === undefined), if (globalThis.window == undefined)
|
|
return getGuardChecker({ node: parent, not: true });
|
|
}
|
|
}
|
|
else if (staticValue.value === null && node.type === 'MemberExpression') {
|
|
if (parent.operator === '!=') {
|
|
// e.g. if (globalThis.window != null)
|
|
return getGuardChecker({ node: parent });
|
|
}
|
|
else if (parent.operator === '==') {
|
|
// e.g. if (globalThis.window == null)
|
|
return getGuardChecker({ node: parent, not: true });
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
if (parent.type === 'UnaryExpression' &&
|
|
parent.operator === 'typeof' &&
|
|
parent.argument === node) {
|
|
const pp = parent.parent;
|
|
if (!pp || pp.type !== 'BinaryExpression') {
|
|
return null;
|
|
}
|
|
const staticValue = getStaticValue(pp.left === parent ? pp.right : pp.left, getScope(context, node));
|
|
if (!staticValue)
|
|
return null;
|
|
if (staticValue.value !== 'undefined' && staticValue.value !== 'object') {
|
|
return null;
|
|
}
|
|
if (pp.operator === '!==' || pp.operator === '!=') {
|
|
if (staticValue.value === 'undefined') {
|
|
// e.g. if (typeof window !== "undefined"), if (typeof window != "undefined")
|
|
return getGuardChecker({ node: pp });
|
|
}
|
|
// e.g. if (typeof window !== "object"), if (typeof window != "object")
|
|
return getGuardChecker({ node: pp, not: true });
|
|
}
|
|
else if (pp.operator === '===' || pp.operator === '==') {
|
|
if (staticValue.value === 'undefined') {
|
|
// e.g. if (typeof window === "undefined"), if (typeof window == "undefined")
|
|
return getGuardChecker({ node: pp, not: true });
|
|
}
|
|
// e.g. if (typeof window === "object"), if (typeof window == "object")
|
|
return getGuardChecker({ node: pp });
|
|
}
|
|
return null;
|
|
}
|
|
if (node.type === 'MemberExpression') {
|
|
if (((parent.type === 'CallExpression' && parent.callee === node) ||
|
|
(parent.type === 'MemberExpression' && parent.object === node)) &&
|
|
parent.optional) {
|
|
// e.g. globalThis.location?.href
|
|
return (n) => n === node;
|
|
}
|
|
// e.g. if (globalThis.window)
|
|
return getGuardChecker({ node });
|
|
}
|
|
return null;
|
|
}
|
|
/**
|
|
* If the node is a guard clause checking,
|
|
* returns a function to check if the node is available.
|
|
*/
|
|
function getGuardChecker(guardInfo) {
|
|
const parent = guardInfo.node.parent;
|
|
if (!parent)
|
|
return null;
|
|
if (parent.type === 'ConditionalExpression') {
|
|
const block = guardInfo.not ? parent.alternate : parent.consequent;
|
|
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
|
|
}
|
|
if (parent.type === 'UnaryExpression' && parent.operator === '!') {
|
|
return getGuardChecker({ not: !guardInfo.not, node: parent });
|
|
}
|
|
if (parent.type === 'SvelteIfBlock' && parent.expression === guardInfo.node) {
|
|
if (!guardInfo.not) {
|
|
if (parent.children.length === 0) {
|
|
return null; // No block to check
|
|
}
|
|
const first = parent.children[0];
|
|
const last = parent.children.at(-1);
|
|
return (n) => first.range[0] <= n.range[0] && n.range[1] <= last.range[1];
|
|
}
|
|
// not
|
|
if (parent.else) {
|
|
const block = parent.else;
|
|
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
|
|
}
|
|
return null;
|
|
}
|
|
if (parent.type === 'IfStatement' && parent.test === guardInfo.node) {
|
|
if (!guardInfo.not) {
|
|
const block = parent.consequent;
|
|
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
|
|
}
|
|
if (parent.alternate) {
|
|
const block = parent.alternate;
|
|
return (n) => block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
|
|
}
|
|
if (!hasJumpStatementInAllPath(parent.consequent)) {
|
|
return null;
|
|
}
|
|
const pp = parent.parent;
|
|
if (!pp || (pp.type !== 'BlockStatement' && pp.type !== 'Program')) {
|
|
return null;
|
|
}
|
|
const start = parent.range[1];
|
|
const end = pp.range[1];
|
|
return (n) => start <= n.range[0] && n.range[1] <= end;
|
|
}
|
|
if (parent.type === 'LogicalExpression') {
|
|
if (!guardInfo.not && parent.operator === '&&') {
|
|
const parentChecker = getGuardChecker({ not: guardInfo.not, node: parent });
|
|
if (parent.left === guardInfo.node) {
|
|
const block = parent.right;
|
|
return (n) => {
|
|
if (parentChecker?.(n)) {
|
|
return true;
|
|
}
|
|
return block.range[0] <= n.range[0] && n.range[1] <= block.range[1];
|
|
};
|
|
}
|
|
return parentChecker;
|
|
}
|
|
if (guardInfo.not && parent.operator === '||') {
|
|
return getGuardChecker({ not: guardInfo.not, node: parent });
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
});
|
|
/**
|
|
* Get the list of browser-specific globals.
|
|
*/
|
|
function getBrowserGlobals() {
|
|
const nodeGlobals = new Set(Object.keys(globals.node));
|
|
return [
|
|
'window',
|
|
'document',
|
|
...Object.keys(globals.browser).filter((name) => !nodeGlobals.has(name))
|
|
];
|
|
}
|
|
/**
|
|
* Checks whether all paths of a given statement have jump statements.
|
|
* @param {Statement} statement
|
|
* @returns {boolean}
|
|
*/
|
|
function hasJumpStatementInAllPath(statement) {
|
|
if (isJumpStatement(statement)) {
|
|
return true;
|
|
}
|
|
if (statement.type === 'BlockStatement') {
|
|
return statement.body.some(hasJumpStatementInAllPath);
|
|
}
|
|
if (statement.type === 'IfStatement') {
|
|
if (!statement.alternate) {
|
|
return false;
|
|
}
|
|
return (hasJumpStatementInAllPath(statement.alternate) &&
|
|
hasJumpStatementInAllPath(statement.consequent));
|
|
}
|
|
return false;
|
|
}
|
|
/**
|
|
* Checks whether the given statement is a jump statement.
|
|
* @param {Statement} statement
|
|
* @returns {statement is JumpStatement}
|
|
*/
|
|
function isJumpStatement(statement) {
|
|
return (statement.type === 'ReturnStatement' ||
|
|
statement.type === 'ContinueStatement' ||
|
|
statement.type === 'BreakStatement');
|
|
}
|