-
-
Notifications
You must be signed in to change notification settings - Fork 629
feat: Add automatic license renewal for gem and Node Renderer #2254
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
Changes from all commits
3a0697d
6052e83
8c905f6
1fb7901
b68d448
6fb0fb7
07d000e
464fbb7
0808bad
319c712
18cdfe7
b1b83f7
f2b0f41
2b305e0
58260a7
2ceb06f
7593128
b68b676
ee27a11
9264d46
432304e
4c1846a
f0bb81d
ec43d8f
86fe0cc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,171 @@ | ||
| /** | ||
| * Caches fetched license tokens to disk. | ||
| * Persists across app restarts to reduce API calls. | ||
| * Validates that cached token belongs to the currently configured license_key. | ||
| * | ||
| * @module shared/licenseCache | ||
| */ | ||
|
|
||
| import * as fs from 'fs'; | ||
| import * as path from 'path'; | ||
| import * as crypto from 'crypto'; | ||
|
|
||
| const CACHE_FILENAME = 'react_on_rails_pro_license.cache'; | ||
|
|
||
| export interface CacheData { | ||
| token: string; | ||
| expires_at: string; | ||
| fetched_at: string; | ||
| license_key_hash: string; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the current license key from environment. | ||
| */ | ||
| function getLicenseKey(): string | undefined { | ||
| return process.env.REACT_ON_RAILS_PRO_LICENSE_KEY; | ||
| } | ||
|
|
||
| /** | ||
| * Computes a hash of the license key for validation. | ||
| * Only stores first 16 chars of SHA256 hash. | ||
| */ | ||
| function computeKeyHash(key: string): string { | ||
| return crypto.createHash('sha256').update(key).digest('hex').substring(0, 16); | ||
| } | ||
|
|
||
| /** | ||
| * Gets the current key hash, or null if no key is set. | ||
| */ | ||
| function getCurrentKeyHash(): string | null { | ||
| const key = getLicenseKey(); | ||
| if (!key) { | ||
| return null; | ||
| } | ||
| return computeKeyHash(key); | ||
| } | ||
|
|
||
| /** | ||
| * Gets the cache directory path. | ||
| * Uses tmp/ relative to current working directory. | ||
| */ | ||
| function getCacheDir(): string { | ||
| return path.join(process.cwd(), 'tmp'); | ||
| } | ||
|
Comment on lines
+52
to
+54
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Node renderer is a separate process from Rails and its working directory may not be the Rails application root. Depending on how the renderer is launched, The Ruby gem resolves the cache path as Consider making the cache directory configurable (e.g. |
||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hardcoded The cache is always written to
Consider making the cache path configurable via |
||
| /** | ||
| * Gets the full cache file path. | ||
| */ | ||
| function getCachePath(): string { | ||
| return path.join(getCacheDir(), CACHE_FILENAME); | ||
| } | ||
|
|
||
| /** | ||
| * Validates that the cached data belongs to the current license key. | ||
| */ | ||
| function isValidForCurrentKey(data: CacheData): boolean { | ||
| const storedHash = data.license_key_hash; | ||
| if (!storedHash) { | ||
| return false; | ||
| } | ||
|
|
||
| const currentHash = getCurrentKeyHash(); | ||
| return storedHash === currentHash; | ||
| } | ||
|
|
||
| /** | ||
| * Reads the cached license data from disk. | ||
| * Returns null if cache doesn't exist, is invalid, or belongs to a different license key. | ||
| */ | ||
| export function readCache(): CacheData | null { | ||
| try { | ||
| const cachePath = getCachePath(); | ||
|
|
||
| if (!fs.existsSync(cachePath)) { | ||
| return null; | ||
| } | ||
|
|
||
| const content = fs.readFileSync(cachePath, 'utf8'); | ||
| const data = JSON.parse(content) as CacheData; | ||
|
|
||
| if (!isValidForCurrentKey(data)) { | ||
| return null; | ||
| } | ||
|
|
||
| return data; | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Writes license data to the cache file. | ||
| * Automatically adds license_key_hash and fetched_at. | ||
| */ | ||
| export function writeCache(data: { token: string; expires_at: string }): void { | ||
| try { | ||
| const cacheDir = getCacheDir(); | ||
| const cachePath = getCachePath(); | ||
|
|
||
| // Ensure cache directory exists | ||
| if (!fs.existsSync(cacheDir)) { | ||
| fs.mkdirSync(cacheDir, { recursive: true }); | ||
| } | ||
|
|
||
| const currentKeyHash = getCurrentKeyHash(); | ||
| if (!currentKeyHash) { | ||
| console.warn('[React on Rails Pro] Cannot write cache: no license key configured'); | ||
| return; | ||
| } | ||
|
|
||
| const cacheData: CacheData = { | ||
| token: data.token, | ||
| expires_at: data.expires_at, | ||
| fetched_at: new Date().toISOString(), | ||
| license_key_hash: currentKeyHash, | ||
| }; | ||
|
|
||
| const tempPath = `${cachePath}.${crypto.randomBytes(8).toString('hex')}.tmp`; | ||
| fs.writeFileSync(tempPath, JSON.stringify(cacheData, null, 2), { mode: 0o600 }); | ||
| fs.renameSync(tempPath, cachePath); | ||
| } catch (error) { | ||
| const errorMessage = error instanceof Error ? error.message : 'Unknown error'; | ||
| console.warn(`[React on Rails Pro] Failed to write license cache: ${errorMessage}`); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Gets the cached token, or null if not available. | ||
| */ | ||
| export function getCachedToken(): string | null { | ||
| const data = readCache(); | ||
| return data?.token ?? null; | ||
|
Comment on lines
+140
to
+142
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Performance:
|
||
| } | ||
|
|
||
| /** | ||
| * Gets the fetched_at timestamp from cache. | ||
| * Returns null if cache doesn't exist or is invalid. | ||
| */ | ||
| export function getFetchedAt(): Date | null { | ||
| const data = readCache(); | ||
| if (!data?.fetched_at) { | ||
| return null; | ||
| } | ||
|
|
||
| const parsedDate = new Date(data.fetched_at); | ||
| return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the expires_at timestamp from cache. | ||
| * Returns null if cache doesn't exist or is invalid. | ||
| */ | ||
| export function getExpiresAt(): Date | null { | ||
| const data = readCache(); | ||
| if (!data?.expires_at) { | ||
| return null; | ||
| } | ||
|
|
||
| const parsedDate = new Date(data.expires_at); | ||
| return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cache directory relies on
process.cwd()matchingRails.rootThe Ruby gem writes the cache to
Rails.root.join("tmp"). The Node renderer writes topath.join(process.cwd(), 'tmp'). These are the same only when the Node renderer is started from the Rails root, which is the typical case but not guaranteed (e.g.cd /tmp && node /app/renderer/server.js).If they diverge, the Ruby gem and Node renderer maintain separate cache files, so a token refreshed on the Ruby side won't be available to the Node renderer on the next restart (and vice versa). Consider making the cache path configurable via an environment variable (e.g.
REACT_ON_RAILS_PRO_CACHE_DIR) so both sides can be pointed at the same location explicitly.