Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JavascriptTranslationMiddleware, added a solution to make caching stable #167

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public interface IJavascriptTranslationMiddlewareConfiguration
string CompiledFolder { get; }
Func<HttpContext, FileHandle, Task<bool>> ShouldTranslateAsync { get; }
Func<HttpContext, Task<bool>> EnableCacheAsync { get; }
VersionedFileRedirectConfig? VersionedFileRedirectConfig { get; }
}

internal class JavascriptTranslationMiddlewareConfiguration : IJavascriptTranslationMiddlewareConfiguration
Expand All @@ -18,6 +19,7 @@ internal class JavascriptTranslationMiddlewareConfiguration : IJavascriptTransla
public string CompiledFolder { get; }
public Func<HttpContext, FileHandle, Task<bool>> ShouldTranslateAsync { get; set; } = (_, _) => Task.FromResult(true);
public Func<HttpContext, Task<bool>> EnableCacheAsync { get; set; } = _ => Task.FromResult(true);
public VersionedFileRedirectConfig? VersionedFileRedirectConfig { get; set; }

public JavascriptTranslationMiddlewareConfiguration(PathString[] requestPathPrefixes, string compiledFolder)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,25 +7,14 @@ namespace MN.L10n.JavascriptTranslationMiddleware
{
public interface IJavascriptTranslationMiddlewareConfigurator
{
/// <summary>
/// Add a path prefix for which the plugin should run. For example if you add /plugin the plugin will run for all requests starting with /plugin
/// </summary>
/// <param name="prefix"></param>
/// <returns></returns>
IJavascriptTranslationMiddlewareConfigurator AddPathPrefix(PathString prefix);
/// <summary>
/// Use to set when the translations should be cached, by default it always is
/// </summary>
/// <param name="predicate"></param>
/// <returns></returns>
IJavascriptTranslationMiddlewareConfigurator EnableCacheWhen(Func<HttpContext, Task<bool>> predicate);

/// <summary>
/// Use to configure when the translationmiddleware should translate JS files. By default it always does.
/// </summary>
/// <param name="predicate"></param>
/// <returns></returns>
IJavascriptTranslationMiddlewareConfigurator TranslateWhen(Func<HttpContext, FileHandle, Task<bool>>? predicate);
IJavascriptTranslationMiddlewareConfigurator TranslateWhen(
Func<HttpContext, FileHandle, Task<bool>>? predicate);

IJavascriptTranslationMiddlewareConfigurator EnableVersionedFileRedirect(Action<VersionedFileRedirectConfig>? configure = null);
IJavascriptTranslationMiddlewareConfigurator DisableVersionedFileRedirect();
}

internal class JavascriptTranslationMiddlewareConfigurator : IJavascriptTranslationMiddlewareConfigurator
Expand All @@ -34,6 +23,7 @@ internal class JavascriptTranslationMiddlewareConfigurator : IJavascriptTranslat
private readonly string _compiledFolder;
private Func<HttpContext, FileHandle, Task<bool>>? _shouldTranslateAsync;
private Func<HttpContext, Task<bool>>? _shouldEnableCacheAsync;
private VersionedFileRedirectConfig? _versionedFileRedirectConfig = new();

public JavascriptTranslationMiddlewareConfigurator(string compiledFolder)
{
Expand All @@ -57,6 +47,19 @@ public IJavascriptTranslationMiddlewareConfigurator TranslateWhen(
_shouldTranslateAsync = predicate;
return this;
}

public IJavascriptTranslationMiddlewareConfigurator EnableVersionedFileRedirect(Action<VersionedFileRedirectConfig>? configure = null)
{
_versionedFileRedirectConfig = new VersionedFileRedirectConfig();
configure?.Invoke(_versionedFileRedirectConfig);
return this;
}

public IJavascriptTranslationMiddlewareConfigurator DisableVersionedFileRedirect()
{
_versionedFileRedirectConfig = null;
return this;
}

public IJavascriptTranslationMiddlewareConfiguration Build()
{
Expand All @@ -67,7 +70,10 @@ public IJavascriptTranslationMiddlewareConfiguration Build()
$"At least one pathPrefix must be provided, plase call {nameof(AddPathPrefix)} while configuring the {nameof(JavascriptTranslationMiddleware)}");

var config =
new JavascriptTranslationMiddlewareConfiguration(_pathPrefixes.ToArray(), _compiledFolder.Trim());
new JavascriptTranslationMiddlewareConfiguration(_pathPrefixes.ToArray(), _compiledFolder.Trim())
{
VersionedFileRedirectConfig = _versionedFileRedirectConfig
};
if (_shouldTranslateAsync is not null) config.ShouldTranslateAsync = _shouldTranslateAsync;

if (_shouldEnableCacheAsync is not null) config.EnableCacheAsync = _shouldEnableCacheAsync;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System;
using Microsoft.AspNetCore.Http;

namespace MN.L10n.JavascriptTranslationMiddleware;

public class VersionedFileRedirectConfig
{
public int? MaxAge { get; set; } = 60 * 30;
public int? StaleWhileRevalidate { get; set; } = 60 * 60 * 24 * 7;
/// <summary>
/// Allows the user to override the Cache-Control max-age by using a query parameter on the referer level.
/// Intended for debugging.
/// </summary>
public string? RefererMaxAgeOverrideParameterName = "_l10ntsMaxAge";
public Action<HttpContext>? OnRedirect { get; set; }
}
79 changes: 73 additions & 6 deletions MN.L10n.JavascriptTranslationMiddleware/FileTranslator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -17,9 +18,12 @@ public class FileTranslator
private readonly IFileHandle _fileHandle;
private readonly Lazy<TranslatedFileInformation> _lazyFileInformation;
private readonly SemaphoreSlim _translateSemaphor = new(1);
private readonly SemaphoreSlim _fileVersionSemaphore = new(1);
private readonly ILogger _logger;
private readonly bool _enableFileVersionGeneration;
private string? _computedFileVersion;

public FileTranslator(IJavascriptTranslationL10nLanguageProvider languageProvider, IFileHandle fileHandle, string languageId, ILogger<FileTranslator> logger)
public FileTranslator(IJavascriptTranslationL10nLanguageProvider languageProvider, IFileHandle fileHandle, string languageId, ILogger<FileTranslator> logger, bool enableFileVersionGeneration)
{
_languageId = languageId;
_logger = logger;
Expand All @@ -28,14 +32,15 @@ public FileTranslator(IJavascriptTranslationL10nLanguageProvider languageProvide
_lazyFileInformation = new Lazy<TranslatedFileInformation>(GetFileInformation);
L10n.TranslationsReloaded += L10nOnTranslationsReloaded;
_logger = logger;
_enableFileVersionGeneration = enableFileVersionGeneration;
}

private void L10nOnTranslationsReloaded(object? sender, EventArgs e)
{
HandleTranslationsChangedAsync().GetAwaiter();
}

public async Task<TranslatedFileInformation> TranslateFile(bool reuseExisting)
public async Task<TranslationResult> TranslateFile(bool reuseExisting)
{
var fileInformation = _lazyFileInformation.Value;
await _translateSemaphor.WaitAsync();
Expand All @@ -45,18 +50,63 @@ public async Task<TranslatedFileInformation> TranslateFile(bool reuseExisting)
if (File.Exists(fileInformation.FilePath) && reuseExisting)
{
_logger.LogTrace("File already exists, reusing it");
return fileInformation;
return new TranslationResult
{
FileInformation = fileInformation,
FileVersion = await GetFileVersionAsync(fileInformation)
};
}

await TranslateAndWriteTranslatedFileAsync(fileInformation);
return fileInformation;
return new TranslationResult
{
FileInformation = fileInformation,
FileVersion = await GetFileVersionAsync(fileInformation)
};
}
finally
{
_translateSemaphor.Release();
}
}

private async Task<string?> GetFileVersionAsync(TranslatedFileInformation fileInformation)
{
if (!_enableFileVersionGeneration)
{
return null;
}

if (_computedFileVersion != null)
{
return _computedFileVersion;
}

try
{
await _fileVersionSemaphore.WaitAsync();
if (_computedFileVersion != null)
{
return _computedFileVersion;
}

await using var stream = File.OpenRead(fileInformation.FilePath);
return await ComputeFileVersionFromStreamAsync(stream);
}
finally
{
_fileVersionSemaphore.Release();
}
}

private async Task<string> ComputeFileVersionFromStreamAsync(Stream stream)
{
using var md5 = MD5.Create();
var hash = await md5.ComputeHashAsync(stream);
var fileVersion = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
return _computedFileVersion = fileVersion;
}

private static string? GetEscapedTranslation(string? translation, char stringContainer)
{
if (translation == null)
Expand Down Expand Up @@ -123,24 +173,35 @@ private async Task HandleTranslationsChangedAsync()
{
_logger.LogTrace("Updating translated file because of translations change");
await _translateSemaphor.WaitAsync();
await _fileVersionSemaphore.WaitAsync();
try
{
await TranslateAndWriteTranslatedFileAsync(_lazyFileInformation.Value);
var translatedContents = await TranslateAndWriteTranslatedFileAsync(_lazyFileInformation.Value);
if (_enableFileVersionGeneration)
{
await ComputeFileVersionFromStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes(translatedContents)));
}
else
{
_computedFileVersion = null;
}
}
finally
{
_translateSemaphor.Release();
_fileVersionSemaphore.Release();
}
}

private async Task TranslateAndWriteTranslatedFileAsync(TranslatedFileInformation fileInfo)
private async Task<string> TranslateAndWriteTranslatedFileAsync(TranslatedFileInformation fileInfo)
{
var contents = await _fileHandle.GetFileContentsAsync();
_logger.LogTrace("Translating file contents");
var translatedContents = TranslateFileContents(contents);
await using var fileWriter = File.CreateText(fileInfo.FilePath);
_logger.LogTrace($"Writing translated contents to disk at {fileInfo.FilePath}");
await fileWriter.WriteAsync(translatedContents);
return translatedContents;
}

private TranslatedFileInformation GetFileInformation()
Expand All @@ -165,6 +226,12 @@ private TranslatedFileInformation GetFileInformation()
}
}

public class TranslationResult
{
public string? FileVersion { get; set; }
public required TranslatedFileInformation FileInformation { get; set; }
}

public class TranslatedFileInformation
{
public string FilePath { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,29 @@ public interface ITranslatorProvider
{
FileTranslator GetOrCreateTranslator(string languageId, IFileHandle fileHandle);
}

public class FileTranslatorProvider : ITranslatorProvider
{
private readonly ConcurrentDictionary<string, FileTranslator> _fileProviders = new();
private readonly IJavascriptTranslationL10nLanguageProvider _l10NLanguageProvider;
private readonly ILogger<FileTranslator> _logger;
private readonly IJavascriptTranslationMiddlewareConfiguration _configuration;

public FileTranslatorProvider(IJavascriptTranslationL10nLanguageProvider l10NLanguageProvider, ILogger<FileTranslator> logger)
public FileTranslatorProvider(IJavascriptTranslationL10nLanguageProvider l10NLanguageProvider,
ILogger<FileTranslator> logger, IJavascriptTranslationMiddlewareConfiguration configuration)
{
_l10NLanguageProvider = l10NLanguageProvider;
_logger = logger;
_configuration = configuration;
}

public FileTranslator GetOrCreateTranslator(string languageId, IFileHandle fileHandle)
{
var fileProviderId = $"{languageId}__{fileHandle.RelativeRequestPath}";

return _fileProviders.GetOrAdd(fileProviderId, _ => new FileTranslator(_l10NLanguageProvider, fileHandle, languageId, _logger));

return _fileProviders.GetOrAdd(fileProviderId,
_ => new FileTranslator(_l10NLanguageProvider, fileHandle, languageId, _logger,
enableFileVersionGeneration: _configuration.VersionedFileRedirectConfig != null));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,26 @@ public JavascriptTranslationMiddleware(IJavascriptTranslationMiddlewareConfigura
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
_logger.LogTrace($"Begin invoke at {context.Request.Path}");
await ProcessRequest(context);
await next(context);
var requestCompleted = await ProcessRequest(context);
if (!requestCompleted)
{
await next(context);
}

_logger.LogTrace("End invoke");
}

private async Task ProcessRequest(HttpContext context)
private async Task<bool> ProcessRequest(HttpContext context)
{
if (!TryGetRewriteContext(context, out var rewriteContext))
{
return;
return false;
}

var remainingParts = rewriteContext.Remaining.Value!.Split('/', StringSplitOptions.RemoveEmptyEntries);
if (remainingParts.Length == 0)
{
return;
return false;
}

var languageId = remainingParts[0];
Expand All @@ -53,7 +57,7 @@ private async Task ProcessRequest(HttpContext context)
if (!fileHandle.Exists)
{
_logger.LogDebug($"Unable to resolve file at {fileHandle.Path}");
return;
return false;
}

var isSupportedFileType = fileHandle.FileName.EndsWith(".js", StringComparison.InvariantCultureIgnoreCase);
Expand All @@ -76,19 +80,59 @@ private async Task ProcessRequest(HttpContext context)
var path = string.Join('/', rewriteContext.MatchingSegment, diskPath);
_logger.LogTrace($"Updated path to be {path}");
context.Request.Path = path;
return;
return false;
}

var translator = _translatorProvider.GetOrCreateTranslator(languageId, fileHandle);
var enableCache = await _config.EnableCacheAsync(context);
_logger.LogTrace($"Translating file at {fileHandle.Path}, {(enableCache ? "using cache" : "not using cache")}");
var translatedFileInformation = await translator.TranslateFile(enableCache);
var result = await translator.TranslateFile(enableCache);
var translatedFileInformation = result.FileInformation;
var versionedRedirectConfig = _config.VersionedFileRedirectConfig;
if (versionedRedirectConfig != null && result.FileVersion != null)
{
var requestedVersion = context.Request.Query["v"];
if (requestedVersion != result.FileVersion)
{
context.Response.StatusCode = 302;
var redirectTo = $"{context.Request.Path}?v={result.FileVersion}";
context.Response.Headers["Location"] = redirectTo;
var maxAge = versionedRedirectConfig.MaxAge;
var referer = context.Request.Headers.Referer.ToString();
if (!string.IsNullOrWhiteSpace(versionedRedirectConfig.RefererMaxAgeOverrideParameterName) &&
!string.IsNullOrWhiteSpace(referer) && referer.Contains(versionedRedirectConfig.RefererMaxAgeOverrideParameterName))
{
var refererMaxAge = referer.Split($"{versionedRedirectConfig.RefererMaxAgeOverrideParameterName}=")[1].Split("&")[0];
if (int.TryParse(refererMaxAge, out var refererMaxAgeParsed))
{
maxAge = refererMaxAgeParsed;
}
}

if (maxAge >= 0)
{
var cacheControlHeader = $"public, max-age={maxAge}";
if (versionedRedirectConfig.StaleWhileRevalidate >= 0)
{
cacheControlHeader += $", stale-while-revalidate={versionedRedirectConfig.StaleWhileRevalidate}";
}
context.Response.Headers.CacheControl = cacheControlHeader;
}

versionedRedirectConfig.OnRedirect?.Invoke(context);

_logger.LogTrace("Redirecting to {location}", redirectTo);
await context.Response.CompleteAsync();
return true;
}
}
_logger.LogTrace($"Translated file saved at {translatedFileInformation.FilePath}");

var newPath = string.Join('/', rewriteContext.MatchingSegment,
translatedFileInformation.RelativeRequestPath);
context.Request.Path = newPath;
_logger.LogTrace($"Updated path to be {newPath}");
return false;
}

private bool TryGetRewriteContext(HttpContext context, [NotNullWhen(true)] out RewriteContext? rewriteContext)
Expand Down
Loading