Skip to content
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
5 changes: 5 additions & 0 deletions .changeset/feature-flag-env-vars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add build-time client config overrides via environment variables, with typed deterministic experiment bucketing helpers for progressive feature rollout and A/B testing.
5 changes: 5 additions & 0 deletions .changeset/sw-session-resync-flags.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add phased service-worker session re-sync controls (foreground resync, visible heartbeat, adaptive backoff/jitter).
30 changes: 30 additions & 0 deletions .github/actions/setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,36 @@ runs:
env:
INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }}

- name: Inject runtime config overrides
if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
shell: bash
working-directory: ${{ github.workspace }}
run: node scripts/inject-client-config.js
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }}

- name: Display injected config
if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }}
shell: bash
working-directory: ${{ github.workspace }}
run: |
summary_file="${GITHUB_STEP_SUMMARY:-}"
echo "::group::Injected Client Config"
experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')"
echo "$experiments_json"
echo "::endgroup::"

if [[ -n "$summary_file" ]]; then
{
echo "### Injected client config"
echo
echo "\`\`\`json"
echo "$experiments_json"
echo "\`\`\`"
} >> "$summary_file"
fi

- name: Build app
if: ${{ inputs.build == 'true' }}
shell: bash
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/cloudflare-web-deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ jobs:
plan:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
environment: preview
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
permissions:
contents: read
pull-requests: write
Expand Down Expand Up @@ -73,6 +77,10 @@ jobs:
apply:
if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
environment: production
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
permissions:
contents: read
defaults:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/cloudflare-web-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,13 @@ jobs:
deploy:
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
runs-on: ubuntu-latest
environment: preview
permissions:
contents: read
pull-requests: write
env:
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
Expand Down
19 changes: 19 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,29 @@
"webPushAppID": "moe.sable.app.sygnal"
},

"experiments": {
"sessionSyncStrategy": {
"enabled": false,
"rolloutPercentage": 0,
"controlVariant": "control",
"variants": ["session-sync-heartbeat", "session-sync-adaptive"]
}
},

"slidingSync": {
"enabled": true
},

"sessionSync": {
"phase1ForegroundResync": false,
"phase2VisibleHeartbeat": false,
"phase3AdaptiveBackoffJitter": false,
"foregroundDebounceMs": 1500,
"heartbeatIntervalMs": 600000,
"resumeHeartbeatSuppressMs": 60000,
"heartbeatMaxBackoffMs": 1800000
},

"featuredCommunities": {
"openAsDefault": false,
"spaces": [
Expand Down
2 changes: 1 addition & 1 deletion knip.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
"entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"],
"ignoreExportsUsedInFile": {
"interface": true,
"type": true
Expand Down
71 changes: 71 additions & 0 deletions scripts/inject-client-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { readFile, writeFile } from 'node:fs/promises';
import process from 'node:process';
import { PrefixedLogger } from './utils/console-style.js';

const CONFIG_PATH = 'config.json';
const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON';
const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT';
const logger = new PrefixedLogger('[config-inject]');

const formatError = (error) => {
if (error instanceof Error) return error.stack ?? error.message;
return String(error);
};

const isPlainObject = (value) =>
typeof value === 'object' && value !== null && !Array.isArray(value);

const deepMerge = (target, source) => {
if (!isPlainObject(target) || !isPlainObject(source)) return source;

const merged = { ...target };
Object.entries(source).forEach(([key, value]) => {
const targetValue = merged[key];
merged[key] =
isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value;
});
return merged;
};

const failOnError = process.env[STRICT_ENV] === 'true';
const overridesRaw = process.env[OVERRIDES_ENV];

if (!overridesRaw) {
logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`);
process.exit(0);
}

let fileConfig;
let overrides;

try {
const file = await readFile(CONFIG_PATH, 'utf8');
fileConfig = JSON.parse(file);
} catch (error) {
logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`);
process.exit(1);
}

try {
overrides = JSON.parse(overridesRaw);
if (!isPlainObject(overrides)) {
throw new Error(`${OVERRIDES_ENV} must be a JSON object.`);
}
} catch (error) {
const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${
failOnError ? 'failing build' : 'skipping overrides'
}.`;
if (failOnError) {
logger.error(`${message} ${formatError(error)}`);
process.exit(1);
}
logger.info(`[warning] ${message} ${formatError(error)}`);
process.exit(0);
}

const mergedConfig = deepMerge(fileConfig, overrides);

await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8');
logger.info(
`Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}`
);
2 changes: 2 additions & 0 deletions src/app/features/settings/developer-tools/DevelopTools.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { copyToClipboard } from '$utils/dom';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { AccountData } from './AccountData';
import { SyncDiagnostics } from './SyncDiagnostics';
import { ExperimentsPanel } from './ExperimentsPanel';
import { DebugLogViewer } from './DebugLogViewer';
import { SentrySettings } from './SentrySettings';

Expand Down Expand Up @@ -115,6 +116,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
)}
</Box>
{developerTools && <SyncDiagnostics />}
{developerTools && <ExperimentsPanel />}
{developerTools && (
<AccountData
expand={expand}
Expand Down
102 changes: 102 additions & 0 deletions src/app/features/settings/developer-tools/ExperimentsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { useMemo } from 'react';
import { Box, Text, color } from 'folds';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { useClientConfig, selectExperimentVariant } from '$hooks/useClientConfig';
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
import { SequenceCardStyle } from '$features/settings/styles.css';

export function ExperimentsPanel() {
const mx = useMatrixClient();
const config = useClientConfig();
const userId = mx.getUserId() ?? undefined;

const experiments = useMemo(() => {
if (!config.experiments) return [];
return Object.entries(config.experiments).map(([key, experimentConfig]) => ({
key,
config: experimentConfig,
selection: selectExperimentVariant(key, experimentConfig, userId),
}));
}, [config.experiments, userId]);

if (experiments.length === 0) {
return (
<Box direction="Column" gap="100">
<Text size="L400">Features & Experiments</Text>
<Text size="T200" style={{ color: color.Secondary.Main }}>
No experiments configured
</Text>
</Box>
);
}

return (
<Box direction="Column" gap="100">
<Text size="L400">Features & Experiments</Text>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
{experiments.map(({ key, config: experimentConfig, selection }) => (
<SettingTile key={key} title={key}>
<Box direction="Column" gap="200">
<Box direction="Row" gap="300">
<Text size="T200">
<strong>Enabled:</strong>
</Text>
<Text
size="T200"
style={{
color: selection.enabled ? color.Success.Main : color.Secondary.Main,
}}
>
{selection.enabled ? 'Yes' : 'No'}
</Text>
</Box>
{selection.enabled && (
<>
<Box direction="Row" gap="300">
<Text size="T200">
<strong>Rollout:</strong>
</Text>
<Text size="T200">{selection.rolloutPercentage}%</Text>
</Box>
<Box direction="Row" gap="300">
<Text size="T200">
<strong>Your Variant:</strong>
</Text>
<Text
size="T200"
style={{
color: selection.inExperiment ? color.Success.Main : color.Secondary.Main,
}}
>
{selection.variant}
{selection.inExperiment && ' (in experiment)'}
{!selection.inExperiment && ' (control)'}
</Text>
</Box>
{experimentConfig.variants && experimentConfig.variants.length > 0 && (
<Box direction="Row" gap="300">
<Text size="T200">
<strong>Treatment Variants:</strong>
</Text>
<Text size="T200">
{experimentConfig.variants
.filter((v) => v !== experimentConfig.controlVariant)
.join(', ')}
</Text>
</Box>
)}
</>
)}
</Box>
</SettingTile>
))}
</SequenceCard>
</Box>
);
}
Loading
Loading