Skip to content

Commit 377cff0

Browse files
authored
feat: default browserless onboarding and request strategies (#895)
## Summary - default the onboarding flow to the JavaScript/browserless strategy and keep the utility links aligned with that path - expose featured included feeds at the API root and allow embedded config lookup for built-in feed paths - align generated feed defaults and onboarding tests with the current request-service strategy baseline ## Verification - docker compose -f .devcontainer/docker-compose.yml run --rm app make setup - docker compose -f .devcontainer/docker-compose.yml run --rm app make ready - browser smoke in chrome-devtools at http://127.0.0.1:4001/ covering guest, token-required, and success states
1 parent 4a047cc commit 377cff0

21 files changed

Lines changed: 491 additions & 144 deletions

app/web/api/v1/root_metadata.rb

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,24 @@ module V1
77
##
88
# Builds the public metadata payload for the API root endpoint.
99
module RootMetadata
10+
FEATURED_FEEDS = [
11+
{
12+
path: '/microsoft.com/azure-products.rss',
13+
title: 'Azure product updates',
14+
description: 'Follow Microsoft Azure product announcements from your own instance.'
15+
},
16+
{
17+
path: '/phys.org/weekly.rss',
18+
title: 'Top science news of the week',
19+
description: 'Try a high-signal feed with stable weekly headlines from the built-in config set.'
20+
},
21+
{
22+
path: '/softwareleadweekly.com/issues.rss',
23+
title: 'Software Lead Weekly issues',
24+
description: 'Follow a long-running newsletter archive from the embedded config catalog.'
25+
}
26+
].freeze
27+
1028
class << self
1129
# @param router [Roda::RodaRequest]
1230
# @return [Hash{Symbol=>Object}]
@@ -30,7 +48,8 @@ def instance_payload(_router)
3048
feed_creation: {
3149
enabled: AutoSource.enabled?,
3250
access_token_required: AutoSource.enabled?
33-
}
51+
},
52+
featured_feeds: FEATURED_FEEDS
3453
}
3554
end
3655
end

app/web/config/local_config.rb

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
# frozen_string_literal: true
22

33
require 'yaml'
4+
begin
5+
require 'html2rss/configs'
6+
rescue LoadError => error
7+
warn "[html2rss-web] Failed to load 'html2rss/configs': #{error.message}"
8+
raise
9+
end
410

511
module Html2rss
612
module Web
@@ -17,6 +23,7 @@ class NotFound < RuntimeError; end
1723
# raised when the local config shape is invalid
1824
class InvalidConfig < RuntimeError; end
1925
FEED_EXTENSION_PATTERN = /\.(json|rss|xml)\z/
26+
EMBEDDED_FEED_NAME_PATTERN = %r{\A[^/]+/.+\z}
2027

2128
# Path to local feed configuration file.
2229
CONFIG_FILE = 'config/feeds.yml'
@@ -27,10 +34,8 @@ class << self
2734
# @return [Hash<Symbol, Any>]
2835
def find(name)
2936
normalized_name = normalize_name(name)
30-
config = snapshot.feeds.fetch(normalized_name.to_sym) do
31-
raise NotFound, "Did not find local feed config at '#{normalized_name}'"
32-
end
33-
config_hash = deep_dup(config.raw)
37+
config_hash = local_feed_config(normalized_name) || embedded_feed_config(normalized_name)
38+
raise NotFound, "Did not find local feed config at '#{normalized_name}'" unless config_hash
3439

3540
apply_global_defaults(config_hash)
3641
end
@@ -76,6 +81,26 @@ def reload!(reason: 'manual')
7681

7782
private
7883

84+
# @param normalized_name [String]
85+
# @return [Hash{Symbol=>Object}, nil]
86+
def local_feed_config(normalized_name)
87+
config = snapshot.feeds[normalized_name.to_sym]
88+
return nil unless config
89+
90+
deep_dup(config.raw)
91+
end
92+
93+
# @param normalized_name [String]
94+
# @return [Hash{Symbol=>Object}, nil]
95+
def embedded_feed_config(normalized_name)
96+
return nil unless defined?(Html2rss::Configs)
97+
return nil unless normalized_name.match?(EMBEDDED_FEED_NAME_PATTERN)
98+
99+
deep_dup(Html2rss::Configs.find_by_name(normalized_name))
100+
rescue Html2rss::Configs::ConfigNotFound
101+
nil
102+
end
103+
79104
# Applies global defaults only when feed-level keys are absent.
80105
#
81106
# @param config [Hash{Symbol=>Object}]
@@ -90,9 +115,9 @@ def apply_global_defaults(config)
90115
end
91116

92117
# @param name [String, Symbol, #to_s]
93-
# @return [String] basename without extension for feed lookup.
118+
# @return [String] path without feed extension for feed lookup.
94119
def normalize_name(name)
95-
File.basename(name.to_s).sub(FEED_EXTENSION_PATTERN, '')
120+
name.to_s.delete_prefix('/').sub(FEED_EXTENSION_PATTERN, '')
96121
end
97122

98123
# Deep-duplicates nested config structures to avoid mutating shared data.

app/web/domain/auto_source.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def enabled?
2121
# @param token_data [Hash{Symbol=>Object}] authenticated account data.
2222
# @param strategy [String]
2323
# @return [Html2rss::Web::Api::V1::FeedMetadata::Metadata, nil]
24-
def create_stable_feed(name, url, token_data, strategy = 'faraday')
24+
def create_stable_feed(name, url, token_data, strategy = Html2rss::RequestService.default_strategy_name.to_s)
2525
return nil unless token_data && FeedAccess.url_allowed_for_username?(token_data[:username], url)
2626

2727
feed_token = Auth.generate_feed_token(token_data[:username], url, strategy: strategy)

app/web/feeds/source_resolver.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def static_cache_identity(feed_name, params)
6969
def static_generator_input(config, params)
7070
generator_input = config.dup
7171
generator_input[:params] = merged_static_params(config, params)
72-
generator_input[:strategy] ||= :faraday
72+
generator_input[:strategy] ||= Html2rss::RequestService.default_strategy_name.to_sym
7373
generator_input
7474
end
7575

docker-compose.yml

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,23 @@ services:
77
restart: unless-stopped
88
ports:
99
- "127.0.0.1:4000:4000"
10-
volumes:
11-
- type: bind
12-
source: ./config/feeds.yml
13-
target: /app/config/feeds.yml
14-
read_only: true
10+
env_file:
11+
- path: .env
12+
required: false
1513
environment:
1614
RACK_ENV: production
1715
PORT: 4000
1816
HTML2RSS_SECRET_KEY: ${HTML2RSS_SECRET_KEY:?set HTML2RSS_SECRET_KEY}
1917
HEALTH_CHECK_TOKEN: ${HEALTH_CHECK_TOKEN:?set HEALTH_CHECK_TOKEN}
2018
BROWSERLESS_IO_WEBSOCKET_URL: ws://browserless:4002
2119
BROWSERLESS_IO_API_TOKEN: ${BROWSERLESS_IO_API_TOKEN:?set BROWSERLESS_IO_API_TOKEN}
20+
# Trial runs use the image's bundled config/feeds.yml.
21+
# Uncomment the block below when you want to replace it with your own file.
22+
# volumes:
23+
# - type: bind
24+
# source: ./config/feeds.yml
25+
# target: /app/config/feeds.yml
26+
# read_only: true
2227

2328
watchtower:
2429
image: containrrr/watchtower

frontend/e2e/smoke.spec.ts

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,58 @@ import { expect, test } from '@playwright/test';
22

33
test.describe('frontend smoke', () => {
44
test('loads create flow and inline access-token gate', async ({ page }) => {
5+
await page.route(/\/api\/v1$/, async (route) => {
6+
await route.fulfill({
7+
status: 200,
8+
contentType: 'application/json',
9+
body: JSON.stringify({
10+
success: true,
11+
data: {
12+
api: {
13+
name: 'html2rss-web API',
14+
description: 'RESTful API for converting websites to RSS feeds',
15+
openapi_url: 'http://example.test/openapi.yaml',
16+
},
17+
instance: {
18+
feed_creation: {
19+
enabled: true,
20+
access_token_required: true,
21+
},
22+
featured_feeds: [],
23+
},
24+
},
25+
}),
26+
});
27+
});
28+
29+
await page.route(/\/api\/v1\/strategies$/, async (route) => {
30+
await route.fulfill({
31+
status: 200,
32+
contentType: 'application/json',
33+
body: JSON.stringify({
34+
success: true,
35+
data: {
36+
strategies: [
37+
{ id: 'faraday', name: 'faraday', display_name: 'Default' },
38+
{
39+
id: 'browserless',
40+
name: 'browserless',
41+
display_name: 'JavaScript pages (recommended)',
42+
},
43+
],
44+
},
45+
meta: { total: 2 },
46+
}),
47+
});
48+
});
49+
550
await page.goto('/');
651

7-
await expect(page.getByLabel('PAGE URL')).toBeVisible();
52+
await expect(page.getByLabel('Page URL')).toBeVisible();
853
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
9-
await expect(page.getByRole('button', { name: 'MORE' })).toBeVisible();
54+
await expect(page.getByRole('button', { name: 'More' })).toBeVisible();
1055

11-
await page.getByLabel('PAGE URL').fill('https://example.com/articles');
56+
await page.getByLabel('Page URL').fill('https://example.com/articles');
1257
await page.getByRole('button', { name: 'Generate feed URL' }).click();
1358

1459
await expect(page.getByRole('heading', { name: 'Add access token' })).toBeVisible();
@@ -18,6 +63,6 @@ test.describe('frontend smoke', () => {
1863

1964
await page.getByRole('button', { name: 'Back' }).click();
2065
await expect(page.getByRole('button', { name: 'Generate feed URL' })).toBeVisible();
21-
await expect(page.getByRole('button', { name: 'MORE' })).toBeVisible();
66+
await expect(page.getByRole('button', { name: 'More' })).toBeVisible();
2267
});
2368
});

frontend/package-lock.json

Lines changed: 8 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@
2727
},
2828
"devDependencies": {
2929
"@hey-api/openapi-ts": "^0.93.1",
30-
"@preact/preset-vite": "^2.10.2",
3130
"@playwright/test": "^1.58.2",
31+
"@preact/preset-vite": "^2.10.2",
3232
"@testing-library/jest-dom": "^6.8.0",
3333
"@testing-library/preact": "^3.2.4",
34+
"baseline-browser-mapping": "^2.10.9",
3435
"jsdom": "^27.0.0",
3536
"msw": "^2.11.3",
3637
"prettier": "^3.x.x",

frontend/src/__tests__/App.contract.test.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ describe('App contract', () => {
1818
http.post('/api/v1/feeds', async ({ request }) => {
1919
const body = (await request.json()) as { url: string; strategy: string };
2020

21-
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'ssrf_filter' });
21+
expect(body).toEqual({ url: 'https://example.com/articles', strategy: 'browserless' });
2222
expect(request.headers.get('authorization')).toBe(`Bearer ${token}`);
2323

2424
return HttpResponse.json(
@@ -35,7 +35,14 @@ describe('App contract', () => {
3535

3636
return HttpResponse.json(
3737
{
38-
items: [{ title: 'Contract Item' }],
38+
items: [
39+
{
40+
title: 'Contract Item',
41+
content_text: 'Contract preview excerpt.',
42+
url: 'https://example.com/contract-item',
43+
date_published: '2024-01-01T00:00:00Z',
44+
},
45+
],
3946
},
4047
{
4148
headers: { 'content-type': 'application/feed+json' },
@@ -47,6 +54,9 @@ describe('App contract', () => {
4754
render(<App />);
4855

4956
await screen.findByLabelText('Page URL');
57+
await waitFor(() => {
58+
expect(screen.getByRole('combobox')).toHaveValue('browserless');
59+
});
5060

5161
const urlInput = screen.getByLabelText('Page URL') as HTMLInputElement;
5262
fireEvent.input(urlInput, { target: { value: 'https://example.com/articles' } });
@@ -89,6 +99,7 @@ describe('App contract', () => {
8999
enabled: true,
90100
access_token_required: true,
91101
},
102+
featured_feeds: [],
92103
},
93104
},
94105
});
@@ -135,6 +146,9 @@ describe('App contract', () => {
135146
render(<App />);
136147

137148
await screen.findByLabelText('Page URL');
149+
await waitFor(() => {
150+
expect(screen.getByRole('combobox')).toHaveValue('browserless');
151+
});
138152

139153
fireEvent.input(screen.getByLabelText('Page URL'), {
140154
target: { value: 'https://example.com/articles' },

0 commit comments

Comments
 (0)