Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Official Expo skill for EAS Workflows — CI/CD pipelines for building and deploying Expo apps.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/fetch.js
1#!/usr/bin/env node23import { createHash } from 'node:crypto';4import { readFile, writeFile, mkdir } from 'node:fs/promises';5import { resolve } from 'node:path';6import process from 'node:process';78const CACHE_DIRECTORY = resolve(import.meta.dirname, '.cache');9const DEFAULT_TTL_SECONDS = 15 * 60; // 15 minutes1011export async function fetchCached(url) {12await mkdir(CACHE_DIRECTORY, { recursive: true });1314const cacheFile = resolve(CACHE_DIRECTORY, hashUrl(url) + '.json');15const cached = await loadCacheEntry(cacheFile);16if (cached && cached.expires > Math.floor(Date.now() / 1000)) {17return cached.data;18}1920// Make request, with conditional If-None-Match if we have an ETag.21// Cache-Control: max-age=0 overrides Node's default 'no-cache' to allow 304 responses.22const response = await fetch(url, {23headers: {24'Cache-Control': 'max-age=0',25...(cached?.etag && { 'If-None-Match': cached.etag }),26},27});2829if (response.status === 304 && cached) {30// Refresh expiration and return cached data31const entry = { ...cached, expires: getExpires(response.headers) };32await saveCacheEntry(cacheFile, entry);33return cached.data;34}3536if (!response.ok) {37throw new Error(`HTTP ${response.status}: ${response.statusText}`);38}3940const etag = response.headers.get('etag');41const data = await response.text();42const expires = getExpires(response.headers);4344await saveCacheEntry(cacheFile, { url, etag, expires, data });4546return data;47}4849function hashUrl(url) {50return createHash('sha256').update(url).digest('hex').slice(0, 16);51}5253async function loadCacheEntry(cacheFile) {54try {55return JSON.parse(await readFile(cacheFile, 'utf-8'));56} catch {57return null;58}59}6061async function saveCacheEntry(cacheFile, entry) {62await writeFile(cacheFile, JSON.stringify(entry, null, 2));63}6465function getExpires(headers) {66const now = Math.floor(Date.now() / 1000);6768// Prefer Cache-Control: max-age69const maxAgeSeconds = parseMaxAge(headers.get('cache-control'));70if (maxAgeSeconds != null) {71return now + maxAgeSeconds;72}7374// Fall back to Expires header75const expires = headers.get('expires');76if (expires) {77const expiresTime = Date.parse(expires);78if (!Number.isNaN(expiresTime)) {79return Math.floor(expiresTime / 1000);80}81}8283// Default TTL84return now + DEFAULT_TTL_SECONDS;85}8687function parseMaxAge(cacheControl) {88if (!cacheControl) {89return null;90}91const match = cacheControl.match(/max-age=(\d+)/i);92return match ? parseInt(match[1], 10) : null;93}9495if (import.meta.main) {96const url = process.argv[2];9798if (!url || url === '--help' || url === '-h') {99console.log(`Usage: fetch <url>100101Fetches a URL with HTTP caching (ETags + Cache-Control/Expires).102Default TTL: ${DEFAULT_TTL_SECONDS / 60} minutes.103Cache is stored in: ${CACHE_DIRECTORY}/`);104process.exit(url ? 0 : 1);105}106107const data = await fetchCached(url);108console.log(data);109}110